@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,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "multi-select",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Multi Select",
|
|
5
|
+
"description": "Multi Select component with size and error and disabled options.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"badge",
|
|
9
|
+
"input",
|
|
10
|
+
"select-item"
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [
|
|
13
|
+
"@floating-ui/dom",
|
|
14
|
+
"class-variance-authority"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "multi-select.component.ts",
|
|
19
|
+
"target": "ui/multi-select.component.ts",
|
|
20
|
+
"content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n ContentChildren,\n QueryList,\n AfterContentInit,\n ElementRef,\n ViewChild,\n OnChanges,\n OnDestroy,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n} from '@angular/core';\n\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { computePosition, flip, shift, offset, autoUpdate, size } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { Subscription } from 'rxjs';\nimport { SelectItemComponent } from './select-item.component';\nimport { SelectService } from './select.service';\nimport { cn } from './utils/cn';\nimport { InputComponent } from './input.component';\nimport { BadgeComponent } from './badge.component';\n\nconst multiSelectTriggerVariants = cva(\n 'flex h-auto min-h-10 w-full items-center justify-between rounded-md border border-input bg-background text-sm shadow-sm transition-all duration-200',\n {\n variants: {\n size: {\n xs: 'px-2 py-1',\n sm: 'px-3 py-1.5',\n default: 'px-3 py-2',\n lg: 'px-4 py-2',\n },\n error: {\n true: 'border-destructive',\n false: '',\n },\n disabled: {\n true: 'cursor-not-allowed border-opacity-50 opacity-50',\n false: '',\n },\n },\n compoundVariants: [\n // Only an interactive trigger gets the hover border and the focus ring.\n {\n disabled: false,\n class:\n 'hover:border-accent focus-visible:border-primary/80 focus-visible:shadow-none focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-0',\n },\n // …and an invalid one rings destructive instead. Declared after the rule\n // above so `cn()` resolves the conflict in its favour.\n {\n disabled: false,\n error: true,\n class: 'focus-visible:border-destructive/80 focus-visible:ring-destructive/30',\n },\n ],\n defaultVariants: { size: 'default', error: false, disabled: false },\n }\n);\n\nexport type MultiSelectProps = VariantProps<typeof multiSelectTriggerVariants>;\n\n/** One chip on the trigger, standing for a selected option. */\nexport interface MultiSelectChip {\n label: string;\n value: any;\n}\n\n/**\n * A multi-select listbox: a trigger that shows the current selection as\n * removable chips and opens a floating panel of `tolle-select-item` options.\n * Implements `ControlValueAccessor`, so it works with `ngModel` and reactive\n * forms.\n */\n@Component({\n selector: 'tolle-multi-select',\n styles: [':host { display: block; }'],\n imports: [FormsModule, BadgeComponent, InputComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [\n SelectService,\n { provide: NG_VALUE_ACCESSOR, useExisting: MultiSelectComponent, multi: true },\n ],\n template: `\n <div class=\"relative w-full\" #container>\n <button\n #trigger\n type=\"button\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-activedescendant]=\"isOpen ? activeDescendantId : null\"\n (click)=\"toggle()\"\n (keydown)=\"onTriggerKeyDown($event)\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\">\n <div class=\"flex max-w-[95%] flex-wrap items-center gap-1\">\n @if (value.length) {\n @for (item of displayItems; track trackChip($index, item)) {\n <tolle-badge\n size=\"xs\"\n variant=\"secondary\"\n [removable]=\"true\"\n (onRemove)=\"removeValue($event, item.value)\">\n {{ item.label }}\n </tolle-badge>\n }\n @if (exceedsDisplayLimit) {\n <span class=\"px-1 text-xs text-muted-foreground\">\n +{{ value.length - maxDisplayItems }} more\n </span>\n }\n @if (maxSelections && value.length >= maxSelections) {\n <span class=\"px-1 text-xs text-muted-foreground\"> (Max reached) </span>\n }\n } @else {\n <span class=\"text-muted-foreground\">{{ placeholder }}</span>\n }\n </div>\n <i\n [class]=\"\n cn(\n 'ri-arrow-down-s-line ml-2 text-muted-foreground transition-transform duration-200',\n isOpen ? 'rotate-180' : ''\n )\n \"></i>\n </button>\n\n @if (isOpen) {\n <div\n #popover\n role=\"listbox\"\n aria-multiselectable=\"true\"\n class=\"fixed z-[999] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md\"\n style=\"visibility: hidden; top: 0; left: 0;\">\n <div class=\"space-y-2 border-b border-border bg-popover p-2\">\n <div class=\"flex items-center justify-between px-1 text-xs\">\n <span class=\"text-muted-foreground\">\n {{ value.length }} selected\n @if (maxSelections) {\n <span>/ {{ maxSelections }} max</span>\n }\n </span>\n @if (maxSelections && value.length >= maxSelections) {\n <span class=\"text-xs font-medium text-destructive\"> Maximum reached </span>\n }\n </div>\n @if (searchable) {\n <tolle-input\n #searchInput\n size=\"xs\"\n placeholder=\"Search...\"\n [(ngModel)]=\"searchQuery\"\n (ngModelChange)=\"onSearchChange($event)\"\n (keydown)=\"onSearchKeyDown($event)\">\n <i prefix class=\"ri-search-line\"></i>\n </tolle-input>\n }\n <div class=\"flex items-center justify-between px-1\">\n <button\n type=\"button\"\n (click)=\"selectAll()\"\n [disabled]=\"!!(maxSelections && selectableItems.length > maxSelections)\"\n [class]=\"\n cn(\n 'text-[10px] font-bold uppercase transition-colors',\n maxSelections && selectableItems.length > maxSelections\n ? 'cursor-not-allowed text-muted-foreground'\n : 'text-primary hover:underline'\n )\n \">\n Select All\n </button>\n <button\n type=\"button\"\n (click)=\"clearAll()\"\n class=\"text-[10px] font-bold uppercase text-muted-foreground hover:underline\">\n Clear\n </button>\n </div>\n </div>\n <div class=\"max-h-60 overflow-y-auto p-1\">\n <ng-content></ng-content>\n @if (noResults) {\n <div class=\"py-4 text-center text-xs text-muted-foreground\">\n No results found for \"{{ searchQuery }}\"\n </div>\n }\n @if (maxSelections && value.length >= maxSelections) {\n <div class=\"border-t border-border bg-muted/20 p-2 text-center\">\n <span class=\"text-xs text-destructive\">\n <i class=\"ri-alert-line mr-1\"></i>\n Maximum selection limit reached ({{ maxSelections }})\n </span>\n </div>\n }\n </div>\n </div>\n }\n </div>\n `,\n})\nexport class MultiSelectComponent\n implements ControlValueAccessor, AfterContentInit, OnChanges, OnDestroy\n{\n /** Text shown on the trigger when nothing is selected. @default 'Select options...' */\n @Input() placeholder = 'Select options...';\n /** Padding and text size of the trigger. @default 'default' */\n @Input() size: MultiSelectProps['size'] = 'default';\n /** Adds a search box that filters the projected options. @default false */\n @Input() searchable = false;\n /** Disables the control and blocks opening the panel. @default false */\n @Input() disabled = false;\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). @default '' */\n @Input() class = '';\n /** Largest number of values that can be selected at once; leave unset for no cap. */\n @Input() maxSelections?: number;\n /** How many chips to render before collapsing the rest into a \"+N more\" hint. @default 3 */\n @Input() maxDisplayItems = 3;\n /** Applies the destructive border and focus ring. @default false */\n @Input() error = false;\n\n /** Emitted with the full array of selected values whenever the selection changes. */\n @Output() valueChange = new EventEmitter<any[]>();\n /** Emitted when the panel opens. */\n @Output() opened = new EventEmitter<void>();\n /** Emitted when the panel closes. */\n @Output() closed = new EventEmitter<void>();\n\n @ViewChild('trigger') trigger!: ElementRef;\n @ViewChild('popover') popover!: ElementRef;\n @ViewChild('searchInput', { read: ElementRef }) searchInput?: ElementRef<HTMLElement>;\n @ContentChildren(SelectItemComponent, { descendants: true })\n items!: QueryList<SelectItemComponent>;\n\n value: any[] = [];\n selectedItems: MultiSelectChip[] = [];\n\n /**\n * The chips actually rendered, built into a field rather than read from a\n * getter. A getter hands `*ngFor` a freshly-allocated array on every\n * change-detection pass; paired with the default identity differ that tore\n * down and rebuilt every badge — including the one under the pointer, whose\n * destruction re-fires its own remove handler. See `trackChip` below, which\n * closes the same hole for the rebuilt chip objects `syncItems()` produces.\n */\n displayItems: MultiSelectChip[] = [];\n exceedsDisplayLimit = false;\n\n isOpen = false;\n searchQuery = '';\n noResults = false;\n private cleanup?: () => void;\n private sub = new Subscription();\n\n // Keyboard navigation (active-descendant) state\n activeIndex = -1;\n private typeaheadBuffer = '';\n private typeaheadTime = 0;\n private pendingTypeaheadChar = '';\n\n constructor(\n private selectService: SelectService,\n private cdr: ChangeDetectorRef\n ) {\n this.sub.add(\n this.selectService.selectedValue$.subscribe(val => {\n if (val !== undefined && this.isOpen) this.toggleValue(val);\n })\n );\n }\n\n get computedTriggerClass() {\n return cn(\n multiSelectTriggerVariants({\n size: this.size,\n error: this.error,\n disabled: this.disabled,\n }),\n this.class\n );\n }\n\n ngAfterContentInit() {\n this.syncItems();\n this.sub.add(\n this.items.changes.subscribe(() => {\n this.syncItems();\n this.cdr.markForCheck();\n })\n );\n }\n\n /**\n * `maxDisplayItems` decides how much of the selection is rendered, so the\n * chip list has to be rebuilt when it changes.\n *\n * The `markForCheck()` is not redundant: under OnPush a `[class]` binding is\n * applied through Angular's *styling* path, which writes the input but —\n * unlike a plain property binding — never marks the component dirty, so the\n * trigger would keep rendering the class it was born with.\n */\n ngOnChanges() {\n this.refreshDisplay();\n this.cdr.markForCheck();\n }\n\n /** `*ngFor` identity for the chips — keyed on the option value, not the\n * wrapper object, which `syncItems()` re-allocates on every sync. */\n trackChip = (_index: number, chip: MultiSelectChip): any => chip.value;\n\n private refreshDisplay(): void {\n this.displayItems = this.selectedItems.slice(0, this.maxDisplayItems);\n this.exceedsDisplayLimit = this.value.length > this.maxDisplayItems;\n }\n\n get selectableItems(): SelectItemComponent[] {\n return this.items ? this.items.filter(item => !item.disabled) : [];\n }\n\n get availableSelections(): number {\n if (!this.maxSelections) return Infinity;\n return Math.max(0, this.maxSelections - this.value.length);\n }\n\n private _outsideClickHandler = (event: MouseEvent) => {\n if (\n !this.trigger.nativeElement.contains(event.target) &&\n !this.popover?.nativeElement.contains(event.target)\n ) {\n this.close();\n }\n };\n\n toggle() {\n if (this.disabled) return;\n this.isOpen ? this.close() : this.open();\n }\n\n open() {\n const wasOpen = this.isOpen;\n this.isOpen = true;\n if (!wasOpen) this.opened.emit();\n this.cdr.markForCheck();\n setTimeout(() => {\n this.updatePosition();\n document.addEventListener('pointerdown', this._outsideClickHandler, true);\n this.initActiveAfterOpen();\n this.cdr.markForCheck();\n });\n }\n\n close() {\n const wasOpen = this.isOpen;\n this.isOpen = false;\n this.searchQuery = '';\n this.onSearchChange('');\n this.resetActive();\n if (this.cleanup) this.cleanup();\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n if (wasOpen) this.closed.emit();\n this.cdr.markForCheck();\n }\n\n private updatePosition() {\n if (!this.trigger || !this.popover) return;\n\n this.cleanup = autoUpdate(this.trigger.nativeElement, this.popover.nativeElement, () => {\n computePosition(this.trigger.nativeElement, this.popover.nativeElement, {\n strategy: 'fixed',\n placement: 'bottom-start',\n middleware: [\n offset(4),\n flip(),\n shift({ padding: 8 }),\n size({\n apply({ rects, elements, availableHeight }) {\n Object.assign(elements.floating.style, {\n width: rects.reference.width + 'px',\n maxHeight: availableHeight + 'px',\n });\n },\n }),\n ],\n }).then(({ x, y, strategy }) => {\n Object.assign(this.popover.nativeElement.style, {\n position: strategy,\n left: x + 'px',\n top: y + 'px',\n visibility: 'visible',\n });\n });\n });\n }\n\n toggleValue(val: any) {\n const index = this.value.indexOf(val);\n if (index > -1) {\n this.value.splice(index, 1);\n } else {\n if (this.maxSelections && this.value.length >= this.maxSelections) return;\n this.value.push(val);\n }\n this.syncItems();\n this.emitValue();\n }\n\n selectAll() {\n if (!this.items) return;\n let itemsToSelect: any[] = [];\n if (this.maxSelections) {\n const availableItems = this.items\n .filter(item => !item.disabled && !this.value.includes(item.value))\n .slice(0, this.availableSelections)\n .map(item => item.value);\n itemsToSelect = [...this.value, ...availableItems];\n } else {\n itemsToSelect = this.items.filter(item => !item.disabled).map(item => item.value);\n }\n this.value = itemsToSelect;\n this.syncItems();\n this.emitValue();\n }\n\n clearAll() {\n this.value = [];\n this.syncItems();\n this.emitValue();\n }\n\n removeValue(event: MouseEvent, val: any) {\n event.stopPropagation();\n this.toggleValue(val);\n }\n\n private emitValue(): void {\n const snapshot = [...this.value];\n this.onChange(snapshot);\n this.valueChange.emit(snapshot);\n this.cdr.markForCheck();\n }\n\n private syncItems() {\n if (!this.items) return;\n this.selectedItems = [];\n this.items.forEach(item => {\n item.selected = this.value.includes(item.value);\n if (item.selected) {\n this.selectedItems.push({ label: item.getLabel(), value: item.value });\n }\n if (this.maxSelections && this.value.length >= this.maxSelections) {\n item.disabled = !this.value.includes(item.value);\n } else if (item.disabled) {\n item.disabled = false;\n }\n });\n this.refreshDisplay();\n this.cdr.markForCheck();\n }\n\n onSearchChange(q: string) {\n const filter = (q || '').toLowerCase();\n let visibleCount = 0;\n this.items.forEach(item => {\n const isVisible = item.getLabel().toLowerCase().includes(filter);\n item.hidden = !isVisible;\n if (isVisible) visibleCount++;\n });\n this.noResults = visibleCount === 0 && filter !== '';\n\n // Re-anchor the active option to the first visible match while open.\n if (this.isOpen) this.setActive(0);\n this.cdr.markForCheck();\n }\n\n // ---- Keyboard navigation (WAI-ARIA listbox pattern) ----\n\n private get navigableItems(): SelectItemComponent[] {\n return this.items ? this.items.filter(i => !i.hidden && !i.disabled) : [];\n }\n\n get activeDescendantId(): string | null {\n const list = this.navigableItems;\n return this.activeIndex >= 0 && this.activeIndex < list.length\n ? list[this.activeIndex].id\n : null;\n }\n\n private updateActiveStates() {\n const list = this.navigableItems;\n const activeItem = this.activeIndex >= 0 ? (list[this.activeIndex] ?? null) : null;\n this.items?.forEach(i => (i.active = i === activeItem));\n this.syncActiveDescendant();\n this.cdr.markForCheck();\n }\n\n private syncActiveDescendant() {\n const input = this.searchInput?.nativeElement?.querySelector('input');\n if (!input) return;\n const id = this.activeDescendantId;\n if (id) input.setAttribute('aria-activedescendant', id);\n else input.removeAttribute('aria-activedescendant');\n }\n\n private setActive(index: number) {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n this.activeIndex = Math.max(0, Math.min(index, list.length - 1));\n this.updateActiveStates();\n list[this.activeIndex]?.scrollIntoActiveView();\n }\n\n private setActiveItem(item: SelectItemComponent) {\n const idx = this.navigableItems.indexOf(item);\n if (idx >= 0) this.setActive(idx);\n }\n\n private moveActive(delta: number) {\n const list = this.navigableItems;\n if (!list.length) return;\n const next =\n this.activeIndex < 0 ? (delta > 0 ? 0 : list.length - 1) : this.activeIndex + delta;\n this.setActive(next); // setActive clamps (no wrap, matching shadcn)\n }\n\n private setActiveToSelectedOrFirst() {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n const selIdx = list.findIndex(i => this.value.includes(i.value));\n this.setActive(selIdx >= 0 ? selIdx : 0);\n }\n\n private initActiveAfterOpen() {\n if (this.pendingTypeaheadChar) {\n const c = this.pendingTypeaheadChar;\n this.pendingTypeaheadChar = '';\n this.setActiveToSelectedOrFirst();\n this.typeahead(c);\n } else {\n this.setActiveToSelectedOrFirst();\n }\n }\n\n private resetActive() {\n this.activeIndex = -1;\n this.typeaheadBuffer = '';\n this.pendingTypeaheadChar = '';\n this.items?.forEach(i => (i.active = false));\n }\n\n private isTypeaheadChar(event: KeyboardEvent): boolean {\n return (\n event.key.length === 1 &&\n event.key !== ' ' &&\n !event.ctrlKey &&\n !event.metaKey &&\n !event.altKey\n );\n }\n\n private typeahead(char: string) {\n const now = Date.now();\n // Reset the buffer based on elapsed time (correctness is not timer-driven).\n if (now - this.typeaheadTime > 500) this.typeaheadBuffer = '';\n this.typeaheadTime = now;\n this.typeaheadBuffer += char.toLowerCase();\n const match = this.navigableItems.find(i =>\n i.getLabel().toLowerCase().startsWith(this.typeaheadBuffer)\n );\n if (match) this.setActiveItem(match);\n }\n\n private selectActive() {\n const item = this.navigableItems[this.activeIndex];\n if (!item) return;\n // Multi-select: mirror SelectItemComponent.onClick — toggle and stay open.\n item.selected = !item.selected;\n this.selectService.registerClick(item.value, item.getLabel());\n // syncItems() ran synchronously via the service subscription; keep the\n // same option highlighted (its index may have shifted).\n this.setActiveItem(item);\n }\n\n onTriggerKeyDown(event: KeyboardEvent) {\n if (this.disabled) return;\n const key = event.key;\n\n if (!this.isOpen) {\n if (\n key === 'ArrowDown' ||\n key === 'ArrowUp' ||\n key === 'Enter' ||\n key === ' ' ||\n key === 'Home' ||\n key === 'End'\n ) {\n event.preventDefault();\n this.open();\n return;\n }\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.pendingTypeaheadChar = key;\n this.open();\n }\n return;\n }\n\n switch (key) {\n case 'ArrowDown':\n event.preventDefault();\n this.moveActive(1);\n break;\n case 'ArrowUp':\n event.preventDefault();\n this.moveActive(-1);\n break;\n case 'Home':\n event.preventDefault();\n this.setActive(0);\n break;\n case 'End':\n event.preventDefault();\n this.setActive(this.navigableItems.length - 1);\n break;\n case 'Enter':\n case ' ':\n event.preventDefault();\n this.selectActive();\n break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n case 'Tab':\n this.close();\n break;\n default:\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.typeahead(key);\n }\n }\n }\n\n // Keydown while the (optional) search input is focused. Arrows/Enter/Escape\n // drive the list; other keys fall through so the user can type to filter.\n onSearchKeyDown(event: KeyboardEvent) {\n if (!this.isOpen) return;\n switch (event.key) {\n case 'ArrowDown':\n event.preventDefault();\n this.moveActive(1);\n break;\n case 'ArrowUp':\n event.preventDefault();\n this.moveActive(-1);\n break;\n case 'Enter':\n event.preventDefault();\n this.selectActive();\n break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n }\n }\n\n ngOnDestroy() {\n this.sub.unsubscribe();\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n if (this.cleanup) this.cleanup();\n }\n\n // ControlValueAccessor\n onChange: any = () => {};\n onTouched: any = () => {};\n writeValue(v: any[]): void {\n this.value = Array.isArray(v) ? v : [];\n this.syncItems();\n this.refreshDisplay();\n this.cdr.markForCheck();\n }\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n protected cn = cn;\n}\n"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "select.service.ts",
|
|
24
|
+
"target": "ui/select.service.ts",
|
|
25
|
+
"content": "import { Injectable } from '@angular/core';\nimport { Subject } from 'rxjs';\n\n@Injectable()\nexport class SelectService {\n // Emits the value of the clicked item\n private selectedValueSource = new Subject<any>();\n selectedValue$ = this.selectedValueSource.asObservable();\n\n // Emits the label/text of the clicked item\n private selectedLabelSource = new Subject<string>();\n selectedLabel$ = this.selectedLabelSource.asObservable();\n\n registerClick(value: any, label: string) {\n this.selectedValueSource.next(value);\n this.selectedLabelSource.next(label);\n }\n}\n"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"path": "utils/cn.ts",
|
|
29
|
+
"target": "ui/utils/cn.ts",
|
|
30
|
+
"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"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "native-select",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Native Select",
|
|
5
|
+
"description": "Native Select component with size and invalid options.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "native-select.component.ts",
|
|
14
|
+
"target": "ui/native-select.component.ts",
|
|
15
|
+
"content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n forwardRef,\n ChangeDetectorRef,\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';\n\nconst nativeSelectVariants = cva(\n 'flex w-full appearance-none items-center rounded-md border border-input bg-background text-foreground shadow-sm transition-colors ' +\n 'focus:outline-none focus-visible:border-primary/80 focus-visible:ring-4 focus-visible:ring-ring/30 ' +\n 'disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n size: {\n xs: 'h-8 pl-2 pr-7 text-xs',\n sm: 'h-9 pl-3 pr-8 text-sm',\n default: 'h-10 pl-3 pr-8 text-sm',\n lg: 'h-11 pl-4 pr-9 text-base',\n },\n invalid: {\n true: 'border-destructive focus-visible:border-destructive/80 focus-visible:ring-destructive/30',\n false: '',\n },\n },\n defaultVariants: { size: 'default', invalid: false },\n }\n);\n\nexport type NativeSelectProps = VariantProps<typeof nativeSelectVariants>;\n\nexport type NativeSelectOption = {\n label: string;\n value: any;\n disabled?: boolean;\n};\n\n/**\n * A styled native `<select>`. Unlike `tolle-select` it uses the platform\n * dropdown, so it needs no portal or floating-ui and works well on mobile and\n * inside forms that must degrade without JavaScript.\n *\n * Options come from `options`, or from projected `<option>` elements when you\n * need `<optgroup>` or custom markup.\n * @new\n */\n@Component({\n selector: 'tolle-native-select',\n styles: [':host { display: block; }'],\n imports: [],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => NativeSelectComponent),\n multi: true,\n },\n ],\n template: `\n <div class=\"relative w-full\">\n <select\n [id]=\"id\"\n [disabled]=\"disabled\"\n [attr.aria-invalid]=\"invalid || null\"\n [attr.aria-label]=\"ariaLabel || null\"\n [class]=\"computedClass\"\n (change)=\"onSelectChange($event)\"\n (blur)=\"onTouched()\">\n @if (placeholder) {\n <option\n value=\"\"\n [selected]=\"value === null || value === undefined || value === ''\"\n disabled>\n {{ placeholder }}\n </option>\n }\n <!--\n The DOM value is the option's INDEX, not its value. A select element\n coerces every option value to a string, which collapses all object\n options to \"[object Object]\" and makes them indistinguishable on the\n way back out. Indexing keeps object- and number-valued options\n round-tripping exactly.\n -->\n @for (option of options; track option; let i = $index) {\n <option\n [value]=\"i\"\n [disabled]=\"option.disabled || false\"\n [selected]=\"option.value === value\">\n {{ option.label }}\n </option>\n }\n <ng-content></ng-content>\n </select>\n\n <i\n class=\"ri-arrow-down-s-line pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground\"\n aria-hidden=\"true\"></i>\n </div>\n `,\n})\nexport class NativeSelectComponent implements ControlValueAccessor {\n /** Id applied to the underlying `<select>`; pair it with a `tolle-label`. */\n @Input() id = `native-select-${Math.random().toString(36).slice(2, 11)}`;\n /** Options to render. Omit and project `<option>` elements for custom markup. @default [] */\n @Input() options: NativeSelectOption[] = [];\n /** Non-selectable prompt shown when no value is set. @default '' */\n @Input() placeholder = '';\n /** Height and text size of the control. @default 'default' */\n @Input() size: NativeSelectProps['size'] = 'default';\n /** Applies the destructive border and sets `aria-invalid`. @default false */\n @Input() invalid = false;\n /** Disables the control. @default false */\n @Input() disabled = false;\n /** Accessible name when there is no associated visible label. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the `<select>` via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the newly selected value whenever the user picks an option. */\n @Output() valueChange = new EventEmitter<any>();\n\n value: any = null;\n onChange: (value: any) => void = () => {};\n onTouched: () => void = () => {};\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n get computedClass() {\n return cn(nativeSelectVariants({ size: this.size, invalid: this.invalid }), this.class);\n }\n\n onSelectChange(event: Event): void {\n const raw = (event.target as HTMLSelectElement).value;\n\n // Options rendered from `options` carry their index as the DOM value, so we\n // can hand back the ORIGINAL value — object, number or string — untouched.\n // Anything else came from a projected <option>, where the string is all\n // there is.\n const index = Number(raw);\n const fromOptions =\n raw !== '' && Number.isInteger(index) && index >= 0 && index < this.options.length;\n const next = fromOptions ? this.options[index].value : raw;\n\n this.value = next;\n this.onChange(next);\n this.valueChange.emit(next);\n }\n\n writeValue(value: any): void {\n this.value = value;\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: (value: any) => void): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: () => void): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "utils/cn.ts",
|
|
19
|
+
"target": "ui/utils/cn.ts",
|
|
20
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "navigation-menu",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Navigation Menu",
|
|
5
|
+
"description": "Navigation Menu component with align options.",
|
|
6
|
+
"category": "navigation",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"@floating-ui/dom",
|
|
10
|
+
"class-variance-authority"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "navigation-menu.component.ts",
|
|
15
|
+
"target": "ui/navigation-menu.component.ts",
|
|
16
|
+
"content": "import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, Injectable, Input, OnDestroy, OnInit, Output, ViewChild, inject, OnChanges } from '@angular/core';\nimport { BehaviorSubject, Subscription } from 'rxjs';\nimport { computePosition, flip, shift, offset, size, autoUpdate } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nlet navigationMenuItemCounter = 0;\n\n/**\n * Coordinates the items of a single `tolle-navigation-menu`. Provided by\n * `NavigationMenuComponent`, injected by its descendants.\n *\n * Closing is deferred by `closeDelay` so a pointer travelling diagonally from a\n * trigger into its panel does not dismiss the menu mid-flight; re-entering\n * either surface calls `cancelClose()` and the pending close is dropped.\n */\n@Injectable()\nexport class NavigationMenuService {\n private openIdSource = new BehaviorSubject<string | null>(null);\n /** Emits the id of the currently open item, or `null` when all are closed. */\n readonly openId$ = this.openIdSource.asObservable();\n\n /** Milliseconds to wait before closing after the pointer leaves. */\n closeDelay = 150;\n\n private closeTimer: ReturnType<typeof setTimeout> | undefined;\n\n /** Id of the open item, or `null`. */\n get openId(): string | null {\n return this.openIdSource.value;\n }\n\n /** True while any panel is open. */\n get isAnyOpen(): boolean {\n return this.openIdSource.value !== null;\n }\n\n /** True while a deferred close is pending — exposed for tests/diagnostics. */\n get isCloseScheduled(): boolean {\n return this.closeTimer !== undefined;\n }\n\n isOpen(id: string): boolean {\n return this.openId === id;\n }\n\n /** Opens an item immediately and cancels any pending close. */\n open(id: string): void {\n this.cancelClose();\n if (this.openId !== id) this.openIdSource.next(id);\n }\n\n /** Closes immediately, dropping any pending close. */\n closeNow(): void {\n this.cancelClose();\n if (this.openId !== null) this.openIdSource.next(null);\n }\n\n /** Queues a close in `closeDelay` ms; a later `open`/`cancelClose` wins. */\n scheduleClose(): void {\n this.cancelClose();\n this.closeTimer = setTimeout(() => {\n this.closeTimer = undefined;\n this.closeNow();\n }, this.closeDelay);\n }\n\n /** Drops a pending close (pointer came back). */\n cancelClose(): void {\n if (this.closeTimer !== undefined) {\n clearTimeout(this.closeTimer);\n this.closeTimer = undefined;\n }\n }\n\n toggle(id: string): void {\n if (this.openId === id) this.closeNow();\n else this.open(id);\n }\n}\n\nconst navigationMenuVariants = cva('relative z-10 flex max-w-max flex-1 items-center justify-center', {\n variants: {\n align: {\n start: 'justify-start',\n center: 'justify-center',\n end: 'justify-end',\n },\n },\n defaultVariants: {\n align: 'center',\n },\n});\n\nexport type NavigationMenuProps = VariantProps<typeof navigationMenuVariants>;\n\nconst navigationMenuTriggerVariants = cva(\n 'group inline-flex w-max select-none items-center justify-center gap-1 rounded-md bg-background font-medium transition-colors ' +\n 'hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none ' +\n 'data-[state=open]:bg-accent/50 data-[state=open]:text-accent-foreground ' +\n 'disabled:pointer-events-none disabled:opacity-50',\n {\n variants: {\n size: {\n sm: 'h-8 px-3 text-xs',\n default: 'h-9 px-4 py-2 text-sm',\n lg: 'h-10 px-5 text-sm',\n },\n },\n defaultVariants: {\n size: 'default',\n },\n }\n);\n\nexport type NavigationMenuTriggerProps = VariantProps<typeof navigationMenuTriggerVariants>;\n\nconst navigationMenuLinkVariants = cva(\n 'flex select-none flex-col gap-1 rounded-md p-3 leading-none no-underline outline-none transition-colors ' +\n 'hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground',\n {\n variants: {\n variant: {\n default: 'text-foreground',\n muted: 'text-muted-foreground hover:text-accent-foreground',\n },\n active: {\n true: 'bg-accent text-accent-foreground',\n false: '',\n },\n },\n defaultVariants: {\n variant: 'default',\n active: false,\n },\n }\n);\n\nexport type NavigationMenuLinkProps = VariantProps<typeof navigationMenuLinkVariants>;\n\n/**\n * Site navigation menu: a row of links and triggers, where each trigger reveals\n * a floating panel on hover, focus or click.\n *\n * ```html\n * <tolle-navigation-menu>\n * <tolle-navigation-menu-list>\n * <tolle-navigation-menu-item>\n * <tolle-navigation-menu-trigger>Products</tolle-navigation-menu-trigger>\n * <tolle-navigation-menu-content class=\"w-[420px]\">\n * <tolle-navigation-menu-link href=\"/analytics\">Analytics</tolle-navigation-menu-link>\n * </tolle-navigation-menu-content>\n * </tolle-navigation-menu-item>\n * </tolle-navigation-menu-list>\n * </tolle-navigation-menu>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-navigation-menu',\n styles: [':host { display: block; }'],\n imports: [],\n providers: [NavigationMenuService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <nav [attr.aria-label]=\"ariaLabel\" [class]=\"computedClass\" (keydown)=\"onKeydown($event)\">\n <ng-content></ng-content>\n </nav>\n `\n})\nexport class NavigationMenuComponent implements OnChanges, OnInit, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Horizontal alignment of the list within the nav. @default 'center' */\n @Input() align: NavigationMenuProps['align'] = 'center';\n /** Accessible name for the `<nav>` landmark. @default 'Main' */\n @Input() ariaLabel = 'Main';\n /**\n * Milliseconds before a panel closes after the pointer leaves — the grace\n * period that lets the pointer travel diagonally into the panel. @default 150\n */\n @Input() set closeDelay(value: number) {\n this.service.closeDelay = value;\n }\n get closeDelay(): number {\n return this.service.closeDelay;\n }\n /** Extra Tailwind classes merged onto the `<nav>` via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the id of the item that opened, or `null` when the menu closes. */\n @Output() openChange = new EventEmitter<string | null>();\n\n private readonly service = inject(NavigationMenuService);\n private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);\n private readonly sub = new Subscription();\n private listening = false;\n\n protected get computedClass(): string {\n return cn(navigationMenuVariants({ align: this.align }), this.class);\n }\n\n ngOnInit(): void {\n this.sub.add(\n this.service.openId$.subscribe(id => {\n this.openChange.emit(id);\n if (id !== null) this.startListening();\n else this.stopListening();\n })\n );\n }\n\n protected onKeydown(event: KeyboardEvent): void {\n if (event.key === 'Escape' && this.service.isAnyOpen) {\n event.preventDefault();\n this.service.closeNow();\n }\n }\n\n // Capture phase so containers that stopPropagation() on their root cannot\n // prevent the outside click from reaching us.\n private outsideClick = (event: Event) => {\n if (!this.host.nativeElement.contains(event.target as Node)) this.service.closeNow();\n };\n\n private startListening(): void {\n if (this.listening) return;\n this.listening = true;\n document.addEventListener('pointerdown', this.outsideClick, true);\n }\n\n private stopListening(): void {\n if (!this.listening) return;\n this.listening = false;\n document.removeEventListener('pointerdown', this.outsideClick, true);\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n this.stopListening();\n this.service.cancelClose();\n }\n}\n\n/** The horizontal list that holds the navigation items. */\n@Component({\n selector: 'tolle-navigation-menu-list',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <ul [class]=\"cn('group flex flex-1 list-none items-center justify-center gap-1', class)\">\n <ng-content></ng-content>\n </ul>\n `\n})\nexport class NavigationMenuListComponent implements OnChanges{\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the `<ul>` via `cn()` (last-wins). */\n @Input() class = '';\n protected readonly cn = cn;\n}\n\n/** One entry in the list — a plain link, or a trigger plus its content panel. */\n@Component({\n selector: 'tolle-navigation-menu-item',\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n host: { '[class]': \"'contents'\" },\n template: `\n <li [class]=\"cn('relative', class)\">\n <ng-content></ng-content>\n </li>\n `\n})\nexport class NavigationMenuItemComponent implements OnChanges, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Explicit id for the item; one is generated when omitted. */\n @Input() id = `tolle-navigation-menu-item-${navigationMenuItemCounter++}`;\n /** Extra Tailwind classes merged onto the `<li>` via `cn()` (last-wins). */\n @Input() class = '';\n\n readonly service = inject(NavigationMenuService);\n protected readonly cn = cn;\n\n triggerElement: HTMLElement | null = null;\n\n get triggerId(): string {\n return `${this.id}-trigger`;\n }\n\n get contentId(): string {\n return `${this.id}-content`;\n }\n\n get isOpen(): boolean {\n return this.service.isOpen(this.id);\n }\n\n ngOnDestroy(): void {\n if (this.isOpen) this.service.closeNow();\n }\n}\n\n/** Button that reveals its item's panel on hover, focus or click. */\n@Component({\n selector: 'tolle-navigation-menu-trigger',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n #trigger\n type=\"button\"\n [id]=\"item.triggerId\"\n [disabled]=\"disabled\"\n aria-haspopup=\"true\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? item.contentId : null\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n [class]=\"computedClass\"\n (click)=\"onClick()\"\n (mouseenter)=\"onPointerEnter()\"\n (mouseleave)=\"onPointerLeave()\"\n (focus)=\"onPointerEnter()\"\n (keydown)=\"onKeydown($event)\"\n >\n <ng-content></ng-content>\n <i\n class=\"ri-arrow-down-s-line text-[16px] transition-transform duration-200\"\n [class.rotate-180]=\"isOpen\"\n aria-hidden=\"true\"\n ></i>\n </button>\n `\n})\nexport class NavigationMenuTriggerComponent implements OnChanges, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Prevents the panel from opening. @default false */\n @Input() disabled = false;\n /** Height/padding scale of the trigger. @default 'default' */\n @Input() size: NavigationMenuTriggerProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n protected readonly item = inject(NavigationMenuItemComponent);\n private readonly service = inject(NavigationMenuService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub: Subscription;\n\n constructor() {\n this.sub = this.service.openId$.subscribe(() => this.cdr.markForCheck());\n }\n\n @ViewChild('trigger') set triggerRef(ref: ElementRef<HTMLButtonElement> | undefined) {\n this.item.triggerElement = ref?.nativeElement ?? null;\n }\n\n protected get isOpen(): boolean {\n return this.item.isOpen;\n }\n\n protected get computedClass(): string {\n return cn(navigationMenuTriggerVariants({ size: this.size }), this.class);\n }\n\n protected onClick(): void {\n if (this.disabled) return;\n this.service.toggle(this.item.id);\n }\n\n protected onPointerEnter(): void {\n if (this.disabled) return;\n this.service.open(this.item.id);\n }\n\n protected onPointerLeave(): void {\n this.service.scheduleClose();\n }\n\n protected onKeydown(event: KeyboardEvent): void {\n if (this.disabled) return;\n if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n this.service.open(this.item.id);\n }\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n}\n\n/** The floating panel revealed by a trigger. Rendered only while open. */\n@Component({\n selector: 'tolle-navigation-menu-content',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @if (isOpen) {\n <div\n #panel\n [id]=\"item.contentId\"\n [attr.aria-labelledby]=\"item.triggerId\"\n data-state=\"open\"\n [class]=\"computedClass\"\n style=\"position: fixed; top: 0; left: 0;\"\n (mouseenter)=\"onPointerEnter()\"\n (mouseleave)=\"onPointerLeave()\"\n >\n <ng-content></ng-content>\n </div>\n }\n `\n})\nexport class NavigationMenuContentComponent implements OnChanges, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Preferred side of the trigger to render on; flips when space is tight. @default 'bottom' */\n @Input() placement: 'bottom' | 'bottom-start' | 'bottom-end' | 'top' | 'top-start' | 'top-end' =\n 'bottom';\n /** Gap in pixels between the trigger and the panel. @default 8 */\n @Input() sideOffset = 8;\n /** Extra Tailwind classes merged onto the panel via `cn()` (last-wins). */\n @Input() class = '';\n\n protected readonly item = inject(NavigationMenuItemComponent);\n private readonly service = inject(NavigationMenuService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub: Subscription;\n private cleanup?: () => void;\n\n constructor() {\n this.sub = this.service.openId$.subscribe(() => this.cdr.markForCheck());\n }\n\n @ViewChild('panel') set panelRef(ref: ElementRef<HTMLElement> | undefined) {\n this.stopPositioning();\n if (ref?.nativeElement) this.startPositioning(ref.nativeElement);\n }\n\n protected get isOpen(): boolean {\n return this.item.isOpen;\n }\n\n protected get computedClass(): string {\n return cn(\n 'z-[1000] overflow-auto rounded-md border border-border bg-popover p-2 text-popover-foreground',\n 'shadow-lg animate-in fade-in zoom-in-95',\n this.class\n );\n }\n\n protected onPointerEnter(): void {\n this.service.cancelClose();\n }\n\n protected onPointerLeave(): void {\n this.service.scheduleClose();\n }\n\n private startPositioning(panel: HTMLElement): void {\n const trigger = this.item.triggerElement;\n if (!trigger) return;\n this.cleanup = autoUpdate(trigger, panel, () => {\n computePosition(trigger, panel, {\n strategy: 'fixed',\n placement: this.placement,\n middleware: [\n offset(this.sideOffset),\n flip(),\n shift({ padding: 8 }),\n // Wide panels are common here — clamp to what the viewport can show\n // instead of letting them overflow.\n size({\n padding: 8,\n apply({ elements, availableWidth, availableHeight }) {\n Object.assign(elements.floating.style, {\n maxWidth: `${Math.max(0, availableWidth)}px`,\n maxHeight: `${Math.max(0, availableHeight)}px`,\n });\n },\n }),\n ],\n }).then(({ x, y }) => {\n Object.assign(panel.style, { left: `${x}px`, top: `${y}px` });\n });\n });\n }\n\n private stopPositioning(): void {\n this.cleanup?.();\n this.cleanup = undefined;\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n this.stopPositioning();\n }\n}\n\n/** A link inside a panel (or directly in the list, for flat entries). */\n@Component({\n selector: 'tolle-navigation-menu-link',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <a\n [attr.href]=\"href || null\"\n [attr.target]=\"target || null\"\n [attr.rel]=\"target === '_blank' ? 'noreferrer noopener' : null\"\n [attr.aria-current]=\"active ? 'page' : null\"\n [attr.data-active]=\"active ? '' : null\"\n [class]=\"computedClass\"\n (click)=\"navigate.emit($event)\"\n >\n <ng-content select=\"[label]\"></ng-content>\n <ng-content></ng-content>\n <ng-content select=\"[description]\"></ng-content>\n </a>\n `\n})\nexport class NavigationMenuLinkComponent implements OnChanges{\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Destination URL. Omit to render a non-navigating item. */\n @Input() href = '';\n /** Anchor target; `_blank` also sets a safe `rel`. */\n @Input() target = '';\n /** Marks the link as the current page. @default false */\n @Input() active = false;\n /** Visual style of the link. @default 'default' */\n @Input() variant: NavigationMenuLinkProps['variant'] = 'default';\n /** Extra Tailwind classes merged onto the anchor via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the click event when the link is activated. */\n @Output() navigate = new EventEmitter<MouseEvent>();\n\n protected get computedClass(): string {\n return cn(\n navigationMenuLinkVariants({ variant: this.variant, active: this.active }),\n this.class\n );\n }\n}\n"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "utils/cn.ts",
|
|
20
|
+
"target": "ui/utils/cn.ts",
|
|
21
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "otp-group",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Otp Group",
|
|
5
|
+
"description": "Otp Group component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "otp-group.component.ts",
|
|
12
|
+
"target": "ui/otp-group.component.ts",
|
|
13
|
+
"content": "import { Component } from '@angular/core';\n\n@Component({\n selector: 'tolle-otp-group',\n imports: [],\n template: `\n <div class=\"flex items-center\"><ng-content></ng-content></div>\n `,\n styles: `:host { display: block; }`\n})\nexport class OtpGroupComponent {\n\n}\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "otp-slot",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Otp Slot",
|
|
5
|
+
"description": "Otp Slot component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "otp-slot.component.ts",
|
|
12
|
+
"target": "ui/otp-slot.component.ts",
|
|
13
|
+
"content": "import { Component, Input } from '@angular/core';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-otp-slot',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n template: `\n <div [class]=\"computedClass\">\n <span class=\"text-lg font-medium\">{{ char || '' }}</span>\n @if (isActive && !char) {\n <div class=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n <div class=\"animate-caret-blink h-6 w-0.5 bg-foreground\"></div>\n </div>\n }\n </div>\n `,\n})\nexport class OtpSlotComponent {\n @Input() char: string | undefined = '';\n @Input() isActive: boolean = false;\n @Input() isFirst: boolean = false;\n @Input() isLast: boolean = false;\n @Input() class: string = '';\n protected cn = cn;\n\n get computedClass() {\n return cn(\n // Base styles (matching input container)\n 'relative flex h-10 w-10 items-center justify-center',\n 'transition-all duration-200',\n 'text-center font-medium select-none',\n\n // Border styling - exactly like input\n 'border border-input shadow-sm bg-background',\n\n // FOCUS STYLING - EXACTLY LIKE INPUT COMPONENT\n this.isActive && [\n // Darker border on focus\n 'border-primary/80',\n // Remove shadow on focus\n 'shadow-none',\n // Focus ring with same styling as input\n 'ring-4',\n 'ring-ring/30',\n 'ring-offset-0',\n // Ensure it's above other slots\n 'z-10',\n ],\n\n // Filled state\n this.char && !this.isActive && 'border-primary/60',\n\n // Border radius\n this.isFirst && 'rounded-l-md',\n this.isLast && 'rounded-r-md',\n\n // Remove left border for all but first slot\n !this.isFirst && '-ml-px',\n\n this.class\n );\n }\n}\n"
|
|
14
|
+
},
|
|
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,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "otp",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Otp",
|
|
5
|
+
"description": "Otp component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "otp.component.ts",
|
|
12
|
+
"target": "ui/otp.component.ts",
|
|
13
|
+
"content": "import { Component, Input, forwardRef, ChangeDetectorRef } from '@angular/core';\n\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-otp',\n styles: [':host { display: block; }'],\n imports: [FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => OtpComponent),\n multi: true,\n },\n ],\n template: `\n <div class=\"relative flex items-center gap-2\">\n <input\n #hiddenInput\n type=\"text\"\n inputmode=\"numeric\"\n autocomplete=\"one-time-code\"\n aria-label=\"One-time code\"\n [maxLength]=\"length\"\n class=\"absolute inset-0 z-20 cursor-default opacity-0\"\n [(ngModel)]=\"value\"\n (input)=\"onInputChange($event)\"\n (focus)=\"isFocused = true\"\n (blur)=\"isFocused = false; onTouched()\"\n [disabled]=\"disabled\" />\n\n @if (auto) {\n <div class=\"pointer-events-none flex items-center gap-2\">\n <div class=\"flex items-center\">\n @for (_ of slots; track _; let i = $index) {\n <div [class]=\"getSlotClass(i)\">\n {{ value[i] || '' }}\n @if (isFocused && value.length === i) {\n <div class=\"absolute inset-0 flex items-center justify-center\">\n <div class=\"animate-caret-blink h-4 w-px bg-foreground duration-1000\"></div>\n </div>\n }\n </div>\n }\n </div>\n </div>\n }\n\n @if (!auto) {\n <div class=\"pointer-events-none flex items-center gap-2\">\n <ng-content></ng-content>\n </div>\n }\n </div>\n `,\n})\nexport class OtpComponent implements ControlValueAccessor {\n @Input()\n set length(val: number) {\n const numericVal = Number(val);\n this._length = numericVal;\n this.slots = Array(numericVal).fill(0);\n }\n get length() {\n return this._length;\n }\n private _length = 6;\n\n @Input() disabled = false;\n @Input() auto = false;\n\n protected slots: number[] = Array(6).fill(0);\n\n value: string = '';\n isFocused = false;\n\n onChange: any = () => {};\n onTouched: any = () => {};\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n onInputChange(event: any) {\n const val = event.target.value.replace(/\\D/g, '');\n this.value = val;\n this.onChange(val);\n this.cdr.markForCheck();\n }\n\n // Visual logic for the automated slots\n getSlotClass(index: number) {\n const isActive = this.isFocused && this.value.length === index;\n\n return cn(\n 'relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all bg-background',\n index === 0 && 'rounded-l-md border-l',\n index === this.length - 1 && 'rounded-r-md',\n isActive && 'z-30 ring-2 ring-ring ring-offset-background'\n );\n }\n\n writeValue(value: any): void {\n this.value = value || '';\n this.cdr.markForCheck();\n }\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\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,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pagination",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Pagination",
|
|
5
|
+
"description": "Pagination component.",
|
|
6
|
+
"category": "navigation",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"input",
|
|
9
|
+
"select",
|
|
10
|
+
"select-item"
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [
|
|
13
|
+
"@floating-ui/dom",
|
|
14
|
+
"class-variance-authority"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "pagination.component.ts",
|
|
19
|
+
"target": "ui/pagination.component.ts",
|
|
20
|
+
"content": "import {\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n Component,\n EventEmitter,\n inject,\n Input,\n OnChanges,\n OnInit,\n Output,\n SimpleChanges,\n} from '@angular/core';\n\nimport { cn } from './utils/cn';\nimport { FormsModule } from '@angular/forms';\nimport { SelectComponent } from './select.component';\nimport { SelectItemComponent } from './select-item.component';\n\n@Component({\n selector: 'tolle-pagination',\n styles: [':host { display: block; }'],\n imports: [FormsModule, SelectComponent, SelectItemComponent],\n template: `\n <nav role=\"navigation\" aria-label=\"pagination\">\n <div [class]=\"cn('flex items-center justify-between px-2 py-1', class)\">\n @if (showCurrentPageInfo) {\n <div class=\"text-sm text-muted-foreground\">\n @if (currentPageInfoTemplate) {\n {{ pageReport }}\n } @else {\n Showing {{ first }} to {{ last }} of {{ totalRecords }} entries\n }\n </div>\n }\n\n <div class=\"flex items-center space-x-6 lg:space-x-8\">\n @if (showPageOptions) {\n <div class=\"flex items-center space-x-2\">\n <p class=\"text-sm font-medium\">Rows per page</p>\n <tolle-select\n class=\"w-[70px]\"\n size=\"sm\"\n [ngModel]=\"currentPageSize\"\n (ngModelChange)=\"sizeChange($event)\">\n @for (opt of pageSizeOptions; track opt) {\n <tolle-select-item [value]=\"opt\">\n {{ opt }}\n </tolle-select-item>\n }\n </tolle-select>\n </div>\n }\n\n <div class=\"flex items-center space-x-2\">\n @if (!showPageLinks) {\n <div class=\"flex w-[100px] items-center justify-center text-sm font-medium\">\n Page {{ currentPage }} of {{ totalPages }}\n </div>\n }\n\n @if (showPageLinks) {\n <div class=\"flex items-center space-x-1\">\n <button\n (click)=\"previousPage()\"\n [disabled]=\"currentPage === 1\"\n aria-label=\"Go to previous page\"\n [class]=\"navBtnClass\">\n <i aria-hidden=\"true\" class=\"ri-arrow-left-s-line\"></i>\n </button>\n @for (page of displayPageIndex; track page) {\n <button\n (click)=\"selectPage(page)\"\n [attr.aria-current]=\"currentPage === page ? 'page' : null\"\n [class]=\"\n cn(\n 'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n currentPage === page\n ? 'bg-primary font-medium text-primary-foreground'\n : 'hover:bg-accent hover:text-accent-foreground'\n )\n \">\n {{ page }}\n </button>\n }\n <button\n (click)=\"nextPage()\"\n [disabled]=\"currentPage === totalPages || totalPages === 0\"\n aria-label=\"Go to next page\"\n [class]=\"navBtnClass\">\n <i aria-hidden=\"true\" class=\"ri-arrow-right-s-line\"></i>\n </button>\n </div>\n }\n </div>\n </div>\n </div>\n </nav>\n `,\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class PaginationComponent implements OnInit, OnChanges {\n @Input() class = '';\n @Input() showPageLinks = true;\n @Input() showPageOptions = true;\n @Input() showCurrentPageInfo = true;\n @Input() currentPageInfoTemplate?: string;\n\n @Input() totalRecords = 0;\n @Input() currentPageSize = 10;\n @Input() currentPage = 1;\n @Input() pageSizeOptions: number[] = [10, 20, 30, 50];\n\n @Output() onPageNumberChange = new EventEmitter<number>();\n @Output() onPageSizeChange = new EventEmitter<number>();\n\n totalPages = 0;\n first = 0;\n last = 0;\n displayPageIndex: number[] = [];\n pageReport = '';\n\n private initialized = false;\n private cd = inject(ChangeDetectorRef);\n protected cn = cn;\n\n navBtnClass =\n 'h-8 w-8 p-0 flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer';\n\n ngOnInit(): void {\n if (this.pageSizeOptions.length === 0) {\n this.pageSizeOptions = [10, 20, 30, 50];\n }\n this.initializePagination();\n }\n\n ngOnChanges(changes: SimpleChanges): void {\n // Only re-init if meaningful data changes\n if (changes['totalRecords'] || changes['currentPage'] || changes['currentPageSize']) {\n this.initializePagination();\n }\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.cd.markForCheck();\n }\n\n private initializePagination(): void {\n this.calcPagination();\n if (!this.initialized) {\n this.initialized = true;\n }\n this.cd.detectChanges();\n }\n\n nextPage() {\n if (this.totalPages > this.currentPage) {\n this.currentPage++;\n this.emitChange();\n }\n }\n\n previousPage() {\n if (this.currentPage > 1) {\n this.currentPage--;\n this.emitChange();\n }\n }\n\n selectPage(page: number) {\n if (this.currentPage === page) return;\n this.currentPage = page;\n this.emitChange();\n }\n\n sizeChange(size: number) {\n this.currentPageSize = size;\n this.currentPage = 1; // Reset to page 1 on size change\n this.emitChange();\n }\n\n private emitChange(): void {\n this.calcPagination();\n this.onPageNumberChange.emit(this.currentPage);\n this.onPageSizeChange.emit(this.currentPageSize);\n this.cd.detectChanges();\n }\n\n private calcPagination(): void {\n this.totalPages = Math.ceil(this.totalRecords / this.currentPageSize) || 0;\n\n // Bounds check\n if (this.currentPage > this.totalPages && this.totalPages > 0) {\n this.currentPage = this.totalPages;\n }\n\n this.first = this.totalRecords === 0 ? 0 : (this.currentPage - 1) * this.currentPageSize + 1;\n this.last = Math.min(this.totalRecords, this.currentPage * this.currentPageSize);\n\n // Calculate Sliding Window for Page Numbers (Max 5 visible)\n const pages: number[] = Array.from({ length: this.totalPages }, (_, i) => i + 1);\n\n if (this.totalPages <= 5) {\n this.displayPageIndex = pages;\n } else {\n let start = Math.max(0, this.currentPage - 3);\n let end = start + 5;\n\n if (end > this.totalPages) {\n end = this.totalPages;\n start = end - 5;\n }\n this.displayPageIndex = pages.slice(start, end);\n }\n\n // Template Parsing\n if (this.currentPageInfoTemplate) {\n this.pageReport = this.currentPageInfoTemplate.replace(\n /{first}|{last}|{totalRecords}|{currentPage}|{currentPageSize}|{totalPages}/g,\n match => {\n switch (match) {\n case '{first}':\n return `${this.first}`;\n case '{last}':\n return `${this.last}`;\n case '{totalRecords}':\n return `${this.totalRecords}`;\n case '{totalPages}':\n return `${this.totalPages}`;\n case '{currentPage}':\n return `${this.currentPage}`;\n case '{currentPageSize}':\n return `${this.currentPageSize}`;\n default:\n return match;\n }\n }\n );\n }\n }\n}\n"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "select.service.ts",
|
|
24
|
+
"target": "ui/select.service.ts",
|
|
25
|
+
"content": "import { Injectable } from '@angular/core';\nimport { Subject } from 'rxjs';\n\n@Injectable()\nexport class SelectService {\n // Emits the value of the clicked item\n private selectedValueSource = new Subject<any>();\n selectedValue$ = this.selectedValueSource.asObservable();\n\n // Emits the label/text of the clicked item\n private selectedLabelSource = new Subject<string>();\n selectedLabel$ = this.selectedLabelSource.asObservable();\n\n registerClick(value: any, label: string) {\n this.selectedValueSource.next(value);\n this.selectedLabelSource.next(label);\n }\n}\n"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"path": "utils/cn.ts",
|
|
29
|
+
"target": "ui/utils/cn.ts",
|
|
30
|
+
"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"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|