unmagic-components 0.2.0 → 0.3.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.
@@ -164,6 +164,22 @@ module Unmagic
164
164
  Components::Button.classes(variant, size: size)
165
165
  end
166
166
 
167
+ # The class string for a form control outside a form builder, so a select_tag
168
+ # or a hand-written input matches the controls the builder styles.
169
+ #
170
+ # <%= select_tag "status", options_for_select(%w[Open Closed]), class: control_classes(:select) %>
171
+ # <%= search_field_tag "q", params[:q], class: control_classes(:input, size: :small) %>
172
+ #
173
+ # kind: :input, :text_area, :password, :date, :select, :check or :radio.
174
+ # size: :small or :large sits a box level with a button_classes button of the
175
+ # same size; a check or radio takes none. The classes come from
176
+ # config.control_class, so they follow the app's own when it has some, and
177
+ # size: only adds the gem's modifier while the gem's class is in use. Rails'
178
+ # own *_tag helpers are never restyled; this is how to opt one in.
179
+ def control_classes(kind, size: nil)
180
+ Components::Control.classes(self, kind, size: size)
181
+ end
182
+
167
183
  # Mounts the shared modal: a <dialog> around the turbo frame modal links load
168
184
  # into. Render it once, in the layout.
169
185
  #
@@ -483,7 +499,7 @@ module Unmagic
483
499
 
484
500
  # text_area_tag, growing with its content. See FormBuilder#autogrow_text_area.
485
501
  def autogrow_text_area_tag(name, content = nil, **options)
486
- Components::Autogrow.wrap(self, text_area_tag(name, content, options))
502
+ Components::Autogrow.wrap(self, text_area_tag(name, content, Components::Control.merge(self, options, :text_area)))
487
503
  end
488
504
 
489
505
  # A hidden field holding a fresh UUIDv7, outside a form builder. See
@@ -7,7 +7,7 @@ module Unmagic
7
7
  # library. An app that already owns these concerns points them at its own
8
8
  # versions in an initializer.
9
9
  class Configuration
10
- attr_writer :empty_state, :pagination, :pagy_for, :submit_class, :modal_frame_id, :flash_tones
10
+ attr_writer :empty_state, :pagination, :pagy_for, :submit_class, :control_class, :modal_frame_id, :flash_tones
11
11
 
12
12
  # The tone each flash type's toast wears, keyed by the flash type as a string.
13
13
  # A type that isn't listed is :info.
@@ -49,6 +49,13 @@ module Unmagic
49
49
  def submit_class
50
50
  @submit_class ||= ->(_view, variant) { "UnmagicButton UnmagicButton--#{variant}" }
51
51
  end
52
+
53
+ # The classes on a form control. Called with (view, kind), where kind is one
54
+ # of Control::CLASSES' keys (:input, :select, :check…); return an app's own
55
+ # classes, or nil to leave the control unstyled.
56
+ def control_class
57
+ @control_class ||= ->(_view, kind) { Control::CLASSES.fetch(kind) }
58
+ end
52
59
  end
53
60
  end
54
61
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # The class a form control wears, by kind. FormBuilder and control_classes both
6
+ # ask here, and this asks the configured control_class seam, so an app with
7
+ # input styles of its own swaps them in, or opts out, in one place. See
8
+ # ActionViewHelpers#control_classes.
9
+ module Control
10
+ # Each kind of control the components render, and the gem's class for it.
11
+ CLASSES = {
12
+ input: "UnmagicInput",
13
+ text_area: "UnmagicInput",
14
+ password: "UnmagicInput",
15
+ date: "UnmagicInput",
16
+ select: "UnmagicSelect",
17
+ check: "UnmagicCheck",
18
+ radio: "UnmagicRadio"
19
+ }.freeze
20
+
21
+ SIZES = %i[small large].freeze
22
+
23
+ # Only the boxes you type into or pick from have a size; a checkbox or radio
24
+ # is sized by the text beside it.
25
+ SIZED = %i[input text_area password date select].freeze
26
+
27
+ # The classes for a kind of control, or nil when the seam gives none. size:
28
+ # adds the gem's modifier, but only while the gem's own class is in use: an
29
+ # app that swapped in its own classes has no rules for the modifier.
30
+ def self.classes(view, kind, size: nil)
31
+ validate!(kind, size)
32
+
33
+ base = Components.configuration.control_class.call(view, kind)
34
+ modifier = "#{CLASSES[kind]}--#{size}" if size && base.to_s.split.include?(CLASSES[kind])
35
+ view.class_names(base, modifier).presence
36
+ end
37
+
38
+ # A copy of options with the kind's classes in front of any class: the caller
39
+ # gave, so the caller's can override the gem's.
40
+ def self.merge(view, options, kind)
41
+ options = (options || {}).dup
42
+ given = options.delete(:class) || options.delete("class")
43
+
44
+ merged = view.class_names(classes(view, kind), given)
45
+ options[:class] = merged if merged.present?
46
+ options
47
+ end
48
+
49
+ def self.validate!(kind, size)
50
+ unless CLASSES.key?(kind)
51
+ raise ArgumentError, "unknown control kind #{kind.inspect} (expected one of #{CLASSES.keys.inspect})"
52
+ end
53
+ return if size.nil?
54
+
55
+ unless SIZES.include?(size)
56
+ raise ArgumentError, "unknown control size #{size.inspect} (expected one of #{SIZES.inspect})"
57
+ end
58
+ raise ArgumentError, "a #{kind} control has no size" unless SIZED.include?(kind)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -21,14 +21,13 @@ module Unmagic
21
21
  end
22
22
  end
23
23
 
24
- # The components' stylesheet is a plain CSS file, deliberately not part of any
25
- # Tailwind build: Tailwind only generates classes it can see, and it does not
26
- # scan installed gems. Serving it through the asset pipeline keeps the gem's
27
- # look self-contained and themeable through --unmagic-* custom properties.
24
+ # The components' JavaScript, served through the asset pipeline. There is no
25
+ # stylesheet to serve: the styles are Tailwind source in
26
+ # app/assets/tailwind/unmagic_components/engine.css, which tailwindcss-rails
27
+ # finds by this engine's name and the host's own Tailwind build compiles.
28
28
  initializer "unmagic_components.assets" do |app|
29
29
  next unless app.config.respond_to?(:assets)
30
30
 
31
- app.config.assets.paths << Engine.root.join("app/assets/stylesheets")
32
31
  app.config.assets.paths << Engine.root.join("app/assets/javascripts")
33
32
  end
34
33
 
@@ -15,10 +15,13 @@ module Unmagic
15
15
  # <%= form.submit "Add label" %>
16
16
  # <% end %>
17
17
  #
18
- # What the control itself looks like is deliberately not decided here. Apps
19
- # style inputs in incompatible ways a class on every input, or a bare-element
20
- # rule and a component library that picked one would be wrong in the other. So
21
- # the builder emits structure and leaves the control's own appearance alone.
18
+ # The controls it builds are styled too. Each wears a class for its kind —
19
+ # UnmagicInput, UnmagicSelect, UnmagicCheckwhich comes from the
20
+ # control_class seam (see Control). The styles hang off those classes and never
21
+ # off bare elements, so an input the gem didn't render keeps whatever the app
22
+ # gives it, and an app with input styles of its own points the seam at them, or
23
+ # returns nil to opt out. Rails' check_box and radio_button are left as they
24
+ # are: a checkbox is styled where the builder lays it out beside its label.
22
25
  class FormBuilder < ::ActionView::Helpers::FormBuilder
23
26
  # Verbs the plain "drop a trailing -e, add -ing" rule gets wrong (consonant
24
27
  # doubling). Everything else the rule handles: Save -> Saving, Create ->
@@ -31,6 +34,51 @@ module Unmagic
31
34
  "run" => "Running"
32
35
  }.freeze
33
36
 
37
+ # The builder's own controls and the kind each is styled as. Each keeps its
38
+ # Rails signature and gains the kind's class. Guarded because the set differs
39
+ # across Rails versions (textarea is Rails 8's name for text_area).
40
+ CONTROL_KINDS = {
41
+ text_field: :input, email_field: :input, number_field: :input, url_field: :input,
42
+ search_field: :input, telephone_field: :input, phone_field: :input,
43
+ password_field: :password,
44
+ text_area: :text_area, textarea: :text_area,
45
+ date_field: :date, time_field: :date, datetime_field: :date, datetime_local_field: :date,
46
+ month_field: :date, week_field: :date
47
+ }.freeze
48
+
49
+ CONTROL_KINDS.each do |name, kind|
50
+ next unless ::ActionView::Helpers::FormBuilder.method_defined?(name)
51
+
52
+ define_method(name) do |method, options = {}|
53
+ super(method, Components::Control.merge(@template, options, kind))
54
+ end
55
+ end
56
+
57
+ def select(method, choices = nil, options = {}, html_options = {}, &block)
58
+ super(method, choices, options, Components::Control.merge(@template, html_options, :select), &block)
59
+ end
60
+
61
+ def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
62
+ super(method, collection, value_method, text_method, options,
63
+ Components::Control.merge(@template, html_options, :select))
64
+ end
65
+
66
+ def grouped_collection_select(method, collection, group_method, group_label_method, option_key_method,
67
+ option_value_method, options = {}, html_options = {})
68
+ super(method, collection, group_method, group_label_method, option_key_method, option_value_method, options,
69
+ Components::Control.merge(@template, html_options, :select))
70
+ end
71
+
72
+ def time_zone_select(method, priority_zones = nil, options = {}, html_options = {})
73
+ super(method, priority_zones, options, Components::Control.merge(@template, html_options, :select))
74
+ end
75
+
76
+ if ::ActionView::Helpers::FormBuilder.method_defined?(:weekday_select)
77
+ def weekday_select(method, options = {}, html_options = {})
78
+ super(method, options, Components::Control.merge(@template, html_options, :select))
79
+ end
80
+ end
81
+
34
82
  # Label + control + hint + error, wrapped consistently. Pass a block to supply
35
83
  # a control the builder doesn't know how to make (a select, a file picker, two
36
84
  # inputs side by side); otherwise it is built from `as:`.
@@ -91,7 +139,7 @@ module Unmagic
91
139
  text << @template.content_tag(:span, hint, class: "UnmagicHint") if hint
92
140
 
93
141
  @template.safe_join [
94
- check_box(method, options),
142
+ check_box(method, Components::Control.merge(@template, options, :check)),
95
143
  @template.content_tag(:span, @template.safe_join(text), class: "UnmagicCheckField__text")
96
144
  ]
97
145
  end
@@ -103,7 +151,7 @@ module Unmagic
103
151
  body = collection_check_boxes(method, collection, value_method, text_method) do |check_box|
104
152
  @template.content_tag(:label, class: "UnmagicCheckField") do
105
153
  @template.safe_join [
106
- check_box.check_box(options),
154
+ check_box.check_box(Components::Control.merge(@template, options, :check)),
107
155
  @template.content_tag(:span, check_box.text, class: "UnmagicCheckField__label")
108
156
  ]
109
157
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Unmagic
4
4
  module Components
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -8,6 +8,7 @@ require "active_support/core_ext/string/output_safety"
8
8
 
9
9
  require_relative "components/version"
10
10
  require_relative "components/configuration"
11
+ require_relative "components/control"
11
12
  require_relative "components/renderers/empty_state"
12
13
  require_relative "components/renderers/pagination"
13
14
  require_relative "components/table_tag"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: unmagic-components
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keith Pitt
@@ -83,8 +83,9 @@ description: UI components for Rails views in the spirit of form_for. Builders f
83
83
  index tables (sortable, deferred, kept live by Turbo Streams), detail lists, forms,
84
84
  cards, page headers and loading skeletons; a Turbo Frame modal, a confirm dialog
85
85
  and flash toasts; menus, tabs, tooltips, local times, copy buttons, autogrowing
86
- textareas and UUID inputs as self-registering custom elements. Plain helpers and
87
- CSS themed through custom properties, with no Tailwind or Stimulus required.
86
+ textareas and UUID inputs as self-registering custom elements. Plain helpers styled
87
+ with Tailwind CSS v4 (required; the host's build compiles the gem's component CSS),
88
+ with no Stimulus.
88
89
  email:
89
90
  - keith@unreasonable-magic.com
90
91
  executables: []
@@ -107,7 +108,7 @@ files:
107
108
  - app/assets/javascripts/unmagic/components/tooltip.js
108
109
  - app/assets/javascripts/unmagic/components/upsert.js
109
110
  - app/assets/javascripts/unmagic/components/uuid_input.js
110
- - app/assets/stylesheets/unmagic/components.css
111
+ - app/assets/tailwind/unmagic_components/engine.css
111
112
  - config/importmap.rb
112
113
  - lib/unmagic-components.rb
113
114
  - lib/unmagic/components.rb
@@ -119,6 +120,7 @@ files:
119
120
  - lib/unmagic/components/card.rb
120
121
  - lib/unmagic/components/configuration.rb
121
122
  - lib/unmagic/components/confirm_template.rb
123
+ - lib/unmagic/components/control.rb
122
124
  - lib/unmagic/components/copy_button.rb
123
125
  - lib/unmagic/components/detail_list.rb
124
126
  - lib/unmagic/components/detail_list/item.rb