layered-ui-rails 0.2.5 → 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: feb436fe39471f18e20438f335008dbd35eefa5035e7dc4c43b25bb410ad2dff
4
- data.tar.gz: eff67adfb3a419be2b85077a00a17ac44c50edc7448f7190deff342002f41ed4
3
+ metadata.gz: a8e4d9560a90e896fb66753e342a555c9ce281764e2c3c7ac9d326a4a52d5ff4
4
+ data.tar.gz: 3c1f55587df50d2f0def13536b0adb3c8869682246932fd73ddcd146a63a4e38
5
5
  SHA512:
6
- metadata.gz: 69a5f98979872031872c93c9b2ff93060955810be38785ab882e275a5f07524834b97bde9a1907877450861f540e9a7dfa56b3d43e1282dfb0f7682c60f157a4
7
- data.tar.gz: e857eb7b2476475e535e27c0902b2549ebf9cd28a4232b1dc39630827f3e9ace63d9e8e540477e632eda99a923c03265f97bd4f6dbd93b479c04f07d60cadc86
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,6 +2,21 @@
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.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
+
5
20
  ## [0.2.5] - 2026-04-09
6
21
 
7
22
  ### Changed
@@ -943,6 +943,17 @@ pre.l-ui-surface {
943
943
  rounded-sm;
944
944
  }
945
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
+
946
957
  /* Switch */
947
958
 
948
959
  .l-ui-switch {
@@ -1113,6 +1124,16 @@ pre.l-ui-surface {
1113
1124
  text-right;
1114
1125
  }
1115
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
+
1116
1137
  .l-ui-table__body {
1117
1138
  @apply h-full
1118
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.5"
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.5
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: []