odt-ui-rails 2.2.4 → 2.2.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.
data/lib/odt_ui/helper.rb CHANGED
@@ -2,28 +2,7 @@
2
2
 
3
3
  module OdtUi
4
4
  module Helper
5
- # =========================================================================
6
- # Helpers for ODT-Lightweight-UI
7
- # Standalone CSS First with seamless Tailwind CSS v4 support.
8
- # =========================================================================
9
-
10
- # Renders an ODT Button component.
11
- #
12
- # @param text [String, nil]
13
- # @param variant [:filled, :frosted, :capsule, :ghost, :light, :outline, :outlined, :subtle]
14
- # @param color [:primary, :secondary, :surface, :muted, :inverse, :success, :warning, :danger, :info, :neutral]
15
- # @param size [:xs, :sm, :md, :lg, :xl]
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]
22
- # @param href [String, nil]
23
- # @param icon [String, nil]
24
- # @param right_icon [String, nil]
25
- # @param type [String]
26
- # @return [ActiveSupport::SafeBuffer]
5
+
27
6
  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
7
  variant_key = normalize_button_variant(variant)
29
8
  color_key = color.to_s.tr("_", "-")
@@ -31,11 +10,10 @@ module OdtUi
31
10
 
32
11
  classes = [
33
12
  "odt-btn",
13
+ "odt-btn--#{variant_key}",
34
14
  "odt-btn--#{variant_key}-#{color_key}",
35
15
  "odt-btn--#{size_key}"
36
16
  ]
37
-
38
- classes << "odt-btn--capsule" if variant.to_sym == :capsule
39
17
  classes << "odt-radius-#{radius.to_s.tr('_', '-')}" if radius.present?
40
18
  classes << "odt-shadow-#{shadow.to_s.tr('_', '-')}" if shadow.present?
41
19
  classes << "odt-btn--full-width" if full_width
@@ -74,16 +52,6 @@ module OdtUi
74
52
  end
75
53
  end
76
54
 
77
- # Renders an ODT Badge component.
78
- #
79
- # @param text [String, nil]
80
- # @param variant [:subtle, :filled, :outlined, :outline]
81
- # @param color [:primary, :secondary, :success, :warning, :danger, :info, :neutral]
82
- # @param size [:sm, :md]
83
- # @param radius [:none, :xs, :sm, :md, :lg, :xl, :"2xl", :"3xl", :"4xl", :full, nil]
84
- # @param dot [Boolean]
85
- # @param icon [String, nil]
86
- # @return [ActiveSupport::SafeBuffer]
87
55
  def odt_badge(text = nil, variant: :subtle, color: :primary, size: :md, radius: nil, dot: false, icon: nil, **html_options, &block)
88
56
  variant_key = variant.to_sym == :outline ? "outlined" : variant.to_s.tr("_", "-")
89
57
  color_key = color.to_s.tr("_", "-")
@@ -91,6 +59,7 @@ module OdtUi
91
59
 
92
60
  classes = [
93
61
  "odt-badge",
62
+ "odt-badge--#{variant_key}",
94
63
  "odt-badge--#{variant_key}-#{color_key}",
95
64
  "odt-badge--#{size_key}"
96
65
  ]
@@ -111,20 +80,6 @@ module OdtUi
111
80
  tag.span(inner_content, **opts)
112
81
  end
113
82
 
114
- # Renders an ODT Card container.
115
- #
116
- # @param title [String, nil]
117
- # @param subtitle [String, nil]
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]
127
- # @return [ActiveSupport::SafeBuffer]
128
83
  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
84
  tag_name = as.to_sym
130
85
  variant_key = variant.to_s.tr("_", "-")
@@ -165,12 +120,6 @@ module OdtUi
165
120
  tag.public_send(tag_name, inner_content, **opts)
166
121
  end
167
122
 
168
- # Renders an ODT Alert banner.
169
- #
170
- # @param message [String, nil]
171
- # @param variant [:success, :warning, :danger, :info]
172
- # @param icon [String, nil]
173
- # @return [ActiveSupport::SafeBuffer]
174
123
  def odt_alert(message = nil, variant: :success, icon: nil, **html_options, &block)
175
124
  variant_key = variant.to_sym
176
125
  default_icons = {
@@ -200,15 +149,6 @@ module OdtUi
200
149
  tag.div(inner_content, **opts)
201
150
  end
202
151
 
203
- # Renders an ODT Heading element.
204
- #
205
- # @param text [String, nil]
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]
211
- # @return [ActiveSupport::SafeBuffer]
212
152
  def odt_heading(text = nil, as: :h2, size: nil, weight: nil, color: :strong, truncate: false, **html_options, &block)
213
153
  tag_name = as.to_sym
214
154
  default_sizes = {
@@ -240,15 +180,6 @@ module OdtUi
240
180
  tag.public_send(tag_name, inner_content, **opts)
241
181
  end
242
182
 
243
- # Renders styled typography body text.
244
- #
245
- # @param text [String, nil]
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]
251
- # @return [ActiveSupport::SafeBuffer]
252
183
  def odt_text(text = nil, as: :p, size: :base, weight: nil, color: :default, truncate: false, **html_options, &block)
253
184
  tag_name = as.to_sym
254
185
  resolved_size = size.to_s.tr("_", "-")
@@ -270,15 +201,6 @@ module OdtUi
270
201
  tag.public_send(tag_name, inner_content, **opts)
271
202
  end
272
203
 
273
- # Renders an ODT Avatar element.
274
- #
275
- # @param name [String, nil]
276
- # @param src [String, nil]
277
- # @param size [:xs, :sm, :md, :lg, :xl, :"2xl"]
278
- # @param color [:secondary, :primary, :neutral]
279
- # @param icon [String]
280
- # @param fallback [String, nil]
281
- # @return [ActiveSupport::SafeBuffer]
282
204
  def odt_avatar(name: nil, src: nil, size: :md, color: :secondary, icon: "fa-solid fa-user", fallback: nil, **html_options)
283
205
  size_key = size.to_s.tr("_", "-")
284
206
  color_key = color.to_s.tr("_", "-")
@@ -305,27 +227,6 @@ module OdtUi
305
227
  tag.div(inner_content, **opts)
306
228
  end
307
229
 
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
230
  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
231
  variant_key = variant.to_s.tr("_", "-")
331
232
  size_key = size.to_s.tr("_", "-")
@@ -405,21 +306,6 @@ module OdtUi
405
306
  end
406
307
  end
407
308
 
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
309
  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
310
  variant_key = variant.to_s.tr("_", "-")
425
311
  size_key = size.to_s.tr("_", "-")
@@ -468,81 +354,24 @@ module OdtUi
468
354
  end
469
355
  end
470
356
 
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
357
  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
358
+ render_custom_select(
359
+ label: label,
360
+ options: options,
361
+ variant: variant,
362
+ size: size,
363
+ radius: radius,
364
+ prompt: prompt,
365
+ error: error,
366
+ helper: helper,
367
+ helper_text: helper_text,
368
+ success: success,
369
+ disabled: disabled,
370
+ full_width: full_width,
371
+ **html_options
507
372
  )
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
373
  end
534
374
 
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
375
  def odt_checkbox(label: nil, description: nil, checked: false, color: :primary, size: :md, radius: nil, disabled: false, error: nil, **html_options)
547
376
  color_key = color.to_sym == :dark ? "neutral" : color.to_s.tr("_", "-")
548
377
  size_key = size.to_s.tr("_", "-")
@@ -587,16 +416,6 @@ module OdtUi
587
416
  end
588
417
  end
589
418
 
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
419
  def odt_radio(label: nil, description: nil, checked: false, color: :primary, size: :md, disabled: false, error: nil, **html_options)
601
420
  color_key = color.to_sym == :dark ? "neutral" : color.to_s.tr("_", "-")
602
421
  size_key = size.to_s.tr("_", "-")
@@ -638,15 +457,6 @@ module OdtUi
638
457
  end
639
458
  end
640
459
 
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
460
  def odt_form_group(label: nil, error: nil, helper: nil, helper_text: nil, success: nil, required: false, **html_options, &block)
651
461
  resolved_helper = helper.presence || helper_text.presence
652
462
  has_error = error.present?
@@ -677,12 +487,7 @@ module OdtUi
677
487
  end
678
488
  end
679
489
 
680
- # Renders an ODT Toaster viewport container.
681
- #
682
- # @param position [:top_right, :top_center, :top_left, :bottom_right, :bottom_center, :bottom_left]
683
- # @param id [String] Element ID for Turbo Stream targeting (default: "odt-toasts")
684
- # @return [ActiveSupport::SafeBuffer]
685
- def odt_toaster(position: :top_right, id: "odt-toasts", **html_options, &block)
490
+ def odt_toaster(position: :top_right, id: "odt-toasts", expand: true, **html_options, &block)
686
491
  pos_key = position.to_s.tr("_", "-")
687
492
  classes = [
688
493
  "odt-toaster",
@@ -690,44 +495,42 @@ module OdtUi
690
495
  html_options.delete(:class)
691
496
  ]
692
497
 
498
+ data_attrs = {
499
+ controller: "odt-toaster",
500
+ odt_toaster_position_value: pos_key,
501
+ odt_toaster_expand_value: expand,
502
+ action: "mouseenter->odt-toaster#hover mouseleave->odt-toaster#leave"
503
+ }.merge(html_options.delete(:data) || {})
504
+
693
505
  opts = html_options.merge(
694
506
  id: id,
695
507
  class: classes.compact.reject(&:blank?).join(" "),
696
508
  role: "region",
697
- aria: { live: "polite" }
509
+ aria: { live: "polite" },
510
+ data: data_attrs
698
511
  )
699
512
 
700
513
  inner_content = block_given? ? capture(&block) : nil
701
514
  tag.div(inner_content, **opts)
702
515
  end
703
516
 
704
- # Renders a single ODT Toast card with Stimulus controller.
705
- #
706
- # @param message [String, nil]
707
- # @param title [String, nil]
708
- # @param variant [:success, :warning, :danger, :error, :info, :loading, :frosted]
709
- # @param icon [String, nil]
710
- # @param duration [Integer] Auto-dismiss duration in milliseconds (0 to disable)
711
- # @param dismissible [Boolean] Whether to display close button
712
- # @param radius [:none, :xs, :sm, :md, :lg, :xl, :"2xl", :"3xl", :"4xl", :full, nil]
713
- # @return [ActiveSupport::SafeBuffer]
714
- def odt_toast(message = nil, title: nil, variant: :success, icon: nil, duration: 4000, dismissible: true, radius: nil, **html_options, &block)
715
- variant_key = variant.to_sym == :error ? :danger : variant.to_sym
716
- default_icons = {
717
- success: "fa-solid fa-circle-check",
718
- warning: "fa-solid fa-triangle-exclamation",
719
- danger: "fa-solid fa-circle-xmark",
720
- info: "fa-solid fa-circle-info",
721
- loading: "fa-solid fa-spinner"
722
- }
723
-
724
- icon_name = icon || default_icons[variant_key] || default_icons[:info]
517
+ def odt_toast(message = nil, title: nil, variant: :elevated, type: :default, icon: nil, duration: 4000, dismissible: true, radius: :"2xl", rich_colors: false, **html_options, &block)
518
+ semantic_types = %i[success warning danger error info loading]
519
+
520
+ if semantic_types.include?(variant.to_sym)
521
+ resolved_type = variant.to_sym == :error ? :danger : variant.to_sym
522
+ resolved_variant = :elevated
523
+ else
524
+ resolved_variant = variant.to_sym
525
+ resolved_type = (type.to_sym == :error ? :danger : type.to_sym)
526
+ end
725
527
 
726
528
  classes = [
727
529
  "odt-toast",
728
- "odt-toast--#{variant_key.to_s.tr('_', '-')}"
530
+ "odt-toast--variant-#{resolved_variant.to_s.tr('_', '-')}"
729
531
  ]
730
532
  classes << "odt-radius-#{radius.to_s.tr('_', '-')}" if radius.present?
533
+ classes << "odt-toast--rich-#{resolved_type}" if rich_colors && resolved_type != :default
731
534
  classes << html_options.delete(:class)
732
535
 
733
536
  data_attrs = {
@@ -743,23 +546,54 @@ module OdtUi
743
546
  )
744
547
 
745
548
  parts = []
746
- if icon_name.present?
747
- icon_classes = ["odt-toast__icon", "odt-toast__icon--#{variant_key}"]
748
- parts << tag.span(render_icon_element(icon_name), class: icon_classes.join(" "))
549
+
550
+ if icon.present?
551
+ icon_classes = ["odt-toast__icon", "odt-toast__icon--#{resolved_type}"]
552
+ parts << tag.span(render_icon_element(icon), class: icon_classes.join(" "))
553
+ elsif resolved_type != :default
554
+ icon_classes = ["odt-toast__icon", "odt-toast__icon--#{resolved_type}"]
555
+ svg_icon = case resolved_type
556
+ when :success
557
+ tag.svg(tag.path(d: "M20 6L9 17l-5-5"), width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2.5", "stroke-linecap": "round", "stroke-linejoin": "round")
558
+ when :info
559
+ tag.svg(safe_join([tag.circle(cx: "12", cy: "12", r: "10"), tag.line(x1: "12", y1: "16", x2: "12", y2: "12"), tag.line(x1: "12", y1: "8", x2: "12.01", y2: "8")]), width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2.5", "stroke-linecap": "round", "stroke-linejoin": "round")
560
+ when :warning
561
+ tag.svg(safe_join([tag.path(d: "M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"), tag.line(x1: "12", y1: "9", x2: "12", y2: "13"), tag.line(x1: "12", y1: "17", x2: "12.01", y2: "17")]), width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2.5", "stroke-linecap": "round", "stroke-linejoin": "round")
562
+ when :danger
563
+ tag.svg(safe_join([tag.circle(cx: "12", cy: "12", r: "10"), tag.line(x1: "15", y1: "9", x2: "9", y2: "15"), tag.line(x1: "9", y1: "9", x2: "15", y2: "15")]), width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2.5", "stroke-linecap": "round", "stroke-linejoin": "round")
564
+ when :loading
565
+ tag.svg(tag.path(d: "M21 12a9 9 0 11-6.219-8.56"), width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2.5", "stroke-linecap": "round", "stroke-linejoin": "round")
566
+ end
567
+ parts << tag.span(svg_icon, class: icon_classes.join(" ")) if svg_icon
749
568
  end
750
569
 
751
570
  body_content = block_given? ? capture(&block) : message
752
571
 
753
572
  parts << tag.div(class: "odt-toast__content") do
754
573
  c_parts = []
755
- c_parts << tag.p(title, class: "odt-toast__title") if title.present?
756
- c_parts << tag.p(body_content, class: "odt-toast__message") if body_content.present?
574
+ if title.present?
575
+ c_parts << tag.p(title, class: "odt-toast__title")
576
+ c_parts << tag.p(body_content, class: "odt-toast__description") if body_content.present?
577
+ elsif body_content.present?
578
+ c_parts << tag.p(body_content, class: "odt-toast__title")
579
+ end
757
580
  safe_join(c_parts)
758
581
  end
759
582
 
760
583
  if dismissible
584
+ close_svg = tag.svg(
585
+ safe_join([tag.line(x1: "18", y1: "6", x2: "6", y2: "18"), tag.line(x1: "6", y1: "6", x2: "18", y2: "18")]),
586
+ width: "14",
587
+ height: "14",
588
+ viewBox: "0 0 24 24",
589
+ fill: "none",
590
+ stroke: "currentColor",
591
+ "stroke-width": "2.5",
592
+ "stroke-linecap": "round",
593
+ "stroke-linejoin": "round"
594
+ )
761
595
  parts << tag.button(
762
- "×".html_safe,
596
+ close_svg,
763
597
  type: "button",
764
598
  class: "odt-toast__close",
765
599
  aria: { label: "Close" },
@@ -771,11 +605,6 @@ module OdtUi
771
605
  tag.div(inner_content, **opts)
772
606
  end
773
607
 
774
- # Renders all active flash messages as ODT Toasts.
775
- #
776
- # @param flash_hash [ActionDispatch::Flash::FlashHash, Hash] Flash messages hash (default: flash)
777
- # @param duration [Integer] Auto-dismiss duration in milliseconds
778
- # @return [ActiveSupport::SafeBuffer]
779
608
  def odt_flash_toasts(flash_hash = flash, duration: 4000)
780
609
  return "".html_safe if flash_hash.blank?
781
610
 
@@ -800,15 +629,6 @@ module OdtUi
800
629
  safe_join(toasts)
801
630
  end
802
631
 
803
- # Generates a Turbo Stream action to render a toast asynchronously.
804
- #
805
- # @param message [String, nil]
806
- # @param title [String, nil]
807
- # @param variant [:success, :warning, :danger, :error, :info]
808
- # @param target [String] Target container ID (default: "odt-toasts")
809
- # @param action [:append, :prepend] Turbo stream action (default: :append)
810
- # @param duration [Integer] Auto-dismiss duration in ms (default: 4000)
811
- # @return [ActiveSupport::SafeBuffer]
812
632
  def odt_toast_stream(message = nil, title: nil, variant: :success, target: "odt-toasts", action: :append, duration: 4000, **options)
813
633
  toast_html = odt_toast(message, title: title, variant: variant, duration: duration, **options)
814
634
 
@@ -821,17 +641,6 @@ module OdtUi
821
641
  end
822
642
  end
823
643
 
824
- # Renders an ODT Modal dialog container with Stimulus controller.
825
- #
826
- # @param id [String, nil] Unique HTML ID of the modal
827
- # @param title [String, nil] Header title text
828
- # @param subtitle [String, nil] Header subtitle text
829
- # @param size [:sm, :md, :lg, :xl, :full] Dialog width size (default: :md)
830
- # @param radius [:none, :xs, :sm, :md, :lg, :xl, :"2xl", :"3xl", :"4xl", :full, nil]
831
- # @param dismissible [Boolean] Whether to render the header close button (default: true)
832
- # @param open [Boolean] Whether modal is initially open (default: false)
833
- # @param prevent_backdrop_close [Boolean] Prevent closing when clicking overlay (default: false)
834
- # @return [ActiveSupport::SafeBuffer]
835
644
  def odt_modal(id: nil, title: nil, subtitle: nil, size: :md, radius: :"2xl", dismissible: true, open: false, prevent_backdrop_close: false, **html_options, &block)
836
645
  size_key = size.to_s.tr("_", "-")
837
646
  radius_key = radius.to_s.tr("_", "-")
@@ -885,12 +694,6 @@ module OdtUi
885
694
  end
886
695
  end
887
696
 
888
- # Renders an ODT Modal Header subcomponent.
889
- #
890
- # @param title [String, nil]
891
- # @param subtitle [String, nil]
892
- # @param dismissible [Boolean]
893
- # @return [ActiveSupport::SafeBuffer]
894
697
  def odt_modal_header(title: nil, subtitle: nil, dismissible: true, **html_options, &block)
895
698
  classes = ["odt-modal__header", html_options.delete(:class)]
896
699
  opts = html_options.merge(class: classes.compact.reject(&:blank?).join(" "))
@@ -922,9 +725,6 @@ module OdtUi
922
725
  end
923
726
  end
924
727
 
925
- # Renders an ODT Modal Body subcomponent.
926
- #
927
- # @return [ActiveSupport::SafeBuffer]
928
728
  def odt_modal_body(**html_options, &block)
929
729
  classes = ["odt-modal__body", html_options.delete(:class)]
930
730
  opts = html_options.merge(class: classes.compact.reject(&:blank?).join(" "))
@@ -932,9 +732,6 @@ module OdtUi
932
732
  tag.div(content, **opts)
933
733
  end
934
734
 
935
- # Renders an ODT Modal Footer subcomponent.
936
- #
937
- # @return [ActiveSupport::SafeBuffer]
938
735
  def odt_modal_footer(**html_options, &block)
939
736
  classes = ["odt-modal__footer", html_options.delete(:class)]
940
737
  opts = html_options.merge(class: classes.compact.reject(&:blank?).join(" "))
@@ -942,9 +739,256 @@ module OdtUi
942
739
  tag.div(content, **opts)
943
740
  end
944
741
 
742
+ def odt_dropdown_menu(open: false, close_on_click_outside: true, close_on_select: true, full_width: false, **html_options, &block)
743
+ classes = [
744
+ "odt-dropdown",
745
+ ("odt-dropdown--full-width" if full_width),
746
+ html_options.delete(:class)
747
+ ].compact.reject(&:blank?)
748
+
749
+ data_attrs = {
750
+ controller: "odt-dropdown",
751
+ odt_dropdown_open_value: open,
752
+ odt_dropdown_close_on_click_outside_value: close_on_click_outside,
753
+ odt_dropdown_close_on_select_value: close_on_select
754
+ }.merge(html_options.delete(:data) || {})
755
+
756
+ opts = html_options.merge(
757
+ class: classes.join(" "),
758
+ data: data_attrs
759
+ )
760
+
761
+ tag.div(**opts, &block)
762
+ end
763
+
764
+ def odt_dropdown_trigger(text = nil, label: nil, helper: nil, helper_text: nil, error: nil, variant: :outlined, size: :md, radius: :xl, chevron: true, placeholder: nil, left_icon: nil, right_icon: nil, full_width: false, disabled: false, **html_options, &block)
765
+ variant_key = variant.to_s.tr("_", "-")
766
+ size_key = size.to_s.tr("_", "-")
767
+ radius_key = radius.to_s.tr("_", "-")
768
+ resolved_helper = helper.presence || helper_text.presence
769
+ has_error = error.present?
770
+
771
+ button_classes = [
772
+ "odt-dropdown__trigger",
773
+ "odt-dropdown__trigger--#{variant_key}",
774
+ "odt-dropdown__trigger--#{size_key}",
775
+ "odt-radius-#{radius_key}",
776
+ ("w-full" if full_width),
777
+ ("odt-dropdown__trigger--error" if has_error),
778
+ html_options.delete(:class)
779
+ ].compact.reject(&:blank?)
780
+
781
+ btn_data = {
782
+ odt_dropdown_target: "trigger",
783
+ action: "click->odt-dropdown#toggle keydown->odt-dropdown#navigate"
784
+ }.merge(html_options.delete(:data) || {})
785
+
786
+ btn_opts = html_options.merge(
787
+ type: "button",
788
+ class: button_classes.join(" "),
789
+ disabled: disabled,
790
+ aria: { haspopup: "menu", expanded: "false" },
791
+ data: btn_data
792
+ )
793
+
794
+ button_content = block_given? ? capture(&block) : text
795
+ content_parts = []
796
+ if left_icon.present?
797
+ content_parts << render_icon_element(left_icon)
798
+ end
799
+
800
+ span_class = button_content.blank? && placeholder.present? ? "odt-dropdown__trigger-text odt-dropdown__placeholder" : "odt-dropdown__trigger-text"
801
+ content_parts << tag.span(button_content.presence || placeholder, class: span_class, data: { odt_dropdown_target: "text" })
802
+
803
+ if right_icon.present?
804
+ content_parts << render_icon_element(right_icon)
805
+ elsif chevron
806
+ content_parts << tag.span(class: "odt-dropdown__chevron", data: { odt_dropdown_target: "chevron" }) do
807
+ tag.svg(width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round") do
808
+ tag.polyline(points: "6 9 12 15 18 9")
809
+ end
810
+ end
811
+ end
812
+
813
+ button_node = tag.button(safe_join(content_parts), **btn_opts)
814
+
815
+ if label.present? || resolved_helper.present? || has_error
816
+ container_classes = [
817
+ "odt-dropdown__container",
818
+ ("odt-dropdown__container--full-width" if full_width)
819
+ ].compact.join(" ")
820
+
821
+ tag.div(class: container_classes) do
822
+ wrapper_parts = []
823
+ wrapper_parts << tag.span(label, class: "odt-dropdown__label") if label.present?
824
+ wrapper_parts << button_node
825
+ if has_error
826
+ wrapper_parts << tag.p(error, class: "odt-error-text", role: "alert")
827
+ elsif resolved_helper.present?
828
+ wrapper_parts << tag.p(resolved_helper, class: "odt-helper-text")
829
+ end
830
+ safe_join(wrapper_parts)
831
+ end
832
+ else
833
+ button_node
834
+ end
835
+ end
836
+
837
+ def odt_dropdown_content(align: :start, side: :bottom, radius: :xl, shadow: :dropdown, full_width: false, **html_options, &block)
838
+ align_key = align.to_s.tr("_", "-")
839
+ side_key = side.to_s.tr("_", "-")
840
+ radius_key = radius.to_s.tr("_", "-")
841
+ shadow_key = shadow.to_s.tr("_", "-")
842
+
843
+ classes = [
844
+ "odt-dropdown__content",
845
+ "odt-dropdown__content--align-#{align_key}",
846
+ "odt-dropdown__content--side-#{side_key}",
847
+ "odt-radius-#{radius_key}",
848
+ "odt-shadow-#{shadow_key}",
849
+ ("w-full" if full_width),
850
+ "hidden",
851
+ html_options.delete(:class)
852
+ ].compact.reject(&:blank?)
853
+
854
+ opts = html_options.merge(
855
+ role: "menu",
856
+ tabindex: "-1",
857
+ class: classes.join(" "),
858
+ data: { odt_dropdown_target: "content" }.merge(html_options.delete(:data) || {})
859
+ )
860
+
861
+ tag.div(**opts, &block)
862
+ end
863
+
864
+ def odt_dropdown_item(text = nil, href: nil, left_icon: nil, right_icon: nil, shortcut: nil, destructive: false, disabled: false, inset: false, value: nil, **html_options, &block)
865
+ classes = [
866
+ "odt-dropdown__item",
867
+ ("odt-dropdown__item--destructive" if destructive),
868
+ ("odt-dropdown__item--disabled" if disabled),
869
+ ("odt-dropdown__item--inset" if inset),
870
+ html_options.delete(:class)
871
+ ].compact.reject(&:blank?)
872
+
873
+ data_attrs = {
874
+ action: "click->odt-dropdown#select"
875
+ }
876
+ data_attrs[:value] = value if value.present?
877
+ data_attrs.merge!(html_options.delete(:data) || {})
878
+
879
+ opts = html_options.merge(
880
+ role: "menuitem",
881
+ tabindex: disabled ? "-1" : "0",
882
+ class: classes.join(" "),
883
+ data: data_attrs
884
+ )
885
+ opts[:disabled] = true if disabled
886
+
887
+ content = block_given? ? capture(&block) : text
888
+ parts = []
889
+ if left_icon.present?
890
+ parts << tag.span(render_icon_element(left_icon), class: "odt-dropdown__item-icon")
891
+ end
892
+
893
+ parts << tag.span(content, class: "odt-dropdown__item-content")
894
+
895
+ if shortcut.present?
896
+ parts << tag.span(shortcut, class: "odt-dropdown__shortcut")
897
+ end
898
+
899
+ if right_icon.present?
900
+ parts << tag.span(render_icon_element(right_icon), class: "odt-dropdown__item-right-icon")
901
+ end
902
+
903
+ inner_content = safe_join(parts)
904
+
905
+ if href.present?
906
+ link_to(inner_content, href, opts)
907
+ else
908
+ opts[:type] = "button"
909
+ tag.button(inner_content, **opts)
910
+ end
911
+ end
912
+
913
+ def odt_dropdown_label(text = nil, inset: false, **html_options, &block)
914
+ classes = [
915
+ "odt-dropdown__label-heading",
916
+ ("odt-dropdown__label-heading--inset" if inset),
917
+ html_options.delete(:class)
918
+ ].compact.reject(&:blank?)
919
+
920
+ content = block_given? ? capture(&block) : text
921
+ tag.div(content, class: classes.join(" "), **html_options)
922
+ end
923
+
924
+ def odt_dropdown_separator(**html_options)
925
+ classes = ["odt-dropdown__separator", html_options.delete(:class)].compact.reject(&:blank?)
926
+ tag.hr(class: classes.join(" "), **html_options)
927
+ end
928
+
929
+ def odt_dropdown_shortcut(text = nil, **html_options, &block)
930
+ classes = ["odt-dropdown__shortcut", html_options.delete(:class)].compact.reject(&:blank?)
931
+ content = block_given? ? capture(&block) : text
932
+ tag.span(content, class: classes.join(" "), **html_options)
933
+ end
934
+
945
935
  private
946
936
 
947
- # Normalizes button variant key with backwards compatibility
937
+ def render_custom_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)
938
+ resolved_helper = helper.presence || helper_text.presence
939
+ name = html_options.delete(:name)
940
+ id = html_options.delete(:id)
941
+ selected_value = html_options.delete(:selected) || html_options.delete(:value)
942
+
943
+ items = if options.is_a?(Array)
944
+ options.map do |opt|
945
+ if opt.is_a?(Array)
946
+ [opt[0].to_s, opt[1].to_s]
947
+ else
948
+ [opt.to_s, opt.to_s]
949
+ end
950
+ end
951
+ else
952
+ []
953
+ end
954
+
955
+ selected_pair = items.find { |_, val| val == selected_value.to_s }
956
+ display_text = selected_pair ? selected_pair[0] : (prompt || items.first&.first || "")
957
+ current_value = selected_pair ? selected_pair[1] : (prompt ? "" : items.first&.last)
958
+
959
+ odt_dropdown_menu(full_width: full_width) do
960
+ parts = []
961
+ if name.present?
962
+ parts << tag.input(type: "hidden", name: name, value: current_value, id: id, data: { odt_dropdown_target: "hiddenInput" })
963
+ end
964
+
965
+ parts << odt_dropdown_trigger(
966
+ display_text,
967
+ label: label,
968
+ helper: resolved_helper,
969
+ error: error,
970
+ variant: variant,
971
+ size: size,
972
+ radius: radius || :xl,
973
+ full_width: full_width,
974
+ disabled: disabled
975
+ )
976
+
977
+ parts << odt_dropdown_content(radius: radius || :xl, full_width: full_width) do
978
+ item_parts = []
979
+ if prompt.present?
980
+ item_parts << odt_dropdown_item(prompt, value: "")
981
+ end
982
+ items.each do |lbl, val|
983
+ item_parts << odt_dropdown_item(lbl, value: val)
984
+ end
985
+ safe_join(item_parts)
986
+ end
987
+
988
+ safe_join(parts)
989
+ end
990
+ end
991
+
948
992
  def normalize_button_variant(variant)
949
993
  case variant.to_sym
950
994
  when :light
@@ -964,7 +1008,6 @@ module OdtUi
964
1008
  end
965
1009
  end
966
1010
 
967
- # Computes initials from a full name (e.g., "John Doe" -> "JD")
968
1011
  def compute_avatar_initials(name)
969
1012
  return nil if name.blank?
970
1013
 
@@ -976,7 +1019,6 @@ module OdtUi
976
1019
  end
977
1020
  end
978
1021
 
979
- # Safely renders an icon (SVG or HTML class)
980
1022
  def render_icon_element(icon)
981
1023
  return nil if icon.blank?
982
1024