layered-ui-rails 0.2.4 → 0.3.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: 272ff1df79793bfe6c59ed6b4ea2ae12a82e8a7b51b5efcc770987b7387ca2c0
4
- data.tar.gz: 21bf4840c729bf7d56cbb0971ab49f45fc24d301166f046c31c1164deeea0c56
3
+ metadata.gz: a8e4d9560a90e896fb66753e342a555c9ce281764e2c3c7ac9d326a4a52d5ff4
4
+ data.tar.gz: 3c1f55587df50d2f0def13536b0adb3c8869682246932fd73ddcd146a63a4e38
5
5
  SHA512:
6
- metadata.gz: 2bba00f8ff4ff4e0b65b96c95ed438325ff50f93bd14c3f81d6274616d921cf196a0350c8780b85866609a4de090f51ba1434e0ee204d68fcb52b230edf72ab3
7
- data.tar.gz: e31d63c421a1d3faa076c76f6a15b09f9465e2456fa9d04a024496e3e83790eb18e192193ec54842655382c81989789d36773dda3aa657f3cb27e23962653f07
6
+ metadata.gz: 99a8dff88d32df2645427d4802d64294744cfc51d185e3b7df7b1fbf25db5887472b654293c6ce3af78440be2730a809c73f37bf282e6166d47b39ec80e85530
7
+ data.tar.gz: 13f9cc999716982874cfc3a6eaff48e63a5c7a06b608e321692de90d573c74e2879229dec97bd6250f3afb5b4bdfd222fef6150643b92e7f38d77b2b28fb85f3
data/AGENTS.md CHANGED
@@ -2,10 +2,6 @@
2
2
 
3
3
  Guidance for AI agents working in this repository.
4
4
 
5
- ## Project
6
-
7
- **layered-ui-rails** - Rails 8+ engine gem: design tokens, Tailwind CSS, Stimulus controllers (theme, mobile nav, panel, modal, tabs). Pure frontend, no server-side logic.
8
-
9
5
  ## Architecture
10
6
 
11
7
  - **Entry:** `require "layered-ui-rails"` → `lib/layered/ui.rb` → `lib/layered/ui/engine.rb`
data/CHANGELOG.md CHANGED
@@ -2,7 +2,27 @@
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
- ## Unreleased
5
+ ## [0.3.0] - 2026-04-11
6
+
7
+ ### Added
8
+
9
+ - Optional Ransack search helper (`l_ui_search_form`) with styled search form wrapping Ransack's `search_form_for`
10
+ - Sort link helper (`l_ui_sort_link`) with styled header cells wrapping Ransack's `sort_link`
11
+ - Stimulus controller (`l-ui--search-form`) for clear button and Turbo Frame targeting
12
+ - Support for multiple independent Ransack collections on a single page using Turbo Frames and `search_key`
13
+ - Graceful degradation when Ransack is not installed (visible warning in development, silent fallback in production)
14
+ - Ransack integration documentation and search helper API reference pages in the dummy app
15
+
16
+ ### Changed
17
+
18
+ - Updated gem description and summary to clarify that the engine is built on Tailwind CSS and integrates with Devise, Pagy, and Ransack
19
+
20
+ ## [0.2.5] - 2026-04-09
21
+
22
+ ### Changed
23
+
24
+ - Reduced form field text size from `text-base` (16px) to `text-sm` (14px) on desktop; remains 16px on mobile to prevent iOS auto-zoom
25
+ - Increased form hint text size from `text-xs` (12px) to `text-sm` (14px)
6
26
 
7
27
  ## [0.2.4] - 2026-04-06
8
28
 
data/README.md CHANGED
@@ -106,7 +106,7 @@ For dynamic theming (e.g. per-tenant branding), use `content_for :l_ui_head` to
106
106
  > <% end %>
107
107
  > ```
108
108
 
109
- See the [Colors documentation](https://layered-ui-rails.layered.ai/pages/layout_colors) for the full list of tokens.
109
+ See the [Colors documentation](https://layered-ui-rails.layered.ai/layout_colors) for the full list of tokens.
110
110
 
111
111
  ## Customising logos and icons
112
112
 
@@ -186,6 +186,8 @@ kamal deploy
186
186
 
187
187
  This project is still in its early days. We welcome issues, feedback, and ideas - they genuinely help shape the direction of the project. That said, we're holding off on accepting pull requests until after the 1.0 release so we can stay focused on getting the core foundations right. Once we're there, we'd love to open things up to broader contributions. Thanks for your patience and interest!
188
188
 
189
+ - [CLA.md](CLA.md) - contributor license agreement
190
+
189
191
  ## License
190
192
 
191
193
  Released under the [Apache 2.0 License](LICENSE).
@@ -196,6 +198,3 @@ Copyright 2026 LAYERED AI LIMITED (UK company number: 17056830). See [NOTICE](NO
196
198
 
197
199
  The source code is fully open, but the layered.ai name, logo, and brand assets are trademarks of LAYERED AI LIMITED. The Apache 2.0 license does not grant rights to use the layered.ai branding. Forks and redistributions must use a distinct name. See [TRADEMARK.md](TRADEMARK.md) for the full policy.
198
200
 
199
- ## Contributing
200
-
201
- - [CLA.md](CLA.md) - contributor license agreement
@@ -901,16 +901,21 @@ pre.l-ui-surface {
901
901
 
902
902
  .l-ui-form__hint {
903
903
  @apply mt-2
904
- text-xs text-foreground-muted;
904
+ text-sm text-foreground-muted;
905
905
  }
906
906
 
907
907
  @utility form__field {
908
908
  @apply block
909
909
  w-full px-3 py-2.5 min-h-[44px]
910
+ /* text-base at mobile; text-sm at md+ */
910
911
  text-base font-inter
911
912
  text-foreground
912
913
  border border-border-control rounded-sm
913
914
  focus-ring;
915
+
916
+ @media (min-width: 768px) {
917
+ @apply text-sm;
918
+ }
914
919
  }
915
920
 
916
921
  .l-ui-form__field {
@@ -938,6 +943,17 @@ pre.l-ui-surface {
938
943
  rounded-sm;
939
944
  }
940
945
 
946
+ /* Search */
947
+
948
+ .l-ui-search__inline {
949
+ @apply flex items-center
950
+ gap-2;
951
+
952
+ .l-ui-form__field {
953
+ @apply mt-0;
954
+ }
955
+ }
956
+
941
957
  /* Switch */
942
958
 
943
959
  .l-ui-switch {
@@ -1108,6 +1124,16 @@ pre.l-ui-surface {
1108
1124
  text-right;
1109
1125
  }
1110
1126
 
1127
+ .l-ui-table__sort-link {
1128
+ @apply inline-flex items-center gap-1
1129
+ no-underline
1130
+ text-foreground;
1131
+ }
1132
+
1133
+ .l-ui-table__sort-indicator {
1134
+ @apply text-foreground-muted;
1135
+ }
1136
+
1111
1137
  .l-ui-table__body {
1112
1138
  @apply h-full
1113
1139
  bg-background
@@ -0,0 +1,137 @@
1
+ module Layered
2
+ module Ui
3
+ module RansackHelper
4
+ # Renders a styled Ransack search form.
5
+ #
6
+ # Simple usage (single input searching across fields):
7
+ # l_ui_search_form(@q, url: users_path, fields: [:name, :email])
8
+ #
9
+ # Custom usage (full control via block):
10
+ # l_ui_search_form(@q, url: users_path) do |f|
11
+ # render "layered_ui/shared/search_field", form: f, field: :name_cont, label: "Name"
12
+ # f.submit "Go", class: "l-ui-button--primary"
13
+ # end
14
+ def l_ui_search_form(query, url: nil, fields: [], predicate: :cont, combinator: :or, label: "Search", placeholder: nil, button: "Search", clear: nil, turbo_frame: nil, html: {}, &block)
15
+ result = require_ransack("l_ui_search_form") { |msg| tag.p(msg, class: "l-ui-notice--warning") }
16
+ return result unless result == true
17
+
18
+ scope = query.context&.search_key || :q
19
+ turbo_action = "advance"
20
+ html = html.merge(class: ["l-ui-form", html[:class]].compact.join(" "))
21
+
22
+ if turbo_frame
23
+ existing_data = (html[:data] || {}).symbolize_keys
24
+ existing_controller = existing_data[:controller]
25
+ controller = [existing_controller, "l-ui--search-form"].compact.join(" ")
26
+ existing_action = existing_data[:action]
27
+ action = [existing_action, "submit->l-ui--search-form#preserve"].compact.join(" ")
28
+ turbo_action = existing_data[:turbo_action] || turbo_action
29
+
30
+ html[:data] = existing_data.except(:controller, :action, :l_ui__search_form_scope_value).merge(
31
+ turbo_frame: turbo_frame, turbo_action: turbo_action,
32
+ controller: controller, action: action,
33
+ l_ui__search_form_scope_value: scope
34
+ )
35
+ end
36
+
37
+ if block
38
+ search_form_for(query, url: url, html: html, as: scope, &block)
39
+ else
40
+ raise ArgumentError, "l_ui_search_form requires at least one field in simple mode (e.g. fields: [:name])" if fields.empty?
41
+
42
+ combined_field = fields.map(&:to_s).join("_#{combinator}_") + "_#{predicate}"
43
+ placeholder ||= "Search by #{fields.map { |f| f.to_s.humanize.downcase }.join(', ')}"
44
+
45
+ search_form_for(query, url: url, html: html, as: scope) do |f|
46
+ f.label(combined_field, label, class: "l-ui-sr-only") +
47
+ tag.div(class: "l-ui-search__inline") do
48
+ content = f.text_field(combined_field, class: "l-ui-form__field", placeholder: placeholder) +
49
+ f.submit(button, class: "l-ui-button--primary")
50
+ if clear
51
+ raise ArgumentError, "l_ui_search_form requires an explicit url: when clear: is set" unless url
52
+ clear_options = { class: "l-ui-button--outline" }
53
+ clear_options[:data] = { turbo_frame: turbo_frame, turbo_action: turbo_action, action: "click->l-ui--search-form#clear" } if turbo_frame
54
+ content += link_to(clear == true ? "Clear" : clear, url, **clear_options)
55
+ end
56
+ content
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ SORT_INDICATORS = {
63
+ "asc" => { symbol: "▲", label: ", sorted ascending", aria: "ascending" },
64
+ "desc" => { symbol: "▼", label: ", sorted descending", aria: "descending" }
65
+ }.freeze
66
+
67
+ # Renders a styled, accessible Ransack sort header cell.
68
+ #
69
+ # Returns a +<th>+ element containing a sort link and an accessible sort
70
+ # direction indicator. The +aria-sort+ attribute is set on the +<th>+
71
+ # so screen readers announce the current sort state.
72
+ #
73
+ # Usage:
74
+ # l_ui_sort_link(@q, :name)
75
+ # l_ui_sort_link(@q, :name, "Full name")
76
+ # l_ui_sort_link(@q, :created_at, "Joined", default_order: :desc)
77
+ # l_ui_sort_link(@q, :name, html: { data: { turbo_action: "replace" } })
78
+ def l_ui_sort_link(query, attribute, label = nil, default_order: nil, turbo_frame: nil, html: {})
79
+ label ||= attribute.to_s.humanize
80
+ link_class = ["l-ui-table__sort-link", html[:class]].compact.join(" ")
81
+
82
+ result = require_ransack("l_ui_sort_link") { |msg| tag.th(tag.span(label, title: msg), class: "l-ui-table__header-cell", scope: "col") }
83
+ return (result || tag.th(label, class: "l-ui-table__header-cell", scope: "col")) unless result == true
84
+
85
+ current_dir = sort_direction_for(query, attribute)
86
+ indicator = SORT_INDICATORS[current_dir]
87
+ aria_sort = indicator&.dig(:aria) || "none"
88
+
89
+ url = sort_url(query, attribute, { default_order: default_order }.compact)
90
+ link_html = html.except(:class)
91
+ if turbo_frame
92
+ existing_data = (link_html[:data] || {}).symbolize_keys
93
+ link_html[:data] = existing_data.merge(turbo_frame: turbo_frame, turbo_action: existing_data[:turbo_action] || "advance")
94
+ end
95
+ link = link_to(url, **link_html, class: link_class) do
96
+ parts = [label]
97
+ if indicator
98
+ parts << tag.span(indicator[:symbol], aria: { hidden: true }, class: "l-ui-table__sort-indicator")
99
+ parts << tag.span(indicator[:label], class: "l-ui-sr-only")
100
+ end
101
+ safe_join(parts)
102
+ end
103
+
104
+ tag.th(link, class: "l-ui-table__header-cell l-ui-table__header-cell--sortable",
105
+ scope: "col", aria: { sort: aria_sort })
106
+ end
107
+
108
+ private
109
+
110
+ def ransack_available?
111
+ defined?(Ransack)
112
+ end
113
+
114
+ # Returns +true+ if Ransack is available. In development, returns the
115
+ # block's result so the caller can render a visible fallback. In
116
+ # production/test, logs and returns +nil+.
117
+ def require_ransack(helper_name)
118
+ return true if ransack_available?
119
+
120
+ message = "#{helper_name} requires the ransack gem. Add `gem \"ransack\"` to your Gemfile."
121
+
122
+ if Rails.env.development?
123
+ return yield(message)
124
+ end
125
+
126
+ Rails.logger.warn("[layered-ui-rails] #{message} The output has been hidden.")
127
+ nil
128
+ end
129
+
130
+ def sort_direction_for(query, attribute)
131
+ return unless query.respond_to?(:sorts)
132
+ sort = query.sorts.detect { |s| s.name == attribute.to_s }
133
+ sort&.dir
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,47 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Preserves query params from other search scopes when a scoped form submits
4
+ // or its clear link is clicked.
5
+ //
6
+ // When multiple Ransack collections share one page, each form only knows about
7
+ // its own fields. This controller reads the current URL at submit time and
8
+ // injects hidden inputs for params that belong to other scopes, so the
9
+ // resulting URL reflects the full state of all collections. The clear action
10
+ // rewrites the clear link's href to include the same preserved params.
11
+ export default class extends Controller {
12
+ static values = { scope: String }
13
+
14
+ preserve(event) {
15
+ this.element.querySelectorAll("[data-l-ui-preserved]").forEach(el => el.remove())
16
+
17
+ for (const [key, value] of this.#otherParams()) {
18
+ const input = document.createElement("input")
19
+ input.type = "hidden"
20
+ input.name = key
21
+ input.value = value
22
+ input.setAttribute("data-l-ui-preserved", "")
23
+ this.element.appendChild(input)
24
+ }
25
+ }
26
+
27
+ clear(event) {
28
+ const link = event.currentTarget
29
+ const preserved = this.#otherParams()
30
+ const base = link.href.split("?")[0]
31
+ const qs = preserved.toString()
32
+ link.href = qs ? `${base}?${qs}` : base
33
+ }
34
+
35
+ #otherParams() {
36
+ const currentParams = new URLSearchParams(window.location.search)
37
+ const scope = this.scopeValue
38
+ const result = new URLSearchParams()
39
+
40
+ for (const [key, value] of currentParams) {
41
+ if (key === scope || key.startsWith(scope + "[") || key === "commit") continue
42
+ result.append(key, value)
43
+ }
44
+
45
+ return result
46
+ }
47
+ }
@@ -5,8 +5,10 @@ import PanelController from "layered_ui/controllers/l_ui/panel_controller"
5
5
  import PanelResizeController from "layered_ui/controllers/l_ui/panel_resize_controller"
6
6
  import PanelButtonController from "layered_ui/controllers/l_ui/panel_button_controller"
7
7
  import ModalController from "layered_ui/controllers/l_ui/modal_controller"
8
+ import SearchFormController from "layered_ui/controllers/l_ui/search_form_controller"
8
9
  import TabsController from "layered_ui/controllers/l_ui/tabs_controller"
9
10
 
11
+ application.register("l-ui--search-form", SearchFormController)
10
12
  application.register("l-ui--theme", ThemeController)
11
13
  application.register("l-ui--navigation", NavigationController)
12
14
  application.register("l-ui--panel", PanelController)
@@ -0,0 +1,6 @@
1
+ <% placeholder = local_assigns.fetch(:placeholder, nil) %>
2
+
3
+ <div class="l-ui-form__group">
4
+ <%= form.label field, label, class: "l-ui-label" %>
5
+ <%= form.text_field field, class: "l-ui-form__field", **(placeholder ? { placeholder: placeholder } : {}) %>
6
+ </div>
@@ -0,0 +1,8 @@
1
+ <% include_blank = local_assigns.fetch(:include_blank, "Any") %>
2
+
3
+ <div class="l-ui-form__group">
4
+ <%= form.label field, label, class: "l-ui-label" %>
5
+ <div class="l-ui-select-wrapper">
6
+ <%= form.select field, options, { include_blank: include_blank }, class: "l-ui-select" %>
7
+ </div>
8
+ </div>
data/config/importmap.rb CHANGED
@@ -11,5 +11,6 @@ pin "layered_ui/controllers/l_ui/navigation_controller", to: "layered_ui/control
11
11
  pin "layered_ui/controllers/l_ui/panel_controller", to: "layered_ui/controllers/l_ui/panel_controller.js"
12
12
  pin "layered_ui/controllers/l_ui/panel_resize_controller", to: "layered_ui/controllers/l_ui/panel_resize_controller.js"
13
13
  pin "layered_ui/controllers/l_ui/panel_button_controller", to: "layered_ui/controllers/l_ui/panel_button_controller.js"
14
+ pin "layered_ui/controllers/l_ui/search_form_controller", to: "layered_ui/controllers/l_ui/search_form_controller.js"
14
15
  pin "layered_ui/controllers/l_ui/tabs_controller", to: "layered_ui/controllers/l_ui/tabs_controller.js"
15
16
  pin "layered_ui/controllers/l_ui/theme_controller", to: "layered_ui/controllers/l_ui/theme_controller.js"
@@ -29,6 +29,7 @@ module Layered
29
29
  helper Layered::Ui::AuthenticationHelper
30
30
  helper Layered::Ui::NavigationHelper
31
31
  helper Layered::Ui::PaginationHelper
32
+ helper Layered::Ui::RansackHelper
32
33
  end
33
34
  end
34
35
 
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Ui
3
- VERSION = "0.2.4"
3
+ VERSION = "0.3.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.2.4
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai
@@ -107,6 +107,20 @@ dependencies:
107
107
  - - "~>"
108
108
  - !ruby/object:Gem::Version
109
109
  version: '7.0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: ransack
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '4.0'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '4.0'
110
124
  - !ruby/object:Gem::Dependency
111
125
  name: sqlite3
112
126
  requirement: !ruby/object:Gem::Requirement
@@ -149,9 +163,10 @@ dependencies:
149
163
  - - "~>"
150
164
  - !ruby/object:Gem::Version
151
165
  version: '2.0'
152
- description: An open source Rails 8+ engine that provides WCAG 2.2 AA compliant design
153
- tokens, Tailwind CSS utilities, and Stimulus controllers for theme switching, mobile
154
- navigation, slide-out panels, modals, and tabs.
166
+ description: An open source Rails 8+ engine built on Tailwind CSS, providing customisable
167
+ WCAG 2.2 AA compliant design tokens, utility classes, and Stimulus controllers for
168
+ theme switching, mobile navigation, slide-out panels, modals, and tabs. Integrates
169
+ with the gems you already use (Devise, Pagy, Ransack).
155
170
  email:
156
171
  - support@layered.ai
157
172
  executables: []
@@ -192,11 +207,13 @@ files:
192
207
  - app/helpers/layered/ui/authentication_helper.rb
193
208
  - app/helpers/layered/ui/navigation_helper.rb
194
209
  - app/helpers/layered/ui/pagination_helper.rb
210
+ - app/helpers/layered/ui/ransack_helper.rb
195
211
  - app/javascript/layered_ui/controllers/l_ui/modal_controller.js
196
212
  - app/javascript/layered_ui/controllers/l_ui/navigation_controller.js
197
213
  - app/javascript/layered_ui/controllers/l_ui/panel_button_controller.js
198
214
  - app/javascript/layered_ui/controllers/l_ui/panel_controller.js
199
215
  - app/javascript/layered_ui/controllers/l_ui/panel_resize_controller.js
216
+ - app/javascript/layered_ui/controllers/l_ui/search_form_controller.js
200
217
  - app/javascript/layered_ui/controllers/l_ui/tabs_controller.js
201
218
  - app/javascript/layered_ui/controllers/l_ui/theme_controller.js
202
219
  - app/javascript/layered_ui/index.js
@@ -218,6 +235,8 @@ files:
218
235
  - app/views/layered_ui/shared/_field_error.html.erb
219
236
  - app/views/layered_ui/shared/_form_errors.html.erb
220
237
  - app/views/layered_ui/shared/_label.html.erb
238
+ - app/views/layered_ui/shared/_search_field.html.erb
239
+ - app/views/layered_ui/shared/_search_select.html.erb
221
240
  - app/views/layouts/layered_ui/_header.html.erb
222
241
  - app/views/layouts/layered_ui/_navigation.html.erb
223
242
  - app/views/layouts/layered_ui/_notice.html.erb
@@ -272,6 +291,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
272
291
  requirements: []
273
292
  rubygems_version: 4.0.6
274
293
  specification_version: 4
275
- summary: Open source, minimalist, responsive, accessible UI system with light and
276
- dark theme support.
294
+ summary: Open source, minimalist Tailwind-based UI system for Rails with responsive,
295
+ accessible components and light/dark themes.
277
296
  test_files: []