@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,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "menubar",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Menubar",
|
|
5
|
+
"description": "Menubar component with size options.",
|
|
6
|
+
"category": "navigation",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"@floating-ui/dom",
|
|
10
|
+
"class-variance-authority"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "menubar.component.ts",
|
|
15
|
+
"target": "ui/menubar.component.ts",
|
|
16
|
+
"content": "import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, Injectable, Input, OnChanges, OnDestroy, OnInit, Output, ViewChild, inject } from '@angular/core';\nimport { BehaviorSubject, Subscription } from 'rxjs';\nimport { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\n/**\n * Contract a `tolle-menubar-menu` fulfils so `MenubarService` can drive focus and\n * keyboard navigation without importing the component (which would be circular).\n */\nexport interface MenubarMenuRef {\n /** Stable id, unique per menu instance. */\n readonly id: string;\n /** The menu's trigger button, once rendered. */\n triggerElement: HTMLElement | null;\n /** The menu's open panel, or `null` while closed. */\n contentElement: HTMLElement | null;\n}\n\nlet menubarMenuCounter = 0;\n\n/**\n * Coordinates the menus of a single `tolle-menubar`. Provided by\n * `MenubarComponent`, injected by every descendant menu/trigger/content.\n *\n * The defining behaviour of a menu bar lives here: once *any* menu is open,\n * hovering a different trigger switches to it immediately (`hoverEnter`), while\n * hovering with everything closed does nothing.\n */\n@Injectable()\nexport class MenubarService {\n private openIdSource = new BehaviorSubject<string | null>(null);\n /** Emits the id of the currently open menu, or `null` when all are closed. */\n readonly openId$ = this.openIdSource.asObservable();\n\n private sizeSource = new BehaviorSubject<'sm' | 'default' | 'lg'>('default');\n /**\n * Emits the bar's size so OnPush triggers re-render when it changes. Reading\n * the parent component's property directly would not mark them dirty.\n */\n readonly size$ = this.sizeSource.asObservable();\n\n /** The bar's current size scale. */\n get size(): 'sm' | 'default' | 'lg' {\n return this.sizeSource.value;\n }\n\n setSize(size: 'sm' | 'default' | 'lg'): void {\n if (this.sizeSource.value !== size) this.sizeSource.next(size);\n }\n\n /** Registered menus in DOM (registration) order. */\n private menus: MenubarMenuRef[] = [];\n\n /** Id of the open menu, or `null`. */\n get openId(): string | null {\n return this.openIdSource.value;\n }\n\n /** True while one of the bar's menus is open. */\n get isAnyOpen(): boolean {\n return this.openIdSource.value !== null;\n }\n\n register(menu: MenubarMenuRef): void {\n if (this.menus.indexOf(menu) === -1) this.menus.push(menu);\n }\n\n unregister(menu: MenubarMenuRef): void {\n const i = this.menus.indexOf(menu);\n if (i > -1) this.menus.splice(i, 1);\n if (this.openId === menu.id) this.close();\n }\n\n isOpen(id: string): boolean {\n return this.openId === id;\n }\n\n open(id: string): void {\n if (this.openId !== id) this.openIdSource.next(id);\n }\n\n close(): void {\n if (this.openId !== null) this.openIdSource.next(null);\n }\n\n toggle(id: string): void {\n if (this.openId === id) this.close();\n else this.open(id);\n }\n\n /**\n * Pointer entered a trigger. Only switches menus when the bar is already\n * \"active\" (something open) — this is what makes a menu bar feel native.\n */\n hoverEnter(id: string): void {\n if (this.isAnyOpen) this.open(id);\n }\n\n /** Moves focus to the previous/next menu, keeping the open state. */\n moveMenu(delta: number): void {\n if (!this.menus.length) return;\n const current = this.menus.findIndex(m => m.id === this.openId);\n const from = current > -1 ? current : this.indexOfFocusedTrigger();\n const next =\n from < 0\n ? delta > 0\n ? 0\n : this.menus.length - 1\n : (from + delta + this.menus.length) % this.menus.length;\n const target = this.menus[next];\n if (this.isAnyOpen) this.open(target.id);\n target.triggerElement?.focus();\n }\n\n /** Moves focus between the enabled items of the currently open menu. */\n moveItem(delta: number): void {\n const items = this.openItems();\n if (!items.length) return;\n const current = items.indexOf(document.activeElement as HTMLElement);\n const next =\n current < 0\n ? delta > 0\n ? 0\n : items.length - 1\n : (current + delta + items.length) % items.length;\n items[next].focus();\n }\n\n /** Focuses the trigger of the given menu (used when Escape closes a menu). */\n focusTrigger(id: string): void {\n this.menus.find(m => m.id === id)?.triggerElement?.focus();\n }\n\n private openItems(): HTMLElement[] {\n const content = this.menus.find(m => m.id === this.openId)?.contentElement;\n if (!content) return [];\n return Array.from(\n content.querySelectorAll<HTMLElement>('[role=\"menuitem\"]:not([data-disabled])')\n );\n }\n\n private indexOfFocusedTrigger(): number {\n const active = document.activeElement;\n return this.menus.findIndex(m => !!m.triggerElement && m.triggerElement === active);\n }\n}\n\nconst menubarVariants = cva(\n 'flex items-center gap-1 rounded-md border border-border bg-background shadow-sm',\n {\n variants: {\n size: {\n sm: 'h-8 p-0.5',\n default: 'h-10 p-1',\n lg: 'h-12 p-1.5',\n },\n },\n defaultVariants: {\n size: 'default',\n },\n }\n);\n\nexport type MenubarProps = VariantProps<typeof menubarVariants>;\n\nconst menubarTriggerVariants = cva(\n 'flex select-none items-center rounded-sm font-medium outline-none transition-colors ' +\n 'hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground ' +\n 'data-[state=open]:bg-accent data-[state=open]:text-accent-foreground ' +\n 'disabled:pointer-events-none disabled:opacity-50',\n {\n variants: {\n size: {\n sm: 'h-7 px-2 text-xs',\n default: 'h-8 px-3 text-sm',\n lg: 'h-9 px-4 text-sm',\n },\n },\n defaultVariants: {\n size: 'default',\n },\n }\n);\n\nexport type MenubarTriggerProps = VariantProps<typeof menubarTriggerVariants>;\n\nconst menubarItemVariants = cva(\n 'relative flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors ' +\n 'focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground ' +\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n {\n variants: {\n variant: {\n default: 'text-popover-foreground',\n destructive: 'text-destructive focus:bg-destructive/10 hover:bg-destructive/10',\n },\n inset: {\n true: 'pl-8',\n false: '',\n },\n },\n defaultVariants: {\n variant: 'default',\n inset: false,\n },\n }\n);\n\nexport type MenubarItemProps = VariantProps<typeof menubarItemVariants>;\n\n/**\n * Desktop-application style menu bar.\n *\n * ```html\n * <tolle-menubar>\n * <tolle-menubar-menu>\n * <tolle-menubar-trigger>File</tolle-menubar-trigger>\n * <tolle-menubar-content>\n * <tolle-menubar-label>Document</tolle-menubar-label>\n * <tolle-menubar-item (select)=\"onNew()\">New</tolle-menubar-item>\n * <tolle-menubar-separator />\n * <tolle-menubar-item variant=\"destructive\">Delete</tolle-menubar-item>\n * </tolle-menubar-content>\n * </tolle-menubar-menu>\n * </tolle-menubar>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-menubar',\n standalone: true,\n imports: [],\n providers: [MenubarService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n host: {\n role: 'menubar',\n '[class]': 'computedClass',\n '(keydown)': 'onKeydown($event)',\n },\n template: `<ng-content></ng-content>`,\n})\nexport class MenubarComponent implements OnInit, OnChanges, OnDestroy {\n /** Height/padding scale, inherited by every trigger in the bar. @default 'default' */\n @Input() size: MenubarProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the bar via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the id of the menu that opened, or `null` when the bar closes. */\n @Output() openChange = new EventEmitter<string | null>();\n\n protected readonly service = inject(MenubarService);\n private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);\n private readonly sub = new Subscription();\n private listening = false;\n\n get computedClass(): string {\n return cn(menubarVariants({ size: this.size }), this.class);\n }\n\n ngOnChanges(): void {\n // Push size into the service so OnPush triggers get told about it.\n this.service.setSize(this.size ?? 'default');\n }\n\n ngOnInit(): void {\n this.service.setSize(this.size ?? 'default');\n this.sub.add(\n this.service.openId$.subscribe(id => {\n this.openChange.emit(id);\n if (id !== null) this.startListening();\n else this.stopListening();\n })\n );\n }\n\n protected onKeydown(event: KeyboardEvent): void {\n switch (event.key) {\n case 'ArrowRight':\n event.preventDefault();\n this.service.moveMenu(1);\n break;\n case 'ArrowLeft':\n event.preventDefault();\n this.service.moveMenu(-1);\n break;\n case 'ArrowDown':\n if (!this.service.isAnyOpen) break;\n event.preventDefault();\n this.service.moveItem(1);\n break;\n case 'ArrowUp':\n if (!this.service.isAnyOpen) break;\n event.preventDefault();\n this.service.moveItem(-1);\n break;\n case 'Escape': {\n const openId = this.service.openId;\n if (openId === null) break;\n event.preventDefault();\n this.service.close();\n this.service.focusTrigger(openId);\n break;\n }\n default:\n break;\n }\n }\n\n // Capture phase so nested containers that stopPropagation() on their root\n // (modal, sheet) cannot prevent the outside click from reaching us.\n private outsideClick = (event: Event) => {\n if (!this.host.nativeElement.contains(event.target as Node)) this.service.close();\n };\n\n private startListening(): void {\n if (this.listening) return;\n this.listening = true;\n document.addEventListener('pointerdown', this.outsideClick, true);\n }\n\n private stopListening(): void {\n if (!this.listening) return;\n this.listening = false;\n document.removeEventListener('pointerdown', this.outsideClick, true);\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n this.stopListening();\n }\n}\n\n/** A single menu in the bar — wraps one trigger and its content panel. */\n@Component({\n selector: 'tolle-menubar-menu',\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n // Presentational so the menubar \"owns\" the trigger's menuitem role directly\n // even though this wrapper element sits in between.\n host: { role: 'none', '[class]': \"'relative inline-flex'\" },\n template: `<ng-content></ng-content>`,\n})\nexport class MenubarMenuComponent implements MenubarMenuRef, OnInit, OnDestroy {\n /** Explicit id for the menu; one is generated when omitted. */\n @Input() id = `tolle-menubar-menu-${menubarMenuCounter++}`;\n\n triggerElement: HTMLElement | null = null;\n contentElement: HTMLElement | null = null;\n\n readonly service = inject(MenubarService);\n\n get triggerId(): string {\n return `${this.id}-trigger`;\n }\n\n get isOpen(): boolean {\n return this.service.isOpen(this.id);\n }\n\n ngOnInit(): void {\n this.service.register(this);\n }\n\n ngOnDestroy(): void {\n this.service.unregister(this);\n }\n}\n\n/** Button that opens its menu on click, and on hover once the bar is active. */\n@Component({\n selector: 'tolle-menubar-trigger',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n host: { role: 'none' },\n template: `\n <button\n #trigger\n type=\"button\"\n role=\"menuitem\"\n [id]=\"menu.triggerId\"\n [disabled]=\"disabled\"\n aria-haspopup=\"menu\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? menu.id + '-content' : null\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n [class]=\"computedClass\"\n (click)=\"onClick()\"\n (mouseenter)=\"onMouseEnter()\"\n >\n <ng-content></ng-content>\n </button>\n `,\n})\nexport class MenubarTriggerComponent implements OnChanges, 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 /** Prevents the menu from opening. @default false */\n @Input() disabled = false;\n /** Overrides the size inherited from `tolle-menubar`. */\n @Input() size?: MenubarTriggerProps['size'];\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n protected readonly menu = inject(MenubarMenuComponent);\n private readonly service = inject(MenubarService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n constructor() {\n this.sub.add(this.service.openId$.subscribe(() => this.cdr.markForCheck()));\n // The bar's size is not an input of this component, so nothing else would\n // mark an OnPush trigger dirty when it changes.\n this.sub.add(this.service.size$.subscribe(() => this.cdr.markForCheck()));\n }\n\n @ViewChild('trigger') set triggerRef(ref: ElementRef<HTMLButtonElement> | undefined) {\n this.menu.triggerElement = ref?.nativeElement ?? null;\n }\n\n protected get isOpen(): boolean {\n return this.menu.isOpen;\n }\n\n protected get computedClass(): string {\n return cn(menubarTriggerVariants({ size: this.size ?? this.service.size }), this.class);\n }\n\n protected onClick(): void {\n if (this.disabled) return;\n this.service.toggle(this.menu.id);\n }\n\n protected onMouseEnter(): void {\n if (this.disabled) return;\n this.service.hoverEnter(this.menu.id);\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n}\n\n/** The dropdown panel of a menu. Rendered only while its menu is open. */\n@Component({\n selector: 'tolle-menubar-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @if (isOpen) {\n <div\n #panel\n role=\"menu\"\n aria-orientation=\"vertical\"\n [id]=\"menu.id + '-content'\"\n [attr.aria-labelledby]=\"menu.triggerId\"\n data-state=\"open\"\n [class]=\"computedClass\"\n style=\"position: fixed; top: 0; left: 0;\"\n >\n <ng-content></ng-content>\n </div>\n }\n `,\n})\nexport class MenubarContentComponent implements OnChanges, 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 /** Preferred side of the trigger to render on; flips when space is tight. @default 'bottom-start' */\n @Input() placement: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' = 'bottom-start';\n /** Gap in pixels between the trigger and the panel. @default 4 */\n @Input() sideOffset = 4;\n /** Extra Tailwind classes merged onto the panel via `cn()` (last-wins). */\n @Input() class = '';\n\n protected readonly menu = inject(MenubarMenuComponent);\n private readonly service = inject(MenubarService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub: Subscription;\n private cleanup?: () => void;\n\n constructor() {\n this.sub = this.service.openId$.subscribe(() => this.cdr.markForCheck());\n }\n\n @ViewChild('panel') set panelRef(ref: ElementRef<HTMLElement> | undefined) {\n const el = ref?.nativeElement ?? null;\n this.menu.contentElement = el;\n this.stopPositioning();\n if (el) this.startPositioning(el);\n }\n\n protected get isOpen(): boolean {\n return this.menu.isOpen;\n }\n\n protected get computedClass(): string {\n return cn(\n 'z-[1000] min-w-[12rem] overflow-hidden rounded-md border border-border bg-popover p-1',\n 'text-popover-foreground shadow-md animate-in fade-in zoom-in-95',\n this.class\n );\n }\n\n private startPositioning(panel: HTMLElement): void {\n const trigger = this.menu.triggerElement;\n if (!trigger) return;\n this.cleanup = autoUpdate(trigger, panel, () => {\n computePosition(trigger, panel, {\n strategy: 'fixed',\n placement: this.placement,\n middleware: [offset(this.sideOffset), flip(), shift({ padding: 8 })],\n }).then(({ x, y }) => {\n Object.assign(panel.style, { left: `${x}px`, top: `${y}px` });\n });\n });\n }\n\n private stopPositioning(): void {\n this.cleanup?.();\n this.cleanup = undefined;\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n this.stopPositioning();\n }\n}\n\n/** A selectable row inside a menu. Closes the bar when activated. */\n@Component({\n selector: 'tolle-menubar-item',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n role=\"menuitem\"\n [attr.tabindex]=\"disabled ? null : -1\"\n [attr.data-disabled]=\"disabled ? '' : null\"\n [attr.aria-disabled]=\"disabled || null\"\n [class]=\"computedClass\"\n (click)=\"activate($event)\"\n (keydown.enter)=\"activate($event)\"\n (keydown.space)=\"activate($event)\"\n >\n <ng-content select=\"[prefix]\"></ng-content>\n <span class=\"flex-1 truncate\"><ng-content></ng-content></span>\n <ng-content select=\"[shortcut]\"></ng-content>\n </div>\n `,\n})\nexport class MenubarItemComponent 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 /** Visual style of the item. @default 'default' */\n @Input() variant: MenubarItemProps['variant'] = 'default';\n /** Adds left padding so the label lines up with items that have icons. @default false */\n @Input() inset = false;\n /** Blocks activation and dims the item. @default false */\n @Input() disabled = false;\n /** Extra Tailwind classes merged onto the item via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted when the item is clicked or activated with Enter/Space. */\n @Output() select = new EventEmitter<Event>();\n\n private readonly service = inject(MenubarService);\n\n protected get computedClass(): string {\n return cn(\n menubarItemVariants({ variant: this.variant, inset: this.inset }),\n this.class\n );\n }\n\n protected activate(event: Event): void {\n if (this.disabled) return;\n event.preventDefault();\n this.select.emit(event);\n this.service.close();\n }\n}\n\n/** A non-interactive heading inside a menu. */\n@Component({\n selector: 'tolle-menubar-label',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n [class]=\"cn('px-2 py-1.5 text-xs font-semibold text-muted-foreground', inset ? 'pl-8' : '', class)\"\n >\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class MenubarLabelComponent 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 /** Adds left padding so the label lines up with items that have icons. @default false */\n @Input() inset = false;\n /** Extra Tailwind classes merged onto the label via `cn()` (last-wins). */\n @Input() class = '';\n protected readonly cn = cn;\n}\n\n/** A horizontal rule between groups of menu items. */\n@Component({\n selector: 'tolle-menubar-separator',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div role=\"separator\" aria-orientation=\"horizontal\" [class]=\"cn('-mx-1 my-1 h-px bg-border', class)\"></div>`,\n})\nexport class MenubarSeparatorComponent 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 readonly cn = cn;\n}\n"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "utils/cn.ts",
|
|
20
|
+
"target": "ui/utils/cn.ts",
|
|
21
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "message-scroller",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Message Scroller",
|
|
5
|
+
"description": "Message Scroller component.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "message-scroller.component.ts",
|
|
12
|
+
"target": "ui/message-scroller.component.ts",
|
|
13
|
+
"content": "import { Component, Directive, Input, Output, EventEmitter, ElementRef, NgZone, ChangeDetectionStrategy, ChangeDetectorRef, AfterViewInit, OnInit, OnDestroy, inject, OnChanges } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { Subscription } from 'rxjs';\nimport { cn } from './utils/cn';\nimport { MessageScrollerService, ScrollStartPosition } from './message-scroller.service';\n\n/**\n * A scroll container built for conversations, where content streams in while\n * the reader is still reading.\n *\n * A plain overflow-scroll div gets this wrong in both directions: it either\n * pins to the bottom and yanks the page mid-sentence, or it never follows and\n * the reader misses the reply. This handles the cases that are easy to get\n * wrong — following the live edge only while the reader is already there,\n * opening a saved thread at the newest turn rather than the absolute end, and\n * holding position when older history loads in above.\n *\n * ```html\n * <tolle-message-scroller startPosition=\"last-anchor\">\n * <tolle-message-scroller-viewport>\n * <tolle-message-scroller-content>\n * <div tolleMessageScrollerItem [scrollAnchor]=\"turn.isNewTurn\" *ngFor=\"let turn of turns\">\n * …\n * </div>\n * </tolle-message-scroller-content>\n * </tolle-message-scroller-viewport>\n * <tolle-message-scroller-button />\n * </tolle-message-scroller>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-message-scroller',\n // The host sits between a consumer-sized parent and the templated flex\n // frame below; display:block would drop the parent's height and the\n // frame's `flex-1 min-h-0` would have nothing to size against.\n styles: [':host { display: flex; flex-direction: column; height: 100%; min-height: 0; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [MessageScrollerService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"cn('relative flex min-h-0 flex-1 flex-col overflow-hidden', class)\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class MessageScrollerComponent implements OnChanges, OnInit, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /**\n * Where to land when the transcript first renders. `last-anchor` opens at the\n * newest turn so a long answer starts from its first line.\n * @default 'last-anchor'\n */\n @Input() startPosition: ScrollStartPosition = 'last-anchor';\n /**\n * Pixels of the previous turn kept visible above an anchored turn, so the\n * reader can see what the reply is responding to. @default 48\n */\n @Input() scrollPreviousItemPeek = 48;\n /**\n * Distance from the bottom, in px, still treated as the live edge.\n * @default 32\n */\n @Input() threshold = 32;\n /** Extra Tailwind classes merged onto the frame via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted when the reader arrives at, or leaves, the live edge. */\n @Output() atBottomChange = new EventEmitter<boolean>();\n\n private readonly service = inject(MessageScrollerService);\n private readonly sub = new Subscription();\n protected cn = cn;\n\n ngOnInit(): void {\n this.service.threshold = this.threshold;\n this.sub.add(this.service.atBottom$.subscribe((v) => this.atBottomChange.emit(v)));\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n /** Jumps to the newest content and resumes following. */\n scrollToBottom(behavior: ScrollBehavior = 'smooth'): void {\n this.service.scrollToBottom(behavior);\n }\n}\n\n/**\n * The scrolling element. Watches its own size and its content's size so it can\n * follow streamed text and hold position when history is prepended.\n */\n@Component({\n selector: 'tolle-message-scroller-viewport',\n // Same reasoning as tolle-message-scroller: this host is the flex item the\n // scrollable inner div sizes against, so it must join the flex chain itself.\n styles: [':host { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div #viewport [class]=\"cn('min-h-0 flex-1 overflow-y-auto overscroll-contain', class)\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class MessageScrollerViewportComponent implements OnChanges, AfterViewInit, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the viewport via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(MessageScrollerService);\n private readonly scroller = inject(MessageScrollerComponent);\n private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);\n private readonly zone = inject(NgZone);\n\n private viewportEl: HTMLElement | null = null;\n private resizeObserver?: ResizeObserver;\n private mutationObserver?: MutationObserver;\n private onScroll = () => this.service.measure();\n /** scrollHeight captured before each DOM mutation, to detect prepends. */\n private lastScrollHeight = 0;\n private lastScrollTop = 0;\n protected cn = cn;\n\n ngAfterViewInit(): void {\n const el = this.host.nativeElement.querySelector<HTMLElement>('div');\n if (!el) return;\n this.viewportEl = el;\n this.service.registerViewport(el);\n\n // Scroll fires at frame rate; keeping the listener outside Angular means we\n // only re-enter change detection when a tracked boolean actually flips.\n this.zone.runOutsideAngular(() => {\n el.addEventListener('scroll', this.onScroll, { passive: true });\n\n if (typeof ResizeObserver !== 'undefined') {\n this.resizeObserver = new ResizeObserver(() => this.service.handleContentGrowth());\n this.resizeObserver.observe(el);\n const content = el.firstElementChild;\n if (content) this.resizeObserver.observe(content);\n }\n\n if (typeof MutationObserver !== 'undefined') {\n this.mutationObserver = new MutationObserver(() => this.afterMutation());\n this.mutationObserver.observe(el, { childList: true, subtree: true, characterData: true });\n }\n });\n\n this.lastScrollHeight = el.scrollHeight;\n\n // Wait a frame so projected content has laid out before positioning.\n setTimeout(() => {\n this.service.applyStartPosition(\n this.scroller.startPosition,\n this.scroller.scrollPreviousItemPeek\n );\n this.lastScrollHeight = el.scrollHeight;\n });\n }\n\n ngOnDestroy(): void {\n this.viewportEl?.removeEventListener('scroll', this.onScroll);\n this.resizeObserver?.disconnect();\n this.mutationObserver?.disconnect();\n }\n\n /**\n * Distinguishes content appended below (stream) from content inserted above\n * (history). Growth while the reader has NOT moved but the scrollable area\n * above them has, means a prepend — restore their position rather than let\n * the view lurch.\n */\n private afterMutation(): void {\n const el = this.viewportEl;\n if (!el) return;\n\n const grew = el.scrollHeight - this.lastScrollHeight;\n const readerMoved = el.scrollTop !== this.lastScrollTop;\n\n if (grew > 0 && !readerMoved && !this.service.isFollowing && el.scrollTop > 0) {\n this.service.preserveOnPrepend(this.lastScrollHeight);\n } else if (grew > 0) {\n this.service.handleContentGrowth();\n }\n\n this.lastScrollHeight = el.scrollHeight;\n this.lastScrollTop = el.scrollTop;\n this.service.measure();\n }\n}\n\n/** The transcript itself. Announced politely so streamed replies reach screen readers. */\n@Component({\n selector: 'tolle-message-scroller-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n role=\"log\"\n aria-live=\"polite\"\n aria-relevant=\"additions text\"\n [class]=\"cn('flex flex-col gap-4 p-4', class)\"\n >\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class MessageScrollerContentComponent 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 transcript via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/**\n * Marks one row of the transcript. Set `scrollAnchor` on the row that begins a\n * conversation turn so `startPosition=\"last-anchor\"` can open there.\n */\n@Directive({\n selector: '[tolleMessageScrollerItem]',\n standalone: true,\n host: { '[attr.data-scroll-anchor]': `scrollAnchor ? '' : null` },\n})\nexport class MessageScrollerItemDirective implements OnInit, OnDestroy {\n /** Marks this row as the start of a turn. @default false */\n @Input() scrollAnchor = false;\n\n private readonly service = inject(MessageScrollerService);\n private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);\n\n ngOnInit(): void {\n if (this.scrollAnchor) this.service.registerAnchor(this.host.nativeElement);\n }\n\n ngOnDestroy(): void {\n this.service.unregisterAnchor(this.host.nativeElement);\n }\n\n /** Brings this row into view — use for jump-to-message. */\n scrollIntoView(behavior: ScrollBehavior = 'smooth'): void {\n this.service.scrollToItem(this.host.nativeElement, behavior);\n }\n}\n\n/** Floating \"jump to latest\" control. Hidden while the reader is already at the edge. */\n@Component({\n selector: 'tolle-message-scroller-button',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n *ngIf=\"!atBottom\"\n type=\"button\"\n [attr.aria-label]=\"ariaLabel\"\n [class]=\"cn(\n 'absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1.5 rounded-full',\n 'border border-border bg-popover px-3 py-1.5 text-xs font-medium text-popover-foreground shadow-md',\n 'transition-colors hover:bg-accent hover:text-accent-foreground',\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n class\n )\"\n (click)=\"jump()\"\n >\n <i class=\"ri-arrow-down-line\" aria-hidden=\"true\"></i>\n <ng-content></ng-content>\n </button>\n `,\n})\nexport class MessageScrollerButtonComponent 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 /** Accessible name for the jump control. @default 'Jump to latest messages' */\n @Input() ariaLabel = 'Jump to latest messages';\n /** Extra Tailwind classes merged onto the button via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(MessageScrollerService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n atBottom = true;\n protected cn = cn;\n\n ngOnInit(): void {\n this.sub.add(\n this.service.atBottom$.subscribe((v) => {\n this.atBottom = v;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n jump(): void {\n this.service.scrollToBottom();\n }\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "message-scroller.service.ts",
|
|
17
|
+
"target": "ui/message-scroller.service.ts",
|
|
18
|
+
"content": "import { Injectable } from '@angular/core';\nimport { BehaviorSubject } from 'rxjs';\n\n/** Where a freshly-mounted scroller should start. */\nexport type ScrollStartPosition = 'top' | 'bottom' | 'last-anchor';\n\n/**\n * Scroll state for one `tolle-message-scroller`, shared with its viewport,\n * items and buttons.\n *\n * The governing rule is: **never move the reader against their intent.** Every\n * automatic scroll here is gated on the reader already being at the live edge.\n * The moment they scroll up, auto-scroll switches off and stays off until they\n * come back down or explicitly jump.\n *\n * Positions are read from and written to the DOM directly rather than mirrored\n * into component state — scroll events fire far too often to run change\n * detection on, and the values would be stale by the time they landed.\n */\n@Injectable()\nexport class MessageScrollerService {\n /** The scrollable element, set by the viewport once it renders. */\n private viewport: HTMLElement | null = null;\n\n /** Rows registered as the start of a conversation turn, in DOM order. */\n private anchors: HTMLElement[] = [];\n\n private readonly atBottomSubject = new BehaviorSubject<boolean>(true);\n /** True while the reader is at (or within `threshold` of) the live edge. */\n readonly atBottom$ = this.atBottomSubject.asObservable();\n\n private readonly followingSubject = new BehaviorSubject<boolean>(true);\n /**\n * True while new content should pull the view along. Set false the instant\n * the reader scrolls away from the bottom.\n */\n readonly following$ = this.followingSubject.asObservable();\n\n /** Distance from the bottom, in px, still considered \"at the live edge\". */\n threshold = 32;\n\n get atBottom(): boolean {\n return this.atBottomSubject.value;\n }\n\n get isFollowing(): boolean {\n return this.followingSubject.value;\n }\n\n registerViewport(el: HTMLElement): void {\n this.viewport = el;\n }\n\n registerAnchor(el: HTMLElement): void {\n if (!this.anchors.includes(el)) this.anchors.push(el);\n }\n\n unregisterAnchor(el: HTMLElement): void {\n const i = this.anchors.indexOf(el);\n if (i > -1) this.anchors.splice(i, 1);\n }\n\n /**\n * Recomputes whether we're at the live edge. Called from the viewport's\n * scroll handler, which runs outside Angular — this only pushes through the\n * subjects when the boolean actually flips, so we re-enter change detection\n * once per state change instead of once per scroll event.\n */\n measure(): void {\n const el = this.viewport;\n if (!el) return;\n\n const distance = el.scrollHeight - el.scrollTop - el.clientHeight;\n const atBottom = distance <= this.threshold;\n\n if (atBottom !== this.atBottomSubject.value) this.atBottomSubject.next(atBottom);\n\n // Leaving the bottom stops following; returning resumes it. This is the\n // whole \"don't fight the reader\" contract in two lines.\n if (atBottom !== this.followingSubject.value) this.followingSubject.next(atBottom);\n }\n\n /** Jumps to the live edge and resumes following. */\n scrollToBottom(behavior: ScrollBehavior = 'smooth'): void {\n const el = this.viewport;\n if (!el) return;\n el.scrollTo({ top: el.scrollHeight, behavior });\n if (!this.followingSubject.value) this.followingSubject.next(true);\n if (!this.atBottomSubject.value) this.atBottomSubject.next(true);\n }\n\n /** Scrolls a specific row into view, e.g. jump-to-message from a search result. */\n scrollToItem(el: HTMLElement, behavior: ScrollBehavior = 'smooth'): void {\n el.scrollIntoView({ behavior, block: 'start' });\n }\n\n /**\n * Positions the transcript on open. `last-anchor` puts the newest turn at the\n * top of the viewport instead of dumping the reader at the absolute end, so a\n * long streamed answer starts from its beginning — with `peek` px of the\n * previous turn left visible for context.\n */\n applyStartPosition(position: ScrollStartPosition, peek: number): void {\n const el = this.viewport;\n if (!el) return;\n\n if (position === 'top') {\n el.scrollTop = 0;\n this.measure();\n return;\n }\n\n if (position === 'last-anchor') {\n const anchor = this.lastAnchorInDomOrder();\n if (anchor) {\n el.scrollTop = Math.max(0, anchor.offsetTop - el.offsetTop - peek);\n this.measure();\n return;\n }\n // No turns marked — fall through to the bottom.\n }\n\n el.scrollTop = el.scrollHeight;\n this.measure();\n }\n\n /**\n * Content grew. Follows only if the reader was already at the edge; uses\n * 'auto' so streamed tokens don't queue a smooth animation per chunk.\n */\n handleContentGrowth(): void {\n if (!this.followingSubject.value) {\n // Not following, but the scrollbar just changed size — re-measure so the\n // jump button's visibility stays honest.\n this.measure();\n return;\n }\n const el = this.viewport;\n if (!el) return;\n el.scrollTop = el.scrollHeight;\n }\n\n /**\n * History was prepended above the current view. Restores the reader's exact\n * position by re-adding the height the new content introduced — otherwise the\n * transcript visibly lurches downward as older messages load in.\n */\n preserveOnPrepend(previousScrollHeight: number): void {\n const el = this.viewport;\n if (!el) return;\n const delta = el.scrollHeight - previousScrollHeight;\n if (delta > 0) el.scrollTop += delta;\n }\n\n /** Current scrollHeight, captured before a prepend so it can be compared after. */\n snapshotScrollHeight(): number {\n return this.viewport?.scrollHeight ?? 0;\n }\n\n /** The last registered anchor in document order (registration order can vary). */\n private lastAnchorInDomOrder(): HTMLElement | undefined {\n if (!this.anchors.length) return undefined;\n return [...this.anchors].sort((a, b) =>\n a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1\n )[this.anchors.length - 1];\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": "message",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Message",
|
|
5
|
+
"description": "Message component with align and size options.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "message.component.ts",
|
|
14
|
+
"target": "ui/message.component.ts",
|
|
15
|
+
"content": "import { Component, Directive, Injectable, Input, ChangeDetectionStrategy, ChangeDetectorRef, OnChanges, OnDestroy, 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';\n\nconst messageVariants = cva('group/message flex w-full gap-2', {\n variants: {\n align: {\n // Incoming: avatar first, content grows to the right.\n start: 'flex-row justify-start',\n // Outgoing: the row is mirrored so the avatar lands on the trailing edge.\n end: 'flex-row-reverse justify-start',\n },\n size: {\n sm: 'gap-1.5 text-sm',\n default: 'gap-2',\n },\n },\n defaultVariants: { align: 'start', size: 'default' },\n});\n\nexport type MessageProps = VariantProps<typeof messageVariants>;\n\n/** Which edge of the transcript a message hangs off. */\nexport type MessageAlign = 'start' | 'end';\n\n/**\n * Shares the owning message's alignment with its OnPush children.\n *\n * Avatar / content / header / footer all need to know which edge the row hangs\n * off, but reading `MessageComponent.align` from a child getter would never mark\n * that child dirty. Emitting through an observable and calling `markForCheck()`\n * on each emission is the only thing that re-renders them.\n */\n@Injectable()\nexport class MessageAlignService {\n private alignSource = new BehaviorSubject<MessageAlign>('start');\n\n /** Emits the owning message's alignment whenever it changes. */\n readonly align$ = this.alignSource.asObservable();\n\n /** The owning message's current alignment. */\n get align(): MessageAlign {\n return this.alignSource.value;\n }\n\n setAlign(align: MessageAlign): void {\n if (this.alignSource.value !== align) this.alignSource.next(align);\n }\n}\n\n/**\n * Base class for the message sub-components: resolves the row's alignment from\n * the parent `tolle-message` and keeps the OnPush view in sync with it.\n */\n@Directive()\nabstract class MessageAlignAware implements OnChanges, OnDestroy {\n /** Extra Tailwind classes merged onto the element via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(MessageAlignService, { optional: true });\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n /**\n * A bound `class` input is written through Angular's styling path, which\n * does not mark an OnPush view dirty — without this every subclass would\n * keep rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n constructor() {\n // Alignment is not an input of this component, so nothing else would mark it\n // dirty when the parent message flips sides.\n if (this.service) {\n this.sub.add(this.service.align$.subscribe(() => this.cdr.markForCheck()));\n }\n }\n\n /** Alignment inherited from the enclosing message, defaulting to 'start'. */\n protected get align(): MessageAlign {\n return this.service?.align ?? 'start';\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n protected cn = cn;\n}\n\n/**\n * The row layout for a single chat message: avatar on one edge, the content\n * column (header, bubble, footer) filling the rest. Compose it with\n * `tolle-bubble` for the visible surface.\n * @new\n */\n@Component({\n selector: 'tolle-message',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [MessageAlignService],\n template: `<div [class]=\"computedClass\" role=\"listitem\"><ng-content></ng-content></div>`,\n})\nexport class MessageComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /** Which edge the message hangs off — 'start' for incoming, 'end' for outgoing. @default 'start' */\n @Input() align: MessageProps['align'] = 'start';\n /** Gap density of the row. @default 'default' */\n @Input() size: MessageProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the row via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(MessageAlignService);\n\n ngOnChanges(): void {\n // Push alignment into the service so OnPush children get told about it.\n this.service.setAlign(this.align ?? 'start');\n \n // A bound `class` input is written through Angular's styling path,\n // which does not mark an OnPush view dirty on its own.\n this.cdr.markForCheck();\n }\n\n get computedClass(): string {\n return cn(messageVariants({ align: this.align, size: this.size }), this.class);\n }\n}\n\nconst messageGroupVariants = cva('flex w-full flex-col', {\n variants: {\n align: {\n start: 'items-start',\n end: 'items-end',\n },\n spacing: {\n tight: 'gap-0.5',\n default: 'gap-1',\n loose: 'gap-3',\n },\n },\n defaultVariants: { align: 'start', spacing: 'default' },\n});\n\nexport type MessageGroupProps = VariantProps<typeof messageGroupVariants>;\n\n/**\n * Stacks consecutive messages from the same sender with tighter spacing, so a\n * burst of replies reads as one turn rather than several.\n */\n@Component({\n selector: 'tolle-message-group',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"computedClass\" role=\"group\"><ng-content></ng-content></div>`,\n})\nexport class MessageGroupComponent 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 /** Edge the grouped messages hang off. @default 'start' */\n @Input() align: MessageGroupProps['align'] = 'start';\n /** Vertical gap between the grouped messages. @default 'default' */\n @Input() spacing: MessageGroupProps['spacing'] = 'default';\n /** Extra Tailwind classes merged onto the group via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass(): string {\n return cn(messageGroupVariants({ align: this.align, spacing: this.spacing }), this.class);\n }\n}\n\n/**\n * Avatar slot for a message. Anchored to the bottom of the row so it sits beside\n * the last line of a multi-line bubble rather than floating at the top.\n */\n@Component({\n selector: 'tolle-message-avatar',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"computedClass\"><ng-content></ng-content></div>`,\n})\nexport class MessageAvatarComponent extends MessageAlignAware {\n get computedClass(): string {\n return cn(\n 'flex size-8 shrink-0 select-none items-center justify-center self-end overflow-hidden rounded-full bg-muted text-xs font-medium text-muted-foreground',\n this.class\n );\n }\n}\n\n/** Content column of a message — wraps the header, the bubble, and the footer. */\n@Component({\n selector: 'tolle-message-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"computedClass\"><ng-content></ng-content></div>`,\n})\nexport class MessageContentComponent extends MessageAlignAware {\n get computedClass(): string {\n return cn(\n 'flex min-w-0 flex-1 flex-col gap-1',\n this.align === 'end' ? 'items-end text-right' : 'items-start text-left',\n this.class\n );\n }\n}\n\n/** Sender name and metadata rendered above the message surface. */\n@Component({\n selector: 'tolle-message-header',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"computedClass\"><ng-content></ng-content></div>`,\n})\nexport class MessageHeaderComponent extends MessageAlignAware {\n get computedClass(): string {\n return cn(\n 'flex items-center gap-2 px-1 text-xs font-medium text-muted-foreground',\n this.align === 'end' ? 'flex-row-reverse' : 'flex-row',\n this.class\n );\n }\n}\n\n/** Delivery status, actions, or a timestamp rendered below the message surface. */\n@Component({\n selector: 'tolle-message-footer',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"computedClass\"><ng-content></ng-content></div>`,\n})\nexport class MessageFooterComponent extends MessageAlignAware {\n get computedClass(): string {\n return cn(\n 'flex items-center gap-2 px-1 text-xs text-muted-foreground',\n this.align === 'end' ? 'flex-row-reverse' : 'flex-row',\n this.class\n );\n }\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "utils/cn.ts",
|
|
19
|
+
"target": "ui/utils/cn.ts",
|
|
20
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "model-selector",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Model Selector",
|
|
5
|
+
"description": "Model Selector component with size and variant options.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"command",
|
|
9
|
+
"popover"
|
|
10
|
+
],
|
|
11
|
+
"dependencies": [
|
|
12
|
+
"@floating-ui/dom",
|
|
13
|
+
"class-variance-authority"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "command.service.ts",
|
|
18
|
+
"target": "ui/command.service.ts",
|
|
19
|
+
"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"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"path": "model-selector.component.ts",
|
|
23
|
+
"target": "ui/model-selector.component.ts",
|
|
24
|
+
"content": "import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, Output, ViewChild, forwardRef, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport { PopoverComponent } from './popover.component';\nimport {\n CommandComponent,\n CommandInputComponent,\n CommandListComponent,\n CommandEmptyComponent,\n CommandGroupComponent,\n CommandItemComponent,\n} from './command.component';\n\n/** One model the user can pick. Deliberately provider-agnostic. */\nexport type ModelOption = {\n /** Stable identifier written back through the form control. */\n id: string;\n /** Human-readable model name. */\n name: string;\n /** Vendor or family; when present the list is grouped by it. */\n provider?: string;\n /** One-line summary shown under the name. */\n description?: string;\n /** Short tag rendered as a pill, e.g. \"new\" or \"preview\". */\n badge?: string;\n /** Prevents the option from being chosen. */\n disabled?: boolean;\n};\n\n/** A provider heading and the models filed under it. */\nexport type ModelSelectorGroup = { provider: string; models: ModelOption[] };\n\nconst modelSelectorTriggerVariants = cva(\n 'inline-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-7 px-2 text-xs',\n sm: 'h-8 px-2.5 text-xs',\n default: 'h-9 px-3 text-sm',\n lg: 'h-10 px-3.5 text-sm',\n },\n variant: {\n default: '',\n ghost: 'border-transparent bg-transparent shadow-none hover:bg-accent hover:text-accent-foreground',\n },\n },\n defaultVariants: { size: 'default', variant: 'default' },\n }\n);\n\nexport type ModelSelectorProps = VariantProps<typeof modelSelectorTriggerVariants>;\n\n/**\n * A compact picker for choosing which model a conversation should use.\n *\n * Implements `ControlValueAccessor`, so it binds with `ngModel` or a reactive\n * form control and round-trips the chosen model's `id`. Searching, keyboard\n * navigation and positioning are reused from `tolle-command` and\n * `tolle-popover` rather than reimplemented here.\n *\n * ```html\n * <tolle-model-selector [models]=\"models\" [(ngModel)]=\"modelId\"></tolle-model-selector>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-model-selector',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [\n CommonModule,\n PopoverComponent,\n CommandComponent,\n CommandInputComponent,\n CommandListComponent,\n CommandEmptyComponent,\n CommandGroupComponent,\n CommandItemComponent,\n ],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => ModelSelectorComponent),\n multi: true,\n },\n ],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <tolle-popover [placement]=\"placement\" (onClose)=\"onPopoverClose()\">\n <button\n trigger\n type=\"button\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n [attr.aria-label]=\"ariaLabel || 'Select a model'\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\"\n >\n <i *ngIf=\"icon\" [class]=\"icon + ' shrink-0 text-muted-foreground'\" aria-hidden=\"true\"></i>\n <span [class]=\"selected ? 'truncate text-foreground' : 'truncate text-muted-foreground'\">{{ triggerLabel }}</span>\n <i class=\"ri-expand-up-down-line shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n </button>\n\n <div class=\"w-72 overflow-hidden rounded-md border border-border bg-popover shadow-md\">\n <tolle-command [ariaLabel]=\"ariaLabel || 'Select a model'\" (selected)=\"onSelect($event)\">\n <tolle-command-input *ngIf=\"searchable\" [placeholder]=\"searchPlaceholder\"></tolle-command-input>\n <tolle-command-list>\n <tolle-command-empty>{{ emptyMessage }}</tolle-command-empty>\n <tolle-command-group *ngFor=\"let group of groups\" [heading]=\"group.provider\">\n <tolle-command-item\n *ngFor=\"let model of group.models\"\n [value]=\"model.id\"\n [label]=\"model.name\"\n [keywords]=\"[model.provider || '', model.description || '']\"\n [disabled]=\"model.disabled || false\"\n class=\"items-start\"\n >\n <i\n class=\"ri-check-line mt-0.5 shrink-0 text-primary\"\n [class.opacity-0]=\"model.id !== value\"\n aria-hidden=\"true\"\n ></i>\n <span class=\"flex min-w-0 flex-1 flex-col\">\n <span class=\"flex items-center gap-1.5\">\n <span class=\"truncate font-medium\">{{ model.name }}</span>\n <span\n *ngIf=\"model.badge\"\n class=\"shrink-0 rounded-full border border-border bg-muted px-1.5 py-px text-[10px] font-medium uppercase tracking-wide text-muted-foreground\"\n >{{ model.badge }}</span\n >\n </span>\n <span *ngIf=\"model.description\" class=\"truncate text-xs text-muted-foreground\">{{ model.description }}</span>\n </span>\n </tolle-command-item>\n </tolle-command-group>\n </tolle-command-list>\n </tolle-command>\n </div>\n </tolle-popover>\n `,\n})\nexport class ModelSelectorComponent implements ControlValueAccessor, OnChanges {\n /** Models to choose from; grouped by `provider` when that field is set. @default [] */\n @Input() models: ModelOption[] = [];\n /** Text shown on the trigger when nothing is selected. @default 'Select a model' */\n @Input() placeholder = 'Select a model';\n /** Placeholder inside the search box. @default 'Search models…' */\n @Input() searchPlaceholder = 'Search models…';\n /** Message shown when the query matches no model. @default 'No models found.' */\n @Input() emptyMessage = 'No models found.';\n /** Shows the search box above the list. @default true */\n @Input() searchable = true;\n /** Prefixes the selected model's name with its provider on the trigger. @default false */\n @Input() showProviderOnTrigger = false;\n /** Height and text size of the trigger. @default 'default' */\n @Input() size: ModelSelectorProps['size'] = 'default';\n /** Visual style of the trigger. @default 'default' */\n @Input() variant: ModelSelectorProps['variant'] = 'default';\n /** Optional remixicon class shown at the start of the trigger. @default 'ri-sparkling-line' */\n @Input() icon = 'ri-sparkling-line';\n /** Where the list opens relative to the trigger. @default 'bottom-start' */\n @Input() placement: 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' = 'bottom-start';\n /** Disables the control. @default false */\n @Input() disabled = false;\n /** Accessible name for the trigger and list. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the chosen model's `id` whenever the selection changes. */\n @Output() valueChange = new EventEmitter<string | null>();\n /** Emitted with the whole `ModelOption` whenever the selection changes. */\n @Output() modelChange = new EventEmitter<ModelOption | null>();\n\n @ViewChild(PopoverComponent) popover?: PopoverComponent;\n\n /** Id of the currently selected model, or `null`. */\n value: string | null = null;\n /** Models bucketed by provider, recomputed whenever `models` changes. */\n groups: ModelSelectorGroup[] = [];\n\n private readonly cdr = inject(ChangeDetectorRef);\n private onChange: (value: string | null) => void = () => {};\n private onTouched: () => void = () => {};\n\n ngOnChanges(): void {\n this.groups = this.groupModels(this.models);\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 selected `ModelOption`, or `null` when the value matches nothing. */\n get selected(): ModelOption | null {\n return this.models.find((model) => model.id === this.value) ?? null;\n }\n\n get triggerLabel(): string {\n const selected = this.selected;\n if (!selected) return this.placeholder;\n return this.showProviderOnTrigger && selected.provider\n ? selected.provider + ' / ' + selected.name\n : selected.name;\n }\n\n get computedTriggerClass() {\n return cn(\n modelSelectorTriggerVariants({ size: this.size, variant: this.variant }),\n this.class\n );\n }\n\n onSelect(id: string): void {\n this.value = id;\n this.onChange(id);\n this.valueChange.emit(id);\n this.modelChange.emit(this.selected);\n this.popover?.close();\n this.cdr.markForCheck();\n }\n\n onPopoverClose(): void {\n this.onTouched();\n }\n\n /**\n * Buckets models by provider, preserving input order both between and within\n * groups. Models with no provider land in a single unlabelled group so a flat\n * list still renders.\n */\n private groupModels(models: ModelOption[]): ModelSelectorGroup[] {\n const groups: ModelSelectorGroup[] = [];\n const byProvider = new Map<string, ModelSelectorGroup>();\n\n for (const model of models) {\n const provider = model.provider ?? '';\n let group = byProvider.get(provider);\n if (!group) {\n group = { provider, models: [] };\n byProvider.set(provider, group);\n groups.push(group);\n }\n group.models.push(model);\n }\n\n return groups;\n }\n\n writeValue(value: string | null): void {\n this.value = value ?? null;\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: (value: string | null) => void): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: () => void): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n}\n"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"path": "utils/cn.ts",
|
|
28
|
+
"target": "ui/utils/cn.ts",
|
|
29
|
+
"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"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "multi-select",
|
|
3
3
|
"type": "registry:ui",
|
|
4
4
|
"title": "Multi Select",
|
|
5
|
-
"description": "Multi Select component.",
|
|
5
|
+
"description": "Multi Select component with size and error and disabled options.",
|
|
6
6
|
"category": "forms",
|
|
7
7
|
"registryDependencies": [
|
|
8
8
|
"badge",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
{
|
|
18
18
|
"path": "multi-select.component.ts",
|
|
19
19
|
"target": "ui/multi-select.component.ts",
|
|
20
|
-
"content": "import { Component, Input, ContentChildren, QueryList, AfterContentInit, ElementRef, ViewChild, OnDestroy } 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 { SelectItemComponent } from './select-item.component';\nimport { SelectService } from './select.service';\nimport { cn } from './utils/cn';\nimport { InputComponent } from './input.component';\nimport { BadgeComponent } from './badge.component';\n\n@Component({\n selector: 'tolle-multi-select',\n standalone: true,\n imports: [CommonModule, FormsModule, BadgeComponent, InputComponent],\n providers: [\n SelectService,\n { provide: NG_VALUE_ACCESSOR, useExisting: MultiSelectComponent, multi: true }\n ],\n template: `\n <div class=\"relative w-full\" #container>\n <button\n #trigger\n type=\"button\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-activedescendant]=\"isOpen ? activeDescendantId : null\"\n (click)=\"toggle()\"\n (keydown)=\"onTriggerKeyDown($event)\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\">\n\n <div class=\"flex flex-wrap gap-1 items-center max-w-[95%]\">\n <ng-container *ngIf=\"value.length; else placeholderTpl\">\n <tolle-badge *ngFor=\"let item of displayItems\" size=\"xs\" variant=\"secondary\" [removable]=\"true\" (onRemove)=\"removeValue($event, item.value)\">\n {{ item.label }}\n </tolle-badge>\n <span *ngIf=\"exceedsDisplayLimit\" class=\"text-xs text-muted-foreground px-1\">\n +{{ value.length - maxDisplayItems }} more\n </span>\n <span *ngIf=\"maxSelections && value.length >= maxSelections\" class=\"text-xs text-muted-foreground px-1\">\n (Max reached)\n </span>\n </ng-container>\n <ng-template #placeholderTpl><span class=\"text-muted-foreground\">{{ placeholder }}</span></ng-template>\n </div>\n <i [class]=\"cn('ri-arrow-down-s-line text-muted-foreground ml-2 transition-transform duration-200', isOpen ? 'rotate-180' : '')\"></i>\n </button>\n\n <div #popover *ngIf=\"isOpen\"\n role=\"listbox\"\n aria-multiselectable=\"true\"\n class=\"fixed bg-popover text-popover-foreground z-[999] rounded-md border border-border shadow-md overflow-hidden\"\n style=\"visibility: hidden; top: 0; left: 0;\">\n\n <div class=\"p-2 border-b border-border space-y-2 bg-popover\">\n <div class=\"flex items-center justify-between px-1 text-xs\">\n <span class=\"text-muted-foreground\">\n {{ value.length }} selected\n <span *ngIf=\"maxSelections\">/ {{ maxSelections }} max</span>\n </span>\n <span *ngIf=\"maxSelections && value.length >= maxSelections\" class=\"text-destructive text-xs font-medium\">\n Maximum reached\n </span>\n </div>\n\n <tolle-input *ngIf=\"searchable\" #searchInput size=\"xs\" placeholder=\"Search...\" [(ngModel)]=\"searchQuery\" (ngModelChange)=\"onSearchChange($event)\" (keydown)=\"onSearchKeyDown($event)\">\n <i prefix class=\"ri-search-line\"></i>\n </tolle-input>\n\n <div class=\"flex items-center justify-between px-1\">\n <button type=\"button\"\n (click)=\"selectAll()\"\n [disabled]=\"!!(maxSelections && selectableItems.length > maxSelections)\"\n [class]=\"cn(\n 'text-[10px] font-bold uppercase transition-colors',\n maxSelections && selectableItems.length > maxSelections\n ? 'text-muted-foreground cursor-not-allowed'\n : 'text-primary hover:underline'\n )\">\n Select All\n </button>\n <button type=\"button\" (click)=\"clearAll()\" class=\"text-[10px] font-bold uppercase text-muted-foreground hover:underline\">\n Clear\n </button>\n </div>\n </div>\n\n <div class=\"p-1 max-h-60 overflow-y-auto\">\n <ng-content></ng-content>\n <div *ngIf=\"noResults\" class=\"py-4 text-center text-xs text-muted-foreground\">\n No results found for \"{{searchQuery}}\"\n </div>\n <div *ngIf=\"maxSelections && value.length >= maxSelections\"\n class=\"p-2 text-center border-t border-border bg-muted/20\">\n <span class=\"text-xs text-destructive\">\n <i class=\"ri-alert-line mr-1\"></i>\n Maximum selection limit reached ({{maxSelections}})\n </span>\n </div>\n </div>\n </div>\n </div>\n `\n})\nexport class MultiSelectComponent implements ControlValueAccessor, AfterContentInit, OnDestroy {\n @Input() placeholder = 'Select options...';\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() searchable = false;\n @Input() disabled = false;\n @Input() class = '';\n @Input() maxSelections?: number;\n @Input() maxDisplayItems = 3;\n @Input() error = false; // Added to support error styling\n\n @ViewChild('trigger') trigger!: ElementRef;\n @ViewChild('popover') popover!: ElementRef;\n @ViewChild('searchInput', { read: ElementRef }) searchInput?: ElementRef<HTMLElement>;\n @ContentChildren(SelectItemComponent, { descendants: true }) items!: QueryList<SelectItemComponent>;\n\n value: any[] = [];\n selectedItems: { label: string, value: any }[] = [];\n isOpen = false;\n searchQuery = '';\n noResults = false;\n private cleanup?: () => void;\n\n // Keyboard navigation (active-descendant) state\n activeIndex = -1;\n private typeaheadBuffer = '';\n private typeaheadTime = 0;\n private pendingTypeaheadChar = '';\n\n constructor(private selectService: SelectService) {\n this.selectService.selectedValue$.subscribe(val => {\n if (val !== undefined && this.isOpen) this.toggleValue(val);\n });\n }\n\n // NEW: Matches InputComponent styles exactly\n get computedTriggerClass() {\n return cn(\n // Base styles\n 'flex min-h-10 w-full items-center justify-between rounded-md border transition-all duration-200 h-auto',\n 'bg-background text-sm',\n\n // Border and shadow\n 'border-input shadow-sm',\n\n // Padding based on size (aligned with InputComponent logic)\n this.size === 'xs' && 'px-2 py-1',\n this.size === 'sm' && 'px-3 py-1.5',\n this.size === 'default' && 'px-3 py-2',\n this.size === 'lg' && 'px-4 py-2',\n\n // Focus state - ZARDUI STYLE (Soft ring, no offset)\n !this.disabled && [\n 'focus-visible:outline-none',\n 'focus-visible:ring-4',\n 'focus-visible:ring-ring/30',\n 'focus-visible:ring-offset-0',\n 'focus-visible:shadow-none',\n 'focus-visible:border-primary/80' // Darkens border on focus\n ],\n\n // Hover state\n !this.disabled && 'hover:border-accent',\n\n // Error state\n this.error && [\n 'border-destructive',\n !this.disabled && [\n 'focus-visible:border-destructive/80',\n 'focus-visible:ring-destructive/30'\n ]\n ],\n\n // Disabled state\n this.disabled && [\n 'cursor-not-allowed opacity-50',\n 'border-opacity-50'\n ],\n\n this.class\n );\n }\n\n ngAfterContentInit() {\n this.syncItems();\n this.items.changes.subscribe(() => this.syncItems());\n }\n\n get displayItems(): { label: string, value: any }[] {\n return this.selectedItems.slice(0, this.maxDisplayItems);\n }\n\n get exceedsDisplayLimit(): boolean {\n return this.value.length > this.maxDisplayItems;\n }\n\n get selectableItems(): SelectItemComponent[] {\n return this.items ? this.items.filter(item => !item.disabled) : [];\n }\n\n get availableSelections(): number {\n if (!this.maxSelections) return Infinity;\n return Math.max(0, this.maxSelections - this.value.length);\n }\n\n private _outsideClickHandler = (event: MouseEvent) => {\n if (!this.trigger.nativeElement.contains(event.target) && !this.popover?.nativeElement.contains(event.target)) {\n this.close();\n }\n };\n\n toggle() {\n if (this.disabled) return;\n this.isOpen ? this.close() : this.open();\n }\n\n open() {\n this.isOpen = true;\n setTimeout(() => {\n this.updatePosition();\n document.addEventListener('mousedown', this._outsideClickHandler);\n this.initActiveAfterOpen();\n });\n }\n\n close() {\n this.isOpen = false;\n this.searchQuery = '';\n this.onSearchChange('');\n this.resetActive();\n if (this.cleanup) this.cleanup();\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n\n private updatePosition() {\n if (!this.trigger || !this.popover) return;\n\n this.cleanup = autoUpdate(this.trigger.nativeElement, this.popover.nativeElement, () => {\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 size({\n apply({ rects, elements, availableHeight }) {\n Object.assign(elements.floating.style, {\n width: `${rects.reference.width}px`,\n maxHeight: `${availableHeight}px`\n });\n },\n }),\n ],\n }).then(({ x, y, strategy }) => {\n Object.assign(this.popover.nativeElement.style, {\n position: strategy,\n left: `${x}px`,\n top: `${y}px`,\n visibility: 'visible',\n });\n });\n });\n }\n\n toggleValue(val: any) {\n const index = this.value.indexOf(val);\n if (index > -1) {\n this.value.splice(index, 1);\n } else {\n if (this.maxSelections && this.value.length >= this.maxSelections) return;\n this.value.push(val);\n }\n this.syncItems();\n this.onChange([...this.value]);\n }\n\n selectAll() {\n if (!this.items) return;\n let itemsToSelect: any[] = [];\n if (this.maxSelections) {\n const availableItems = this.items\n .filter(item => !item.disabled && !this.value.includes(item.value))\n .slice(0, this.availableSelections)\n .map(item => item.value);\n itemsToSelect = [...this.value, ...availableItems];\n } else {\n itemsToSelect = this.items.filter(item => !item.disabled).map(item => item.value);\n }\n this.value = itemsToSelect;\n this.syncItems();\n this.onChange([...this.value]);\n }\n\n clearAll() {\n this.value = [];\n this.syncItems();\n this.onChange([]);\n }\n\n removeValue(event: MouseEvent, val: any) {\n event.stopPropagation();\n this.toggleValue(val);\n }\n\n private syncItems() {\n if (!this.items) return;\n this.selectedItems = [];\n this.items.forEach(item => {\n item.selected = this.value.includes(item.value);\n if (item.selected) {\n this.selectedItems.push({ label: item.getLabel(), value: item.value });\n }\n if (this.maxSelections && this.value.length >= this.maxSelections) {\n item.disabled = !this.value.includes(item.value);\n } else if (item.disabled) {\n item.disabled = false;\n }\n });\n }\n\n onSearchChange(q: string) {\n const filter = (q || '').toLowerCase();\n let visibleCount = 0;\n this.items.forEach(item => {\n const isVisible = item.getLabel().toLowerCase().includes(filter);\n item.hidden = !isVisible;\n if (isVisible) visibleCount++;\n });\n this.noResults = visibleCount === 0 && filter !== '';\n\n // Re-anchor the active option to the first visible match while open.\n if (this.isOpen) this.setActive(0);\n }\n\n // ---- Keyboard navigation (WAI-ARIA listbox pattern) ----\n\n private get navigableItems(): SelectItemComponent[] {\n return this.items ? this.items.filter(i => !i.hidden && !i.disabled) : [];\n }\n\n get activeDescendantId(): string | null {\n const list = this.navigableItems;\n return this.activeIndex >= 0 && this.activeIndex < list.length\n ? list[this.activeIndex].id\n : null;\n }\n\n private updateActiveStates() {\n const list = this.navigableItems;\n const activeItem = this.activeIndex >= 0 ? list[this.activeIndex] ?? null : null;\n this.items?.forEach(i => (i.active = i === activeItem));\n this.syncActiveDescendant();\n }\n\n private syncActiveDescendant() {\n const input = this.searchInput?.nativeElement?.querySelector('input');\n if (!input) return;\n const id = this.activeDescendantId;\n if (id) input.setAttribute('aria-activedescendant', id);\n else input.removeAttribute('aria-activedescendant');\n }\n\n private setActive(index: number) {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n this.activeIndex = Math.max(0, Math.min(index, list.length - 1));\n this.updateActiveStates();\n list[this.activeIndex]?.scrollIntoActiveView();\n }\n\n private setActiveItem(item: SelectItemComponent) {\n const idx = this.navigableItems.indexOf(item);\n if (idx >= 0) this.setActive(idx);\n }\n\n private moveActive(delta: number) {\n const list = this.navigableItems;\n if (!list.length) return;\n const next = this.activeIndex < 0 ? (delta > 0 ? 0 : list.length - 1) : this.activeIndex + delta;\n this.setActive(next); // setActive clamps (no wrap, matching shadcn)\n }\n\n private setActiveToSelectedOrFirst() {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n const selIdx = list.findIndex(i => this.value.includes(i.value));\n this.setActive(selIdx >= 0 ? selIdx : 0);\n }\n\n private initActiveAfterOpen() {\n if (this.pendingTypeaheadChar) {\n const c = this.pendingTypeaheadChar;\n this.pendingTypeaheadChar = '';\n this.setActiveToSelectedOrFirst();\n this.typeahead(c);\n } else {\n this.setActiveToSelectedOrFirst();\n }\n }\n\n private resetActive() {\n this.activeIndex = -1;\n this.typeaheadBuffer = '';\n this.pendingTypeaheadChar = '';\n this.items?.forEach(i => (i.active = false));\n }\n\n private isTypeaheadChar(event: KeyboardEvent): boolean {\n return event.key.length === 1 && event.key !== ' ' && !event.ctrlKey && !event.metaKey && !event.altKey;\n }\n\n private typeahead(char: string) {\n const now = Date.now();\n // Reset the buffer based on elapsed time (correctness is not timer-driven).\n if (now - this.typeaheadTime > 500) this.typeaheadBuffer = '';\n this.typeaheadTime = now;\n this.typeaheadBuffer += char.toLowerCase();\n const match = this.navigableItems.find(i =>\n i.getLabel().toLowerCase().startsWith(this.typeaheadBuffer)\n );\n if (match) this.setActiveItem(match);\n }\n\n private selectActive() {\n const item = this.navigableItems[this.activeIndex];\n if (!item) return;\n // Multi-select: mirror SelectItemComponent.onClick — toggle and stay open.\n item.selected = !item.selected;\n this.selectService.registerClick(item.value, item.getLabel());\n // syncItems() ran synchronously via the service subscription; keep the\n // same option highlighted (its index may have shifted).\n this.setActiveItem(item);\n }\n\n onTriggerKeyDown(event: KeyboardEvent) {\n if (this.disabled) return;\n const key = event.key;\n\n if (!this.isOpen) {\n if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ' || key === 'Home' || key === 'End') {\n event.preventDefault();\n this.open();\n return;\n }\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.pendingTypeaheadChar = key;\n this.open();\n }\n return;\n }\n\n switch (key) {\n case 'ArrowDown': event.preventDefault(); this.moveActive(1); break;\n case 'ArrowUp': event.preventDefault(); this.moveActive(-1); break;\n case 'Home': event.preventDefault(); this.setActive(0); break;\n case 'End': event.preventDefault(); this.setActive(this.navigableItems.length - 1); break;\n case 'Enter':\n case ' ':\n event.preventDefault();\n this.selectActive();\n break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n case 'Tab':\n this.close();\n break;\n default:\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.typeahead(key);\n }\n }\n }\n\n // Keydown while the (optional) search input is focused. Arrows/Enter/Escape\n // drive the list; other keys fall through so the user can type to filter.\n onSearchKeyDown(event: KeyboardEvent) {\n if (!this.isOpen) return;\n switch (event.key) {\n case 'ArrowDown': event.preventDefault(); this.moveActive(1); break;\n case 'ArrowUp': event.preventDefault(); this.moveActive(-1); break;\n case 'Enter': event.preventDefault(); this.selectActive(); break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n }\n }\n\n ngOnDestroy() {\n document.removeEventListener('mousedown', this._outsideClickHandler);\n if (this.cleanup) this.cleanup();\n }\n\n // ControlValueAccessor\n onChange: any = () => { };\n onTouched: any = () => { };\n writeValue(v: any[]): void {\n this.value = Array.isArray(v) ? v : [];\n this.syncItems();\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"
|
|
20
|
+
"content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n ContentChildren,\n QueryList,\n AfterContentInit,\n ElementRef,\n ViewChild,\n OnChanges,\n OnDestroy,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { computePosition, flip, shift, offset, autoUpdate, size } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { Subscription } from 'rxjs';\nimport { SelectItemComponent } from './select-item.component';\nimport { SelectService } from './select.service';\nimport { cn } from './utils/cn';\nimport { InputComponent } from './input.component';\nimport { BadgeComponent } from './badge.component';\n\nconst multiSelectTriggerVariants = cva(\n 'flex h-auto min-h-10 w-full items-center justify-between rounded-md border border-input bg-background text-sm shadow-sm transition-all duration-200',\n {\n variants: {\n size: {\n xs: 'px-2 py-1',\n sm: 'px-3 py-1.5',\n default: 'px-3 py-2',\n lg: 'px-4 py-2',\n },\n error: {\n true: 'border-destructive',\n false: '',\n },\n disabled: {\n true: 'cursor-not-allowed border-opacity-50 opacity-50',\n false: '',\n },\n },\n compoundVariants: [\n // Only an interactive trigger gets the hover border and the focus ring.\n {\n disabled: false,\n class:\n 'hover:border-accent focus-visible:border-primary/80 focus-visible:shadow-none focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-0',\n },\n // …and an invalid one rings destructive instead. Declared after the rule\n // above so `cn()` resolves the conflict in its favour.\n {\n disabled: false,\n error: true,\n class: 'focus-visible:border-destructive/80 focus-visible:ring-destructive/30',\n },\n ],\n defaultVariants: { size: 'default', error: false, disabled: false },\n }\n);\n\nexport type MultiSelectProps = VariantProps<typeof multiSelectTriggerVariants>;\n\n/** One chip on the trigger, standing for a selected option. */\nexport interface MultiSelectChip {\n label: string;\n value: any;\n}\n\n/**\n * A multi-select listbox: a trigger that shows the current selection as\n * removable chips and opens a floating panel of `tolle-select-item` options.\n * Implements `ControlValueAccessor`, so it works with `ngModel` and reactive\n * forms.\n */\n@Component({\n selector: 'tolle-multi-select',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, FormsModule, BadgeComponent, InputComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [\n SelectService,\n { provide: NG_VALUE_ACCESSOR, useExisting: MultiSelectComponent, multi: true }\n ],\n template: `\n <div class=\"relative w-full\" #container>\n <button\n #trigger\n type=\"button\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-activedescendant]=\"isOpen ? activeDescendantId : null\"\n (click)=\"toggle()\"\n (keydown)=\"onTriggerKeyDown($event)\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\">\n\n <div class=\"flex flex-wrap gap-1 items-center max-w-[95%]\">\n <ng-container *ngIf=\"value.length; else placeholderTpl\">\n <tolle-badge *ngFor=\"let item of displayItems; trackBy: trackChip\" size=\"xs\" variant=\"secondary\" [removable]=\"true\" (onRemove)=\"removeValue($event, item.value)\">\n {{ item.label }}\n </tolle-badge>\n <span *ngIf=\"exceedsDisplayLimit\" class=\"text-xs text-muted-foreground px-1\">\n +{{ value.length - maxDisplayItems }} more\n </span>\n <span *ngIf=\"maxSelections && value.length >= maxSelections\" class=\"text-xs text-muted-foreground px-1\">\n (Max reached)\n </span>\n </ng-container>\n <ng-template #placeholderTpl><span class=\"text-muted-foreground\">{{ placeholder }}</span></ng-template>\n </div>\n <i [class]=\"cn('ri-arrow-down-s-line text-muted-foreground ml-2 transition-transform duration-200', isOpen ? 'rotate-180' : '')\"></i>\n </button>\n\n <div #popover *ngIf=\"isOpen\"\n role=\"listbox\"\n aria-multiselectable=\"true\"\n class=\"fixed bg-popover text-popover-foreground z-[999] rounded-md border border-border shadow-md overflow-hidden\"\n style=\"visibility: hidden; top: 0; left: 0;\">\n\n <div class=\"p-2 border-b border-border space-y-2 bg-popover\">\n <div class=\"flex items-center justify-between px-1 text-xs\">\n <span class=\"text-muted-foreground\">\n {{ value.length }} selected\n <span *ngIf=\"maxSelections\">/ {{ maxSelections }} max</span>\n </span>\n <span *ngIf=\"maxSelections && value.length >= maxSelections\" class=\"text-destructive text-xs font-medium\">\n Maximum reached\n </span>\n </div>\n\n <tolle-input *ngIf=\"searchable\" #searchInput size=\"xs\" placeholder=\"Search...\" [(ngModel)]=\"searchQuery\" (ngModelChange)=\"onSearchChange($event)\" (keydown)=\"onSearchKeyDown($event)\">\n <i prefix class=\"ri-search-line\"></i>\n </tolle-input>\n\n <div class=\"flex items-center justify-between px-1\">\n <button type=\"button\"\n (click)=\"selectAll()\"\n [disabled]=\"!!(maxSelections && selectableItems.length > maxSelections)\"\n [class]=\"cn(\n 'text-[10px] font-bold uppercase transition-colors',\n maxSelections && selectableItems.length > maxSelections\n ? 'text-muted-foreground cursor-not-allowed'\n : 'text-primary hover:underline'\n )\">\n Select All\n </button>\n <button type=\"button\" (click)=\"clearAll()\" class=\"text-[10px] font-bold uppercase text-muted-foreground hover:underline\">\n Clear\n </button>\n </div>\n </div>\n\n <div class=\"p-1 max-h-60 overflow-y-auto\">\n <ng-content></ng-content>\n <div *ngIf=\"noResults\" class=\"py-4 text-center text-xs text-muted-foreground\">\n No results found for \"{{searchQuery}}\"\n </div>\n <div *ngIf=\"maxSelections && value.length >= maxSelections\"\n class=\"p-2 text-center border-t border-border bg-muted/20\">\n <span class=\"text-xs text-destructive\">\n <i class=\"ri-alert-line mr-1\"></i>\n Maximum selection limit reached ({{maxSelections}})\n </span>\n </div>\n </div>\n </div>\n </div>\n `\n})\nexport class MultiSelectComponent\n implements ControlValueAccessor, AfterContentInit, OnChanges, OnDestroy {\n /** Text shown on the trigger when nothing is selected. @default 'Select options...' */\n @Input() placeholder = 'Select options...';\n /** Padding and text size of the trigger. @default 'default' */\n @Input() size: MultiSelectProps['size'] = 'default';\n /** Adds a search box that filters the projected options. @default false */\n @Input() searchable = false;\n /** Disables the control and blocks opening the panel. @default false */\n @Input() disabled = false;\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). @default '' */\n @Input() class = '';\n /** Largest number of values that can be selected at once; leave unset for no cap. */\n @Input() maxSelections?: number;\n /** How many chips to render before collapsing the rest into a \"+N more\" hint. @default 3 */\n @Input() maxDisplayItems = 3;\n /** Applies the destructive border and focus ring. @default false */\n @Input() error = false;\n\n /** Emitted with the full array of selected values whenever the selection changes. */\n @Output() valueChange = new EventEmitter<any[]>();\n /** Emitted when the panel opens. */\n @Output() opened = new EventEmitter<void>();\n /** Emitted when the panel closes. */\n @Output() closed = new EventEmitter<void>();\n\n @ViewChild('trigger') trigger!: ElementRef;\n @ViewChild('popover') popover!: ElementRef;\n @ViewChild('searchInput', { read: ElementRef }) searchInput?: ElementRef<HTMLElement>;\n @ContentChildren(SelectItemComponent, { descendants: true }) items!: QueryList<SelectItemComponent>;\n\n value: any[] = [];\n selectedItems: MultiSelectChip[] = [];\n\n /**\n * The chips actually rendered, built into a field rather than read from a\n * getter. A getter hands `*ngFor` a freshly-allocated array on every\n * change-detection pass; paired with the default identity differ that tore\n * down and rebuilt every badge — including the one under the pointer, whose\n * destruction re-fires its own remove handler. See `trackChip` below, which\n * closes the same hole for the rebuilt chip objects `syncItems()` produces.\n */\n displayItems: MultiSelectChip[] = [];\n exceedsDisplayLimit = false;\n\n isOpen = false;\n searchQuery = '';\n noResults = false;\n private cleanup?: () => void;\n private sub = new Subscription();\n\n // Keyboard navigation (active-descendant) state\n activeIndex = -1;\n private typeaheadBuffer = '';\n private typeaheadTime = 0;\n private pendingTypeaheadChar = '';\n\n constructor(\n private selectService: SelectService,\n private cdr: ChangeDetectorRef\n ) {\n this.sub.add(\n this.selectService.selectedValue$.subscribe(val => {\n if (val !== undefined && this.isOpen) this.toggleValue(val);\n })\n );\n }\n\n get computedTriggerClass() {\n return cn(\n multiSelectTriggerVariants({\n size: this.size,\n error: this.error,\n disabled: this.disabled,\n }),\n this.class\n );\n }\n\n ngAfterContentInit() {\n this.syncItems();\n this.sub.add(this.items.changes.subscribe(() => {\n this.syncItems();\n this.cdr.markForCheck();\n }));\n }\n\n /**\n * `maxDisplayItems` decides how much of the selection is rendered, so the\n * chip list has to be rebuilt when it changes.\n *\n * The `markForCheck()` is not redundant: under OnPush a `[class]` binding is\n * applied through Angular's *styling* path, which writes the input but —\n * unlike a plain property binding — never marks the component dirty, so the\n * trigger would keep rendering the class it was born with.\n */\n ngOnChanges() {\n this.refreshDisplay();\n this.cdr.markForCheck();\n }\n\n /** `*ngFor` identity for the chips — keyed on the option value, not the\n * wrapper object, which `syncItems()` re-allocates on every sync. */\n trackChip = (_index: number, chip: MultiSelectChip): any => chip.value;\n\n private refreshDisplay(): void {\n this.displayItems = this.selectedItems.slice(0, this.maxDisplayItems);\n this.exceedsDisplayLimit = this.value.length > this.maxDisplayItems;\n }\n\n get selectableItems(): SelectItemComponent[] {\n return this.items ? this.items.filter(item => !item.disabled) : [];\n }\n\n get availableSelections(): number {\n if (!this.maxSelections) return Infinity;\n return Math.max(0, this.maxSelections - this.value.length);\n }\n\n private _outsideClickHandler = (event: MouseEvent) => {\n if (!this.trigger.nativeElement.contains(event.target) && !this.popover?.nativeElement.contains(event.target)) {\n this.close();\n }\n };\n\n toggle() {\n if (this.disabled) return;\n this.isOpen ? this.close() : this.open();\n }\n\n open() {\n const wasOpen = this.isOpen;\n this.isOpen = true;\n if (!wasOpen) this.opened.emit();\n this.cdr.markForCheck();\n setTimeout(() => {\n this.updatePosition();\n document.addEventListener('pointerdown', this._outsideClickHandler, true);\n this.initActiveAfterOpen();\n this.cdr.markForCheck();\n });\n }\n\n close() {\n const wasOpen = this.isOpen;\n this.isOpen = false;\n this.searchQuery = '';\n this.onSearchChange('');\n this.resetActive();\n if (this.cleanup) this.cleanup();\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n if (wasOpen) this.closed.emit();\n this.cdr.markForCheck();\n }\n\n private updatePosition() {\n if (!this.trigger || !this.popover) return;\n\n this.cleanup = autoUpdate(this.trigger.nativeElement, this.popover.nativeElement, () => {\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 size({\n apply({ rects, elements, availableHeight }) {\n Object.assign(elements.floating.style, {\n width: rects.reference.width + 'px',\n maxHeight: availableHeight + 'px'\n });\n },\n }),\n ],\n }).then(({ x, y, strategy }) => {\n Object.assign(this.popover.nativeElement.style, {\n position: strategy,\n left: x + 'px',\n top: y + 'px',\n visibility: 'visible',\n });\n });\n });\n }\n\n toggleValue(val: any) {\n const index = this.value.indexOf(val);\n if (index > -1) {\n this.value.splice(index, 1);\n } else {\n if (this.maxSelections && this.value.length >= this.maxSelections) return;\n this.value.push(val);\n }\n this.syncItems();\n this.emitValue();\n }\n\n selectAll() {\n if (!this.items) return;\n let itemsToSelect: any[] = [];\n if (this.maxSelections) {\n const availableItems = this.items\n .filter(item => !item.disabled && !this.value.includes(item.value))\n .slice(0, this.availableSelections)\n .map(item => item.value);\n itemsToSelect = [...this.value, ...availableItems];\n } else {\n itemsToSelect = this.items.filter(item => !item.disabled).map(item => item.value);\n }\n this.value = itemsToSelect;\n this.syncItems();\n this.emitValue();\n }\n\n clearAll() {\n this.value = [];\n this.syncItems();\n this.emitValue();\n }\n\n removeValue(event: MouseEvent, val: any) {\n event.stopPropagation();\n this.toggleValue(val);\n }\n\n private emitValue(): void {\n const snapshot = [...this.value];\n this.onChange(snapshot);\n this.valueChange.emit(snapshot);\n this.cdr.markForCheck();\n }\n\n private syncItems() {\n if (!this.items) return;\n this.selectedItems = [];\n this.items.forEach(item => {\n item.selected = this.value.includes(item.value);\n if (item.selected) {\n this.selectedItems.push({ label: item.getLabel(), value: item.value });\n }\n if (this.maxSelections && this.value.length >= this.maxSelections) {\n item.disabled = !this.value.includes(item.value);\n } else if (item.disabled) {\n item.disabled = false;\n }\n });\n this.refreshDisplay();\n this.cdr.markForCheck();\n }\n\n onSearchChange(q: string) {\n const filter = (q || '').toLowerCase();\n let visibleCount = 0;\n this.items.forEach(item => {\n const isVisible = item.getLabel().toLowerCase().includes(filter);\n item.hidden = !isVisible;\n if (isVisible) visibleCount++;\n });\n this.noResults = visibleCount === 0 && filter !== '';\n\n // Re-anchor the active option to the first visible match while open.\n if (this.isOpen) this.setActive(0);\n this.cdr.markForCheck();\n }\n\n // ---- Keyboard navigation (WAI-ARIA listbox pattern) ----\n\n private get navigableItems(): SelectItemComponent[] {\n return this.items ? this.items.filter(i => !i.hidden && !i.disabled) : [];\n }\n\n get activeDescendantId(): string | null {\n const list = this.navigableItems;\n return this.activeIndex >= 0 && this.activeIndex < list.length\n ? list[this.activeIndex].id\n : null;\n }\n\n private updateActiveStates() {\n const list = this.navigableItems;\n const activeItem = this.activeIndex >= 0 ? list[this.activeIndex] ?? null : null;\n this.items?.forEach(i => (i.active = i === activeItem));\n this.syncActiveDescendant();\n this.cdr.markForCheck();\n }\n\n private syncActiveDescendant() {\n const input = this.searchInput?.nativeElement?.querySelector('input');\n if (!input) return;\n const id = this.activeDescendantId;\n if (id) input.setAttribute('aria-activedescendant', id);\n else input.removeAttribute('aria-activedescendant');\n }\n\n private setActive(index: number) {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n this.activeIndex = Math.max(0, Math.min(index, list.length - 1));\n this.updateActiveStates();\n list[this.activeIndex]?.scrollIntoActiveView();\n }\n\n private setActiveItem(item: SelectItemComponent) {\n const idx = this.navigableItems.indexOf(item);\n if (idx >= 0) this.setActive(idx);\n }\n\n private moveActive(delta: number) {\n const list = this.navigableItems;\n if (!list.length) return;\n const next = this.activeIndex < 0 ? (delta > 0 ? 0 : list.length - 1) : this.activeIndex + delta;\n this.setActive(next); // setActive clamps (no wrap, matching shadcn)\n }\n\n private setActiveToSelectedOrFirst() {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n const selIdx = list.findIndex(i => this.value.includes(i.value));\n this.setActive(selIdx >= 0 ? selIdx : 0);\n }\n\n private initActiveAfterOpen() {\n if (this.pendingTypeaheadChar) {\n const c = this.pendingTypeaheadChar;\n this.pendingTypeaheadChar = '';\n this.setActiveToSelectedOrFirst();\n this.typeahead(c);\n } else {\n this.setActiveToSelectedOrFirst();\n }\n }\n\n private resetActive() {\n this.activeIndex = -1;\n this.typeaheadBuffer = '';\n this.pendingTypeaheadChar = '';\n this.items?.forEach(i => (i.active = false));\n }\n\n private isTypeaheadChar(event: KeyboardEvent): boolean {\n return event.key.length === 1 && event.key !== ' ' && !event.ctrlKey && !event.metaKey && !event.altKey;\n }\n\n private typeahead(char: string) {\n const now = Date.now();\n // Reset the buffer based on elapsed time (correctness is not timer-driven).\n if (now - this.typeaheadTime > 500) this.typeaheadBuffer = '';\n this.typeaheadTime = now;\n this.typeaheadBuffer += char.toLowerCase();\n const match = this.navigableItems.find(i =>\n i.getLabel().toLowerCase().startsWith(this.typeaheadBuffer)\n );\n if (match) this.setActiveItem(match);\n }\n\n private selectActive() {\n const item = this.navigableItems[this.activeIndex];\n if (!item) return;\n // Multi-select: mirror SelectItemComponent.onClick — toggle and stay open.\n item.selected = !item.selected;\n this.selectService.registerClick(item.value, item.getLabel());\n // syncItems() ran synchronously via the service subscription; keep the\n // same option highlighted (its index may have shifted).\n this.setActiveItem(item);\n }\n\n onTriggerKeyDown(event: KeyboardEvent) {\n if (this.disabled) return;\n const key = event.key;\n\n if (!this.isOpen) {\n if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ' || key === 'Home' || key === 'End') {\n event.preventDefault();\n this.open();\n return;\n }\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.pendingTypeaheadChar = key;\n this.open();\n }\n return;\n }\n\n switch (key) {\n case 'ArrowDown': event.preventDefault(); this.moveActive(1); break;\n case 'ArrowUp': event.preventDefault(); this.moveActive(-1); break;\n case 'Home': event.preventDefault(); this.setActive(0); break;\n case 'End': event.preventDefault(); this.setActive(this.navigableItems.length - 1); break;\n case 'Enter':\n case ' ':\n event.preventDefault();\n this.selectActive();\n break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n case 'Tab':\n this.close();\n break;\n default:\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.typeahead(key);\n }\n }\n }\n\n // Keydown while the (optional) search input is focused. Arrows/Enter/Escape\n // drive the list; other keys fall through so the user can type to filter.\n onSearchKeyDown(event: KeyboardEvent) {\n if (!this.isOpen) return;\n switch (event.key) {\n case 'ArrowDown': event.preventDefault(); this.moveActive(1); break;\n case 'ArrowUp': event.preventDefault(); this.moveActive(-1); break;\n case 'Enter': event.preventDefault(); this.selectActive(); break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n }\n }\n\n ngOnDestroy() {\n this.sub.unsubscribe();\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n if (this.cleanup) this.cleanup();\n }\n\n // ControlValueAccessor\n onChange: any = () => { };\n onTouched: any = () => { };\n writeValue(v: any[]): void {\n this.value = Array.isArray(v) ? v : [];\n this.syncItems();\n this.refreshDisplay();\n this.cdr.markForCheck();\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"
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
"path": "select.service.ts",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "native-select",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Native Select",
|
|
5
|
+
"description": "Native Select component with size and invalid options.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "native-select.component.ts",
|
|
14
|
+
"target": "ui/native-select.component.ts",
|
|
15
|
+
"content": "import { Component, Input, Output, EventEmitter, forwardRef, ChangeDetectorRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst nativeSelectVariants = cva(\n 'flex w-full appearance-none items-center 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 pl-2 pr-7 text-xs',\n sm: 'h-9 pl-3 pr-8 text-sm',\n default: 'h-10 pl-3 pr-8 text-sm',\n lg: 'h-11 pl-4 pr-9 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\nexport type NativeSelectProps = VariantProps<typeof nativeSelectVariants>;\n\nexport type NativeSelectOption = {\n label: string;\n value: any;\n disabled?: boolean;\n};\n\n/**\n * A styled native `<select>`. Unlike `tolle-select` it uses the platform\n * dropdown, so it needs no portal or floating-ui and works well on mobile and\n * inside forms that must degrade without JavaScript.\n *\n * Options come from `options`, or from projected `<option>` elements when you\n * need `<optgroup>` or custom markup.\n * @new\n */\n@Component({\n selector: 'tolle-native-select',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => NativeSelectComponent),\n multi: true,\n },\n ],\n template: `\n <div class=\"relative w-full\">\n <select\n [id]=\"id\"\n [disabled]=\"disabled\"\n [attr.aria-invalid]=\"invalid || null\"\n [attr.aria-label]=\"ariaLabel || null\"\n [class]=\"computedClass\"\n (change)=\"onSelectChange($event)\"\n (blur)=\"onTouched()\"\n >\n <option *ngIf=\"placeholder\" value=\"\" [selected]=\"value === null || value === undefined || value === ''\" disabled>\n {{ placeholder }}\n </option>\n <!--\n The DOM value is the option's INDEX, not its value. A select element\n coerces every option value to a string, which collapses all object\n options to \"[object Object]\" and makes them indistinguishable on the\n way back out. Indexing keeps object- and number-valued options\n round-tripping exactly.\n -->\n <option\n *ngFor=\"let option of options; let i = index\"\n [value]=\"i\"\n [disabled]=\"option.disabled || false\"\n [selected]=\"option.value === value\"\n >\n {{ option.label }}\n </option>\n <ng-content></ng-content>\n </select>\n\n <i\n class=\"ri-arrow-down-s-line pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground\"\n aria-hidden=\"true\"\n ></i>\n </div>\n `,\n})\nexport class NativeSelectComponent implements ControlValueAccessor {\n /** Id applied to the underlying `<select>`; pair it with a `tolle-label`. */\n @Input() id = `native-select-${Math.random().toString(36).slice(2, 11)}`;\n /** Options to render. Omit and project `<option>` elements for custom markup. @default [] */\n @Input() options: NativeSelectOption[] = [];\n /** Non-selectable prompt shown when no value is set. @default '' */\n @Input() placeholder = '';\n /** Height and text size of the control. @default 'default' */\n @Input() size: NativeSelectProps['size'] = 'default';\n /** Applies the destructive border and sets `aria-invalid`. @default false */\n @Input() invalid = false;\n /** Disables the control. @default false */\n @Input() disabled = false;\n /** Accessible name when there is no associated visible label. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the `<select>` via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the newly selected value whenever the user picks an option. */\n @Output() valueChange = new EventEmitter<any>();\n\n value: any = null;\n onChange: (value: any) => void = () => {};\n onTouched: () => void = () => {};\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n get computedClass() {\n return cn(nativeSelectVariants({ size: this.size, invalid: this.invalid }), this.class);\n }\n\n onSelectChange(event: Event): void {\n const raw = (event.target as HTMLSelectElement).value;\n\n // Options rendered from `options` carry their index as the DOM value, so we\n // can hand back the ORIGINAL value — object, number or string — untouched.\n // Anything else came from a projected <option>, where the string is all\n // there is.\n const index = Number(raw);\n const fromOptions = raw !== '' && Number.isInteger(index) && index >= 0 && index < this.options.length;\n const next = fromOptions ? this.options[index].value : raw;\n\n this.value = next;\n this.onChange(next);\n this.valueChange.emit(next);\n }\n\n writeValue(value: any): void {\n this.value = value;\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: (value: any) => void): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: () => void): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\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,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "navigation-menu",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Navigation Menu",
|
|
5
|
+
"description": "Navigation Menu component with align options.",
|
|
6
|
+
"category": "navigation",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"@floating-ui/dom",
|
|
10
|
+
"class-variance-authority"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "navigation-menu.component.ts",
|
|
15
|
+
"target": "ui/navigation-menu.component.ts",
|
|
16
|
+
"content": "import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, Injectable, Input, OnDestroy, OnInit, Output, ViewChild, inject, OnChanges } from '@angular/core';\nimport { BehaviorSubject, Subscription } from 'rxjs';\nimport { computePosition, flip, shift, offset, size, autoUpdate } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nlet navigationMenuItemCounter = 0;\n\n/**\n * Coordinates the items of a single `tolle-navigation-menu`. Provided by\n * `NavigationMenuComponent`, injected by its descendants.\n *\n * Closing is deferred by `closeDelay` so a pointer travelling diagonally from a\n * trigger into its panel does not dismiss the menu mid-flight; re-entering\n * either surface calls `cancelClose()` and the pending close is dropped.\n */\n@Injectable()\nexport class NavigationMenuService {\n private openIdSource = new BehaviorSubject<string | null>(null);\n /** Emits the id of the currently open item, or `null` when all are closed. */\n readonly openId$ = this.openIdSource.asObservable();\n\n /** Milliseconds to wait before closing after the pointer leaves. */\n closeDelay = 150;\n\n private closeTimer: ReturnType<typeof setTimeout> | undefined;\n\n /** Id of the open item, or `null`. */\n get openId(): string | null {\n return this.openIdSource.value;\n }\n\n /** True while any panel is open. */\n get isAnyOpen(): boolean {\n return this.openIdSource.value !== null;\n }\n\n /** True while a deferred close is pending — exposed for tests/diagnostics. */\n get isCloseScheduled(): boolean {\n return this.closeTimer !== undefined;\n }\n\n isOpen(id: string): boolean {\n return this.openId === id;\n }\n\n /** Opens an item immediately and cancels any pending close. */\n open(id: string): void {\n this.cancelClose();\n if (this.openId !== id) this.openIdSource.next(id);\n }\n\n /** Closes immediately, dropping any pending close. */\n closeNow(): void {\n this.cancelClose();\n if (this.openId !== null) this.openIdSource.next(null);\n }\n\n /** Queues a close in `closeDelay` ms; a later `open`/`cancelClose` wins. */\n scheduleClose(): void {\n this.cancelClose();\n this.closeTimer = setTimeout(() => {\n this.closeTimer = undefined;\n this.closeNow();\n }, this.closeDelay);\n }\n\n /** Drops a pending close (pointer came back). */\n cancelClose(): void {\n if (this.closeTimer !== undefined) {\n clearTimeout(this.closeTimer);\n this.closeTimer = undefined;\n }\n }\n\n toggle(id: string): void {\n if (this.openId === id) this.closeNow();\n else this.open(id);\n }\n}\n\nconst navigationMenuVariants = cva('relative z-10 flex max-w-max flex-1 items-center justify-center', {\n variants: {\n align: {\n start: 'justify-start',\n center: 'justify-center',\n end: 'justify-end',\n },\n },\n defaultVariants: {\n align: 'center',\n },\n});\n\nexport type NavigationMenuProps = VariantProps<typeof navigationMenuVariants>;\n\nconst navigationMenuTriggerVariants = cva(\n 'group inline-flex w-max select-none items-center justify-center gap-1 rounded-md bg-background font-medium transition-colors ' +\n 'hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none ' +\n 'data-[state=open]:bg-accent/50 data-[state=open]:text-accent-foreground ' +\n 'disabled:pointer-events-none disabled:opacity-50',\n {\n variants: {\n size: {\n sm: 'h-8 px-3 text-xs',\n default: 'h-9 px-4 py-2 text-sm',\n lg: 'h-10 px-5 text-sm',\n },\n },\n defaultVariants: {\n size: 'default',\n },\n }\n);\n\nexport type NavigationMenuTriggerProps = VariantProps<typeof navigationMenuTriggerVariants>;\n\nconst navigationMenuLinkVariants = cva(\n 'flex select-none flex-col gap-1 rounded-md p-3 leading-none no-underline outline-none transition-colors ' +\n 'hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground',\n {\n variants: {\n variant: {\n default: 'text-foreground',\n muted: 'text-muted-foreground hover:text-accent-foreground',\n },\n active: {\n true: 'bg-accent text-accent-foreground',\n false: '',\n },\n },\n defaultVariants: {\n variant: 'default',\n active: false,\n },\n }\n);\n\nexport type NavigationMenuLinkProps = VariantProps<typeof navigationMenuLinkVariants>;\n\n/**\n * Site navigation menu: a row of links and triggers, where each trigger reveals\n * a floating panel on hover, focus or click.\n *\n * ```html\n * <tolle-navigation-menu>\n * <tolle-navigation-menu-list>\n * <tolle-navigation-menu-item>\n * <tolle-navigation-menu-trigger>Products</tolle-navigation-menu-trigger>\n * <tolle-navigation-menu-content class=\"w-[420px]\">\n * <tolle-navigation-menu-link href=\"/analytics\">Analytics</tolle-navigation-menu-link>\n * </tolle-navigation-menu-content>\n * </tolle-navigation-menu-item>\n * </tolle-navigation-menu-list>\n * </tolle-navigation-menu>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-navigation-menu',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n providers: [NavigationMenuService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <nav [attr.aria-label]=\"ariaLabel\" [class]=\"computedClass\" (keydown)=\"onKeydown($event)\">\n <ng-content></ng-content>\n </nav>\n `,\n})\nexport class NavigationMenuComponent implements OnChanges, OnInit, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Horizontal alignment of the list within the nav. @default 'center' */\n @Input() align: NavigationMenuProps['align'] = 'center';\n /** Accessible name for the `<nav>` landmark. @default 'Main' */\n @Input() ariaLabel = 'Main';\n /**\n * Milliseconds before a panel closes after the pointer leaves — the grace\n * period that lets the pointer travel diagonally into the panel. @default 150\n */\n @Input() set closeDelay(value: number) {\n this.service.closeDelay = value;\n }\n get closeDelay(): number {\n return this.service.closeDelay;\n }\n /** Extra Tailwind classes merged onto the `<nav>` via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the id of the item that opened, or `null` when the menu closes. */\n @Output() openChange = new EventEmitter<string | null>();\n\n private readonly service = inject(NavigationMenuService);\n private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);\n private readonly sub = new Subscription();\n private listening = false;\n\n protected get computedClass(): string {\n return cn(navigationMenuVariants({ align: this.align }), this.class);\n }\n\n ngOnInit(): void {\n this.sub.add(\n this.service.openId$.subscribe(id => {\n this.openChange.emit(id);\n if (id !== null) this.startListening();\n else this.stopListening();\n })\n );\n }\n\n protected onKeydown(event: KeyboardEvent): void {\n if (event.key === 'Escape' && this.service.isAnyOpen) {\n event.preventDefault();\n this.service.closeNow();\n }\n }\n\n // Capture phase so containers that stopPropagation() on their root cannot\n // prevent the outside click from reaching us.\n private outsideClick = (event: Event) => {\n if (!this.host.nativeElement.contains(event.target as Node)) this.service.closeNow();\n };\n\n private startListening(): void {\n if (this.listening) return;\n this.listening = true;\n document.addEventListener('pointerdown', this.outsideClick, true);\n }\n\n private stopListening(): void {\n if (!this.listening) return;\n this.listening = false;\n document.removeEventListener('pointerdown', this.outsideClick, true);\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n this.stopListening();\n this.service.cancelClose();\n }\n}\n\n/** The horizontal list that holds the navigation items. */\n@Component({\n selector: 'tolle-navigation-menu-list',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <ul [class]=\"cn('group flex flex-1 list-none items-center justify-center gap-1', class)\">\n <ng-content></ng-content>\n </ul>\n `,\n})\nexport class NavigationMenuListComponent 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 `<ul>` via `cn()` (last-wins). */\n @Input() class = '';\n protected readonly cn = cn;\n}\n\n/** One entry in the list — a plain link, or a trigger plus its content panel. */\n@Component({\n selector: 'tolle-navigation-menu-item',\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n host: { '[class]': \"'contents'\" },\n template: `\n <li [class]=\"cn('relative', class)\">\n <ng-content></ng-content>\n </li>\n `,\n})\nexport class NavigationMenuItemComponent implements OnChanges, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Explicit id for the item; one is generated when omitted. */\n @Input() id = `tolle-navigation-menu-item-${navigationMenuItemCounter++}`;\n /** Extra Tailwind classes merged onto the `<li>` via `cn()` (last-wins). */\n @Input() class = '';\n\n readonly service = inject(NavigationMenuService);\n protected readonly cn = cn;\n\n triggerElement: HTMLElement | null = null;\n\n get triggerId(): string {\n return `${this.id}-trigger`;\n }\n\n get contentId(): string {\n return `${this.id}-content`;\n }\n\n get isOpen(): boolean {\n return this.service.isOpen(this.id);\n }\n\n ngOnDestroy(): void {\n if (this.isOpen) this.service.closeNow();\n }\n}\n\n/** Button that reveals its item's panel on hover, focus or click. */\n@Component({\n selector: 'tolle-navigation-menu-trigger',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n #trigger\n type=\"button\"\n [id]=\"item.triggerId\"\n [disabled]=\"disabled\"\n aria-haspopup=\"true\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? item.contentId : null\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n [class]=\"computedClass\"\n (click)=\"onClick()\"\n (mouseenter)=\"onPointerEnter()\"\n (mouseleave)=\"onPointerLeave()\"\n (focus)=\"onPointerEnter()\"\n (keydown)=\"onKeydown($event)\"\n >\n <ng-content></ng-content>\n <i\n class=\"ri-arrow-down-s-line text-[16px] transition-transform duration-200\"\n [class.rotate-180]=\"isOpen\"\n aria-hidden=\"true\"\n ></i>\n </button>\n `,\n})\nexport class NavigationMenuTriggerComponent implements OnChanges, 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 /** Prevents the panel from opening. @default false */\n @Input() disabled = false;\n /** Height/padding scale of the trigger. @default 'default' */\n @Input() size: NavigationMenuTriggerProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n protected readonly item = inject(NavigationMenuItemComponent);\n private readonly service = inject(NavigationMenuService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub: Subscription;\n\n constructor() {\n this.sub = this.service.openId$.subscribe(() => this.cdr.markForCheck());\n }\n\n @ViewChild('trigger') set triggerRef(ref: ElementRef<HTMLButtonElement> | undefined) {\n this.item.triggerElement = ref?.nativeElement ?? null;\n }\n\n protected get isOpen(): boolean {\n return this.item.isOpen;\n }\n\n protected get computedClass(): string {\n return cn(navigationMenuTriggerVariants({ size: this.size }), this.class);\n }\n\n protected onClick(): void {\n if (this.disabled) return;\n this.service.toggle(this.item.id);\n }\n\n protected onPointerEnter(): void {\n if (this.disabled) return;\n this.service.open(this.item.id);\n }\n\n protected onPointerLeave(): void {\n this.service.scheduleClose();\n }\n\n protected onKeydown(event: KeyboardEvent): void {\n if (this.disabled) return;\n if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n this.service.open(this.item.id);\n }\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n}\n\n/** The floating panel revealed by a trigger. Rendered only while open. */\n@Component({\n selector: 'tolle-navigation-menu-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @if (isOpen) {\n <div\n #panel\n [id]=\"item.contentId\"\n [attr.aria-labelledby]=\"item.triggerId\"\n data-state=\"open\"\n [class]=\"computedClass\"\n style=\"position: fixed; top: 0; left: 0;\"\n (mouseenter)=\"onPointerEnter()\"\n (mouseleave)=\"onPointerLeave()\"\n >\n <ng-content></ng-content>\n </div>\n }\n `,\n})\nexport class NavigationMenuContentComponent implements OnChanges, 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 /** Preferred side of the trigger to render on; flips when space is tight. @default 'bottom' */\n @Input() placement: 'bottom' | 'bottom-start' | 'bottom-end' | 'top' | 'top-start' | 'top-end' =\n 'bottom';\n /** Gap in pixels between the trigger and the panel. @default 8 */\n @Input() sideOffset = 8;\n /** Extra Tailwind classes merged onto the panel via `cn()` (last-wins). */\n @Input() class = '';\n\n protected readonly item = inject(NavigationMenuItemComponent);\n private readonly service = inject(NavigationMenuService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub: Subscription;\n private cleanup?: () => void;\n\n constructor() {\n this.sub = this.service.openId$.subscribe(() => this.cdr.markForCheck());\n }\n\n @ViewChild('panel') set panelRef(ref: ElementRef<HTMLElement> | undefined) {\n this.stopPositioning();\n if (ref?.nativeElement) this.startPositioning(ref.nativeElement);\n }\n\n protected get isOpen(): boolean {\n return this.item.isOpen;\n }\n\n protected get computedClass(): string {\n return cn(\n 'z-[1000] overflow-auto rounded-md border border-border bg-popover p-2 text-popover-foreground',\n 'shadow-lg animate-in fade-in zoom-in-95',\n this.class\n );\n }\n\n protected onPointerEnter(): void {\n this.service.cancelClose();\n }\n\n protected onPointerLeave(): void {\n this.service.scheduleClose();\n }\n\n private startPositioning(panel: HTMLElement): void {\n const trigger = this.item.triggerElement;\n if (!trigger) return;\n this.cleanup = autoUpdate(trigger, panel, () => {\n computePosition(trigger, panel, {\n strategy: 'fixed',\n placement: this.placement,\n middleware: [\n offset(this.sideOffset),\n flip(),\n shift({ padding: 8 }),\n // Wide panels are common here — clamp to what the viewport can show\n // instead of letting them overflow.\n size({\n padding: 8,\n apply({ elements, availableWidth, availableHeight }) {\n Object.assign(elements.floating.style, {\n maxWidth: `${Math.max(0, availableWidth)}px`,\n maxHeight: `${Math.max(0, availableHeight)}px`,\n });\n },\n }),\n ],\n }).then(({ x, y }) => {\n Object.assign(panel.style, { left: `${x}px`, top: `${y}px` });\n });\n });\n }\n\n private stopPositioning(): void {\n this.cleanup?.();\n this.cleanup = undefined;\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n this.stopPositioning();\n }\n}\n\n/** A link inside a panel (or directly in the list, for flat entries). */\n@Component({\n selector: 'tolle-navigation-menu-link',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <a\n [attr.href]=\"href || null\"\n [attr.target]=\"target || null\"\n [attr.rel]=\"target === '_blank' ? 'noreferrer noopener' : null\"\n [attr.aria-current]=\"active ? 'page' : null\"\n [attr.data-active]=\"active ? '' : null\"\n [class]=\"computedClass\"\n (click)=\"navigate.emit($event)\"\n >\n <ng-content select=\"[label]\"></ng-content>\n <ng-content></ng-content>\n <ng-content select=\"[description]\"></ng-content>\n </a>\n `,\n})\nexport class NavigationMenuLinkComponent 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 /** Destination URL. Omit to render a non-navigating item. */\n @Input() href = '';\n /** Anchor target; `_blank` also sets a safe `rel`. */\n @Input() target = '';\n /** Marks the link as the current page. @default false */\n @Input() active = false;\n /** Visual style of the link. @default 'default' */\n @Input() variant: NavigationMenuLinkProps['variant'] = 'default';\n /** Extra Tailwind classes merged onto the anchor via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the click event when the link is activated. */\n @Output() navigate = new EventEmitter<MouseEvent>();\n\n protected get computedClass(): string {\n return cn(\n navigationMenuLinkVariants({ variant: this.variant, active: this.active }),\n this.class\n );\n }\n}\n"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "utils/cn.ts",
|
|
20
|
+
"target": "ui/utils/cn.ts",
|
|
21
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"path": "otp-group.component.ts",
|
|
12
12
|
"target": "ui/otp-group.component.ts",
|
|
13
|
-
"content": "import { Component } from '@angular/core';\n\n@Component({\n selector: 'tolle-otp-group',\n standalone: true,\n imports: [],\n template: `\n <div class=\"flex items-center\"><ng-content></ng-content></div>\n `,\n styles:
|
|
13
|
+
"content": "import { Component } from '@angular/core';\n\n@Component({\n selector: 'tolle-otp-group',\n standalone: true,\n imports: [],\n template: `\n <div class=\"flex items-center\"><ng-content></ng-content></div>\n `,\n styles: `:host { display: block; }`\n})\nexport class OtpGroupComponent {\n\n}\n"
|
|
14
14
|
}
|
|
15
15
|
]
|
|
16
16
|
}
|
package/registry/r/otp-slot.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"path": "otp-slot.component.ts",
|
|
12
12
|
"target": "ui/otp-slot.component.ts",
|
|
13
|
-
"content": "import { Component, Input } from '@angular/core';\nimport { cn } from \"./utils/cn\";\nimport { NgIf } from '@angular/common';\n\n@Component({\n selector: 'tolle-otp-slot',\n standalone: true,\n imports: [NgIf],\n template: `\n <div [class]=\"computedClass\">\n <span class=\"text-lg font-medium\">{{ char || '' }}</span>\n <div *ngIf=\"isActive && !char\" class=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n <div class=\"h-6 w-0.5 animate-caret-blink bg-foreground\"></div>\n </div>\n </div>\n `\n})\nexport class OtpSlotComponent {\n @Input() char: string | undefined = '';\n @Input() isActive: boolean = false;\n @Input() isFirst: boolean = false;\n @Input() isLast: boolean = false;\n @Input() class: string = '';\n protected cn = cn;\n\n get computedClass() {\n return cn(\n // Base styles (matching input container)\n 'relative flex h-10 w-10 items-center justify-center',\n 'transition-all duration-200',\n 'text-center font-medium select-none',\n\n // Border styling - exactly like input\n 'border border-input shadow-sm bg-background',\n\n // FOCUS STYLING - EXACTLY LIKE INPUT COMPONENT\n this.isActive && [\n // Darker border on focus\n 'border-primary/80',\n // Remove shadow on focus\n 'shadow-none',\n // Focus ring with same styling as input\n 'ring-4',\n 'ring-ring/30',\n 'ring-offset-0',\n // Ensure it's above other slots\n 'z-10'\n ],\n\n // Filled state\n this.char && !this.isActive && 'border-primary/60',\n\n // Border radius\n this.isFirst && 'rounded-l-md',\n this.isLast && 'rounded-r-md',\n\n // Remove left border for all but first slot\n !this.isFirst && '-ml-px',\n\n this.class\n );\n }\n}\n"
|
|
13
|
+
"content": "import { Component, Input } from '@angular/core';\nimport { cn } from \"./utils/cn\";\nimport { NgIf } from '@angular/common';\n\n@Component({\n selector: 'tolle-otp-slot',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [NgIf],\n template: `\n <div [class]=\"computedClass\">\n <span class=\"text-lg font-medium\">{{ char || '' }}</span>\n <div *ngIf=\"isActive && !char\" class=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n <div class=\"h-6 w-0.5 animate-caret-blink bg-foreground\"></div>\n </div>\n </div>\n `\n})\nexport class OtpSlotComponent {\n @Input() char: string | undefined = '';\n @Input() isActive: boolean = false;\n @Input() isFirst: boolean = false;\n @Input() isLast: boolean = false;\n @Input() class: string = '';\n protected cn = cn;\n\n get computedClass() {\n return cn(\n // Base styles (matching input container)\n 'relative flex h-10 w-10 items-center justify-center',\n 'transition-all duration-200',\n 'text-center font-medium select-none',\n\n // Border styling - exactly like input\n 'border border-input shadow-sm bg-background',\n\n // FOCUS STYLING - EXACTLY LIKE INPUT COMPONENT\n this.isActive && [\n // Darker border on focus\n 'border-primary/80',\n // Remove shadow on focus\n 'shadow-none',\n // Focus ring with same styling as input\n 'ring-4',\n 'ring-ring/30',\n 'ring-offset-0',\n // Ensure it's above other slots\n 'z-10'\n ],\n\n // Filled state\n this.char && !this.isActive && 'border-primary/60',\n\n // Border radius\n this.isFirst && 'rounded-l-md',\n this.isLast && 'rounded-r-md',\n\n // Remove left border for all but first slot\n !this.isFirst && '-ml-px',\n\n this.class\n );\n }\n}\n"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"path": "utils/cn.ts",
|
package/registry/r/otp.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"path": "otp.component.ts",
|
|
12
12
|
"target": "ui/otp.component.ts",
|
|
13
|
-
"content": "import { Component, Input, forwardRef, ChangeDetectorRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-otp',\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [{\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => OtpComponent),\n multi: true\n }],\n template: `\n <div class=\"relative flex items-center gap-2\">\n <input\n #hiddenInput\n type=\"text\"\n inputmode=\"numeric\"\n autocomplete=\"one-time-code\"\n aria-label=\"One-time code\"\n [maxLength]=\"length\"\n class=\"absolute inset-0 opacity-0 cursor-default z-20\"\n [(ngModel)]=\"value\"\n (input)=\"onInputChange($event)\"\n (focus)=\"isFocused = true\"\n (blur)=\"isFocused = false; onTouched()\"\n [disabled]=\"disabled\"\n />\n\n <div *ngIf=\"auto\" class=\"flex items-center gap-2 pointer-events-none\">\n <div class=\"flex items-center\">\n <div *ngFor=\"let _ of slots; let i = index\"\n [class]=\"getSlotClass(i)\"\n >\n {{ value[i] || '' }}\n <div *ngIf=\"isFocused && value.length === i\" class=\"absolute inset-0 flex items-center justify-center\">\n <div class=\"h-4 w-px animate-caret-blink bg-foreground duration-1000\"></div>\n </div>\n </div>\n </div>\n </div>\n\n <div *ngIf=\"!auto\" class=\"flex items-center gap-2 pointer-events-none\">\n <ng-content></ng-content>\n </div>\n </div>\n `\n})\nexport class OtpComponent implements ControlValueAccessor {\n @Input()\n set length(val: number) {\n const numericVal = Number(val);\n this._length = numericVal;\n this.slots = Array(numericVal).fill(0);\n }\n get length() { return this._length; }\n private _length = 6;\n\n @Input() disabled = false;\n @Input() auto = false;\n\n protected slots: number[] = Array(6).fill(0);\n\n value: string = '';\n isFocused = false;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n constructor(private cdr: ChangeDetectorRef) { }\n\n onInputChange(event: any) {\n const val = event.target.value.replace(/\\D/g, '');\n this.value = val;\n this.onChange(val);\n this.cdr.markForCheck();\n }\n\n // Visual logic for the automated slots\n getSlotClass(index: number) {\n const isActive = this.isFocused && this.value.length === index;\n\n return cn(\n 'relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all bg-background',\n index === 0 && 'rounded-l-md border-l',\n index === this.length - 1 && 'rounded-r-md',\n isActive && 'z-30 ring-2 ring-ring ring-offset-background'\n );\n }\n\n writeValue(value: any): void { this.value = value || ''; this.cdr.markForCheck(); }\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; this.cdr.markForCheck(); }\n}\n"
|
|
13
|
+
"content": "import { Component, Input, forwardRef, ChangeDetectorRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-otp',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [{\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => OtpComponent),\n multi: true\n }],\n template: `\n <div class=\"relative flex items-center gap-2\">\n <input\n #hiddenInput\n type=\"text\"\n inputmode=\"numeric\"\n autocomplete=\"one-time-code\"\n aria-label=\"One-time code\"\n [maxLength]=\"length\"\n class=\"absolute inset-0 opacity-0 cursor-default z-20\"\n [(ngModel)]=\"value\"\n (input)=\"onInputChange($event)\"\n (focus)=\"isFocused = true\"\n (blur)=\"isFocused = false; onTouched()\"\n [disabled]=\"disabled\"\n />\n\n <div *ngIf=\"auto\" class=\"flex items-center gap-2 pointer-events-none\">\n <div class=\"flex items-center\">\n <div *ngFor=\"let _ of slots; let i = index\"\n [class]=\"getSlotClass(i)\"\n >\n {{ value[i] || '' }}\n <div *ngIf=\"isFocused && value.length === i\" class=\"absolute inset-0 flex items-center justify-center\">\n <div class=\"h-4 w-px animate-caret-blink bg-foreground duration-1000\"></div>\n </div>\n </div>\n </div>\n </div>\n\n <div *ngIf=\"!auto\" class=\"flex items-center gap-2 pointer-events-none\">\n <ng-content></ng-content>\n </div>\n </div>\n `\n})\nexport class OtpComponent implements ControlValueAccessor {\n @Input()\n set length(val: number) {\n const numericVal = Number(val);\n this._length = numericVal;\n this.slots = Array(numericVal).fill(0);\n }\n get length() { return this._length; }\n private _length = 6;\n\n @Input() disabled = false;\n @Input() auto = false;\n\n protected slots: number[] = Array(6).fill(0);\n\n value: string = '';\n isFocused = false;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n constructor(private cdr: ChangeDetectorRef) { }\n\n onInputChange(event: any) {\n const val = event.target.value.replace(/\\D/g, '');\n this.value = val;\n this.onChange(val);\n this.cdr.markForCheck();\n }\n\n // Visual logic for the automated slots\n getSlotClass(index: number) {\n const isActive = this.isFocused && this.value.length === index;\n\n return cn(\n 'relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all bg-background',\n index === 0 && 'rounded-l-md border-l',\n index === this.length - 1 && 'rounded-r-md',\n isActive && 'z-30 ring-2 ring-ring ring-offset-background'\n );\n }\n\n writeValue(value: any): void { this.value = value || ''; this.cdr.markForCheck(); }\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; this.cdr.markForCheck(); }\n}\n"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"path": "utils/cn.ts",
|