layered-ui-rails 0.14.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: dd3b300ca92cbb2d2bc81086f5bc641f5124ce17e9ee8bfe509bca379fea9a32
4
- data.tar.gz: 4dd8fadd0855594730d434e48dcd3eee163c0d8c3859212cfcfaa67a3856ccdb
3
+ metadata.gz: e263735e08a190494c81dd8c50552f0f407e80bca6704abea9dca2f3dc78ce37
4
+ data.tar.gz: 611458285efd46bc8c6b64f5766b65626152ed0d10d8d1cfef3eac6226f6f3b4
5
5
  SHA512:
6
- metadata.gz: 4f55763d1411e9d97fba34b3d7cebf22642c7aed74679e9f4218bc6389145acf3f2a5503cabfa9d0157ddc7873f9b337be0d7821349e33c2283b710568a9b773
7
- data.tar.gz: 0fd9a835309c100a8a15968a55843fd7d857fc941a0e77686881fa022d244add42addd67c3c76931fea6dd2c7565dc69df3cee45ee12a11cc78215cb57bdf85a
6
+ metadata.gz: 31aeaab41317883a2a027520b3aa656c5bc987825f51a7d79000d2746c7088046fe691a53827ad43aa2f0360558dd7208956bf5f18aad61536defea559f197cb
7
+ data.tar.gz: 128ac9723c177fbbcd4ff5a03f677e3c24fe924209b8a04fa985340b3b561bf07e3616e899cae352a1f02085c1e7f63a0c6aeb28137f453565017a0001e2a717
@@ -37,7 +37,7 @@ Then render the engine layout from your application layout. Place all `content_f
37
37
 
38
38
  ## Layout structure
39
39
 
40
- The engine layout provides a fixed header (63px), optional sidebar navigation (240px wide), optional resizable panel (320px default), and a main content area. Dark mode is built in with a toggle and localStorage persistence.
40
+ The engine layout provides a fixed header (63px), optional sidebar navigation (256px wide), optional resizable panel (320px default), and a main content area. Dark mode is built in with a toggle and localStorage persistence.
41
41
 
42
42
  ### Content blocks
43
43
 
@@ -140,7 +140,7 @@ Drag handle for resizing the panel width on desktop.
140
140
  **Keyboard:** Arrow Left/Right (10px), Shift+Arrow (50px), Home/End
141
141
  **Storage key:** `panelWidth` (pixel value)
142
142
 
143
- - Min width: 240px
143
+ - Min width: 256px
144
144
  - Default width: 480px
145
145
  - Double-click handle to reset to default
146
146
 
@@ -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
@@ -271,7 +282,6 @@ Always combine the base block with a modifier, e.g. `<span class="l-ui-badge l-u
271
282
  .l-ui-panel__button--dragging During drag
272
283
  .l-ui-panel__button--snapping Snapping to edge
273
284
  .l-ui-panel__button--hidden Hidden when panel is open (toggled by Stimulus)
274
- .l-ui-panel__close-button Panel close button (combine with l-ui-button l-ui-button--icon)
275
285
  .l-ui-panel__icon Panel button inline SVG icon
276
286
  .l-ui-panel__icon--light Panel button icon (light, for custom image override)
277
287
  .l-ui-panel__icon--dark Panel button icon (dark, for custom image override)
@@ -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/AGENTS.md CHANGED
@@ -6,7 +6,7 @@ Guidance for AI agents working in this repository.
6
6
 
7
7
  - **Entry:** `require "layered-ui-rails"` → `lib/layered/ui.rb` → `lib/layered/ui/engine.rb`
8
8
  - **Engine:** importmap, assets, Pagy helpers when present; helpers: `AuthenticationHelper`, `NavigationHelper`, `PagyHelper`
9
- - **CSS** `app/assets/tailwind/layered/ui/styles.css`: OKLCH tokens, `.dark` on `<html>`, `@theme` utilities (`bg-background`, etc.), BEM components (`.l-ui-button--primary`, etc.). Layout: 63px header, 240px sidebar, 320px panel. WCAG 2.2 AA.
9
+ - **CSS** `app/assets/tailwind/layered/ui/styles.css`: OKLCH tokens, `.dark` on `<html>`, `@theme` utilities (`bg-background`, etc.), BEM components (`.l-ui-button--primary`, etc.). Layout: 63px header, 256px sidebar, 320px panel. WCAG 2.2 AA.
10
10
  - **CSS `@apply`:** Multi-line with grouping, following the Prettier Tailwind plugin order: layout → sizing → spacing → typography → backgrounds → borders → effects → transitions → interactivity. Within each group, follow Tailwind's own ordering (not alphabetical). State variants (`hover:`, `focus:`, `active:`, `disabled:`) and responsive prefixes (`sm:`, `md:`, `lg:`) are grouped with their base utility. Single utilities may stay on one line.
11
11
  - **Generators:** `bin/rails generate layered:ui:install` (copy CSS, import CSS, import JS)
12
12
  - **JS** `app/javascript/layered_ui/`: Stimulus controllers registered as `l-ui--theme`, `l-ui--navigation`, `l-ui--panel`, `l-ui--modal`, `l-ui--tabs`
data/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
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
+
22
+ ## [0.15.0] - 2026-05-11
23
+
24
+ ### Breaking
25
+
26
+ - `l-ui-panel__close-button` modifier removed; the panel close button is now an `l-ui-button--outline l-ui-button--icon`
27
+ - Navigation width increased from 240px to 256px (affects `NAV_WIDTH` consumers and the panel resize minimum width)
28
+
29
+ ### Added
30
+
31
+ - `icon_panel_close.svg` asset for the panel close button
32
+
33
+ ### Changed
34
+
35
+ - Borders removed from navigation container (right and top), navigation user block, panel container (left edge), and panel header
36
+ - `h2` elements now have a bottom border for stronger visual separation
37
+ - Table cell padding evened out to `p-2`
38
+ - Panel header padding adjusted (`p-4 pb-0`) and header button gap increased to `gap-4`
39
+ - Modal backdrop click detection now uses the dialog's bounding rect, so clicks on the dialog's own padding no longer close it
40
+ - Navigation item corners rounded on the left only (`rounded-l-sm`)
41
+
5
42
  ## [0.14.0] - 2026-05-10
6
43
 
7
44
  ### Breaking
@@ -0,0 +1,4 @@
1
+ <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
2
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 8l4 4-4 4" />
3
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19 4v16" />
4
+ </svg>
@@ -118,7 +118,8 @@
118
118
  }
119
119
 
120
120
  h2 {
121
- @apply heading text-lg;
121
+ @apply heading text-lg
122
+ border-b border-border pb-2;
122
123
  }
123
124
 
124
125
  h3 {
@@ -360,7 +361,7 @@
360
361
  @apply overflow-x-auto
361
362
  my-3 p-3
362
363
  bg-surface
363
- rounded-md;
364
+ rounded-sm;
364
365
  }
365
366
 
366
367
  .l-ui-markdown pre code {
@@ -456,7 +457,7 @@
456
457
  }
457
458
 
458
459
  .l-ui-body--always-show-navigation .l-ui-page--with-navigation {
459
- @apply md:ml-[240px];
460
+ @apply md:ml-[256px];
460
461
  }
461
462
 
462
463
  .l-ui-page__vertically-centered {
@@ -488,11 +489,29 @@
488
489
  px-4 py-3;
489
490
  }
490
491
 
492
+ .l-ui-body--header-contained .l-ui-header {
493
+ @apply w-full max-w-7xl
494
+ mx-auto;
495
+ }
496
+
491
497
  .l-ui-header__navigation {
492
498
  @apply flex items-center
493
499
  gap-4;
494
500
  }
495
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
+
496
515
  .l-ui-header__icon {
497
516
  @apply h-5.5 w-auto;
498
517
  }
@@ -562,6 +581,10 @@
562
581
  @apply dark:invert;
563
582
  }
564
583
 
584
+ .l-ui-button--primary .l-ui-icon {
585
+ @apply invert dark:invert-0;
586
+ }
587
+
565
588
  .l-ui-icon--xs {
566
589
  @apply w-4 h-4;
567
590
  }
@@ -585,11 +608,11 @@
585
608
  /* Navigation */
586
609
 
587
610
  .l-ui-navigation-container {
588
- @apply fixed top-[var(--header-height)] left-0 bottom-0
611
+ @apply fixed top-[calc(var(--header-height)+1px)] left-0 bottom-0
589
612
  z-50
590
- w-[240px]
613
+ w-[256px]
591
614
  bg-background
592
- border-t border-border
615
+ md:border-r md:border-border
593
616
  -translate-x-full
594
617
  transition-transform duration-300;
595
618
  }
@@ -726,13 +749,12 @@
726
749
  .l-ui-navigation__links {
727
750
  @apply flex flex-col flex-1 overflow-y-auto
728
751
  min-h-0
729
- gap-0.5 px-3 md:pr-0 py-3;
752
+ gap-0.5 px-3 py-3;
730
753
  }
731
754
 
732
755
  .l-ui-navigation__user {
733
756
  @apply shrink-0
734
- p-4
735
- border-t border-border;
757
+ p-4;
736
758
  }
737
759
 
738
760
  .l-ui-navigation__user-name-and-email {
@@ -1268,7 +1290,7 @@ pre.l-ui-surface {
1268
1290
  }
1269
1291
 
1270
1292
  .l-ui-table__cell {
1271
- @apply px-2 py-2.5
1293
+ @apply p-2
1272
1294
  text-sm whitespace-nowrap text-foreground-muted;
1273
1295
  }
1274
1296
 
@@ -1486,7 +1508,7 @@ pre.l-ui-surface {
1486
1508
  z-40
1487
1509
  w-full md:w-[480px]
1488
1510
  bg-background
1489
- md:border-l md:border-border
1511
+ border-l border-border
1490
1512
  transform translate-x-full
1491
1513
  transition-transform duration-300;
1492
1514
  }
@@ -1529,13 +1551,13 @@ pre.l-ui-surface {
1529
1551
 
1530
1552
  .l-ui-panel__header-content {
1531
1553
  @apply flex items-center
1532
- px-4 py-2;
1554
+ p-4 py-3;
1533
1555
  }
1534
1556
 
1535
1557
  .l-ui-panel__header-buttons {
1536
1558
  @apply flex items-center
1537
1559
  w-full
1538
- gap-2;
1560
+ gap-4;
1539
1561
  }
1540
1562
 
1541
1563
  .l-ui-panel__header-heading {
@@ -1551,10 +1573,6 @@ pre.l-ui-surface {
1551
1573
  gap-2;
1552
1574
  }
1553
1575
 
1554
- .l-ui-panel__close-button {
1555
- @apply -mr-4;
1556
- }
1557
-
1558
1576
  .l-ui-panel__body {
1559
1577
  @apply flex-1 overflow-y-auto overscroll-contain
1560
1578
  p-4 space-y-4;
@@ -1649,7 +1667,7 @@ pre.l-ui-surface {
1649
1667
  p-4 gap-4
1650
1668
  text-foreground
1651
1669
  bg-surface
1652
- rounded-md;
1670
+ rounded-sm;
1653
1671
  }
1654
1672
 
1655
1673
  .l-ui-message--sent .l-ui-message__bubble {
@@ -1794,8 +1812,17 @@ pre.l-ui-surface {
1794
1812
  border-0 md:border md:border-border rounded-none md:rounded-sm;
1795
1813
  }
1796
1814
 
1815
+ .l-ui-modal[open] {
1816
+ @apply flex flex-col;
1817
+ }
1818
+
1797
1819
  .l-ui-modal::backdrop {
1798
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);
1799
1826
  }
1800
1827
 
1801
1828
  .l-ui-modal__header {
@@ -1805,7 +1832,7 @@ pre.l-ui-surface {
1805
1832
  }
1806
1833
 
1807
1834
  .l-ui-modal__body {
1808
- @apply overflow-y-auto
1835
+ @apply flex-1 min-h-0 overflow-y-auto
1809
1836
  px-5 py-4;
1810
1837
  }
1811
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
@@ -39,14 +39,26 @@ export default class extends Controller {
39
39
  this.dialogTarget.close()
40
40
  }
41
41
 
42
- // Close the modal when clicking outside the dialog
42
+ // Close the modal when clicking outside the dialog rect (on the backdrop).
43
+ // Comparing against event.target alone is unreliable: clicks on the dialog's
44
+ // own padding/empty space also have target === dialog, which would close it.
43
45
  closeOnBackdrop(event) {
44
- if (event.target === this.dialogTarget) {
46
+ if (event.target !== this.dialogTarget) return
47
+
48
+ const rect = this.dialogTarget.getBoundingClientRect()
49
+ const insideDialog =
50
+ event.clientX >= rect.left &&
51
+ event.clientX <= rect.right &&
52
+ event.clientY >= rect.top &&
53
+ event.clientY <= rect.bottom
54
+
55
+ if (!insideDialog) {
45
56
  this.close()
46
57
  }
47
58
  }
48
59
 
49
- // 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>").
50
62
  dialogTargetConnected(element) {
51
63
  this._closeHandler = () => {
52
64
  this.constructor.openCount = Math.max(0, this.constructor.openCount - 1)
@@ -60,11 +72,21 @@ export default class extends Controller {
60
72
  announce("Dialog closed", this)
61
73
  }
62
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)
63
84
  }
64
85
 
65
- // Remove the close event listener on the dialog
86
+ // Remove listeners
66
87
  dialogTargetDisconnected(element) {
67
88
  element.removeEventListener("close", this._closeHandler)
89
+ document.removeEventListener("click", this._externalOpenHandler)
68
90
  }
69
91
 
70
92
  disconnect() {
@@ -2,7 +2,7 @@ import { Controller } from "@hotwired/stimulus"
2
2
  import { storageGet, storageSet, storageRemove } from "layered_ui/utilities/storage"
3
3
  import { isMobile, hasNavigation, NAV_WIDTH } from "layered_ui/utilities/layout"
4
4
 
5
- const MIN_WIDTH = 240
5
+ const MIN_WIDTH = 256
6
6
  const DEFAULT_WIDTH = 480
7
7
 
8
8
  export default class extends Controller {
@@ -1,5 +1,5 @@
1
1
  export const MOBILE_BREAKPOINT = 768
2
- export const NAV_WIDTH = 240
2
+ export const NAV_WIDTH = 256
3
3
 
4
4
  export function isMobile() {
5
5
  return window.innerWidth < MOBILE_BREAKPOINT
@@ -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"
@@ -17,7 +17,7 @@
17
17
  aria-label="Resize panel"
18
18
  aria-orientation="vertical"
19
19
  aria-valuenow="480"
20
- aria-valuemin="240"
20
+ aria-valuemin="256"
21
21
  aria-valuemax="1200"
22
22
  aria-valuetext="Panel width: 480 pixels"
23
23
  tabindex="0"
@@ -35,13 +35,13 @@
35
35
 
36
36
  <div class="l-ui-panel__header-actions">
37
37
  <button type="button"
38
- class="l-ui-button l-ui-button--icon l-ui-panel__close-button"
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)"
42
42
  data-action="click->l-ui--panel#toggle"
43
43
  data-l-ui--panel-target="hideButton">
44
- <%= image_tag "layered_ui/icon_chevron_right.svg", alt: "", class: "l-ui-icon l-ui-icon--sm", aria: { hidden: true } %>
44
+ <%= image_tag "layered_ui/icon_panel_close.svg", alt: "", class: "l-ui-icon l-ui-icon--sm", aria: { hidden: true } %>
45
45
  </button>
46
46
  </div>
47
47
  </div>
@@ -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.14.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.14.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai
@@ -201,6 +201,7 @@ files:
201
201
  - app/assets/images/layered_ui/icon_linkedin.svg
202
202
  - app/assets/images/layered_ui/icon_mail.svg
203
203
  - app/assets/images/layered_ui/icon_moon.svg
204
+ - app/assets/images/layered_ui/icon_panel_close.svg
204
205
  - app/assets/images/layered_ui/icon_sun.svg
205
206
  - app/assets/images/layered_ui/icon_x.svg
206
207
  - app/assets/images/layered_ui/icon_youtube.svg
@@ -212,6 +213,7 @@ files:
212
213
  - app/helpers/layered/ui/authentication_helper.rb
213
214
  - app/helpers/layered/ui/breadcrumbs_helper.rb
214
215
  - app/helpers/layered/ui/form_helper.rb
216
+ - app/helpers/layered/ui/modal_helper.rb
215
217
  - app/helpers/layered/ui/navigation_helper.rb
216
218
  - app/helpers/layered/ui/pagy_helper.rb
217
219
  - app/helpers/layered/ui/ransack_helper.rb