Skip to content

Commit d31b08d

Browse files
Add tag component (#107)
1 parent 9aee758 commit d31b08d

16 files changed

Lines changed: 759 additions & 3 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ Quick reference:
182182
| `l_ui_authentication` | Default header login/register buttons (Devise) |
183183
| `l_ui_navigation_toggle` | Default header sidebar toggle button |
184184
| `l_ui_popover(id: nil, placement: :bottom, align: :start, container: {})` | Floating panel anchored to a trigger, built on the native `popover` attribute |
185+
| `l_ui_tag(rounded: false, container: {})` | Interactive tag built from segments (`t.text`, `t.button`, `t.link`, `t.remove`) with an optional attached popover (`t.popover`); `rounded: true` for a pill shape |
185186

186187
## CSS classes
187188

@@ -200,6 +201,7 @@ Key components:
200201
| Forms | `.l-ui-form`, `.l-ui-form__group`, `.l-ui-form__field`, `.l-ui-label`, `.l-ui-select` |
201202
| Tables | `.l-ui-table`, `.l-ui-table__header`, `.l-ui-table__cell`, `--primary`, `--action`, `.l-ui-table__action`, `--danger` |
202203
| Badges | `.l-ui-badge`, `--rounded`, `--default`, `--success`, `--warning`, `--danger` |
204+
| Tags | `.l-ui-tag`, `--rounded`, `__text`, `__button`, `__remove` |
203205
| Notices | `.l-ui-notice` (base), `--success`, `--warning`, `--error` |
204206
| Tabs | `.l-ui-tabs`, `.l-ui-tabs__list`, `.l-ui-tabs__tab`, `--active` |
205207
| Modal | `.l-ui-modal`, `.l-ui-modal__header`, `.l-ui-modal__body` |

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,20 @@ Always combine the base block with one variant modifier (e.g. `class="l-ui-notic
269269

270270
Always combine the base block with a modifier, e.g. `<span class="l-ui-badge l-ui-badge--success">`.
271271

272+
## Tags
273+
274+
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.
275+
276+
```
277+
.l-ui-tag Tag container (div); carries colour and shape but no padding - segments supply it
278+
.l-ui-tag--rounded Pill shape (matching l-ui-badge--rounded); default is the subtle badge radius
279+
.l-ui-tag__text Static label segment (span)
280+
.l-ui-tag__button Interactive label segment (button or a); focus ring + pointer
281+
.l-ui-tag__remove Trailing remove segment (button or a) holding a ✕ icon (e.g. l-ui-icon--xs); needs an aria-label
282+
```
283+
284+
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.
285+
272286
## Tabs
273287

274288
```

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,41 @@ For a list of actions (e.g. a "more" menu on a table row), wrap the items in a `
367367
}) %>
368368
```
369369

370+
## Tag
371+
372+
```ruby
373+
l_ui_tag(rounded: false, container: {}, &block)
374+
```
375+
376+
Renders an interactive tag (`class="l-ui-tag"`) - e.g. an email recipient or an active filter. Visually related to `l-ui-badge` but a distinct control: a badge is a static styled span, while a tag is a container whose segments (label, remove) are separately interactive.
377+
378+
- `rounded` (Boolean, optional) - pill shape (matching `l-ui-badge--rounded`); defaults to `false` for the subtle badge radius
379+
- `container` (Hash, optional) - extra HTML attributes for the wrapping `<div>` (e.g. `class:`)
380+
- `&block` - declare segments on the builder; they render in call order. Use `<%` (not `<%=`) - segment content is captured by the builder
381+
382+
Builder methods:
383+
384+
- `t.text(content = nil, **opts, &block)` - static label segment (`<span class="l-ui-tag__text">`)
385+
- `t.button(**opts, &block)` - button segment (`<button class="l-ui-tag__button">`); opens the tag's popover when one is declared
386+
- `t.link(url, **opts, &block)` - link segment, styled like a button segment
387+
- `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
388+
- `t.popover(id: nil, placement: :bottom, align: :start, &block)` - attaches a popover; the block is the popover body. Options match `l_ui_popover`
389+
390+
```erb
391+
<%= l_ui_tag do |t| %>
392+
<% t.button(aria: { label: "Edit status filter" }) do %>
393+
Status: Active
394+
<% end %>
395+
<% t.remove remove_filter_path, aria: { label: "Remove status filter" },
396+
data: { turbo_frame: "results" } %>
397+
<% t.popover do %>
398+
<p>Filter controls.</p>
399+
<% end %>
400+
<% end %>
401+
```
402+
403+
When a popover is declared, the tag container itself becomes the `l-ui--popover` controller root and placement target - the popover aligns with the whole tag rather than the label button inside it - and button segments are wired to open it via `popovertarget`. A tag without a popover renders no Stimulus wiring.
404+
370405
## Header
371406

372407
```ruby

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
All notable changes to this project will be documented in this file. This project follows [Semantic Versioning](https://semver.org/).
44

5+
## [Unreleased]
6+
7+
### Added
8+
9+
- Tags: interactive tags (`l-ui-tag`), such as email recipients or active filters. Visually related to badges but a distinct control: a badge is a static styled span, while a tag is a container whose segments - `l-ui-tag__text` (static label), `l-ui-tag__button` (button or link label), and `l-ui-tag__remove` (trailing ✕ link or button) - are separately interactive, each carrying its share of the tag's padding so every part is a hit target. Takes the same subtle corner radius as a badge by default, with `l-ui-tag--rounded` for a pill shape (matching `l-ui-badge--rounded`). The `l_ui_tag` helper builds the tag from builder calls (`t.text`, `t.button`, `t.link`, `t.remove`, `t.popover`) and takes a `rounded:` option; declaring `t.popover` makes the whole tag the popover's placement target and wires button segments to open it via `popovertarget`. The default `t.remove` control is a filled x-circle.
10+
- `icon_close_circle.svg` (a filled circle with a cross knocked out) added to the bundled icon set, used by the tag remove control and available as a general-purpose icon.
11+
512
## [0.20.0] - 2026-07-08
613

714
### Added

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ An open source, Rails 8+ engine that provides WCAG 2.2 AA compliant design token
3636
- **Dark/light theme** - system preference detection with localStorage persistence and manual toggle
3737
- **Responsive layout** - header, sidebar navigation, main content area, and optional resizable panel
3838
- **WCAG 2.2 AA compliant** - skip links, focus indicators, ARIA attributes, and 4.5:1 contrast ratios
39-
- **Components** - buttons, forms, surfaces, tables, tabs, notices, badges, conversations, modals, popovers, and pagination
39+
- **Components** - buttons, forms, surfaces, tables, tabs, notices, badges, tags, conversations, modals, popovers, and pagination
4040
- **Optional integrations** - Devise authentication and Pagy pagination with styled views
4141
- **Customisable branding** - Override the default logos and icons and colors
4242
- **Google Lighthouse** - `layered-ui-rails` scores a [perfect 100](https://github.com/layered-ai-public/layered-ui-rails/raw/refs/heads/main/test/dummy/app/assets/images/lighthouse.webp) across all four Google Lighthouse categories - performance, accessibility, best practices, and SEO
Lines changed: 3 additions & 0 deletions
Loading

app/assets/tailwind/layered_ui/engine.css

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1855,6 +1855,62 @@ pre.l-ui-surface {
18551855
bg-error-bg;
18561856
}
18571857

1858+
/* Tags */
1859+
1860+
/* An interactive tag - e.g. an email recipient or an active filter. Related
1861+
to the badge visually, but a distinct control: a badge is a static styled
1862+
span, while a tag is a container with interactive segments. The container
1863+
carries no padding of its own - each segment supplies its share, so every
1864+
part of the tag is a hit target. */
1865+
1866+
.l-ui-tag {
1867+
@apply inline-flex items-stretch
1868+
min-h-[32px]
1869+
text-xs font-medium
1870+
text-foreground-muted
1871+
bg-surface-highlighted
1872+
rounded;
1873+
}
1874+
1875+
/* Pill shape, matching l-ui-badge--rounded. */
1876+
.l-ui-tag--rounded {
1877+
@apply !rounded-full;
1878+
}
1879+
1880+
.l-ui-tag__text {
1881+
@apply flex items-center
1882+
gap-1 px-3;
1883+
}
1884+
1885+
.l-ui-tag__button {
1886+
@apply flex items-center
1887+
gap-1 px-3
1888+
rounded
1889+
focus-ring
1890+
cursor-pointer;
1891+
}
1892+
1893+
/* Segment focus rings follow the tag's shape. */
1894+
.l-ui-tag--rounded .l-ui-tag__button,
1895+
.l-ui-tag--rounded .l-ui-tag__remove {
1896+
@apply !rounded-full;
1897+
}
1898+
1899+
/* When a remove segment follows, it owns the gap between the label and the
1900+
✕ (its left padding), so the label segment gives up its right padding. */
1901+
.l-ui-tag__text:has(+ .l-ui-tag__remove),
1902+
.l-ui-tag__button:has(+ .l-ui-tag__remove) {
1903+
@apply pr-0;
1904+
}
1905+
1906+
.l-ui-tag__remove {
1907+
@apply flex items-center
1908+
pl-1.5 pr-2
1909+
rounded
1910+
focus-ring
1911+
cursor-pointer;
1912+
}
1913+
18581914
/* Panel */
18591915

18601916
.l-ui-panel__button {
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
module Layered
2+
module Ui
3+
module TagHelper
4+
# Renders an interactive tag - e.g. an email recipient or an active
5+
# filter. Visually related to +l-ui-badge+, but a distinct control: a
6+
# badge is a static styled span, while a tag is a container whose
7+
# segments (label, remove) are separately interactive.
8+
#
9+
# <%= l_ui_tag do |t| %>
10+
# <% t.text "alice@example.com" %>
11+
# <% t.remove aria: { label: "Remove alice@example.com" } %>
12+
# <% end %>
13+
#
14+
# A tag may carry a popover. The whole tag becomes the placement target
15+
# (so the popover aligns with the tag rather than the segment inside
16+
# it), and button segments open it via +popovertarget+:
17+
#
18+
# <%= l_ui_tag do |t| %>
19+
# <% t.button(aria: { label: "Edit status filter" }) do %>
20+
# Status: Active
21+
# <% end %>
22+
# <% t.remove remove_filter_path, aria: { label: "Remove status filter" } %>
23+
# <% t.popover do %>
24+
# <p>Filter controls.</p>
25+
# <% end %>
26+
# <% end %>
27+
#
28+
# Note: use +<% t.button %>+ (without the equals sign) so segment content
29+
# is captured by the builder rather than written to the body buffer.
30+
#
31+
# Segments render in the order they are declared; the popover element is
32+
# always appended last. Give icon-only segments (such as +t.remove+) an
33+
# +aria-label+ so they have an accessible name.
34+
#
35+
# Options:
36+
# rounded: (Boolean) Pill shape (matching +l-ui-badge--rounded+); defaults to false for the subtle badge shape.
37+
# container: (Hash) Extra HTML attributes for the wrapping <div>.
38+
#
39+
# Builder methods:
40+
# t.text(content = nil, **options, &block) Static label segment (<span>).
41+
# t.button(**options, &block) Button segment; opens the tag's popover when one is declared.
42+
# t.link(url, **options, &block) Link segment, styled like a button segment.
43+
# t.remove(url = nil, **options, &block) Trailing remove segment: a link when +url+ is given, otherwise a
44+
# button. Renders a ✕ icon unless the block supplies custom content.
45+
# t.popover(id: nil, placement: :bottom, align: :start, &block)
46+
# Attaches a popover to the tag; the block is the popover body.
47+
# Options match +l_ui_popover+.
48+
def l_ui_tag(rounded: false, container: {}, &block)
49+
builder = TagBuilder.new(self)
50+
capture { block.call(builder) }
51+
52+
container_attrs = container.deep_dup
53+
container_attrs[:class] = class_names("l-ui-tag", ("l-ui-tag--rounded" if rounded), container_attrs[:class])
54+
55+
if builder.popover?
56+
container_data = container_attrs[:data] || {}
57+
existing_controller = container_data.delete(:controller) || container_data.delete("controller")
58+
container_data[:controller] = [existing_controller, "l-ui--popover"].compact.reject(&:empty?).join(" ")
59+
container_data[:"l-ui--popover-target"] = "trigger"
60+
container_data[:"l-ui--popover-placement-value"] = builder.popover_placement
61+
container_data[:"l-ui--popover-align-value"] = builder.popover_align
62+
container_attrs[:data] = container_data
63+
end
64+
65+
tag.div(**container_attrs) do
66+
parts = builder.segments.map { |segment| l_ui_tag_segment(builder, segment) }
67+
parts << l_ui_tag_popover(builder) if builder.popover?
68+
safe_join(parts)
69+
end
70+
end
71+
72+
class TagBuilder
73+
attr_reader :segments, :popover_id, :popover_placement, :popover_align, :popover_body
74+
75+
def initialize(view)
76+
@view = view
77+
@segments = []
78+
end
79+
80+
def text(content = nil, **options, &block)
81+
content = @view.capture(&block) if block
82+
@segments << { kind: :text, content: content, options: options }
83+
nil
84+
end
85+
86+
def button(**options, &block)
87+
content = block ? @view.capture(&block) : nil
88+
@segments << { kind: :button, content: content, options: options }
89+
nil
90+
end
91+
92+
def link(url, **options, &block)
93+
content = block ? @view.capture(&block) : nil
94+
@segments << { kind: :link, url: url, content: content, options: options }
95+
nil
96+
end
97+
98+
def remove(url = nil, **options, &block)
99+
content = block ? @view.capture(&block) : nil
100+
@segments << { kind: :remove, url: url, content: content, options: options }
101+
nil
102+
end
103+
104+
def popover(id: nil, placement: :bottom, align: :start, &block)
105+
@popover_id = id || "l-ui-tag-popover-#{SecureRandom.hex(4)}"
106+
@popover_placement = placement
107+
@popover_align = align
108+
@popover_body = @view.capture(&block)
109+
nil
110+
end
111+
112+
def popover?
113+
!@popover_id.nil?
114+
end
115+
end
116+
117+
private
118+
119+
def l_ui_tag_segment(builder, segment)
120+
options = segment[:options].deep_dup
121+
122+
case segment[:kind]
123+
when :text
124+
options[:class] = class_names("l-ui-tag__text", options[:class])
125+
tag.span(segment[:content], **options)
126+
when :button
127+
options[:class] = class_names("l-ui-tag__button", options[:class])
128+
options[:type] ||= "button"
129+
options[:popovertarget] ||= builder.popover_id if builder.popover?
130+
tag.button(segment[:content], **options)
131+
when :link
132+
options[:class] = class_names("l-ui-tag__button", options[:class])
133+
link_to(segment[:content], segment[:url], options)
134+
when :remove
135+
options[:class] = class_names("l-ui-tag__remove", options[:class])
136+
content = segment[:content] || l_ui_tag_remove_icon
137+
if segment[:url]
138+
link_to(content, segment[:url], options)
139+
else
140+
options[:type] ||= "button"
141+
tag.button(content, **options)
142+
end
143+
end
144+
end
145+
146+
# Filled x-circle, matching the bundled +icon_close_circle.svg+ asset.
147+
# Rendered inline (rather than via image_tag) so it inherits the tag's
148+
# muted +currentColor+; an <img>-loaded SVG cannot.
149+
def l_ui_tag_remove_icon
150+
tag.svg(
151+
tag.path("fill-rule" => "evenodd", "clip-rule" => "evenodd",
152+
"d" => "M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm-1.72 6.97a.75.75 0 0 0-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06L12 13.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L13.06 12l1.72-1.72a.75.75 0 1 0-1.06-1.06L12 10.94l-1.72-1.72Z"),
153+
class: "l-ui-icon--sm",
154+
fill: "currentColor",
155+
"viewBox" => "0 0 24 24",
156+
"aria-hidden" => "true"
157+
)
158+
end
159+
160+
def l_ui_tag_popover(builder)
161+
tag.div(
162+
builder.popover_body,
163+
id: builder.popover_id,
164+
popover: "auto",
165+
class: "l-ui-popover",
166+
data: { "l-ui--popover-target" => "popover" }
167+
)
168+
end
169+
end
170+
end
171+
end

lib/layered/ui/engine.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class Engine < ::Rails::Engine
3838
helper Layered::Ui::ModalHelper
3939
helper Layered::Ui::PopoverHelper
4040
helper Layered::Ui::RansackHelper
41+
helper Layered::Ui::TagHelper
4142
end
4243
end
4344

test/dummy/app/controllers/pages_controller.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ def notices
1616
def badges
1717
end
1818

19+
def tags
20+
end
21+
1922
def links
2023
end
2124

0 commit comments

Comments
 (0)