stimulus_plumbers 0.4.4 → 0.4.8

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +16 -0
  4. data/docs/architecture.md +33 -0
  5. data/docs/component/avatar.md +84 -0
  6. data/docs/component/button.md +135 -0
  7. data/docs/component/calendar.md +230 -0
  8. data/docs/component/card.md +94 -0
  9. data/docs/component/combobox.md +254 -0
  10. data/docs/component/dispatcher.md +76 -0
  11. data/docs/component/divider.md +47 -0
  12. data/docs/component/form.md +264 -0
  13. data/docs/component/icon.md +44 -0
  14. data/docs/component/link.md +90 -0
  15. data/docs/component/list.md +161 -0
  16. data/docs/component/modal.md +19 -0
  17. data/docs/component/plumber.md +205 -0
  18. data/docs/component/popover.md +138 -0
  19. data/docs/component/theme.md +201 -0
  20. data/docs/component/timeline.md +138 -0
  21. data/docs/guide.md +27 -0
  22. data/lib/generators/stimulus_plumbers/install/install_generator.rb +29 -0
  23. data/lib/stimulus_plumbers/components/button.rb +1 -1
  24. data/lib/stimulus_plumbers/components/card.rb +1 -1
  25. data/lib/stimulus_plumbers/components/combobox.rb +1 -1
  26. data/lib/stimulus_plumbers/components/link.rb +1 -1
  27. data/lib/stimulus_plumbers/components/list/item.rb +1 -1
  28. data/lib/stimulus_plumbers/components/timeline/event.rb +1 -1
  29. data/lib/stimulus_plumbers/engine.rb +4 -0
  30. data/lib/stimulus_plumbers/generators/css_entrypoint.rb +74 -0
  31. data/lib/stimulus_plumbers/generators/tokens_directive.rb +20 -0
  32. data/lib/stimulus_plumbers/plumber/slots.rb +10 -3
  33. data/lib/stimulus_plumbers/version.rb +1 -1
  34. data/lib/tasks/stimulus_plumbers.rake +18 -0
  35. data/vendor/ARIA.md +88 -0
  36. data/vendor/controller/docs/calendar.md +215 -0
  37. data/vendor/controller/docs/clipboard.md +61 -0
  38. data/vendor/controller/docs/combobox.md +242 -0
  39. data/vendor/controller/docs/dismisser.md +39 -0
  40. data/vendor/controller/docs/flipper.md +46 -0
  41. data/vendor/controller/docs/input-clearable.md +46 -0
  42. data/vendor/controller/docs/input-formatter.md +90 -0
  43. data/vendor/controller/docs/modal.md +60 -0
  44. data/vendor/controller/docs/panner.md +24 -0
  45. data/vendor/controller/docs/popover.md +113 -0
  46. data/vendor/controller/docs/timeline.md +84 -0
  47. data/vendor/controller/guide.md +16 -0
  48. data/vendor/controller/manifest.json +386 -0
  49. metadata +37 -1
@@ -0,0 +1,201 @@
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
+ Adding the `stimulus_plumbers_tailwind` gem registers and activates the Tailwind theme automatically — no config needed. To switch to a custom theme instead:
8
+
9
+ ```ruby
10
+ # config/initializers/stimulus_plumbers.rb
11
+ StimulusPlumbers.configure do |config|
12
+ config.theme.use(MyCustomTheme.new)
13
+ end
14
+ ```
15
+
16
+ ## Design tokens
17
+
18
+ - Component classes reference CSS custom properties (e.g. `--sp-color-primary`) defined in `stimulus_plumbers/tokens.css`
19
+ - Run `bin/rails generate stimulus_plumbers:install` once to import it — see the [README](../../README.md#installation)
20
+ - Override tokens with your own `:root` block after the generated import — the cascade applies your values without forking the file
21
+
22
+ ```css
23
+ :root {
24
+ --sp-color-primary: oklch(60% 0.18 260);
25
+ }
26
+ ```
27
+
28
+ ## Theme keys
29
+
30
+ Each row lists the theme key, the component that uses it, and the variants it accepts.
31
+
32
+ ### Form fields
33
+
34
+ | Key | Component | Variants |
35
+ | -------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------- |
36
+ | `form_group` | `Fields::Group` (wrapper div) | `layout: :stacked\|:inline`, `error: bool` |
37
+ | `form_field_label` | `Fields::Label`, `Fields::Fieldset` (`<legend>`) | `required: bool`, `hidden: bool` |
38
+ | `form_field_required_mark` | Required `*` span in label/legend | — |
39
+ | `form_field_hint` | `Fields::Hint` | — |
40
+ | `form_field_error` | `Fields::Error` | — |
41
+ | `form_field_choice_items` | Collection wrapper (the items container inside a fieldset) | `layout: :stacked\|:inline` |
42
+ | `form_field_checkbox_label` | `<label>` wrapping each checkbox in a collection | `type: :default\|:button\|:card`, `variant:` |
43
+ | `form_field_radio_label` | `<label>` for each radio button in a collection | `type: :default\|:button\|:card`, `variant:` |
44
+ | `form_field_input` | Text-like `<input>` (text, email, number, url, tel, …) | `error: bool` |
45
+ | `form_field_floating` | `<input>` inside a floating-label field | `type: :filled\|:outlined\|:standard`, `error: bool` |
46
+ | `form_field_floating_group` | Wrapper `<div>` that groups floating input + label | `type: :filled\|:outlined\|:standard` |
47
+ | `form_field_floating_label` | Animated `<label>` that floats above the input | `type: :filled\|:outlined\|:standard`, `error: bool` |
48
+ | `form_field_input_textarea` | `<textarea>` | `error: bool` |
49
+ | `form_field_input_file` | `<input type="file">` | `error: bool` |
50
+ | `form_field_input_select` | Native `<select>` | `error: bool` |
51
+ | `form_field_input_checkbox` | `<input type="checkbox">` element in a collection | `type: :default\|:button\|:card`, `variant:`, `error: bool` |
52
+ | `form_field_input_radio` | `<input type="radio">` element in a collection | `type: :default\|:button\|:card`, `variant:`, `error: bool` |
53
+ | `form_field_input_combobox` | Combobox wrapper input (resets child input/trigger styles) | `error: bool` |
54
+ | `form_field_input_reveal` | Password reveal wrapper (resets child input styles) | `error: bool` |
55
+ | `form_field_input_clearable` | Clearable wrapper (resets child input styles) | — |
56
+ | `form_field_input_button_reveal` | Show/hide toggle button inside a password field | — |
57
+ | `form_field_input_button_clear` | Clear button inside a search/clearable field | — |
58
+ | `form_submit` | Submit button (`Builder#submit`) | `type: :link\|:default\|…`, `variant:` |
59
+ | `input_group` | `Fields::InputGroup` (input + adornment wrapper) | `error: bool` |
60
+
61
+ ### Calendar
62
+
63
+ | Key | Component | Variants |
64
+ | -------------------------- | ------------------------------------------------------- | --------------- |
65
+ | `calendar` | `Calendar`, `Calendar::Turbo` | — |
66
+ | `calendar_days_of_week` | `Calendar::Turbo::DaysOfWeek` | — |
67
+ | `calendar_days_of_month` | `Calendar::Turbo::DaysOfMonth` | — |
68
+ | `calendar_row` | `Calendar::Turbo::DaysOfMonth` (each week row) | — |
69
+ | `calendar_day` | `Calendar::Turbo::DaysOfMonth` (each day cell) | `outside: bool` |
70
+ | `calendar_months_of_year` | `Calendar::Turbo::MonthsOfYear` (rowgroup wrapper) | — |
71
+ | `calendar_month` | `Calendar::Turbo::MonthsOfYear` (each month cell) | — |
72
+ | `calendar_years_of_decade` | `Calendar::Turbo::YearsOfDecade` (rowgroup wrapper) | — |
73
+ | `calendar_year` | `Calendar::Turbo::YearsOfDecade` (each year cell) | — |
74
+ | `calendar_quarter_grid` | `Calendar::Turbo` year/decade grid wrapper (4-col grid) | — |
75
+
76
+ ### Combobox
77
+
78
+ | Key | Component | Variants |
79
+ | ------------------------------------ | -------------------------------------------------------------------------------- | ---------------------------------- |
80
+ | `combobox_listbox` | `Combobox::Dropdown`, `Combobox::Typeahead`, `Combobox::Time::Drum` (the `<ul>`) | — |
81
+ | `combobox_option` | `Combobox::Options::Option` | `selected: bool`, `disabled: bool` |
82
+ | `combobox_option_group` | `Combobox::Options::OptionGroup` | — |
83
+ | `combobox_typeahead_loading` | `Combobox::Typeahead` (loading indicator) | — |
84
+ | `combobox_typeahead_empty` | `Combobox::Typeahead` (no-results message) | — |
85
+ | `combobox_time` | `Combobox::Time` (drum wrapper) | — |
86
+ | `combobox_date_navigation` | `Combobox::Date::Navigation` (nav bar) | — |
87
+ | `combobox_date_navigation_navigator` | `Combobox::Date::Navigator` (each prev/next/title button) | — |
88
+
89
+ ### Other components
90
+
91
+ | Key | Component | Variants |
92
+ | -------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
93
+ | `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` |
94
+ | `button_group` | `Button::Group` | `layout: :inline\|:stacked` |
95
+ | `button_icon` | `Button` (icon rendered via `icon_leading:` / `icon_trailing:`) | — |
96
+ | `link` | `Link` | `type: :default\|:button\|:card`, `variant: :default\|:success\|:destructive\|:warning\|:info` |
97
+ | `link_icon` | `Link` (icon rendered via `icon_leading:` / `icon_trailing:`) | — |
98
+ | `list` | `List` outer `<ul>` | — |
99
+ | `list_section` | `List::Section` wrapper `<li>` | — |
100
+ | `list_section_title` | Section title `<span>` or `<h{n}>` | — |
101
+ | `list_section_description` | Section description `<span>` | — |
102
+ | `list_item` | `List::Item` — applied to inner `<a>` or `<button>` | — |
103
+ | `list_item_icon` | Leading and trailing icons inside a list item | — |
104
+ | `list_item_content` | Content wrapper `<span>` (title + description) | — |
105
+ | `list_item_title` | Title `<span>` inside a list item | — |
106
+ | `list_item_description` | Description `<span>` inside a list item | — |
107
+ | `avatar` | `Avatar` | `size:` |
108
+ | `card` | `Card` outer `<div>` | `variant: :primary\|:secondary\|:tertiary\|…` |
109
+ | `card_header` | Card header wrapper `<div>` (icon + title) | — |
110
+ | `card_icon` | Icon inside the card header | — |
111
+ | `card_title` | Card title element (`h2` etc.) | — |
112
+ | `card_body` | Card body `<div>` | — |
113
+ | `card_action` | Card action `<div>` | — |
114
+ | `icon` | `Icon` | — |
115
+ | `popover` | `Popover` | — |
116
+
117
+ ## Custom theme
118
+
119
+ Subclass `Themes::Base` and implement private `*_classes` methods for the keys you want to override. Return `{ classes: "…" }`.
120
+
121
+ ```ruby
122
+ class MyTheme < StimulusPlumbers::Themes::Base
123
+ private
124
+
125
+ def form_label_classes(hidden: false, **)
126
+ { classes: hidden ? "sr-only label" : "label" }
127
+ end
128
+
129
+ def form_group_classes(layout: :stacked, **)
130
+ { classes: layout == :inline ? "field-row" : "field" }
131
+ end
132
+ end
133
+
134
+ StimulusPlumbers.configure do |c|
135
+ c.theme.register(:my_theme, MyTheme)
136
+ c.theme.use(:my_theme)
137
+ end
138
+ ```
139
+
140
+ ### Providing icons
141
+
142
+ Override `icons` to supply icons to the `Icon` component. The return value must respond to `[]` and `key?`:
143
+
144
+ ```ruby
145
+ class MyTheme < StimulusPlumbers::Themes::Base
146
+ def icons
147
+ @icons ||= {
148
+ "check" => {
149
+ elements: [{ tag: :path, d: "M5 13l4 4L19 7", stroke_linecap: :round, stroke_linejoin: :round }]
150
+ }
151
+ }
152
+ end
153
+ end
154
+ ```
155
+
156
+ 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`).
157
+
158
+ If `icons[name]` returns `nil`, `Icon` renders an empty `<span>` fallback.
159
+
160
+ #### File-based icon sources
161
+
162
+ For SVG-file-backed icon sources, the core gem provides two utilities in `StimulusPlumbers::Themes::Icons`:
163
+
164
+ **`Icons::External`** — a module that parses SVG files into icon hashes. Include it into any module that implements `svg_path(key)`:
165
+
166
+ ```ruby
167
+ module MyIcons
168
+ include StimulusPlumbers::Themes::Icons::External
169
+ extend self
170
+
171
+ private
172
+
173
+ def svg_path(key)
174
+ File.expand_path("#{key}.svg", __dir__)
175
+ end
176
+ end
177
+ ```
178
+
179
+ `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.
180
+
181
+ **`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:`:
182
+
183
+ ```ruby
184
+ ICONS = StimulusPlumbers::Themes::Icons::Registry.new(
185
+ sources: [MyIcons],
186
+ aliases: { "close" => "x-mark" }
187
+ )
188
+ ```
189
+
190
+ 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.
191
+
192
+ ### Schema validators
193
+
194
+ When defining a custom schema (advanced), the `validate:` key accepts:
195
+
196
+ | Type | Behaviour |
197
+ | ------------- | -------------------------------------------------------------------------------- |
198
+ | Array / Range | Value must be `include?`-d |
199
+ | Symbol | Method called on the theme; return must respond to `include?` or be truthy/falsy |
200
+ | Proc | Called via `instance_exec`; same return conventions |
201
+ | `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).
data/docs/guide.md ADDED
@@ -0,0 +1,27 @@
1
+ # Guide
2
+
3
+ ## Building forms
4
+
5
+ Use `StimulusPlumbers::Form::Builder` (set `config.action_view.default_form_builder`, or pass
6
+ `builder:` to `form_with`). Two levels:
7
+
8
+ - **Level 2 — recommended.** Full accessible field (label + input + hint + error):
9
+ `f.field(attr, as:)`, `f.collection_field(attr, as:, collection:, ...)`, `f.choice(attr, as:)`.
10
+ See [docs/component/form.md](component/form.md) for valid `as:` values per builder method and
11
+ which ones are backed by a Stimulus controller (date/time/select/search pickers).
12
+ - **Level 1.** Native helper overrides (`f.text_field`, `f.select`, `f.check_box`, ...) render
13
+ only the themed input element — use when you control the surrounding markup.
14
+
15
+ Submit with `f.submit` (themed button).
16
+
17
+ ## Building views
18
+
19
+ Render components with `sp_*` helpers (`sp_button`, `sp_button_group`, `sp_card`, `sp_list`,
20
+ `sp_link`, `sp_avatar`, `sp_divider`, `sp_icon`, `sp_popover`, ...) — see the
21
+ [Components table](../README.md#components) for the full helper list, and each component's
22
+ `docs/component/<name>.md` for its keyword options, slots, and themed params.
23
+
24
+ ## Stimulus integration
25
+
26
+ Most display components are pure markup; interactive ones (combobox, popover, calendar) emit their
27
+ `data-controller` attributes automatically — no manual wiring needed in Rails views.
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "stimulus_plumbers/generators/css_entrypoint"
5
+ require "stimulus_plumbers/generators/tokens_directive"
6
+
7
+ module StimulusPlumbers
8
+ module Generators
9
+ class InstallGenerator < Rails::Generators::Base
10
+ include StimulusPlumbers::Generators::CssEntrypoint
11
+
12
+ STIMULUS_PLUMBERS_CSS_FILE = CssEntrypoint::STIMULUS_PLUMBERS_CSS_FILE
13
+ CSS_CANDIDATES = %w[
14
+ app/assets/stylesheets/application.tailwind.css
15
+ app/assets/stylesheets/application.css
16
+ app/javascript/entrypoints/application.css
17
+ ].freeze
18
+
19
+ def install
20
+ css_file = entry_css_file(candidates: CSS_CANDIDATES, env_var: STIMULUS_PLUMBERS_CSS_FILE)
21
+ unless css_file
22
+ return warn_entry_css_not_found(candidates: CSS_CANDIDATES, env_var: STIMULUS_PLUMBERS_CSS_FILE, label: "CSS")
23
+ end
24
+
25
+ apply_edit(css_file, TokensDirective.directive, stale_pattern: TokensDirective.stale_pattern)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -4,7 +4,7 @@ module StimulusPlumbers
4
4
  module Components
5
5
  class Button < Plumber::Base
6
6
  def render(content = nil, icon_leading: nil, icon_trailing: nil, **kwargs, &block)
7
- slots = Button::Slots.new
7
+ slots = Button::Slots.new(template)
8
8
  slots.with_icon_leading(icon_leading) if icon_leading
9
9
  slots.with_icon_trailing(icon_trailing) if icon_trailing
10
10
 
@@ -4,7 +4,7 @@ module StimulusPlumbers
4
4
  module Components
5
5
  class Card < Plumber::Base
6
6
  def render(variant: :tertiary, title_tag: :h2, **kwargs, &block)
7
- slots = Card::Slots.new
7
+ slots = Card::Slots.new(template)
8
8
  yield slots if block_given?
9
9
 
10
10
  html_options = merge_html_options(theme.resolve(:card, variant: variant), kwargs)
@@ -21,7 +21,7 @@ module StimulusPlumbers
21
21
  private
22
22
 
23
23
  def resolve_builder
24
- builder = Combobox::Builder.new
24
+ builder = Combobox::Builder.new(template)
25
25
  yield builder if block_given?
26
26
  builder
27
27
  end
@@ -6,7 +6,7 @@ module StimulusPlumbers
6
6
  def render(content = nil, url:, icon_leading: nil, icon_trailing: nil, **kwargs, &block)
7
7
  icon_trailing ||= "external-link" if kwargs[:target] == "_blank"
8
8
 
9
- slots = Link::Slots.new
9
+ slots = Link::Slots.new(template)
10
10
  slots.with_icon_leading(icon_leading) if icon_leading
11
11
  slots.with_icon_trailing(icon_trailing) if icon_trailing
12
12
 
@@ -5,7 +5,7 @@ module StimulusPlumbers
5
5
  class List
6
6
  class Item < Plumber::Base
7
7
  def render(content = nil, **kwargs, &block)
8
- slots = List::Item::Slots.new
8
+ slots = List::Item::Slots.new(template)
9
9
  slots.with_title(content) if content
10
10
  slots.with_icon_trailing("external-link") if kwargs[:url].present? && kwargs[:target] == "_blank"
11
11
  yield slots if block_given?
@@ -13,7 +13,7 @@ module StimulusPlumbers
13
13
  def render(datetime: nil, id: nil, interactive: false, orientation: :vertical, **kwargs, &block)
14
14
  @detail_id = self.class.detail_id_for(id || template.sp_dom_id)
15
15
 
16
- slots = Timeline::Event::Slots.new
16
+ slots = Timeline::Event::Slots.new(template)
17
17
  yield slots if block_given?
18
18
 
19
19
  validate_slots!(slots, datetime: datetime)
@@ -18,5 +18,9 @@ module StimulusPlumbers
18
18
  include StimulusPlumbers::Helpers
19
19
  end
20
20
  end
21
+
22
+ rake_tasks do
23
+ load File.join(__dir__, "../tasks/stimulus_plumbers.rake")
24
+ end
21
25
  end
22
26
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module Generators
5
+ module CssEntrypoint
6
+ STIMULUS_PLUMBERS_CSS_FILE = "STIMULUS_PLUMBERS_CSS_FILE"
7
+
8
+ def entry_css_file(candidates:, env_var:, fallback_env_var: nil)
9
+ [env_var, fallback_env_var].compact.each do |var|
10
+ next unless ENV[var]
11
+
12
+ path = File.expand_path(ENV.fetch(var, nil))
13
+ return path if File.exist?(path)
14
+ end
15
+
16
+ candidates
17
+ .map { |c| File.join(destination_root, c) }
18
+ .find { |f| File.exist?(f) }
19
+ end
20
+
21
+ def warn_entry_css_not_found(candidates:, env_var:, label:, fallback_env_var: nil)
22
+ tried = candidates.map { |c| File.join(destination_root, c) }
23
+ [env_var, fallback_env_var].compact.each do |var|
24
+ tried.unshift(File.expand_path(ENV[var])) if ENV[var]
25
+ end
26
+ say "Could not find a #{label} entry file. Tried: #{tried.join(", ")}. " \
27
+ "Set #{env_var}=/path/to/entry.css and re-run.",
28
+ :red
29
+ end
30
+
31
+ def apply_edit(css_file, directive, stale_pattern:, anchor_pattern: nil)
32
+ content = File.read(css_file)
33
+ new_content, status = content_edit(content, directive, stale_pattern: stale_pattern, anchor_pattern: anchor_pattern)
34
+
35
+ if new_content
36
+ File.write(css_file, new_content)
37
+ say_status status, relative_to_destination(css_file), :green
38
+ else
39
+ say_status :identical, relative_to_destination(css_file)
40
+ end
41
+ rescue Errno::EROFS, Errno::EACCES => e
42
+ say "Could not update #{relative_to_destination(css_file)}: #{e.message}. Skipping.", :yellow
43
+ end
44
+
45
+ # `stale_pattern:` is required — every call site needs stale-import detection,
46
+ # so making it optional would leave an untested "anchor without stale" state.
47
+ def content_edit(content, directive, stale_pattern:, anchor_pattern: nil)
48
+ return nil if content.include?(directive)
49
+
50
+ if (existing = content.match(stale_pattern))
51
+ return [content.sub(existing[0], directive), :update]
52
+ end
53
+
54
+ anchor_pattern ? insert_near_anchor(content, directive, anchor_pattern) : prepend(content, directive)
55
+ end
56
+
57
+ def prepend(content, directive)
58
+ ["#{directive}\n#{content}", :prepend]
59
+ end
60
+
61
+ def insert_near_anchor(content, directive, anchor_pattern)
62
+ if (anchor = content.match(anchor_pattern))
63
+ [content.sub(anchor[0], "#{anchor[0]}\n#{directive}"), :insert]
64
+ else
65
+ ["#{content.rstrip}\n#{directive}\n", :append]
66
+ end
67
+ end
68
+
69
+ def relative_to_destination(path)
70
+ path.delete_prefix("#{destination_root}/")
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module Generators
5
+ module TokensDirective
6
+ GEM_ROOT = File.expand_path("../../..", __dir__)
7
+ TOKENS_CSS_REL_PATH = "app/assets/stylesheets/stimulus_plumbers/tokens.css"
8
+
9
+ module_function
10
+
11
+ def directive
12
+ %(@import "#{GEM_ROOT}/#{TOKENS_CSS_REL_PATH}";)
13
+ end
14
+
15
+ def stale_pattern
16
+ %r{@import "[^"]*#{Regexp.escape(TOKENS_CSS_REL_PATH)}";}
17
+ end
18
+ end
19
+ end
20
+ end
@@ -11,8 +11,9 @@ module StimulusPlumbers
11
11
  end
12
12
  end
13
13
 
14
- def initialize
14
+ def initialize(template = nil)
15
15
  @slots = {}
16
+ @template = template
16
17
  end
17
18
 
18
19
  def resolve(name)
@@ -20,7 +21,7 @@ module StimulusPlumbers
20
21
  return unless entry
21
22
 
22
23
  value = entry[:value]
23
- value = value.call if value.is_a?(Proc)
24
+ value = capture_block(value) if value.is_a?(Proc)
24
25
  block_given? ? yield(value) : value
25
26
  end
26
27
 
@@ -38,6 +39,12 @@ module StimulusPlumbers
38
39
 
39
40
  private
40
41
 
42
+ # Runs the slot block through the view so its ERB output is returned,
43
+ # not written to whatever buffer happens to be active.
44
+ def capture_block(block)
45
+ @template ? @template.capture(&block) : block.call
46
+ end
47
+
41
48
  def set_slot(name, value, options = {})
42
49
  @slots[name] = { value: value, options: options }
43
50
  end
@@ -54,7 +61,7 @@ module StimulusPlumbers
54
61
 
55
62
  def define_by_slot(name, by)
56
63
  define_method(:"with_#{name}") do |&block|
57
- sub = by.new
64
+ sub = by.new(@template)
58
65
  block&.call(sub)
59
66
  set_slot(name, sub)
60
67
  nil
@@ -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.8"
5
5
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :stimulus_plumbers do
4
+ desc "Add the tokens.css @import into the CSS entry file"
5
+ task(:install) do
6
+ unless ENV["STIMULUS_PLUMBERS_SKIP_INSTALL"]
7
+ require "generators/stimulus_plumbers/install/install_generator"
8
+ StimulusPlumbers::Generators::InstallGenerator.new(
9
+ [], {}, { destination_root: Rails.root.to_s }
10
+ ).invoke_all
11
+ end
12
+ end
13
+ end
14
+
15
+ Rake::Task["stimulus_plumbers:install"].enhance(["environment"]) unless ENV["STIMULUS_PLUMBERS_SKIP_INSTALL"]
16
+
17
+ # Hooks into "assets:precompile" as a prerequisite; Rake merges rather than overwrites, so order-independent.
18
+ task "assets:precompile" => ["stimulus_plumbers:install"]