layered-ui-rails 0.21.0 → 0.22.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: 9ced93c0df2346914e675fcdf3dd10657a079d3dcc046271dfb6bb24e6ff2c8c
4
- data.tar.gz: 5e64064b0fdb591d0621118350bf5c1b181c88de7799a02a9b7f2e7e628f5ad0
3
+ metadata.gz: 2371574fe91ff33cc9926d8e5fccf86afd408b90f61dc570514bd86d5f1bb149
4
+ data.tar.gz: ef320467046f71ad12c3b16f0f738188f3a5b40cf705d50e9fe8f11ea1512f6e
5
5
  SHA512:
6
- metadata.gz: 9408f35f4b9b2d2a57d0227ec06cbf55a8f8c9ac692d97e1a0a6e9765f0b369bf9732aea5d8742b4c454c1cc555e258303abb2e57f878c352db18b5416fb454b
7
- data.tar.gz: e24b88f24d9f70192bdfa75166f287da9bb01165c6af436b25efc812e7d83e1c5df2cd915896a75aa5c26a4c699c2fbcd3ac6661a0f8f0888d94d8366daac241
6
+ metadata.gz: f5ea052a4964edc0edea159e1179aee36a32ec491c2cf39ed09de01af8be343e0bb289f9e5e911d5e95a887379357fa284966ff6d4471edeaa16f3c2cd3dc81b
7
+ data.tar.gz: 31a3897b1c16acc5923b6a442004f4f2ccc9e9f731270724ce39739e014df8afef062af1516a8a95aeb009b35727e602129bdc7cdbc8fa024d072c527dabdad8
@@ -111,7 +111,7 @@ Accessible tabbed interface with keyboard navigation.
111
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
112
 
113
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)
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)
115
115
 
116
116
  ```html
117
117
  <div data-controller="l-ui--popover">
@@ -130,6 +130,7 @@ Positions a native `popover`-attribute element relative to its trigger, with aut
130
130
  Features:
131
131
  - Repositions on open, and while open on window resize/scroll
132
132
  - 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
133
134
 
134
135
  ## Panel (`l-ui--panel`)
135
136
 
@@ -202,3 +203,20 @@ When multiple search forms exist on one page (each with a different `scope` valu
202
203
  &lt;%= l_ui_pagy(@users_pagy) %&gt;
203
204
  &lt;% end %&gt;
204
205
  ```
206
+
207
+ ## Scroll hint (`l-ui--scroll-hint`)
208
+
209
+ Fades the clipped edges of a horizontal scroller while more content is available in that direction, so it is obvious the region can be scrolled.
210
+
211
+ **Targets:** `scroller` (the scrollable element)
212
+ **Behaviour:** toggles `l-ui-scroll-hint--left`/`l-ui-scroll-hint--right` on the wrapper from the scroll position; updates on scroll and when the scroller or its content resizes. While the scroller overflows it is given `tabindex="0"` so keyboard users can scroll it (WCAG 2.1.1); the attribute is removed when it fits.
213
+
214
+ `l_ui_table` wires this up automatically. For hand-written tables (or any other horizontal scroller), wrap the scrolling element:
215
+
216
+ ```html
217
+ <div class="l-ui-scroll-hint" data-controller="l-ui--scroll-hint">
218
+ <div class="l-ui-table-container" data-l-ui--scroll-hint-target="scroller">
219
+ <table class="l-ui-table">...</table>
220
+ </div>
221
+ </div>
222
+ ```
@@ -204,6 +204,7 @@ Always combine the `l-ui-surface` base class with any modifiers (e.g. `l-ui-surf
204
204
 
205
205
  ```
206
206
  .l-ui-table Table element
207
+ .l-ui-table--floating-actions Modifier: pins action cells to the right-hand edge of the scroll container during horizontal scrolling (sticky; solid background with a faded leading edge)
207
208
  .l-ui-table__header <thead> row
208
209
  .l-ui-table__header-cell <th> cell
209
210
  .l-ui-table__header-cell--action Right-aligned action header
@@ -217,6 +218,9 @@ Always combine the `l-ui-surface` base class with any modifiers (e.g. `l-ui-surf
217
218
  .l-ui-table__action Action link/button (inside a __cell--action)
218
219
  .l-ui-table__action--danger Danger modifier (combine with l-ui-table__action)
219
220
  .l-ui-table-container Overflow wrapper for responsive tables
221
+ .l-ui-scroll-hint Wrapper around a horizontal scroller (e.g. l-ui-table-container); fades the clipped edge while more content is available (paired with the l-ui--scroll-hint controller)
222
+ .l-ui-scroll-hint--left Fade visible on the left edge (toggled by the controller)
223
+ .l-ui-scroll-hint--right Fade visible on the right edge (toggled by the controller)
220
224
  ```
221
225
 
222
226
  WCAG 2.2 AA table pattern:
@@ -274,14 +278,16 @@ Always combine the base block with a modifier, e.g. `<span class="l-ui-badge l-u
274
278
  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
279
 
276
280
  ```
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
277
282
  .l-ui-tag Tag container (div); carries colour and shape but no padding - segments supply it
278
283
  .l-ui-tag--rounded Pill shape (matching l-ui-badge--rounded); default is the subtle badge radius
279
284
  .l-ui-tag__text Static label segment (span)
280
285
  .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
281
287
  .l-ui-tag__remove Trailing remove segment (button or a) holding a ✕ icon (e.g. l-ui-icon--xs); needs an aria-label
282
288
  ```
283
289
 
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.
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.
285
291
 
286
292
  ## Tabs
287
293
 
@@ -197,8 +197,8 @@ Returns a `<th>` element with sort link and ARIA sort attributes.
197
197
 
198
198
  ```ruby
199
199
  l_ui_table(records, columns:, caption: nil, actions: nil,
200
- actions_label: "Actions", query: nil, url: nil,
201
- turbo_frame: nil, row_id: nil)
200
+ actions_label: "Actions", floating_actions: false,
201
+ query: nil, url: nil, turbo_frame: nil, row_id: nil)
202
202
  ```
203
203
 
204
204
  - `records` (ActiveRecord::Relation or Array) - the collection to render
@@ -206,11 +206,14 @@ l_ui_table(records, columns:, caption: nil, actions: nil,
206
206
  - `caption` (String, optional) - visually hidden table caption for accessibility
207
207
  - `actions` (Proc, optional) - receives (record), returns action cell content
208
208
  - `actions_label` (String) - header text for the actions column, default "Actions"
209
+ - `floating_actions` (Boolean) - pin the actions column to the right-hand edge of the scroll container while the table scrolls horizontally (adds `l-ui-table--floating-actions`), default false
209
210
  - `query` (Ransack::Search, optional) - enables sortable column headers
210
211
  - `url` (String, optional) - sort link URL (passed to `l_ui_sort_link`)
211
212
  - `turbo_frame` (String, optional) - turbo frame target for sort links
212
213
  - `row_id` (Proc, optional) - receives (record), returns the `<tr>` id. Defaults to `dom_id(record)` for records that respond to `to_key` (ActiveRecord). Return `nil` to omit the id.
213
214
 
215
+ The rendered table is wrapped in an `l-ui-scroll-hint` element wired to the `l-ui--scroll-hint` controller, which fades the clipped edge when the table overflows horizontally.
216
+
214
217
  Column options:
215
218
  - `attribute` (Symbol) - used for label generation and sort links
216
219
  - `label` (String, optional) - custom header text; defaults to humanised attribute
@@ -325,12 +328,13 @@ Calling `dialog.showModal()` directly is not supported - it bypasses the `l-ui--
325
328
  ## Popover
326
329
 
327
330
  ```ruby
328
- 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)
329
332
  ```
330
333
 
331
334
  - `id` (String, optional) - DOM id for the popover element; defaults to an auto-generated id
332
335
  - `placement` (Symbol, optional) - `:top`, `:bottom` (default), `:left`, or `:right`. Flips automatically if it would overflow the viewport
333
336
  - `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`
334
338
  - `container` (Hash, optional) - extra HTML attributes for the wrapping `<div>` (e.g. `class:`)
335
339
  - `&block` - the block's content is the popover body; call `p.trigger(**options, &block)` inside it to render the trigger button
336
340
 
@@ -385,7 +389,9 @@ Builder methods:
385
389
  - `t.button(**opts, &block)` - button segment (`<button class="l-ui-tag__button">`); opens the tag's popover when one is declared
386
390
  - `t.link(url, **opts, &block)` - link segment, styled like a button segment
387
391
  - `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`
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).
389
395
 
390
396
  ```erb
391
397
  <%= l_ui_tag do |t| %>
data/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
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.22.0] - 2026-07-10
6
+
7
+ ### Added
8
+
9
+ - Floating table actions: pass `floating_actions: true` to `l_ui_table` (or add `l-ui-table--floating-actions` to a hand-written table) to pin the actions column to the right-hand edge of the scroll container, so row actions stay visible while the table scrolls horizontally. The pinned cells sit on a solid background with a faded leading edge, and the container gains scroll padding so focused cells scroll clear of the pinned column.
10
+ - Scroll hint: the new `l-ui--scroll-hint` Stimulus controller and `l-ui-scroll-hint` wrapper fade the clipped edges of a horizontal scroller while more content is available in that direction. While the scroller overflows it is made keyboard-focusable so the region stays keyboard-scrollable. `l_ui_table` wraps every table in a scroll hint automatically; wrap hand-written tables (or any other horizontal scroller) yourself.
11
+ - Popover `open:` option: `l_ui_popover(open: true)` (and `t.popover(open: true)` on a tag) opens the popover as soon as its Stimulus controller connects, shown and positioned in the same task so it never paints unpositioned - useful for re-opening a filter popover after a form submission re-renders the page.
12
+ - `l-ui-tag-row`: the canonical container for a row of tags plus any trailing actions (e.g. a filter bar) - a wrapping flex row with a gap, so tags move onto new lines on narrow screens instead of overflowing.
13
+ - Tag truncation: a tag never grows past its container, and label segments wrap their content in the new `l-ui-tag__label` so long labels truncate with an ellipsis instead of overflowing. The `l_ui_tag` helper renders the wrapper automatically; add it yourself in hand-written markup.
14
+
5
15
  ## [0.21.0] - 2026-07-09
6
16
 
7
17
  ### Added
@@ -1655,7 +1655,16 @@ pre.l-ui-surface {
1655
1655
  /* Tables */
1656
1656
 
1657
1657
  .l-ui-table-container {
1658
- @apply overflow-x-auto;
1658
+ @apply overflow-x-auto
1659
+ focus-ring;
1660
+ scrollbar-width: thin;
1661
+ scrollbar-color: var(--color-border) transparent;
1662
+ }
1663
+
1664
+ /* Scrolls focused cells clear of the pinned actions column (WCAG 2.4.11) */
1665
+
1666
+ .l-ui-table-container:has(.l-ui-table--floating-actions) {
1667
+ @apply scroll-pr-20;
1659
1668
  }
1660
1669
 
1661
1670
  .l-ui-table {
@@ -1739,6 +1748,50 @@ pre.l-ui-surface {
1739
1748
  @apply pr-0;
1740
1749
  }
1741
1750
 
1751
+ /* The 1.5rem fade-in must match the leading padding so the actions always
1752
+ sit on solid background; z-10 lifts the cells above the scroll-hint fade */
1753
+
1754
+ .l-ui-table--floating-actions th.l-ui-table__header-cell--action,
1755
+ .l-ui-table--floating-actions td.l-ui-table__cell--action {
1756
+ @apply sticky right-0 z-10
1757
+ pl-6 pr-2
1758
+ bg-linear-to-r from-background/0 to-background to-[1.5rem];
1759
+ }
1760
+
1761
+ /* Scroll hint */
1762
+
1763
+ /* The --left/--right modifiers are toggled by the l-ui--scroll-hint
1764
+ controller from the scroll position of the wrapped scroller */
1765
+
1766
+ .l-ui-scroll-hint {
1767
+ @apply relative;
1768
+ }
1769
+
1770
+ .l-ui-scroll-hint::before,
1771
+ .l-ui-scroll-hint::after {
1772
+ @apply absolute inset-y-0
1773
+ w-8
1774
+ opacity-0
1775
+ transition-opacity
1776
+ pointer-events-none;
1777
+ content: "";
1778
+ }
1779
+
1780
+ .l-ui-scroll-hint::before {
1781
+ @apply left-0
1782
+ bg-linear-to-r from-background to-background/0;
1783
+ }
1784
+
1785
+ .l-ui-scroll-hint::after {
1786
+ @apply right-0
1787
+ bg-linear-to-l from-background to-background/0;
1788
+ }
1789
+
1790
+ .l-ui-scroll-hint--left::before,
1791
+ .l-ui-scroll-hint--right::after {
1792
+ @apply opacity-100;
1793
+ }
1794
+
1742
1795
  /* Pagination */
1743
1796
 
1744
1797
  @utility pagination-base {
@@ -1863,9 +1916,22 @@ pre.l-ui-surface {
1863
1916
  carries no padding of its own - each segment supplies its share, so every
1864
1917
  part of the tag is a hit target. */
1865
1918
 
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
+
1866
1932
  .l-ui-tag {
1867
1933
  @apply inline-flex items-stretch
1868
- min-h-[32px]
1934
+ max-w-full min-h-[32px]
1869
1935
  text-xs font-medium
1870
1936
  text-foreground-muted
1871
1937
  bg-surface-highlighted
@@ -1879,17 +1945,26 @@ pre.l-ui-surface {
1879
1945
 
1880
1946
  .l-ui-tag__text {
1881
1947
  @apply flex items-center
1948
+ min-w-0
1882
1949
  gap-1 px-3;
1883
1950
  }
1884
1951
 
1885
1952
  .l-ui-tag__button {
1886
1953
  @apply flex items-center
1954
+ min-w-0
1887
1955
  gap-1 px-3
1888
1956
  rounded
1889
1957
  focus-ring
1890
1958
  cursor-pointer;
1891
1959
  }
1892
1960
 
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
+
1893
1968
  /* Segment focus rings follow the tag's shape. */
1894
1969
  .l-ui-tag--rounded .l-ui-tag__button,
1895
1970
  .l-ui-tag--rounded .l-ui-tag__remove {
@@ -22,8 +22,11 @@ module Layered
22
22
  # placement: (Symbol) :top, :bottom (default), :left, or :right. Flips automatically if it would overflow the viewport.
23
23
  # align: (Symbol) :start (default) flushes the popover's leading edge with the trigger's; :end flushes its trailing edge instead
24
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
+ # 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.
25
28
  # 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)
27
30
  id ||= "l-ui-popover-#{SecureRandom.hex(4)}"
28
31
  builder = PopoverBuilder.new(self, id: id)
29
32
  body_content = capture { block.call(builder) }
@@ -34,6 +37,7 @@ module Layered
34
37
  container_data[:controller] = [existing_controller, "l-ui--popover"].compact.reject(&:empty?).join(" ")
35
38
  container_data[:"l-ui--popover-placement-value"] = placement
36
39
  container_data[:"l-ui--popover-align-value"] = align
40
+ container_data[:"l-ui--popover-open-value"] = true if open
37
41
  container_attrs[:data] = container_data
38
42
 
39
43
  tag.div(**container_attrs) do
@@ -31,6 +31,14 @@ module Layered
31
31
  # sortable: (Boolean) Show sort link when query: is provided. Defaults to true.
32
32
  # render: (Proc) Required. Receives (record), returns cell content.
33
33
  #
34
+ # Pass +floating_actions: true+ to pin the actions column to the
35
+ # right-hand edge of the scroll container, so row actions stay visible
36
+ # while the table scrolls horizontally.
37
+ #
38
+ # The table is wrapped in an +l-ui-scroll-hint+ element wired to the
39
+ # +l-ui--scroll-hint+ controller, which fades the clipped edge while
40
+ # more columns are available in that direction.
41
+ #
34
42
  # Pass +query:+ (a Ransack search object) and +turbo_frame:+ to enable
35
43
  # sortable column headers via +l_ui_sort_link+.
36
44
  #
@@ -38,7 +46,7 @@ module Layered
38
46
  # +to_key+ (i.e. ActiveRecord), so individual rows can be targeted by
39
47
  # Turbo Streams. Pass +row_id:+ as a proc to override (return +nil+ to
40
48
  # omit the id).
41
- def l_ui_table(records, columns:, caption: nil, actions: nil, actions_label: "Actions", query: nil, url: nil, turbo_frame: nil, row_id: nil)
49
+ def l_ui_table(records, columns:, caption: nil, actions: nil, actions_label: "Actions", floating_actions: false, query: nil, url: nil, turbo_frame: nil, row_id: nil)
42
50
  columns = normalise_table_columns(columns)
43
51
  col_count = columns.size + (actions ? 1 : 0)
44
52
 
@@ -75,8 +83,11 @@ module Layered
75
83
  end
76
84
 
77
85
  caption_tag = caption ? tag.caption(caption, class: "l-ui-sr-only") : nil
78
- table = tag.table(class: "l-ui-table") { safe_join([caption_tag, thead, tbody].compact) }
79
- tag.div(table, class: "l-ui-table-container")
86
+ table_classes = ["l-ui-table"]
87
+ table_classes << "l-ui-table--floating-actions" if actions && floating_actions
88
+ table = tag.table(class: table_classes.join(" ")) { safe_join([caption_tag, thead, tbody].compact) }
89
+ container = tag.div(table, class: "l-ui-table-container", data: { "l-ui--scroll-hint-target" => "scroller" })
90
+ tag.div(container, class: "l-ui-scroll-hint", data: { controller: "l-ui--scroll-hint" })
80
91
  end
81
92
 
82
93
  private
@@ -42,7 +42,7 @@ module Layered
42
42
  # t.link(url, **options, &block) Link segment, styled like a button segment.
43
43
  # t.remove(url = nil, **options, &block) Trailing remove segment: a link when +url+ is given, otherwise a
44
44
  # 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)
46
46
  # Attaches a popover to the tag; the block is the popover body.
47
47
  # Options match +l_ui_popover+.
48
48
  def l_ui_tag(rounded: false, container: {}, &block)
@@ -59,6 +59,7 @@ module Layered
59
59
  container_data[:"l-ui--popover-target"] = "trigger"
60
60
  container_data[:"l-ui--popover-placement-value"] = builder.popover_placement
61
61
  container_data[:"l-ui--popover-align-value"] = builder.popover_align
62
+ container_data[:"l-ui--popover-open-value"] = true if builder.popover_open
62
63
  container_attrs[:data] = container_data
63
64
  end
64
65
 
@@ -70,7 +71,7 @@ module Layered
70
71
  end
71
72
 
72
73
  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
74
75
 
75
76
  def initialize(view)
76
77
  @view = view
@@ -101,10 +102,11 @@ module Layered
101
102
  nil
102
103
  end
103
104
 
104
- def popover(id: nil, placement: :bottom, align: :start, &block)
105
+ def popover(id: nil, placement: :bottom, align: :start, open: false, &block)
105
106
  @popover_id = id || "l-ui-tag-popover-#{SecureRandom.hex(4)}"
106
107
  @popover_placement = placement
107
108
  @popover_align = align
109
+ @popover_open = open
108
110
  @popover_body = @view.capture(&block)
109
111
  nil
110
112
  end
@@ -122,15 +124,15 @@ module Layered
122
124
  case segment[:kind]
123
125
  when :text
124
126
  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)
126
128
  when :button
127
129
  options[:class] = class_names("l-ui-tag__button", options[:class])
128
130
  options[:type] ||= "button"
129
131
  options[:popovertarget] ||= builder.popover_id if builder.popover?
130
- tag.button(segment[:content], **options)
132
+ tag.button(l_ui_tag_label(segment[:content]), **options)
131
133
  when :link
132
134
  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)
134
136
  when :remove
135
137
  options[:class] = class_names("l-ui-tag__remove", options[:class])
136
138
  content = segment[:content] || l_ui_tag_remove_icon
@@ -143,6 +145,13 @@ module Layered
143
145
  end
144
146
  end
145
147
 
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
+
146
155
  # Filled x-circle, matching the bundled +icon_close_circle.svg+ asset.
147
156
  # Rendered inline (rather than via image_tag) so it inherits the tag's
148
157
  # muted +currentColor+; an <img>-loaded SVG cannot.
@@ -9,7 +9,8 @@ export default class extends Controller {
9
9
  static targets = ["trigger", "popover"]
10
10
  static values = {
11
11
  placement: { type: String, default: "bottom" },
12
- align: { type: String, default: "start" }
12
+ align: { type: String, default: "start" },
13
+ open: { type: Boolean, default: false }
13
14
  }
14
15
 
15
16
  static GAP = 8
@@ -24,6 +25,16 @@ export default class extends Controller {
24
25
  }
25
26
  }
26
27
  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
+ }
27
38
  }
28
39
 
29
40
  popoverTargetDisconnected(element) {
@@ -0,0 +1,46 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Fades the clipped edges of a horizontal scroller while more content is
4
+ // available in that direction, by toggling the l-ui-scroll-hint--left/--right
5
+ // modifiers on its wrapper. Also makes the scroller keyboard-focusable while
6
+ // it overflows, so the region stays keyboard-scrollable.
7
+ export default class extends Controller {
8
+ static targets = ["scroller"]
9
+
10
+ initialize() {
11
+ this.update = this.update.bind(this)
12
+ }
13
+
14
+ connect() {
15
+ this.scrollerTarget.addEventListener("scroll", this.update, { passive: true })
16
+
17
+ this.resizeObserver = new ResizeObserver(this.update)
18
+ this.resizeObserver.observe(this.scrollerTarget)
19
+ if (this.scrollerTarget.firstElementChild) {
20
+ this.resizeObserver.observe(this.scrollerTarget.firstElementChild)
21
+ }
22
+
23
+ this.update()
24
+ }
25
+
26
+ disconnect() {
27
+ this.scrollerTarget.removeEventListener("scroll", this.update)
28
+ this.resizeObserver.disconnect()
29
+ }
30
+
31
+ update() {
32
+ const scroller = this.scrollerTarget
33
+ // 1px tolerance for fractional scroll positions on high-DPI screens
34
+ const overflows = scroller.scrollWidth - scroller.clientWidth > 1
35
+
36
+ // Keep the region keyboard-scrollable while it overflows (WCAG 2.1.1)
37
+ if (overflows) {
38
+ scroller.setAttribute("tabindex", "0")
39
+ } else {
40
+ scroller.removeAttribute("tabindex")
41
+ }
42
+
43
+ this.element.classList.toggle("l-ui-scroll-hint--left", scroller.scrollLeft > 1)
44
+ this.element.classList.toggle("l-ui-scroll-hint--right", overflows && scroller.scrollLeft < scroller.scrollWidth - scroller.clientWidth - 1)
45
+ }
46
+ }
@@ -7,6 +7,7 @@ import PanelResizeController from "layered_ui/controllers/l_ui/panel_resize_cont
7
7
  import PanelButtonController from "layered_ui/controllers/l_ui/panel_button_controller"
8
8
  import ModalController from "layered_ui/controllers/l_ui/modal_controller"
9
9
  import PopoverController from "layered_ui/controllers/l_ui/popover_controller"
10
+ import ScrollHintController from "layered_ui/controllers/l_ui/scroll_hint_controller"
10
11
  import SearchFormController from "layered_ui/controllers/l_ui/search_form_controller"
11
12
  import TabsController from "layered_ui/controllers/l_ui/tabs_controller"
12
13
 
@@ -19,4 +20,5 @@ application.register("l-ui--panel-resize", PanelResizeController)
19
20
  application.register("l-ui--panel-button", PanelButtonController)
20
21
  application.register("l-ui--modal", ModalController)
21
22
  application.register("l-ui--popover", PopoverController)
23
+ application.register("l-ui--scroll-hint", ScrollHintController)
22
24
  application.register("l-ui--tabs", TabsController)
data/config/importmap.rb CHANGED
@@ -14,6 +14,7 @@ pin "layered_ui/controllers/l_ui/panel_controller", to: "layered_ui/controllers/
14
14
  pin "layered_ui/controllers/l_ui/panel_resize_controller", to: "layered_ui/controllers/l_ui/panel_resize_controller.js"
15
15
  pin "layered_ui/controllers/l_ui/panel_button_controller", to: "layered_ui/controllers/l_ui/panel_button_controller.js"
16
16
  pin "layered_ui/controllers/l_ui/popover_controller", to: "layered_ui/controllers/l_ui/popover_controller.js"
17
+ pin "layered_ui/controllers/l_ui/scroll_hint_controller", to: "layered_ui/controllers/l_ui/scroll_hint_controller.js"
17
18
  pin "layered_ui/controllers/l_ui/search_form_controller", to: "layered_ui/controllers/l_ui/search_form_controller.js"
18
19
  pin "layered_ui/controllers/l_ui/tabs_controller", to: "layered_ui/controllers/l_ui/tabs_controller.js"
19
20
  pin "layered_ui/controllers/l_ui/theme_controller", to: "layered_ui/controllers/l_ui/theme_controller.js"
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Ui
3
- VERSION = "0.21.0"
3
+ VERSION = "0.22.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.21.0
4
+ version: 0.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai
@@ -234,6 +234,7 @@ files:
234
234
  - app/javascript/layered_ui/controllers/l_ui/panel_controller.js
235
235
  - app/javascript/layered_ui/controllers/l_ui/panel_resize_controller.js
236
236
  - app/javascript/layered_ui/controllers/l_ui/popover_controller.js
237
+ - app/javascript/layered_ui/controllers/l_ui/scroll_hint_controller.js
237
238
  - app/javascript/layered_ui/controllers/l_ui/search_form_controller.js
238
239
  - app/javascript/layered_ui/controllers/l_ui/tabs_controller.js
239
240
  - app/javascript/layered_ui/controllers/l_ui/theme_controller.js