Skip to content

Commit 12a3910

Browse files
Add popover component
Adds a popover component built on the native HTML popover attribute: - l-ui--popover Stimulus controller computes placement (top, bottom, left, right) with auto-flip near viewport edges; showing, hiding, light-dismiss, and Escape-to-close are handled by the browser - l_ui_popover helper renders the trigger button, popover element, and Stimulus wiring, with placement, align, and container options - l-ui-popover CSS classes, including menu variants for action lists (menu, menu-item, menu-item--danger, menu-divider) - Popovers documentation page in the dummy app, plus row action menu examples on the tables pages - Navigation items gain a hover background (bg-surface), with the active state kept above it - Skill references updated for the new helper, classes, and controller
1 parent 539f502 commit 12a3910

19 files changed

Lines changed: 833 additions & 2 deletions

File tree

.claude/skills/layered-ui-rails/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ Quick reference:
181181
| `l_ui_theme_toggle` | Default header theme toggle button |
182182
| `l_ui_authentication` | Default header login/register buttons (Devise) |
183183
| `l_ui_navigation_toggle` | Default header sidebar toggle button |
184+
| `l_ui_popover(id: nil, placement: :bottom, align: :start, container: {})` | Floating panel anchored to a trigger, built on the native `popover` attribute |
184185

185186
## CSS classes
186187

@@ -202,6 +203,7 @@ Key components:
202203
| Notices | `.l-ui-notice` (base), `--success`, `--warning`, `--error` |
203204
| Tabs | `.l-ui-tabs`, `.l-ui-tabs__list`, `.l-ui-tabs__tab`, `--active` |
204205
| Modal | `.l-ui-modal`, `.l-ui-modal__header`, `.l-ui-modal__body` |
206+
| Popover | `.l-ui-popover` |
205207

206208
## Stimulus controllers
207209

@@ -217,6 +219,7 @@ All controllers use the `l-ui--` namespace and are auto-registered via importmap
217219
| Panel resize | `l-ui--panel-resize` | Panel width drag handle |
218220
| Modal | `l-ui--modal` | Native `<dialog>` with focus trap |
219221
| Tabs | `l-ui--tabs` | Accessible tabbed interface |
222+
| Popover | `l-ui--popover` | Positions a native `popover`-attribute element relative to its trigger, with auto-flip |
220223
| Search form | `l-ui--search-form` | Multi-scope search with Turbo support and pagination param preservation |
221224

222225
## Theming

.claude/skills/layered-ui-rails/references/CONTROLLERS.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,31 @@ Accessible tabbed interface with keyboard navigation.
106106
</div>
107107
```
108108

109+
## Popover (`l-ui--popover`)
110+
111+
Positions a native `popover`-attribute element relative to its trigger, with auto-flip near viewport edges. Showing, hiding, light-dismiss (outside click), and Escape-to-close are all handled by the browser via the `popover` attribute - this controller only handles placement.
112+
113+
**Targets:** `trigger`, `popover`
114+
**Values:** `placement` (String, default `"bottom"`; one of `"top"`, `"bottom"`, `"left"`, `"right"`), `align` (String, default `"start"`; `"start"` or `"end"` - which edge of the popover flushes with the trigger on the cross axis)
115+
116+
```html
117+
<div data-controller="l-ui--popover">
118+
<button type="button" popovertarget="my-popover"
119+
data-l-ui--popover-target="trigger"
120+
class="l-ui-button l-ui-button--outline">
121+
Open popover
122+
</button>
123+
<div id="my-popover" popover="auto" class="l-ui-popover"
124+
data-l-ui--popover-target="popover">
125+
Content here.
126+
</div>
127+
</div>
128+
```
129+
130+
Features:
131+
- Repositions on open, and while open on window resize/scroll
132+
- Flips to the opposite side if the preferred placement would overflow the viewport
133+
109134
## Panel (`l-ui--panel`)
110135

111136
Resizable side panel. Full-width overlay on mobile, docked sidebar on desktop.

.claude/skills/layered-ui-rails/references/CSS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,16 @@ Always combine the base block with a modifier, e.g. `<span class="l-ui-badge l-u
287287
.l-ui-modal__body Scrollable modal content
288288
```
289289

290+
## Popover
291+
292+
```
293+
.l-ui-popover popover-attribute element; positioned by the l-ui--popover Stimulus controller
294+
.l-ui-popover__menu Full-width list wrapper for a menu of actions inside a popover
295+
.l-ui-popover__menu-item Full-width action link/button inside l-ui-popover__menu
296+
.l-ui-popover__menu-item--danger Danger styling for a destructive menu item
297+
.l-ui-popover__menu-divider Horizontal rule separating groups of items (apply to an <hr>)
298+
```
299+
290300
## Breadcrumbs
291301

292302
```

.claude/skills/layered-ui-rails/references/HELPERS.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,51 @@ The button does not need to be inside the helper's wrapper, and no `data-control
322322

323323
Calling `dialog.showModal()` directly is not supported - it bypasses the `l-ui--modal` controller and skips scroll lock, focus restoration, open-count tracking, and the screen-reader announcement.
324324

325+
## Popover
326+
327+
```ruby
328+
l_ui_popover(id: nil, placement: :bottom, align: :start, container: {}, &block)
329+
```
330+
331+
- `id` (String, optional) - DOM id for the popover element; defaults to an auto-generated id
332+
- `placement` (Symbol, optional) - `:top`, `:bottom` (default), `:left`, or `:right`. Flips automatically if it would overflow the viewport
333+
- `align` (Symbol, optional) - `:start` (default) flushes the popover's leading edge with the trigger's; `:end` flushes its trailing edge instead. For example, `placement: :bottom, align: :end` hangs the popover down and to the left of a trigger at the right end of a row
334+
- `container` (Hash, optional) - extra HTML attributes for the wrapping `<div>` (e.g. `class:`)
335+
- `&block` - the block's content is the popover body; call `p.trigger(**options, &block)` inside it to render the trigger button
336+
337+
```erb
338+
<%= l_ui_popover(placement: :bottom) do |p| %>
339+
<% p.trigger(class: "l-ui-button l-ui-button--outline") do %>
340+
Options
341+
<% end %>
342+
<p>Popover content.</p>
343+
<% end %>
344+
```
345+
346+
Renders the trigger `<button popovertarget="...">` and the `popover="auto"` element (`class="l-ui-popover"`), and wires the `l-ui--popover` Stimulus controller for placement. Showing, hiding, light-dismiss (outside click), and Escape-to-close are all handled natively by the browser via the `popover` attribute - no Stimulus action is needed to open or close it.
347+
348+
If the trigger contains only an icon (no visible text), pass `aria-label:` to `p.trigger` so the button has an accessible name.
349+
350+
For a list of actions (e.g. a "more" menu on a table row), wrap the items in a `<div class="l-ui-popover__menu">` and give each link/button `l-ui-popover__menu-item` (add `l-ui-popover__menu-item--danger` for destructive actions, and an `<hr class="l-ui-popover__menu-divider">` to separate groups of items):
351+
352+
```erb
353+
<%= l_ui_table(@users, columns: [...],
354+
actions: ->(r) {
355+
l_ui_popover(align: :end) do |p|
356+
p.trigger(class: "l-ui-button l-ui-button--outline l-ui-button--icon l-ui-button--small", "aria-label" => "Actions for #{r.name}") do
357+
image_tag "layered_ui/icon_more.svg", alt: "", class: "l-ui-icon l-ui-icon--sm", aria: { hidden: true }
358+
end
359+
tag.div(class: "l-ui-popover__menu") do
360+
safe_join([
361+
link_to("Edit", edit_user_path(r), class: "l-ui-popover__menu-item"),
362+
tag.hr(class: "l-ui-popover__menu-divider"),
363+
button_to("Delete", user_path(r), method: :delete, class: "l-ui-popover__menu-item l-ui-popover__menu-item--danger")
364+
])
365+
end
366+
end
367+
}) %>
368+
```
369+
325370
## Header
326371

327372
```ruby
Lines changed: 5 additions & 0 deletions
Loading

app/assets/tailwind/layered_ui/engine.css

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1035,12 +1035,16 @@
10351035
w-full min-h-[40px]
10361036
gap-3 px-3
10371037
text-sm font-medium text-foreground-muted
1038+
hover:bg-surface
10381039
rounded-sm
10391040
focus-ring
10401041
transition-colors;
10411042
}
10421043

1043-
.l-ui-navigation__item--active {
1044+
/* Repeats hover so the active background wins over the base item's
1045+
hover:bg-surface - equal specificity, but this rule comes later. */
1046+
.l-ui-navigation__item--active,
1047+
.l-ui-navigation__item--active:hover {
10441048
@apply text-foreground
10451049
bg-surface-highlighted;
10461050
}
@@ -2242,6 +2246,45 @@ pre.l-ui-surface {
22422246
px-5 py-4;
22432247
}
22442248

2249+
/* Popover */
2250+
2251+
.l-ui-popover {
2252+
@apply fixed inset-auto
2253+
max-w-xs
2254+
m-0 p-3
2255+
text-sm text-foreground
2256+
bg-background
2257+
border border-border rounded-sm;
2258+
}
2259+
2260+
/* A list of actions inside a popover (e.g. a table row's "more" menu).
2261+
Negative margin cancels l-ui-popover's own padding; px-1/py-1 restore a
2262+
small inset for the list itself. */
2263+
.l-ui-popover__menu {
2264+
@apply flex flex-col
2265+
-m-3 px-1 py-1;
2266+
}
2267+
2268+
.l-ui-popover__menu-item {
2269+
@apply block w-full
2270+
px-3 py-2
2271+
text-sm text-left text-foreground
2272+
hover:bg-surface focus:bg-surface
2273+
rounded-sm
2274+
focus-ring
2275+
transition-colors
2276+
cursor-pointer;
2277+
}
2278+
2279+
.l-ui-popover__menu-item--danger {
2280+
@apply text-danger;
2281+
}
2282+
2283+
.l-ui-popover__menu-divider {
2284+
@apply my-1
2285+
border-t border-border;
2286+
}
2287+
22452288
/* Safari */
22462289
@media (max-width: 767px) {
22472290
/* iOS Safari ignores overflow:hidden on body; position:fixed is required */
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
module Layered
2+
module Ui
3+
module PopoverHelper
4+
# Renders a floating panel anchored to a trigger button, built on the
5+
# native HTML +popover+ attribute. Showing, hiding, light-dismiss, and
6+
# Escape-to-close are all handled by the browser; the +l-ui--popover+
7+
# Stimulus controller only computes placement (with auto-flip near
8+
# viewport edges).
9+
#
10+
# <%= l_ui_popover(placement: :bottom) do |p| %>
11+
# <% p.trigger(class: "l-ui-button l-ui-button--outline") do %>
12+
# Options
13+
# <% end %>
14+
# <p>Popover content.</p>
15+
# <% end %>
16+
#
17+
# Note: use +<% p.trigger %>+ (without the equals sign) so its content is
18+
# captured by the builder rather than written to the body buffer.
19+
#
20+
# Options:
21+
# id: (String) DOM id for the popover element; defaults to an auto-generated id.
22+
# placement: (Symbol) :top, :bottom (default), :left, or :right. Flips automatically if it would overflow the viewport.
23+
# align: (Symbol) :start (default) flushes the popover's leading edge with the trigger's; :end flushes its trailing edge instead
24+
# (e.g. placement: :bottom, align: :end hangs the popover down and to the left of a trigger at the right end of a row).
25+
# container: (Hash) Extra HTML attributes for the wrapping <div>.
26+
def l_ui_popover(id: nil, placement: :bottom, align: :start, container: {}, &block)
27+
id ||= "l-ui-popover-#{SecureRandom.hex(4)}"
28+
builder = PopoverBuilder.new(self, id: id)
29+
body_content = capture { block.call(builder) }
30+
31+
container_attrs = container.deep_dup
32+
container_data = container_attrs[:data] || {}
33+
existing_controller = container_data.delete(:controller) || container_data.delete("controller")
34+
container_data[:controller] = [existing_controller, "l-ui--popover"].compact.reject(&:empty?).join(" ")
35+
container_data[:"l-ui--popover-placement-value"] = placement
36+
container_data[:"l-ui--popover-align-value"] = align
37+
container_attrs[:data] = container_data
38+
39+
tag.div(**container_attrs) do
40+
safe_join([
41+
builder.trigger_html || ActiveSupport::SafeBuffer.new,
42+
render_popover(builder, body_content)
43+
])
44+
end
45+
end
46+
47+
class PopoverBuilder
48+
attr_reader :id, :trigger_html
49+
50+
def initialize(view, id:)
51+
@view = view
52+
@id = id
53+
end
54+
55+
def trigger(**options, &block)
56+
options = options.deep_dup
57+
options[:type] ||= "button"
58+
options[:popovertarget] = id
59+
data = options[:data] || {}
60+
data[:"l-ui--popover-target"] = "trigger"
61+
options[:data] = data
62+
content = @view.capture(&block)
63+
@trigger_html = @view.tag.button(content, **options)
64+
nil
65+
end
66+
end
67+
68+
private
69+
70+
def render_popover(builder, body_content)
71+
tag.div(
72+
body_content,
73+
id: builder.id,
74+
popover: "auto",
75+
class: "l-ui-popover",
76+
data: { "l-ui--popover-target" => "popover" }
77+
)
78+
end
79+
end
80+
end
81+
end
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
// Positions a native `popover`-attribute element relative to its trigger.
4+
// Showing, hiding, light-dismiss, and Escape-to-close are all handled by the
5+
// browser via the popover attribute; this controller only computes placement
6+
// (with auto-flip near viewport edges), since CSS anchor positioning isn't
7+
// yet reliable across all supported browsers.
8+
export default class extends Controller {
9+
static targets = ["trigger", "popover"]
10+
static values = {
11+
placement: { type: String, default: "bottom" },
12+
align: { type: String, default: "start" }
13+
}
14+
15+
static GAP = 8
16+
17+
popoverTargetConnected(element) {
18+
this._toggleHandler = (event) => {
19+
if (event.newState === "open") {
20+
this.position()
21+
this._addRepositionListeners()
22+
} else {
23+
this._removeRepositionListeners()
24+
}
25+
}
26+
element.addEventListener("toggle", this._toggleHandler)
27+
}
28+
29+
popoverTargetDisconnected(element) {
30+
element.removeEventListener("toggle", this._toggleHandler)
31+
this._removeRepositionListeners()
32+
}
33+
34+
disconnect() {
35+
this._removeRepositionListeners()
36+
}
37+
38+
// Compute and apply the popover's fixed position relative to the trigger,
39+
// flipping to the opposite side if the preferred placement would overflow
40+
// the viewport.
41+
position() {
42+
if (!this.hasTriggerTarget || !this.hasPopoverTarget) return
43+
44+
const gap = this.constructor.GAP
45+
const trigger = this.triggerTarget.getBoundingClientRect()
46+
const popover = this.popoverTarget.getBoundingClientRect()
47+
const align = this.alignValue
48+
49+
// The cross-axis coordinate (horizontal for top/bottom, vertical for
50+
// left/right): "start" flushes the popover's leading edge with the
51+
// trigger's, "end" flushes its trailing edge instead - e.g. a "bottom"
52+
// placement with "end" align hangs the popover down and to the left of
53+
// a trigger sitting at the right end of a row.
54+
const crossAxisStart = (placement) =>
55+
placement === "top" || placement === "bottom"
56+
? align === "end" ? trigger.right - popover.width : trigger.left
57+
: align === "end" ? trigger.bottom - popover.height : trigger.top
58+
59+
const coordsFor = (placement) => {
60+
switch (placement) {
61+
case "top":
62+
return { top: trigger.top - popover.height - gap, left: crossAxisStart(placement) }
63+
case "left":
64+
return { top: crossAxisStart(placement), left: trigger.left - popover.width - gap }
65+
case "right":
66+
return { top: crossAxisStart(placement), left: trigger.right + gap }
67+
default:
68+
return { top: trigger.bottom + gap, left: crossAxisStart(placement) }
69+
}
70+
}
71+
72+
let placement = this.placementValue
73+
let coords = coordsFor(placement)
74+
75+
if (placement === "bottom" && coords.top + popover.height > window.innerHeight) {
76+
placement = "top"
77+
coords = coordsFor(placement)
78+
} else if (placement === "top" && coords.top < 0) {
79+
placement = "bottom"
80+
coords = coordsFor(placement)
81+
} else if (placement === "right" && coords.left + popover.width > window.innerWidth) {
82+
placement = "left"
83+
coords = coordsFor(placement)
84+
} else if (placement === "left" && coords.left < 0) {
85+
placement = "right"
86+
coords = coordsFor(placement)
87+
}
88+
89+
let left = coords.left
90+
if (placement === "top" || placement === "bottom") {
91+
left = Math.min(Math.max(gap, left), window.innerWidth - popover.width - gap)
92+
}
93+
94+
this.popoverTarget.style.top = `${Math.max(gap, coords.top)}px`
95+
this.popoverTarget.style.left = `${left}px`
96+
}
97+
98+
// Private
99+
100+
_addRepositionListeners() {
101+
this._repositionHandler = () => this.position()
102+
window.addEventListener("resize", this._repositionHandler)
103+
window.addEventListener("scroll", this._repositionHandler, true)
104+
}
105+
106+
_removeRepositionListeners() {
107+
if (!this._repositionHandler) return
108+
window.removeEventListener("resize", this._repositionHandler)
109+
window.removeEventListener("scroll", this._repositionHandler, true)
110+
this._repositionHandler = null
111+
}
112+
}

0 commit comments

Comments
 (0)