daisyui 1.2.1 → 1.3.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45836c445f318018c42bc88fe53d1db4b97080f29c798261f395c40a91361059
4
- data.tar.gz: 2b76e96194673161211d35f96e0ed8bde1fb04fd622322f85d2547d41312781e
3
+ metadata.gz: c8372a79aabdd1c0a9d0c4aeb43c6b31d936e9dc3f1b88910ffc36096ee18abc
4
+ data.tar.gz: d81e3d626b9eedc22ea83460aa1516ef7741769d9979db6ac65f56b6d12ad051
5
5
  SHA512:
6
- metadata.gz: b12fa159079d54ca2744212f450edf7df8bf210b42ae3c219f635248a06ab7cd2e9f6ce1ce2aad2325da0a4623c9ee7d24f6efab65255e401dec74091fbcbff0
7
- data.tar.gz: 8f4dc6cbfbe831586d4e19b240082b2e1c3b90d667d60bc61ec374aa7e2458636451af88ea81c371031d4abff858996842631a63cb9ec0eca6c651039c98851e
6
+ metadata.gz: c282bc8ca3c715f0a3f867c004ef0e2baf5cfb7205c1108d91d0a57bd6c10c7316ffc848907778eee3af1aa9da58cf720050149914addcafd9006a0089b17124
7
+ data.tar.gz: 17dd67c0f8d552f8fc9d9213b5476fd08e3772ea74fc989db65eb183f628ee0324446b0087d69a73393add3faea8cc9742fc32dcdd78b30300c186fa71777429
data/CHANGELOG.md CHANGED
@@ -19,12 +19,22 @@
19
19
  positioning fallback (Safari < 26, Firefox < 147) and optional roving
20
20
  keyboard navigation. The default `:popover` dropdown remains zero-JS, and the
21
21
  gem stays a plain Phlex library when Rails is absent.
22
+ - `Tooltip` `:popover` modifier for collision-aware tooltips. It renders real
23
+ tooltip content in the browser top layer, opens on hover or focus, flips and
24
+ shifts within the visual viewport, follows scroll/resize while open, and
25
+ exposes `role="tooltip"`/`aria-describedby` semantics. The bundled
26
+ `daisy-tooltip` Stimulus controller is auto-pinned for importmap-rails apps
27
+ and supports a custom controller identifier via `stimulus:`.
22
28
 
23
29
  ### Changed
24
30
 
25
31
  - Range component: added range-vertical modifier for vertical orientation (DaisyUI 5.6)
26
32
  - Tooltip component: added alignment modifiers (tooltip-start, tooltip-center, tooltip-end) and tooltip-content sub-component (DaisyUI 5.6)
27
33
 
34
+ ### Fixed
35
+
36
+ - Popover tooltips no longer clip arrows that extend beyond the tooltip card.
37
+
28
38
  ## [0.1.0] - 2024-08-02
29
39
 
30
40
  - Initial release
data/README.md CHANGED
@@ -248,6 +248,58 @@ JS-bundler (esbuild/vite/webpack) consumers: import the controller from the
248
248
  gem's `app/javascript/daisy_ui/controllers/daisy_dropdown_controller.js` and
249
249
  register it manually.
250
250
 
251
+ ## Collision-aware `Tooltip(:popover)`
252
+
253
+ The `:popover` Tooltip modifier renders real tooltip content in the browser top
254
+ layer instead of a `data-tip` pseudo-element. It therefore escapes clipping
255
+ ancestors, and the bundled `daisy-tooltip` controller flips and shifts the
256
+ tooltip to remain inside the visual viewport. Geometry listeners are attached
257
+ only while the tooltip is open.
258
+
259
+ ```ruby
260
+ Tooltip(:popover, tip: "Helpful details") do
261
+ Button(:circle) { "?" }
262
+ end
263
+ ```
264
+
265
+ Directional and color modifiers compose normally:
266
+
267
+ ```ruby
268
+ Tooltip(:popover, :bottom, :info, tip: "Saved automatically") do
269
+ Button(:ghost) { "Status" }
270
+ end
271
+ ```
272
+
273
+ Rich content uses the existing `content` sub-component:
274
+
275
+ ```ruby
276
+ Tooltip(:popover) do |tooltip|
277
+ tooltip.content(class: "max-w-64") do
278
+ strong { "Keyboard shortcut" }
279
+ kbd { "⌘ K" }
280
+ end
281
+ Button { "Commands" }
282
+ end
283
+ ```
284
+
285
+ Register the gem controllers once with
286
+ `lazyLoadControllersFrom("daisy_ui/controllers", application)`, as shown in the
287
+ dropdown controller section above. Importmap-rails applications receive the
288
+ controller pin from the gem automatically. Bundler applications can import
289
+ `app/javascript/daisy_ui/controllers/daisy_tooltip_controller.js` and register
290
+ it as `daisy-tooltip`.
291
+
292
+ Popover tooltips:
293
+
294
+ - open on pointer hover or keyboard focus and close on leave, blur, or Escape;
295
+ - use `role="tooltip"` and merge their id into the first interactive trigger's
296
+ `aria-describedby`;
297
+ - preserve caller-supplied Stimulus controllers;
298
+ - accept `popover_id:` for a stable tooltip id and `stimulus: "your-id"` to
299
+ override the controller identifier.
300
+
301
+ Classic `Tooltip(tip: ...)` rendering remains unchanged.
302
+
251
303
  # MCP Server (Claude Code Integration)
252
304
 
253
305
  This gem includes an MCP (Model Context Protocol) server that provides component information to AI assistants like Claude Code.
@@ -0,0 +1,231 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const VIEWPORT_PADDING = 8
4
+ const GAP = 8
5
+ const ARROW_WIDTH = 10
6
+ const ARROW_HEIGHT = 4
7
+ const FOCUSABLE_SELECTOR = 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
8
+ const OPPOSITE = { top: "bottom", bottom: "top", left: "right", right: "left" }
9
+
10
+ // daisy-tooltip — positions a :popover Tooltip in the browser top layer.
11
+ //
12
+ // The Popover API escapes clipping ancestors. This controller supplies the
13
+ // hover/focus lifecycle expected of a tooltip, then flips and shifts the real
14
+ // tooltip element only while it is open so it remains inside the visual
15
+ // viewport. No global listeners remain attached while the tooltip is closed.
16
+ export default class extends Controller {
17
+ static targets = ["content", "arrow"]
18
+ static values = { placement: { type: String, default: "top" } }
19
+
20
+ connect() {
21
+ if (!this.hasContentTarget) return
22
+
23
+ this.originalMaxWidth = this.contentTarget.style.maxWidth
24
+ this.onPointerEnter = () => this.show()
25
+ this.onPointerLeave = () => this.#hideUnlessFocused()
26
+ this.onFocusIn = () => this.show()
27
+ this.onFocusOut = (event) => this.#handleFocusOut(event)
28
+ this.onKeydown = (event) => this.#handleKeydown(event)
29
+ this.onGeometryChange = () => this.#schedulePosition()
30
+
31
+ this.element.addEventListener("pointerenter", this.onPointerEnter)
32
+ this.element.addEventListener("pointerleave", this.onPointerLeave)
33
+ this.element.addEventListener("focusin", this.onFocusIn)
34
+ this.element.addEventListener("focusout", this.onFocusOut)
35
+ this.#describeTrigger()
36
+
37
+ if (this.element.classList.contains("tooltip-open")) this.show()
38
+ }
39
+
40
+ disconnect() {
41
+ this.element.removeEventListener("pointerenter", this.onPointerEnter)
42
+ this.element.removeEventListener("pointerleave", this.onPointerLeave)
43
+ this.element.removeEventListener("focusin", this.onFocusIn)
44
+ this.element.removeEventListener("focusout", this.onFocusOut)
45
+ this.#teardownOpen()
46
+ this.#restoreDescription()
47
+ if (this.hasContentTarget) this.contentTarget.style.maxWidth = this.originalMaxWidth
48
+ }
49
+
50
+ show() {
51
+ if (!this.hasContentTarget || this.#isOpen()) return
52
+
53
+ this.contentTarget.style.visibility = "hidden"
54
+ this.contentTarget.showPopover()
55
+ this.#setupOpen()
56
+ this.#schedulePosition()
57
+ }
58
+
59
+ hide() {
60
+ if (this.#isOpen()) this.contentTarget.hidePopover()
61
+ this.contentTarget.style.visibility = "hidden"
62
+ this.#teardownOpen()
63
+ }
64
+
65
+ #setupOpen() {
66
+ window.addEventListener("resize", this.onGeometryChange)
67
+ window.addEventListener("scroll", this.onGeometryChange, true)
68
+ window.addEventListener("keydown", this.onKeydown)
69
+ window.visualViewport?.addEventListener("resize", this.onGeometryChange)
70
+ window.visualViewport?.addEventListener("scroll", this.onGeometryChange)
71
+ }
72
+
73
+ #teardownOpen() {
74
+ cancelAnimationFrame(this.frame)
75
+ window.removeEventListener("resize", this.onGeometryChange)
76
+ window.removeEventListener("scroll", this.onGeometryChange, true)
77
+ window.removeEventListener("keydown", this.onKeydown)
78
+ window.visualViewport?.removeEventListener("resize", this.onGeometryChange)
79
+ window.visualViewport?.removeEventListener("scroll", this.onGeometryChange)
80
+ }
81
+
82
+ #schedulePosition() {
83
+ cancelAnimationFrame(this.frame)
84
+ this.frame = requestAnimationFrame(() => this.#position())
85
+ }
86
+
87
+ #position() {
88
+ if (!this.#isOpen()) return
89
+
90
+ const viewport = this.#viewport()
91
+ const availableWidth = Math.max(0, viewport.width - VIEWPORT_PADDING * 2)
92
+ this.contentTarget.style.maxWidth = this.originalMaxWidth
93
+ const computedMaxWidth = Number.parseFloat(getComputedStyle(this.contentTarget).maxWidth)
94
+ const maxWidth = Number.isFinite(computedMaxWidth) ? Math.min(computedMaxWidth, availableWidth) : availableWidth
95
+ this.contentTarget.style.maxWidth = `${maxWidth}px`
96
+
97
+ const trigger = this.element.getBoundingClientRect()
98
+ const tooltip = this.contentTarget.getBoundingClientRect()
99
+ const preferred = this.#coordinates(this.placementValue, trigger, tooltip)
100
+ const oppositePlacement = OPPOSITE[this.placementValue] || "bottom"
101
+ const opposite = this.#coordinates(oppositePlacement, trigger, tooltip)
102
+ const useOpposite = this.#overflow(opposite, tooltip, viewport) < this.#overflow(preferred, tooltip, viewport)
103
+ const placement = useOpposite ? oppositePlacement : this.placementValue
104
+ const coordinates = useOpposite ? opposite : preferred
105
+ const { x, y } = this.#shift(coordinates, tooltip, viewport)
106
+
107
+ Object.assign(this.contentTarget.style, { left: `${x}px`, top: `${y}px`, visibility: "visible" })
108
+ this.#positionArrow(placement, trigger, tooltip, x, y)
109
+ }
110
+
111
+ #coordinates(placement, trigger, tooltip) {
112
+ const centerX = trigger.left + trigger.width / 2
113
+ const centerY = trigger.top + trigger.height / 2
114
+
115
+ switch (placement) {
116
+ case "bottom":
117
+ return { x: centerX - tooltip.width / 2, y: trigger.bottom + GAP }
118
+ case "left":
119
+ return { x: trigger.left - tooltip.width - GAP, y: centerY - tooltip.height / 2 }
120
+ case "right":
121
+ return { x: trigger.right + GAP, y: centerY - tooltip.height / 2 }
122
+ default:
123
+ return { x: centerX - tooltip.width / 2, y: trigger.top - tooltip.height - GAP }
124
+ }
125
+ }
126
+
127
+ #overflow({ x, y }, tooltip, viewport) {
128
+ const left = viewport.left + VIEWPORT_PADDING
129
+ const right = viewport.left + viewport.width - VIEWPORT_PADDING
130
+ const top = viewport.top + VIEWPORT_PADDING
131
+ const bottom = viewport.top + viewport.height - VIEWPORT_PADDING
132
+
133
+ return (
134
+ Math.max(0, left - x) +
135
+ Math.max(0, x + tooltip.width - right) +
136
+ Math.max(0, top - y) +
137
+ Math.max(0, y + tooltip.height - bottom)
138
+ )
139
+ }
140
+
141
+ #shift({ x, y }, tooltip, viewport) {
142
+ const left = viewport.left + VIEWPORT_PADDING
143
+ const right = viewport.left + viewport.width - VIEWPORT_PADDING
144
+ const top = viewport.top + VIEWPORT_PADDING
145
+ const bottom = viewport.top + viewport.height - VIEWPORT_PADDING
146
+
147
+ return {
148
+ x: tooltip.width > right - left ? left : Math.min(Math.max(x, left), right - tooltip.width),
149
+ y: tooltip.height > bottom - top ? top : Math.min(Math.max(y, top), bottom - tooltip.height),
150
+ }
151
+ }
152
+
153
+ #positionArrow(placement, trigger, tooltip, x, y) {
154
+ if (!this.hasArrowTarget) return
155
+
156
+ const centerX = trigger.left + trigger.width / 2 - x
157
+ const centerY = trigger.top + trigger.height / 2 - y
158
+ const arrow = this.arrowTarget.style
159
+ Object.assign(arrow, { inset: "auto", transform: "none" })
160
+
161
+ if (placement === "top" || placement === "bottom") {
162
+ arrow.left = `${this.#clamp(centerX - ARROW_WIDTH / 2, 4, tooltip.width - ARROW_WIDTH - 4)}px`
163
+ if (placement === "top") arrow.bottom = `${-ARROW_HEIGHT}px`
164
+ else {
165
+ arrow.top = `${-ARROW_HEIGHT}px`
166
+ arrow.transform = "rotate(180deg)"
167
+ }
168
+ return
169
+ }
170
+
171
+ arrow.top = `${this.#clamp(centerY - ARROW_HEIGHT / 2, 4, tooltip.height - ARROW_HEIGHT - 4)}px`
172
+ if (placement === "left") {
173
+ arrow.right = "-7px"
174
+ arrow.transform = "rotate(-90deg)"
175
+ } else {
176
+ arrow.left = "-7px"
177
+ arrow.transform = "rotate(90deg)"
178
+ }
179
+ }
180
+
181
+ #viewport() {
182
+ const visualViewport = window.visualViewport
183
+ return {
184
+ left: visualViewport?.offsetLeft ?? 0,
185
+ top: visualViewport?.offsetTop ?? 0,
186
+ width: visualViewport?.width ?? window.innerWidth,
187
+ height: visualViewport?.height ?? window.innerHeight,
188
+ }
189
+ }
190
+
191
+ #handleFocusOut(event) {
192
+ if (!this.element.contains(event.relatedTarget)) this.hide()
193
+ }
194
+
195
+ #hideUnlessFocused() {
196
+ if (!this.element.matches(":focus-within")) this.hide()
197
+ }
198
+
199
+ #handleKeydown(event) {
200
+ if (event.key !== "Escape") return
201
+
202
+ this.hide()
203
+ }
204
+
205
+ #describeTrigger() {
206
+ const trigger = this.element.matches(FOCUSABLE_SELECTOR)
207
+ ? this.element
208
+ : this.element.querySelector(FOCUSABLE_SELECTOR)
209
+ if (!trigger || !this.contentTarget.id) return
210
+
211
+ this.describedTrigger = trigger
212
+ this.previousDescription = trigger.getAttribute("aria-describedby")
213
+ const descriptions = this.previousDescription?.split(/\s+/).filter(Boolean) || []
214
+ trigger.setAttribute("aria-describedby", [...new Set([...descriptions, this.contentTarget.id])].join(" "))
215
+ }
216
+
217
+ #restoreDescription() {
218
+ if (!this.describedTrigger) return
219
+
220
+ if (this.previousDescription === null) this.describedTrigger.removeAttribute("aria-describedby")
221
+ else this.describedTrigger.setAttribute("aria-describedby", this.previousDescription)
222
+ }
223
+
224
+ #isOpen() {
225
+ return this.hasContentTarget && this.contentTarget.matches(":popover-open")
226
+ }
227
+
228
+ #clamp(value, minimum, maximum) {
229
+ return Math.min(Math.max(value, minimum), Math.max(minimum, maximum))
230
+ }
231
+ }
data/config/importmap.rb CHANGED
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Auto-pins the gem's bundled Stimulus controllers for importmap-rails consumers.
4
- # Exposes `daisy_ui/controllers/daisy_dropdown_controller`. Register it in the
5
- # host app (lazily recommended):
4
+ # Exposes the bundled `daisy-dropdown` and `daisy-tooltip` controllers. Register
5
+ # them in the host app (lazily recommended):
6
6
  #
7
7
  # // app/javascript/controllers/index.js
8
8
  # import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
@@ -1,28 +1,118 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module DaisyUI
4
6
  class Tooltip < Base
5
7
  self.component_class = :tooltip
6
8
 
7
- def initialize(*, tip: nil, as: :div, **)
8
- super(*, as:, **)
9
+ PLACEMENTS = %i[top bottom left right].freeze
10
+ DEFAULT_STIMULUS_IDENTIFIER = "daisy-tooltip"
11
+ POPOVER_STYLE = "position:fixed;inset:auto;margin:0;border:0;opacity:1;transform:none;overflow:visible;" \
12
+ "pointer-events:none;visibility:hidden"
13
+ ARROW_STYLE = "position:absolute;width:.625rem;height:.25rem;background:var(--tt-bg);" \
14
+ "clip-path:polygon(0 0,100% 0,50% 100%);pointer-events:none"
15
+
16
+ def initialize(*modifiers, tip: nil, as: :div, popover_id: nil, stimulus: true, **options)
17
+ @popover = modifiers.include?(:popover)
18
+ @popover_id = popover_id if @popover
19
+ @stimulus = stimulus
9
20
  @tip = tip
21
+ if popover?
22
+ validate_stimulus!
23
+ boolean_placements = PLACEMENTS.select { |candidate| options[candidate] == true }
24
+ wire_controller(options, modifiers + boolean_placements)
25
+ end
26
+ super(*modifiers, as:, **options)
10
27
  end
11
28
 
12
29
  def view_template(&)
13
- opts = { class: classes, **attributes }
14
- opts[:data_tip] = tip if tip
15
- public_send(as, **opts, &)
30
+ if popover?
31
+ public_send(as, class: merge_classes(classes, "after:hidden"), **attributes) do
32
+ yield self if block_given?
33
+ content { plain tip } if tip && !@content_rendered
34
+ end
35
+ else
36
+ opts = { class: classes, **attributes }
37
+ opts[:data_tip] = tip if tip
38
+ public_send(as, **opts, &)
39
+ end
16
40
  end
17
41
 
18
42
  def content(**options, &)
19
- div(class: component_classes("tooltip-content", options:), **options, &)
43
+ if popover?
44
+ @content_rendered = true
45
+ options[:id] ||= popover_id
46
+ options[:popover] = "manual"
47
+ options[:role] ||= "tooltip"
48
+ options[:style] = merge_style(options[:style], POPOVER_STYLE)
49
+ merge_stimulus_target(options, "content")
50
+
51
+ div(class: component_classes("tooltip-content", options:), **options) do
52
+ span(
53
+ aria_hidden: "true",
54
+ data: { stimulus_target_key => "arrow" },
55
+ style: ARROW_STYLE
56
+ )
57
+ yield if block_given?
58
+ end
59
+ else
60
+ div(class: component_classes("tooltip-content", options:), **options, &)
61
+ end
20
62
  end
21
63
 
22
64
  private
23
65
 
24
66
  attr_reader :tip
25
67
 
68
+ def popover?
69
+ @popover
70
+ end
71
+
72
+ def popover_id
73
+ @popover_id ||= "tooltip_#{SecureRandom.hex(8)}"
74
+ end
75
+
76
+ def stimulus_identifier
77
+ return DEFAULT_STIMULUS_IDENTIFIER if @stimulus == true
78
+
79
+ @stimulus.to_s
80
+ end
81
+
82
+ def validate_stimulus!
83
+ valid_identifier = (@stimulus.is_a?(String) || @stimulus.is_a?(Symbol)) && @stimulus.to_s.match?(/\A\S+\z/)
84
+ return if @stimulus == true || valid_identifier
85
+
86
+ raise ArgumentError, "stimulus must be true or a non-empty controller identifier in popover mode"
87
+ end
88
+
89
+ def stimulus_target_key
90
+ :"#{stimulus_identifier}_target"
91
+ end
92
+
93
+ def placement(source_modifiers = modifiers)
94
+ PLACEMENTS.find { |candidate| source_modifiers.include?(candidate) } || :top
95
+ end
96
+
97
+ def wire_controller(options, source_modifiers)
98
+ data = (options[:data] || {}).dup
99
+ controllers = data[:controller].to_s.split
100
+ data[:controller] = (controllers + [stimulus_identifier]).uniq.join(" ")
101
+ data[:"#{stimulus_identifier}_placement_value"] = placement(source_modifiers)
102
+ options[:data] = data
103
+ end
104
+
105
+ def merge_stimulus_target(options, target)
106
+ data = (options[:data] || {}).dup
107
+ targets = data[stimulus_target_key].to_s.split
108
+ data[stimulus_target_key] = (targets + [target]).uniq.join(" ")
109
+ options[:data] = data
110
+ end
111
+
112
+ def merge_style(existing, added)
113
+ [existing&.chomp(";"), added].compact.join(";")
114
+ end
115
+
26
116
  register_modifiers(
27
117
  # "sm:tooltip-open"
28
118
  # "@sm:tooltip-open"
@@ -3,5 +3,5 @@
3
3
  module DaisyUI
4
4
  # This timestamp is automatically updated when releasing a new version
5
5
  # Format: YYYY-MM-DD HH:MM:SS UTC
6
- UPDATED_AT = "2026-07-03 17:10:17 UTC"
6
+ UPDATED_AT = "2026-08-31 13:48:07 UTC"
7
7
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DaisyUI
4
- VERSION = "1.2.1"
4
+ VERSION = "1.3.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: daisyui
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -56,6 +56,7 @@ files:
56
56
  - LICENSE.txt
57
57
  - README.md
58
58
  - app/javascript/daisy_ui/controllers/daisy_dropdown_controller.js
59
+ - app/javascript/daisy_ui/controllers/daisy_tooltip_controller.js
59
60
  - config/importmap.rb
60
61
  - exe/daisyui-mcp
61
62
  - lib/daisy_ui.rb