odt-ui-rails 2.0.1 → 2.2.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.
data/lib/odt_ui/helper.rb CHANGED
@@ -2,154 +2,68 @@
2
2
 
3
3
  module OdtUi
4
4
  module Helper
5
- BUTTON_VARIANTS = {
6
- filled: {
7
- primary: "bg-primary-500 text-white hover:bg-primary-600 active:bg-primary-700 shadow-card hover:shadow-lg focus-visible:ring-primary-400",
8
- secondary: "bg-secondary-500 text-slate-900 hover:bg-secondary-600 active:bg-secondary-700 shadow-card hover:shadow-lg focus-visible:ring-secondary-400",
9
- success: "bg-success text-white hover:opacity-90 active:opacity-100 shadow-sm focus-visible:ring-green-400",
10
- warning: "bg-warning text-slate-900 hover:opacity-90 active:opacity-100 shadow-sm focus-visible:ring-amber-400",
11
- danger: "bg-danger text-white hover:opacity-90 active:opacity-100 shadow-sm focus-visible:ring-red-400",
12
- neutral: "bg-neutral-800 text-white hover:bg-neutral-900 active:bg-neutral-950 shadow-sm focus-visible:ring-neutral-400"
13
- },
14
- light: {
15
- primary: "bg-primary-50 text-primary-700 hover:bg-primary-100 active:bg-primary-200 focus-visible:ring-primary-300",
16
- secondary: "bg-secondary-50 text-secondary-900 hover:bg-secondary-100 active:bg-secondary-200 focus-visible:ring-secondary-300",
17
- success: "bg-surface-success text-fg-success hover:bg-green-100 active:bg-green-200 focus-visible:ring-green-300",
18
- warning: "bg-surface-warning text-fg-warning hover:bg-amber-100 active:bg-amber-200 focus-visible:ring-amber-300",
19
- danger: "bg-surface-danger text-fg-danger hover:bg-red-100 active:bg-red-200 focus-visible:ring-red-300",
20
- neutral: "bg-surface-muted text-fg-muted hover:bg-surface-subtle active:bg-surface-subtle focus-visible:ring-neutral-300"
21
- },
22
- outline: {
23
- primary: "border border-primary-300 text-primary-600 bg-transparent hover:bg-primary-50 active:bg-primary-100 focus-visible:ring-primary-300",
24
- secondary: "border border-secondary-300 text-secondary-900 bg-transparent hover:bg-secondary-50 active:bg-secondary-100 focus-visible:ring-secondary-300",
25
- danger: "border border-danger text-danger bg-transparent hover:bg-surface-danger active:bg-red-100 focus-visible:ring-red-300",
26
- neutral: "border border-line text-fg bg-transparent hover:bg-surface-subtle active:bg-surface-muted focus-visible:ring-neutral-300"
27
- },
28
- subtle: {
29
- primary: "bg-transparent text-primary-600 hover:bg-primary-50 active:bg-primary-100",
30
- secondary: "bg-transparent text-secondary-900 hover:bg-secondary-50 active:bg-secondary-100",
31
- neutral: "bg-transparent text-fg hover:bg-surface-subtle active:bg-surface-muted",
32
- danger: "bg-transparent text-danger hover:bg-surface-danger active:bg-red-100"
33
- },
34
- ghost: {
35
- primary: "bg-transparent text-fg-primary hover:text-primary-600 hover:bg-primary-50 active:bg-primary-100",
36
- secondary: "bg-transparent text-fg hover:bg-surface-muted active:bg-surface-subtle",
37
- neutral: "bg-transparent text-fg-muted hover:text-fg hover:bg-surface-muted active:bg-surface-subtle"
38
- }
39
- }.freeze
40
-
41
- BUTTON_SIZES = {
42
- xs: "px-2.5 py-1 text-xs gap-1",
43
- sm: "px-3.5 py-1.5 text-xs gap-1.5 font-medium",
44
- md: "px-5 py-2.5 text-sm gap-2 font-medium",
45
- lg: "px-6 py-3 text-base gap-2.5 font-semibold",
46
- xl: "px-7 py-3.5 text-lg gap-3 font-semibold"
47
- }.freeze
48
-
49
- RADIUS_CLASSES = {
50
- none: "rounded-none",
51
- xs: "rounded-xs",
52
- sm: "rounded-sm",
53
- md: "rounded-md",
54
- lg: "rounded-lg",
55
- xl: "rounded-xl",
56
- "2xl": "rounded-2xl",
57
- "3xl": "rounded-3xl",
58
- "4xl": "rounded-4xl",
59
- full: "rounded-full"
60
- }.freeze
61
-
62
- BADGE_VARIANTS = {
63
- subtle: {
64
- primary: "bg-primary-50 text-primary-600",
65
- secondary: "bg-secondary-100 text-secondary-900",
66
- success: "bg-surface-success text-success",
67
- warning: "bg-surface-warning text-warning",
68
- danger: "bg-surface-danger text-danger",
69
- info: "bg-surface-info text-info",
70
- neutral: "bg-surface-muted text-fg-muted"
71
- },
72
- filled: {
73
- primary: "bg-primary-500 text-white",
74
- secondary: "bg-secondary-500 text-slate-900",
75
- success: "bg-success text-white",
76
- warning: "bg-warning text-slate-900",
77
- danger: "bg-danger text-white",
78
- info: "bg-info text-white",
79
- neutral: "bg-neutral-800 text-white"
80
- },
81
- outline: {
82
- primary: "border border-primary-300 text-primary-600 bg-transparent",
83
- secondary: "border border-secondary-300 text-secondary-800 bg-transparent",
84
- success: "border border-line-success text-success bg-transparent",
85
- warning: "border border-line-warning text-warning bg-transparent",
86
- danger: "border border-line-danger text-danger bg-transparent",
87
- info: "border border-info text-info bg-transparent",
88
- neutral: "border border-line text-fg-muted bg-transparent"
89
- }
90
- }.freeze
91
-
92
- BADGE_SIZES = {
93
- sm: "px-3 py-1.5 text-xs font-semibold",
94
- md: "px-3.5 py-1.5 text-sm font-semibold"
95
- }.freeze
96
-
97
- CARD_VARIANTS = {
98
- elevated: "bg-surface shadow-card",
99
- frosted: "bg-surface/70 backdrop-blur-xl shadow-card",
100
- subtle: "bg-surface-subtle",
101
- muted: "bg-surface-muted",
102
- outlined: "bg-surface border border-line shadow-none"
103
- }.freeze
104
-
105
- AVATAR_SIZES = {
106
- xs: "w-6 h-6 min-w-6 min-h-6 text-[10px]",
107
- sm: "w-8 h-8 min-w-8 min-h-8 text-xs",
108
- md: "w-10 h-10 min-w-10 min-h-10 text-sm",
109
- lg: "w-12 h-12 min-w-12 min-h-12 text-base",
110
- xl: "w-16 h-16 min-w-16 min-h-16 text-xl"
111
- }.freeze
112
-
113
- AVATAR_COLORS = {
114
- secondary: "bg-secondary-100 text-secondary-900",
115
- primary: "bg-primary-100 text-primary-700",
116
- neutral: "bg-surface-muted text-fg"
117
- }.freeze
5
+ # =========================================================================
6
+ # Helpers for ODT-Lightweight-UI
7
+ # Standalone CSS First with seamless Tailwind CSS v4 support.
8
+ # =========================================================================
118
9
 
10
+ # Renders an ODT Button component.
11
+ #
119
12
  # @param text [String, nil]
120
- # @param variant [:filled, :light, :outline, :subtle, :ghost]
121
- # @param color [:primary, :secondary, :success, :warning, :danger, :neutral]
13
+ # @param variant [:filled, :frosted, :capsule, :ghost, :light, :outline, :outlined, :subtle]
14
+ # @param color [:primary, :secondary, :surface, :muted, :inverse, :success, :warning, :danger, :info, :neutral]
122
15
  # @param size [:xs, :sm, :md, :lg, :xl]
123
- # @param radius [:none, :xs, :sm, :md, :lg, :xl, :"2xl", :"3xl", :"4xl", :full]
16
+ # @param radius [:none, :xs, :sm, :md, :lg, :xl, :"2xl", :"3xl", :"4xl", :full, nil]
17
+ # @param shadow [:none, :card, :dropdown, :modal, :popover, nil]
18
+ # @param icon_only [Boolean]
19
+ # @param full_width [Boolean]
20
+ # @param loading [Boolean]
21
+ # @param disabled [Boolean]
124
22
  # @param href [String, nil]
125
23
  # @param icon [String, nil]
126
24
  # @param right_icon [String, nil]
127
- # @param full_width [Boolean]
128
- # @param disabled [Boolean]
129
25
  # @param type [String]
130
26
  # @return [ActiveSupport::SafeBuffer]
131
- def odt_button(text = nil, variant: :filled, color: :primary, size: :md, radius: :full, href: nil, icon: nil, right_icon: nil, full_width: false, disabled: false, type: "button", **html_options, &block)
132
- variant_key = variant.to_sym
133
- color_key = color.to_sym
134
- size_key = size.to_sym
135
- radius_key = radius.to_s.to_sym
136
-
137
- base_class = "inline-flex items-center justify-center font-medium transition-all duration-200 select-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none cursor-pointer"
138
-
139
- variant_map = BUTTON_VARIANTS[variant_key] || BUTTON_VARIANTS[:filled]
140
- style_class = variant_map[color_key] || variant_map[:primary] || variant_map.values.first
141
- size_class = BUTTON_SIZES[size_key] || BUTTON_SIZES[:md]
142
- radius_class = RADIUS_CLASSES[radius_key] || RADIUS_CLASSES[:full]
143
- width_class = full_width ? "w-full" : ""
144
-
145
- classes = [base_class, style_class, size_class, radius_class, width_class, html_options[:class]].compact.join(" ")
146
- opts = html_options.merge(class: classes)
147
- opts[:disabled] = true if disabled
27
+ def odt_button(text = nil, variant: :filled, color: :primary, size: :md, radius: nil, shadow: nil, icon_only: false, full_width: false, loading: false, disabled: false, href: nil, icon: nil, right_icon: nil, type: "button", **html_options, &block)
28
+ variant_key = normalize_button_variant(variant)
29
+ color_key = color.to_s.tr("_", "-")
30
+ size_key = size.to_s.tr("_", "-")
31
+
32
+ classes = [
33
+ "odt-btn",
34
+ "odt-btn--#{variant_key}-#{color_key}",
35
+ "odt-btn--#{size_key}"
36
+ ]
37
+
38
+ classes << "odt-btn--capsule" if variant.to_sym == :capsule
39
+ classes << "odt-radius-#{radius.to_s.tr('_', '-')}" if radius.present?
40
+ classes << "odt-shadow-#{shadow.to_s.tr('_', '-')}" if shadow.present?
41
+ classes << "odt-btn--full-width" if full_width
42
+ classes << "odt-btn--icon-only" if icon_only
43
+ classes << "odt-btn--loading" if loading
44
+ classes << "odt-btn--disabled" if disabled
45
+ classes << html_options.delete(:class)
46
+
47
+ opts = html_options.merge(class: classes.compact.reject(&:blank?).join(" "))
48
+ opts[:disabled] = true if disabled || loading
49
+ opts[:aria] = (opts[:aria] || {}).merge(busy: true) if loading
148
50
 
149
51
  parts = []
150
- parts << (icon.start_with?("<") ? icon.html_safe : tag.i(class: icon)) if icon.present?
151
- parts << (block_given? ? capture(&block) : text)
152
- parts << (right_icon.start_with?("<") ? right_icon.html_safe : tag.i(class: right_icon)) if right_icon.present?
52
+ if loading
53
+ parts << tag.span(class: "odt-spinner", aria: { hidden: "true" })
54
+ elsif icon.present?
55
+ parts << render_icon_element(icon)
56
+ end
57
+
58
+ button_text = block_given? ? capture(&block) : text
59
+ if button_text.present?
60
+ parts << (icon_only ? button_text : tag.span(button_text))
61
+ end
62
+
63
+ if right_icon.present? && !loading
64
+ parts << render_icon_element(right_icon)
65
+ end
66
+
153
67
  inner_content = safe_join(parts.compact, " ")
154
68
 
155
69
  if href.present?
@@ -160,179 +74,652 @@ module OdtUi
160
74
  end
161
75
  end
162
76
 
77
+ # Renders an ODT Badge component.
78
+ #
163
79
  # @param text [String, nil]
164
- # @param variant [:subtle, :filled, :outline]
80
+ # @param variant [:subtle, :filled, :outlined, :outline]
165
81
  # @param color [:primary, :secondary, :success, :warning, :danger, :info, :neutral]
166
82
  # @param size [:sm, :md]
167
- # @param radius [:none, :xs, :sm, :md, :lg, :xl, :"2xl", :"3xl", :"4xl", :full]
83
+ # @param radius [:none, :xs, :sm, :md, :lg, :xl, :"2xl", :"3xl", :"4xl", :full, nil]
168
84
  # @param dot [Boolean]
169
85
  # @param icon [String, nil]
170
86
  # @return [ActiveSupport::SafeBuffer]
171
- def odt_badge(text = nil, variant: :subtle, color: :neutral, size: :sm, radius: :full, dot: false, icon: nil, **html_options, &block)
172
- variant_key = variant.to_sym
173
- color_key = color.to_sym
174
- size_key = size.to_sym
175
- radius_key = radius.to_s.to_sym
87
+ def odt_badge(text = nil, variant: :subtle, color: :primary, size: :md, radius: nil, dot: false, icon: nil, **html_options, &block)
88
+ variant_key = variant.to_sym == :outline ? "outlined" : variant.to_s.tr("_", "-")
89
+ color_key = color.to_s.tr("_", "-")
90
+ size_key = size.to_s.tr("_", "-")
176
91
 
177
- base_class = "inline-flex items-center gap-1.5 leading-none transition-colors select-none font-medium shrink-0"
178
- variant_map = BADGE_VARIANTS[variant_key] || BADGE_VARIANTS[:subtle]
179
- style_class = variant_map[color_key] || variant_map[:neutral] || variant_map.values.first
180
- size_class = BADGE_SIZES[size_key] || BADGE_SIZES[:sm]
181
- radius_class = RADIUS_CLASSES[radius_key] || RADIUS_CLASSES[:full]
92
+ classes = [
93
+ "odt-badge",
94
+ "odt-badge--#{variant_key}-#{color_key}",
95
+ "odt-badge--#{size_key}"
96
+ ]
182
97
 
183
- classes = [base_class, style_class, size_class, radius_class, html_options[:class]].compact.join(" ")
184
- opts = html_options.merge(class: classes)
98
+ classes << "odt-radius-#{radius.to_s.tr('_', '-')}" if radius.present?
99
+ classes << html_options.delete(:class)
100
+
101
+ opts = html_options.merge(class: classes.compact.reject(&:blank?).join(" "))
185
102
 
186
103
  parts = []
187
- parts << tag.span(class: "w-1.5 h-1.5 rounded-full bg-current opacity-80 shrink-0") if dot
188
- parts << (icon.start_with?("<") ? icon.html_safe : tag.i(class: icon)) if icon.present?
189
- parts << (block_given? ? capture(&block) : text)
190
- inner_content = safe_join(parts.compact, " ")
104
+ parts << tag.span(class: "odt-badge__dot", aria: { hidden: "true" }) if dot
105
+ parts << render_icon_element(icon) if icon.present?
191
106
 
107
+ badge_text = block_given? ? capture(&block) : text
108
+ parts << tag.span(badge_text) if badge_text.present?
109
+
110
+ inner_content = safe_join(parts.compact, " ")
192
111
  tag.span(inner_content, **opts)
193
112
  end
194
113
 
114
+ # Renders an ODT Card container.
115
+ #
195
116
  # @param title [String, nil]
196
117
  # @param subtitle [String, nil]
197
- # @param variant [:elevated, :frosted, :subtle, :muted, :outlined]
198
- # @param radius [:none, :xs, :sm, :md, :lg, :xl, :"2xl", :"3xl", :"4xl", :full]
199
- # @param border [Boolean, Symbol, String]
200
- # @param hover [Boolean]
118
+ # @param variant [:elevated, :outlined, :subtle, :muted, :ghost, :frosted]
119
+ # @param background [Symbol, String, nil]
120
+ # @param border [Symbol, String, Boolean, nil]
121
+ # @param shadow [Symbol, String, nil]
122
+ # @param radius [Symbol, String, nil]
123
+ # @param padding [Symbol, String, nil]
124
+ # @param hoverable [Boolean]
125
+ # @param hover [Boolean] Alias for hoverable
126
+ # @param as [:div, :article, :section, :aside, :main]
201
127
  # @return [ActiveSupport::SafeBuffer]
202
- def odt_card(title: nil, subtitle: nil, variant: :frosted, radius: :"3xl", border: false, hover: true, **html_options, &block)
203
- variant_class = CARD_VARIANTS[variant.to_sym] || CARD_VARIANTS[:frosted]
204
- radius_class = RADIUS_CLASSES[radius.to_s.to_sym] || RADIUS_CLASSES[:"3xl"]
205
- hover_class = hover ? "hover:shadow-primary-200/50 hover:-translate-y-0.5 transition-all duration-300" : ""
206
- border_class = if border == true
207
- "border border-line"
208
- elsif border.is_a?(Symbol) || border.is_a?(String)
209
- "border border-#{border}"
210
- else
211
- "border-0"
128
+ def odt_card(title: nil, subtitle: nil, variant: :elevated, background: nil, border: nil, shadow: nil, radius: nil, padding: nil, hoverable: false, hover: false, as: :div, **html_options, &block)
129
+ tag_name = as.to_sym
130
+ variant_key = variant.to_s.tr("_", "-")
131
+
132
+ classes = ["odt-card", "odt-card--#{variant_key}"]
133
+
134
+ if background.present?
135
+ bg_key = background.to_s.tr("_", "-")
136
+ classes << "odt-card--bg-#{bg_key}"
212
137
  end
213
138
 
214
- classes = ["p-6 flex flex-col h-full", variant_class, border_class, radius_class, hover_class, html_options[:class]].compact.join(" ")
215
- opts = html_options.merge(class: classes)
139
+ if border.present?
140
+ if border == true
141
+ classes << "odt-card--border-base"
142
+ else
143
+ classes << "odt-card--border-#{border.to_s.tr('_', '-')}"
144
+ end
145
+ end
146
+
147
+ classes << "odt-shadow-#{shadow.to_s.tr('_', '-')}" if shadow.present?
148
+ classes << "odt-radius-#{radius.to_s.tr('_', '-')}" if radius.present?
149
+ classes << "odt-card--padding-#{padding.to_s.tr('_', '-')}" if padding.present?
150
+ classes << "odt-card--hoverable" if hoverable || hover
151
+ classes << html_options.delete(:class)
216
152
 
217
- header = if title.present?
218
- tag.div(class: "mb-3") do
219
- concat tag.h3(title, class: "text-xl font-bold text-fg-strong leading-tight")
220
- concat tag.p(subtitle, class: "text-sm text-fg-muted mt-1") if subtitle.present?
153
+ opts = html_options.merge(class: classes.compact.reject(&:blank?).join(" "))
154
+
155
+ header = if title.present? || subtitle.present?
156
+ tag.div(class: "odt-card__header") do
157
+ concat tag.h3(title, class: "odt-card__title") if title.present?
158
+ concat tag.p(subtitle, class: "odt-card__subtitle") if subtitle.present?
221
159
  end
222
160
  end
223
- content = block_given? ? capture(&block) : nil
224
- inner_content = safe_join([header, content].compact)
225
161
 
226
- tag.div(inner_content, **opts)
162
+ body_content = block_given? ? capture(&block) : nil
163
+ inner_content = safe_join([header, body_content].compact)
164
+
165
+ tag.public_send(tag_name, inner_content, **opts)
227
166
  end
228
167
 
168
+ # Renders an ODT Alert banner.
169
+ #
229
170
  # @param message [String, nil]
230
171
  # @param variant [:success, :warning, :danger, :info]
231
172
  # @param icon [String, nil]
232
173
  # @return [ActiveSupport::SafeBuffer]
233
174
  def odt_alert(message = nil, variant: :success, icon: nil, **html_options, &block)
234
- styles = {
235
- success: { bg: "bg-surface-success border-green-200 text-fg-success", icon: "fa-solid fa-circle-check" },
236
- warning: { bg: "bg-surface-warning border-amber-200 text-fg-warning", icon: "fa-solid fa-triangle-exclamation" },
237
- danger: { bg: "bg-surface-danger border-red-200 text-fg-danger", icon: "fa-solid fa-circle-xmark" },
238
- info: { bg: "bg-surface-info border-blue-200 text-fg-info", icon: "fa-solid fa-circle-info" }
175
+ variant_key = variant.to_sym
176
+ default_icons = {
177
+ success: "fa-solid fa-circle-check",
178
+ warning: "fa-solid fa-triangle-exclamation",
179
+ danger: "fa-solid fa-circle-xmark",
180
+ info: "fa-solid fa-circle-info"
239
181
  }
240
182
 
241
- config = styles[variant.to_sym] || styles[:info]
242
- icon_name = icon || config[:icon]
183
+ icon_name = icon || default_icons[variant_key] || default_icons[:info]
243
184
 
244
- classes = ["p-4 rounded-2xl border flex items-center gap-3 text-sm shadow-sm", config[:bg], html_options[:class]].compact.join(" ")
245
- opts = html_options.merge(class: classes)
185
+ classes = [
186
+ "odt-alert",
187
+ "odt-alert--#{variant_key}",
188
+ html_options.delete(:class)
189
+ ]
190
+
191
+ opts = html_options.merge(class: classes.compact.reject(&:blank?).join(" "), role: "alert")
246
192
 
247
193
  parts = []
248
- parts << tag.i(class: "#{icon_name} text-base shrink-0") if icon_name.present?
249
- parts << (block_given? ? capture(&block) : tag.p(message, class: "font-medium"))
250
- inner_content = safe_join(parts.compact)
194
+ parts << tag.span(render_icon_element(icon_name), class: "odt-alert__icon") if icon_name.present?
251
195
 
196
+ content = block_given? ? capture(&block) : message
197
+ parts << tag.div(content, class: "odt-alert__content") if content.present?
198
+
199
+ inner_content = safe_join(parts.compact)
252
200
  tag.div(inner_content, **opts)
253
201
  end
254
202
 
203
+ # Renders an ODT Heading element.
204
+ #
255
205
  # @param text [String, nil]
256
- # @param as [:h1, :h2, :h3, :h4, :h5]
206
+ # @param as [:h1, :h2, :h3, :h4, :h5, :h6]
207
+ # @param size [:5xl, :4xl, :3xl, :2xl, :xl, :lg, nil]
208
+ # @param weight [:light, :normal, :medium, :semibold, :bold, :extrabold, nil]
209
+ # @param color [:strong, :default, :muted, :subtle, :disabled, :primary, :secondary, :success, :warning, :danger, :info]
210
+ # @param truncate [Boolean]
257
211
  # @return [ActiveSupport::SafeBuffer]
258
- def odt_heading(text = nil, as: :h2, **html_options, &block)
212
+ def odt_heading(text = nil, as: :h2, size: nil, weight: nil, color: :strong, truncate: false, **html_options, &block)
259
213
  tag_name = as.to_sym
260
- heading_styles = {
261
- h1: "text-3xl font-bold text-fg-strong tracking-tight",
262
- h2: "text-2xl font-bold text-fg-strong tracking-tight",
263
- h3: "text-xl font-bold text-fg-strong leading-tight",
264
- h4: "text-lg font-semibold text-fg-strong",
265
- h5: "text-base font-semibold text-fg-strong"
214
+ default_sizes = {
215
+ h1: "4xl",
216
+ h2: "3xl",
217
+ h3: "2xl",
218
+ h4: "xl",
219
+ h5: "lg",
220
+ h6: "lg"
266
221
  }
267
222
 
268
- style_class = heading_styles[tag_name] || heading_styles[:h2]
269
- classes = [style_class, html_options[:class]].compact.join(" ")
270
- opts = html_options.merge(class: classes)
223
+ resolved_size = (size || default_sizes[tag_name] || "2xl").to_s.tr("_", "-")
224
+ resolved_weight = (weight || "bold").to_s.tr("_", "-")
225
+ resolved_color = (color || "strong").to_s.tr("_", "-")
226
+
227
+ classes = [
228
+ "odt-heading",
229
+ "odt-heading--#{resolved_size}",
230
+ "odt-weight-#{resolved_weight}",
231
+ "odt-color-#{resolved_color}"
232
+ ]
233
+
234
+ classes << "odt-truncate" if truncate
235
+ classes << html_options.delete(:class)
236
+
237
+ opts = html_options.merge(class: classes.compact.reject(&:blank?).join(" "))
271
238
  inner_content = block_given? ? capture(&block) : text
272
239
 
273
240
  tag.public_send(tag_name, inner_content, **opts)
274
241
  end
275
242
 
243
+ # Renders styled typography body text.
244
+ #
276
245
  # @param text [String, nil]
277
- # @param color [:default, :strong, :muted, :subtle, :primary]
278
- # @param size [:xs, :sm, :base, :lg]
246
+ # @param as [:p, :span, :div, :label, :small]
247
+ # @param size [:xs, :sm, :base, :lg, :xl, :"2xl"]
248
+ # @param weight [:light, :normal, :medium, :semibold, :bold, :extrabold, nil]
249
+ # @param color [:default, :strong, :muted, :subtle, :disabled, :primary, :secondary, :success, :warning, :danger, :info]
250
+ # @param truncate [Boolean]
279
251
  # @return [ActiveSupport::SafeBuffer]
280
- def odt_text(text = nil, color: :default, size: :base, **html_options, &block)
281
- color_styles = {
282
- default: "text-fg",
283
- strong: "text-fg-strong font-medium",
284
- muted: "text-fg-muted",
285
- subtle: "text-fg-subtle",
286
- primary: "text-primary-600 font-medium"
287
- }
252
+ def odt_text(text = nil, as: :p, size: :base, weight: nil, color: :default, truncate: false, **html_options, &block)
253
+ tag_name = as.to_sym
254
+ resolved_size = size.to_s.tr("_", "-")
255
+ resolved_color = color.to_s.tr("_", "-")
288
256
 
289
- size_styles = {
290
- xs: "text-xs",
291
- sm: "text-sm",
292
- base: "text-base",
293
- lg: "text-lg"
294
- }
257
+ classes = [
258
+ "odt-text",
259
+ "odt-text--#{resolved_size}",
260
+ "odt-color-#{resolved_color}"
261
+ ]
295
262
 
296
- c_class = color_styles[color.to_sym] || color_styles[:default]
297
- s_class = size_styles[size.to_sym] || size_styles[:base]
263
+ classes << "odt-weight-#{weight.to_s.tr('_', '-')}" if weight.present?
264
+ classes << "odt-truncate" if truncate
265
+ classes << html_options.delete(:class)
298
266
 
299
- classes = [c_class, s_class, html_options[:class]].compact.join(" ")
300
- opts = html_options.merge(class: classes)
267
+ opts = html_options.merge(class: classes.compact.reject(&:blank?).join(" "))
301
268
  inner_content = block_given? ? capture(&block) : text
302
269
 
303
- tag.p(inner_content, **opts)
270
+ tag.public_send(tag_name, inner_content, **opts)
304
271
  end
305
272
 
273
+ # Renders an ODT Avatar element.
274
+ #
306
275
  # @param name [String, nil]
307
276
  # @param src [String, nil]
308
- # @param size [:xs, :sm, :md, :lg, :xl]
277
+ # @param size [:xs, :sm, :md, :lg, :xl, :"2xl"]
309
278
  # @param color [:secondary, :primary, :neutral]
310
279
  # @param icon [String]
280
+ # @param fallback [String, nil]
311
281
  # @return [ActiveSupport::SafeBuffer]
312
- def odt_avatar(name: nil, src: nil, size: :sm, color: :secondary, icon: "fa-solid fa-user", **html_options)
313
- s_class = AVATAR_SIZES[size.to_sym] || AVATAR_SIZES[:sm]
314
- c_class = AVATAR_COLORS[color.to_sym] || AVATAR_COLORS[:secondary]
315
- classes = ["aspect-square rounded-full flex items-center justify-center font-bold uppercase select-none shrink-0 overflow-hidden ring-2 ring-white shadow-xs", s_class, c_class, html_options[:class]].compact.join(" ")
316
- opts = html_options.merge(class: classes)
317
-
318
- initials = if name.present?
319
- parts = name.strip.split(/\s+/)
320
- if parts.length == 1
321
- parts[0].slice(0, 2).upcase
322
- else
323
- "#{parts.first[0]}#{parts.last[0]}".upcase
324
- end
325
- end
282
+ def odt_avatar(name: nil, src: nil, size: :md, color: :secondary, icon: "fa-solid fa-user", fallback: nil, **html_options)
283
+ size_key = size.to_s.tr("_", "-")
284
+ color_key = color.to_s.tr("_", "-")
285
+
286
+ classes = [
287
+ "odt-avatar",
288
+ "odt-avatar--#{size_key}",
289
+ "odt-avatar--#{color_key}",
290
+ html_options.delete(:class)
291
+ ]
292
+
293
+ opts = html_options.merge(class: classes.compact.reject(&:blank?).join(" "))
294
+
295
+ initials = fallback.presence || compute_avatar_initials(name)
326
296
 
327
297
  inner_content = if src.present?
328
- image_tag(src, alt: name || "Avatar", class: "w-full h-full object-cover")
298
+ image_tag(src, alt: name || "Avatar", class: "odt-avatar__image")
329
299
  elsif initials.present?
330
- tag.span(initials, class: "leading-none")
300
+ tag.span(initials, class: "odt-avatar__fallback")
331
301
  else
332
- tag.i(class: icon)
302
+ tag.span(render_icon_element(icon), class: "odt-avatar__fallback")
333
303
  end
334
304
 
335
305
  tag.div(inner_content, **opts)
336
306
  end
307
+
308
+ # Renders an ODT text/number/email/password input.
309
+ #
310
+ # @param label [String, nil]
311
+ # @param variant [:outlined, :filled, :frosted]
312
+ # @param size [:sm, :md, :lg]
313
+ # @param radius [:none, :xs, :sm, :md, :lg, :xl, :"2xl", :"3xl", :"4xl", :full, nil]
314
+ # @param type [String]
315
+ # @param placeholder [String, nil]
316
+ # @param error [String, nil]
317
+ # @param helper [String, nil]
318
+ # @param helper_text [String, nil] Alias for helper
319
+ # @param success [String, nil]
320
+ # @param left_icon [String, nil]
321
+ # @param right_icon [String, nil]
322
+ # @param prefix [String, nil]
323
+ # @param suffix [String, nil]
324
+ # @param addon_before [String, nil]
325
+ # @param addon_after [String, nil]
326
+ # @param disabled [Boolean]
327
+ # @param full_width [Boolean]
328
+ # @return [ActiveSupport::SafeBuffer]
329
+ def odt_input(label: nil, variant: :outlined, size: :md, radius: nil, type: "text", placeholder: nil, error: nil, helper: nil, helper_text: nil, success: nil, left_icon: nil, right_icon: nil, prefix: nil, suffix: nil, addon_before: nil, addon_after: nil, disabled: false, full_width: true, **html_options)
330
+ variant_key = variant.to_s.tr("_", "-")
331
+ size_key = size.to_s.tr("_", "-")
332
+ resolved_helper = helper.presence || helper_text.presence
333
+ has_error = error.present?
334
+ has_success = success.present? && !has_error
335
+
336
+ input_classes = [
337
+ "odt-input",
338
+ "odt-input--#{variant_key}",
339
+ "odt-input--#{size_key}"
340
+ ]
341
+
342
+ input_classes << "odt-radius-#{radius.to_s.tr('_', '-')}" if radius.present?
343
+ input_classes << "odt-input--error" if has_error
344
+ input_classes << "odt-input--success" if has_success
345
+ input_classes << "odt-input--has-left-icon" if left_icon.present? || prefix.present?
346
+ input_classes << "odt-input--has-right-icon" if right_icon.present? || suffix.present?
347
+ input_classes << "odt-input--has-addon-before" if addon_before.present?
348
+ input_classes << "odt-input--has-addon-after" if addon_after.present?
349
+ input_classes << html_options.delete(:class)
350
+
351
+ input_opts = html_options.merge(
352
+ type: type,
353
+ class: input_classes.compact.reject(&:blank?).join(" "),
354
+ disabled: disabled
355
+ )
356
+ input_opts[:placeholder] = placeholder if placeholder.present?
357
+
358
+ container_classes = ["odt-input-container"]
359
+ container_classes << "odt-input-container--full-width" if full_width
360
+
361
+ tag.div(class: container_classes.join(" ")) do
362
+ parts = []
363
+ parts << tag.label(label, class: "odt-label") if label.present?
364
+
365
+ input_field = tag.div(class: "odt-input-wrapper") do
366
+ wrap_parts = []
367
+ if left_icon.present?
368
+ wrap_parts << tag.span(render_icon_element(left_icon), class: "odt-input__left-icon")
369
+ elsif prefix.present?
370
+ wrap_parts << tag.span(prefix, class: "odt-input__left-icon")
371
+ end
372
+
373
+ wrap_parts << tag.input(**input_opts)
374
+
375
+ if right_icon.present?
376
+ wrap_parts << tag.span(render_icon_element(right_icon), class: "odt-input__right-icon")
377
+ elsif suffix.present?
378
+ wrap_parts << tag.span(suffix, class: "odt-input__right-icon")
379
+ end
380
+
381
+ safe_join(wrap_parts)
382
+ end
383
+
384
+ if addon_before.present? || addon_after.present?
385
+ parts << tag.div(class: "odt-input__addon-group") do
386
+ addon_parts = []
387
+ addon_parts << tag.span(addon_before, class: "odt-input__addon-before") if addon_before.present?
388
+ addon_parts << input_field
389
+ addon_parts << tag.span(addon_after, class: "odt-input__addon-after") if addon_after.present?
390
+ safe_join(addon_parts)
391
+ end
392
+ else
393
+ parts << input_field
394
+ end
395
+
396
+ if has_error
397
+ parts << tag.p(error, class: "odt-error-text", role: "alert")
398
+ elsif has_success
399
+ parts << tag.p(success, class: "odt-success-text")
400
+ elsif resolved_helper.present?
401
+ parts << tag.p(resolved_helper, class: "odt-helper-text")
402
+ end
403
+
404
+ safe_join(parts)
405
+ end
406
+ end
407
+
408
+ # Renders an ODT textarea field.
409
+ #
410
+ # @param label [String, nil]
411
+ # @param variant [:outlined, :filled, :frosted]
412
+ # @param size [:sm, :md, :lg]
413
+ # @param radius [:none, :xs, :sm, :md, :lg, :xl, :"2xl", :"3xl", :"4xl", :full, nil]
414
+ # @param rows [Integer]
415
+ # @param resize [:none, :vertical, :horizontal, :both]
416
+ # @param error [String, nil]
417
+ # @param helper [String, nil]
418
+ # @param helper_text [String, nil]
419
+ # @param success [String, nil]
420
+ # @param disabled [Boolean]
421
+ # @param full_width [Boolean]
422
+ # @return [ActiveSupport::SafeBuffer]
423
+ def odt_textarea(label: nil, variant: :outlined, size: :md, radius: nil, rows: 4, resize: :vertical, error: nil, helper: nil, helper_text: nil, success: nil, disabled: false, full_width: true, **html_options, &block)
424
+ variant_key = variant.to_s.tr("_", "-")
425
+ size_key = size.to_s.tr("_", "-")
426
+ resize_key = resize.to_s.tr("_", "-")
427
+ resolved_helper = helper.presence || helper_text.presence
428
+ has_error = error.present?
429
+ has_success = success.present? && !has_error
430
+
431
+ classes = [
432
+ "odt-textarea",
433
+ "odt-textarea--#{variant_key}",
434
+ "odt-textarea--#{size_key}",
435
+ "odt-textarea--resize-#{resize_key}"
436
+ ]
437
+
438
+ classes << "odt-radius-#{radius.to_s.tr('_', '-')}" if radius.present?
439
+ classes << "odt-textarea--error" if has_error
440
+ classes << "odt-textarea--success" if has_success
441
+ classes << html_options.delete(:class)
442
+
443
+ opts = html_options.merge(
444
+ class: classes.compact.reject(&:blank?).join(" "),
445
+ rows: rows,
446
+ disabled: disabled
447
+ )
448
+
449
+ content = block_given? ? capture(&block) : html_options.delete(:value)
450
+
451
+ container_classes = ["odt-input-container"]
452
+ container_classes << "odt-input-container--full-width" if full_width
453
+
454
+ tag.div(class: container_classes.join(" ")) do
455
+ parts = []
456
+ parts << tag.label(label, class: "odt-label") if label.present?
457
+ parts << tag.textarea(content, **opts)
458
+
459
+ if has_error
460
+ parts << tag.p(error, class: "odt-error-text", role: "alert")
461
+ elsif has_success
462
+ parts << tag.p(success, class: "odt-success-text")
463
+ elsif resolved_helper.present?
464
+ parts << tag.p(resolved_helper, class: "odt-helper-text")
465
+ end
466
+
467
+ safe_join(parts)
468
+ end
469
+ end
470
+
471
+ # Renders an ODT select dropdown.
472
+ #
473
+ # @param label [String, nil]
474
+ # @param options [Array] Options compatible with Rails options_for_select
475
+ # @param variant [:outlined, :filled, :frosted]
476
+ # @param size [:sm, :md, :lg]
477
+ # @param radius [:none, :xs, :sm, :md, :lg, :xl, :"2xl", :"3xl", :"4xl", :full, nil]
478
+ # @param prompt [String, nil]
479
+ # @param error [String, nil]
480
+ # @param helper [String, nil]
481
+ # @param helper_text [String, nil]
482
+ # @param success [String, nil]
483
+ # @param disabled [Boolean]
484
+ # @param full_width [Boolean]
485
+ # @return [ActiveSupport::SafeBuffer]
486
+ def odt_select(label: nil, options: [], variant: :outlined, size: :md, radius: nil, prompt: nil, error: nil, helper: nil, helper_text: nil, success: nil, disabled: false, full_width: true, **html_options)
487
+ variant_key = variant.to_s.tr("_", "-")
488
+ size_key = size.to_s.tr("_", "-")
489
+ resolved_helper = helper.presence || helper_text.presence
490
+ has_error = error.present?
491
+ has_success = success.present? && !has_error
492
+
493
+ classes = [
494
+ "odt-select",
495
+ "odt-select--#{variant_key}",
496
+ "odt-select--#{size_key}"
497
+ ]
498
+
499
+ classes << "odt-radius-#{radius.to_s.tr('_', '-')}" if radius.present?
500
+ classes << "odt-select--error" if has_error
501
+ classes << "odt-select--success" if has_success
502
+ classes << html_options.delete(:class)
503
+
504
+ opts = html_options.merge(
505
+ class: classes.compact.reject(&:blank?).join(" "),
506
+ disabled: disabled
507
+ )
508
+
509
+ option_tags = if prompt.present?
510
+ tag.option(prompt, value: "") + (options.is_a?(String) ? options.html_safe : options_for_select(options))
511
+ else
512
+ options.is_a?(String) ? options.html_safe : options_for_select(options)
513
+ end
514
+
515
+ container_classes = ["odt-input-container"]
516
+ container_classes << "odt-input-container--full-width" if full_width
517
+
518
+ tag.div(class: container_classes.join(" ")) do
519
+ parts = []
520
+ parts << tag.label(label, class: "odt-label") if label.present?
521
+ parts << tag.select(option_tags, **opts)
522
+
523
+ if has_error
524
+ parts << tag.p(error, class: "odt-error-text", role: "alert")
525
+ elsif has_success
526
+ parts << tag.p(success, class: "odt-success-text")
527
+ elsif resolved_helper.present?
528
+ parts << tag.p(resolved_helper, class: "odt-helper-text")
529
+ end
530
+
531
+ safe_join(parts)
532
+ end
533
+ end
534
+
535
+ # Renders an ODT styled Checkbox.
536
+ #
537
+ # @param label [String, nil]
538
+ # @param description [String, nil]
539
+ # @param checked [Boolean]
540
+ # @param color [:primary, :secondary, :success, :warning, :danger, :dark, :neutral]
541
+ # @param size [:sm, :md, :lg]
542
+ # @param radius [:none, :xs, :sm, :md, :lg, :full, nil]
543
+ # @param disabled [Boolean]
544
+ # @param error [String, nil]
545
+ # @return [ActiveSupport::SafeBuffer]
546
+ def odt_checkbox(label: nil, description: nil, checked: false, color: :primary, size: :md, radius: nil, disabled: false, error: nil, **html_options)
547
+ color_key = color.to_sym == :dark ? "neutral" : color.to_s.tr("_", "-")
548
+ size_key = size.to_s.tr("_", "-")
549
+ has_error = error.present?
550
+
551
+ classes = [
552
+ "odt-checkbox",
553
+ "odt-checkbox--#{color_key}",
554
+ "odt-checkbox--#{size_key}"
555
+ ]
556
+
557
+ classes << "odt-radius-#{radius.to_s.tr('_', '-')}" if radius.present?
558
+ classes << "odt-checkbox--error" if has_error
559
+ classes << html_options.delete(:class)
560
+
561
+ opts = html_options.merge(
562
+ type: "checkbox",
563
+ class: classes.compact.reject(&:blank?).join(" "),
564
+ disabled: disabled
565
+ )
566
+ opts[:checked] = "checked" if checked
567
+
568
+ tag.div(class: "odt-checkbox-wrapper") do
569
+ parts = []
570
+ parts << tag.label(class: "odt-checkbox-container") do
571
+ label_parts = [tag.input(**opts)]
572
+
573
+ if label.present? || description.present?
574
+ label_parts << tag.div(class: "odt-checkbox__label-content") do
575
+ d_parts = []
576
+ d_parts << tag.span(label, class: "odt-checkbox__label") if label.present?
577
+ d_parts << tag.span(description, class: "odt-checkbox__description") if description.present?
578
+ safe_join(d_parts)
579
+ end
580
+ end
581
+
582
+ safe_join(label_parts)
583
+ end
584
+
585
+ parts << tag.p(error, class: "odt-error-text", role: "alert") if has_error
586
+ safe_join(parts)
587
+ end
588
+ end
589
+
590
+ # Renders an ODT styled Radio button.
591
+ #
592
+ # @param label [String, nil]
593
+ # @param description [String, nil]
594
+ # @param checked [Boolean]
595
+ # @param color [:primary, :secondary, :success, :warning, :danger, :dark, :neutral]
596
+ # @param size [:sm, :md, :lg]
597
+ # @param disabled [Boolean]
598
+ # @param error [String, nil]
599
+ # @return [ActiveSupport::SafeBuffer]
600
+ def odt_radio(label: nil, description: nil, checked: false, color: :primary, size: :md, disabled: false, error: nil, **html_options)
601
+ color_key = color.to_sym == :dark ? "neutral" : color.to_s.tr("_", "-")
602
+ size_key = size.to_s.tr("_", "-")
603
+ has_error = error.present?
604
+
605
+ classes = [
606
+ "odt-radio",
607
+ "odt-radio--#{color_key}",
608
+ "odt-radio--#{size_key}",
609
+ html_options.delete(:class)
610
+ ]
611
+
612
+ opts = html_options.merge(
613
+ type: "radio",
614
+ class: classes.compact.reject(&:blank?).join(" "),
615
+ disabled: disabled
616
+ )
617
+ opts[:checked] = "checked" if checked
618
+
619
+ tag.div(class: "odt-radio-wrapper") do
620
+ parts = []
621
+ parts << tag.label(class: "odt-radio-container") do
622
+ label_parts = [tag.input(**opts)]
623
+
624
+ if label.present? || description.present?
625
+ label_parts << tag.div(class: "odt-radio__label-content") do
626
+ d_parts = []
627
+ d_parts << tag.span(label, class: "odt-radio__label") if label.present?
628
+ d_parts << tag.span(description, class: "odt-radio__description") if description.present?
629
+ safe_join(d_parts)
630
+ end
631
+ end
632
+
633
+ safe_join(label_parts)
634
+ end
635
+
636
+ parts << tag.p(error, class: "odt-error-text", role: "alert") if has_error
637
+ safe_join(parts)
638
+ end
639
+ end
640
+
641
+ # Renders a Form Group wrapper with label, slot, error, and helper texts.
642
+ #
643
+ # @param label [String, nil]
644
+ # @param error [String, nil]
645
+ # @param helper [String, nil]
646
+ # @param helper_text [String, nil]
647
+ # @param success [String, nil]
648
+ # @param required [Boolean]
649
+ # @return [ActiveSupport::SafeBuffer]
650
+ def odt_form_group(label: nil, error: nil, helper: nil, helper_text: nil, success: nil, required: false, **html_options, &block)
651
+ resolved_helper = helper.presence || helper_text.presence
652
+ has_error = error.present?
653
+ has_success = success.present? && !has_error
654
+
655
+ classes = ["odt-form-group", html_options.delete(:class)]
656
+ opts = html_options.merge(class: classes.compact.reject(&:blank?).join(" "))
657
+
658
+ tag.div(**opts) do
659
+ parts = []
660
+ if label.present?
661
+ label_classes = ["odt-label"]
662
+ label_classes << "odt-label--required" if required
663
+ parts << tag.label(label, class: label_classes.join(" "))
664
+ end
665
+
666
+ parts << capture(&block) if block_given?
667
+
668
+ if has_error
669
+ parts << tag.p(error, class: "odt-error-text", role: "alert")
670
+ elsif has_success
671
+ parts << tag.p(success, class: "odt-success-text")
672
+ elsif resolved_helper.present?
673
+ parts << tag.p(resolved_helper, class: "odt-helper-text")
674
+ end
675
+
676
+ safe_join(parts)
677
+ end
678
+ end
679
+
680
+ private
681
+
682
+ # Normalizes button variant key with backwards compatibility
683
+ def normalize_button_variant(variant)
684
+ case variant.to_sym
685
+ when :light
686
+ "light"
687
+ when :outline, :outlined
688
+ "outline"
689
+ when :subtle
690
+ "subtle"
691
+ when :frosted
692
+ "frosted"
693
+ when :capsule
694
+ "capsule"
695
+ when :ghost
696
+ "ghost"
697
+ else
698
+ "filled"
699
+ end
700
+ end
701
+
702
+ # Computes initials from a full name (e.g., "John Doe" -> "JD")
703
+ def compute_avatar_initials(name)
704
+ return nil if name.blank?
705
+
706
+ parts = name.strip.split(/\s+/)
707
+ if parts.length == 1
708
+ parts[0].slice(0, 2).upcase
709
+ else
710
+ "#{parts.first[0]}#{parts.last[0]}".upcase
711
+ end
712
+ end
713
+
714
+ # Safely renders an icon (SVG or HTML class)
715
+ def render_icon_element(icon)
716
+ return nil if icon.blank?
717
+
718
+ if icon.start_with?("<")
719
+ icon.html_safe
720
+ else
721
+ tag.i(class: icon, aria: { hidden: "true" })
722
+ end
723
+ end
337
724
  end
338
725
  end