@tolle_/tolle-ui 21.1.2 → 21.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/tolle-ui.mjs +30099 -5285
- package/fesm2022/tolle-ui.mjs.map +1 -1
- package/package.json +17 -5
- package/preset.js +35 -1
- package/registry/docs-content.json +11486 -0
- package/registry/llms-full.txt +4067 -0
- package/registry/llms.txt +127 -0
- package/registry/manifest.json +12656 -0
- package/registry/r/accordion-item.json +21 -0
- package/registry/r/accordion.json +23 -0
- package/registry/r/alert-dialog-dynamic.json +31 -0
- package/registry/r/alert-dialog.json +26 -0
- package/registry/r/alert.json +23 -0
- package/registry/r/aspect-ratio.json +21 -0
- package/registry/r/attachment.json +23 -0
- package/registry/r/avatar-fallback.json +16 -0
- package/registry/r/avatar.json +23 -0
- package/registry/r/badge.json +23 -0
- package/registry/r/bar-list.json +21 -0
- package/registry/r/breadcrumb-item.json +16 -0
- package/registry/r/breadcrumb-link.json +16 -0
- package/registry/r/breadcrumb-separator.json +16 -0
- package/registry/r/breadcrumb.json +21 -0
- package/registry/r/bubble.json +23 -0
- package/registry/r/button-group.json +21 -0
- package/registry/r/button.json +23 -0
- package/registry/r/calendar.json +23 -0
- package/registry/r/card.json +21 -0
- package/registry/r/carousel.json +18 -0
- package/registry/r/category-bar.json +21 -0
- package/registry/r/chain-of-thought.json +23 -0
- package/registry/r/chart-pie.json +30 -0
- package/registry/r/chart-spark.json +30 -0
- package/registry/r/chart.json +28 -0
- package/registry/r/checkbox.json +23 -0
- package/registry/r/checkpoint.json +23 -0
- package/registry/r/collapsible.json +21 -0
- package/registry/r/combobox.json +31 -0
- package/registry/r/command-dialog.json +21 -0
- package/registry/r/command.json +26 -0
- package/registry/r/confirmation.json +23 -0
- package/registry/r/context-menu-trigger.json +23 -0
- package/registry/r/context-menu.json +28 -0
- package/registry/r/context.json +26 -0
- package/registry/r/conversation.json +23 -0
- package/registry/r/country-selector.json +31 -0
- package/registry/r/data-table.json +38 -0
- package/registry/r/date-picker.json +27 -0
- package/registry/r/date-range-picker.json +32 -0
- package/registry/r/date-time-picker.json +28 -0
- package/registry/r/direction.json +16 -0
- package/registry/r/dropdown-item.json +21 -0
- package/registry/r/dropdown-label.json +16 -0
- package/registry/r/dropdown-menu.json +16 -0
- package/registry/r/dropdown-separator.json +16 -0
- package/registry/r/dropdown-trigger.json +20 -0
- package/registry/r/empty-state.json +23 -0
- package/registry/r/field.json +23 -0
- package/registry/r/hover-card.json +23 -0
- package/registry/r/inline-citation.json +26 -0
- package/registry/r/input-group.json +23 -0
- package/registry/r/input.json +21 -0
- package/registry/r/item.json +23 -0
- package/registry/r/kbd.json +23 -0
- package/registry/r/label.json +21 -0
- package/registry/r/marker.json +23 -0
- package/registry/r/masked-input.json +21 -0
- package/registry/r/menubar.json +24 -0
- package/registry/r/message-scroller.json +26 -0
- package/registry/r/message.json +23 -0
- package/registry/r/modal.json +36 -0
- package/registry/r/model-selector.json +32 -0
- package/registry/r/multi-select.json +33 -0
- package/registry/r/native-select.json +23 -0
- package/registry/r/navigation-menu.json +24 -0
- package/registry/r/otp-group.json +16 -0
- package/registry/r/otp-slot.json +21 -0
- package/registry/r/otp.json +21 -0
- package/registry/r/pagination.json +33 -0
- package/registry/r/phone-number-input.json +34 -0
- package/registry/r/plan.json +23 -0
- package/registry/r/popover-content.json +21 -0
- package/registry/r/popover.json +18 -0
- package/registry/r/progress-circle.json +21 -0
- package/registry/r/progress.json +21 -0
- package/registry/r/prompt-input.json +23 -0
- package/registry/r/queue.json +23 -0
- package/registry/r/radio-group.json +26 -0
- package/registry/r/radio-item.json +26 -0
- package/registry/r/range-calendar.json +28 -0
- package/registry/r/reasoning.json +23 -0
- package/registry/r/resizable-panel-item.json +23 -0
- package/registry/r/resizable-panel.json +23 -0
- package/registry/r/resizable.json +21 -0
- package/registry/r/scroll-area.json +21 -0
- package/registry/r/segment.json +21 -0
- package/registry/r/select-group.json +21 -0
- package/registry/r/select-item.json +26 -0
- package/registry/r/select-separator.json +21 -0
- package/registry/r/select.json +32 -0
- package/registry/r/separator.json +21 -0
- package/registry/r/sheet-wrapper.json +31 -0
- package/registry/r/sheet.json +21 -0
- package/registry/r/shimmer.json +23 -0
- package/registry/r/sidebar.json +21 -0
- package/registry/r/skeleton.json +23 -0
- package/registry/r/slider.json +21 -0
- 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 +23 -0
- package/registry/r/table.json +21 -0
- package/registry/r/tabs.json +23 -0
- package/registry/r/tag-input.json +25 -0
- package/registry/r/task.json +23 -0
- package/registry/r/textarea.json +21 -0
- package/registry/r/time-picker.json +24 -0
- package/registry/r/toaster.json +26 -0
- package/registry/r/toggle-group.json +25 -0
- package/registry/r/toggle.json +23 -0
- package/registry/r/tolle-cell.json +16 -0
- package/registry/r/tool.json +23 -0
- package/registry/r/tooltip.json +23 -0
- package/registry/r/tracker.json +25 -0
- package/registry/r/typography.json +23 -0
- package/registry/registry.json +2545 -0
- package/theme.css +230 -10
- package/types/tolle-ui.d.ts +7433 -823
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "input-group",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Input Group",
|
|
5
|
+
"description": "Input Group component with size and invalid and stacked options.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "input-group.component.ts",
|
|
14
|
+
"target": "ui/input-group.component.ts",
|
|
15
|
+
"content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n forwardRef,\n ChangeDetectorRef,\n} from '@angular/core';\n\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst inputGroupVariants = cva(\n 'group/input-group relative flex w-full items-center rounded-md border border-input bg-background shadow-sm transition-colors ' +\n 'focus-within:border-primary/80 focus-within:ring-4 focus-within:ring-ring/30 ' +\n 'has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50',\n {\n variants: {\n size: {\n xs: 'h-8 gap-1 px-1.5 text-xs',\n sm: 'h-9 gap-1.5 px-2 text-sm',\n default: 'h-10 gap-2 px-2 text-sm',\n lg: 'h-11 gap-2 px-3 text-base',\n },\n invalid: {\n true: 'border-destructive focus-within:border-destructive/80 focus-within:ring-destructive/30',\n false: '',\n },\n // `block-start` / `block-end` addons stack, so the group can no longer be a\n // fixed-height row.\n stacked: {\n true: 'h-auto flex-col items-stretch gap-0 px-0 py-0',\n false: '',\n },\n },\n defaultVariants: { size: 'default', invalid: false, stacked: false },\n }\n);\n\nexport type InputGroupProps = VariantProps<typeof inputGroupVariants>;\n\n/**\n * Wraps an input with leading/trailing addons — icons, text, buttons or a\n * spinner — inside a single bordered control that shows one shared focus ring.\n *\n * Put a `tolle-input-group-input` (or `tolle-input-group-textarea`) inside,\n * alongside any number of `tolle-input-group-addon` elements.\n * @new\n */\n@Component({\n selector: 'tolle-input-group',\n styles: [':host { display: block; }'],\n imports: [],\n template: `<div [class]=\"computedClass\"><ng-content></ng-content></div>`,\n})\nexport class InputGroupComponent {\n /** Height and text size of the group; match it to the input inside. @default 'default' */\n @Input() size: InputGroupProps['size'] = 'default';\n /** Applies the destructive border and focus ring. @default false */\n @Input() invalid = false;\n /** Switches to a stacked column layout for `block-start`/`block-end` addons. @default false */\n @Input() stacked = false;\n /** Extra Tailwind classes merged onto the group via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(\n inputGroupVariants({ size: this.size, invalid: this.invalid, stacked: this.stacked }),\n this.class\n );\n }\n}\n\nconst inputGroupAddonVariants = cva('flex shrink-0 items-center gap-1.5 text-muted-foreground', {\n variants: {\n align: {\n 'inline-start': 'order-first',\n 'inline-end': 'order-last',\n 'block-start': 'order-first w-full border-b border-border px-3 py-1.5',\n 'block-end': 'order-last w-full border-t border-border px-3 py-1.5',\n },\n },\n defaultVariants: { align: 'inline-start' },\n});\n\nexport type InputGroupAddonProps = VariantProps<typeof inputGroupAddonVariants>;\n\n/**\n * A slot beside (or above/below) the input for icons, text or buttons.\n * Use `block-start` / `block-end` together with the group's `stacked` input.\n */\n@Component({\n selector: 'tolle-input-group-addon',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n template: `<div [class]=\"computedClass\"><ng-content></ng-content></div>`,\n})\nexport class InputGroupAddonComponent {\n /** Where the addon sits relative to the input. @default 'inline-start' */\n @Input() align: InputGroupAddonProps['align'] = 'inline-start';\n /** Extra Tailwind classes merged onto the addon via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(inputGroupAddonVariants({ align: this.align }), this.class);\n }\n}\n\nconst inputGroupButtonVariants = cva(\n 'inline-flex shrink-0 items-center justify-center gap-1 rounded font-medium transition-colors ' +\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 ' +\n 'disabled:pointer-events-none disabled:opacity-50',\n {\n variants: {\n variant: {\n default: 'bg-primary text-primary-foreground hover:bg-primary/90',\n secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',\n outline: 'border border-border bg-transparent hover:bg-accent hover:text-accent-foreground',\n ghost: 'bg-transparent hover:bg-accent hover:text-accent-foreground',\n destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',\n },\n size: {\n xs: 'h-6 px-2 text-xs',\n sm: 'h-7 px-2.5 text-sm',\n 'icon-xs': 'h-6 w-6 p-0 text-xs',\n 'icon-sm': 'h-7 w-7 p-0 text-sm',\n },\n },\n defaultVariants: { variant: 'ghost', size: 'icon-sm' },\n }\n);\n\nexport type InputGroupButtonProps = VariantProps<typeof inputGroupButtonVariants>;\n\n/** A compact button sized to sit inside an input group addon. */\n@Component({\n selector: 'tolle-input-group-button',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n template: `\n <button\n [type]=\"type\"\n [disabled]=\"disabled\"\n [attr.aria-label]=\"ariaLabel || null\"\n [class]=\"computedClass\"\n (click)=\"clicked.emit($event)\">\n <ng-content></ng-content>\n </button>\n `,\n})\nexport class InputGroupButtonComponent {\n /** Visual style of the button. @default 'ghost' */\n @Input() variant: InputGroupButtonProps['variant'] = 'ghost';\n /** Size of the button; the `icon-*` sizes render a square. @default 'icon-sm' */\n @Input() size: InputGroupButtonProps['size'] = 'icon-sm';\n /** Native button type. @default 'button' */\n @Input() type: 'button' | 'submit' | 'reset' = 'button';\n /** Disables the button. @default false */\n @Input() disabled = false;\n /** Accessible name — required when the button shows only an icon. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the button via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the click event when the button is pressed. */\n @Output() clicked = new EventEmitter<MouseEvent>();\n\n get computedClass() {\n return cn(inputGroupButtonVariants({ variant: this.variant, size: this.size }), this.class);\n }\n}\n\n/** Plain text inside an addon — a unit, prefix, or character counter. */\n@Component({\n selector: 'tolle-input-group-text',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n template: `<span\n [class]=\"cn('select-none whitespace-nowrap text-sm text-muted-foreground', class)\"\n ><ng-content></ng-content\n ></span>`,\n})\nexport class InputGroupTextComponent {\n /** Extra Tailwind classes merged onto the text via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/**\n * The input inside a `tolle-input-group`. Deliberately borderless and\n * unfocusable-looking — the surrounding group owns the border and focus ring.\n */\n@Component({\n selector: 'tolle-input-group-input',\n styles: [':host { display: block; }'],\n imports: [FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => InputGroupInputComponent),\n multi: true,\n },\n ],\n template: `\n <input\n [id]=\"id\"\n [type]=\"type\"\n [placeholder]=\"placeholder\"\n [disabled]=\"disabled\"\n [readOnly]=\"readonly\"\n [value]=\"value ?? ''\"\n [attr.aria-describedby]=\"ariaDescribedby || null\"\n [class]=\"computedClass\"\n (input)=\"onInputChange($event)\"\n (blur)=\"onTouched()\" />\n `,\n})\nexport class InputGroupInputComponent implements ControlValueAccessor {\n /** Id applied to the underlying `<input>`. */\n @Input() id = `input-group-input-${Math.random().toString(36).slice(2, 11)}`;\n /** Native input type. @default 'text' */\n @Input() type = 'text';\n /** Placeholder text. @default '' */\n @Input() placeholder = '';\n /** Disables the input. @default false */\n @Input() disabled = false;\n /** Makes the input read-only. @default false */\n @Input() readonly = false;\n /** Id of the element describing this input, for `aria-describedby`. @default '' */\n @Input() ariaDescribedby = '';\n /** Extra Tailwind classes merged onto the input via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the new value on every keystroke. */\n @Output() valueChange = new EventEmitter<string>();\n\n value: string | null = '';\n onChange: (value: string) => void = () => {};\n onTouched: () => void = () => {};\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n get computedClass() {\n return cn(\n 'min-w-0 flex-1 border-none bg-transparent p-0 text-foreground outline-none',\n 'placeholder:text-muted-foreground',\n 'focus:outline-none focus:ring-0 focus:shadow-none',\n 'disabled:cursor-not-allowed',\n this.class\n );\n }\n\n onInputChange(event: Event): void {\n if (this.readonly || this.disabled) return;\n const next = (event.target as HTMLInputElement).value;\n this.value = next;\n this.onChange(next);\n this.valueChange.emit(next);\n }\n\n writeValue(value: string | null): void {\n this.value = value;\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: (value: string) => 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\n/** The textarea equivalent of `tolle-input-group-input`. */\n@Component({\n selector: 'tolle-input-group-textarea',\n styles: [':host { display: block; }'],\n imports: [FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => InputGroupTextareaComponent),\n multi: true,\n },\n ],\n template: `\n <textarea\n [id]=\"id\"\n [rows]=\"rows\"\n [placeholder]=\"placeholder\"\n [disabled]=\"disabled\"\n [readOnly]=\"readonly\"\n [value]=\"value ?? ''\"\n [attr.aria-describedby]=\"ariaDescribedby || null\"\n [class]=\"computedClass\"\n (input)=\"onInputChange($event)\"\n (blur)=\"onTouched()\"></textarea>\n `,\n})\nexport class InputGroupTextareaComponent implements ControlValueAccessor {\n /** Id applied to the underlying `<textarea>`. */\n @Input() id = `input-group-textarea-${Math.random().toString(36).slice(2, 11)}`;\n /** Visible rows. @default 3 */\n @Input() rows = 3;\n /** Placeholder text. @default '' */\n @Input() placeholder = '';\n /** Disables the textarea. @default false */\n @Input() disabled = false;\n /** Makes the textarea read-only. @default false */\n @Input() readonly = false;\n /** Id of the element describing this textarea, for `aria-describedby`. @default '' */\n @Input() ariaDescribedby = '';\n /** Extra Tailwind classes merged onto the textarea via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the new value on every keystroke. */\n @Output() valueChange = new EventEmitter<string>();\n\n value: string | null = '';\n onChange: (value: string) => void = () => {};\n onTouched: () => void = () => {};\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n get computedClass() {\n return cn(\n 'min-w-0 flex-1 resize-none border-none bg-transparent p-0 py-2 text-foreground outline-none',\n 'placeholder:text-muted-foreground',\n 'focus:outline-none focus:ring-0 focus:shadow-none',\n 'disabled:cursor-not-allowed',\n this.class\n );\n }\n\n onInputChange(event: Event): void {\n if (this.readonly || this.disabled) return;\n const next = (event.target as HTMLTextAreaElement).value;\n this.value = next;\n this.onChange(next);\n this.valueChange.emit(next);\n }\n\n writeValue(value: string | null): void {\n this.value = value;\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: (value: string) => 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,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "input",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Input",
|
|
5
|
+
"description": "Input component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "input.component.ts",
|
|
12
|
+
"target": "ui/input.component.ts",
|
|
13
|
+
"content": "import {\n Component,\n Input,\n forwardRef,\n ChangeDetectorRef,\n ElementRef,\n ViewChild,\n AfterViewInit,\n} from '@angular/core';\n\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-input',\n styles: [':host { display: block; }'],\n imports: [FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => InputComponent),\n multi: true,\n },\n ],\n template: `\n <div class=\"flex w-full flex-col gap-1.5\">\n @if (label) {\n <label [for]=\"id\" [class]=\"computedLabelClass\">\n {{ label }}\n </label>\n }\n\n <div [class]=\"computedContainerClass\" (click)=\"focusInput()\">\n <div\n class=\"flex h-full items-center text-muted-foreground transition-colors duration-200 group-focus-within:text-primary\">\n <ng-content select=\"[prefix]\"></ng-content>\n </div>\n\n <input\n #inputElement\n [id]=\"id\"\n [type]=\"type\"\n [placeholder]=\"placeholder\"\n [disabled]=\"disabled\"\n [readOnly]=\"readonly\"\n [(ngModel)]=\"value\"\n (blur)=\"onBlur()\"\n (focus)=\"onFocus()\"\n (input)=\"onInputChange($event)\"\n [class]=\"computedInputClass\"\n [attr.aria-invalid]=\"error || null\"\n [attr.aria-describedby]=\"\n error && errorMessage ? id + '-error' : hint ? id + '-hint' : null\n \" />\n\n <div\n class=\"flex h-full items-center text-muted-foreground transition-colors duration-200 group-focus-within:text-primary\">\n <ng-content select=\"[suffix]\"></ng-content>\n </div>\n </div>\n\n @if (!disabled) {\n @if (hint && !error) {\n <p\n [id]=\"id + '-hint'\"\n class=\"px-1 text-xs text-muted-foreground transition-opacity duration-200\"\n [class.opacity-0]=\"isFocused && hideHintOnFocus\">\n {{ hint }}\n </p>\n }\n @if (error && errorMessage) {\n <p [id]=\"id + '-error'\" class=\"px-1 text-xs text-destructive\">\n {{ errorMessage }}\n </p>\n }\n }\n </div>\n `,\n})\nexport class InputComponent implements ControlValueAccessor, AfterViewInit {\n @ViewChild('inputElement') inputElement!: ElementRef<HTMLInputElement>;\n\n @Input() id: string = `input-${Math.random().toString(36).substr(2, 9)}`;\n @Input() label: string = '';\n @Input() hint: string = '';\n @Input() errorMessage: string = '';\n @Input() type: string = 'text';\n @Input() placeholder: string = '';\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() containerClass: string = '';\n @Input() class: string = '';\n\n // New States\n @Input() disabled: boolean = false;\n @Input() readonly: boolean = false;\n @Input() error: boolean = false;\n\n // Focus behavior\n @Input() hideHintOnFocus: boolean = true;\n\n value: any = '';\n onChange: any = () => {};\n onTouched: any = () => {};\n isFocused: boolean = false;\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n ngAfterViewInit() {\n if (this.inputElement?.nativeElement.hasAttribute('autofocus')) {\n setTimeout(() => this.inputElement.nativeElement.focus());\n }\n }\n\n writeValue(value: any): void {\n this.value = value;\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n onInputChange(event: Event): void {\n if (this.readonly || this.disabled) return;\n const val = (event.target as HTMLInputElement).value;\n this.value = val;\n this.onChange(val);\n }\n\n onFocus(): void {\n this.isFocused = true;\n }\n\n onBlur(): void {\n this.isFocused = false;\n this.onTouched();\n }\n\n focusInput(): void {\n if (!this.disabled && this.inputElement) {\n this.inputElement.nativeElement.focus();\n }\n }\n\n protected readonly cn = cn;\n\n get computedLabelClass() {\n return cn(\n 'text-sm font-medium text-foreground leading-none transition-opacity duration-200',\n this.disabled && 'opacity-50'\n );\n }\n\n get computedContainerClass() {\n return cn(\n // Base styles\n 'group relative flex items-center w-full rounded-md border transition-all duration-200',\n 'bg-background',\n\n // Border and shadow\n 'border-input shadow-sm',\n\n // Sizing\n this.size === 'xs' && 'h-8 px-2 gap-1.5 text-xs',\n this.size === 'sm' && 'h-9 px-3 gap-2 text-sm',\n this.size === 'default' && 'h-10 px-3 gap-2 text-sm',\n this.size === 'lg' && 'h-11 px-4 gap-3 text-base',\n\n // Focus state - SIMPLE AND ELEGANT LIKE ZARDUI\n // The magic happens in CSS: border darkens automatically on focus\n !(this.readonly || this.disabled) && [\n 'focus-within:border-primary/80',\n 'focus-within:ring-4',\n 'focus-within:ring-ring/30',\n 'focus-within:ring-offset-0',\n 'focus-within:shadow-none',\n ],\n\n // Error state\n this.error && [\n 'border-destructive',\n !(this.readonly || this.disabled) && [\n 'focus-within:border-destructive/80',\n 'focus-within:ring-destructive/30',\n ],\n ],\n\n // Disabled state\n this.disabled && ['cursor-not-allowed opacity-50', 'border-opacity-50'],\n\n // Readonly state\n this.readonly && [\n 'cursor-default',\n 'border-dashed',\n !this.disabled && 'focus-within:ring-0 focus-within:border-opacity-100',\n ],\n\n this.containerClass\n );\n }\n\n get computedInputClass() {\n return cn(\n // Base styles\n 'flex-1 bg-transparent border-none p-0',\n 'placeholder:text-muted-foreground',\n\n // Remove all default focus styles\n 'focus:outline-none focus:ring-0 focus:shadow-none',\n\n // Text sizing\n this.size === 'xs' && 'text-xs',\n this.size === 'sm' && 'text-sm',\n this.size === 'default' && 'text-sm',\n this.size === 'lg' && 'text-base',\n\n // Cursor states\n this.disabled && 'cursor-not-allowed',\n this.readonly && 'cursor-default',\n\n // Text color\n 'text-foreground',\n\n // Selection color\n 'selection:bg-primary/20 selection:text-foreground',\n\n this.class\n );\n }\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "item",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Item",
|
|
5
|
+
"description": "Item component with variant and size options.",
|
|
6
|
+
"category": "layout",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "item.component.ts",
|
|
14
|
+
"target": "ui/item.component.ts",
|
|
15
|
+
"content": "import {\n Component,\n Input,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n OnChanges,\n inject,\n} from '@angular/core';\n\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst itemVariants = cva(\n 'group/item flex items-center gap-4 rounded-md border text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n {\n variants: {\n variant: {\n default: 'border-transparent bg-transparent',\n outline: 'border-border bg-transparent',\n muted: 'border-transparent bg-muted/50',\n },\n size: {\n xs: 'px-2 py-1.5 gap-2',\n sm: 'px-3 py-2 gap-3',\n default: 'px-4 py-3',\n },\n },\n defaultVariants: { variant: 'default', size: 'default' },\n }\n);\n\nexport type ItemProps = VariantProps<typeof itemVariants>;\n\n/**\n * A flex container for content rows built from media, title, description and\n * actions — profiles, notifications, settings rows, list entries. Use\n * `tolle-field` instead when wrapping a form control.\n * @new\n */\n@Component({\n selector: 'tolle-item',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"computedClass\"><ng-content></ng-content></div>`,\n})\nexport class ItemComponent 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 row. @default 'default' */\n @Input() variant: ItemProps['variant'] = 'default';\n /** Padding and gap density of the row. @default 'default' */\n @Input() size: ItemProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the row via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(itemVariants({ variant: this.variant, size: this.size }), this.class);\n }\n}\n\n/** Stacks several `tolle-item` rows as a single list. */\n@Component({\n selector: 'tolle-item-group',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div role=\"list\" [class]=\"cn('flex flex-col', class)\"><ng-content></ng-content></div>`,\n})\nexport class ItemGroupComponent 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 group via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** Leading media slot — avatar, icon, or thumbnail. */\n@Component({\n selector: 'tolle-item-media',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div\n [class]=\"cn('flex shrink-0 items-center justify-center text-muted-foreground', class)\">\n <ng-content></ng-content>\n </div>`,\n})\nexport class ItemMediaComponent 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 media slot via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** Grows to fill the row; wraps the title and description. */\n@Component({\n selector: 'tolle-item-content',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"cn('flex min-w-0 flex-1 flex-col gap-0.5', class)\">\n <ng-content></ng-content>\n </div>`,\n})\nexport class ItemContentComponent 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 content column via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** Primary line of the row. */\n@Component({\n selector: 'tolle-item-title',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<p [class]=\"cn('truncate font-medium leading-none text-foreground', class)\">\n <ng-content></ng-content>\n </p>`,\n})\nexport class ItemTitleComponent 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 title via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** Secondary line of the row. */\n@Component({\n selector: 'tolle-item-description',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<p [class]=\"cn('truncate text-sm text-muted-foreground', class)\">\n <ng-content></ng-content>\n </p>`,\n})\nexport class ItemDescriptionComponent 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 description via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** Trailing slot for buttons, switches, or badges. */\n@Component({\n selector: 'tolle-item-actions',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"cn('flex shrink-0 items-center gap-2', class)\">\n <ng-content></ng-content>\n </div>`,\n})\nexport class ItemActionsComponent 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 actions slot via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** Optional full-width header above the row's main line. */\n@Component({\n selector: 'tolle-item-header',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"cn('flex w-full items-center justify-between gap-2', class)\">\n <ng-content></ng-content>\n </div>`,\n})\nexport class ItemHeaderComponent 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 header via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** Optional full-width footer below the row's main line. */\n@Component({\n selector: 'tolle-item-footer',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"cn('flex w-full items-center justify-between gap-2', class)\">\n <ng-content></ng-content>\n </div>`,\n})\nexport class ItemFooterComponent 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 footer via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "utils/cn.ts",
|
|
19
|
+
"target": "ui/utils/cn.ts",
|
|
20
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kbd",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Kbd",
|
|
5
|
+
"description": "Kbd component with size and variant options.",
|
|
6
|
+
"category": "actions",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "kbd.component.ts",
|
|
14
|
+
"target": "ui/kbd.component.ts",
|
|
15
|
+
"content": "import {\n Component,\n Input,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n OnChanges,\n inject,\n} from '@angular/core';\n\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst kbdVariants = cva(\n 'inline-flex select-none items-center justify-center gap-1 rounded border border-border font-mono font-medium text-muted-foreground',\n {\n variants: {\n size: {\n sm: 'h-4 min-w-4 px-1 text-[10px]',\n default: 'h-5 min-w-5 px-1.5 text-[11px]',\n lg: 'h-6 min-w-6 px-2 text-xs',\n },\n variant: {\n default: 'bg-muted',\n outline: 'bg-transparent',\n },\n },\n defaultVariants: { size: 'default', variant: 'default' },\n }\n);\n\nexport type KbdProps = VariantProps<typeof kbdVariants>;\n\n/**\n * Renders a keyboard key or shortcut, e.g. `<tolle-kbd>⌘</tolle-kbd>`.\n * Pairs with `tolle-command-shortcut` inside command menus.\n * @new\n */\n@Component({\n selector: 'tolle-kbd',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<kbd [class]=\"computedClass\"><ng-content></ng-content></kbd>`,\n})\nexport class KbdComponent 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 /** Size of the key cap. @default 'default' */\n @Input() size: KbdProps['size'] = 'default';\n /** Visual style of the key cap. @default 'default' */\n @Input() variant: KbdProps['variant'] = 'default';\n /** Extra Tailwind classes merged onto the key cap via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(kbdVariants({ size: this.size, variant: this.variant }), this.class);\n }\n}\n\n/**\n * Groups several `tolle-kbd` keys into one shortcut, e.g. ⌘ + K.\n */\n@Component({\n selector: 'tolle-kbd-group',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<span [class]=\"cn('inline-flex items-center gap-1', class)\"\n ><ng-content></ng-content\n ></span>`,\n})\nexport class KbdGroupComponent 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 group via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\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,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "label",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Label",
|
|
5
|
+
"description": "Label component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "label.component.ts",
|
|
12
|
+
"target": "ui/label.component.ts",
|
|
13
|
+
"content": "import { Component, Input } from '@angular/core';\n\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-label',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n template: `\n <label [attr.for]=\"for\" [class]=\"computedClass\">\n <ng-content></ng-content>\n </label>\n `,\n})\nexport class LabelComponent {\n @Input() for?: string;\n @Input() class: string = '';\n\n get computedClass() {\n return cn(\n 'inline-flex items-center gap-2 select-none text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50',\n this.class\n );\n }\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "marker",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Marker",
|
|
5
|
+
"description": "Marker component with variant options.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "marker.component.ts",
|
|
14
|
+
"target": "ui/marker.component.ts",
|
|
15
|
+
"content": "import {\n Component,\n Input,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n OnChanges,\n inject,\n} from '@angular/core';\n\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst markerVariants = cva('flex w-full items-center gap-2 text-xs', {\n variants: {\n variant: {\n // System notes and tool activity — centred, quiet.\n default: 'justify-center py-1 text-muted-foreground',\n // A labelled rule between two stretches of transcript.\n separator: 'py-3 text-muted-foreground',\n // Live status such as \"Thinking…\", aligned with the transcript's leading edge.\n status: 'justify-start py-1 text-muted-foreground',\n // A failed send or a tool error.\n error: 'justify-center py-1 font-medium text-destructive',\n },\n },\n defaultVariants: { variant: 'default' },\n});\n\nexport type MarkerProps = VariantProps<typeof markerVariants>;\n\n/**\n * An inline row in a chat transcript that is not a message: a system note, a\n * streaming status, tool activity, or a labelled date separator.\n * @new\n */\n@Component({\n selector: 'tolle-marker',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n [class]=\"computedClass\"\n [attr.role]=\"variant === 'separator' ? 'separator' : isLive ? 'status' : null\"\n [attr.aria-live]=\"isLive ? 'polite' : null\">\n @if (variant === 'separator') {\n <span aria-hidden=\"true\" class=\"h-px flex-1 bg-border\"></span>\n }\n\n @if (icon) {\n <i [class]=\"iconClass\" aria-hidden=\"true\"></i>\n }\n\n <span [class]=\"labelClass\">\n @if (label) {\n <span>{{ label }}</span>\n }\n <ng-content></ng-content>\n </span>\n\n @if (variant === 'separator') {\n <span aria-hidden=\"true\" class=\"h-px flex-1 bg-border\"></span>\n }\n </div>\n `,\n})\nexport class MarkerComponent 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 /** Kind of transcript marker this row represents. @default 'default' */\n @Input() variant: MarkerProps['variant'] = 'default';\n /** Text for the marker — the date on a separator, or the status wording. */\n @Input() label = '';\n /** Remixicon class for a leading icon, e.g. 'ri-tools-line'. */\n @Input() icon = '';\n /** Applies the shimmer text sweep, for live status such as \"Thinking…\". @default false */\n @Input() shimmer = false;\n /** Extra Tailwind classes merged onto the row via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass(): string {\n return cn(markerVariants({ variant: this.variant }), this.class);\n }\n\n /** Whether this marker announces a change to assistive technology. */\n get isLive(): boolean {\n return this.variant === 'status' || this.variant === 'error';\n }\n\n get iconClass(): string {\n return cn('shrink-0', this.icon, this.shimmer && 'animate-pulse');\n }\n\n get labelClass(): string {\n return cn(\n 'inline-flex items-center gap-1 whitespace-nowrap',\n this.variant === 'separator' && 'shrink-0 px-1 font-medium',\n this.shimmer && 'shimmer'\n );\n }\n}\n\n/**\n * Groups related markers — a run of tool calls, say — into one indented block so\n * they read as a single step in the transcript.\n */\n@Component({\n selector: 'tolle-marker-group',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"cn('flex w-full flex-col gap-0.5', class)\" role=\"group\">\n <ng-content></ng-content>\n </div>`,\n})\nexport class MarkerGroupComponent 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 group via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\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,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "masked-input",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Masked Input",
|
|
5
|
+
"description": "Masked Input component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "masked-input.component.ts",
|
|
12
|
+
"target": "ui/masked-input.component.ts",
|
|
13
|
+
"content": "import {\n Component,\n Input,\n forwardRef,\n ElementRef,\n ViewChild,\n AfterContentChecked,\n ChangeDetectorRef,\n} from '@angular/core';\n\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-masked-input',\n styles: [':host { display: block; }'],\n imports: [FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => MaskedInputComponent),\n multi: true,\n },\n ],\n template: `\n <div class=\"flex w-full flex-col gap-1.5\">\n @if (label) {\n <label [for]=\"id\" [class]=\"computedLabelClass\">\n {{ label }}\n </label>\n }\n\n <div [class]=\"computedContainerClass\" (click)=\"focusInput()\">\n <!-- Prefix Icon -->\n <div\n class=\"flex h-full items-center text-muted-foreground transition-colors duration-200 group-focus-within:text-primary\">\n <ng-content select=\"[prefix]\"></ng-content>\n </div>\n\n <input\n #inputEl\n [id]=\"id\"\n [type]=\"type\"\n [placeholder]=\"placeholder\"\n [disabled]=\"disabled\"\n [readOnly]=\"readonly\"\n [value]=\"displayValue\"\n (input)=\"onInput($event)\"\n (blur)=\"onBlur()\"\n (focus)=\"onFocus()\"\n [class]=\"computedInputClass\"\n [attr.aria-invalid]=\"error || null\"\n [attr.aria-describedby]=\"error && errorMessage ? id + '-error' : null\" />\n\n <!-- Suffix Icon -->\n <div\n class=\"flex h-full items-center text-muted-foreground transition-colors duration-200 group-focus-within:text-primary\">\n <ng-content select=\"[suffix]\"></ng-content>\n </div>\n </div>\n\n @if (!disabled) {\n @if (hint && !error) {\n <p\n class=\"px-1 text-xs text-muted-foreground transition-opacity duration-200\"\n [class.opacity-0]=\"isFocused && hideHintOnFocus\">\n {{ hint }}\n </p>\n }\n @if (error && errorMessage) {\n <p [id]=\"id + '-error'\" class=\"px-1 text-xs text-destructive\">\n {{ errorMessage }}\n </p>\n }\n }\n </div>\n `,\n})\nexport class MaskedInputComponent implements ControlValueAccessor, AfterContentChecked {\n @Input() id: string = `masked-input-${Math.random().toString(36).substr(2, 9)}`;\n @Input() label: string = '';\n @Input() hint: string = '';\n @Input() errorMessage: string = '';\n @Input() mask: string = '';\n @Input() placeholder = '';\n @Input() type = 'text';\n @Input() disabled = false;\n @Input() readonly = false;\n @Input() class = '';\n @Input() containerClass: string = '';\n @Input() error: boolean = false;\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() returnRaw = false;\n @Input() hideHintOnFocus: boolean = true;\n @Input() externalFocused: boolean | undefined;\n\n @ViewChild('inputEl', { static: true }) inputEl!: ElementRef<HTMLInputElement>;\n\n hasPrefix = false;\n hasSuffix = false;\n displayValue = '';\n isFocused: boolean = false;\n\n private tokens: { [key: string]: RegExp } = {\n '0': /\\d/,\n '9': /\\d/,\n a: /[a-z]/i,\n A: /[a-z]/i,\n '*': /[a-z0-9]/i,\n };\n\n onChange: any = () => {};\n onTouched: any = () => {};\n\n constructor(\n private el: ElementRef,\n private cdr: ChangeDetectorRef\n ) {}\n\n ngAfterContentChecked() {\n const prefix = this.el.nativeElement.querySelector('[prefix]');\n const suffix = this.el.nativeElement.querySelector('[suffix]');\n\n if (this.hasPrefix !== !!prefix || this.hasSuffix !== !!suffix) {\n this.hasPrefix = !!prefix;\n this.hasSuffix = !!suffix;\n this.cdr.detectChanges();\n }\n }\n\n get computedLabelClass() {\n return cn(\n 'text-sm font-medium text-foreground leading-none transition-opacity duration-200',\n this.disabled && 'opacity-50'\n );\n }\n\n get computedContainerClass() {\n const focused = this.externalFocused !== undefined ? this.externalFocused : this.isFocused;\n return cn(\n // Base styles\n 'group relative flex items-center w-full border transition-all duration-200',\n 'bg-background border-input shadow-sm',\n\n // Sizing\n this.size === 'xs' && 'h-8 px-2 gap-1.5 text-xs',\n this.size === 'sm' && 'h-9 px-3 gap-2 text-sm',\n this.size === 'default' && 'h-10 px-3 gap-2 text-sm',\n this.size === 'lg' && 'h-11 px-4 gap-3 text-base',\n\n // Rounded corners\n 'rounded-md',\n\n // Focus state\n !(this.readonly || this.disabled) && [\n 'focus-within:ring-4',\n 'focus-within:ring-ring/30',\n 'focus-within:ring-offset-0',\n 'focus-within:shadow-none',\n this.error ? 'focus-within:border-destructive/80' : 'focus-within:border-primary/80',\n ],\n\n // Error state\n this.error && [\n 'border-destructive',\n !(this.readonly || this.disabled) && 'focus-within:ring-destructive/30',\n ],\n\n // Disabled state\n this.disabled && ['cursor-not-allowed opacity-50', 'border-opacity-50'],\n\n // Readonly state\n this.readonly && ['cursor-default', 'border-dashed'],\n\n this.containerClass\n );\n }\n\n get computedInputClass() {\n return cn(\n // Base styles\n 'flex-1 bg-transparent border-none p-0',\n 'placeholder:text-muted-foreground',\n\n // Remove all default focus styles\n 'focus:outline-none focus:ring-0 focus:shadow-none',\n\n // Text sizing\n this.size === 'xs' && 'text-xs',\n this.size === 'sm' && 'text-sm',\n this.size === 'default' && 'text-sm',\n this.size === 'lg' && 'text-base',\n\n // Cursor states\n this.disabled && 'cursor-not-allowed',\n this.readonly && 'cursor-default',\n\n // Text color\n 'text-foreground',\n\n // Selection color\n 'selection:bg-primary/20 selection:text-foreground',\n\n this.class\n );\n }\n\n focusInput(): void {\n if (!this.disabled && this.inputEl) {\n this.inputEl.nativeElement.focus();\n }\n }\n\n onFocus(): void {\n this.isFocused = true;\n }\n\n onBlur(): void {\n this.isFocused = false;\n this.onTouched();\n }\n\n // --- Masking Logic ---\n onInput(event: Event) {\n if (this.readonly || this.disabled) return;\n const input = event.target as HTMLInputElement;\n const raw = this.unmask(input.value);\n const masked = this.applyMask(raw);\n this.displayValue = masked;\n input.value = masked;\n this.returnRaw ? this.onChange(raw) : this.onChange(masked);\n }\n\n private applyMask(rawValue: string): string {\n let rawIndex = 0;\n let formatted = '';\n for (let i = 0; i < this.mask.length; i++) {\n if (rawIndex >= rawValue.length) break;\n const maskChar = this.mask[i];\n const rawChar = rawValue[rawIndex];\n if (this.tokens[maskChar]) {\n if (this.tokens[maskChar].test(rawChar)) {\n formatted += rawChar;\n rawIndex++;\n } else {\n rawIndex++;\n i--;\n }\n } else {\n formatted += maskChar;\n if (rawChar === maskChar) rawIndex++;\n }\n }\n return formatted;\n }\n\n private unmask(val: string): string {\n return val.replace(/[^a-zA-Z0-9]/g, '');\n }\n\n writeValue(value: any): void {\n this.displayValue = value ? this.applyMask(this.unmask(value.toString())) : '';\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n protected cn = cn;\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -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 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 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 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 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 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 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 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 {\n Component,\n Directive,\n Input,\n Output,\n EventEmitter,\n ElementRef,\n NgZone,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n AfterViewInit,\n OnInit,\n OnDestroy,\n inject,\n OnChanges,\n} from '@angular/core';\n\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 imports: [],\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 imports: [],\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 imports: [],\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 <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 imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @if (!atBottom) {\n <button\n type=\"button\"\n [attr.aria-label]=\"ariaLabel\"\n [class]=\"\n 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 \"\n (click)=\"jump()\">\n <i class=\"ri-arrow-down-line\" aria-hidden=\"true\"></i>\n <ng-content></ng-content>\n </button>\n }\n `,\n})\nexport class MessageScrollerButtonComponent implements OnChanges, OnInit, OnDestroy {\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 {\n Component,\n Directive,\n Injectable,\n Input,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n OnChanges,\n OnDestroy,\n inject,\n} from '@angular/core';\n\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 imports: [],\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 imports: [],\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 imports: [],\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 imports: [],\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 imports: [],\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 imports: [],\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,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "modal",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Modal",
|
|
5
|
+
"description": "Modal component.",
|
|
6
|
+
"category": "overlays",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "modal-ref.ts",
|
|
12
|
+
"target": "ui/modal-ref.ts",
|
|
13
|
+
"content": "import { OverlayRef } from '@angular/cdk/overlay';\nimport { BehaviorSubject, Subject } from 'rxjs';\nimport { Modal } from './modal';\nimport { ModalStackService } from './modal-stack.service';\n\n/**\n * Safety net if the exit animation's `animationend` never fires — reduced\n * motion, no matching keyframe, or a panel that never rendered — so a modal\n * can never get stuck open forever.\n */\nconst CLOSE_FALLBACK_MS = 300;\n\nexport class ModalRef<R = any> {\n private readonly _afterClosed$ = new Subject<R | undefined | null>();\n afterClosed$ = this._afterClosed$.asObservable();\n\n private readonly _closingSubject = new BehaviorSubject<boolean>(false);\n /** True once `close()` has been called — drives the panel's exit animation. */\n readonly closing$ = this._closingSubject.asObservable();\n\n constructor(\n private overlay: OverlayRef,\n public modal: Modal,\n private stack: ModalStackService\n ) {\n this.stack.register(this);\n\n // Handle Backdrop Click\n this.overlay.backdropClick().subscribe(() => {\n if (this.modal.backdropClose) {\n this.close();\n }\n });\n }\n\n /**\n * Closes the modal. Signals the panel and backdrop to play their exit\n * animation, then tears down the overlay once it finishes (or after\n * `CLOSE_FALLBACK_MS`, whichever comes first).\n * @param result Data to pass back to the caller\n */\n close(result?: R): void {\n if (this._closingSubject.value) return;\n this._closingSubject.next(true);\n this.overlay.backdropElement?.setAttribute('data-state', 'closed');\n\n let finished = false;\n const finish = () => {\n if (finished) return;\n finished = true;\n this._afterClosed$.next(result);\n this._afterClosed$.complete();\n this.overlay.dispose();\n this.stack.unregister(this);\n };\n\n const panel = this.overlay.overlayElement.querySelector<HTMLElement>('[data-state]');\n if (panel) {\n panel.addEventListener('animationend', finish, { once: true });\n setTimeout(finish, CLOSE_FALLBACK_MS);\n } else {\n finish();\n }\n }\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "modal-stack.service.ts",
|
|
17
|
+
"target": "ui/modal-stack.service.ts",
|
|
18
|
+
"content": "import { Injectable } from '@angular/core';\nimport { ModalRef } from './modal-ref';\n\n@Injectable({ providedIn: 'root' })\nexport class ModalStackService {\n private _stack = new Set<ModalRef>();\n\n register(ref: ModalRef): void {\n this._stack.add(ref);\n }\n\n unregister(ref: ModalRef): void {\n this._stack.delete(ref);\n }\n\n /** Instantly closes all open modals */\n closeAll(): void {\n this._stack.forEach(ref => ref.close());\n this._stack.clear();\n }\n\n get activeCount(): number {\n return this._stack.size;\n }\n}\n"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"path": "modal.component.ts",
|
|
22
|
+
"target": "ui/modal.component.ts",
|
|
23
|
+
"content": "import {\n ChangeDetectorRef,\n Component,\n inject,\n OnDestroy,\n OnInit,\n TemplateRef,\n Type,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { A11yModule } from '@angular/cdk/a11y';\nimport { Subscription } from 'rxjs';\nimport { ModalRef } from './modal-ref';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-modal',\n imports: [CommonModule, A11yModule],\n template: `\n <div\n [class]=\"modalClasses\"\n class=\"pointer-events-auto\"\n [attr.data-state]=\"closing ? 'closed' : 'open'\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"ref.modal.title ? titleId : null\"\n [attr.aria-describedby]=\"contentType === 'string' ? descId : null\"\n cdkTrapFocus\n cdkTrapFocusAutoCapture\n (mousedown)=\"$event.stopPropagation()\">\n @if (ref.modal.showCloseButton) {\n <button\n type=\"button\"\n (click)=\"ref.close()\"\n aria-label=\"Close\"\n class=\"absolute right-4 top-4 grid h-6 w-6 place-items-center rounded-sm text-muted-foreground opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\">\n <i class=\"ri-close-line text-base\" aria-hidden=\"true\"></i>\n </button>\n }\n\n <!-- Header -->\n @if (ref.modal.title) {\n <div class=\"flex shrink-0 flex-col gap-1.5 pr-8 text-center sm:text-left\">\n <h2\n [id]=\"titleId\"\n class=\"text-lg font-semibold leading-none tracking-tight text-foreground\">\n {{ ref.modal.title }}\n </h2>\n </div>\n }\n\n <!-- Body -->\n <div class=\"min-h-0 flex-1 overflow-y-auto\">\n @switch (contentType) {\n @case ('string') {\n <p [id]=\"descId\" class=\"text-sm leading-relaxed text-muted-foreground\">{{ content }}</p>\n }\n @case ('template') {\n <ng-container *ngTemplateOutlet=\"asTemplate; context: ref.modal.context\"></ng-container>\n }\n @case ('component') {\n <ng-container *ngComponentOutlet=\"asComponent\"></ng-container>\n }\n }\n </div>\n </div>\n `,\n styles: [\n `\n :host {\n display: contents; /* This makes the host \"disappear\" so the div targets the overlay pane directly */\n }\n `,\n ],\n})\nexport class ModalComponent implements OnInit, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscription = new Subscription();\n\n contentType: 'template' | 'string' | 'component' = 'string';\n content: any;\n modalClasses = '';\n /** True while the exit animation plays, just before the overlay is torn down. */\n closing = false;\n\n /** Stable, per-instance ids used to wire dialog ARIA relationships. */\n private readonly _uid = Math.random().toString(36).substr(2, 9);\n readonly titleId = `tolle-modal-title-${this._uid}`;\n readonly descId = `tolle-modal-desc-${this._uid}`;\n\n constructor(public ref: ModalRef) {}\n\n ngOnInit() {\n this.content = this.ref.modal.content;\n this.modalClasses = this.getModalSizeCss();\n this.determineContentType();\n\n this.subscription.add(\n this.ref.closing$.subscribe(closing => {\n this.closing = closing;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy() {\n this.subscription.unsubscribe();\n }\n\n private getModalSizeCss(): string {\n const { size } = this.ref.modal;\n\n return cn(\n // Surface: overlay card that never exceeds the viewport (header stays, body scrolls).\n 'relative flex w-full mx-auto flex-col gap-4 rounded-lg border border-border bg-background p-6 text-foreground shadow-lg duration-200',\n\n // Enter/exit animation, driven by `data-state` (see `closing` above).\n 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',\n 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',\n\n size === 'fullscreen' ? 'h-screen w-screen rounded-none' : 'max-h-[85vh]',\n\n // Sizing scale with explicit max-widths\n size === 'xs' && 'max-w-[320px]',\n size === 'sm' && 'max-w-[425px]',\n size === 'default' && 'max-w-[512px]',\n size === 'lg' && 'max-w-[1024px]',\n size === 'xl' && 'max-w-[1280px]'\n );\n }\n\n private determineContentType() {\n if (typeof this.content === 'string') this.contentType = 'string';\n else if (this.content instanceof TemplateRef) this.contentType = 'template';\n else this.contentType = 'component';\n }\n\n get asTemplate() {\n return this.content as TemplateRef<any>;\n }\n get asComponent() {\n return this.content as Type<any>;\n }\n protected cn = cn;\n}\n"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"path": "modal.ts",
|
|
27
|
+
"target": "ui/modal.ts",
|
|
28
|
+
"content": "import { TemplateRef, Type } from \"@angular/core\";\n\n/**\n * Configuration for `ModalService.open()`. Always passed as a plain object\n * literal, so unset fields fall back to `MODAL_DEFAULTS` in `modal.service.ts`\n * rather than a class field initializer — those never run for a literal.\n */\nexport interface Modal<T = any> {\n /** The content to display (String, Component, or Template) */\n content: string | Type<any> | TemplateRef<any>;\n\n /** Optional title for the standard header */\n title?: string;\n\n /** * Predefined size scale.\n * - xs: 320px (Mobile alerts)\n * - sm: 425px (Standard dialogs)\n * - default: 512px (Forms)\n * - lg: 1024px (Data tables)\n * - xl: 1280px (Large forms, dashboards)\n * - fullscreen: 100vw/100vh (Complex workflows)\n */\n size?: 'xs' | 'sm' | 'default' | 'lg' | 'xl' | 'fullscreen';\n\n /** * If true (default), clicking the backdrop closes the modal.\n * Set to false for \"blocking\" modals (e.g., Terms of Service).\n */\n backdropClose?: boolean;\n\n /** * Data to pass to a Component content.\n * Accessed via @Input() in the child component.\n */\n data?: { [key: string]: any };\n\n /** * Context to pass to a TemplateRef content.\n * Accessed via `let-val` in the HTML.\n */\n context?: T;\n\n /** Whether the built-in corner close button renders. Defaults to true. */\n showCloseButton?: boolean;\n}\n"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "utils/cn.ts",
|
|
32
|
+
"target": "ui/utils/cn.ts",
|
|
33
|
+
"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"
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -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 {\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n Component,\n EventEmitter,\n Input,\n OnChanges,\n Output,\n ViewChild,\n forwardRef,\n inject,\n} from '@angular/core';\n\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:\n '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 imports: [\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 @if (icon) {\n <i [class]=\"icon + ' shrink-0 text-muted-foreground'\" aria-hidden=\"true\"></i>\n }\n <span [class]=\"selected ? 'truncate text-foreground' : 'truncate text-muted-foreground'\">{{\n triggerLabel\n }}</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 @if (searchable) {\n <tolle-command-input [placeholder]=\"searchPlaceholder\"></tolle-command-input>\n }\n <tolle-command-list>\n <tolle-command-empty>{{ emptyMessage }}</tolle-command-empty>\n @for (group of groups; track group) {\n <tolle-command-group [heading]=\"group.provider\">\n @for (model of group.models; track model) {\n <tolle-command-item\n [value]=\"model.id\"\n [label]=\"model.name\"\n [keywords]=\"[model.provider || '', model.description || '']\"\n [disabled]=\"model.disabled || false\"\n class=\"items-start\">\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\"></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 @if (model.badge) {\n <span\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 }\n </span>\n @if (model.description) {\n <span class=\"truncate text-xs text-muted-foreground\">{{\n model.description\n }}</span>\n }\n </span>\n </tolle-command-item>\n }\n </tolle-command-group>\n }\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(modelSelectorTriggerVariants({ size: this.size, variant: this.variant }), this.class);\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
|
+
}
|