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.
@@ -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).
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StimulusPlumbers
4
- VERSION = "0.4.4"
4
+ VERSION = "0.4.5"
5
5
  end