stimulus_plumbers 0.4.4 → 0.4.5
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/CHANGELOG.md +7 -0
- data/docs/architecture.md +33 -0
- data/docs/component/avatar.md +84 -0
- data/docs/component/button.md +135 -0
- data/docs/component/calendar.md +223 -0
- data/docs/component/card.md +92 -0
- data/docs/component/combobox.md +254 -0
- data/docs/component/dispatcher.md +76 -0
- data/docs/component/divider.md +47 -0
- data/docs/component/form.md +238 -0
- data/docs/component/icon.md +44 -0
- data/docs/component/link.md +90 -0
- data/docs/component/list.md +161 -0
- data/docs/component/modal.md +19 -0
- data/docs/component/plumber.md +183 -0
- data/docs/component/popover.md +138 -0
- data/docs/component/theme.md +188 -0
- data/docs/component/timeline.md +138 -0
- data/lib/stimulus_plumbers/version.rb +1 -1
- data/vendor/controllers.manifest.json +386 -0
- metadata +19 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Theming
|
|
2
|
+
|
|
3
|
+
Stimulus Plumbers uses a theme system to apply presentational CSS classes to form fields and components. The default theme is Tailwind CSS. Custom themes can be created by subclassing `Themes::Base`.
|
|
4
|
+
|
|
5
|
+
## Configuration
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# config/initializers/stimulus_plumbers.rb
|
|
9
|
+
StimulusPlumbers.configure do |config|
|
|
10
|
+
config.theme.use(:tailwind)
|
|
11
|
+
# config.theme.use(MyCustomTheme.new)
|
|
12
|
+
end
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Theme keys
|
|
16
|
+
|
|
17
|
+
Each row lists the theme key, the component that uses it, and the variants it accepts.
|
|
18
|
+
|
|
19
|
+
### Form fields
|
|
20
|
+
|
|
21
|
+
| Key | Component | Variants |
|
|
22
|
+
| -------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------- |
|
|
23
|
+
| `form_group` | `Fields::Group` (wrapper div) | `layout: :stacked\|:inline`, `error: bool` |
|
|
24
|
+
| `form_field_label` | `Fields::Label`, `Fields::Fieldset` (`<legend>`) | `required: bool`, `hidden: bool` |
|
|
25
|
+
| `form_field_required_mark` | Required `*` span in label/legend | — |
|
|
26
|
+
| `form_field_hint` | `Fields::Hint` | — |
|
|
27
|
+
| `form_field_error` | `Fields::Error` | — |
|
|
28
|
+
| `form_field_choice_items` | Collection wrapper (the items container inside a fieldset) | `layout: :stacked\|:inline` |
|
|
29
|
+
| `form_field_checkbox_label` | `<label>` wrapping each checkbox in a collection | `type: :default\|:button\|:card`, `variant:` |
|
|
30
|
+
| `form_field_radio_label` | `<label>` for each radio button in a collection | `type: :default\|:button\|:card`, `variant:` |
|
|
31
|
+
| `form_field_input` | Text-like `<input>` (text, email, number, url, tel, …) | `error: bool` |
|
|
32
|
+
| `form_field_floating` | `<input>` inside a floating-label field | `type: :filled\|:outlined\|:standard`, `error: bool` |
|
|
33
|
+
| `form_field_floating_group` | Wrapper `<div>` that groups floating input + label | `type: :filled\|:outlined\|:standard` |
|
|
34
|
+
| `form_field_floating_label` | Animated `<label>` that floats above the input | `type: :filled\|:outlined\|:standard`, `error: bool` |
|
|
35
|
+
| `form_field_input_textarea` | `<textarea>` | `error: bool` |
|
|
36
|
+
| `form_field_input_file` | `<input type="file">` | `error: bool` |
|
|
37
|
+
| `form_field_input_select` | Native `<select>` | `error: bool` |
|
|
38
|
+
| `form_field_input_checkbox` | `<input type="checkbox">` element in a collection | `type: :default\|:button\|:card`, `variant:`, `error: bool` |
|
|
39
|
+
| `form_field_input_radio` | `<input type="radio">` element in a collection | `type: :default\|:button\|:card`, `variant:`, `error: bool` |
|
|
40
|
+
| `form_field_input_combobox` | Combobox wrapper input (resets child input/trigger styles) | `error: bool` |
|
|
41
|
+
| `form_field_input_reveal` | Password reveal wrapper (resets child input styles) | `error: bool` |
|
|
42
|
+
| `form_field_input_clearable` | Clearable wrapper (resets child input styles) | — |
|
|
43
|
+
| `form_field_input_button_reveal` | Show/hide toggle button inside a password field | — |
|
|
44
|
+
| `form_field_input_button_clear` | Clear button inside a search/clearable field | — |
|
|
45
|
+
| `form_submit` | Submit button (`Builder#submit`) | `type: :link\|:default\|…`, `variant:` |
|
|
46
|
+
| `input_group` | `Fields::InputGroup` (input + adornment wrapper) | `error: bool` |
|
|
47
|
+
|
|
48
|
+
### Calendar
|
|
49
|
+
|
|
50
|
+
| Key | Component | Variants |
|
|
51
|
+
| -------------------------- | ------------------------------------------------------- | --------------- |
|
|
52
|
+
| `calendar` | `Calendar`, `Calendar::Turbo` | — |
|
|
53
|
+
| `calendar_days_of_week` | `Calendar::Turbo::DaysOfWeek` | — |
|
|
54
|
+
| `calendar_days_of_month` | `Calendar::Turbo::DaysOfMonth` | — |
|
|
55
|
+
| `calendar_row` | `Calendar::Turbo::DaysOfMonth` (each week row) | — |
|
|
56
|
+
| `calendar_day` | `Calendar::Turbo::DaysOfMonth` (each day cell) | `outside: bool` |
|
|
57
|
+
| `calendar_months_of_year` | `Calendar::Turbo::MonthsOfYear` (rowgroup wrapper) | — |
|
|
58
|
+
| `calendar_month` | `Calendar::Turbo::MonthsOfYear` (each month cell) | — |
|
|
59
|
+
| `calendar_years_of_decade` | `Calendar::Turbo::YearsOfDecade` (rowgroup wrapper) | — |
|
|
60
|
+
| `calendar_year` | `Calendar::Turbo::YearsOfDecade` (each year cell) | — |
|
|
61
|
+
| `calendar_quarter_grid` | `Calendar::Turbo` year/decade grid wrapper (4-col grid) | — |
|
|
62
|
+
|
|
63
|
+
### Combobox
|
|
64
|
+
|
|
65
|
+
| Key | Component | Variants |
|
|
66
|
+
| ------------------------------------ | -------------------------------------------------------------------------------- | ---------------------------------- |
|
|
67
|
+
| `combobox_listbox` | `Combobox::Dropdown`, `Combobox::Typeahead`, `Combobox::Time::Drum` (the `<ul>`) | — |
|
|
68
|
+
| `combobox_option` | `Combobox::Options::Option` | `selected: bool`, `disabled: bool` |
|
|
69
|
+
| `combobox_option_group` | `Combobox::Options::OptionGroup` | — |
|
|
70
|
+
| `combobox_typeahead_loading` | `Combobox::Typeahead` (loading indicator) | — |
|
|
71
|
+
| `combobox_typeahead_empty` | `Combobox::Typeahead` (no-results message) | — |
|
|
72
|
+
| `combobox_time` | `Combobox::Time` (drum wrapper) | — |
|
|
73
|
+
| `combobox_date_navigation` | `Combobox::Date::Navigation` (nav bar) | — |
|
|
74
|
+
| `combobox_date_navigation_navigator` | `Combobox::Date::Navigator` (each prev/next/title button) | — |
|
|
75
|
+
|
|
76
|
+
### Other components
|
|
77
|
+
|
|
78
|
+
| Key | Component | Variants |
|
|
79
|
+
| -------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
80
|
+
| `button` | `Button` | `type: :default\|:outline\|:ghost\|:fab\|:fab_outline\|:dashed\|:card`, `variant: :primary\|:secondary\|:tertiary\|:success\|:destructive\|:warning\|:info`, `size: :xs\|:sm\|:md\|:lg\|:xl` |
|
|
81
|
+
| `button_group` | `Button::Group` | `layout: :inline\|:stacked` |
|
|
82
|
+
| `button_icon` | `Button` (icon rendered via `icon_leading:` / `icon_trailing:`) | — |
|
|
83
|
+
| `link` | `Link` | `type: :default\|:button\|:card`, `variant: :default\|:success\|:destructive\|:warning\|:info` |
|
|
84
|
+
| `link_icon` | `Link` (icon rendered via `icon_leading:` / `icon_trailing:`) | — |
|
|
85
|
+
| `list` | `List` outer `<ul>` | — |
|
|
86
|
+
| `list_section` | `List::Section` wrapper `<li>` | — |
|
|
87
|
+
| `list_section_title` | Section title `<span>` or `<h{n}>` | — |
|
|
88
|
+
| `list_section_description` | Section description `<span>` | — |
|
|
89
|
+
| `list_item` | `List::Item` — applied to inner `<a>` or `<button>` | — |
|
|
90
|
+
| `list_item_icon` | Leading and trailing icons inside a list item | — |
|
|
91
|
+
| `list_item_content` | Content wrapper `<span>` (title + description) | — |
|
|
92
|
+
| `list_item_title` | Title `<span>` inside a list item | — |
|
|
93
|
+
| `list_item_description` | Description `<span>` inside a list item | — |
|
|
94
|
+
| `avatar` | `Avatar` | `size:` |
|
|
95
|
+
| `card` | `Card` outer `<div>` | `variant: :primary\|:secondary\|:tertiary\|…` |
|
|
96
|
+
| `card_header` | Card header wrapper `<div>` (icon + title) | — |
|
|
97
|
+
| `card_icon` | Icon inside the card header | — |
|
|
98
|
+
| `card_title` | Card title element (`h2` etc.) | — |
|
|
99
|
+
| `card_body` | Card body `<div>` | — |
|
|
100
|
+
| `card_action` | Card action `<div>` | — |
|
|
101
|
+
| `icon` | `Icon` | — |
|
|
102
|
+
| `popover` | `Popover` | — |
|
|
103
|
+
|
|
104
|
+
## Custom theme
|
|
105
|
+
|
|
106
|
+
Subclass `Themes::Base` and implement private `*_classes` methods for the keys you want to override. Return `{ classes: "…" }`.
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
class MyTheme < StimulusPlumbers::Themes::Base
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def form_label_classes(hidden: false, **)
|
|
113
|
+
{ classes: hidden ? "sr-only label" : "label" }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def form_group_classes(layout: :stacked, **)
|
|
117
|
+
{ classes: layout == :inline ? "field-row" : "field" }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
StimulusPlumbers.configure do |c|
|
|
122
|
+
c.theme.register(:my_theme, MyTheme)
|
|
123
|
+
c.theme.use(:my_theme)
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Providing icons
|
|
128
|
+
|
|
129
|
+
Override `icons` to supply icons to the `Icon` component. The return value must respond to `[]` and `key?`:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
class MyTheme < StimulusPlumbers::Themes::Base
|
|
133
|
+
def icons
|
|
134
|
+
@icons ||= {
|
|
135
|
+
"check" => {
|
|
136
|
+
elements: [{ tag: :path, d: "M5 13l4 4L19 7", stroke_linecap: :round, stroke_linejoin: :round }]
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Each icon is a hash with optional SVG-level keys (`fill:`, `stroke:`, `view_box:`, `stroke_width:`, `width:`, `height:`) and a required `elements:` array. Each element needs a `tag:` (`:path`, `:circle`, `:rect`, etc.) and its attributes (`d:`, `fill:`, `opacity:`, `stroke_linecap:`, etc.). Unknown keys are ignored; missing SVG keys fall back to defaults (`fill: "none"`, `stroke: "currentColor"`, `view_box: "0 0 24 24"`, `stroke_width: 1.5`).
|
|
144
|
+
|
|
145
|
+
If `icons[name]` returns `nil`, `Icon` renders an empty `<span>` fallback.
|
|
146
|
+
|
|
147
|
+
#### File-based icon sources
|
|
148
|
+
|
|
149
|
+
For SVG-file-backed icon sources, the core gem provides two utilities in `StimulusPlumbers::Themes::Icons`:
|
|
150
|
+
|
|
151
|
+
**`Icons::External`** — a module that parses SVG files into icon hashes. Include it into any module that implements `svg_path(key)`:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
module MyIcons
|
|
155
|
+
include StimulusPlumbers::Themes::Icons::External
|
|
156
|
+
extend self
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def svg_path(key)
|
|
161
|
+
File.expand_path("#{key}.svg", __dir__)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`External` exposes `include?(key)` and `fetch(key)`, where `fetch` returns the parsed icon hash or `nil` if the file doesn't exist. Override `svg_defaults(key)` to inject source-specific SVG attribute defaults.
|
|
167
|
+
|
|
168
|
+
**`Icons::Registry`** — a lazy-loading `SimpleDelegator` wrapping a `Hash`. Results are memoized on first access. Takes `sources:` (array of icon source modules) and optional `aliases:`:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
ICONS = StimulusPlumbers::Themes::Icons::Registry.new(
|
|
172
|
+
sources: [MyIcons],
|
|
173
|
+
aliases: { "close" => "x-mark" }
|
|
174
|
+
)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Sources are tried in order; the first non-nil result wins. Aliases are resolved before querying sources, so `icons["close"]` fetches `"x-mark"` from the sources.
|
|
178
|
+
|
|
179
|
+
### Schema validators
|
|
180
|
+
|
|
181
|
+
When defining a custom schema (advanced), the `validate:` key accepts:
|
|
182
|
+
|
|
183
|
+
| Type | Behaviour |
|
|
184
|
+
| ------------- | -------------------------------------------------------------------------------- |
|
|
185
|
+
| Array / Range | Value must be `include?`-d |
|
|
186
|
+
| Symbol | Method called on the theme; return must respond to `include?` or be truthy/falsy |
|
|
187
|
+
| Proc | Called via `instance_exec`; same return conventions |
|
|
188
|
+
| `nil` | No validation — any value accepted |
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Timeline
|
|
2
|
+
|
|
3
|
+
Rails helper for rendering a themed timeline as an ordered list with optional interactive (expandable) events.
|
|
4
|
+
|
|
5
|
+
## Helper
|
|
6
|
+
|
|
7
|
+
### `sp_timeline`
|
|
8
|
+
|
|
9
|
+
```erb
|
|
10
|
+
<%# Static — server-rendered date text %>
|
|
11
|
+
<%= sp_timeline do |t| %>
|
|
12
|
+
<% t.event(datetime: "2024-01-15") do |e| %>
|
|
13
|
+
<% e.with_indicator %>
|
|
14
|
+
<% e.with_time { "January 2024" } %>
|
|
15
|
+
<% e.with_title { "Event title" } %>
|
|
16
|
+
<% e.with_description { "Brief text" } %>
|
|
17
|
+
<% e.with_actions { sp_link("Read more", url: "/") } %>
|
|
18
|
+
<% end %>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<%# Interactive (expandable) — client-formatted date %>
|
|
22
|
+
<%= sp_timeline(interactive: true,
|
|
23
|
+
data: { "timeline-date-format-value": { month: "long", year: "numeric", day: "numeric" }.to_json }) do |t| %>
|
|
24
|
+
<% t.event(id: "event-1", datetime: "2024-01-15") do |e| %>
|
|
25
|
+
<% e.with_indicator %>
|
|
26
|
+
<% e.with_trigger { "Event title" } %>
|
|
27
|
+
<% e.with_description { "Brief text" } %>
|
|
28
|
+
<% e.with_detail { "Expanded content" } %>
|
|
29
|
+
<% e.with_actions { sp_link("Read more", url: "/") } %>
|
|
30
|
+
<% end %>
|
|
31
|
+
<% end %>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
| Option | Default | Description |
|
|
35
|
+
| ---------------- | ----------- | ------------------------------------------------------------------------------------------------------------ |
|
|
36
|
+
| `orientation:` | `:vertical` | Layout direction — `:vertical` \| `:horizontal`. Passed to theme as `theme.resolve(:timeline, orientation:)` |
|
|
37
|
+
| `interactive:` | `false` | Adds `data-controller="timeline"` to `<ol>` |
|
|
38
|
+
| `**html_options` | — | Forwarded to the outer `<ol>` |
|
|
39
|
+
|
|
40
|
+
### `t.event` options
|
|
41
|
+
|
|
42
|
+
| Option | Default | Description |
|
|
43
|
+
| ---------------- | ------- | ---------------------------------------------------------------- |
|
|
44
|
+
| `datetime:` | `nil` | Renders `<time datetime="...">` — required when `e.time {}` used |
|
|
45
|
+
| `id:` | `nil` | Stable base ID; derives detail element id via `detail_id_for` |
|
|
46
|
+
| `**html_options` | — | Forwarded to the `<li>` |
|
|
47
|
+
|
|
48
|
+
### Event slot methods (yielded as `e`)
|
|
49
|
+
|
|
50
|
+
| Slot method | Description |
|
|
51
|
+
| -------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
52
|
+
| `e.with_indicator` | Dot indicator `<div aria-hidden="true">` |
|
|
53
|
+
| `e.with_indicator(icon: "name")` | Icon indicator — renders `Icon` component inside the wrapper |
|
|
54
|
+
| `e.with_time { text }` | Text content for `<time>`; requires `datetime:` on `t.event` |
|
|
55
|
+
| `e.with_title { text }` | Static `<h3>` — mutually exclusive with `e.with_trigger` |
|
|
56
|
+
| `e.with_trigger { text }` | `<h3><button>` — mutually exclusive with `e.with_title`; button has `aria-expanded` and `aria-controls` |
|
|
57
|
+
| `e.with_description { text }` | `<p>` element |
|
|
58
|
+
| `e.with_detail { text }` | Collapsible `<div hidden>` — requires `e.with_trigger`; id linked to trigger's `aria-controls` |
|
|
59
|
+
| `e.with_actions { content }` | Actions wrapper `<div>` — suppressed when block yields nil/empty |
|
|
60
|
+
|
|
61
|
+
**Constraints:**
|
|
62
|
+
|
|
63
|
+
- `e.with_title` and `e.with_trigger` are mutually exclusive — raises `ArgumentError` if both set.
|
|
64
|
+
- `e.with_detail` requires `e.with_trigger` — raises `ArgumentError` otherwise.
|
|
65
|
+
- `e.with_time` requires `datetime:` on `t.event` — raises `ArgumentError` otherwise.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Rendered HTML Structure
|
|
70
|
+
|
|
71
|
+
### Static
|
|
72
|
+
|
|
73
|
+
```html
|
|
74
|
+
<ol>
|
|
75
|
+
<li>
|
|
76
|
+
<div aria-hidden="true"></div>
|
|
77
|
+
<!-- indicator dot -->
|
|
78
|
+
<time datetime="2024-01-15">January 2024</time>
|
|
79
|
+
<h3>Event title</h3>
|
|
80
|
+
<p>Brief text</p>
|
|
81
|
+
<div><!-- actions --></div>
|
|
82
|
+
</li>
|
|
83
|
+
</ol>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Interactive
|
|
87
|
+
|
|
88
|
+
```html
|
|
89
|
+
<ol data-controller="timeline">
|
|
90
|
+
<li>
|
|
91
|
+
<div aria-hidden="true"></div>
|
|
92
|
+
<time datetime="2024-01-15"></time>
|
|
93
|
+
<!-- filled by JS dateFormatValue -->
|
|
94
|
+
<h3>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
aria-expanded="false"
|
|
98
|
+
aria-controls="event-1_detail"
|
|
99
|
+
data-timeline-target="trigger"
|
|
100
|
+
data-action="timeline#toggle"
|
|
101
|
+
>
|
|
102
|
+
Event title
|
|
103
|
+
</button>
|
|
104
|
+
</h3>
|
|
105
|
+
<p>Brief text</p>
|
|
106
|
+
<div id="event-1_detail" hidden data-timeline-target="detail">
|
|
107
|
+
Expanded content
|
|
108
|
+
</div>
|
|
109
|
+
<div><!-- actions --></div>
|
|
110
|
+
</li>
|
|
111
|
+
</ol>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Theme keys
|
|
117
|
+
|
|
118
|
+
| Key | Element | Variants |
|
|
119
|
+
| --------------------------- | ---------------------------------- | --------------------- |
|
|
120
|
+
| `timeline` | Outer `<ol>` | `orientation:` |
|
|
121
|
+
| `timeline_item` | `<li>` | `orientation:` |
|
|
122
|
+
| `timeline_item_indicator` | Indicator `<div>` | `type: :dot \| :icon` |
|
|
123
|
+
| `timeline_item_time` | `<time>` | — |
|
|
124
|
+
| `timeline_item_title` | Static `<h3>` | — |
|
|
125
|
+
| `timeline_item_heading` | `<h3>` wrapping the trigger button | — |
|
|
126
|
+
| `timeline_item_trigger` | `<button>` inside the heading | — |
|
|
127
|
+
| `timeline_item_description` | `<p>` | — |
|
|
128
|
+
| `timeline_item_detail` | Collapsible `<div>` | — |
|
|
129
|
+
| `timeline_item_actions` | Actions `<div>` | — |
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## ARIA
|
|
134
|
+
|
|
135
|
+
- See [ARIA.md](../../ARIA.md) for WCAG 2.1 AA criteria and disclosure widget patterns.
|
|
136
|
+
- The trigger button uses `aria-expanded` (managed by the JS controller) and `aria-controls` pointing to the detail `id`.
|
|
137
|
+
- Indicator elements carry `aria-hidden="true"` to hide decorative markup from screen readers.
|
|
138
|
+
- For JS controller targets, values, and actions, see the [stimulus-plumbers JS controller doc](../../stimulus-plumbers/docs/component/timeline.md).
|