@tolle_/tolle-ui 18.2.23 → 18.2.25

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.
Files changed (183) hide show
  1. package/esm2022/lib/accordion-item.component.mjs +17 -5
  2. package/esm2022/lib/alert-dialog-dynamic.component.mjs +1 -1
  3. package/esm2022/lib/alert-dialog.component.mjs +48 -10
  4. package/esm2022/lib/alert-dialog.service.mjs +13 -2
  5. package/esm2022/lib/alert.component.mjs +5 -5
  6. package/esm2022/lib/avatar.component.mjs +30 -9
  7. package/esm2022/lib/badge.component.mjs +41 -15
  8. package/esm2022/lib/breadcrumb-link.component.mjs +5 -5
  9. package/esm2022/lib/button.component.mjs +14 -3
  10. package/esm2022/lib/calendar.component.mjs +99 -17
  11. package/esm2022/lib/card.component.mjs +29 -9
  12. package/esm2022/lib/carousel/carousel.component.mjs +19 -3
  13. package/esm2022/lib/checkbox.component.mjs +44 -21
  14. package/esm2022/lib/collapsible.component.mjs +12 -4
  15. package/esm2022/lib/country-selector.component.mjs +140 -20
  16. package/esm2022/lib/data-table.component.mjs +27 -16
  17. package/esm2022/lib/date-picker.component.mjs +60 -25
  18. package/esm2022/lib/date-range-picker.component.mjs +64 -26
  19. package/esm2022/lib/dropdown-item.component.mjs +19 -5
  20. package/esm2022/lib/dropdown-label.component.mjs +3 -3
  21. package/esm2022/lib/dropdown-menu.component.mjs +3 -3
  22. package/esm2022/lib/dropdown-separator.component.mjs +3 -3
  23. package/esm2022/lib/dropdown-trigger.directive.mjs +7 -3
  24. package/esm2022/lib/empty-state.component.mjs +3 -3
  25. package/esm2022/lib/hover-card.component.mjs +4 -2
  26. package/esm2022/lib/input.component.mjs +7 -5
  27. package/esm2022/lib/label.component.mjs +2 -2
  28. package/esm2022/lib/masked-input.component.mjs +3 -3
  29. package/esm2022/lib/modal.component.mjs +58 -22
  30. package/esm2022/lib/modal.service.mjs +13 -1
  31. package/esm2022/lib/multi-select.component.mjs +228 -20
  32. package/esm2022/lib/otp.component.mjs +6 -3
  33. package/esm2022/lib/pagination.component.mjs +19 -9
  34. package/esm2022/lib/progress.component.mjs +8 -5
  35. package/esm2022/lib/radio-group.component.mjs +51 -3
  36. package/esm2022/lib/radio-item.component.mjs +46 -8
  37. package/esm2022/lib/radio-service.mjs +60 -1
  38. package/esm2022/lib/range-calendar.component.mjs +317 -98
  39. package/esm2022/lib/resizable-panel.component.mjs +5 -4
  40. package/esm2022/lib/scroll-area.component.mjs +5 -5
  41. package/esm2022/lib/segment.component.mjs +73 -7
  42. package/esm2022/lib/select-item.component.mjs +20 -2
  43. package/esm2022/lib/select-separator.component.mjs +3 -3
  44. package/esm2022/lib/select.component.mjs +221 -14
  45. package/esm2022/lib/separator.component.mjs +12 -6
  46. package/esm2022/lib/sheet-ref.mjs +8 -1
  47. package/esm2022/lib/sheet-wrapper.component.mjs +27 -11
  48. package/esm2022/lib/sheet.component.mjs +57 -16
  49. package/esm2022/lib/sheet.service.mjs +5 -1
  50. package/esm2022/lib/sidebar.component.mjs +73 -15
  51. package/esm2022/lib/skeleton.component.mjs +19 -5
  52. package/esm2022/lib/switch.component.mjs +59 -53
  53. package/esm2022/lib/tabs.component.mjs +75 -13
  54. package/esm2022/lib/tag-input.component.mjs +6 -3
  55. package/esm2022/lib/textarea.component.mjs +9 -7
  56. package/esm2022/lib/theme.service.mjs +114 -138
  57. package/esm2022/lib/toaster.component.mjs +19 -9
  58. package/esm2022/lib/toggle-group.component.mjs +76 -4
  59. package/esm2022/lib/tolle-config.mjs +1 -1
  60. package/esm2022/lib/tooltip.directive.mjs +41 -17
  61. package/esm2022/lib/utils/color.mjs +324 -0
  62. package/esm2022/public-api.mjs +2 -1
  63. package/fesm2022/tolle-ui.mjs +2715 -682
  64. package/fesm2022/tolle-ui.mjs.map +1 -1
  65. package/lib/alert-dialog.component.d.ts +10 -1
  66. package/lib/avatar.component.d.ts +16 -3
  67. package/lib/badge.component.d.ts +17 -5
  68. package/lib/button.component.d.ts +8 -1
  69. package/lib/calendar.component.d.ts +21 -3
  70. package/lib/card.component.d.ts +6 -0
  71. package/lib/carousel/carousel.component.d.ts +1 -0
  72. package/lib/checkbox.component.d.ts +12 -4
  73. package/lib/country-selector.component.d.ts +11 -2
  74. package/lib/date-picker.component.d.ts +2 -1
  75. package/lib/date-range-picker.component.d.ts +5 -2
  76. package/lib/dropdown-item.component.d.ts +4 -2
  77. package/lib/dropdown-trigger.directive.d.ts +1 -1
  78. package/lib/modal.component.d.ts +6 -0
  79. package/lib/multi-select.component.d.ts +20 -0
  80. package/lib/otp.component.d.ts +1 -0
  81. package/lib/progress.component.d.ts +1 -0
  82. package/lib/radio-group.component.d.ts +8 -0
  83. package/lib/radio-item.component.d.ts +10 -4
  84. package/lib/radio-service.d.ts +29 -0
  85. package/lib/range-calendar.component.d.ts +33 -4
  86. package/lib/segment.component.d.ts +13 -0
  87. package/lib/select-item.component.d.ts +4 -1
  88. package/lib/select.component.d.ts +20 -0
  89. package/lib/separator.component.d.ts +2 -0
  90. package/lib/sheet-wrapper.component.d.ts +4 -0
  91. package/lib/sheet.component.d.ts +10 -1
  92. package/lib/sidebar.component.d.ts +2 -0
  93. package/lib/skeleton.component.d.ts +9 -3
  94. package/lib/switch.component.d.ts +13 -20
  95. package/lib/tabs.component.d.ts +11 -1
  96. package/lib/theme.service.d.ts +21 -6
  97. package/lib/toaster.component.d.ts +2 -2
  98. package/lib/toggle-group.component.d.ts +20 -2
  99. package/lib/tolle-config.d.ts +8 -0
  100. package/lib/tooltip.directive.d.ts +3 -0
  101. package/lib/utils/color.d.ts +87 -0
  102. package/package.json +4 -1
  103. package/preset.js +28 -0
  104. package/public-api.d.ts +1 -0
  105. package/registry/docs-content.json +4214 -0
  106. package/registry/llms-full.txt +1599 -0
  107. package/registry/llms.txt +83 -0
  108. package/registry/manifest.json +4925 -0
  109. package/registry/r/accordion-item.json +21 -0
  110. package/registry/r/accordion.json +23 -0
  111. package/registry/r/alert-dialog-dynamic.json +31 -0
  112. package/registry/r/alert-dialog.json +26 -0
  113. package/registry/r/alert.json +23 -0
  114. package/registry/r/aspect-ratio.json +21 -0
  115. package/registry/r/avatar-fallback.json +16 -0
  116. package/registry/r/avatar.json +23 -0
  117. package/registry/r/badge.json +23 -0
  118. package/registry/r/breadcrumb-item.json +16 -0
  119. package/registry/r/breadcrumb-link.json +16 -0
  120. package/registry/r/breadcrumb-separator.json +16 -0
  121. package/registry/r/breadcrumb.json +21 -0
  122. package/registry/r/button-group.json +21 -0
  123. package/registry/r/button.json +23 -0
  124. package/registry/r/calendar.json +23 -0
  125. package/registry/r/card.json +21 -0
  126. package/registry/r/carousel.json +18 -0
  127. package/registry/r/checkbox.json +23 -0
  128. package/registry/r/collapsible.json +21 -0
  129. package/registry/r/context-menu-trigger.json +23 -0
  130. package/registry/r/context-menu.json +28 -0
  131. package/registry/r/country-selector.json +31 -0
  132. package/registry/r/data-table.json +38 -0
  133. package/registry/r/date-picker.json +27 -0
  134. package/registry/r/date-range-picker.json +32 -0
  135. package/registry/r/dropdown-item.json +21 -0
  136. package/registry/r/dropdown-label.json +16 -0
  137. package/registry/r/dropdown-menu.json +16 -0
  138. package/registry/r/dropdown-separator.json +16 -0
  139. package/registry/r/dropdown-trigger.json +20 -0
  140. package/registry/r/empty-state.json +23 -0
  141. package/registry/r/hover-card.json +23 -0
  142. package/registry/r/input.json +21 -0
  143. package/registry/r/label.json +21 -0
  144. package/registry/r/masked-input.json +21 -0
  145. package/registry/r/modal.json +36 -0
  146. package/registry/r/multi-select.json +33 -0
  147. package/registry/r/otp-group.json +16 -0
  148. package/registry/r/otp-slot.json +21 -0
  149. package/registry/r/otp.json +21 -0
  150. package/registry/r/pagination.json +32 -0
  151. package/registry/r/phone-number-input.json +34 -0
  152. package/registry/r/popover-content.json +21 -0
  153. package/registry/r/popover.json +18 -0
  154. package/registry/r/progress.json +21 -0
  155. package/registry/r/radio-group.json +26 -0
  156. package/registry/r/radio-item.json +26 -0
  157. package/registry/r/range-calendar.json +28 -0
  158. package/registry/r/resizable-panel-item.json +23 -0
  159. package/registry/r/resizable-panel.json +23 -0
  160. package/registry/r/resizable.json +21 -0
  161. package/registry/r/scroll-area.json +21 -0
  162. package/registry/r/segment.json +21 -0
  163. package/registry/r/select-group.json +21 -0
  164. package/registry/r/select-item.json +26 -0
  165. package/registry/r/select-separator.json +21 -0
  166. package/registry/r/select.json +31 -0
  167. package/registry/r/separator.json +21 -0
  168. package/registry/r/sheet-wrapper.json +31 -0
  169. package/registry/r/sheet.json +21 -0
  170. package/registry/r/sidebar.json +21 -0
  171. package/registry/r/skeleton.json +23 -0
  172. package/registry/r/slider.json +21 -0
  173. package/registry/r/switch.json +23 -0
  174. package/registry/r/tabs.json +23 -0
  175. package/registry/r/tag-input.json +25 -0
  176. package/registry/r/textarea.json +21 -0
  177. package/registry/r/toaster.json +26 -0
  178. package/registry/r/toggle-group.json +25 -0
  179. package/registry/r/toggle.json +23 -0
  180. package/registry/r/tolle-cell.json +16 -0
  181. package/registry/r/tooltip.json +23 -0
  182. package/registry/registry.json +1540 -0
  183. package/theme.css +60 -5
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "context-menu-trigger",
3
+ "type": "registry:ui",
4
+ "title": "Context Menu Trigger",
5
+ "description": "Context Menu Trigger component.",
6
+ "category": "overlays",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "@floating-ui/dom"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "context-menu-trigger.directive.ts",
14
+ "target": "ui/context-menu-trigger.directive.ts",
15
+ "content": "import { Directive, ElementRef, EventEmitter, HostListener, inject, Input, Output } from '@angular/core';\nimport { ContextMenuService, ContextMenuItem } from './context-menu.service';\n\n@Directive({\n selector: '[tolleContextMenuTrigger]',\n standalone: true\n})\nexport class ContextMenuTriggerDirective {\n private elementRef = inject(ElementRef);\n private contextMenuService = inject(ContextMenuService);\n\n @Input('tolleContextMenuTrigger') items: ContextMenuItem[] = [];\n @Output() action = new EventEmitter<string>();\n\n @HostListener('contextmenu', ['$event'])\n onContextMenu(event: MouseEvent) {\n event.stopPropagation();\n this.contextMenuService.open({\n event,\n items: this.items,\n triggerElement: this.elementRef.nativeElement,\n onAction: (actionId) => this.action.emit(actionId)\n });\n }\n}\n"
16
+ },
17
+ {
18
+ "path": "context-menu.service.ts",
19
+ "target": "ui/context-menu.service.ts",
20
+ "content": "import { Injectable, EventEmitter } from '@angular/core';\nimport { computePosition, offset, shift, flip, size } from '@floating-ui/dom';\n\nexport type ContextMenuItem = {\n id?: string;\n label?: string;\n icon?: string;\n disabled?: boolean;\n destructive?: boolean;\n separator?: boolean;\n submenu?: ContextMenuItem[];\n shortcut?: string;\n checked?: boolean;\n type?: 'default' | 'checkbox' | 'radio';\n}\n\nexport type ContextMenuState = {\n x: number;\n y: number;\n items: ContextMenuItem[];\n isOpen: boolean;\n triggerElement?: HTMLElement;\n onAction?: (actionId: string) => void;\n}\n\n@Injectable({\n providedIn: 'root'\n})\nexport class ContextMenuService {\n private _state: ContextMenuState = {\n x: 0,\n y: 0,\n items: [],\n isOpen: false\n };\n\n readonly stateChanged = new EventEmitter<ContextMenuState>();\n\n open(config: {\n event: MouseEvent;\n items?: ContextMenuItem[];\n triggerElement?: HTMLElement;\n onAction?: (actionId: string) => void;\n }) {\n const { event, items = [], triggerElement, onAction } = config;\n\n // Prevent browser context menu\n event.preventDefault();\n\n this._state = {\n x: event.clientX,\n y: event.clientY,\n items,\n isOpen: true,\n triggerElement,\n onAction\n };\n\n this.stateChanged.emit(this._state);\n }\n\n close() {\n this._state = { ...this._state, isOpen: false };\n this.stateChanged.emit(this._state);\n }\n\n async positionMenu(menuElement: HTMLElement) {\n const state = this._state;\n if (!state.isOpen || !menuElement) return;\n\n // Create a precise virtual element for the mouse coordinates\n const virtualEl = {\n getBoundingClientRect() {\n return {\n width: 0,\n height: 0,\n x: state.x,\n y: state.y,\n top: state.y,\n left: state.x,\n right: state.x,\n bottom: state.y,\n };\n }\n };\n\n const { x, y } = await computePosition(virtualEl as any, menuElement, {\n placement: 'bottom-start', // Standard: Menu top-left aligns with mouse\n strategy: 'fixed',\n middleware: [\n offset(2), // Slight offset so mouse isn't covering the first pixel\n flip({\n padding: 10,\n fallbackPlacements: ['bottom-end', 'top-start', 'top-end']\n }),\n shift({ padding: 10 }),\n size({\n apply({ availableHeight, availableWidth, elements }) {\n Object.assign(elements.floating.style, {\n maxHeight: `${Math.max(100, availableHeight - 16)}px`,\n maxWidth: `${Math.max(200, availableWidth - 16)}px`,\n overflowY: 'auto'\n });\n }\n })\n ]\n });\n\n Object.assign(menuElement.style, {\n position: 'fixed',\n left: '0',\n top: '0',\n transform: `translate(${Math.round(x)}px, ${Math.round(y)}px)`\n });\n }\n\n async positionSubmenu(triggerElement: HTMLElement, submenuElement: HTMLElement) {\n if (!triggerElement || !submenuElement) return;\n\n const { x, y } = await computePosition(triggerElement, submenuElement, {\n placement: 'right-start', // Standard: Submenu opens to the right\n strategy: 'fixed',\n middleware: [\n offset(-4), // Overlap slightly with parent menu\n flip({\n padding: 10,\n fallbackPlacements: ['left-start', 'bottom-start']\n }),\n shift({ padding: 10 }),\n size({\n apply({ availableHeight, availableWidth, elements }) {\n Object.assign(elements.floating.style, {\n maxHeight: `${Math.max(100, availableHeight - 16)}px`,\n maxWidth: `${Math.max(200, availableWidth - 16)}px`,\n overflowY: 'auto'\n });\n }\n })\n ]\n });\n\n Object.assign(submenuElement.style, {\n position: 'fixed',\n left: '0',\n top: '0',\n transform: `translate(${Math.round(x)}px, ${Math.round(y)}px)`\n });\n }\n\n performAction(actionId: string) {\n this._state.onAction?.(actionId);\n this.close();\n }\n}"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "context-menu",
3
+ "type": "registry:ui",
4
+ "title": "Context Menu",
5
+ "description": "Context Menu component.",
6
+ "category": "overlays",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "@floating-ui/dom"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "context-menu.component.ts",
14
+ "target": "ui/context-menu.component.ts",
15
+ "content": "import { Component, ElementRef, HostListener, inject, OnDestroy, OnInit, ViewChild, ViewChildren, QueryList, ChangeDetectorRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ContextMenuItem, ContextMenuService, ContextMenuState } from './context-menu.service';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-context-menu',\n standalone: true,\n imports: [CommonModule],\n template: `\n @if (isOpen) {\n <div\n #menu\n role=\"menu\"\n aria-orientation=\"vertical\"\n tabindex=\"-1\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n class=\"fixed z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md\n data-[state=closed]:animate-out data-[state=open]:animate-in\n data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\n data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95\n slide-in-from-top-2\"\n [style.visibility]=\"isPositioned ? 'visible' : 'hidden'\"\n [style.opacity]=\"isPositioned ? '1' : '0'\"\n style=\"will-change: transform; top: 0; left: 0;\"\n (keydown)=\"onKeyDown($event)\"\n >\n <div class=\"flex flex-col\">\n @for (item of items; track $index) {\n @if (item.separator) {\n <div role=\"separator\" class=\"h-px -mx-1 my-1 bg-border\"></div>\n } @else {\n <div class=\"relative group\">\n <button\n #itemButton\n type=\"button\"\n role=\"menuitem\"\n [attr.aria-haspopup]=\"item.submenu ? 'true' : null\"\n [attr.aria-expanded]=\"openSubmenuId === item.id ? 'true' : null\"\n [attr.aria-disabled]=\"item.disabled ? 'true' : null\"\n (click)=\"onItemClick(item)\"\n (mouseenter)=\"onItemMouseEnter(item, itemButton)\"\n (mouseleave)=\"onItemMouseLeave()\"\n [disabled]=\"item.disabled\"\n [class]=\"cn(\n 'relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',\n 'transition-colors 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 item.destructive ? 'text-destructive focus:bg-destructive/10 focus:text-destructive' : '',\n item.disabled && 'opacity-50 cursor-not-allowed pointer-events-none',\n (item.type === 'checkbox' || item.type === 'radio') ? 'pl-8' : '',\n activeIndex === $index && 'bg-accent text-accent-foreground'\n )\"\n >\n @if (item.checked) {\n <span class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <i [class]=\"item.type === 'radio' ? 'ri-checkbox-blank-circle-fill h-2 w-2 fill-current' : 'ri-check-line h-4 w-4'\"></i>\n </span>\n }\n @if (item.icon) {\n <i [class]=\"cn(item.icon, 'mr-2 h-4 w-4')\"></i>\n }\n <span class=\"flex-1 text-left\">{{ item.label }}</span>\n @if (item.shortcut && !item.submenu) {\n <span class=\"ml-auto text-xs tracking-widest text-muted-foreground pl-2\">{{ item.shortcut }}</span>\n }\n @if (item.submenu) {\n <i class=\"ri-arrow-right-s-line ml-auto h-4 w-4 opacity-50\"></i>\n }\n </button>\n </div>\n }\n }\n </div>\n </div>\n\n @if (openSubmenuId && activeSubmenuItems.length > 0) {\n <div\n #submenu\n role=\"menu\"\n [id]=\"'submenu-' + openSubmenuId\"\n class=\"fixed z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md\"\n [style.visibility]=\"isSubmenuPositioned ? 'visible' : 'hidden'\"\n [style.opacity]=\"isSubmenuPositioned ? '1' : '0'\"\n style=\"will-change: transform\"\n (mouseenter)=\"onSubmenuMouseEnter()\"\n (mouseleave)=\"onSubmenuMouseLeave()\"\n >\n @for (subItem of activeSubmenuItems; track $index) {\n @if (subItem.separator) {\n <div role=\"separator\" class=\"h-px -mx-1 my-1 bg-border\"></div>\n } @else {\n <button\n type=\"button\"\n role=\"menuitem\"\n (click)=\"onSubItemClick(subItem)\"\n [disabled]=\"subItem.disabled\"\n [class]=\"cn(\n 'relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',\n 'transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground',\n subItem.disabled && 'opacity-50 cursor-not-allowed pointer-events-none',\n (subItem.type === 'checkbox' || subItem.type === 'radio') ? 'pl-8' : '',\n subItem.destructive ? 'text-destructive hover:bg-destructive/10' : ''\n )\"\n >\n @if (subItem.checked) {\n <span class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <i [class]=\"subItem.type === 'radio' ? 'ri-checkbox-blank-circle-fill h-2 w-2 fill-current' : 'ri-check-line h-4 w-4'\"></i>\n </span>\n }\n @if (subItem.icon) {\n <i [class]=\"cn(subItem.icon, 'mr-2 h-4 w-4')\"></i>\n }\n <span class=\"flex-1 text-left\">{{ subItem.label }}</span>\n @if (subItem.shortcut) {\n <span class=\"ml-auto text-xs tracking-widest text-muted-foreground pl-2\">{{ subItem.shortcut }}</span>\n }\n </button>\n }\n }\n </div>\n }\n }\n `,\n styles: [`\n :host { display: contents; }\n `]\n})\nexport class ContextMenuComponent implements OnInit, OnDestroy {\n private contextMenuService = inject(ContextMenuService);\n private elementRef = inject(ElementRef);\n private cdr = inject(ChangeDetectorRef);\n\n @ViewChild('menu') menuElement!: ElementRef<HTMLDivElement>;\n\n // We only have one active submenu now, so we can use ViewChild instead of ViewChildren\n @ViewChild('submenu') submenuElement!: ElementRef<HTMLDivElement>;\n\n @ViewChildren('itemButton') itemButtons!: QueryList<ElementRef<HTMLButtonElement>>;\n\n isOpen = false;\n isPositioned = false;\n isSubmenuPositioned = false;\n items: ContextMenuItem[] = [];\n\n openSubmenuId: string | null = null;\n activeSubmenuTrigger: HTMLElement | null = null; // Store the button that triggered the submenu\n\n activeIndex = -1;\n private stateSubscription: any;\n private submenuTimeout: any;\n\n protected readonly cn = cn;\n\n ngOnInit() {\n this.stateSubscription = this.contextMenuService.stateChanged.subscribe((state: ContextMenuState) => {\n this.isOpen = state.isOpen;\n this.items = state.items;\n\n if (state.isOpen) {\n this.activeIndex = -1;\n this.isPositioned = false;\n this.cdr.detectChanges();\n requestAnimationFrame(() => {\n this.positionMenu();\n this.focusFirstItem();\n });\n } else {\n this.closeSubmenu();\n this.activeIndex = -1;\n this.isPositioned = false;\n }\n });\n }\n\n // Helper to get items for the currently open submenu\n get activeSubmenuItems(): ContextMenuItem[] {\n if (!this.openSubmenuId) return [];\n return this.items.find(i => i.id === this.openSubmenuId)?.submenu || [];\n }\n\n @HostListener('document:click', ['$event'])\n @HostListener('document:contextmenu', ['$event'])\n onDocumentEvents(event: MouseEvent) {\n if (!this.isOpen) return;\n\n // Check if click is inside main menu OR the active submenu\n const clickedInsideMenu = this.menuElement?.nativeElement.contains(event.target as Node);\n const clickedInsideSubmenu = this.submenuElement?.nativeElement.contains(event.target as Node);\n\n if (clickedInsideMenu || clickedInsideSubmenu) {\n return;\n }\n\n this.contextMenuService.close();\n }\n\n async positionMenu() {\n if (this.menuElement?.nativeElement) {\n this.isPositioned = false;\n this.cdr.detectChanges();\n await this.contextMenuService.positionMenu(this.menuElement.nativeElement);\n this.isPositioned = true;\n this.cdr.detectChanges();\n // Focus the menu container so Arrow/Enter/Escape keydown handlers fire.\n this.menuElement.nativeElement.focus();\n }\n }\n\n focusFirstItem() {\n const firstEnabledIndex = this.items.findIndex(item => !item.disabled && !item.separator);\n if (firstEnabledIndex !== -1) {\n this.activeIndex = firstEnabledIndex;\n }\n }\n\n onItemClick(item: ContextMenuItem) {\n if (item.disabled || item.separator) return;\n\n if (item.submenu) {\n // Toggle logic handled by enter/leave mostly, but click can force toggle\n if (this.openSubmenuId === item.id) {\n this.closeSubmenu();\n } else {\n // We need the button ref to open it via click. \n // Implementation detail: Hover is preferred for submenus.\n }\n } else {\n this.contextMenuService.performAction(item.id!);\n }\n }\n\n onItemMouseEnter(item: ContextMenuItem, trigger: HTMLButtonElement) {\n if (item.disabled || item.separator) return;\n\n // Clear any pending close actions\n clearTimeout(this.submenuTimeout);\n\n this.submenuTimeout = setTimeout(() => {\n if (item.submenu) {\n this.openSubmenuId = item.id!;\n this.activeSubmenuTrigger = trigger; // Save the trigger for positioning\n this.positionSubmenu();\n } else {\n this.closeSubmenu();\n }\n }, 150);\n }\n\n onItemMouseLeave() {\n // We don't close immediately; we wait to see if the user enters the submenu\n // Logic handled by the timeout in `onItemMouseEnter` of a sibling, \n // or we could add a timeout here to close if they go nowhere.\n }\n\n onSubmenuMouseEnter() {\n // If user is hovering the submenu, keep it open\n clearTimeout(this.submenuTimeout);\n }\n\n onSubmenuMouseLeave() {\n // If user leaves submenu, standard behavior is usually to keep it open \n // until they hover another main item or click away.\n }\n\n private closeSubmenu() {\n this.openSubmenuId = null;\n this.activeSubmenuTrigger = null;\n this.isSubmenuPositioned = false;\n }\n\n private async positionSubmenu() {\n this.isSubmenuPositioned = false;\n this.cdr.detectChanges(); // Render the submenu DOM\n\n requestAnimationFrame(async () => {\n if (this.activeSubmenuTrigger && this.submenuElement) {\n await this.contextMenuService.positionSubmenu(\n this.activeSubmenuTrigger,\n this.submenuElement.nativeElement\n );\n this.isSubmenuPositioned = true;\n this.cdr.detectChanges();\n }\n });\n }\n\n onSubItemClick(item: ContextMenuItem) {\n if (item.disabled || item.separator) return;\n this.contextMenuService.performAction(item.id!);\n }\n\n onKeyDown(event: KeyboardEvent) {\n switch (event.key) {\n case 'ArrowDown':\n event.preventDefault();\n this.navigate(1);\n break;\n case 'ArrowUp':\n event.preventDefault();\n this.navigate(-1);\n break;\n case 'ArrowRight':\n const currentItem = this.items[this.activeIndex];\n if (currentItem?.submenu) {\n event.preventDefault();\n // Force open submenu via keyboard\n this.openSubmenuId = currentItem.id!;\n const trigger = this.itemButtons.toArray()[this.activeIndex]?.nativeElement;\n if (trigger) {\n this.activeSubmenuTrigger = trigger;\n this.positionSubmenu();\n }\n }\n break;\n case 'ArrowLeft':\n if (this.openSubmenuId) {\n event.preventDefault();\n this.closeSubmenu();\n }\n break;\n case 'Enter':\n if (this.activeIndex !== -1) {\n event.preventDefault();\n this.onItemClick(this.items[this.activeIndex]);\n }\n break;\n case 'Escape':\n event.preventDefault();\n this.contextMenuService.close();\n break;\n }\n }\n\n private navigate(direction: number) {\n let nextIndex = this.activeIndex + direction;\n const count = this.items.length;\n while (nextIndex >= 0 && nextIndex < count) {\n const item = this.items[nextIndex];\n if (!item.disabled && !item.separator) {\n this.activeIndex = nextIndex;\n return;\n }\n nextIndex += direction;\n }\n }\n\n @HostListener('document:keydown.escape')\n onEscape() {\n this.contextMenuService.close();\n }\n\n ngOnDestroy() {\n if (this.stateSubscription) {\n this.stateSubscription.unsubscribe();\n }\n clearTimeout(this.submenuTimeout);\n }\n}"
16
+ },
17
+ {
18
+ "path": "context-menu.service.ts",
19
+ "target": "ui/context-menu.service.ts",
20
+ "content": "import { Injectable, EventEmitter } from '@angular/core';\nimport { computePosition, offset, shift, flip, size } from '@floating-ui/dom';\n\nexport type ContextMenuItem = {\n id?: string;\n label?: string;\n icon?: string;\n disabled?: boolean;\n destructive?: boolean;\n separator?: boolean;\n submenu?: ContextMenuItem[];\n shortcut?: string;\n checked?: boolean;\n type?: 'default' | 'checkbox' | 'radio';\n}\n\nexport type ContextMenuState = {\n x: number;\n y: number;\n items: ContextMenuItem[];\n isOpen: boolean;\n triggerElement?: HTMLElement;\n onAction?: (actionId: string) => void;\n}\n\n@Injectable({\n providedIn: 'root'\n})\nexport class ContextMenuService {\n private _state: ContextMenuState = {\n x: 0,\n y: 0,\n items: [],\n isOpen: false\n };\n\n readonly stateChanged = new EventEmitter<ContextMenuState>();\n\n open(config: {\n event: MouseEvent;\n items?: ContextMenuItem[];\n triggerElement?: HTMLElement;\n onAction?: (actionId: string) => void;\n }) {\n const { event, items = [], triggerElement, onAction } = config;\n\n // Prevent browser context menu\n event.preventDefault();\n\n this._state = {\n x: event.clientX,\n y: event.clientY,\n items,\n isOpen: true,\n triggerElement,\n onAction\n };\n\n this.stateChanged.emit(this._state);\n }\n\n close() {\n this._state = { ...this._state, isOpen: false };\n this.stateChanged.emit(this._state);\n }\n\n async positionMenu(menuElement: HTMLElement) {\n const state = this._state;\n if (!state.isOpen || !menuElement) return;\n\n // Create a precise virtual element for the mouse coordinates\n const virtualEl = {\n getBoundingClientRect() {\n return {\n width: 0,\n height: 0,\n x: state.x,\n y: state.y,\n top: state.y,\n left: state.x,\n right: state.x,\n bottom: state.y,\n };\n }\n };\n\n const { x, y } = await computePosition(virtualEl as any, menuElement, {\n placement: 'bottom-start', // Standard: Menu top-left aligns with mouse\n strategy: 'fixed',\n middleware: [\n offset(2), // Slight offset so mouse isn't covering the first pixel\n flip({\n padding: 10,\n fallbackPlacements: ['bottom-end', 'top-start', 'top-end']\n }),\n shift({ padding: 10 }),\n size({\n apply({ availableHeight, availableWidth, elements }) {\n Object.assign(elements.floating.style, {\n maxHeight: `${Math.max(100, availableHeight - 16)}px`,\n maxWidth: `${Math.max(200, availableWidth - 16)}px`,\n overflowY: 'auto'\n });\n }\n })\n ]\n });\n\n Object.assign(menuElement.style, {\n position: 'fixed',\n left: '0',\n top: '0',\n transform: `translate(${Math.round(x)}px, ${Math.round(y)}px)`\n });\n }\n\n async positionSubmenu(triggerElement: HTMLElement, submenuElement: HTMLElement) {\n if (!triggerElement || !submenuElement) return;\n\n const { x, y } = await computePosition(triggerElement, submenuElement, {\n placement: 'right-start', // Standard: Submenu opens to the right\n strategy: 'fixed',\n middleware: [\n offset(-4), // Overlap slightly with parent menu\n flip({\n padding: 10,\n fallbackPlacements: ['left-start', 'bottom-start']\n }),\n shift({ padding: 10 }),\n size({\n apply({ availableHeight, availableWidth, elements }) {\n Object.assign(elements.floating.style, {\n maxHeight: `${Math.max(100, availableHeight - 16)}px`,\n maxWidth: `${Math.max(200, availableWidth - 16)}px`,\n overflowY: 'auto'\n });\n }\n })\n ]\n });\n\n Object.assign(submenuElement.style, {\n position: 'fixed',\n left: '0',\n top: '0',\n transform: `translate(${Math.round(x)}px, ${Math.round(y)}px)`\n });\n }\n\n performAction(actionId: string) {\n this._state.onAction?.(actionId);\n this.close();\n }\n}"
21
+ },
22
+ {
23
+ "path": "utils/cn.ts",
24
+ "target": "ui/utils/cn.ts",
25
+ "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"
26
+ }
27
+ ]
28
+ }