layered-ui-rails 0.20.0 → 0.21.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: f93bf15a80c67a460c8eada9407beb94507d3dce7fe511e935355abcd329a4d8
4
- data.tar.gz: 06d49cc35c54fa21fddb6ced245abfa9ad4783003a03074beb9467e03f46cbb1
3
+ metadata.gz: 9ced93c0df2346914e675fcdf3dd10657a079d3dcc046271dfb6bb24e6ff2c8c
4
+ data.tar.gz: 5e64064b0fdb591d0621118350bf5c1b181c88de7799a02a9b7f2e7e628f5ad0
5
5
  SHA512:
6
- metadata.gz: f4435d521aa96f5e74b0d1183ae5d486b795d28eb6d6dc16728b8147599715a5e49b64c0a226a78b5aed479c6586dd73f02962321d2e976e693453f79d022fe6
7
- data.tar.gz: 21030bae58aefc605b07370d6dcc615a1f1441566a72b28859068151109d41005b1fc333234878293a4c02d424d5896f9b3230886f1efcfc071ce3cbb493a7d4
6
+ metadata.gz: 9408f35f4b9b2d2a57d0227ec06cbf55a8f8c9ac692d97e1a0a6e9765f0b369bf9732aea5d8742b4c454c1cc555e258303abb2e57f878c352db18b5416fb454b
7
+ data.tar.gz: e24b88f24d9f70192bdfa75166f287da9bb01165c6af436b25efc812e7d83e1c5df2cd915896a75aa5c26a4c699c2fbcd3ac6661a0f8f0888d94d8366daac241
@@ -182,6 +182,7 @@ Quick reference:
182
182
  | `l_ui_authentication` | Default header login/register buttons (Devise) |
183
183
  | `l_ui_navigation_toggle` | Default header sidebar toggle button |
184
184
  | `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 |
185
186
 
186
187
  ## CSS classes
187
188
 
@@ -200,6 +201,7 @@ Key components:
200
201
  | Forms | `.l-ui-form`, `.l-ui-form__group`, `.l-ui-form__field`, `.l-ui-label`, `.l-ui-select` |
201
202
  | Tables | `.l-ui-table`, `.l-ui-table__header`, `.l-ui-table__cell`, `--primary`, `--action`, `.l-ui-table__action`, `--danger` |
202
203
  | Badges | `.l-ui-badge`, `--rounded`, `--default`, `--success`, `--warning`, `--danger` |
204
+ | Tags | `.l-ui-tag`, `--rounded`, `__text`, `__button`, `__remove` |
203
205
  | Notices | `.l-ui-notice` (base), `--success`, `--warning`, `--error` |
204
206
  | Tabs | `.l-ui-tabs`, `.l-ui-tabs__list`, `.l-ui-tabs__tab`, `--active` |
205
207
  | Modal | `.l-ui-modal`, `.l-ui-modal__header`, `.l-ui-modal__body` |
@@ -269,6 +269,20 @@ Always combine the base block with one variant modifier (e.g. `class="l-ui-notic
269
269
 
270
270
  Always combine the base block with a modifier, e.g. `<span class="l-ui-badge l-ui-badge--success">`.
271
271
 
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
+
272
286
  ## Tabs
273
287
 
274
288
  ```
@@ -367,6 +367,41 @@ For a list of actions (e.g. a "more" menu on a table row), wrap the items in a `
367
367
  }) %>
368
368
  ```
369
369
 
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
+
370
405
  ## Header
371
406
 
372
407
  ```ruby
data/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. This project follows [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [0.21.0] - 2026-07-09
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
+
5
12
  ## [0.20.0] - 2026-07-08
6
13
 
7
14
  ### Added
data/README.md CHANGED
@@ -36,7 +36,7 @@ An open source, Rails 8+ engine that provides WCAG 2.2 AA compliant design token
36
36
  - **Dark/light theme** - system preference detection with localStorage persistence and manual toggle
37
37
  - **Responsive layout** - header, sidebar navigation, main content area, and optional resizable panel
38
38
  - **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
40
40
  - **Optional integrations** - Devise authentication and Pagy pagination with styled views
41
41
  - **Customisable branding** - Override the default logos and icons and colors
42
42
  - **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
@@ -0,0 +1,3 @@
1
+ <svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
2
+ <path fill-rule="evenodd" clip-rule="evenodd" 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" />
3
+ </svg>
@@ -1855,6 +1855,62 @@ pre.l-ui-surface {
1855
1855
  bg-error-bg;
1856
1856
  }
1857
1857
 
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
+
1858
1914
  /* Panel */
1859
1915
 
1860
1916
  .l-ui-panel__button {
@@ -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
@@ -38,6 +38,7 @@ module Layered
38
38
  helper Layered::Ui::ModalHelper
39
39
  helper Layered::Ui::PopoverHelper
40
40
  helper Layered::Ui::RansackHelper
41
+ helper Layered::Ui::TagHelper
41
42
  end
42
43
  end
43
44
 
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Ui
3
- VERSION = "0.20.0"
3
+ VERSION = "0.21.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: layered-ui-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.20.0
4
+ version: 0.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai
@@ -193,6 +193,7 @@ files:
193
193
  - app/assets/images/layered_ui/icon_chevron_down.svg
194
194
  - app/assets/images/layered_ui/icon_chevron_right.svg
195
195
  - app/assets/images/layered_ui/icon_close.svg
196
+ - app/assets/images/layered_ui/icon_close_circle.svg
196
197
  - app/assets/images/layered_ui/icon_dark.svg
197
198
  - app/assets/images/layered_ui/icon_discord.svg
198
199
  - app/assets/images/layered_ui/icon_github.svg
@@ -224,6 +225,7 @@ files:
224
225
  - app/helpers/layered/ui/popover_helper.rb
225
226
  - app/helpers/layered/ui/ransack_helper.rb
226
227
  - app/helpers/layered/ui/table_helper.rb
228
+ - app/helpers/layered/ui/tag_helper.rb
227
229
  - app/helpers/layered/ui/title_bar_helper.rb
228
230
  - app/javascript/layered_ui/controllers/l_ui/modal_controller.js
229
231
  - app/javascript/layered_ui/controllers/l_ui/navigation_controller.js