layered-ui-rails 0.4.0 → 0.5.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: '0596cb85b0ecaf7e9efe534a5d7b7ff9e9c3f0848d1faf8972ce7f1e4617fb1b'
4
- data.tar.gz: f03e28644cd462bcf5988d851d4f85ad177761fd37eea52ed404a4046b230404
3
+ metadata.gz: 0f1a66524b7aad7bb6cca544c4936735e286f2a1a2bccf620a47bd08c3c6b3ef
4
+ data.tar.gz: 779bb09a82b63891245d917c25c40f82e2e1c505291c3967daf0f1db4a4cbb94
5
5
  SHA512:
6
- metadata.gz: 72a5b12ad90bd8997f03e7f9624af1ef3cfeeffc25db54a147c435bde91bb6ed49c353bf049210ec122eae10297db94b96d1918c88131acb13fac1786171feca
7
- data.tar.gz: 150ccf137e7e41cb2181444b11c73cb3bb583ae3e1160b3f87cc70ee019acf34c688af70e6a09ad04b5ff756a5e8f7f72fd4e9afedbb408a052ad6b81cde41ca
6
+ metadata.gz: cbac626bf832dc7599b37a4234aac65e1f8cbd1282d1c2bda20e9df3c97319ed468adde98af047affa463895d0bdcf433bd6512af4ceef59c843eba21caf443d
7
+ data.tar.gz: f62ab50c0825ee0b152fd48b1c508750cbe04bff880217aa0a7ffa4663d54db3ab285d08546fcdc52117cac2817c2e66196d3f8b96bfff4a6f584c0671e52508
@@ -102,6 +102,9 @@ Quick reference:
102
102
  | `l_ui_pagy(pagy)` | Styled pagination (requires pagy gem) |
103
103
  | `l_ui_search_form(query, url:, fields:, ...)` | Search form (requires ransack gem) |
104
104
  | `l_ui_sort_link(query, attribute, label = nil, ...)` | Sortable table header (requires ransack gem) |
105
+ | `l_ui_table(records, columns:, caption:, ...)` | Styled accessible data table with optional sort and actions |
106
+ | `l_ui_form(record, fields:, url:, method:)` | Complete form with fields, error summary, and submit |
107
+ | `l_ui_normalise_field(record, config)` | Normalise a raw field config hash into canonical form |
105
108
  | `l_ui_user_signed_in?` | Check if user is authenticated |
106
109
  | `l_ui_current_user` | Current user object |
107
110
 
@@ -13,17 +13,28 @@ All classes use the `l-ui-` prefix with BEM naming. Use these in host app views.
13
13
 
14
14
  ## Buttons
15
15
 
16
+ Standalone variants (use one of these, not combined with each other):
17
+
16
18
  ```
17
- .l-ui-button Base button with padding and focus ring
18
- .l-ui-button--primary Accent-coloured button
19
- .l-ui-button--outline Bordered button
20
- .l-ui-button--outline-danger Red bordered button
21
- .l-ui-button--full Full-width button
22
- .l-ui-button--icon Icon-only button (fixed size, no text)
23
- .l-ui-button--disabled Disabled appearance
19
+ .l-ui-button Plain button with padding and focus ring
20
+ .l-ui-button--primary Accent-coloured solid button
21
+ .l-ui-button--danger Solid red button (for destructive actions)
22
+ .l-ui-button--outline Bordered button
23
+ .l-ui-button--outline-danger Red bordered button (for destructive actions)
24
+ .l-ui-button--icon Icon-only button (fixed size, no text)
24
25
  .l-ui-button--navigation-toggle Mobile navigation toggle
25
- .l-ui-button--panel-close Panel close button
26
+ .l-ui-button--panel-close Panel close button
27
+ ```
28
+
29
+ Modifiers (combine with a standalone variant above):
30
+
26
31
  ```
32
+ .l-ui-button--full Full-width (e.g. l-ui-button--primary l-ui-button--full)
33
+ ```
34
+
35
+ Any button variant is automatically styled as disabled when the `disabled` HTML attribute is present - no extra class needed.
36
+
37
+ For destructive actions use `l-ui-button--danger` (solid) or `l-ui-button--outline-danger` (bordered).
27
38
 
28
39
  ## Surfaces
29
40
 
@@ -47,6 +58,7 @@ All classes use the `l-ui-` prefix with BEM naming. Use these in host app views.
47
58
  .l-ui-form__field Input/textarea styling
48
59
  .l-ui-form__errors Error summary box
49
60
  .l-ui-form__errors-list Bulleted error list
61
+ .l-ui-form__actions Right-aligned action button container (full-width buttons on mobile)
50
62
  .l-ui-form__field-error Individual field error message
51
63
  .l-ui-form__hint Field hint text
52
64
  .l-ui-form__required Required indicator (*)
@@ -120,6 +120,87 @@ Returns a `<th>` element with sort link and ARIA sort attributes.
120
120
  </table>
121
121
  ```
122
122
 
123
+ ## Table
124
+
125
+ ```ruby
126
+ l_ui_table(records, columns:, caption: nil, actions: nil,
127
+ actions_label: "Actions", query: nil, url: nil, turbo_frame: nil)
128
+ ```
129
+
130
+ - `records` (ActiveRecord::Relation or Array) - the collection to render
131
+ - `columns` (Array<Hash>) - column definitions (see below)
132
+ - `caption` (String, optional) - visually hidden table caption for accessibility
133
+ - `actions` (Proc, optional) - receives (record), returns action cell content
134
+ - `actions_label` (String) - header text for the actions column, default "Actions"
135
+ - `query` (Ransack::Search, optional) - enables sortable column headers
136
+ - `url` (String, optional) - sort link URL (passed to `l_ui_sort_link`)
137
+ - `turbo_frame` (String, optional) - turbo frame target for sort links
138
+
139
+ Column options:
140
+ - `attribute` (Symbol) - used for label generation and sort links
141
+ - `label` (String, optional) - custom header text; defaults to humanised attribute
142
+ - `primary` (Boolean, optional) - renders as `<th scope="row">`; defaults to first column
143
+ - `sortable` (Boolean, optional) - show sort link when `query:` is provided; defaults to true
144
+ - `render` (Proc, required) - receives (record), returns cell content
145
+
146
+ ### `l_ui_format_datetime(value)`
147
+
148
+ Formats a date/time value as `"%-d %b %Y, %H:%M"` (e.g. "15 Apr 2026, 10:30"). Returns `nil` for `nil` input. Useful inside `render:` procs for date columns.
149
+
150
+ ```erb
151
+ <%= l_ui_table(@users,
152
+ columns: [
153
+ { attribute: :name, primary: true, render: ->(r) { link_to r.name, user_path(r) } },
154
+ { attribute: :email, render: ->(r) { r.email } },
155
+ { attribute: :created_at, label: "Joined", render: ->(r) { l_ui_format_datetime(r.created_at) } },
156
+ ],
157
+ actions: ->(r) { link_to "Edit", edit_user_path(r) },
158
+ caption: "Users",
159
+ query: @q,
160
+ turbo_frame: "users") %>
161
+ ```
162
+
163
+ ## Form
164
+
165
+ ```ruby
166
+ l_ui_form(record, fields:, url:, method: nil)
167
+ ```
168
+
169
+ - `record` (ActiveRecord) - the model instance
170
+ - `fields` (Array<Hash>) - field definitions (see below)
171
+ - `url` (String) - form action URL
172
+ - `method` (Symbol, optional) - HTTP method override
173
+
174
+ Renders a complete form with all fields, error summary, and submit button via the `layered/ui/managed_resource/form` partial.
175
+
176
+ Field options:
177
+ - `attribute` (Symbol) - model attribute
178
+ - `as` (Symbol, optional) - field type; auto-detected from column type. Supported: `:string`, `:text`, `:email`, `:number`, `:date`, `:datetime`, `:select`, `:checkbox`, `:hidden`
179
+ - `label` (String, optional) - custom label text; defaults to humanised attribute
180
+ - `required` (Boolean, optional) - marks field as required; default false
181
+ - `hint` (String, optional) - help text below the field
182
+ - `collection` (Array, optional) - required for `:select` type; e.g. `[['Label', value], ...]`
183
+ - `placeholder` (String, optional) - input placeholder text
184
+
185
+ ```erb
186
+ <%= l_ui_form(@post,
187
+ fields: [
188
+ { attribute: :title, required: true },
189
+ { attribute: :body, as: :text },
190
+ { attribute: :category, as: :select, collection: Category.pluck(:name, :id) },
191
+ { attribute: :published, as: :checkbox },
192
+ ],
193
+ url: posts_path) %>
194
+ ```
195
+
196
+ ### Form utility helpers
197
+
198
+ ```ruby
199
+ l_ui_normalise_field(record, config) # Normalise a raw field config into canonical form
200
+ l_ui_field_error_id(record, attribute) # Error element ID for aria-describedby
201
+ l_ui_field_hint_id(record, attribute) # Hint element ID for aria-describedby
202
+ ```
203
+
123
204
  ## Authentication
124
205
 
125
206
  ```ruby
data/AGENTS.md CHANGED
@@ -23,7 +23,7 @@ Guidance for AI agents working in this repository.
23
23
  - Use normal dashes '-' not long ones '—'
24
24
  - Locale: Favour en-GB (British English), unless terms are defined by technical standards (e.g. LICENSE, COLOR).
25
25
  - Titles: capitalise first word only (e.g. "This title")
26
- - Document new styles in the dummy app
26
+ - Document new styles in the dummy app. When adding a new CSS class or helper, add a working example to the relevant dummy app documentation page (in `test/dummy/app/views/pages/`) following the existing style (heading, description paragraph, live example, "View code" modal). Update the relevant skill reference file in `.claude/skills/layered-ui-rails/references/` to include the new class or helper.
27
27
  - Use l-ui classes only in engine views, with no additional Tailwind utilities, as Tailwind classes referenced only inside the engine will not be generated by the host app’s build
28
28
  - Dummy app documentation pages may use additional Tailwind utilities, but should favour l-ui classes where possible
29
29
  - Importmap for JS (no bundler)
data/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
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.5.0] - 2026-04-19
6
+
7
+ ### Added
8
+
9
+ - `l-ui-form__actions` CSS class for right-aligned form action buttons that stack full-width on mobile
10
+ - `l_ui_format_datetime` helper for consistent date/time formatting in table cells
11
+
12
+ ### Changed
13
+
14
+ - Collapsible surface padding moved from container to summary/content elements for better open/closed spacing
15
+ - `.l-ui-surface__summary` now prevents text selection with `select-none`
16
+ - Markdown `pre` background changed from `bg-surface-active` to `bg-surface`
17
+ - `l-ui-button--danger` is now a standalone variant (no longer requires a base button class)
18
+ - `l_ui_table` now requires a `render:` proc on every column - the helper no longer auto-extracts values via `public_send`/hash access or auto-formats dates
19
+ - `l_ui_table` no longer renders with an implicit top margin - wrap in a utility class if spacing is needed
20
+ - `l_ui_form` submit button now uses `l-ui-form__actions` wrapper for right-aligned, mobile-responsive layout
21
+ - `l-ui-form` no longer renders with an implicit top margin - wrap in a utility class if spacing is needed
22
+ - Disabled button styling is now automatic via the `disabled` HTML attribute on any button variant - no extra class needed
23
+
24
+ ### Removed
25
+
26
+ - `l-ui-button--disabled` modifier class - use the `disabled` HTML attribute instead
27
+
5
28
  ## [0.4.0] - 2026-04-14
6
29
 
7
30
  ### Added
@@ -352,7 +352,7 @@
352
352
 
353
353
  .l-ui-markdown pre {
354
354
  @apply my-3 p-3
355
- bg-surface-active
355
+ bg-surface
356
356
  rounded-md
357
357
  overflow-x-auto;
358
358
  }
@@ -750,6 +750,12 @@
750
750
  text-foreground;
751
751
  }
752
752
 
753
+ .l-ui-button--danger {
754
+ @apply button
755
+ bg-danger
756
+ text-white;
757
+ }
758
+
753
759
  .l-ui-button--outline-danger {
754
760
  @apply button
755
761
  border border-danger
@@ -780,7 +786,8 @@
780
786
  transition-colors;
781
787
  }
782
788
 
783
- .l-ui-button--disabled {
789
+ [class*="l-ui-button--"]:disabled,
790
+ .l-ui-button:disabled {
784
791
  @apply opacity-50
785
792
  cursor-not-allowed;
786
793
  }
@@ -854,11 +861,13 @@ pre.l-ui-surface {
854
861
 
855
862
  .l-ui-surface--collapsible {
856
863
  @apply surface
864
+ p-0
857
865
  bg-surface;
858
866
  }
859
867
 
860
868
  .l-ui-surface--collapsible-active {
861
869
  @apply surface
870
+ p-0
862
871
  bg-surface-active;
863
872
  }
864
873
 
@@ -867,24 +876,30 @@ pre.l-ui-surface {
867
876
  &[open] > .l-ui-surface__summary .l-ui-surface__chevron {
868
877
  @apply rotate-90;
869
878
  }
879
+
880
+ &.l-ui-surface--sm {
881
+ @apply p-0;
882
+ }
870
883
  }
871
884
 
872
885
  .l-ui-surface--sm {
873
886
  @apply p-3;
874
887
 
875
888
  .l-ui-surface__summary {
876
- @apply text-sm font-semibold;
889
+ @apply p-3
890
+ text-sm font-semibold;
877
891
  }
878
892
 
879
893
  .l-ui-surface__content {
880
- @apply pt-3;
894
+ @apply px-3 pb-3;
881
895
  }
882
896
  }
883
897
 
884
898
  .l-ui-surface__summary {
885
899
  @apply flex items-center justify-between
900
+ p-4
886
901
  font-bold
887
- list-none cursor-pointer focus-ring;
902
+ list-none cursor-pointer select-none focus-ring;
888
903
 
889
904
  &::-webkit-details-marker {
890
905
  display: none;
@@ -896,13 +911,13 @@ pre.l-ui-surface {
896
911
  }
897
912
 
898
913
  .l-ui-surface__content {
899
- @apply pt-4;
914
+ @apply px-4 pb-4;
900
915
  }
901
916
 
902
917
  /* Form */
903
918
 
904
919
  .l-ui-form {
905
- @apply w-full mt-4;
920
+ @apply w-full;
906
921
  }
907
922
 
908
923
  .l-ui-form__errors-list {
@@ -916,6 +931,15 @@ pre.l-ui-surface {
916
931
  @apply mt-4;
917
932
  }
918
933
 
934
+ .l-ui-form__actions {
935
+ @apply flex flex-col gap-2 justify-end
936
+ mt-8;
937
+
938
+ @media (min-width: 768px) {
939
+ @apply flex-row;
940
+ }
941
+ }
942
+
919
943
  .l-ui-form__group--large-gap {
920
944
  @apply mt-8;
921
945
  }
@@ -1,11 +1,19 @@
1
1
  module Layered
2
2
  module Ui
3
3
  module TableHelper
4
+ # Formats a date/time value for display in a table cell.
5
+ #
6
+ # l_ui_format_datetime(record.created_at) # => "15 Apr 2026, 10:30"
7
+ def l_ui_format_datetime(value)
8
+ value&.strftime("%-d %b %Y, %H:%M")
9
+ end
10
+
4
11
  # Renders a styled, accessible data table.
5
12
  #
6
13
  # Use this helper in any view to render a table with the engine's
7
- # +l-ui-table+ styles. It supports custom cell rendering via procs,
8
- # an optional actions column, and Ransack sort links.
14
+ # +l-ui-table+ styles. Every column must supply a +render:+ proc
15
+ # that receives a record and returns cell content - the helper does
16
+ # not extract or format data itself.
9
17
  #
10
18
  # l_ui_table(@personas,
11
19
  # columns: [
@@ -17,15 +25,11 @@ module Layered
17
25
  # )
18
26
  #
19
27
  # Column options:
20
- # attribute: (Symbol) Model attribute for data and label generation.
28
+ # attribute: (Symbol) Used for label generation and sort links.
21
29
  # label: (String) Custom header text. Defaults to humanised attribute.
22
30
  # primary: (Boolean) Renders as <th scope="row">. Defaults to first column.
23
31
  # sortable: (Boolean) Show sort link when query: is provided. Defaults to true.
24
- # render: (Proc) Receives (record), returns cell content.
25
- #
26
- # When no +render:+ proc is given, the cell value is extracted via
27
- # +record[attribute]+ for hashes or +record.public_send(attribute)+
28
- # for objects, with automatic date formatting.
32
+ # render: (Proc) Required. Receives (record), returns cell content.
29
33
  #
30
34
  # Pass +query:+ (a Ransack search object) and +turbo_frame:+ to enable
31
35
  # sortable column headers via +l_ui_sort_link+.
@@ -67,7 +71,7 @@ module Layered
67
71
 
68
72
  caption_tag = caption ? tag.caption(caption, class: "l-ui-sr-only") : nil
69
73
  table = tag.table(class: "l-ui-table") { safe_join([caption_tag, thead, tbody].compact) }
70
- tag.div(table, class: "l-ui-container--table l-ui-utility--mt-lg")
74
+ tag.div(table, class: "l-ui-container--table")
71
75
  end
72
76
 
73
77
  private
@@ -75,6 +79,12 @@ module Layered
75
79
  def normalise_table_columns(columns)
76
80
  has_primary = columns.any? { |c| c[:primary] }
77
81
  columns.each_with_index.map do |col, i|
82
+ unless col[:render]
83
+ raise ArgumentError,
84
+ "Column #{col[:attribute].inspect} is missing a :render proc. " \
85
+ "Every column must supply render: ->(record) { ... }"
86
+ end
87
+
78
88
  {
79
89
  attribute: col[:attribute],
80
90
  label: col[:label] || col[:attribute].to_s.humanize,
@@ -86,12 +96,7 @@ module Layered
86
96
  end
87
97
 
88
98
  def table_cell(record, col)
89
- if col[:render]
90
- value = col[:render].call(record)
91
- else
92
- raw = record.is_a?(Hash) ? record[col[:attribute]] : record.public_send(col[:attribute])
93
- value = raw.respond_to?(:strftime) ? raw.strftime("%-d %b %Y %H:%M") : raw
94
- end
99
+ value = col[:render].call(record)
95
100
 
96
101
  if col[:primary]
97
102
  tag.th(value, class: "l-ui-table__cell--primary", scope: "row")
@@ -2,7 +2,7 @@
2
2
  <div class="l-ui-page--width-constrained">
3
3
  <h1>Resend confirmation instructions</h1>
4
4
 
5
- <%= form_with(model: resource, as: resource_name, url: confirmation_path(resource_name), method: :post, class: 'l-ui-form') do |f| %>
5
+ <%= form_with(model: resource, as: resource_name, url: confirmation_path(resource_name), method: :post, class: 'l-ui-form l-ui-utility--mt-md') do |f| %>
6
6
  <%= render 'layered_ui/shared/form_errors', item: resource %>
7
7
 
8
8
  <div class="l-ui-form__group">
@@ -2,7 +2,7 @@
2
2
  <div class="l-ui-page--width-constrained">
3
3
  <h1>Change your password</h1>
4
4
 
5
- <%= form_with(model: resource, as: resource_name, url: password_path(resource_name), method: :put, class: 'l-ui-form') do |f| %>
5
+ <%= form_with(model: resource, as: resource_name, url: password_path(resource_name), method: :put, class: 'l-ui-form l-ui-utility--mt-md') do |f| %>
6
6
  <%= render 'layered_ui/shared/form_errors', item: resource %>
7
7
  <%= f.hidden_field :reset_password_token %>
8
8
 
@@ -2,7 +2,7 @@
2
2
  <div class="l-ui-page--width-constrained">
3
3
  <h1>Forgot your password?</h1>
4
4
 
5
- <%= form_with(model: resource, as: resource_name, url: password_path(resource_name), method: :post, class: 'l-ui-form') do |f| %>
5
+ <%= form_with(model: resource, as: resource_name, url: password_path(resource_name), method: :post, class: 'l-ui-form l-ui-utility--mt-md') do |f| %>
6
6
  <%= render 'layered_ui/shared/form_errors', item: resource %>
7
7
 
8
8
  <div class="l-ui-form__group">
@@ -2,7 +2,7 @@
2
2
  <div class="l-ui-page--width-constrained">
3
3
  <h1>Register</h1>
4
4
 
5
- <%= form_with(model: resource, as: resource_name, url: registration_path(resource_name), class: 'l-ui-form') do |f| %>
5
+ <%= form_with(model: resource, as: resource_name, url: registration_path(resource_name), class: 'l-ui-form l-ui-utility--mt-md') do |f| %>
6
6
  <%= render 'layered_ui/shared/form_errors', item: resource %>
7
7
 
8
8
  <% if resource.class.column_names.include?('name') %>
@@ -2,7 +2,7 @@
2
2
  <div class="l-ui-page--width-constrained">
3
3
  <h1>Login</h1>
4
4
 
5
- <%= form_with(model: resource, as: resource_name, url: session_path(resource_name), class: 'l-ui-form') do |f| %>
5
+ <%= form_with(model: resource, as: resource_name, url: session_path(resource_name), class: 'l-ui-form l-ui-utility--mt-md') do |f| %>
6
6
  <%= render 'layered_ui/shared/form_errors', item: resource %>
7
7
 
8
8
  <div class="l-ui-form__group">
@@ -2,7 +2,7 @@
2
2
  <div class="l-ui-page--width-constrained">
3
3
  <h1>Resend unlock instructions</h1>
4
4
 
5
- <%= form_with(model: resource, as: resource_name, url: unlock_path(resource_name), method: :post, class: 'l-ui-form') do |f| %>
5
+ <%= form_with(model: resource, as: resource_name, url: unlock_path(resource_name), method: :post, class: 'l-ui-form l-ui-utility--mt-md') do |f| %>
6
6
  <%= render 'layered_ui/shared/form_errors', item: resource %>
7
7
 
8
8
  <div class="l-ui-form__group">
@@ -9,7 +9,7 @@
9
9
  <%= render "layered/ui/managed_resource/field", form: f, record: record, config: config %>
10
10
  <% end %>
11
11
 
12
- <div class="l-ui-form__group">
12
+ <div class="l-ui-form__actions">
13
13
  <%= f.submit(record.new_record? ? "Create" : "Save changes",
14
14
  class: "l-ui-button l-ui-button--primary") %>
15
15
  </div>
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Ui
3
- VERSION = "0.4.0"
3
+ VERSION = "0.5.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.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai