keystone_ui 0.36.0 → 0.38.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: d7a0b5685fe1dc1107e8e75d274e80401826ed52616ae95c20d6a288fdc5c647
4
- data.tar.gz: 8220957d8d617f0c32c71b264c84c5bf8b992a3cc2e3bb512f3f4e0a1cbf8bb0
3
+ metadata.gz: ba03a37368cf5e1f7e0b18721a1eca9a04aae5dea7a5b3d8887e3db9956f9e8f
4
+ data.tar.gz: 6c0eb35d049ec308a68984f32cd787d8fa1c183057a975322844187f294c31fc
5
5
  SHA512:
6
- metadata.gz: 58889e18335df436f8b13caad57648caf6ed3e13e17b50dc5d6011ae0f8fa85ceb98a9e9fd682f90cd10729ed3d3f20603ed34e438afb09a33090ec4d7493c95
7
- data.tar.gz: c79c624b0a0a09e83b1f6fcee1566df17c7adf96f8141a20c8ee3ed20b361ab7faa1135be33bcf41f780e68e663e78d9ba833d27af09e72c782d4763a09463da
6
+ metadata.gz: 36f95ca64410c3d39cae7355a0f1f7673a66e963ecf78a67d82a5559c74eef5ea02de3dc4668aef26fdab81b7b330d8d30dbb1a0aea658f13a188fdfdf296e27
7
+ data.tar.gz: 41c5aff5beccf97a8fac694a8cb6e4250e9b6c4456efb78dc1335df8c5a3be086bacae0b5a28292520666bfae44f7df5b8d6cc9c2fbe36f88f619c0a61e33cc1
@@ -1,10 +1,11 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
- static targets = ["menu", "option"]
4
+ static targets = ["menu", "option", "error"]
5
5
  static values = { saveUrl: String }
6
6
 
7
7
  connect() {
8
+ this.shown = this.optionTargets.map(option => [ option, this.checkboxIn(option).checked ])
8
9
  this._close = this.close.bind(this)
9
10
  document.addEventListener("click", this._close)
10
11
  }
@@ -66,6 +67,10 @@ export default class extends Controller {
66
67
 
67
68
  moved() {
68
69
  this.changed = true
70
+ this.refreshMoveButtons()
71
+ }
72
+
73
+ refreshMoveButtons() {
69
74
  const options = this.optionTargets
70
75
  options.forEach((option, index) => {
71
76
  option.querySelector('[data-action="click->column-picker#moveUp"]').disabled = index === 0
@@ -99,8 +104,20 @@ export default class extends Controller {
99
104
  "X-CSRF-Token": token
100
105
  },
101
106
  body: JSON.stringify({ hidden_columns: this.hiddenColumns(), column_order: this.columnOrder() })
102
- }).then(() => {
107
+ }).then((response) => {
108
+ if (!response.ok) return this.failed()
109
+
103
110
  Turbo.visit(window.location.href, { action: "replace" })
111
+ }).catch(() => this.failed())
112
+ }
113
+
114
+ failed() {
115
+ this.errorTarget.classList.remove("hidden")
116
+ this.shown.forEach(([ option, checked ]) => {
117
+ option.parentNode.insertBefore(option, null)
118
+ this.checkboxIn(option).checked = checked
119
+ option.querySelector("label").classList.toggle("ks-menu-option-hidden", !checked)
104
120
  })
121
+ this.refreshMoveButtons()
105
122
  }
106
123
  }
@@ -29,4 +29,5 @@
29
29
  </div>
30
30
  <% end %>
31
31
  </div>
32
+ <p data-column-picker-target="error" role="alert" class="<%= ERROR_CLASSES %>">Your column changes were not saved.</p>
32
33
  </div>
@@ -11,6 +11,7 @@ module Keystone
11
11
  OPTION_HIDDEN_CLASSES = "ks-menu-option-hidden"
12
12
  OPTION_ROW_CLASSES = "flex items-center"
13
13
  MOVE_BUTTON_CLASSES = "ks-menu-move text-sm"
14
+ ERROR_CLASSES = "ks-error hidden"
14
15
 
15
16
  COLUMNS_ICON = <<~SVG.freeze
16
17
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
@@ -20,18 +21,17 @@ module Keystone
20
21
 
21
22
  attr_reader :save_url
22
23
 
23
- def initialize(columns:, hidden_columns: [], save_url: nil)
24
- @columns = columns
25
- @hidden_keys = Array(hidden_columns).map(&:to_sym).to_set
24
+ def initialize(columns:, hidden_columns: [], save_url: nil, layout: nil)
25
+ @layout = layout || SavedLayout.new(columns: columns, value: nil, default_hidden: hidden_columns)
26
26
  @save_url = save_url
27
27
  end
28
28
 
29
29
  def hideable_columns
30
- @columns.select(&:hideable?)
30
+ @layout.columns.select(&:hideable?)
31
31
  end
32
32
 
33
33
  def hidden?(key)
34
- @hidden_keys.include?(key.to_sym)
34
+ @layout.hidden?(key)
35
35
  end
36
36
 
37
37
  def option_classes(key)
@@ -32,8 +32,9 @@ module Keystone
32
32
  def initialize(items:, columns:, empty_message: nil, sort: nil, sort_direction: nil, sort_url: nil, hidden_columns: [], key: nil)
33
33
  @items = items.to_a
34
34
  @all_columns = columns.map { |col| normalize_column(col) }
35
- @hidden_columns = hidden_columns
36
- @columns = visible_columns(hidden_columns)
35
+ @default_hidden = hidden_columns
36
+ @layout = SavedLayout.new(columns: @all_columns, value: nil, default_hidden: hidden_columns)
37
+ @columns = @layout.visible_columns
37
38
  @key = key
38
39
  @empty_message = empty_message
39
40
  @sort = sort&.to_sym
@@ -63,7 +64,7 @@ module Keystone
63
64
  def column_picker
64
65
  return unless @save_url
65
66
 
66
- @column_picker ||= ColumnPickerComponent.new(columns: @all_columns, hidden_columns: @hidden_columns, save_url: @save_url)
67
+ @column_picker ||= ColumnPickerComponent.new(columns: @all_columns, layout: @layout, save_url: @save_url)
67
68
  end
68
69
 
69
70
  def column_keys
@@ -151,25 +152,11 @@ module Keystone
151
152
  saved = KeystoneUi.configuration.supplied_preference(helpers, @key)
152
153
  return unless saved
153
154
 
154
- @hidden_columns = saved[:value].to_h.fetch("hidden_columns", @hidden_columns)
155
- @all_columns = ordered_columns(saved[:value].to_h["column_order"])
156
- @columns = visible_columns(@hidden_columns)
155
+ @layout = SavedLayout.new(columns: @all_columns, value: saved[:value], default_hidden: @default_hidden)
156
+ @columns = @layout.visible_columns
157
157
  @save_url = saved[:save_url]
158
158
  end
159
159
 
160
- def ordered_columns(column_order)
161
- return @all_columns unless column_order
162
-
163
- positions = Array(column_order).map(&:to_sym).each_with_index.to_h
164
- hideable = @all_columns.select(&:hideable?).sort_by.with_index { |col, index| [ positions.fetch(col.key, positions.size + index) ] }
165
- @all_columns.map { |col| col.hideable? ? hideable.shift : col }
166
- end
167
-
168
- def visible_columns(hidden_columns)
169
- hidden_keys = Array(hidden_columns).map(&:to_sym).to_set
170
- @all_columns.reject { |col| col.hideable? && hidden_keys.include?(col.key) }
171
- end
172
-
173
160
  def visual_column_count
174
161
  @columns.length + (actions? ? 1 : 0)
175
162
  end
@@ -0,0 +1,48 @@
1
+ <% if !anything_to_show? %>
2
+ <%= content %>
3
+ <% elsif sidebar? %>
4
+ <% sidebar = capture do %>
5
+ <nav class="<%= SIDEBAR_CLASSES %>">
6
+ <% if logo? %>
7
+ <div><%= logo %></div>
8
+ <% end %>
9
+ <% groups.each do |group, tabs| %>
10
+ <div class="<%= SIDEBAR_GROUP_CLASSES %>">
11
+ <p class="<%= sidebar_group_label_classes(tabs) %>"><%= group.label %></p>
12
+ <% tabs.each do |tab| %>
13
+ <a class="<%= sidebar_tab_classes(tab) %>" href="<%= tab.href_for(helpers) %>"><%= tab.label %></a>
14
+ <% end %>
15
+ </div>
16
+ <% end %>
17
+ <% if menus? %>
18
+ <div class="<%= SIDEBAR_MENUS_CLASSES %>"><%= menus %></div>
19
+ <% end %>
20
+ </nav>
21
+ <% end %>
22
+ <div class="<%= SIDEBAR_ROW_CLASSES %>">
23
+ <%= sidebar if sidebar_first? %>
24
+ <div class="<%= SIDEBAR_CONTENT_CLASSES %>"><%= content %></div>
25
+ <%= sidebar unless sidebar_first? %>
26
+ </div>
27
+ <% else %>
28
+ <div class="<%= TOP_BAR_CLASSES %>">
29
+ <%= render Keystone::Ui::NavbarComponent.new(sticky: false) do |navbar| %>
30
+ <% if logo? %>
31
+ <% navbar.with_logo do %><%= logo %><% end %>
32
+ <% end %>
33
+ <% navbar.with_desktop_links do %>
34
+ <% groups.each do |group, tabs| %>
35
+ <%= render Keystone::Ui::NavDropdownComponent.new(title: group.label, area: group.label, active: holds_current?(tabs)) do %>
36
+ <% tabs.each do |tab| %>
37
+ <%= render Keystone::Ui::NavItemComponent.new(label: tab.label, href: tab.href_for(helpers), active: current?(tab)) %>
38
+ <% end %>
39
+ <% end %>
40
+ <% end %>
41
+ <% end %>
42
+ <% if menus? %>
43
+ <% navbar.with_desktop_right do %><%= menus %><% end %>
44
+ <% end %>
45
+ <% end %>
46
+ </div>
47
+ <%= content %>
48
+ <% end %>
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "keystone_ui/navigation_order"
4
+
5
+ module Keystone
6
+ module Ui
7
+ class NavigationComponent < ViewComponent::Base
8
+ TOP_BAR_CLASSES = "hidden lg:block sticky top-0 z-40"
9
+ SIDEBAR_ROW_CLASSES = "lg:flex"
10
+ SIDEBAR_CLASSES = "ks-sidebar hidden lg:flex lg:sticky lg:top-0 lg:h-screen shrink-0 overflow-y-auto"
11
+ SIDEBAR_GROUP_CLASSES = "flex flex-col"
12
+ SIDEBAR_GROUP_LABEL_CLASSES = "ks-sidebar-group-label"
13
+ SIDEBAR_TAB_CLASSES = "ks-sidebar-tab"
14
+ ACTIVE_CLASS = "active"
15
+ SIDEBAR_MENUS_CLASSES = "mt-auto"
16
+ SIDEBAR_CONTENT_CLASSES = "min-w-0 flex-1"
17
+ SIDEBAR_PLACEMENTS = %w[left right].freeze
18
+
19
+ renders_one :logo
20
+ renders_one :menus
21
+
22
+ attr_reader :groups
23
+
24
+ def before_render
25
+ permitted = KeystoneUi.configuration.navigation_groups
26
+ .map { |group| [ group, group.tabs_permitted_for(helpers) ] }
27
+ .reject { |_group, tabs| tabs.empty? }
28
+ @groups = KeystoneUi::NavigationOrder.new(saved_navigation["order"] || []).arrange(permitted)
29
+ @current_tab = KeystoneUi.configuration.supplied_current_tab(helpers)
30
+ end
31
+
32
+ def current?(tab)
33
+ tab.key == @current_tab
34
+ end
35
+
36
+ def holds_current?(tabs)
37
+ tabs.any? { |tab| current?(tab) }
38
+ end
39
+
40
+ def sidebar_group_label_classes(tabs)
41
+ [ SIDEBAR_GROUP_LABEL_CLASSES, (ACTIVE_CLASS if holds_current?(tabs)) ].compact.join(" ")
42
+ end
43
+
44
+ def sidebar_tab_classes(tab)
45
+ [ SIDEBAR_TAB_CLASSES, (ACTIVE_CLASS if current?(tab)) ].compact.join(" ")
46
+ end
47
+
48
+ def anything_to_show?
49
+ groups.any? || logo? || menus?
50
+ end
51
+
52
+ def sidebar?
53
+ SIDEBAR_PLACEMENTS.include?(placement)
54
+ end
55
+
56
+ def sidebar_first?
57
+ placement == "left"
58
+ end
59
+
60
+ private
61
+
62
+ def placement
63
+ saved_navigation["placement"]
64
+ end
65
+
66
+ def saved_navigation
67
+ value = KeystoneUi.configuration.supplied_preference(helpers, :navigation)&.dig(:value)
68
+ value.is_a?(Hash) ? value : {}
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keystone
4
+ module Ui
5
+ class SavedLayout
6
+ def initialize(columns:, value:, default_hidden:)
7
+ @columns = columns
8
+ @value = value.is_a?(Hash) ? value : {}
9
+ @default_hidden = default_hidden
10
+ end
11
+
12
+ def columns
13
+ @ordered_columns ||= ordered_columns
14
+ end
15
+
16
+ def visible_columns
17
+ columns.reject { |column| hidden?(column.key) }
18
+ end
19
+
20
+ def hidden?(key)
21
+ column = @columns.find { |col| col.key == key.to_sym }
22
+ column&.hideable? && hidden_keys.include?(key.to_sym)
23
+ end
24
+
25
+ private
26
+
27
+ def ordered_columns
28
+ column_order = saved_list("column_order")
29
+ return @columns unless column_order
30
+
31
+ positions = Array(column_order).map(&:to_sym).each_with_index.to_h
32
+ hideable = @columns.select(&:hideable?).sort_by.with_index { |col, index| positions.fetch(col.key, positions.size + index) }
33
+ @columns.map { |col| col.hideable? ? hideable.shift : col }
34
+ end
35
+
36
+ def hidden_keys
37
+ @hidden_keys ||= Array(saved_list("hidden_columns") || @default_hidden).map(&:to_sym).to_set
38
+ end
39
+
40
+ def saved_list(name)
41
+ list = @value[name]
42
+ list if list.is_a?(Array)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -141,6 +141,10 @@ module KeystoneUiHelper
141
141
  render Keystone::Ui::NavbarComponent.new(**args), &block
142
142
  end
143
143
 
144
+ def ui_navigation(&block)
145
+ render Keystone::Ui::NavigationComponent.new, &block
146
+ end
147
+
144
148
  def ui_nav_item(**args)
145
149
  render Keystone::Ui::NavItemComponent.new(**args)
146
150
  end
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "keystone_ui/navigation_group"
4
+
3
5
  module KeystoneUi
4
6
  class Configuration
5
- attr_accessor :accent, :surface, :theme_mode_supplier, :look_supplier, :trail_supplier, :preference_supplier
6
- attr_reader :tailwind_imports, :tailwind_sources, :looks, :default_look
7
+ attr_accessor :accent, :surface, :theme_mode_supplier, :look_supplier, :trail_supplier, :preference_supplier, :current_tab_supplier
8
+ attr_reader :tailwind_imports, :tailwind_sources, :looks, :default_look, :navigation_groups
7
9
 
8
10
  def initialize
9
11
  @accent = :blue
@@ -11,6 +13,7 @@ module KeystoneUi
11
13
  @tailwind_imports = []
12
14
  @tailwind_sources = []
13
15
  @looks = {}
16
+ @navigation_groups = []
14
17
  end
15
18
 
16
19
  def default_look=(name)
@@ -22,6 +25,12 @@ module KeystoneUi
22
25
  @tailwind_imports << path.to_s
23
26
  end
24
27
 
28
+ def navigation_group(label)
29
+ group = NavigationGroup.new(label)
30
+ yield(group)
31
+ @navigation_groups << group
32
+ end
33
+
25
34
  def supplied_look(view)
26
35
  look_supplier&.call(view)&.to_s
27
36
  end
@@ -37,6 +46,10 @@ module KeystoneUi
37
46
  def supplied_preference(view, key)
38
47
  preference_supplier&.call(view, key)
39
48
  end
49
+
50
+ def supplied_current_tab(view)
51
+ current_tab_supplier&.call(view)
52
+ end
40
53
  end
41
54
 
42
55
  def self.configuration
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ class NavigationGroup
5
+ Tab = Struct.new(:key, :label, :href, :permitted, keyword_init: true) do
6
+ def href_for(view)
7
+ href.respond_to?(:call) ? href.call(view) : href
8
+ end
9
+ end
10
+
11
+ attr_reader :label, :tabs
12
+
13
+ def initialize(label)
14
+ @label = label
15
+ @tabs = []
16
+ end
17
+
18
+ def tabs_permitted_for(view)
19
+ tabs.select { |tab| tab.permitted.call(view) }
20
+ end
21
+
22
+ def tab(key, label:, href:, permitted:)
23
+ @tabs << Tab.new(key: key, label: label, href: href, permitted: permitted)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ class NavigationOrder
5
+ def initialize(saved)
6
+ @saved = saved.is_a?(Array) ? saved.grep(Hash) : []
7
+ end
8
+
9
+ def arrange(groups)
10
+ in_saved_order(groups, group_labels) { |group, _tabs| group.label }
11
+ .map { |group, tabs| [ group, arrange_tabs(group, tabs) ] }
12
+ end
13
+
14
+ private
15
+
16
+ def in_saved_order(items, saved_names)
17
+ items.each_with_index
18
+ .sort_by { |item, declared| [ saved_names.index(yield(item)) || saved_names.size, declared ] }
19
+ .map(&:first)
20
+ end
21
+
22
+ def group_labels
23
+ @saved.map { |entry| entry["group"] }
24
+ end
25
+
26
+ def arrange_tabs(group, tabs)
27
+ in_saved_order(tabs, saved_tab_keys(group)) { |tab| tab.key.to_s }
28
+ end
29
+
30
+ def saved_tab_keys(group)
31
+ tabs = @saved.find { |entry| entry["group"] == group.label }&.fetch("tabs", nil)
32
+ tabs.is_a?(Array) ? tabs : []
33
+ end
34
+ end
35
+ end
@@ -37,6 +37,7 @@ module Keystone
37
37
  Keystone::Ui::NavItemComponent,
38
38
  Keystone::Ui::BottomNavItemComponent,
39
39
  Keystone::Ui::NavbarComponent,
40
+ Keystone::Ui::NavigationComponent,
40
41
  Keystone::Ui::BottomNavComponent,
41
42
  Keystone::Ui::SettingsLinkComponent,
42
43
  Keystone::Ui::FormPageComponent,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module KeystoneUi
4
- VERSION = "0.36.0"
4
+ VERSION = "0.38.0"
5
5
  end
@@ -112,6 +112,28 @@ outer element. See Conventions before using it.
112
112
  `mobile_center`, `mobile_right`. Desktop slots are hidden below `lg:` and the
113
113
  mobile slots above it. `desktop_right` renders only when `desktop_links` is
114
114
  also filled.
115
+ - `ui_navigation` — no keywords, takes a block holding the page content. Placed
116
+ in the layout around `yield`, it draws the tabs the app declares with
117
+ `config.navigation_group` as a top bar of menus from `lg:` up, then the
118
+ content. It leaves out tabs whose permission check fails and groups with no
119
+ tab left, and below `lg:` it draws only the content. Inside the block, a
120
+ `navigation.with_logo do ... end` block puts the app's logo at the left end
121
+ of the top bar, and a `navigation.with_menus do ... end` block puts menus
122
+ such as the account menu and the user menu at its right end, so the layout
123
+ draws no second bar for them. When the preference supplied for
124
+ `:navigation` holds `{ "placement" => "left" }` or `"right"`, it draws a
125
+ sidebar on that side of the content from `lg:` up instead, with each group's
126
+ label above its tabs, the logo at its top and the menus at its bottom, and
127
+ below `lg:` the content takes the full width. Any other placement draws the
128
+ top bar. When that value holds an `"order"` list of
129
+ `{ "group" => label, "tabs" => [tab key strings] }` entries, both placements
130
+ draw the named groups and tabs first in that order, then the rest in
131
+ declared order, ignoring names no longer declared. When the app sets `config.current_tab_supplier`, the tab whose key
132
+ it returns for the page and that tab's group are shown as active in either
133
+ placement, and nothing is marked when it returns `nil`, a key no declared
134
+ tab has, or is not set. Check whether the app's Keystone UI
135
+ initializer declares navigation groups before adding desktop tabs by hand;
136
+ declaring them is `keystone_ui-install`'s job.
115
137
  - `ui_nav_item(label:, href:, active: false)` — one desktop navigation link.
116
138
  - `ui_nav_dropdown(title:, area:, active: false)` — takes a block. A navbar
117
139
  dropdown; the block holds the menu links.
@@ -240,8 +262,11 @@ outer element. See Conventions before using it.
240
262
  and the hideable ones fill the remaining places. With no `"column_order"` the
241
263
  columns keep their declared order. Whenever the supplier
242
264
  returns a save address, the table renders a "Columns" menu in a row above
243
- itself, aligned right, that saves to it as `ui_column_picker` does, including
244
- for a person with nothing saved yet. When the
265
+ itself, aligned right, that saves to it as `ui_column_picker` does and shows
266
+ the same message when a save fails, including for a person with nothing saved
267
+ yet. That menu lists the hideable columns in
268
+ the order the table shows them and leaves ticked exactly the ones the table
269
+ shows. When the
245
270
  supplier returns nothing, when no supplier is set, or when no `key:` is
246
271
  passed, the table renders from `hidden_columns:` with no Columns menu. In
247
272
  every case `hidden_columns:` is the table's default layout.
@@ -269,7 +294,11 @@ outer element. See Conventions before using it.
269
294
  `X-CSRF-Token` header, then reloads the page. A menu closed with nothing
270
295
  changed sends nothing. `column_order` lists every hideable column's key in
271
296
  the menu's order when it closes. With no `save_url:` it sends nothing. The app must provide that endpoint and persist
272
- both lists. The picker does not reorder the table: beside a table without
297
+ both lists. The endpoint must answer with a success status when it has saved
298
+ them. When it answers with an error status, or the request cannot reach the
299
+ server, the menu does not reload the page: it shows "Your column changes were
300
+ not saved." under the Columns button, and puts its boxes and its order back
301
+ to what the table shows. The picker does not reorder the table: beside a table without
273
302
  `key:`, the app must pass the table and the picker its columns in the saved
274
303
  order itself. A table
275
304
  given `key:` renders its own Columns menu when the supplier gives a save
@@ -150,11 +150,15 @@ holds the catalog.
150
150
  table, at its right, and saves the user's choice there, even before anything
151
151
  has been saved. The menu lists the hideable columns in the order the table
152
152
  shows them, each with a box to show or hide it and buttons to move it up or
153
- down. An unticked column's name is greyed at once. Ticking boxes and moving
153
+ down. The menu and the table read the same saved layout, so the menu's
154
+ ticked boxes and order always match the columns the table shows. An unticked column's name is greyed at once. Ticking boxes and moving
154
155
  columns sends nothing while the menu is open. Closing the menu, with its
155
156
  Columns button or by clicking anywhere outside it, saves the hidden columns
156
157
  and the order together once, then reloads the page. Closing it with nothing
157
- changed saves nothing.
158
+ changed saves nothing. When the save is refused or cannot reach the server,
159
+ the page does not reload. The Columns menu shows the message "Your
160
+ column changes were not saved." and puts its boxes and order back to what
161
+ the table shows.
158
162
  A table with no key, or an app with no supplier, renders from its own call
159
163
  alone. Setting up the supplier belongs to the install local.
160
164
  - **Suggestions.** A form field can carry a list of suggested values. The
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: keystone_ui-install
3
- description: Use to hook Keystone UI into a project — adding the gem, running the install generator to wire Tailwind, the Stimulus controllers and the layout's theme attributes, and configuring the palette, the theme mode supplier, registered looks, the breadcrumb trail supplier, the saved table layout supplier and extra Tailwind imports and sources.
3
+ description: Use to hook Keystone UI into a project — adding the gem, running the install generator to wire Tailwind, the Stimulus controllers and the layout's theme attributes, and configuring the palette, the theme mode supplier, registered looks, the breadcrumb trail supplier, the saved table layout supplier, the navigation groups and current tab supplier, and extra Tailwind imports and sources.
4
4
  tools: Bash, Read, Edit
5
5
  scope: UI — pages, forms, tables, navigation, dashboards
6
6
  ---
@@ -28,7 +28,12 @@ built on ViewComponent; hook it in before building any screen with those helpers
28
28
  show pages that pass none, and their Back link when they pass none either),
29
29
  `preference_supplier` (a callable that supplies the saved hidden columns and
30
30
  column order of a data table given a `key:`, and the address its Columns menu
31
- saves to),
31
+ saves to, and the navigation's placement under the key `:navigation`),
32
+ `navigation_group` (a labelled group of desktop tabs that `ui_navigation`
33
+ draws, each tab declared with `tab key, label:, href:, permitted:`),
34
+ `current_tab_supplier` (a callable that supplies the key of the declared tab
35
+ the current page belongs to, so `ui_navigation` marks it and its group
36
+ active),
32
37
  and the `tailwind_imports` and `tailwind_sources` lists (extra CSS files and
33
38
  scan paths added to the Tailwind build).
34
39
  - `keystone_theme_attributes` — a layout helper placed inside the `<html>` tag.
@@ -183,6 +188,10 @@ built on ViewComponent; hook it in before building any screen with those helpers
183
188
  config.look_supplier = ->(view) { view.current_user&.look }
184
189
  config.trail_supplier = ->(view) { view.breadcrumb_trail }
185
190
  config.preference_supplier = ->(view, key) { TablePreferences.for(view.current_user, key) }
191
+ config.navigation_group "Sales" do |group|
192
+ group.tab :quotes, label: "Quotes", href: ->(view) { view.quotes_path }, permitted: ->(view) { view.policy(Quote).index? }
193
+ end
194
+ config.current_tab_supplier = ->(view) { view.controller.try(:navigation_tab) }
186
195
  config.tailwind_imports << "/absolute/path/to/extra.css"
187
196
  config.tailwind_sources << "/absolute/path/to/components/**/*.{erb,rb}"
188
197
  end
@@ -282,9 +291,45 @@ built on ViewComponent; hook it in before building any screen with those helpers
282
291
  string keys, `"hidden_columns"` and `"column_order"`, or the saved order
283
292
  is not applied. With no `save_url`, the saved layout applies and no menu
284
293
  is shown.
294
+ - The action at `save_url` must answer a stored layout with a 2xx status.
295
+ Any other status, or a request that cannot reach the server, leaves the
296
+ page unreloaded. The table then shows "Your column changes were not
297
+ saved." beside its Columns button, and the menu's boxes and order go back
298
+ to what the table shows.
299
+ - For the key `:navigation` the supplier returns the person's navigation
300
+ placement as `{ value: { "placement" => "left" } }`, with `"left"`,
301
+ `"right"` or `"top"`. `ui_navigation` draws a sidebar on the left or the
302
+ right of the page content on desktop screens for the first two, and the
303
+ top bar for `"top"`, `nil`, a value with no `"placement"` string key, or
304
+ any other placement.
305
+ - The same `:navigation` value may hold the person's order of groups and
306
+ tabs under `"order"`, as
307
+ `[ { "group" => "Admin" }, { "group" => "Sales", "tabs" => [ "orders", "quotes" ] } ]`.
308
+ A group is named by its declared label and a tab by its key as a string.
309
+ `ui_navigation` draws the named groups and tabs first in that order, then
310
+ the rest in declared order, and ignores names no longer declared.
285
311
  - A companion preferences gem may set this supplier for the app. Ask the
286
312
  developer whether the app uses one, or which code stores each user's table
287
313
  layouts, before writing the callable.
314
+ - `navigation_group "Label" do |group| ... end` — declares one menu of
315
+ desktop tabs, in the order the groups are declared. Inside the block,
316
+ `group.tab key, label:, href:, permitted:` adds a tab in order. `href:` is
317
+ an address or a callable that receives the view and returns one.
318
+ `permitted:` is a callable that receives the view and returns whether the
319
+ current person may see the tab. The layout renders them with
320
+ `ui_navigation` around `yield`, setting the app's logo with
321
+ `navigation.with_logo` and its account and user menus with
322
+ `navigation.with_menus` inside the block, so the layout draws no other
323
+ top bar. Ask the developer which tabs and groups the app has, and which
324
+ permission check guards each, before declaring them.
325
+ - `current_tab_supplier` — a callable that receives the view and returns the
326
+ key of the declared tab the current page belongs to, or `nil`.
327
+ `ui_navigation` shows that tab and its group as active in the top bar and
328
+ gives that tab and its group's label the `active` class in the sidebar.
329
+ `nil`, a key no declared tab has, or no supplier marks nothing. The
330
+ callable must answer for every page the layout draws, so a page with no
331
+ tab returns `nil` rather than raising. Ask the developer how a page names
332
+ its tab, such as a method on each controller, before writing the callable.
288
333
  - `tailwind_imports` and `tailwind_sources` — lists to append to, never
289
334
  assign. Each import becomes an `@import` line and each source becomes an
290
335
  `@source` line in `keystone_source.css` on the next boot. They are for
@@ -39,6 +39,7 @@ develop:
39
39
  - ui_action_menu
40
40
  - ui_action_menu_item
41
41
  - ui_navbar
42
+ - ui_navigation
42
43
  - ui_nav_item
43
44
  - ui_nav_dropdown
44
45
  - ui_bottom_nav
@@ -71,6 +72,8 @@ sources:
71
72
  - app/components/keystone/ui/figure_component.rb
72
73
  - lib/generators/keystone/install_generator.rb
73
74
  - lib/keystone_ui/configuration.rb
75
+ - lib/keystone_ui/navigation_group.rb
76
+ - lib/keystone_ui/navigation_order.rb
74
77
  - lib/keystone_ui/missing_back_link.rb
75
78
  - lib/keystone_ui/incomplete_trail.rb
76
79
  - lib/keystone_ui/theme_choice.rb
@@ -131,6 +134,8 @@ sources:
131
134
  - app/components/keystone/ui/nav_dropdown_component.rb
132
135
  - app/components/keystone/ui/nav_item_component.rb
133
136
  - app/components/keystone/ui/navbar_component.rb
137
+ - app/components/keystone/ui/navigation_component.rb
138
+ - app/components/keystone/ui/navigation_component.html.erb
134
139
  - app/components/keystone/ui/option_card_component.rb
135
140
  - app/components/keystone/ui/page_component.rb
136
141
  - app/components/keystone/ui/page_component.html.erb
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keystone_ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.36.0
4
+ version: 0.38.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tyler Schneider
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 0.12.0
18
+ version: 0.14.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 0.12.0
25
+ version: 0.14.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: view_component
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -161,6 +161,8 @@ files:
161
161
  - app/components/keystone/ui/nav_item_component.rb
162
162
  - app/components/keystone/ui/navbar_component.html.erb
163
163
  - app/components/keystone/ui/navbar_component.rb
164
+ - app/components/keystone/ui/navigation_component.html.erb
165
+ - app/components/keystone/ui/navigation_component.rb
164
166
  - app/components/keystone/ui/option_card_component.html.erb
165
167
  - app/components/keystone/ui/option_card_component.rb
166
168
  - app/components/keystone/ui/page_component.html.erb
@@ -175,6 +177,7 @@ files:
175
177
  - app/components/keystone/ui/progress_component.rb
176
178
  - app/components/keystone/ui/radio_card_component.html.erb
177
179
  - app/components/keystone/ui/radio_card_component.rb
180
+ - app/components/keystone/ui/saved_layout.rb
178
181
  - app/components/keystone/ui/section_component.html.erb
179
182
  - app/components/keystone/ui/section_component.rb
180
183
  - app/components/keystone/ui/select_component.html.erb
@@ -205,6 +208,8 @@ files:
205
208
  - lib/keystone_ui/look_check.rb
206
209
  - lib/keystone_ui/look_choice.rb
207
210
  - lib/keystone_ui/missing_back_link.rb
211
+ - lib/keystone_ui/navigation_group.rb
212
+ - lib/keystone_ui/navigation_order.rb
208
213
  - lib/keystone_ui/safelist.rb
209
214
  - lib/keystone_ui/source_css.rb
210
215
  - lib/keystone_ui/theme_choice.rb