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.
- checksums.yaml +4 -4
- data/README.md +86 -0
- data/app/assets/sdr_view_components.css +84 -0
- data/app/components/base_component.rb +14 -0
- data/app/components/sdr_view_components/elements/heading_component.rb +3 -2
- data/app/components/sdr_view_components/elements/tabs/tab_list_component.html.erb +16 -4
- data/app/components/sdr_view_components/elements/tabs/tab_list_component.rb +29 -2
- data/app/components/sdr_view_components/forms/label_component.rb +4 -5
- data/app/components/sdr_view_components/tab_form/hidden_fields_form_component.html.erb +5 -0
- data/app/components/sdr_view_components/tab_form/hidden_fields_form_component.rb +23 -0
- data/app/components/sdr_view_components/tab_form/pane_component.html.erb +16 -0
- data/app/components/sdr_view_components/tab_form/pane_component.rb +53 -0
- data/app/components/sdr_view_components/tab_form/tab_component.html.erb +14 -0
- data/app/components/sdr_view_components/tab_form/tab_component.rb +51 -0
- data/app/components/sdr_view_components/tab_form/tab_list_component.html.erb +20 -0
- data/app/components/sdr_view_components/tab_form/tab_list_component.rb +46 -0
- data/app/components/sdr_view_components/tab_form/tabbed_form_builder.rb +41 -0
- data/app/javascript/sdr_view_components/tab_error_controller.js +81 -0
- data/app/javascript/sdr_view_components/tab_link_controller.js +13 -0
- data/app/javascript/sdr_view_components/tab_nav_controller.js +24 -0
- data/app/javascript/sdr_view_components/tab_select_controller.js +22 -0
- data/lib/sdr_view_components/version.rb +1 -1
- data/spec/components/previews/sdr_view_components/elements/button_link_component_preview.rb +6 -0
- data/spec/components/previews/sdr_view_components/elements/tabs/tab_list_component_preview/collapsible.html.erb +14 -0
- data/spec/components/previews/sdr_view_components/elements/tabs/tab_list_component_preview.rb +2 -0
- data/spec/components/previews/sdr_view_components/forms/label_component_preview/mark_required.html.erb +8 -0
- data/spec/components/previews/sdr_view_components/forms/label_component_preview.rb +2 -0
- data/spec/components/previews/sdr_view_components/tab_form/tab_list_component_preview/default.html.erb +25 -0
- data/spec/components/previews/sdr_view_components/tab_form/tab_list_component_preview.rb +17 -0
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e251b4177b159e8978b41116e020d3d58d5f95071c6dbccd9b212d3751c9a118
|
|
4
|
+
data.tar.gz: be45e43ececfc02ed410b67844418c80371a28a1b89596a56630f3d81ce2df22
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 << " <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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
19
|
+
<%= tag.div class: content_classes do %>
|
|
8
20
|
<% panes.each do |pane| %><%= pane %><% end %>
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|
|
@@ -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 %>
|
|
@@ -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
|
+
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
|