keystone_ui-app 0.3.2 → 0.4.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: 212b3a2339346db6ef60600e92c827266be5aceb55abe863f0e528b20f397b5f
4
- data.tar.gz: be8069bbf3b0a9ec98c681bb71dc0114606b931ae147c3781ddce7eadbe104f4
3
+ metadata.gz: 7a6cdad5c44b2fa3fc519cc758c0b8b99641457035c225a84afe6cc4741bcb3d
4
+ data.tar.gz: cfa779ce21f5cf17a37240b9229ed6205db21938809944d733cf77a535af4c46
5
5
  SHA512:
6
- metadata.gz: bfcb75b375a224b33da6373aed1c5592e62db6c554d5216e398001dc3207fb6b1fad5503d261bcc0da6ed29de701f9ae83858705499734e781477fda5ae279f2
7
- data.tar.gz: 21632cbb807b95428a45e9e385debdec0885de72dca1e5555a279ab2370ea64daaa8f6633a10025b4886b74dd2f8a96da0d02ce71e264dfacd008aa7a95a5846
6
+ metadata.gz: 046a185c7e455766e39168ce20d65984addbb8a1c6b626da6abf37954366e067b023c03132fd9a16b59dc05831c7d41e411058704490b54dcbbeeb4112e8d72f
7
+ data.tar.gz: b9db43449c3593b9a260453d2f1646ede0a2cd90d9d87780dcb07dbae7f992e603547e71ded173b268c1f3282092740f14073bea9009ee5f78f8aacee6d21f7f
data/README.md CHANGED
@@ -59,14 +59,95 @@ end
59
59
 
60
60
  A page that should show no bottom tabs calls `content_for :hide_bottom_tabs, true`.
61
61
 
62
+ ## App areas
63
+
64
+ An app area is a part of the app that covers one or more keystone_ui navigation groups and gets one bottom tab. Declare the areas and the More page in the same initializer as the bottom tabs:
65
+
66
+ ```ruby
67
+ KeystoneUi::App.configure do |config|
68
+ config.bottom_tab(label: "Home", href: "/", icon: HOME_ICON_SVG, permitted: ->(view) { view.user_signed_in? }, section: nil, ios_system_image: "house", android_icon: "home")
69
+ config.app_area(name: "Selling", description: "Quotes and orders", groups: [ "Sales" ], on_by_default: true, opens: :quotes, icon: ->(view) { view.render("icons/sales") }, ios_system_image: "cart", android_icon: "shopping_cart")
70
+ config.more_page(href: ->(view) { view.main_app.more_path }, icon: MORE_ICON_SVG, ios_system_image: "ellipsis", android_icon: "more_horiz")
71
+ end
72
+ ```
73
+
74
+ - `name:` is the area's name, and its tab is labelled with it.
75
+ - `description:` says what the area holds.
76
+ - `groups:` lists the labels of the keystone_ui navigation groups the area covers.
77
+ - `on_by_default:` says whether the area is on for an account, and until accounts can save a choice it decides for every account.
78
+ - `opens:` is the key of the navigation page the area's tab opens, and is optional.
79
+ - `icon:`, `ios_system_image:` and `android_icon:` are the tab's icons, given as a bottom tab's are.
80
+ - `more_page` declares the address of the app's More page and its tab's icons, and `href:` and `icon:` take a string or a callable given the view.
81
+
82
+ The bar on a phone shows, in this order:
83
+
84
+ 1. The bottom tabs the person may see.
85
+ 2. One tab for each area that is on and holds a page the person may open. The tabs follow the order the person saved for their navigation groups, each area in the place of its first group, or the order the areas are declared in when the person saved none.
86
+ 3. A More tab, when the app declares a More page and the person may open a page that no tab in the bar opens.
87
+
88
+ The bar holds no more than five tabs, More included, and leaves out the areas that come last first. An area's tab opens the page `opens:` names when the person may open it, and otherwise the first page in the area the person may open, in their saved order. An area's tab is marked current on any page in one of its groups, and the More tab is marked current on the More page.
89
+
90
+ An app that declares no areas and no More page keeps the bar its bottom tabs give it.
91
+
92
+ ### The More page
93
+
94
+ The app adds the route its `more_page` declaration points at, and draws the page in its own view with one call:
95
+
96
+ ```erb
97
+ <%= ui_more_page %>
98
+ ```
99
+
100
+ It lists every navigation group the person may see, each under a heading with the group's label and with the pages they may open under it. Groups and pages follow the person's desktop menu, including an order they saved, and a page declared with `navigation_tab` outside any group is a single row with no heading. Each row opens its page.
101
+
102
+ ## The account menu
103
+
104
+ The person's account links, accounts and notifications are declared once and drawn the same way on desktop and on a phone:
105
+
106
+ ```ruby
107
+ KeystoneUi::App.configure do |config|
108
+ config.person_supplier = ->(view) { { name: view.current_user.name, email: view.current_user.email, avatar_url: view.current_user.avatar_url } }
109
+ config.accounts_supplier = ->(view) { view.current_user.accounts.map { |account| { name: account.name, href: view.main_app.switch_account_path(account), current: account == view.current_account } } }
110
+ config.notifications(href: ->(view) { view.main_app.notifications_path }, unread_count: ->(view) { view.current_user.notifications.unread.count })
111
+ config.account_page(href: ->(view) { view.main_app.account_path })
112
+ config.account_link(label: "Settings", href: ->(view) { view.main_app.edit_user_registration_path }, permitted: ->(view) { view.user_signed_in? })
113
+ config.account_link(label: "Sign out", href: ->(view) { view.main_app.destroy_user_session_path }, permitted: ->(view) { view.user_signed_in? }, sign_out: true)
114
+ end
115
+ ```
116
+
117
+ - `account_link` declares one link, and the links are listed in the order they are declared.
118
+ - `label:` is the link's label, `href:` takes a string or a callable given the view, and `permitted:` is a callable given the view that says whether the person sees the link.
119
+ - `sign_out: true` draws the link as a button that sends DELETE to its address.
120
+ - `account_page` declares the address of the app's Account page, and `href:` takes a string or a callable given the view.
121
+ - `person_supplier` returns the person's `name`, `email` and an optional `avatar_url`. The avatar shows the picture when `avatar_url` is given, and otherwise the first letter of the name.
122
+ - `accounts_supplier` returns the person's accounts, each with a `name`, the `href` that switches to it and whether it is `current`. Choosing an account sends PATCH to its `href`.
123
+ - `notifications` declares the notifications address and the unread count, and each takes a value or a callable given the view.
124
+
125
+ Once the app declares an account link, the right of the desktop top bar shows the bell with the unread count, the account switcher named after the current account, and the person's avatar, which opens a dropdown of the account links they may see. These take the place of the menus the layout passes to the frame. An app that declares no account links keeps the menus it passes.
126
+
127
+ When the app declares an Account page, the top of every screen on a phone browser shows the person's avatar as a link to it, beside the phone menus. Inside the native app the avatar is not drawn.
128
+
129
+ ### The Account page
130
+
131
+ The app adds the route its `account_page` declaration points at, and draws the page in its own view with one call:
132
+
133
+ ```erb
134
+ <%= ui_account_page %>
135
+ ```
136
+
137
+ It shows the person's avatar, name and email, then every account with the current one marked, then a Notifications row with the unread count, then the account links the person may see in the same order as the desktop dropdown. A long name wraps and a long email is cut short. Inside the native app it is drawn as an ordinary web page.
138
+
62
139
  ## Boot check
63
140
 
64
- The app refuses to boot when its bottom tabs or screen rules are declared wrong, and the error says which one:
141
+ The app refuses to boot when its bottom tabs, areas, More page or screen rules are declared wrong, and the error says which one:
65
142
 
66
143
  - A bottom tab with no label, address, icon, iOS image or Android icon.
67
144
  - A bottom tab whose `section:` is not the label of a declared keystone_ui navigation group.
68
145
  - Two bottom tabs with the same label.
69
146
  - More than five bottom tabs.
147
+ - A More page with more than four bottom tabs.
148
+ - An area with no tab icon, iOS image or Android icon.
149
+ - An area that names a group that is not a declared keystone_ui navigation group, named with the area.
150
+ - Two areas that cover the same group, named with the group.
70
151
  - A screen rule with no pattern.
71
152
 
72
153
  A tab with no label and a screen rule with no pattern are named by their position in the order they were declared, counting from 1.
@@ -124,7 +205,7 @@ That adds two addresses:
124
205
 
125
206
  The controller behind them inherits from the host's `ApplicationController`, so a bottom tab's `permitted:` and `href:` callables are given a view with the host's helpers. The host keeps both addresses open to a person who is not signed in, since the native app reads them before sign-in.
126
207
 
127
- Each configuration's `settings.tabs` holds one native tab for each bottom tab the person is permitted to see, with its label as the title and its address as the path. The iOS app gets a list with each tab's `ios_system_image_name`. The Android app gets the same tabs with each tab's `icon`, encoded as one JSON string.
208
+ Each configuration's `settings.tabs` holds the same tabs, in the same order, as the person's bar on a phone browser, with each tab's label as the title and its address as the path. The iOS app gets a list with each tab's `ios_system_image_name`. The Android app gets the same tabs with each tab's `icon`, encoded as one JSON string.
128
209
 
129
210
  Both configurations ship a rule that opens a page whose address ends in `/new` or `/edit` as a modal. The Android configuration also ships a rule before it that opens every page in the `hotwire://fragment/web` fragment with pull to refresh.
130
211
 
@@ -142,7 +223,8 @@ end
142
223
  - The `html` element carries keystone_ui's theme attributes and the current locale.
143
224
  - The `html` element carries the `dark` class when the theme mode keystone_ui chooses for the person is dark, and leaves it off for any other mode.
144
225
  - The `body` element carries the attributes the layout passes as `body:`.
145
- - On a desktop browser, keystone_ui's navigation bar with the logo and the menus.
226
+ - On a desktop browser, keystone_ui's navigation bar with the logo and the menus, or the account menu once the app declares account links.
227
+ - On a phone browser, the person's avatar at the top of the header, linking to the Account page, when the app declares one.
146
228
  - On a phone browser, the phone menus at the top of the header on every page, including form and show pages, inside an element hidden from the `lg` breakpoint up.
147
229
  - On a phone browser, keystone_ui's phone header for a page drawn with `ui_form_page` or `ui_show_page`, with its back link, title and subtitle.
148
230
  - On a phone browser, a page that is not a form or show page shows a menu of the current section's tabs, with the tab the page belongs to marked as active. The section is the navigation group holding the tab keystone_ui's current tab supplier names, and the menu leaves out tabs the person is not permitted to see. A page in no section shows no menu.
@@ -151,7 +233,7 @@ end
151
233
  - Inside the native app, the `add-button` bridge component sends a page header's add action to the native plus button, and a page with no add action sends none.
152
234
  - Inside the native app, a `style` element in the head that hides the web submit button of a form marked with `ui_native_form_data` once the native app has connected its `form` bridge component.
153
235
  - Inside the native app, no navigation bar, no phone menus, no phone header and no footer, the `hotwire-native` class on the `html` element, and a viewport that cannot be pinch-zoomed.
154
- - On a phone browser, keystone_ui's bottom nav at the foot of every page with the tabs the app declares, leaving out tabs the person is not permitted to see. The tab whose section is the navigation group holding the current tab is marked as active. The bar is hidden from the `lg` breakpoint up, and is not drawn inside the native app, on a page that calls `content_for :hide_bottom_tabs, true`, or when the person may see none of the declared tabs.
236
+ - On a phone browser, keystone_ui's bottom nav at the foot of every page with the bottom tabs the person is permitted to see, then the tabs of the app's areas and the More tab as the App areas section says. The tab whose section, or whose area's groups, hold the current tab is marked as active. The bar is hidden from the `lg` breakpoint up, and is not drawn inside the native app, on a page that calls `content_for :hide_bottom_tabs, true`, or when the person may see none of the declared tabs.
155
237
  - In a browser, the footer, unless the page calls `content_for :hide_footer, true`.
156
238
 
157
239
  ## Licence
@@ -0,0 +1,32 @@
1
+ <div class="<%= WRAPPER_CLASSES %>">
2
+ <% if notifications %>
3
+ <%= link_to notifications.href_for(helpers), class: BELL_CLASSES, aria: { label: "Notifications" } do %>
4
+ <%= BELL_ICON.html_safe %>
5
+ <% if unread_count.positive? %>
6
+ <%= render Keystone::Ui::BadgeComponent.new(label: unread_count.to_s, variant: :info) %>
7
+ <% end %>
8
+ <% end %>
9
+ <% end %>
10
+ <% if current_account %>
11
+ <div class="<%= SWITCHER_CLASSES %>">
12
+ <%= render Keystone::Ui::NavDropdownComponent.new(title: current_account[:name], area: "accounts") do %>
13
+ <% accounts.each do |account| %>
14
+ <% if account[:current] %>
15
+ <span class="ks-nav-item active" aria-current="true"><%= account[:name] %></span>
16
+ <% else %>
17
+ <%= button_to account[:name], account[:href], method: :patch, class: BUTTON_CLASSES %>
18
+ <% end %>
19
+ <% end %>
20
+ <% end %>
21
+ </div>
22
+ <% end %>
23
+ <%= render Keystone::Ui::NavDropdownComponent.new(title: render(KeystoneUi::App::AvatarComponent.new), area: "account") do %>
24
+ <% links.each do |link| %>
25
+ <% if link.sign_out %>
26
+ <%= button_to link.label, link.href_for(helpers), method: :delete, class: BUTTON_CLASSES %>
27
+ <% else %>
28
+ <%= render Keystone::Ui::NavItemComponent.new(label: link.label, href: link.href_for(helpers)) %>
29
+ <% end %>
30
+ <% end %>
31
+ <% end %>
32
+ </div>
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module App
5
+ class AccountMenuComponent < ViewComponent::Base
6
+ WRAPPER_CLASSES = "ks-app-account-menu flex items-center gap-2"
7
+ BUTTON_CLASSES = "ks-nav-item w-full text-left cursor-pointer"
8
+
9
+ SWITCHER_CLASSES = "ks-app-account-switcher"
10
+ BELL_CLASSES = "ks-app-notifications-bell ks-nav-item inline-flex items-center gap-1"
11
+ BELL_ICON = <<~SVG.freeze
12
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="size-5" aria-hidden="true">
13
+ <path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
14
+ </svg>
15
+ SVG
16
+
17
+ def notifications
18
+ KeystoneUi::App.configuration.notifications_link
19
+ end
20
+
21
+ def unread_count
22
+ notifications.unread_count_for(helpers).to_i
23
+ end
24
+
25
+ def accounts
26
+ KeystoneUi::App.configuration.accounts(helpers)
27
+ end
28
+
29
+ def current_account
30
+ accounts.find { |account| account[:current] }
31
+ end
32
+
33
+ def links
34
+ KeystoneUi::App.configuration.visible_account_links(helpers)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,60 @@
1
+ <section>
2
+ <%= render Keystone::Ui::SectionComponent.new do %>
3
+ <div class="<%= PERSON_CLASSES %>">
4
+ <%= render KeystoneUi::App::AvatarComponent.new %>
5
+ <div class="min-w-0 flex-1">
6
+ <span class="<%= NAME_CLASSES %>"><%= person[:name] %></span>
7
+ <span class="<%= EMAIL_CLASSES %>"><%= person[:email] %></span>
8
+ </div>
9
+ </div>
10
+ <% end %>
11
+ </section>
12
+ <% if accounts.any? %>
13
+ <section class="ks-app-account-accounts">
14
+ <%= render Keystone::Ui::SectionComponent.new(title: "Accounts") do %>
15
+ <div class="ks-value-list">
16
+ <% accounts.each do |account| %>
17
+ <% if account[:current] %>
18
+ <div class="<%= ROW_CLASSES %>" aria-current="true">
19
+ <span class="<%= LABEL_CLASSES %>"><%= account[:name] %></span>
20
+ <%= render Keystone::Ui::BadgeComponent.new(label: "Current", variant: :success, class: "shrink-0") %>
21
+ </div>
22
+ <% else %>
23
+ <%= button_to account[:href], method: :patch, class: BUTTON_ROW_CLASSES do %>
24
+ <span class="<%= LABEL_CLASSES %>"><%= account[:name] %></span>
25
+ <%= CHEVRON_ICON.html_safe %>
26
+ <% end %>
27
+ <% end %>
28
+ <% end %>
29
+ </div>
30
+ <% end %>
31
+ </section>
32
+ <% end %>
33
+ <% if notifications %>
34
+ <section>
35
+ <%= render Keystone::Ui::SectionComponent.new do %>
36
+ <%= render Keystone::Ui::LinkListComponent.new(rows: [ notifications_row ]) %>
37
+ <% end %>
38
+ </section>
39
+ <% end %>
40
+ <% if links.any? %>
41
+ <section class="ks-app-account-links">
42
+ <%= render Keystone::Ui::SectionComponent.new do %>
43
+ <div class="ks-value-list">
44
+ <% links.each do |link| %>
45
+ <% if link.sign_out %>
46
+ <%= button_to link.href_for(helpers), method: :delete, class: BUTTON_ROW_CLASSES do %>
47
+ <span class="<%= LABEL_CLASSES %>"><%= link.label %></span>
48
+ <%= CHEVRON_ICON.html_safe %>
49
+ <% end %>
50
+ <% else %>
51
+ <%= link_to link.href_for(helpers), class: ROW_CLASSES do %>
52
+ <span class="<%= LABEL_CLASSES %>"><%= link.label %></span>
53
+ <%= CHEVRON_ICON.html_safe %>
54
+ <% end %>
55
+ <% end %>
56
+ <% end %>
57
+ </div>
58
+ <% end %>
59
+ </section>
60
+ <% end %>
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module App
5
+ class AccountPageComponent < ViewComponent::Base
6
+ PERSON_CLASSES = "ks-app-account-person flex min-w-0 items-center gap-3"
7
+ NAME_CLASSES = "ks-paragraph block font-semibold break-words"
8
+ EMAIL_CLASSES = "ks-value-list-note block truncate"
9
+ ROW_CLASSES = "ks-value-list-row items-center no-underline"
10
+ BUTTON_ROW_CLASSES = "#{ROW_CLASSES} w-full text-left cursor-pointer"
11
+ CHEVRON_ICON = Keystone::Ui::SettingsLinkComponent::CHEVRON_ICON
12
+ LABEL_CLASSES = "ks-paragraph ks-settings-link-label block min-w-0 flex-1 break-words"
13
+
14
+ def accounts
15
+ KeystoneUi::App.configuration.accounts(helpers)
16
+ end
17
+
18
+ def notifications
19
+ KeystoneUi::App.configuration.notifications_link
20
+ end
21
+
22
+ def notifications_row
23
+ count = notifications.unread_count_for(helpers).to_i
24
+ row = { label: "Notifications", href: notifications.href_for(helpers) }
25
+ count.positive? ? row.merge(badge: { label: count.to_s, variant: :info }) : row
26
+ end
27
+
28
+ def links
29
+ KeystoneUi::App.configuration.visible_account_links(helpers)
30
+ end
31
+
32
+ def person
33
+ KeystoneUi::App.configuration.person(helpers)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module App
5
+ class AvatarComponent < ViewComponent::Base
6
+ CLASSES = "ks-app-avatar inline-flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-gray-200 text-sm font-semibold text-gray-700 dark:bg-gray-700 dark:text-gray-100"
7
+
8
+ def call
9
+ tag.span(picture || initial, class: CLASSES)
10
+ end
11
+
12
+ private
13
+
14
+ def person
15
+ KeystoneUi::App.configuration.person(helpers)
16
+ end
17
+
18
+ def picture
19
+ tag.img(src: person[:avatar_url], alt: person[:name].to_s, class: "size-full object-cover") if person[:avatar_url].present?
20
+ end
21
+
22
+ def initial
23
+ person[:name].to_s.strip[0].to_s.upcase
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,5 +1,5 @@
1
1
  <%= render Keystone::Ui::BottomNavComponent.new do %>
2
2
  <% tabs.each do |tab| %>
3
- <%= render Keystone::Ui::BottomNavItemComponent.new(label: tab.label, href: tab.href_for(helpers), icon: tab.icon_for(helpers), active: active?(tab)) %>
3
+ <%= render Keystone::Ui::BottomNavItemComponent.new(label: tab.label, href: tab.href, icon: tab.icon, active: active?(tab)) %>
4
4
  <% end %>
5
5
  <% end %>
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "uri"
4
4
  require "rack/utils"
5
+ require "keystone_ui/app/bottom_bar"
5
6
  require "keystone_ui/app/current_section"
6
7
 
7
8
  module KeystoneUi
@@ -12,21 +13,21 @@ module KeystoneUi
12
13
  end
13
14
 
14
15
  def tabs
15
- @tabs ||= KeystoneUi::App.configuration.bottom_tabs.select { |tab| tab.permitted.call(helpers) }
16
+ @tabs ||= KeystoneUi::App::BottomBar.new(helpers).tabs
16
17
  end
17
18
 
18
19
  def active?(tab)
19
- return in_section?(tab) if tab.section
20
+ return in_section?(tab) if tab.sections.any?
20
21
 
21
22
  tabs.none? { |other| in_section?(other) } && on_page?(tab)
22
23
  end
23
24
 
24
25
  def in_section?(tab)
25
- section.present? && tab.section == section.label
26
+ section.present? && tab.sections.include?(section.label)
26
27
  end
27
28
 
28
29
  def on_page?(tab)
29
- address = URI.parse(tab.href_for(helpers))
30
+ address = URI.parse(tab.href)
30
31
  address.path == request.path &&
31
32
  Rack::Utils.parse_query(address.query).all? { |name, value| request.query_parameters[name] == value }
32
33
  end
@@ -0,0 +1,7 @@
1
+ <% groups.each do |group| %>
2
+ <section>
3
+ <%= render Keystone::Ui::SectionComponent.new(title: group.label) do %>
4
+ <%= render Keystone::Ui::LinkListComponent.new(rows: group.rows) %>
5
+ <% end %>
6
+ </section>
7
+ <% end %>
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "keystone_ui/visible_navigation"
4
+
5
+ module KeystoneUi
6
+ module App
7
+ class MorePageComponent < ViewComponent::Base
8
+ Group = Struct.new(:label, :rows, keyword_init: true)
9
+
10
+ def groups
11
+ visible_groups.map do |group|
12
+ Group.new(label: (group.label unless group.standalone), rows: group.tabs.map { |page| { label: page.label, href: page.href } })
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def visible_groups
19
+ KeystoneUi::VisibleNavigation.new(
20
+ KeystoneUi.configuration.navigation_groups,
21
+ view: helpers,
22
+ saved_order: saved_order,
23
+ current_tab: nil
24
+ ).groups
25
+ end
26
+
27
+ def saved_order
28
+ value = KeystoneUi.configuration.supplied_preference(helpers, :navigation)&.dig(:value)
29
+ value.is_a?(Hash) && value["order"].is_a?(Array) ? value["order"] : []
30
+ end
31
+ end
32
+ end
33
+ end
@@ -8,7 +8,16 @@
8
8
  <%= tag.body(**body) do %>
9
9
  <header>
10
10
  <%= flash %>
11
- <%= tag.div(phone_menus, class: PHONE_ONLY) if phone_menus? && !native_app? %>
11
+ <% if phone_avatar_shown? %>
12
+ <%= tag.div(class: PHONE_AVATAR_ROW) do %>
13
+ <%= tag.div(phone_menus, class: "min-w-0 flex-1") %>
14
+ <%= link_to account_page.href_for(helpers), class: "shrink-0", aria: { label: "Account" } do %>
15
+ <%= render KeystoneUi::App::AvatarComponent.new %>
16
+ <% end %>
17
+ <% end %>
18
+ <% elsif phone_menus? && !native_app? %>
19
+ <%= tag.div(phone_menus, class: PHONE_ONLY) %>
20
+ <% end %>
12
21
  <% if phone_header_shown? %>
13
22
  <%= helpers.ui_mobile_header(title: page_signalled(:title), back_url: page_signalled(:back_url), subtitle: page_signalled(:subtitle)) %>
14
23
  <% elsif !native_app? %>
@@ -24,7 +33,11 @@
24
33
  <% else %>
25
34
  <%= helpers.ui_navigation do |navigation| %>
26
35
  <% navigation.with_logo { logo.to_s } if logo? %>
27
- <% navigation.with_menus { menus.to_s } if menus? %>
36
+ <% if account_menu? %>
37
+ <% navigation.with_menus { render KeystoneUi::App::AccountMenuComponent.new } %>
38
+ <% elsif menus? %>
39
+ <% navigation.with_menus { menus.to_s } %>
40
+ <% end %>
28
41
  <%= page %>
29
42
  <% end %>
30
43
  <% end %>
@@ -13,6 +13,7 @@ module KeystoneUi
13
13
  renders_one :footer
14
14
 
15
15
  PHONE_ONLY = "lg:hidden"
16
+ PHONE_AVATAR_ROW = "#{PHONE_ONLY} flex items-center gap-2 pr-4"
16
17
  PAGE_SIGNALS = %i[show_page form_page].freeze
17
18
  VIEWPORT = "width=device-width, initial-scale=1"
18
19
  NATIVE_APP_VIEWPORT = "#{VIEWPORT}, maximum-scale=1.0, user-scalable=0"
@@ -46,6 +47,18 @@ module KeystoneUi
46
47
  page_signal && !native_app?
47
48
  end
48
49
 
50
+ def account_page
51
+ KeystoneUi::App.configuration.account_page_link
52
+ end
53
+
54
+ def phone_avatar_shown?
55
+ account_page && !native_app?
56
+ end
57
+
58
+ def account_menu?
59
+ KeystoneUi::App.configuration.account_links.any?
60
+ end
61
+
49
62
  def footer_shown?
50
63
  !native_app? && !helpers.content_for?(:hide_footer)
51
64
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "keystone_ui/app/bottom_bar"
4
+
3
5
  module KeystoneUi
4
6
  module App
5
7
  class PathConfigurationsController < ::ApplicationController
@@ -21,11 +23,11 @@ module KeystoneUi
21
23
  private
22
24
 
23
25
  def ios_tabs
24
- permitted_tabs.map { |tab| { title: tab.label, path: tab.href_for(view), ios_system_image_name: tab.ios_system_image } }
26
+ bar_tabs.map { |tab| { title: tab.label, path: tab.href, ios_system_image_name: tab.ios_system_image } }
25
27
  end
26
28
 
27
29
  def android_tabs
28
- permitted_tabs.map { |tab| { title: tab.label, path: tab.href_for(view), icon: tab.android_icon } }
30
+ bar_tabs.map { |tab| { title: tab.label, path: tab.href, icon: tab.android_icon } }
29
31
  end
30
32
 
31
33
  def app_settings
@@ -36,8 +38,8 @@ module KeystoneUi
36
38
  KeystoneUi::App.configuration.native_rules
37
39
  end
38
40
 
39
- def permitted_tabs
40
- KeystoneUi::App.configuration.bottom_tabs.select { |tab| tab.permitted.call(view) }
41
+ def bar_tabs
42
+ @bar_tabs ||= KeystoneUi::App::BottomBar.new(view).tabs
41
43
  end
42
44
 
43
45
  def view
@@ -5,6 +5,14 @@ module KeystoneUiAppHelper
5
5
  render KeystoneUi::App::ShellComponent.new(body: body), &block
6
6
  end
7
7
 
8
+ def ui_more_page
9
+ render KeystoneUi::App::MorePageComponent.new
10
+ end
11
+
12
+ def ui_account_page
13
+ render KeystoneUi::App::AccountPageComponent.new
14
+ end
15
+
8
16
  def ui_native_form_data
9
17
  { controller: "bridge--form", action: "turbo:submit-start->bridge--form#submitStart turbo:submit-end->bridge--form#submitEnd" }
10
18
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "keystone_ui/navigation_order"
4
+ require "keystone_ui/app/shell_check"
5
+
6
+ module KeystoneUi
7
+ module App
8
+ class BottomBar
9
+ Tab = Struct.new(:label, :href, :icon, :ios_system_image, :android_icon, :sections, keyword_init: true)
10
+
11
+ MORE_LABEL = "More"
12
+ MOST_TABS = ShellCheck::MOST_BOTTOM_TABS
13
+
14
+ def initialize(view)
15
+ @view = view
16
+ end
17
+
18
+ def tabs
19
+ shown = fixed_tabs + area_tabs
20
+ full = shown.first(MOST_TABS)
21
+ more_tab_needed?(full) ? shown.first(MOST_TABS - 1) + [ more_tab ] : full
22
+ end
23
+
24
+ private
25
+
26
+ def fixed_tabs
27
+ configuration.bottom_tabs.select { |tab| tab.permitted.call(@view) }.map do |tab|
28
+ Tab.new(label: tab.label, href: tab.href_for(@view), icon: tab.icon_for(@view), ios_system_image: tab.ios_system_image, android_icon: tab.android_icon, sections: [ tab.section ].compact)
29
+ end
30
+ end
31
+
32
+ def area_tabs
33
+ shown = configuration.app_areas.select { |area| area.on_by_default && area_pages(area).any? }
34
+ in_person_order(shown).map { |area| area_tab(area) }
35
+ end
36
+
37
+ def area_tab(area)
38
+ Tab.new(label: area.name, href: opened_page(area).href_for(@view), icon: area.icon_for(@view), ios_system_image: area.ios_system_image, android_icon: area.android_icon, sections: area.groups)
39
+ end
40
+
41
+ def more_tab_needed?(shown)
42
+ configuration.more_page_tab && (permitted_groups.flat_map(&:last).map { |page| page.href_for(@view) } - shown.map(&:href)).any?
43
+ end
44
+
45
+ def more_tab
46
+ more = configuration.more_page_tab
47
+ Tab.new(label: MORE_LABEL, href: more.href_for(@view), icon: more.icon_for(@view), ios_system_image: more.ios_system_image, android_icon: more.android_icon, sections: [])
48
+ end
49
+
50
+ def opened_page(area)
51
+ pages = area_pages(area)
52
+ pages.find { |page| page.key == area.opens } || pages.first
53
+ end
54
+
55
+ def in_person_order(areas)
56
+ return areas if saved_order.empty?
57
+
58
+ labels = arranged_groups.map { |group, _pages| group.label }
59
+ areas.each_with_index
60
+ .sort_by { |area, declared| [ area.groups.filter_map { |label| labels.index(label) }.min, declared ] }
61
+ .map(&:first)
62
+ end
63
+
64
+ def area_pages(area)
65
+ arranged_groups.select { |group, _pages| area.groups.include?(group.label) }.flat_map(&:last)
66
+ end
67
+
68
+ def arranged_groups
69
+ @arranged_groups ||= KeystoneUi::NavigationOrder.new(saved_order).arrange(permitted_groups)
70
+ end
71
+
72
+ def permitted_groups
73
+ @permitted_groups ||= KeystoneUi.configuration.navigation_groups
74
+ .map { |group| [ group, group.tabs_permitted_for(@view) ] }
75
+ .reject { |_group, pages| pages.empty? }
76
+ end
77
+
78
+ def saved_order
79
+ @saved_order ||= begin
80
+ value = KeystoneUi.configuration.supplied_preference(@view, :navigation)&.dig(:value)
81
+ value.is_a?(Hash) && value["order"].is_a?(Array) ? value["order"] : []
82
+ end
83
+ end
84
+
85
+ def configuration
86
+ KeystoneUi::App.configuration
87
+ end
88
+ end
89
+ end
90
+ end
@@ -13,10 +13,52 @@ module KeystoneUi
13
13
  end
14
14
  end
15
15
 
16
- attr_reader :bottom_tabs, :native_rules, :native_settings
16
+ AppArea = Struct.new(:name, :description, :groups, :on_by_default, :opens, :icon, :ios_system_image, :android_icon, keyword_init: true) do
17
+ def icon_for(view)
18
+ icon.respond_to?(:call) ? icon.call(view) : icon
19
+ end
20
+ end
21
+
22
+ MorePage = Struct.new(:href, :icon, :ios_system_image, :android_icon, keyword_init: true) do
23
+ def href_for(view)
24
+ href.respond_to?(:call) ? href.call(view) : href
25
+ end
26
+
27
+ def icon_for(view)
28
+ icon.respond_to?(:call) ? icon.call(view) : icon
29
+ end
30
+ end
31
+
32
+ AccountLink = Struct.new(:label, :href, :permitted, :sign_out, keyword_init: true) do
33
+ def href_for(view)
34
+ href.respond_to?(:call) ? href.call(view) : href
35
+ end
36
+ end
37
+
38
+ AccountPage = Struct.new(:href, keyword_init: true) do
39
+ def href_for(view)
40
+ href.respond_to?(:call) ? href.call(view) : href
41
+ end
42
+ end
43
+
44
+ Notifications = Struct.new(:href, :unread_count, keyword_init: true) do
45
+ def href_for(view)
46
+ href.respond_to?(:call) ? href.call(view) : href
47
+ end
48
+
49
+ def unread_count_for(view)
50
+ unread_count.respond_to?(:call) ? unread_count.call(view) : unread_count
51
+ end
52
+ end
53
+
54
+ attr_accessor :person_supplier, :accounts_supplier
55
+
56
+ attr_reader :bottom_tabs, :app_areas, :more_page_tab, :account_links, :account_page_link, :notifications_link, :native_rules, :native_settings
17
57
 
18
58
  def initialize
19
59
  @bottom_tabs = []
60
+ @app_areas = []
61
+ @account_links = []
20
62
  @native_rules = []
21
63
  @native_settings = {}
22
64
  end
@@ -25,6 +67,38 @@ module KeystoneUi
25
67
  @bottom_tabs << BottomTab.new(label: label, href: href, icon: icon, permitted: permitted, section: section, ios_system_image: ios_system_image, android_icon: android_icon)
26
68
  end
27
69
 
70
+ def app_area(name:, description:, groups:, on_by_default:, icon:, ios_system_image:, android_icon:, opens: nil)
71
+ @app_areas << AppArea.new(name: name, description: description, groups: groups, on_by_default: on_by_default, opens: opens, icon: icon, ios_system_image: ios_system_image, android_icon: android_icon)
72
+ end
73
+
74
+ def more_page(href:, icon:, ios_system_image:, android_icon:)
75
+ @more_page_tab = MorePage.new(href: href, icon: icon, ios_system_image: ios_system_image, android_icon: android_icon)
76
+ end
77
+
78
+ def account_link(label:, href:, permitted:, sign_out: false)
79
+ @account_links << AccountLink.new(label: label, href: href, permitted: permitted, sign_out: sign_out)
80
+ end
81
+
82
+ def visible_account_links(view)
83
+ account_links.select { |link| link.permitted.call(view) }
84
+ end
85
+
86
+ def account_page(href:)
87
+ @account_page_link = AccountPage.new(href: href)
88
+ end
89
+
90
+ def notifications(href:, unread_count:)
91
+ @notifications_link = Notifications.new(href: href, unread_count: unread_count)
92
+ end
93
+
94
+ def person(view)
95
+ person_supplier ? person_supplier.call(view) : {}
96
+ end
97
+
98
+ def accounts(view)
99
+ accounts_supplier ? accounts_supplier.call(view) : []
100
+ end
101
+
28
102
  def native_rule(patterns:, properties:)
29
103
  @native_rules << { patterns: patterns, properties: properties }
30
104
  end
@@ -11,7 +11,9 @@ module KeystoneUi
11
11
  KeystoneUi::App::ShellCheck.new(
12
12
  bottom_tabs: configuration.bottom_tabs,
13
13
  native_rules: configuration.native_rules,
14
- sections: KeystoneUi.configuration.navigation_groups.map(&:label)
14
+ sections: KeystoneUi.configuration.navigation_groups.map(&:label),
15
+ app_areas: configuration.app_areas,
16
+ more_page: configuration.more_page_tab
15
17
  ).call
16
18
  end
17
19
 
@@ -7,8 +7,10 @@ module KeystoneUi
7
7
 
8
8
  MOST_BOTTOM_TABS = 5
9
9
 
10
- def initialize(bottom_tabs:, native_rules:, sections:)
10
+ def initialize(bottom_tabs:, native_rules:, sections:, app_areas: [], more_page: nil)
11
11
  @bottom_tabs = bottom_tabs
12
+ @more_page = more_page
13
+ @app_areas = app_areas
12
14
  @native_rules = native_rules
13
15
  @sections = sections
14
16
  end
@@ -24,6 +26,9 @@ module KeystoneUi
24
26
  end
25
27
  raise Error, "More than one bottom tab has the label #{shared_label}." if shared_label
26
28
  raise Error, "#{@bottom_tabs.size} bottom tabs are declared, and no more than #{MOST_BOTTOM_TABS} are allowed." if @bottom_tabs.size > MOST_BOTTOM_TABS
29
+ raise Error, "#{@bottom_tabs.size} bottom tabs are declared with a More page, and no more than #{MOST_BOTTOM_TABS - 1} are allowed." if @more_page && @bottom_tabs.size > MOST_BOTTOM_TABS - 1
30
+ @app_areas.each { |area| check_area(area) }
31
+ raise Error, "More than one area covers the #{shared_group} group." if shared_group
27
32
  @native_rules.each.with_index(1) do |rule, position|
28
33
  raise Error, "Screen rule #{position} has no pattern." if Array(rule[:patterns]).all? { |pattern| blank?(pattern) }
29
34
  end
@@ -32,6 +37,18 @@ module KeystoneUi
32
37
 
33
38
  private
34
39
 
40
+ def check_area(area)
41
+ raise Error, "The #{area.name} area's tab has no icon." if blank?(area.icon)
42
+ raise Error, "The #{area.name} area's tab has no iOS image." if blank?(area.ios_system_image)
43
+ raise Error, "The #{area.name} area's tab has no Android icon." if blank?(area.android_icon)
44
+ undeclared = area.groups.find { |group| !@sections.include?(group) }
45
+ raise Error, "The #{area.name} area names the #{undeclared} group, which is not a declared navigation group." if undeclared
46
+ end
47
+
48
+ def shared_group
49
+ @app_areas.flat_map { |area| area.groups.uniq }.tally.find { |_group, count| count > 1 }&.first
50
+ end
51
+
35
52
  def shared_label
36
53
  @bottom_tabs.map(&:label).tally.find { |_label, count| count > 1 }&.first
37
54
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module KeystoneUi
4
4
  module App
5
- VERSION = "0.3.2"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keystone_ui-app
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tyler Schneider
@@ -80,8 +80,15 @@ files:
80
80
  - app/assets/javascripts/keystone_ui_app/hotwire_native_bridge.js
81
81
  - app/assets/javascripts/keystone_ui_app/index.js
82
82
  - app/assets/javascripts/keystone_ui_app/menu_button_controller.js
83
+ - app/components/keystone_ui/app/account_menu_component.html.erb
84
+ - app/components/keystone_ui/app/account_menu_component.rb
85
+ - app/components/keystone_ui/app/account_page_component.html.erb
86
+ - app/components/keystone_ui/app/account_page_component.rb
87
+ - app/components/keystone_ui/app/avatar_component.rb
83
88
  - app/components/keystone_ui/app/bottom_tabs_component.html.erb
84
89
  - app/components/keystone_ui/app/bottom_tabs_component.rb
90
+ - app/components/keystone_ui/app/more_page_component.html.erb
91
+ - app/components/keystone_ui/app/more_page_component.rb
85
92
  - app/components/keystone_ui/app/native_add_button_component.rb
86
93
  - app/components/keystone_ui/app/native_menu_button_component.rb
87
94
  - app/components/keystone_ui/app/page_actions_component.html.erb
@@ -96,6 +103,7 @@ files:
96
103
  - config/routes.rb
97
104
  - lib/keystone_ui-app.rb
98
105
  - lib/keystone_ui/app.rb
106
+ - lib/keystone_ui/app/bottom_bar.rb
99
107
  - lib/keystone_ui/app/configuration.rb
100
108
  - lib/keystone_ui/app/current_section.rb
101
109
  - lib/keystone_ui/app/engine.rb