sdr_view_components 0.4.2 → 0.6.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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +86 -0
  3. data/app/assets/sdr_view_components.css +84 -0
  4. data/app/components/base_component.rb +14 -0
  5. data/app/components/sdr_view_components/elements/heading_component.rb +3 -2
  6. data/app/components/sdr_view_components/elements/tabs/tab_list_component.html.erb +16 -4
  7. data/app/components/sdr_view_components/elements/tabs/tab_list_component.rb +29 -2
  8. data/app/components/sdr_view_components/forms/label_component.rb +4 -5
  9. data/app/components/sdr_view_components/tab_form/hidden_fields_form_component.html.erb +5 -0
  10. data/app/components/sdr_view_components/tab_form/hidden_fields_form_component.rb +23 -0
  11. data/app/components/sdr_view_components/tab_form/pane_component.html.erb +16 -0
  12. data/app/components/sdr_view_components/tab_form/pane_component.rb +53 -0
  13. data/app/components/sdr_view_components/tab_form/tab_component.html.erb +14 -0
  14. data/app/components/sdr_view_components/tab_form/tab_component.rb +51 -0
  15. data/app/components/sdr_view_components/tab_form/tab_list_component.html.erb +20 -0
  16. data/app/components/sdr_view_components/tab_form/tab_list_component.rb +46 -0
  17. data/app/components/sdr_view_components/tab_form/tabbed_form_builder.rb +41 -0
  18. data/app/javascript/sdr_view_components/tab_error_controller.js +81 -0
  19. data/app/javascript/sdr_view_components/tab_link_controller.js +13 -0
  20. data/app/javascript/sdr_view_components/tab_nav_controller.js +24 -0
  21. data/app/javascript/sdr_view_components/tab_select_controller.js +22 -0
  22. data/lib/sdr_view_components/version.rb +1 -1
  23. data/spec/components/previews/sdr_view_components/elements/button_link_component_preview.rb +6 -0
  24. data/spec/components/previews/sdr_view_components/elements/tabs/tab_list_component_preview/collapsible.html.erb +14 -0
  25. data/spec/components/previews/sdr_view_components/elements/tabs/tab_list_component_preview.rb +2 -0
  26. data/spec/components/previews/sdr_view_components/forms/label_component_preview/mark_required.html.erb +8 -0
  27. data/spec/components/previews/sdr_view_components/forms/label_component_preview.rb +2 -0
  28. data/spec/components/previews/sdr_view_components/tab_form/tab_list_component_preview/default.html.erb +25 -0
  29. data/spec/components/previews/sdr_view_components/tab_form/tab_list_component_preview.rb +17 -0
  30. metadata +18 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8de1e6c4339890803922546b84f23b95b8226034289306bcd9afc7f5a12680b
4
- data.tar.gz: 1ba1793859c3b03bb41128682e17c6e4feec4de1509810a2b15773042e0effbf
3
+ metadata.gz: e251b4177b159e8978b41116e020d3d58d5f95071c6dbccd9b212d3751c9a118
4
+ data.tar.gz: be45e43ececfc02ed410b67844418c80371a28a1b89596a56630f3d81ce2df22
5
5
  SHA512:
6
- metadata.gz: b70f694252831c5ce2b9e55e84f0b3aa53447954dea2fb4a29b10efd495a47944f1a27036cbbf82b69746d817234a9ea81b967b344f4e216a5909056bae00e5d
7
- data.tar.gz: d53b27ec2eb61c0ff85b9dae7fda11024c748ee70563b16e07a230720ab7f94f6644d51b82376a63072480d996390e27e46e316e735cfc9e5d59a06a7c652274
6
+ metadata.gz: 638147ae953c62036f9553375bbac77530a7b6dfdc4dcd9243954b4bf702250a70a5953fb9b4444f2d1c5c249f45a797816a0def51f323c57d6fad91a57d566c
7
+ data.tar.gz: c7a3458363dfe0bc1000fb4b24ce6415fece60c20f3d06722c5f626204eff3582badefe1a9ab0696000aa949777484dbd3c6a50938762c1e600a8782d08907c8
data/README.md CHANGED
@@ -46,6 +46,38 @@ import ToastController from "sdr_view_components/toast_controller"
46
46
  application.register("sdr-toast", ToastController)
47
47
  ```
48
48
 
49
+ `SdrViewComponents::TabForm::TabListComponent` (see [Tab form components](#tab-form-components) below) similarly ships three controllers: `sdr_view_components/tab_error_controller`, `sdr_view_components/tab_link_controller`, and `sdr_view_components/tab_nav_controller`. Pin and register each the same way, using the `sdr-tab-error`, `sdr-tab-link`, and `sdr-tab-nav` identifiers respectively:
50
+
51
+ ```ruby
52
+ pin "sdr_view_components/tab_error_controller", to: "sdr_view_components/tab_error_controller.js"
53
+ pin "sdr_view_components/tab_link_controller", to: "sdr_view_components/tab_link_controller.js"
54
+ pin "sdr_view_components/tab_nav_controller", to: "sdr_view_components/tab_nav_controller.js"
55
+ ```
56
+
57
+ ```javascript
58
+ import { application } from "controllers/application"
59
+ import TabErrorController from "sdr_view_components/tab_error_controller"
60
+ import TabLinkController from "sdr_view_components/tab_link_controller"
61
+ import TabNavController from "sdr_view_components/tab_nav_controller"
62
+
63
+ application.register("sdr-tab-error", TabErrorController)
64
+ application.register("sdr-tab-link", TabLinkController)
65
+ application.register("sdr-tab-nav", TabNavController)
66
+ ```
67
+
68
+ `SdrViewComponents::Elements::Tabs::TabListComponent`'s `collapse_below` option (see [General usage](#general-usage) below) ships `sdr_view_components/tab_select_controller`, registered as `sdr-tab-select`:
69
+
70
+ ```ruby
71
+ pin "sdr_view_components/tab_select_controller", to: "sdr_view_components/tab_select_controller.js"
72
+ ```
73
+
74
+ ```javascript
75
+ import { application } from "controllers/application"
76
+ import TabSelectController from "sdr_view_components/tab_select_controller"
77
+
78
+ application.register("sdr-tab-select", TabSelectController)
79
+ ```
80
+
49
81
  ## Usage
50
82
 
51
83
  ### Form components
@@ -93,8 +125,62 @@ Each of the supported components above uses the provided "basic" components:
93
125
 
94
126
  At a minimum, each of these components must be provided wih the `form:` and `field_name:` parameters. Additionally, you can provide additional parameters that will be passed to the standard ActionView::Helpers::Tag.
95
127
 
128
+ ### Tab form components
129
+
130
+ SdrViewComponents provides components for rendering a tabbed form, where a form's fields are split across tabs rather than shown all at once:
131
+
132
+ - `SdrViewComponents::TabForm::TabListComponent` -- renders the tab navigation and tab panes. It doesn't render a `<form>` tag itself.
133
+ - `SdrViewComponents::TabForm::TabComponent` -- a single tab, rendered via `TabListComponent#with_tab`.
134
+ - `SdrViewComponents::TabForm::HiddenFieldsFormComponent` -- renders the one real `<form>` tag backing the tabbed form. It only contains hidden fields; the visible fields (rendered inside `TabListComponent`'s panes) associate with this form via the HTML `form` attribute rather than DOM nesting, since panes aren't necessarily inside the `<form>`.
135
+ - `SdrViewComponents::TabForm::TabbedFormBuilder` -- a `FormBuilder` that automatically sets the `form` attribute (pointing at the `HiddenFieldsFormComponent`'s form id) on every field it builds.
136
+ - `SdrViewComponents::TabForm::PaneComponent` -- generic tab-pane chrome (optional header/help/footer, Bootstrap `tab-pane` classes, and the target wiring the `sdr-tab-error` controller needs). Wrap it in an app-specific component if you need pane-level buttons or layout beyond what it provides.
137
+
138
+ Panes passed to `TabListComponent#with_pane` can be any renderable object (a `ViewComponent`, a block, etc.) -- the caller supplies its own pane component, whether that's `PaneComponent` directly or a wrapper around it. If the pane responds to `active_tab_name=`, `TabListComponent` sets it centrally, so panes don't need `active_tab_name` passed to them individually.
139
+
140
+ `TabListComponent` requires JavaScript to mark tabs containing invalid fields -- see the `sdr-tab-error` controller under [JavaScript](#javascript) above.
141
+
142
+ A minimal example:
143
+
144
+ ```erb
145
+ <%= render SdrViewComponents::TabForm::HiddenFieldsFormComponent.new(model: @work_form, id: 'tabbed_form', hidden_fields: %i[lock version]) %>
146
+
147
+ <%= render SdrViewComponents::TabForm::TabListComponent.new(id: 'tabbed_form', active_tab_name: :details) do |tab_list| %>
148
+ <% tab_list.with_tab(label: 'Details', tab_name: :details, mark_required: true) %>
149
+ <% tab_list.with_tab(label: 'Notes', tab_name: :notes) %>
150
+
151
+ <% form_with(model: @work_form, html: { id: 'tabbed_form' }, builder: SdrViewComponents::TabForm::TabbedFormBuilder) do |form| %>
152
+ <% tab_list.with_pane(SdrViewComponents::TabForm::PaneComponent.new(tab_name: :details, label: 'Details')) do %>
153
+ <%= render SdrViewComponents::Forms::TextFieldComponent.new(form:, field_name: :title) %>
154
+ <% end %>
155
+
156
+ <% tab_list.with_pane(SdrViewComponents::TabForm::PaneComponent.new(tab_name: :notes, label: 'Notes')) do %>
157
+ <%= render SdrViewComponents::Forms::TextAreaComponent.new(form:, field_name: :notes) %>
158
+ <% end %>
159
+ <% end %>
160
+ <% end %>
161
+ ```
162
+
163
+ See the Lookbook preview for `SdrViewComponents::TabForm::TabListComponent` for a runnable example.
164
+
96
165
  ### General usage:
97
166
 
167
+ `SdrViewComponents::Elements::Tabs::TabListComponent` accepts a `collapse_below:` option (one of `:sm`, `:md`, `:lg`, `:xl`, `:xxl`). When given, the tabs are replaced with a `<select>` below that Bootstrap breakpoint instead of wrapping or scrolling -- the `<select>` drives the same Bootstrap tab JavaScript as the tabs, so panes switch the same way regardless of which control is visible. Leave it unset (the default) to always render the tabs. This requires the `sdr-tab-select` controller -- see [JavaScript](#javascript) above.
168
+
169
+ ```erb
170
+ <%= render SdrViewComponents::Elements::Tabs::TabListComponent.new(variant: :underline, collapse_below: :xl) do |tab_list| %>
171
+ <% tab_list.with_tab(label: 'Details', id: 'details-tab', pane_id: 'details-pane', active: true) %>
172
+ <% tab_list.with_tab(label: 'History', id: 'history-tab', pane_id: 'history-pane') %>
173
+
174
+ <% tab_list.with_pane(id: 'details-pane', tab_id: 'details-tab', active: true) do %>
175
+ <p>Details pane content.</p>
176
+ <% end %>
177
+
178
+ <% tab_list.with_pane(id: 'history-pane', tab_id: 'history-tab') do %>
179
+ <p>History pane content.</p>
180
+ <% end %>
181
+ <% end %>
182
+ ```
183
+
98
184
  ```
99
185
  <% render SdrViewComponent::....>
100
186
  ```
@@ -76,6 +76,90 @@
76
76
  font-weight: bold;
77
77
  }
78
78
 
79
+ /* Tab form */
80
+ .tab-form .nav-pills {
81
+ background-color: var(--stanford-10-black);
82
+ }
83
+
84
+ .tab-form .nav-pills .nav-link {
85
+ --bs-nav-link-padding-x: 0.5rem;
86
+
87
+ border-radius: unset;
88
+ display: block flex;
89
+ text-align: left;
90
+ }
91
+
92
+ .tab-form .nav-pills .nav-link:not(:first-child) {
93
+ border-top: var(--bs-border-width) var(--bs-border-style) rgb(var(--bs-white-rgb), 1);
94
+ }
95
+
96
+ .tab-form .nav-pills .nav-link:first-child {
97
+ border-top-left-radius: var(--bs-border-radius);
98
+ border-top-right-radius: var(--bs-border-radius);
99
+ }
100
+
101
+ .tab-form .nav-pills .nav-link:last-child {
102
+ border-bottom-left-radius: var(--bs-border-radius);
103
+ border-bottom-right-radius: var(--bs-border-radius);
104
+ }
105
+
106
+ .tab-form .nav-pills .nav-link:not(.active) {
107
+ color: var(--stanford-black);
108
+ padding-left: 1.7rem; /* Align the text of inactive nav items with the active one, roughly */
109
+ }
110
+
111
+ .tab-form .nav-pills .nav-link:not(.active):hover {
112
+ text-decoration: underline;
113
+ }
114
+
115
+ .tab-form .nav-pills .nav-link.active {
116
+ background-color: var(--stanford-black);
117
+ color: var(--bs-white);
118
+ font-weight: 700;
119
+ }
120
+
121
+ .tab-form .nav-pills .nav-link.active .required {
122
+ color: var(--bs-white);
123
+ }
124
+
125
+ .tab-form .nav-pills .nav-link.active::before {
126
+ display: inline-block;
127
+ margin-right: 2px;
128
+ font-family: bootstrap-icons;
129
+ vertical-align: -15%;
130
+ content: "\F138"; /* right arrow */
131
+ }
132
+
133
+ .tab-form .nav-pills .nav-link.is-invalid {
134
+ color: var(--bs-danger-text-emphasis);
135
+ }
136
+
137
+ .tab-form .nav-pills .nav-link.is-invalid.active {
138
+ background-color: white;
139
+ border: 2px solid var(--stanford-black);
140
+ }
141
+
142
+ .tab-form .nav-pills .nav-link.is-invalid::after {
143
+ margin-left: 10px;
144
+ display: inline-block;
145
+ font-family: bootstrap-icons;
146
+ font-weight: normal;
147
+ vertical-align: -15%;
148
+ content: "\F33A"; /* exclamation triangle fill */
149
+ }
150
+
151
+ .tab-form .pane-header {
152
+ padding-bottom: 40px;
153
+ }
154
+
155
+ .tab-form .pane-section {
156
+ padding-bottom: 40px;
157
+ }
158
+
159
+ .tab-form .pane-section:last-child {
160
+ padding-bottom: 80px;
161
+ }
162
+
79
163
  /* Tables */
80
164
  table.table-h3 caption {
81
165
  padding: .25rem;
@@ -28,4 +28,18 @@ class BaseComponent < ViewComponent::Base
28
28
  h[key.to_s.delete_prefix(prefix).to_sym] = value if key.to_s.start_with?(prefix)
29
29
  end
30
30
  end
31
+
32
+ # Append a required-field marker to a label.
33
+ #
34
+ # @param label [String] The label text.
35
+ # @param mark_required [Boolean] Whether to append the marker.
36
+ # @param hidden_label [String] The screen-reader-only text describing why the marker is present.
37
+ # @return [String] The label, marked as required if requested.
38
+ def mark_label_required(label:, mark_required: false, hidden_label: 'required')
39
+ label.to_s.dup.tap do |label_text|
40
+ if mark_required
41
+ label_text << "&nbsp;<span class=\"required\">*</span><span class=\"visually-hidden\"> (#{hidden_label})</span>".html_safe # rubocop:disable Rails/OutputSafety, Layout/LineLength
42
+ end
43
+ end.html_safe # rubocop:disable Rails/OutputSafety
44
+ end
31
45
  end
@@ -4,13 +4,14 @@ module SdrViewComponents
4
4
  module Elements
5
5
  # Applies an h# component with the expected styling.
6
6
  class HeadingComponent < BaseComponent
7
- def initialize(level:, text: nil, variant: nil, classes: [])
7
+ def initialize(level:, text: nil, variant: nil, classes: [], **options)
8
8
  raise ArgumentError, 'Invalid level' unless %i[h1 h2 h3 h4 h5 h6].include?(level.to_sym)
9
9
 
10
10
  @level = level
11
11
  @variant = variant
12
12
  @classes = classes
13
13
  @text = text # Provide text or content
14
+ @options = options
14
15
  super()
15
16
  end
16
17
 
@@ -22,7 +23,7 @@ module SdrViewComponents
22
23
  def call
23
24
  return unless tag.respond_to?(@level)
24
25
 
25
- tag.public_send(@level, class: classes) do
26
+ tag.public_send(@level, class: classes, **@options) do
26
27
  @text || content
27
28
  end
28
29
  end
@@ -1,9 +1,21 @@
1
- <%= tag.ul class: classes, role: 'tablist' do %>
2
- <% tabs.each do |tab| %><%= tab %><% end %>
1
+ <% if collapse_below? %>
2
+ <div data-controller="sdr-tab-select" data-action="shown.bs.tab->sdr-tab-select#sync">
3
+ <%= tag.select class: select_classes, 'aria-label': 'Select a tab', data: { sdr_tab_select_target: 'select', action: 'sdr-tab-select#change' } do %>
4
+ <% tabs.each do |tab| %><%= tag.option(tab.label, value: tab.id, selected: tab.active?) %><% end %>
5
+ <% end %>
6
+
7
+ <%= tag.ul class: classes, role: 'tablist' do %>
8
+ <% tabs.each do |tab| %><%= tab %><% end %>
9
+ <% end %>
10
+ </div>
11
+ <% else %>
12
+ <%= tag.ul class: classes, role: 'tablist' do %>
13
+ <% tabs.each do |tab| %><%= tab %><% end %>
14
+ <% end %>
3
15
  <% end %>
4
16
 
5
17
  <%= header %>
6
18
 
7
- <div class="tab-content">
19
+ <%= tag.div class: content_classes do %>
8
20
  <% panes.each do |pane| %><%= pane %><% end %>
9
- </div>
21
+ <% end %>
@@ -5,21 +5,48 @@ module SdrViewComponents
5
5
  module Tabs
6
6
  # Component for rendering a list of tabs in a tabbed interface.
7
7
  class TabListComponent < BaseComponent
8
+ BREAKPOINTS = %i[sm md lg xl xxl].freeze
9
+
8
10
  renders_one :header # optional
9
11
  renders_many :tabs, Elements::Tabs::TabComponent
10
12
  renders_many :panes, Elements::Tabs::PaneComponent
11
13
 
12
- def initialize(classes: [], variant: :default)
14
+ # @param classes [Array<String>, String] additional classes for the tab list.
15
+ # @param content_classes [Array<String>, String] additional classes for the tab content container.
16
+ # @param variant [Symbol] the visual style of the tabs (:default or :underline).
17
+ # @param collapse_below [Symbol, nil] if given, the tabs are replaced with a `<select>`
18
+ # below this Bootstrap breakpoint (one of :sm, :md, :lg, :xl, :xxl). The tabs remain
19
+ # fully functional (the `<select>` drives the same Bootstrap tab JavaScript) -- this
20
+ # only changes how they're presented at narrow viewports. Leave nil (the default) to
21
+ # always render the tabs.
22
+ def initialize(classes: [], content_classes: [], variant: :default, collapse_below: nil)
13
23
  @classes = classes
24
+ @content_classes = content_classes
14
25
  @variant = variant
26
+ @collapse_below = collapse_below
15
27
 
16
28
  raise ArgumentError, "Invalid variant: #{variant}" unless %i[underline default].include?(variant)
29
+ if collapse_below && BREAKPOINTS.exclude?(collapse_below)
30
+ raise ArgumentError, "Invalid collapse_below: #{collapse_below}"
31
+ end
17
32
 
18
33
  super()
19
34
  end
20
35
 
36
+ def collapse_below?
37
+ @collapse_below.present?
38
+ end
39
+
21
40
  def classes
22
- merge_classes('nav', @classes, variant_classes)
41
+ merge_classes('nav', @classes, variant_classes, collapse_below? ? ['d-none', "d-#{@collapse_below}-flex"] : nil)
42
+ end
43
+
44
+ def select_classes
45
+ merge_classes('form-select', "d-#{@collapse_below}-none", @classes)
46
+ end
47
+
48
+ def content_classes
49
+ merge_classes('tab-content', @content_classes)
23
50
  end
24
51
 
25
52
  private
@@ -5,7 +5,7 @@ module SdrViewComponents
5
5
  # Component for rendering a form label.
6
6
  class LabelComponent < BaseComponent
7
7
  def initialize(form:, field_name:, text: nil, default_class: 'form-label', hidden: false, # rubocop:disable Metrics/ParameterLists
8
- classes: [], tooltip: nil, caption: nil)
8
+ classes: [], tooltip: nil, caption: nil, mark_required: false)
9
9
  @form = form
10
10
  @text = text
11
11
  @field_name = field_name
@@ -14,15 +14,14 @@ module SdrViewComponents
14
14
  @classes = classes
15
15
  @tooltip = tooltip
16
16
  @caption = caption
17
+ @mark_required = mark_required
17
18
  super()
18
19
  end
19
20
 
20
- attr_reader :field_name, :form, :tooltip, :caption
21
+ attr_reader :field_name, :form, :tooltip, :caption, :mark_required
21
22
 
22
23
  def text
23
- return field_name if @text.blank?
24
-
25
- @text
24
+ mark_label_required(label: @text.presence || field_name, mark_required:)
26
25
  end
27
26
 
28
27
  def classes
@@ -0,0 +1,5 @@
1
+ <%= form_with(model:, id:, url:, html: { novalidate: true }) do |form| %>
2
+ <% hidden_fields.each do |field| %>
3
+ <%= form.hidden_field field %>
4
+ <% end %>
5
+ <% end %>
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SdrViewComponents
4
+ module TabForm
5
+ # Renders the one real <form> tag backing a TabListComponent-based tabbed form.
6
+ # Contains only hidden fields -- the tabbed form's visible fields associate with this
7
+ # form via the `form` HTML attribute (see TabbedFormBuilder), not DOM nesting.
8
+ #
9
+ # `url:` is optional and only needed when the model can't derive its own submission url
10
+ # (e.g. it has no resourceful routes).
11
+ class HiddenFieldsFormComponent < BaseComponent
12
+ def initialize(model:, id:, hidden_fields: [], url: nil)
13
+ @model = model
14
+ @id = id
15
+ @hidden_fields = hidden_fields
16
+ @url = url
17
+ super()
18
+ end
19
+
20
+ attr_reader :model, :id, :hidden_fields, :url
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,16 @@
1
+ <%= tag.div(id:, class: classes, role: 'tabpanel', 'aria-labelledby': tab_id, tabindex: '0', data: { sdr_tab_error_target: 'pane' }) do %>
2
+ <div class="d-flex flex-column justify-content-between h-100">
3
+ <div>
4
+ <% if label || help_text || help? %>
5
+ <%= tag.div(class: pane_header_class) do %>
6
+ <% if label %><h2 class="tooltip-header"><%= label %></h2><% end %>
7
+ <%= render SdrViewComponents::Elements::TooltipComponent.new(target_label: label, tooltip:) %>
8
+ <% if help_text %><p class="mb-0"><%= help_text || help %></p><% end %>
9
+ <%= help if help? %>
10
+ <% end %>
11
+ <% end %>
12
+ <%= content %>
13
+ </div>
14
+ <%= footer %>
15
+ </div>
16
+ <% end %>
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SdrViewComponents
4
+ module TabForm
5
+ # Component for a tab pane in a SdrViewComponents::TabForm::TabListComponent.
6
+ # Based on https://getbootstrap.com/docs/5.3/components/navs-tabs/#javascript-behavior
7
+ class PaneComponent < BaseComponent
8
+ renders_one :footer
9
+ renders_one :help
10
+
11
+ attr_accessor :active_tab_name
12
+
13
+ def initialize(tab_name:, label: nil, help_text: nil, tooltip: nil, pane_header_class: 'pane-header', # rubocop:disable Metrics/ParameterLists
14
+ mark_required: false)
15
+ @tab_name = tab_name
16
+ @label = label
17
+ @help_text = help_text
18
+ @tooltip = tooltip
19
+ @pane_header_class = pane_header_class
20
+ @mark_required = mark_required
21
+ super()
22
+ end
23
+
24
+ attr_reader :tab_name, :help_text, :tooltip, :pane_header_class
25
+
26
+ def selected?
27
+ tab_name == active_tab_name
28
+ end
29
+
30
+ def mark_required?
31
+ @mark_required
32
+ end
33
+
34
+ def label
35
+ return unless @label
36
+
37
+ mark_label_required(label: @label, mark_required: mark_required?)
38
+ end
39
+
40
+ def classes
41
+ merge_classes(%w[tab-pane fade h-100], selected? ? 'show active' : nil)
42
+ end
43
+
44
+ def id
45
+ "#{tab_name}-pane"
46
+ end
47
+
48
+ def tab_id
49
+ "#{tab_name}-tab"
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,14 @@
1
+ <%= tag.button(
2
+ class: classes,
3
+ id:,
4
+ data:,
5
+ type: 'button',
6
+ 'aria-controls': pane_id,
7
+ 'aria-selected': selected?,
8
+ 'aria-labelledby': id,
9
+ 'aria-role': 'tab',
10
+ tabindex: '0',
11
+ role: 'tab'
12
+ ) do %>
13
+ <%= tag.span(label) %>
14
+ <% end %>
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SdrViewComponents
4
+ module TabForm
5
+ # Component for a tab in a SdrViewComponents::TabForm::TabListComponent.
6
+ class TabComponent < BaseComponent
7
+ attr_accessor :active_tab_name
8
+
9
+ def initialize(label:, tab_name:, mark_required: false, data: {})
10
+ @label = label
11
+ @tab_name = tab_name
12
+ @mark_required = mark_required
13
+ @data = data
14
+ super()
15
+ end
16
+
17
+ attr_reader :tab_name
18
+
19
+ def selected?
20
+ tab_name == active_tab_name
21
+ end
22
+
23
+ def classes
24
+ merge_classes('nav-link w-100', selected? ? 'active' : nil)
25
+ end
26
+
27
+ def id
28
+ "#{tab_name}-tab"
29
+ end
30
+
31
+ def pane_id
32
+ "#{tab_name}-pane"
33
+ end
34
+
35
+ def mark_required?
36
+ @mark_required
37
+ end
38
+
39
+ def label
40
+ mark_label_required(label: @label, mark_required: mark_required?, hidden_label: 'contains required fields')
41
+ end
42
+
43
+ # Callers can pass their own `data:` (e.g. an `action:` to wire up other Stimulus controllers);
44
+ # it's merged with the data this component needs to function as a Bootstrap tab wired to
45
+ # TabListComponent's sdr-tab-error controller.
46
+ def data
47
+ { bs_toggle: 'tab', bs_target: "##{pane_id}", sdr_tab_error_target: 'tab' }.merge(@data)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,20 @@
1
+ <%= tag.div class: classes, data: do %>
2
+ <div class="col-md-3">
3
+ <%= tag.div class: 'nav nav-pills d-flex flex-column rounded-top rounded-bottom', role: 'tablist navigation', 'aria-orientation': 'vertical', tabindex: '0' do %>
4
+ <% tabs.each do |tab| %>
5
+ <% tab.active_tab_name = active_tab_name %>
6
+ <%= tab %>
7
+ <% end %>
8
+ <% end %>
9
+ <span class="required mt-2">* Required</span>
10
+ </div>
11
+ <div class="col-md-9">
12
+ <div class="tab-content h-100">
13
+ <% panes.each do |pane| %>
14
+ <% pane.active_tab_name = active_tab_name if pane.respond_to?(:active_tab_name=) %>
15
+ <%= pane %>
16
+ <% end %>
17
+ </div>
18
+ </div>
19
+ <span id="<%= invalid_description_id %>" class="visually-hidden" data-sdr-tab-error-target="invalidDescription">Error: required fields missing</span>
20
+ <% end %>
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SdrViewComponents
4
+ module TabForm
5
+ # Component for rendering tabbed navigation for a form.
6
+ #
7
+ # TabListComponent doesn't render a <form> tag itself -- the real <form> is rendered
8
+ # separately (see HiddenFieldsFormComponent), and the panes' fields are associated with it
9
+ # via the HTML `form` attribute (see TabbedFormBuilder) rather than DOM nesting, since panes
10
+ # aren't necessarily inside the <form>.
11
+ #
12
+ # Panes can be any renderable object; if it responds to `active_tab_name=`, that's set for you
13
+ # before rendering, so panes don't have to be passed `active_tab_name` at construction.
14
+ class TabListComponent < BaseComponent
15
+ renders_many :tabs, SdrViewComponents::TabForm::TabComponent
16
+ renders_many :panes, ->(pane) { pane }
17
+
18
+ def initialize(id:, active_tab_name:, classes: [], data: {})
19
+ @id = id
20
+ @active_tab_name = active_tab_name
21
+ @classes = classes
22
+ @data = data
23
+ super()
24
+ end
25
+
26
+ attr_reader :id, :active_tab_name
27
+
28
+ def classes
29
+ # Provides tab-error, tab-form as the static default classes
30
+ # merged with any additional classes passed in.
31
+ merge_classes(%w[tab-error row tab-form gx-4 gy-4 mb-5], @classes)
32
+ end
33
+
34
+ # `change` events fired by fields anywhere in the panes bubble up to this
35
+ # component's root element (fields are associated with the real form via the
36
+ # `form` attribute, not DOM nesting, so actions can't live on the `<form>` tag itself).
37
+ def data
38
+ @data.merge(controller: 'sdr-tab-error')
39
+ end
40
+
41
+ def invalid_description_id
42
+ "#{id}-invalid-description"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SdrViewComponents
4
+ module TabForm
5
+ # A FormBuilder for use with SdrViewComponents::TabForm::TabListComponent-based forms.
6
+ #
7
+ # TabListComponent doesn't render a <form> tag itself -- the real <form> is rendered
8
+ # separately by the caller (see HiddenFieldsFormComponent), and fields are associated with it
9
+ # via the HTML `form` attribute rather than DOM nesting (since panes aren't necessarily inside
10
+ # the <form>). This builder automatically sets that `form` attribute on every field it builds,
11
+ # so callers don't need to pass `form: form_id` explicitly at every call site.
12
+ #
13
+ # It also makes `id` (used above to derive the `form` attribute) available on builders
14
+ # created via `fields_for`, by falling back to the parent builder's `id`. Rails' `fields_for`
15
+ # doesn't otherwise propagate the `html: { id: }` option to nested builders.
16
+ class TabbedFormBuilder < ActionView::Helpers::FormBuilder
17
+ FIELD_METHODS = %i[text_field email_field textarea text_area hidden_field file_field radio_button checkbox
18
+ date_field].freeze
19
+
20
+ FIELD_METHODS.each do |field_method|
21
+ define_method(field_method) do |*args, &block|
22
+ # These methods take a plain trailing options Hash (not real keyword arguments), and
23
+ # callers pass it either as `key: value` pairs or as a literal Hash -- both arrive here
24
+ # as a positional Hash, so it has to be merged in place rather than via **kwargs.
25
+ args << {} unless args.last.is_a?(Hash)
26
+ args[-1] = { form: id }.merge(args.last)
27
+ super(*args, &block)
28
+ end
29
+ end
30
+
31
+ def select(method, choices = nil, options = {}, html_options = {}, &)
32
+ html_options[:form] ||= id
33
+ super
34
+ end
35
+
36
+ def id
37
+ super || options[:parent_builder]&.id
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,81 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+ import * as bootstrap from 'bootstrap'
3
+
4
+ export default class extends Controller {
5
+ static targets = ['tab', 'pane', 'invalidDescription']
6
+
7
+ connect () {
8
+ // For each tab / pane pair, check if the pane contains an invalid input.
9
+ // If it does, add the is-invalid class to the tab and modify the tab's
10
+ // accessible label to include the error message.
11
+ this.tabTargets.forEach((tabEl, index) => {
12
+ const paneElement = this.paneTargets[index]
13
+ const invalidElements = paneElement.querySelectorAll('.is-invalid')
14
+ if (invalidElements.length > 0) {
15
+ tabEl.classList.add('is-invalid')
16
+ const currentLabelledBy = tabEl.getAttribute('aria-labelledby')
17
+ const newLabelledBy = `${currentLabelledBy} ${this.invalidDescriptionTarget.id}`
18
+ tabEl.setAttribute('aria-labelledby', newLabelledBy)
19
+ }
20
+ })
21
+
22
+ // Show the first tab that has an invalid input.
23
+ const firstErrorTabEl = this.tabTargets.find(tabEl => tabEl.classList.contains('is-invalid'))
24
+ if (firstErrorTabEl) {
25
+ bootstrap.Tab.getOrCreateInstance(firstErrorTabEl).show() // eslint-disable-line no-undef
26
+ }
27
+ }
28
+
29
+ changed (event) {
30
+ const target = event.target
31
+ if (!target.classList.contains('is-invalid')) return
32
+ const paneElement = target.closest('.tab-pane')
33
+ // Mark the invalid target that was changed as no longer invalid, *and* mark any other invalid field
34
+ // with the same ID as no longer invalid. We think this is both harmless and it marks as valid the
35
+ // first name and last name fields in the contributor form, which are duplicated due to their being
36
+ // names in both the ORCID section and the non-ORCID section.
37
+ const invalidFieldElements = paneElement.querySelectorAll(`#${target.id}`)
38
+ if (invalidFieldElements) invalidFieldElements.forEach(element => element.classList.remove('is-invalid'))
39
+
40
+ // Remove the invalid feedback divs corresponding to invalid fields that are changed.
41
+ const invalidFeedbackElements = paneElement.querySelectorAll(`#${target.id}_error`)
42
+ if (invalidFeedbackElements) invalidFeedbackElements.forEach(element => element.remove())
43
+
44
+ // If the current pane no longer has fields marked as invalid, mark the tab as no longer
45
+ // being invalid.
46
+ const tabEl = this.tabElementFromId(paneElement.id.replace('-pane', ''))
47
+ if (!paneElement.querySelector('.is-invalid')) tabEl.classList.remove('is-invalid')
48
+ }
49
+
50
+ // Clear all invalid feedbacks on a pane and removes invalid from the tab.
51
+ clearInvalidStatus (paneAndTabId) {
52
+ const tabElement = this.tabElementFromId(paneAndTabId)
53
+ const paneElement = this.paneElementFromId(paneAndTabId)
54
+ const invalidElements = paneElement.querySelectorAll('.is-invalid')
55
+ if (invalidElements.length > 0) {
56
+ invalidElements.forEach(invalidElement => {
57
+ if (invalidElement.tagName === 'DIV') {
58
+ invalidElement.remove()
59
+ } else {
60
+ invalidElement.classList.remove('is-invalid')
61
+ }
62
+ })
63
+ tabElement.classList.remove('is-invalid')
64
+ }
65
+ }
66
+
67
+ updateTabInvalidStatus (paneAndTabId) {
68
+ const tabElement = this.tabElementFromId(paneAndTabId)
69
+ const paneElement = this.paneElementFromId(paneAndTabId)
70
+
71
+ if (!paneElement.querySelector('.is-invalid')) tabElement.classList.remove('is-invalid')
72
+ }
73
+
74
+ tabElementFromId (paneAndTabId) {
75
+ return this.tabTargets.find(tab => tab.id === `${paneAndTabId}-tab`)
76
+ }
77
+
78
+ paneElementFromId (paneAndTabId) {
79
+ return this.paneTargets.find(pane => pane.id === `${paneAndTabId}-pane`)
80
+ }
81
+ }
@@ -0,0 +1,13 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+ import * as bootstrap from 'bootstrap'
3
+
4
+ // Switches to a tab when the link is clicked.
5
+ // Provide the #id of the tab to switch to in the href attribute of the link.
6
+ export default class extends Controller {
7
+ show (event) {
8
+ event.preventDefault()
9
+
10
+ const tabAnchor = this.element.getAttribute('href')
11
+ bootstrap.Tab.getOrCreateInstance(tabAnchor).show() // eslint-disable-line no-undef
12
+ }
13
+ }
@@ -0,0 +1,24 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+ import * as bootstrap from 'bootstrap'
3
+
4
+ export default class extends Controller {
5
+ static values = { tabId: String }
6
+
7
+ showNext (event) {
8
+ event.preventDefault()
9
+
10
+ const nextTab = this.currentTab.nextElementSibling
11
+ bootstrap.Tab.getOrCreateInstance(nextTab).show()
12
+ }
13
+
14
+ showPrevious (event) {
15
+ event.preventDefault()
16
+
17
+ const previousTab = this.currentTab.previousElementSibling
18
+ bootstrap.Tab.getOrCreateInstance(previousTab).show()
19
+ }
20
+
21
+ get currentTab () {
22
+ return document.querySelector(`#${this.tabIdValue}`)
23
+ }
24
+ }
@@ -0,0 +1,22 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ // Drives a <select> that stands in for a tab list on narrow viewports (see
4
+ // TabListComponent's `collapse_below` option). Selecting an option shows the
5
+ // corresponding tab; activating a tab some other way (e.g. clicking the tab
6
+ // list above the collapse breakpoint) keeps the select's value in sync.
7
+ //
8
+ // Uses window.bootstrap rather than `import * as bootstrap from 'bootstrap'`:
9
+ // the UMD bootstrap.bundle.min.js has no ES module exports, so an ESM import
10
+ // of it resolves to an empty namespace object even though the script itself
11
+ // still assigns the real API to window.bootstrap as a side effect.
12
+ export default class extends Controller {
13
+ static targets = ['select']
14
+
15
+ change (event) {
16
+ window.bootstrap.Tab.getOrCreateInstance(document.getElementById(event.target.value)).show()
17
+ }
18
+
19
+ sync (event) {
20
+ this.selectTarget.value = event.target.id
21
+ }
22
+ }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SdrViewComponents
4
- VERSION = '0.4.2'
4
+ VERSION = '0.6.0'
5
5
  end
@@ -33,6 +33,12 @@ module SdrViewComponents
33
33
  render SdrViewComponents::Elements::ButtonLinkComponent.new(link: '/example', label: 'Info Link Button',
34
34
  variant: :info)
35
35
  end
36
+
37
+ def nil
38
+ render SdrViewComponents::Elements::ButtonLinkComponent.new(link: '/example', label: 'Nil Variant Link Button',
39
+ variant: nil)
40
+ end
41
+
36
42
  # @!endgroup
37
43
 
38
44
  # @!group Button Sizes
@@ -0,0 +1,14 @@
1
+ <%# Resize the preview frame below the sm breakpoint to see the tabs collapse into a select. %>
2
+ <%# The sdr-tab-select controller isn't registered in this dummy app, so the select won't switch panes here -- see the consuming app for a working example. %>
3
+ <%= render SdrViewComponents::Elements::Tabs::TabListComponent.new(variant: :underline, collapse_below: :sm) do |tab_list| %>
4
+ <% tab_list.with_tab(label: 'Details', id: 'details-tab', pane_id: 'details-pane', active: true) %>
5
+ <% tab_list.with_tab(label: 'History', id: 'history-tab', pane_id: 'history-pane') %>
6
+
7
+ <% tab_list.with_pane(id: 'details-pane', tab_id: 'details-tab', active: true) do %>
8
+ <p>Details pane content.</p>
9
+ <% end %>
10
+
11
+ <% tab_list.with_pane(id: 'history-pane', tab_id: 'history-tab') do %>
12
+ <p>History pane content.</p>
13
+ <% end %>
14
+ <% end %>
@@ -10,6 +10,8 @@ module SdrViewComponents
10
10
  # @!endgroup
11
11
 
12
12
  def with_header; end
13
+
14
+ def collapsible; end
13
15
  end
14
16
  end
15
17
  end
@@ -0,0 +1,8 @@
1
+ <%= form_with(url: '/example', scope: :item) do |form| %>
2
+ <%= render SdrViewComponents::Forms::LabelComponent.new(
3
+ form: form,
4
+ field_name: :title,
5
+ text: 'Collection title',
6
+ mark_required: true
7
+ ) %>
8
+ <% end %>
@@ -11,6 +11,8 @@ module SdrViewComponents
11
11
  def with_tooltip; end
12
12
 
13
13
  def with_caption; end
14
+
15
+ def mark_required; end
14
16
  # @!endgroup
15
17
  end
16
18
  end
@@ -0,0 +1,25 @@
1
+ <% model = SdrViewComponents::TabForm::TabListComponentPreview::TabFormPreviewModel.new(title: 'Project overview') %>
2
+
3
+ <%# The real <form> -- only holds hidden fields. Visible fields below associate with it via %>
4
+ <%# the `form` HTML attribute (see TabbedFormBuilder), not DOM nesting. %>
5
+ <%= render SdrViewComponents::TabForm::HiddenFieldsFormComponent.new(model:, id: 'preview-tabbed-form', url: '/example') %>
6
+
7
+ <%= render SdrViewComponents::TabForm::TabListComponent.new(id: 'preview-tabbed-form', active_tab_name: :details) do |tab_list| %>
8
+ <% tab_list.with_tab(label: 'Details', tab_name: :details, mark_required: true) %>
9
+ <% tab_list.with_tab(label: 'Notes', tab_name: :notes) %>
10
+
11
+ <%# This builder is used only to render pane content; TabListComponent won't render this as a %>
12
+ <%# form. TabbedFormBuilder sets the `form` attribute on the fields it builds to the real %>
13
+ <%# form's id, so they submit correctly even though they aren't nested inside it. %>
14
+ <% form_with(model:, html: { id: 'preview-tabbed-form' }, url: '/example', builder: SdrViewComponents::TabForm::TabbedFormBuilder) do |form| %>
15
+ <% details_pane = SdrViewComponents::TabForm::PaneComponent.new(tab_name: :details, label: 'Details', help_text: 'The essentials.') %>
16
+ <% tab_list.with_pane(details_pane) do %>
17
+ <%= render SdrViewComponents::Forms::TextFieldComponent.new(form:, field_name: :title) %>
18
+ <% end %>
19
+
20
+ <% notes_pane = SdrViewComponents::TabForm::PaneComponent.new(tab_name: :notes, label: 'Notes') %>
21
+ <% tab_list.with_pane(notes_pane) do %>
22
+ <%= render SdrViewComponents::Forms::TextAreaComponent.new(form:, field_name: :notes) %>
23
+ <% end %>
24
+ <% end %>
25
+ <% end %>
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SdrViewComponents
4
+ module TabForm
5
+ class TabListComponentPreview < ViewComponent::Preview
6
+ def default; end
7
+
8
+ class TabFormPreviewModel
9
+ include ActiveModel::Model
10
+ include ActiveModel::Attributes
11
+
12
+ attribute :title, :string, default: nil
13
+ attribute :notes, :string, default: nil
14
+ end
15
+ end
16
+ end
17
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sdr_view_components
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aaron Collier
@@ -146,6 +146,15 @@ files:
146
146
  - app/components/sdr_view_components/structure/style_override_dark_component.rb
147
147
  - app/components/sdr_view_components/structure/style_override_light_component.html.erb
148
148
  - app/components/sdr_view_components/structure/style_override_light_component.rb
149
+ - app/components/sdr_view_components/tab_form/hidden_fields_form_component.html.erb
150
+ - app/components/sdr_view_components/tab_form/hidden_fields_form_component.rb
151
+ - app/components/sdr_view_components/tab_form/pane_component.html.erb
152
+ - app/components/sdr_view_components/tab_form/pane_component.rb
153
+ - app/components/sdr_view_components/tab_form/tab_component.html.erb
154
+ - app/components/sdr_view_components/tab_form/tab_component.rb
155
+ - app/components/sdr_view_components/tab_form/tab_list_component.html.erb
156
+ - app/components/sdr_view_components/tab_form/tab_list_component.rb
157
+ - app/components/sdr_view_components/tab_form/tabbed_form_builder.rb
149
158
  - app/components/sdr_view_components/tables/base_table_component.html.erb
150
159
  - app/components/sdr_view_components/tables/base_table_component.rb
151
160
  - app/components/sdr_view_components/tables/cell_component.html.erb
@@ -158,6 +167,10 @@ files:
158
167
  - app/components/sdr_view_components/tables/row_component.html.erb
159
168
  - app/components/sdr_view_components/tables/row_component.rb
160
169
  - app/components/sdr_view_components/tables/table_component.rb
170
+ - app/javascript/sdr_view_components/tab_error_controller.js
171
+ - app/javascript/sdr_view_components/tab_link_controller.js
172
+ - app/javascript/sdr_view_components/tab_nav_controller.js
173
+ - app/javascript/sdr_view_components/tab_select_controller.js
161
174
  - app/javascript/sdr_view_components/toast_controller.js
162
175
  - app/views/layouts/lookbook.html.erb
163
176
  - config/routes.rb
@@ -187,6 +200,7 @@ files:
187
200
  - spec/components/previews/sdr_view_components/elements/progress_bar_component_preview.rb
188
201
  - spec/components/previews/sdr_view_components/elements/spinner_component_preview.rb
189
202
  - spec/components/previews/sdr_view_components/elements/tabs/tab_list_component_preview.rb
203
+ - spec/components/previews/sdr_view_components/elements/tabs/tab_list_component_preview/collapsible.html.erb
190
204
  - spec/components/previews/sdr_view_components/elements/tabs/tab_list_component_preview/default_variant.html.erb
191
205
  - spec/components/previews/sdr_view_components/elements/tabs/tab_list_component_preview/underline_variant.html.erb
192
206
  - spec/components/previews/sdr_view_components/elements/tabs/tab_list_component_preview/with_header.html.erb
@@ -242,6 +256,7 @@ files:
242
256
  - spec/components/previews/sdr_view_components/forms/label_component_preview.rb
243
257
  - spec/components/previews/sdr_view_components/forms/label_component_preview/default.html.erb
244
258
  - spec/components/previews/sdr_view_components/forms/label_component_preview/hidden_label.html.erb
259
+ - spec/components/previews/sdr_view_components/forms/label_component_preview/mark_required.html.erb
245
260
  - spec/components/previews/sdr_view_components/forms/label_component_preview/with_caption.html.erb
246
261
  - spec/components/previews/sdr_view_components/forms/label_component_preview/with_tooltip.html.erb
247
262
  - spec/components/previews/sdr_view_components/forms/radio_button_component_preview.rb
@@ -298,6 +313,8 @@ files:
298
313
  - spec/components/previews/sdr_view_components/structure/header_component_preview/light_variant.html.erb
299
314
  - spec/components/previews/sdr_view_components/structure/header_component_preview/no_rosette.html.erb
300
315
  - spec/components/previews/sdr_view_components/structure/header_component_preview/white_variant.html.erb
316
+ - spec/components/previews/sdr_view_components/tab_form/tab_list_component_preview.rb
317
+ - spec/components/previews/sdr_view_components/tab_form/tab_list_component_preview/default.html.erb
301
318
  - spec/components/previews/sdr_view_components/tables/cell_component_preview.rb
302
319
  - spec/components/previews/sdr_view_components/tables/cell_component_preview/default.html.erb
303
320
  - spec/components/previews/sdr_view_components/tables/cell_component_preview/with_colspan.html.erb