daisyui 1.2.0 → 1.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d82c1b4f52e8a5b62fb723e83557c1def70805b24c855aced7381c8c2f80dc0e
4
- data.tar.gz: 831d1acb486a33fb1b779643d273a58925a921a456d20be7374cf4a5ed6c3cf4
3
+ metadata.gz: d6868c0a6fbec134472fd51d293059527c056b84c3606fdc460a3335e9ce09f9
4
+ data.tar.gz: 4eab735dbc402b5967a7d9225fc64cd6fea6a312135762aeca77e0cd46d58f72
5
5
  SHA512:
6
- metadata.gz: 061d1ff9eb34a488db5ab1aff21491e9c4f8b39912c7749637e3941d9738edd5cbef993b5d52bd6e55620f4b6f4207f9ad7285df0f672507c3552f16be775d1a
7
- data.tar.gz: 598db62e9eefc346e30b1d42d449bc49c54c6a80c5d917c21360fb1511ea0ed17380a2c9b2be8b4f1edd647d4281b5b134e18995be66ff1d884de17428a9d127
6
+ metadata.gz: be45f9a0eb9668b3eec64079656919bce38eac29ad02b6d20f1dfd7a596f7db9398f47c1633995b1091855515273f75e3e848dd244228177d1f138b98d3ae7ce
7
+ data.tar.gz: ca6520ba004e142524092b29c4e76622b2c05f1002d11c529659fb8874ef34344f0be5160f84f0b966d997d5913f3e6852abd673f1ae18dc4ae22cf53d285598
data/CHANGELOG.md CHANGED
@@ -19,6 +19,12 @@
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
 
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,117 @@
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;pointer-events:none;visibility:hidden"
12
+ ARROW_STYLE = "position:absolute;width:.625rem;height:.25rem;background:var(--tt-bg);" \
13
+ "clip-path:polygon(0 0,100% 0,50% 100%);pointer-events:none"
14
+
15
+ def initialize(*modifiers, tip: nil, as: :div, popover_id: nil, stimulus: true, **options)
16
+ @popover = modifiers.include?(:popover)
17
+ @popover_id = popover_id if @popover
18
+ @stimulus = stimulus
9
19
  @tip = tip
20
+ if popover?
21
+ validate_stimulus!
22
+ boolean_placements = PLACEMENTS.select { |candidate| options[candidate] == true }
23
+ wire_controller(options, modifiers + boolean_placements)
24
+ end
25
+ super(*modifiers, as:, **options)
10
26
  end
11
27
 
12
28
  def view_template(&)
13
- opts = { class: classes, **attributes }
14
- opts[:data_tip] = tip if tip
15
- public_send(as, **opts, &)
29
+ if popover?
30
+ public_send(as, class: merge_classes(classes, "after:hidden"), **attributes) do
31
+ yield self if block_given?
32
+ content { plain tip } if tip && !@content_rendered
33
+ end
34
+ else
35
+ opts = { class: classes, **attributes }
36
+ opts[:data_tip] = tip if tip
37
+ public_send(as, **opts, &)
38
+ end
16
39
  end
17
40
 
18
41
  def content(**options, &)
19
- div(class: component_classes("tooltip-content", options:), **options, &)
42
+ if popover?
43
+ @content_rendered = true
44
+ options[:id] ||= popover_id
45
+ options[:popover] = "manual"
46
+ options[:role] ||= "tooltip"
47
+ options[:style] = merge_style(options[:style], POPOVER_STYLE)
48
+ merge_stimulus_target(options, "content")
49
+
50
+ div(class: component_classes("tooltip-content", options:), **options) do
51
+ span(
52
+ aria_hidden: "true",
53
+ data: { stimulus_target_key => "arrow" },
54
+ style: ARROW_STYLE
55
+ )
56
+ yield if block_given?
57
+ end
58
+ else
59
+ div(class: component_classes("tooltip-content", options:), **options, &)
60
+ end
20
61
  end
21
62
 
22
63
  private
23
64
 
24
65
  attr_reader :tip
25
66
 
67
+ def popover?
68
+ @popover
69
+ end
70
+
71
+ def popover_id
72
+ @popover_id ||= "tooltip_#{SecureRandom.hex(8)}"
73
+ end
74
+
75
+ def stimulus_identifier
76
+ return DEFAULT_STIMULUS_IDENTIFIER if @stimulus == true
77
+
78
+ @stimulus.to_s
79
+ end
80
+
81
+ def validate_stimulus!
82
+ valid_identifier = (@stimulus.is_a?(String) || @stimulus.is_a?(Symbol)) && @stimulus.to_s.match?(/\A\S+\z/)
83
+ return if @stimulus == true || valid_identifier
84
+
85
+ raise ArgumentError, "stimulus must be true or a non-empty controller identifier in popover mode"
86
+ end
87
+
88
+ def stimulus_target_key
89
+ :"#{stimulus_identifier}_target"
90
+ end
91
+
92
+ def placement(source_modifiers = modifiers)
93
+ PLACEMENTS.find { |candidate| source_modifiers.include?(candidate) } || :top
94
+ end
95
+
96
+ def wire_controller(options, source_modifiers)
97
+ data = (options[:data] || {}).dup
98
+ controllers = data[:controller].to_s.split
99
+ data[:controller] = (controllers + [stimulus_identifier]).uniq.join(" ")
100
+ data[:"#{stimulus_identifier}_placement_value"] = placement(source_modifiers)
101
+ options[:data] = data
102
+ end
103
+
104
+ def merge_stimulus_target(options, target)
105
+ data = (options[:data] || {}).dup
106
+ targets = data[stimulus_target_key].to_s.split
107
+ data[stimulus_target_key] = (targets + [target]).uniq.join(" ")
108
+ options[:data] = data
109
+ end
110
+
111
+ def merge_style(existing, added)
112
+ [existing&.chomp(";"), added].compact.join(";")
113
+ end
114
+
26
115
  register_modifiers(
27
116
  # "sm:tooltip-open"
28
117
  # "@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-06-26 15:27:35 UTC"
6
+ UPDATED_AT = "2026-08-30 12:32:05 UTC"
7
7
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DaisyUI
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.0"
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.0
4
+ version: 1.3.0
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