m9sh 0.1.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.
Files changed (104) hide show
  1. checksums.yaml +7 -0
  2. data/.do/app.yaml +25 -0
  3. data/.dockerignore +51 -0
  4. data/.idea/.gitignore +8 -0
  5. data/.idea/aws.xml +17 -0
  6. data/.idea/hotcdn.iml +189 -0
  7. data/.idea/jsLibraryMappings.xml +6 -0
  8. data/.idea/misc.xml +4 -0
  9. data/.idea/modules.xml +8 -0
  10. data/.idea/vcs.xml +6 -0
  11. data/.mise.toml +3 -0
  12. data/.node-version +1 -0
  13. data/Dockerfile +84 -0
  14. data/README.md +230 -0
  15. data/Rakefile +6 -0
  16. data/app/components/m9sh/accordion_component.rb +122 -0
  17. data/app/components/m9sh/alert_component.rb +72 -0
  18. data/app/components/m9sh/alert_dialog_component.rb +100 -0
  19. data/app/components/m9sh/avatar_component.rb +65 -0
  20. data/app/components/m9sh/badge_component.rb +37 -0
  21. data/app/components/m9sh/base_component.rb +21 -0
  22. data/app/components/m9sh/breadcrumb_component.rb +100 -0
  23. data/app/components/m9sh/button_component.rb +54 -0
  24. data/app/components/m9sh/card_component.rb +90 -0
  25. data/app/components/m9sh/checkbox_component.rb +36 -0
  26. data/app/components/m9sh/collapsible_component.rb +47 -0
  27. data/app/components/m9sh/dialog_component.rb +123 -0
  28. data/app/components/m9sh/dropdown_menu_component.rb +27 -0
  29. data/app/components/m9sh/dropdown_menu_content_component.rb +24 -0
  30. data/app/components/m9sh/dropdown_menu_item_component.rb +36 -0
  31. data/app/components/m9sh/dropdown_menu_separator_component.rb +9 -0
  32. data/app/components/m9sh/dropdown_menu_trigger_component.rb +19 -0
  33. data/app/components/m9sh/hover_card_component.rb +48 -0
  34. data/app/components/m9sh/input_component.rb +33 -0
  35. data/app/components/m9sh/label_component.rb +27 -0
  36. data/app/components/m9sh/main_component.rb +16 -0
  37. data/app/components/m9sh/navigation_menu_component.rb +95 -0
  38. data/app/components/m9sh/popover_component.rb +47 -0
  39. data/app/components/m9sh/progress_component.rb +46 -0
  40. data/app/components/m9sh/radio_group_component.rb +88 -0
  41. data/app/components/m9sh/select_component.rb +51 -0
  42. data/app/components/m9sh/separator_component.rb +40 -0
  43. data/app/components/m9sh/sheet_component.rb +123 -0
  44. data/app/components/m9sh/sidebar_component.rb +126 -0
  45. data/app/components/m9sh/sidebar_group_component.rb +51 -0
  46. data/app/components/m9sh/sidebar_inset_component.rb +16 -0
  47. data/app/components/m9sh/sidebar_menu_button_component.rb +56 -0
  48. data/app/components/m9sh/sidebar_menu_component.rb +16 -0
  49. data/app/components/m9sh/sidebar_menu_item_component.rb +16 -0
  50. data/app/components/m9sh/sidebar_provider_component.rb +29 -0
  51. data/app/components/m9sh/sidebar_trigger_component.rb +44 -0
  52. data/app/components/m9sh/skeleton_component.rb +32 -0
  53. data/app/components/m9sh/slider_component.rb +83 -0
  54. data/app/components/m9sh/spinner_component.rb +46 -0
  55. data/app/components/m9sh/switch_component.rb +47 -0
  56. data/app/components/m9sh/table_component.rb +111 -0
  57. data/app/components/m9sh/tabs_component.rb +92 -0
  58. data/app/components/m9sh/textarea_component.rb +44 -0
  59. data/app/components/m9sh/theme_toggle_component.rb +88 -0
  60. data/app/components/m9sh/toast_component.rb +86 -0
  61. data/app/components/m9sh/toaster_component.rb +20 -0
  62. data/app/components/m9sh/toggle_component.rb +64 -0
  63. data/app/components/m9sh/tooltip_component.rb +48 -0
  64. data/app/components/m9sh/typography_component.rb +56 -0
  65. data/app/components/m9sh/utilities.rb +26 -0
  66. data/app/javascript/controllers/m9sh/accordion_controller.js +110 -0
  67. data/app/javascript/controllers/m9sh/alert_dialog_controller.js +47 -0
  68. data/app/javascript/controllers/m9sh/collapsible_controller.js +57 -0
  69. data/app/javascript/controllers/m9sh/dialog_controller.js +119 -0
  70. data/app/javascript/controllers/m9sh/dropdown_menu_controller.js +103 -0
  71. data/app/javascript/controllers/m9sh/hover_card_controller.js +66 -0
  72. data/app/javascript/controllers/m9sh/navigation_menu_controller.js +219 -0
  73. data/app/javascript/controllers/m9sh/popover_controller.js +113 -0
  74. data/app/javascript/controllers/m9sh/radio_controller.js +59 -0
  75. data/app/javascript/controllers/m9sh/sheet_controller.js +46 -0
  76. data/app/javascript/controllers/m9sh/sidebar_controller.js +114 -0
  77. data/app/javascript/controllers/m9sh/sidebar_provider_controller.js +12 -0
  78. data/app/javascript/controllers/m9sh/slider_controller.js +90 -0
  79. data/app/javascript/controllers/m9sh/switch_controller.js +33 -0
  80. data/app/javascript/controllers/m9sh/tabs_controller.js +51 -0
  81. data/app/javascript/controllers/m9sh/theme_controller.js +50 -0
  82. data/app/javascript/controllers/m9sh/toast_controller.js +46 -0
  83. data/app/javascript/controllers/m9sh/toaster_controller.js +70 -0
  84. data/app/javascript/controllers/m9sh/toggle_controller.js +27 -0
  85. data/app/javascript/controllers/m9sh/tooltip_controller.js +86 -0
  86. data/components.json +21 -0
  87. data/config.ru +6 -0
  88. data/exe/m9sh +12 -0
  89. data/fix_namespaces.py +32 -0
  90. data/fly.toml +30 -0
  91. data/koyeb.yaml +26 -0
  92. data/lib/m9sh/cli.rb +234 -0
  93. data/lib/m9sh/config.rb +114 -0
  94. data/lib/m9sh/generator.rb +183 -0
  95. data/lib/m9sh/registry.rb +107 -0
  96. data/lib/m9sh/registry.yml +384 -0
  97. data/lib/m9sh/version.rb +5 -0
  98. data/lib/m9sh.rb +11 -0
  99. data/package-lock.json +99 -0
  100. data/package.json +28 -0
  101. data/pnpm-lock.yaml +75 -0
  102. data/tailwind.config.js +93 -0
  103. data/update_namespace.py +73 -0
  104. metadata +208 -0
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M9sh
4
+ class TooltipComponent < BaseComponent
5
+ include Utilities
6
+
7
+ renders_one :trigger, lambda { |&block|
8
+ tag.div(
9
+ data: {
10
+ action: "mouseenter->m9sh--tooltip#show mouseleave->m9sh--tooltip#hide",
11
+ m9sh__tooltip_target: "trigger"
12
+ }
13
+ ) { block.call }
14
+ }
15
+
16
+ renders_one :tooltip_content, lambda { |&block|
17
+ tag.div(
18
+ class: "absolute z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground shadow-md hidden animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
19
+ data: {
20
+ m9sh__tooltip_target: "content"
21
+ },
22
+ role: "tooltip"
23
+ ) { block.call }
24
+ }
25
+
26
+ def initialize(side: "top", delay: 200, **extra_attrs)
27
+ @side = side
28
+ @delay = delay
29
+ super(**extra_attrs)
30
+ end
31
+
32
+ def call
33
+ tag.div(
34
+ **component_attrs("relative inline-block"),
35
+ data: {
36
+ controller: "m9sh--tooltip",
37
+ m9sh__tooltip_side_value: @side,
38
+ m9sh__tooltip_delay_value: @delay
39
+ }
40
+ ) do
41
+ safe_join([
42
+ trigger,
43
+ tooltip_content
44
+ ].compact)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M9sh
4
+ class TypographyComponent < BaseComponent
5
+ include Utilities
6
+
7
+ VARIANTS = {
8
+ h1: "scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl",
9
+ h2: "scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0",
10
+ h3: "scroll-m-20 text-2xl font-semibold tracking-tight",
11
+ h4: "scroll-m-20 text-xl font-semibold tracking-tight",
12
+ p: "leading-7 [&:not(:first-child)]:mt-6",
13
+ blockquote: "mt-6 border-l-2 pl-6 italic",
14
+ list: "my-6 ml-6 list-disc [&>li]:mt-2",
15
+ inline_code: "relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold",
16
+ lead: "text-xl text-muted-foreground",
17
+ large: "text-lg font-semibold",
18
+ small: "text-sm font-medium leading-none",
19
+ muted: "text-sm text-muted-foreground"
20
+ }.freeze
21
+
22
+ TAGS = {
23
+ h1: :h1,
24
+ h2: :h2,
25
+ h3: :h3,
26
+ h4: :h4,
27
+ p: :p,
28
+ blockquote: :blockquote,
29
+ list: :ul,
30
+ inline_code: :code,
31
+ lead: :p,
32
+ large: :div,
33
+ small: :small,
34
+ muted: :p
35
+ }.freeze
36
+
37
+ def initialize(variant: :p, tag: nil, **extra_attrs)
38
+ @variant = variant.to_sym
39
+ @tag = tag || TAGS[@variant]
40
+ super(**extra_attrs)
41
+ end
42
+
43
+ def call
44
+ tag.public_send(
45
+ @tag,
46
+ **component_attrs(variant_classes)
47
+ ) { content }
48
+ end
49
+
50
+ private
51
+
52
+ def variant_classes
53
+ VARIANTS[@variant] || VARIANTS[:p]
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M9sh
4
+ module Utilities
5
+ # Merge class names intelligently, handling Tailwind utility conflicts
6
+ def cn(*class_strings)
7
+ class_strings
8
+ .flatten
9
+ .compact
10
+ .map(&:to_s)
11
+ .map(&:strip)
12
+ .reject(&:empty?)
13
+ .join(" ")
14
+ .split
15
+ .uniq
16
+ .join(" ")
17
+ end
18
+
19
+ # Generate variant classes based on variant and size props
20
+ def variant_classes(variant_styles, variant, size = nil)
21
+ classes = variant_styles.dig(:variants, variant.to_sym) || variant_styles[:default]
22
+ size_classes = size ? variant_styles.dig(:sizes, size.to_sym) : nil
23
+ cn(classes, size_classes)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,110 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["item", "button", "content", "trigger"]
5
+ static values = {
6
+ type: { type: String, default: "single" },
7
+ collapsible: { type: Boolean, default: true },
8
+ default: String
9
+ }
10
+
11
+ connect() {
12
+ this.openItems = new Set()
13
+
14
+ if (this.hasDefaultValue) {
15
+ this.openItems.add(this.defaultValue)
16
+ this.updateItemStates()
17
+ }
18
+ }
19
+
20
+ toggle(event) {
21
+ event.preventDefault()
22
+
23
+ const button = event.currentTarget
24
+ const item = button.closest('[data-m9sh--accordion-target="item"]')
25
+ const value = item.dataset.value
26
+
27
+ if (this.typeValue === "single") {
28
+ this.toggleSingle(value)
29
+ } else {
30
+ this.toggleMultiple(value)
31
+ }
32
+
33
+ this.updateItemStates()
34
+ }
35
+
36
+ toggleSingle(value) {
37
+ if (this.openItems.has(value)) {
38
+ if (this.collapsibleValue) {
39
+ this.openItems.clear()
40
+ }
41
+ } else {
42
+ this.openItems.clear()
43
+ this.openItems.add(value)
44
+ }
45
+ }
46
+
47
+ toggleMultiple(value) {
48
+ if (this.openItems.has(value)) {
49
+ this.openItems.delete(value)
50
+ } else {
51
+ this.openItems.add(value)
52
+ }
53
+ }
54
+
55
+ updateItemStates() {
56
+ this.itemTargets.forEach(item => {
57
+ const value = item.dataset.value
58
+ const isOpen = this.openItems.has(value)
59
+ const button = item.querySelector('[data-m9sh--accordion-target="button"]')
60
+ const content = item.querySelector('[data-m9sh--accordion-target="content"]')
61
+
62
+ if (button) {
63
+ button.setAttribute("aria-expanded", isOpen)
64
+ button.dataset.state = isOpen ? "open" : "closed"
65
+ }
66
+
67
+ if (content) {
68
+ content.dataset.state = isOpen ? "open" : "closed"
69
+
70
+ if (isOpen) {
71
+ // Open: show element, set height to 0, then animate to full height
72
+ content.style.display = "block"
73
+ content.style.overflow = "hidden"
74
+
75
+ // Force reflow
76
+ content.offsetHeight
77
+
78
+ const height = content.scrollHeight
79
+ content.style.height = height + "px"
80
+
81
+ // After animation completes, remove fixed height
82
+ setTimeout(() => {
83
+ if (this.openItems.has(value)) {
84
+ content.style.height = "auto"
85
+ content.style.overflow = "visible"
86
+ }
87
+ }, 300)
88
+ } else {
89
+ // Close: set current height, then animate to 0
90
+ const height = content.scrollHeight
91
+ content.style.height = height + "px"
92
+ content.style.overflow = "hidden"
93
+
94
+ // Force reflow
95
+ content.offsetHeight
96
+
97
+ content.style.height = "0px"
98
+
99
+ // After animation, hide element
100
+ setTimeout(() => {
101
+ if (!this.openItems.has(value)) {
102
+ content.style.display = "none"
103
+ content.style.height = ""
104
+ }
105
+ }, 300)
106
+ }
107
+ }
108
+ })
109
+ }
110
+ }
@@ -0,0 +1,47 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["overlay", "content"]
5
+
6
+ connect() {
7
+ this.isOpen = false
8
+ }
9
+
10
+ open(event) {
11
+ if (event) event.preventDefault()
12
+
13
+ this.isOpen = true
14
+ document.body.style.overflow = "hidden"
15
+
16
+ this.overlayTarget.classList.remove("hidden")
17
+ this.overlayTarget.setAttribute("data-state", "open")
18
+
19
+ this.contentTarget.classList.remove("hidden")
20
+ this.contentTarget.setAttribute("data-state", "open")
21
+ }
22
+
23
+ close(event) {
24
+ if (event) event.preventDefault()
25
+
26
+ this.isOpen = false
27
+ document.body.style.overflow = ""
28
+
29
+ this.overlayTarget.setAttribute("data-state", "closed")
30
+ this.contentTarget.setAttribute("data-state", "closed")
31
+
32
+ setTimeout(() => {
33
+ if (!this.isOpen) {
34
+ this.overlayTarget.classList.add("hidden")
35
+ this.contentTarget.classList.add("hidden")
36
+ }
37
+ }, 200)
38
+ }
39
+
40
+ stopPropagation(event) {
41
+ event.stopPropagation()
42
+ }
43
+
44
+ disconnect() {
45
+ document.body.style.overflow = ""
46
+ }
47
+ }
@@ -0,0 +1,57 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["content", "inner"]
5
+ static values = {
6
+ open: { type: Boolean, default: false }
7
+ }
8
+
9
+ connect() {
10
+ this.updateState()
11
+ }
12
+
13
+ openValueChanged() {
14
+ this.updateState()
15
+ }
16
+
17
+ toggle(event) {
18
+ if (event) event.preventDefault()
19
+ this.openValue = !this.openValue
20
+ }
21
+
22
+ updateState() {
23
+ if (!this.hasContentTarget) return
24
+
25
+ if (this.openValue) {
26
+ this.open()
27
+ } else {
28
+ this.close()
29
+ }
30
+ }
31
+
32
+ open() {
33
+ this.contentTarget.setAttribute("data-state", "open")
34
+ this.contentTarget.style.height = "auto"
35
+
36
+ const height = this.innerTarget.scrollHeight
37
+ this.contentTarget.style.height = "0px"
38
+
39
+ setTimeout(() => {
40
+ this.contentTarget.style.height = `${height}px`
41
+ }, 10)
42
+
43
+ setTimeout(() => {
44
+ this.contentTarget.style.height = "auto"
45
+ }, 310)
46
+ }
47
+
48
+ close() {
49
+ const height = this.innerTarget.scrollHeight
50
+ this.contentTarget.style.height = `${height}px`
51
+
52
+ setTimeout(() => {
53
+ this.contentTarget.setAttribute("data-state", "closed")
54
+ this.contentTarget.style.height = "0px"
55
+ }, 10)
56
+ }
57
+ }
@@ -0,0 +1,119 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["overlay", "content", "trigger"]
5
+ static values = { open: Boolean }
6
+
7
+ connect() {
8
+ this.boundHandleEscape = this.handleEscape.bind(this)
9
+
10
+ if (this.openValue) {
11
+ this.show()
12
+ }
13
+ }
14
+
15
+ disconnect() {
16
+ document.removeEventListener("keydown", this.boundHandleEscape)
17
+ }
18
+
19
+ open(event) {
20
+ event.preventDefault()
21
+ this.openValue = true
22
+ this.show()
23
+ }
24
+
25
+ close(event) {
26
+ if (event) event.preventDefault()
27
+ this.openValue = false
28
+ this.hide()
29
+ }
30
+
31
+ show() {
32
+ if (this.hasOverlayTarget) {
33
+ this.overlayTarget.style.display = ""
34
+ this.overlayTarget.dataset.state = "open"
35
+
36
+ if (this.hasContentTarget) {
37
+ this.contentTarget.dataset.state = "open"
38
+ }
39
+
40
+ // Add event listener for escape key
41
+ document.addEventListener("keydown", this.boundHandleEscape)
42
+
43
+ // Focus trap
44
+ this.trapFocus()
45
+
46
+ // Prevent body scroll
47
+ document.body.style.overflow = "hidden"
48
+ }
49
+ }
50
+
51
+ hide() {
52
+ if (this.hasOverlayTarget) {
53
+ this.overlayTarget.dataset.state = "closed"
54
+
55
+ if (this.hasContentTarget) {
56
+ this.contentTarget.dataset.state = "closed"
57
+ }
58
+
59
+ // Remove event listener
60
+ document.removeEventListener("keydown", this.boundHandleEscape)
61
+
62
+ // Restore body scroll
63
+ document.body.style.overflow = ""
64
+
65
+ // Hide after animation
66
+ setTimeout(() => {
67
+ if (!this.openValue && this.hasOverlayTarget) {
68
+ this.overlayTarget.style.display = "none"
69
+ }
70
+ }, 200)
71
+ }
72
+ }
73
+
74
+ handleBackdropClick(event) {
75
+ // Only close if clicking directly on overlay, not content
76
+ if (event.target === this.overlayTarget) {
77
+ this.close()
78
+ }
79
+ }
80
+
81
+ handleEscape(event) {
82
+ if (event.key === "Escape" || event.keyCode === 27) {
83
+ this.close()
84
+ }
85
+ }
86
+
87
+ trapFocus() {
88
+ if (!this.hasContentTarget) return
89
+
90
+ const focusableElements = this.contentTarget.querySelectorAll(
91
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
92
+ )
93
+
94
+ if (focusableElements.length === 0) return
95
+
96
+ const firstElement = focusableElements[0]
97
+ const lastElement = focusableElements[focusableElements.length - 1]
98
+
99
+ // Focus first element
100
+ firstElement.focus()
101
+
102
+ // Trap focus within dialog
103
+ this.contentTarget.addEventListener("keydown", (event) => {
104
+ if (event.key !== "Tab") return
105
+
106
+ if (event.shiftKey) {
107
+ if (document.activeElement === firstElement) {
108
+ event.preventDefault()
109
+ lastElement.focus()
110
+ }
111
+ } else {
112
+ if (document.activeElement === lastElement) {
113
+ event.preventDefault()
114
+ firstElement.focus()
115
+ }
116
+ }
117
+ })
118
+ }
119
+ }
@@ -0,0 +1,103 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["trigger", "content"]
5
+ static values = {
6
+ align: { type: String, default: "end" },
7
+ side: { type: String, default: "bottom" }
8
+ }
9
+
10
+ connect() {
11
+ this.isOpen = false
12
+ this.positionContent()
13
+ }
14
+
15
+ toggle(event) {
16
+ event.stopPropagation()
17
+ this.isOpen ? this.close() : this.open()
18
+ }
19
+
20
+ open() {
21
+ if (this.isOpen) return
22
+
23
+ this.isOpen = true
24
+ this.contentTarget.classList.remove("hidden")
25
+ this.positionContent()
26
+
27
+ // Add animation classes
28
+ requestAnimationFrame(() => {
29
+ this.contentTarget.classList.add("transform", "opacity-100", "scale-100")
30
+ this.contentTarget.classList.remove("opacity-0", "scale-95")
31
+ })
32
+ }
33
+
34
+ close() {
35
+ if (!this.isOpen) return
36
+
37
+ this.isOpen = false
38
+
39
+ // Add closing animation
40
+ this.contentTarget.classList.add("opacity-0", "scale-95")
41
+ this.contentTarget.classList.remove("opacity-100", "scale-100")
42
+
43
+ // Hide after animation
44
+ setTimeout(() => {
45
+ if (!this.isOpen) {
46
+ this.contentTarget.classList.add("hidden")
47
+ }
48
+ }, 75)
49
+ }
50
+
51
+ hide(event) {
52
+ // Don't hide if clicking inside the dropdown
53
+ if (this.element.contains(event.target)) return
54
+ this.close()
55
+ }
56
+
57
+ positionContent() {
58
+ if (!this.hasTriggerTarget || !this.hasContentTarget) return
59
+
60
+ const triggerRect = this.triggerTarget.getBoundingClientRect()
61
+ const contentRect = this.contentTarget.getBoundingClientRect()
62
+ const viewportHeight = window.innerHeight
63
+ const viewportWidth = window.innerWidth
64
+
65
+ let top = 0
66
+ let left = 0
67
+
68
+ // Vertical positioning
69
+ if (this.sideValue === "top") {
70
+ top = -contentRect.height - 4
71
+ } else {
72
+ top = triggerRect.height + 4
73
+ }
74
+
75
+ // Horizontal positioning
76
+ if (this.alignValue === "start") {
77
+ left = 0
78
+ } else if (this.alignValue === "center") {
79
+ left = (triggerRect.width - contentRect.width) / 2
80
+ } else {
81
+ left = triggerRect.width - contentRect.width
82
+ }
83
+
84
+ // Adjust if content would go outside viewport
85
+ const absoluteTop = triggerRect.top + top
86
+ const absoluteLeft = triggerRect.left + left
87
+
88
+ if (absoluteTop < 0) {
89
+ top = triggerRect.height + 4
90
+ } else if (absoluteTop + contentRect.height > viewportHeight) {
91
+ top = -contentRect.height - 4
92
+ }
93
+
94
+ if (absoluteLeft < 0) {
95
+ left = 0
96
+ } else if (absoluteLeft + contentRect.width > viewportWidth) {
97
+ left = triggerRect.width - contentRect.width
98
+ }
99
+
100
+ this.contentTarget.style.top = `${top}px`
101
+ this.contentTarget.style.left = `${left}px`
102
+ }
103
+ }
@@ -0,0 +1,66 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["trigger", "content"]
5
+ static values = {
6
+ openDelay: { type: Number, default: 200 },
7
+ closeDelay: { type: Number, default: 300 }
8
+ }
9
+
10
+ connect() {
11
+ this.showTimeout = null
12
+ this.hideTimeout = null
13
+ }
14
+
15
+ disconnect() {
16
+ this.clearTimeouts()
17
+ }
18
+
19
+ show() {
20
+ this.clearTimeouts()
21
+
22
+ this.showTimeout = setTimeout(() => {
23
+ this.contentTarget.classList.remove("hidden")
24
+ this.contentTarget.setAttribute("data-state", "open")
25
+ this.positionContent()
26
+ }, this.openDelayValue)
27
+ }
28
+
29
+ hide() {
30
+ this.clearTimeouts()
31
+
32
+ this.hideTimeout = setTimeout(() => {
33
+ this.contentTarget.setAttribute("data-state", "closed")
34
+
35
+ setTimeout(() => {
36
+ this.contentTarget.classList.add("hidden")
37
+ }, 150)
38
+ }, this.closeDelayValue)
39
+ }
40
+
41
+ cancelHide() {
42
+ this.clearTimeouts()
43
+ }
44
+
45
+ clearTimeouts() {
46
+ if (this.showTimeout) {
47
+ clearTimeout(this.showTimeout)
48
+ this.showTimeout = null
49
+ }
50
+ if (this.hideTimeout) {
51
+ clearTimeout(this.hideTimeout)
52
+ this.hideTimeout = null
53
+ }
54
+ }
55
+
56
+ positionContent() {
57
+ if (!this.hasContentTarget) return
58
+
59
+ const triggerRect = this.triggerTarget.getBoundingClientRect()
60
+
61
+ // Position below the trigger, aligned to the left
62
+ this.contentTarget.style.top = "100%"
63
+ this.contentTarget.style.left = "0"
64
+ this.contentTarget.style.marginTop = "0.5rem"
65
+ }
66
+ }