Skip to content

Commit 25727cd

Browse files
Add popover open option and mobile-friendly tag rows (#109)
1 parent a9c0938 commit 25727cd

11 files changed

Lines changed: 233 additions & 27 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ Accessible tabbed interface with keyboard navigation.
111111
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.
112112

113113
**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)
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), `open` (Boolean, default `false`; when true the popover opens as soon as the controller connects, shown and positioned in the same task so it never paints unpositioned - e.g. re-opening a filter popover after a form submission re-renders the page)
115115

116116
```html
117117
<div data-controller="l-ui--popover">
@@ -130,6 +130,7 @@ Positions a native `popover`-attribute element relative to its trigger, with aut
130130
Features:
131131
- Repositions on open, and while open on window resize/scroll
132132
- Flips to the opposite side if the preferred placement would overflow the viewport
133+
- `data-l-ui--popover-open-value="true"` opens the popover on connect without a flash of unpositioned content
133134

134135
## Panel (`l-ui--panel`)
135136

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,14 +278,16 @@ Always combine the base block with a modifier, e.g. `<span class="l-ui-badge l-u
278278
Interactive tag - e.g. an email recipient or an active filter. A badge is a static styled span; a tag is a container with separately interactive segments.
279279

280280
```
281+
.l-ui-tag-row Canonical container for a row of tags plus any trailing actions (e.g. a filter bar); wrapping flex row with gap
281282
.l-ui-tag Tag container (div); carries colour and shape but no padding - segments supply it
282283
.l-ui-tag--rounded Pill shape (matching l-ui-badge--rounded); default is the subtle badge radius
283284
.l-ui-tag__text Static label segment (span)
284285
.l-ui-tag__button Interactive label segment (button or a); focus ring + pointer
286+
.l-ui-tag__label Inner wrapper (span) around a text/button segment's content; truncates long labels with an ellipsis
285287
.l-ui-tag__remove Trailing remove segment (button or a) holding a ✕ icon (e.g. l-ui-icon--xs); needs an aria-label
286288
```
287289

288-
A tag takes the same subtle corner radius as a badge by default; add `l-ui-tag--rounded` for a pill. Segments must be direct children in label-then-remove order; when a remove segment follows, the label segment automatically drops its right padding so the remove owns the gap and every part of the tag stays clickable. Prefer the `l_ui_tag` helper (see HELPERS.md), which also wires an optional popover.
290+
A tag takes the same subtle corner radius as a badge by default; add `l-ui-tag--rounded` for a pill. Segments must be direct children in label-then-remove order; when a remove segment follows, the label segment automatically drops its right padding so the remove owns the gap and every part of the tag stays clickable. A tag never grows past its container; wrap label content in `l-ui-tag__label` so it truncates with an ellipsis instead of overflowing. Prefer the `l_ui_tag` helper (see HELPERS.md), which renders the label wrapper and wires an optional popover.
289291

290292
## Tabs
291293

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,12 +328,13 @@ Calling `dialog.showModal()` directly is not supported - it bypasses the `l-ui--
328328
## Popover
329329

330330
```ruby
331-
l_ui_popover(id: nil, placement: :bottom, align: :start, container: {}, &block)
331+
l_ui_popover(id: nil, placement: :bottom, align: :start, open: false, container: {}, &block)
332332
```
333333

334334
- `id` (String, optional) - DOM id for the popover element; defaults to an auto-generated id
335335
- `placement` (Symbol, optional) - `:top`, `:bottom` (default), `:left`, or `:right`. Flips automatically if it would overflow the viewport
336336
- `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
337+
- `open` (Boolean, optional) - when `true`, the popover opens as soon as its Stimulus controller connects, shown and positioned in the same task so it never paints unpositioned. Useful for re-opening a popover after a form submission re-renders the page (e.g. `open: params[:filtering].present?`). Defaults to `false`
337338
- `container` (Hash, optional) - extra HTML attributes for the wrapping `<div>` (e.g. `class:`)
338339
- `&block` - the block's content is the popover body; call `p.trigger(**options, &block)` inside it to render the trigger button
339340

@@ -388,7 +389,9 @@ Builder methods:
388389
- `t.button(**opts, &block)` - button segment (`<button class="l-ui-tag__button">`); opens the tag's popover when one is declared
389390
- `t.link(url, **opts, &block)` - link segment, styled like a button segment
390391
- `t.remove(url = nil, **opts, &block)` - trailing remove segment (`l-ui-tag__remove`): a link when `url` is given, otherwise a button. Renders a ✕ icon unless the block supplies custom content. Pass `aria: { label: ... }` so it has an accessible name
391-
- `t.popover(id: nil, placement: :bottom, align: :start, &block)` - attaches a popover; the block is the popover body. Options match `l_ui_popover`
392+
- `t.popover(id: nil, placement: :bottom, align: :start, open: false, &block)` - attaches a popover; the block is the popover body. Options match `l_ui_popover`
393+
394+
Text, button, and link segments wrap their content in `<span class="l-ui-tag__label">`, which truncates long labels with an ellipsis when the tag is squeezed (the tag itself never grows past its container).
392395

393396
```erb
394397
<%= l_ui_tag do |t| %>

app/assets/tailwind/layered_ui/engine.css

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1916,9 +1916,22 @@ pre.l-ui-surface {
19161916
carries no padding of its own - each segment supplies its share, so every
19171917
part of the tag is a hit target. */
19181918

1919+
.l-ui-tag-row {
1920+
@apply flex flex-wrap items-center
1921+
gap-2;
1922+
}
1923+
1924+
/* The row wraps whole items onto new lines, so a squeezed trailing action
1925+
should move down rather than wrap its label mid-text. Scoped here because
1926+
elsewhere (e.g. a full-width button in a narrow panel) nowrap would force
1927+
horizontal overflow instead of wrapping. */
1928+
.l-ui-tag-row .l-ui-button {
1929+
@apply whitespace-nowrap;
1930+
}
1931+
19191932
.l-ui-tag {
19201933
@apply inline-flex items-stretch
1921-
min-h-[32px]
1934+
max-w-full min-h-[32px]
19221935
text-xs font-medium
19231936
text-foreground-muted
19241937
bg-surface-highlighted
@@ -1932,17 +1945,26 @@ pre.l-ui-surface {
19321945

19331946
.l-ui-tag__text {
19341947
@apply flex items-center
1948+
min-w-0
19351949
gap-1 px-3;
19361950
}
19371951

19381952
.l-ui-tag__button {
19391953
@apply flex items-center
1954+
min-w-0
19401955
gap-1 px-3
19411956
rounded
19421957
focus-ring
19431958
cursor-pointer;
19441959
}
19451960

1961+
/* This wrapper exists so long labels can truncate: ellipsis has no effect on
1962+
text sitting directly inside a flex container like the segment. */
1963+
.l-ui-tag__label {
1964+
@apply overflow-hidden
1965+
text-ellipsis whitespace-nowrap;
1966+
}
1967+
19461968
/* Segment focus rings follow the tag's shape. */
19471969
.l-ui-tag--rounded .l-ui-tag__button,
19481970
.l-ui-tag--rounded .l-ui-tag__remove {

app/helpers/layered/ui/popover_helper.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@ module PopoverHelper
2222
# placement: (Symbol) :top, :bottom (default), :left, or :right. Flips automatically if it would overflow the viewport.
2323
# align: (Symbol) :start (default) flushes the popover's leading edge with the trigger's; :end flushes its trailing edge instead
2424
# (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+
# open: (Boolean) When true, the popover opens as soon as its Stimulus controller connects - shown and positioned in the same
26+
# task, so it never paints unpositioned. Useful for re-opening a popover after a form submission re-renders
27+
# the page. Defaults to false.
2528
# container: (Hash) Extra HTML attributes for the wrapping <div>.
26-
def l_ui_popover(id: nil, placement: :bottom, align: :start, container: {}, &block)
29+
def l_ui_popover(id: nil, placement: :bottom, align: :start, open: false, container: {}, &block)
2730
id ||= "l-ui-popover-#{SecureRandom.hex(4)}"
2831
builder = PopoverBuilder.new(self, id: id)
2932
body_content = capture { block.call(builder) }
@@ -34,6 +37,7 @@ def l_ui_popover(id: nil, placement: :bottom, align: :start, container: {}, &blo
3437
container_data[:controller] = [existing_controller, "l-ui--popover"].compact.reject(&:empty?).join(" ")
3538
container_data[:"l-ui--popover-placement-value"] = placement
3639
container_data[:"l-ui--popover-align-value"] = align
40+
container_data[:"l-ui--popover-open-value"] = true if open
3741
container_attrs[:data] = container_data
3842

3943
tag.div(**container_attrs) do

app/helpers/layered/ui/tag_helper.rb

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ module TagHelper
4242
# t.link(url, **options, &block) Link segment, styled like a button segment.
4343
# t.remove(url = nil, **options, &block) Trailing remove segment: a link when +url+ is given, otherwise a
4444
# button. Renders a ✕ icon unless the block supplies custom content.
45-
# t.popover(id: nil, placement: :bottom, align: :start, &block)
45+
# t.popover(id: nil, placement: :bottom, align: :start, open: false, &block)
4646
# Attaches a popover to the tag; the block is the popover body.
4747
# Options match +l_ui_popover+.
4848
def l_ui_tag(rounded: false, container: {}, &block)
@@ -59,6 +59,7 @@ def l_ui_tag(rounded: false, container: {}, &block)
5959
container_data[:"l-ui--popover-target"] = "trigger"
6060
container_data[:"l-ui--popover-placement-value"] = builder.popover_placement
6161
container_data[:"l-ui--popover-align-value"] = builder.popover_align
62+
container_data[:"l-ui--popover-open-value"] = true if builder.popover_open
6263
container_attrs[:data] = container_data
6364
end
6465

@@ -70,7 +71,7 @@ def l_ui_tag(rounded: false, container: {}, &block)
7071
end
7172

7273
class TagBuilder
73-
attr_reader :segments, :popover_id, :popover_placement, :popover_align, :popover_body
74+
attr_reader :segments, :popover_id, :popover_placement, :popover_align, :popover_open, :popover_body
7475

7576
def initialize(view)
7677
@view = view
@@ -101,10 +102,11 @@ def remove(url = nil, **options, &block)
101102
nil
102103
end
103104

104-
def popover(id: nil, placement: :bottom, align: :start, &block)
105+
def popover(id: nil, placement: :bottom, align: :start, open: false, &block)
105106
@popover_id = id || "l-ui-tag-popover-#{SecureRandom.hex(4)}"
106107
@popover_placement = placement
107108
@popover_align = align
109+
@popover_open = open
108110
@popover_body = @view.capture(&block)
109111
nil
110112
end
@@ -122,15 +124,15 @@ def l_ui_tag_segment(builder, segment)
122124
case segment[:kind]
123125
when :text
124126
options[:class] = class_names("l-ui-tag__text", options[:class])
125-
tag.span(segment[:content], **options)
127+
tag.span(l_ui_tag_label(segment[:content]), **options)
126128
when :button
127129
options[:class] = class_names("l-ui-tag__button", options[:class])
128130
options[:type] ||= "button"
129131
options[:popovertarget] ||= builder.popover_id if builder.popover?
130-
tag.button(segment[:content], **options)
132+
tag.button(l_ui_tag_label(segment[:content]), **options)
131133
when :link
132134
options[:class] = class_names("l-ui-tag__button", options[:class])
133-
link_to(segment[:content], segment[:url], options)
135+
link_to(l_ui_tag_label(segment[:content]), segment[:url], options)
134136
when :remove
135137
options[:class] = class_names("l-ui-tag__remove", options[:class])
136138
content = segment[:content] || l_ui_tag_remove_icon
@@ -143,6 +145,13 @@ def l_ui_tag_segment(builder, segment)
143145
end
144146
end
145147

148+
# Wraps a label segment's content so long labels can truncate with an
149+
# ellipsis - ellipsis has no effect on text sitting directly inside a
150+
# flex container like the segment.
151+
def l_ui_tag_label(content)
152+
tag.span(content, class: "l-ui-tag__label")
153+
end
154+
146155
# Filled x-circle, matching the bundled +icon_close_circle.svg+ asset.
147156
# Rendered inline (rather than via image_tag) so it inherits the tag's
148157
# muted +currentColor+; an <img>-loaded SVG cannot.

app/javascript/layered_ui/controllers/l_ui/popover_controller.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ export default class extends Controller {
99
static targets = ["trigger", "popover"]
1010
static values = {
1111
placement: { type: String, default: "bottom" },
12-
align: { type: String, default: "start" }
12+
align: { type: String, default: "start" },
13+
open: { type: Boolean, default: false }
1314
}
1415

1516
static GAP = 8
@@ -24,6 +25,16 @@ export default class extends Controller {
2425
}
2526
}
2627
element.addEventListener("toggle", this._toggleHandler)
28+
29+
// Open on connect when requested (e.g. re-opening a filter popover after
30+
// a form submission re-renders the page). showPopover() and position()
31+
// must run back-to-back in the same task: the native toggle event is
32+
// dispatched asynchronously, so relying on the listener alone would paint
33+
// the popover unpositioned at the viewport origin for one frame.
34+
if (this.openValue && !element.matches(":popover-open")) {
35+
element.showPopover()
36+
this.position()
37+
}
2738
}
2839

2940
popoverTargetDisconnected(element) {

test/dummy/app/views/pages/popovers.html.erb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,38 @@
131131
</dialog>
132132
</div>
133133

134+
<h2 class="mt-8 l-ui-heading--section">Open on connect</h2>
135+
136+
<p class="mt-4">Pass <code>open: true</code> to open the popover as soon as its Stimulus controller connects - for example, re-opening a filter popover after a form submission re-renders the page. The controller shows and positions the popover in the same task, so it never paints unpositioned. As an <code>auto</code> popover it still light-dismisses as usual, so the example below is only open until you click elsewhere; reload the page to see it again.</p>
137+
138+
<%= l_ui_popover(open: true, container: { class: "mt-4" }) do |p| %>
139+
<% p.trigger(class: "l-ui-button l-ui-button--outline") do %>
140+
Opened on page load
141+
<% end %>
142+
<p>This popover was open when the page loaded.</p>
143+
<% end %>
144+
145+
<div class="mt-6" data-controller="l-ui--modal">
146+
<button class="l-ui-button l-ui-button--outline l-ui-button--small" data-action="l-ui--modal#open">View code</button>
147+
<dialog class="l-ui-modal" data-l-ui--modal-target="dialog" data-action="click->l-ui--modal#closeOnBackdrop" aria-labelledby="modal-popover-open-heading">
148+
<div class="l-ui-modal__header">
149+
<h3 id="modal-popover-open-heading">Open on connect</h3>
150+
<button class="l-ui-button l-ui-button--icon" data-action="l-ui--modal#close" aria-label="Close">
151+
<%= image_tag "layered_ui/icon_close.svg", alt: "", class: "l-ui-icon l-ui-icon--sm", aria: { hidden: true } %>
152+
</button>
153+
</div>
154+
<div class="l-ui-modal__body">
155+
<pre class="l-ui-surface"><code>&lt;%# e.g. open: params[:filtering].present? %&gt;
156+
&lt;%= l_ui_popover(open: true) do |p| %&gt;
157+
&lt;% p.trigger(class: "l-ui-button l-ui-button--outline") do %&gt;
158+
Opened on page load
159+
&lt;% end %&gt;
160+
&lt;p&gt;This popover was open when the page loaded.&lt;/p&gt;
161+
&lt;% end %&gt;</code></pre>
162+
</div>
163+
</dialog>
164+
</div>
165+
134166
<h2 class="mt-8 l-ui-heading--section">Menu content</h2>
135167

136168
<p class="mt-4">For a list of actions (e.g. a "more" menu on a table row), use <code>l-ui-popover__menu</code> to reset the popover's padding to a full-width list, and <code>l-ui-popover__menu-item</code> on each link or button. Add <code>l-ui-popover__menu-item--danger</code> for destructive actions, and an <code>&lt;hr class="l-ui-popover__menu-divider"&gt;</code> to separate groups of items. Give an icon-only trigger an <code>aria-label</code>. Pair with <code>align: :end</code> when the trigger sits at the end of a row.</p>
@@ -202,6 +234,10 @@
202234
<th class="l-ui-table__cell l-ui-table__cell--primary" scope="row"><code>align:</code></th>
203235
<td class="l-ui-table__cell"><code>:start</code> (default) or <code>:end</code> - which edge of the popover flushes with the trigger on the cross axis</td>
204236
</tr>
237+
<tr>
238+
<th class="l-ui-table__cell l-ui-table__cell--primary" scope="row"><code>open:</code></th>
239+
<td class="l-ui-table__cell">When <code>true</code>, the popover opens as soon as its Stimulus controller connects, shown and positioned in the same task so it never paints unpositioned; defaults to <code>false</code></td>
240+
</tr>
205241
<tr>
206242
<th class="l-ui-table__cell l-ui-table__cell--primary" scope="row"><code>container:</code></th>
207243
<td class="l-ui-table__cell">Extra HTML attributes for the wrapping <code>&lt;div&gt;</code> (e.g. <code>class:</code>)</td>

0 commit comments

Comments
 (0)