layered-ui-rails 0.15.0 → 0.16.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: 21924f2cc31a2154c80002c22b304b3061eee7af0c6a6b80221932c62f17c0cd
4
- data.tar.gz: 74c95e1eae950256dd07ab02a426ba77c3e213087709f92e9256e94b52eba987
3
+ metadata.gz: e263735e08a190494c81dd8c50552f0f407e80bca6704abea9dca2f3dc78ce37
4
+ data.tar.gz: 611458285efd46bc8c6b64f5766b65626152ed0d10d8d1cfef3eac6226f6f3b4
5
5
  SHA512:
6
- metadata.gz: 23b89112299368d834cc3c6d6a2599c529dd9d6152a4cdb91e68822d7c3042d896011f2fa7bef9635caa082ce233302f74f431a65179e97245d432c0fe73e834
7
- data.tar.gz: 2e4973efa5a08199438d2d420cfbe42c253c6d4aaedfdd61962ff3ccf34d93c40dd227b6e5d191c506d2cbf5786214883280a1c78ab21df4a90697d6f19cc373
6
+ metadata.gz: 31aeaab41317883a2a027520b3aa656c5bc987825f51a7d79000d2746c7088046fe691a53827ad43aa2f0360558dd7208956bf5f18aad61536defea559f197cb
7
+ data.tar.gz: 128ac9723c177fbbcd4ff5a03f677e3c24fe924209b8a04fa985340b3b561bf07e3616e899cae352a1f02085c1e7f63a0c6aeb28137f453565017a0001e2a717
@@ -17,6 +17,16 @@ Two patterns deviate from strict BEM by design - leave them in place rather than
17
17
 
18
18
  This gives author-written links inside long-form content (Markdown, prose, ad-hoc views) consistent underline/colour treatment without forcing an explicit class on every link. Engine elements opt out automatically because their class names contain `l-ui-`. Side effect: any host-app class containing the substring `l-ui-` will also opt an `<a>` out of bare-link styling.
19
19
 
20
+ ## Body modifiers
21
+
22
+ Applied to `<body>` via the `:l_ui_body_class` yield to toggle layout-level behaviour:
23
+
24
+ ```
25
+ .l-ui-body--always-show-navigation Pin sidebar navigation open on desktop
26
+ .l-ui-body--hide-header Hide the header and collapse its reserved space
27
+ .l-ui-body--header-contained Constrain the header's inner row to max-w-7xl (landing pages)
28
+ ```
29
+
20
30
  ## Page layout
21
31
 
22
32
  ```
@@ -249,6 +259,7 @@ Always combine the base block with a modifier, e.g. `<span class="l-ui-badge l-u
249
259
  ```
250
260
  .l-ui-header-container Fixed header container
251
261
  .l-ui-header Header flexbox
262
+ .l-ui-header__links Inline link list for landing-page headers (yielded via :l_ui_header_links)
252
263
  .l-ui-header__icon Header icon (responsive)
253
264
  .l-ui-header__icon--light Light theme icon
254
265
  .l-ui-header__icon--dark Dark theme icon
@@ -208,13 +208,14 @@ Formats a date/time value as `"%-d %b %Y, %H:%M"` (e.g. "15 Apr 2026, 10:30"). R
208
208
  ## Form
209
209
 
210
210
  ```ruby
211
- l_ui_form(record, fields:, url:, method: nil)
211
+ l_ui_form(record, fields:, url:, method: nil, submit: nil)
212
212
  ```
213
213
 
214
214
  - `record` (ActiveRecord) - the model instance
215
215
  - `fields` (Array<Hash>) - field definitions (see below)
216
216
  - `url` (String) - form action URL
217
217
  - `method` (Symbol, optional) - HTTP method override
218
+ - `submit` (String, optional) - submit button text; defaults to "Create" for new records and "Save" for persisted records
218
219
 
219
220
  Renders a complete form with all fields, error summary, and submit button via the `layered/ui/managed_resource/form` partial.
220
221
 
@@ -246,6 +247,46 @@ l_ui_field_error_id(record, attribute) # Error element ID for aria-describedby
246
247
  l_ui_field_hint_id(record, attribute) # Hint element ID for aria-describedby
247
248
  ```
248
249
 
250
+ ## Modal
251
+
252
+ ```ruby
253
+ l_ui_modal(title:, id: nil, heading_level: :h3, container: {}, &block)
254
+ ```
255
+
256
+ - `title` (String) - heading shown in the modal header and used for `aria-labelledby`
257
+ - `id` (String, optional) - DOM id for the `<dialog>`; defaults to an auto-generated id
258
+ - `heading_level` (Symbol, optional) - heading tag for the title (e.g. `:h2`, `:h3`). Defaults to `:h3`
259
+ - `container` (Hash, optional) - extra HTML attributes for the wrapping `<div>` (e.g. `class:`)
260
+ - `&block` - the block's content is the modal body; call `m.trigger(**options, &block)` inside it to render a colocated trigger button
261
+
262
+ ```erb
263
+ <%= l_ui_modal(title: "Socials") do |m| %>
264
+ <% m.trigger(class: "l-ui-button l-ui-button--outline") do %>
265
+ Open socials
266
+ <% end %>
267
+ <p>Body content.</p>
268
+ <% end %>
269
+ ```
270
+
271
+ Renders the trigger `<button>` (if `m.trigger` is called), the `<dialog class="l-ui-modal">` with a header (title + close button), and wires the `l-ui--modal` Stimulus controller, including backdrop-click close.
272
+
273
+ If the trigger contains only an icon (no visible text), pass `aria-label:` to `m.trigger` so the button has an accessible name.
274
+
275
+ `m.trigger` renders a colocated trigger button. To open the same modal from elsewhere on the page (or to have multiple triggers), give the modal a known `id:` and add `data-l-ui-modal-open="<that id>"` to any button:
276
+
277
+ ```erb
278
+ <%= l_ui_modal(title: "Confirm", id: "confirm-modal") do |m| %>
279
+ <% m.trigger(class: "l-ui-button") do %>Open<% end %>
280
+ <p>Body content.</p>
281
+ <% end %>
282
+
283
+ <button type="button" data-l-ui-modal-open="confirm-modal">Open from elsewhere</button>
284
+ ```
285
+
286
+ The button does not need to be inside the helper's wrapper, and no `data-controller` or `data-action` is required - the `l-ui--modal` controller listens at the document level for clicks on `[data-l-ui-modal-open]` matching its dialog id.
287
+
288
+ 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.
289
+
249
290
  ## Authentication
250
291
 
251
292
  ```ruby
data/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
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.16.0] - 2026-05-16
6
+
7
+ ### Added
8
+
9
+ - `l_ui_modal` helper with external-trigger support for declaratively rendering modal dialogs
10
+ - `l-ui-header--contained` modifier and a yield slot for header links
11
+ - `submit:` option on `l_ui_form` to override the submit button text (defaults to "Create" for new records, "Save" for persisted)
12
+
13
+ ### Changed
14
+
15
+ - Header links now left-aligned
16
+ - Navigation container right border restored at `md` and above; panel container left border restored
17
+ - Modal backdrop now tinted in addition to the existing blur (foreground at 25% in light mode, background at 60% in dark mode)
18
+ - Active navigation items now round on all four corners (`rounded-sm`) rather than the left only
19
+ - Panel close button now uses `l-ui-button--primary` instead of `l-ui-button--outline`
20
+ - Icons inside `l-ui-button--primary` now invert in light mode and stay un-inverted in dark mode, so they always contrast against the accent-coloured button background
21
+
5
22
  ## [0.15.0] - 2026-05-11
6
23
 
7
24
  ### Breaking
@@ -361,7 +361,7 @@
361
361
  @apply overflow-x-auto
362
362
  my-3 p-3
363
363
  bg-surface
364
- rounded-md;
364
+ rounded-sm;
365
365
  }
366
366
 
367
367
  .l-ui-markdown pre code {
@@ -489,11 +489,29 @@
489
489
  px-4 py-3;
490
490
  }
491
491
 
492
+ .l-ui-body--header-contained .l-ui-header {
493
+ @apply w-full max-w-7xl
494
+ mx-auto;
495
+ }
496
+
492
497
  .l-ui-header__navigation {
493
498
  @apply flex items-center
494
499
  gap-4;
495
500
  }
496
501
 
502
+ .l-ui-header__links {
503
+ @apply hidden sm:flex items-center
504
+ mr-auto ml-8
505
+ gap-8
506
+ text-sm font-medium;
507
+ }
508
+
509
+ .l-ui-header__links a {
510
+ @apply text-foreground-muted
511
+ rounded-sm
512
+ focus-ring;
513
+ }
514
+
497
515
  .l-ui-header__icon {
498
516
  @apply h-5.5 w-auto;
499
517
  }
@@ -563,6 +581,10 @@
563
581
  @apply dark:invert;
564
582
  }
565
583
 
584
+ .l-ui-button--primary .l-ui-icon {
585
+ @apply invert dark:invert-0;
586
+ }
587
+
566
588
  .l-ui-icon--xs {
567
589
  @apply w-4 h-4;
568
590
  }
@@ -590,6 +612,7 @@
590
612
  z-50
591
613
  w-[256px]
592
614
  bg-background
615
+ md:border-r md:border-border
593
616
  -translate-x-full
594
617
  transition-transform duration-300;
595
618
  }
@@ -632,7 +655,7 @@
632
655
  w-full min-h-[40px]
633
656
  gap-3 px-3
634
657
  text-sm font-medium text-foreground-muted
635
- rounded-l-sm
658
+ rounded-sm
636
659
  focus-ring
637
660
  transition-colors;
638
661
  }
@@ -1485,6 +1508,7 @@ pre.l-ui-surface {
1485
1508
  z-40
1486
1509
  w-full md:w-[480px]
1487
1510
  bg-background
1511
+ border-l border-border
1488
1512
  transform translate-x-full
1489
1513
  transition-transform duration-300;
1490
1514
  }
@@ -1521,12 +1545,13 @@ pre.l-ui-surface {
1521
1545
  }
1522
1546
 
1523
1547
  .l-ui-panel__header {
1524
- @apply flex flex-col;
1548
+ @apply flex flex-col
1549
+ border-b border-border;
1525
1550
  }
1526
1551
 
1527
1552
  .l-ui-panel__header-content {
1528
1553
  @apply flex items-center
1529
- p-4 pb-0;
1554
+ p-4 py-3;
1530
1555
  }
1531
1556
 
1532
1557
  .l-ui-panel__header-buttons {
@@ -1642,7 +1667,7 @@ pre.l-ui-surface {
1642
1667
  p-4 gap-4
1643
1668
  text-foreground
1644
1669
  bg-surface
1645
- rounded-md;
1670
+ rounded-sm;
1646
1671
  }
1647
1672
 
1648
1673
  .l-ui-message--sent .l-ui-message__bubble {
@@ -1787,8 +1812,17 @@ pre.l-ui-surface {
1787
1812
  border-0 md:border md:border-border rounded-none md:rounded-sm;
1788
1813
  }
1789
1814
 
1815
+ .l-ui-modal[open] {
1816
+ @apply flex flex-col;
1817
+ }
1818
+
1790
1819
  .l-ui-modal::backdrop {
1791
1820
  @apply backdrop-blur-sm;
1821
+ background-color: color-mix(in oklch, var(--foreground) 25%, transparent);
1822
+ }
1823
+
1824
+ .dark .l-ui-modal::backdrop {
1825
+ background-color: color-mix(in oklch, var(--background) 60%, transparent);
1792
1826
  }
1793
1827
 
1794
1828
  .l-ui-modal__header {
@@ -1798,7 +1832,7 @@ pre.l-ui-surface {
1798
1832
  }
1799
1833
 
1800
1834
  .l-ui-modal__body {
1801
- @apply overflow-y-auto
1835
+ @apply flex-1 min-h-0 overflow-y-auto
1802
1836
  px-5 py-4;
1803
1837
  }
1804
1838
 
@@ -10,9 +10,9 @@ module Layered
10
10
  # fields: Post.l_managed_resource_fields,
11
11
  # url: managed_posts_path)
12
12
  #
13
- def l_ui_form(record, fields:, url:, method: nil)
13
+ def l_ui_form(record, fields:, url:, method: nil, submit: nil)
14
14
  render partial: "layered/ui/managed_resource/form",
15
- locals: { record: record, fields: fields, url: url, method: method }
15
+ locals: { record: record, fields: fields, url: url, method: method, submit: submit }
16
16
  end
17
17
 
18
18
  # Normalises a raw field config hash into a canonical form.
@@ -0,0 +1,112 @@
1
+ module Layered
2
+ module Ui
3
+ module ModalHelper
4
+ # Renders a modal as a <dialog> wired up to the +l-ui--modal+ Stimulus
5
+ # controller. The block's content is the modal body; call +m.trigger+ to
6
+ # render a colocated trigger button.
7
+ #
8
+ # <%= l_ui_modal(title: "Socials") do |m| %>
9
+ # <% m.trigger(class: "l-ui-button l-ui-button--outline") do %>
10
+ # Open socials
11
+ # <% end %>
12
+ # <p>Body content.</p>
13
+ # <% end %>
14
+ #
15
+ # To open the modal from elsewhere on the page (or to have multiple
16
+ # triggers), give it a known +id:+ and add +data-l-ui-modal-open="<id>"+
17
+ # to any button. The button does not need to live inside the helper's
18
+ # wrapper; the +l-ui--modal+ controller listens at the document level
19
+ # for matching clicks.
20
+ #
21
+ # <button type="button" data-l-ui-modal-open="confirm-modal">Open</button>
22
+ #
23
+ # Calling +dialog.showModal()+ directly is not supported: it bypasses the
24
+ # +l-ui--modal+ controller and skips scroll lock, focus restoration, open-
25
+ # count tracking, and the screen-reader announcement.
26
+ #
27
+ # Note: use +<% m.trigger %>+ (without the equals sign) so its content is
28
+ # captured by the builder rather than written to the body buffer.
29
+ #
30
+ # Options:
31
+ # title: (String) Required. Modal heading; also used for aria-labelledby.
32
+ # id: (String) DOM id for the <dialog>; defaults to an auto-generated id.
33
+ # heading_level: (Symbol) Heading tag for the title (e.g. :h2, :h3). Defaults to :h3.
34
+ # container: (Hash) Extra HTML attributes for the wrapping <div>.
35
+ def l_ui_modal(title:, id: nil, heading_level: :h3, container: {}, &block)
36
+ id ||= "l-ui-modal-#{SecureRandom.hex(4)}"
37
+ builder = ModalBuilder.new(self, title: title, id: id, heading_level: heading_level)
38
+ body_content = capture { block.call(builder) }
39
+
40
+ container_attrs = container.deep_dup
41
+ container_data = container_attrs[:data] || {}
42
+ existing_controller = container_data.delete(:controller) || container_data.delete("controller")
43
+ container_data[:controller] = [existing_controller, "l-ui--modal"].compact.reject(&:empty?).join(" ")
44
+ container_attrs[:data] = container_data
45
+
46
+ tag.div(**container_attrs) do
47
+ safe_join([
48
+ builder.trigger_html || ActiveSupport::SafeBuffer.new,
49
+ render_modal_dialog(builder, body_content)
50
+ ])
51
+ end
52
+ end
53
+
54
+ class ModalBuilder
55
+ attr_reader :title, :id, :heading_level, :trigger_html
56
+
57
+ def initialize(view, title:, id:, heading_level: :h3)
58
+ @view = view
59
+ @title = title
60
+ @id = id
61
+ @heading_level = heading_level
62
+ end
63
+
64
+ def trigger(**options, &block)
65
+ options = options.deep_dup
66
+ options[:type] ||= "button"
67
+ data = options[:data] || {}
68
+ existing_action = data.delete(:action) || data.delete("action")
69
+ data[:action] = [existing_action, "l-ui--modal#open"].compact.reject(&:empty?).join(" ")
70
+ options[:data] = data
71
+ content = @view.capture(&block)
72
+ @trigger_html = @view.tag.button(content, **options)
73
+ nil
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def render_modal_dialog(builder, body_content)
80
+ heading_id = "#{builder.id}-heading"
81
+
82
+ dialog_attrs = {
83
+ id: builder.id,
84
+ class: "l-ui-modal",
85
+ data: {
86
+ "l-ui--modal-target" => "dialog",
87
+ "action" => "click->l-ui--modal#closeOnBackdrop"
88
+ },
89
+ aria: { labelledby: heading_id }
90
+ }
91
+
92
+ tag.dialog(**dialog_attrs) do
93
+ safe_join([
94
+ tag.div(class: "l-ui-modal__header") do
95
+ safe_join([
96
+ content_tag(builder.heading_level, builder.title, id: heading_id),
97
+ tag.button(
98
+ image_tag("layered_ui/icon_close.svg", alt: "", class: "l-ui-icon l-ui-icon--sm", aria: { hidden: true }),
99
+ class: "l-ui-button l-ui-button--icon",
100
+ type: "button",
101
+ data: { action: "l-ui--modal#close" },
102
+ "aria-label" => "Close"
103
+ )
104
+ ])
105
+ end,
106
+ tag.div(body_content, class: "l-ui-modal__body")
107
+ ])
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -57,7 +57,8 @@ export default class extends Controller {
57
57
  }
58
58
  }
59
59
 
60
- // Handle the close event on the dialog
60
+ // Handle the close event on the dialog and document-level clicks from
61
+ // external triggers (any button with data-l-ui-modal-open="<dialog id>").
61
62
  dialogTargetConnected(element) {
62
63
  this._closeHandler = () => {
63
64
  this.constructor.openCount = Math.max(0, this.constructor.openCount - 1)
@@ -71,11 +72,21 @@ export default class extends Controller {
71
72
  announce("Dialog closed", this)
72
73
  }
73
74
  element.addEventListener("close", this._closeHandler)
75
+
76
+ this._externalOpenHandler = (event) => {
77
+ if (!element.id) return
78
+ const trigger = event.target.closest(`[data-l-ui-modal-open="${CSS.escape(element.id)}"]`)
79
+ if (!trigger) return
80
+ event.preventDefault()
81
+ this.open()
82
+ }
83
+ document.addEventListener("click", this._externalOpenHandler)
74
84
  }
75
85
 
76
- // Remove the close event listener on the dialog
86
+ // Remove listeners
77
87
  dialogTargetDisconnected(element) {
78
88
  element.removeEventListener("close", this._closeHandler)
89
+ document.removeEventListener("click", this._externalOpenHandler)
79
90
  }
80
91
 
81
92
  disconnect() {
@@ -10,7 +10,7 @@
10
10
  <% end %>
11
11
 
12
12
  <div class="l-ui-form__actions">
13
- <%= f.submit(record.new_record? ? "Create" : "Save",
13
+ <%= f.submit(submit || (record.new_record? ? "Create" : "Save"),
14
14
  class: "l-ui-button l-ui-button--primary") %>
15
15
  </div>
16
16
  <% end %>
@@ -7,6 +7,12 @@
7
7
  <%= yield(:l_ui_logo_dark).presence || image_tag("layered_ui/logo_dark.svg", alt: "", class: "l-ui-header__logo l-ui-header__logo--dark") %>
8
8
  <% end %>
9
9
 
10
+ <% if yield(:l_ui_header_links).present? %>
11
+ <nav class="l-ui-header__links" aria-label="Sections">
12
+ <%= yield(:l_ui_header_links) %>
13
+ </nav>
14
+ <% end %>
15
+
10
16
  <nav class="l-ui-header__navigation" aria-label="Header navigation">
11
17
  <button
12
18
  type="button"
@@ -35,7 +35,7 @@
35
35
 
36
36
  <div class="l-ui-panel__header-actions">
37
37
  <button type="button"
38
- class="l-ui-button l-ui-button--outline l-ui-button--icon"
38
+ class="l-ui-button l-ui-button--primary l-ui-button--icon"
39
39
  aria-label="Hide panel"
40
40
  aria-controls="panel"
41
41
  title="Toggle panel (Ctrl+i / ⌘i)"
@@ -33,6 +33,7 @@ module Layered
33
33
  helper Layered::Ui::TableHelper
34
34
  helper Layered::Ui::TitleBarHelper
35
35
  helper Layered::Ui::FormHelper
36
+ helper Layered::Ui::ModalHelper
36
37
  helper Layered::Ui::RansackHelper
37
38
  end
38
39
  end
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Ui
3
- VERSION = "0.15.0"
3
+ VERSION = "0.16.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.15.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai
@@ -213,6 +213,7 @@ files:
213
213
  - app/helpers/layered/ui/authentication_helper.rb
214
214
  - app/helpers/layered/ui/breadcrumbs_helper.rb
215
215
  - app/helpers/layered/ui/form_helper.rb
216
+ - app/helpers/layered/ui/modal_helper.rb
216
217
  - app/helpers/layered/ui/navigation_helper.rb
217
218
  - app/helpers/layered/ui/pagy_helper.rb
218
219
  - app/helpers/layered/ui/ransack_helper.rb