view_primitives 0.1.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.
Files changed (140) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +84 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +198 -0
  5. data/lib/generators/view_primitives/add/add_generator.rb +110 -0
  6. data/lib/generators/view_primitives/add/templates/accordion/accordion_component.html.erb +10 -0
  7. data/lib/generators/view_primitives/add/templates/accordion/accordion_component.rb.tt +22 -0
  8. data/lib/generators/view_primitives/add/templates/accordion/accordion_controller.js +15 -0
  9. data/lib/generators/view_primitives/add/templates/accordion/accordion_item_component.rb.tt +47 -0
  10. data/lib/generators/view_primitives/add/templates/alert/alert_component.rb.tt +62 -0
  11. data/lib/generators/view_primitives/add/templates/alert_dialog/alert_dialog_component.rb.tt +55 -0
  12. data/lib/generators/view_primitives/add/templates/aspect_ratio/aspect_ratio_component.rb.tt +18 -0
  13. data/lib/generators/view_primitives/add/templates/audio/audio_component.rb.tt +51 -0
  14. data/lib/generators/view_primitives/add/templates/avatar/avatar_component.rb.tt +37 -0
  15. data/lib/generators/view_primitives/add/templates/badge/badge_component.rb.tt +35 -0
  16. data/lib/generators/view_primitives/add/templates/banner/banner_component.rb.tt +29 -0
  17. data/lib/generators/view_primitives/add/templates/bottom_nav/bottom_nav_component.rb.tt +38 -0
  18. data/lib/generators/view_primitives/add/templates/breadcrumb/breadcrumb_component.rb.tt +37 -0
  19. data/lib/generators/view_primitives/add/templates/button/button_component.rb.tt +61 -0
  20. data/lib/generators/view_primitives/add/templates/button_group/button_group_component.rb.tt +23 -0
  21. data/lib/generators/view_primitives/add/templates/calendar/calendar_component.rb.tt +121 -0
  22. data/lib/generators/view_primitives/add/templates/calendar/calendar_controller.js +86 -0
  23. data/lib/generators/view_primitives/add/templates/card/card_component.rb.tt +16 -0
  24. data/lib/generators/view_primitives/add/templates/card/card_content_component.rb.tt +16 -0
  25. data/lib/generators/view_primitives/add/templates/card/card_description_component.rb.tt +17 -0
  26. data/lib/generators/view_primitives/add/templates/card/card_footer_component.rb.tt +16 -0
  27. data/lib/generators/view_primitives/add/templates/card/card_header_component.rb.tt +17 -0
  28. data/lib/generators/view_primitives/add/templates/card/card_title_component.rb.tt +17 -0
  29. data/lib/generators/view_primitives/add/templates/carousel/carousel_component.rb.tt +102 -0
  30. data/lib/generators/view_primitives/add/templates/carousel/carousel_controller.js +48 -0
  31. data/lib/generators/view_primitives/add/templates/chart/chart_component.rb.tt +63 -0
  32. data/lib/generators/view_primitives/add/templates/chart/chart_controller.js +29 -0
  33. data/lib/generators/view_primitives/add/templates/chat_bubble/chat_bubble_component.rb.tt +53 -0
  34. data/lib/generators/view_primitives/add/templates/checkbox/checkbox_component.rb.tt +50 -0
  35. data/lib/generators/view_primitives/add/templates/collapsible/collapsible_component.rb.tt +31 -0
  36. data/lib/generators/view_primitives/add/templates/combobox/combobox_component.rb.tt +87 -0
  37. data/lib/generators/view_primitives/add/templates/combobox/combobox_controller.js +38 -0
  38. data/lib/generators/view_primitives/add/templates/command/command_component.rb.tt +85 -0
  39. data/lib/generators/view_primitives/add/templates/command/command_controller.js +50 -0
  40. data/lib/generators/view_primitives/add/templates/context_menu/context_menu_component.rb.tt +47 -0
  41. data/lib/generators/view_primitives/add/templates/context_menu/context_menu_controller.js +20 -0
  42. data/lib/generators/view_primitives/add/templates/data_table/data_table_component.rb.tt +163 -0
  43. data/lib/generators/view_primitives/add/templates/data_table/data_table_controller.js +115 -0
  44. data/lib/generators/view_primitives/add/templates/date_picker/date_picker_component.rb.tt +92 -0
  45. data/lib/generators/view_primitives/add/templates/date_picker/date_picker_controller.js +48 -0
  46. data/lib/generators/view_primitives/add/templates/device_mockup/device_mockup_component.rb.tt +65 -0
  47. data/lib/generators/view_primitives/add/templates/dialog/dialog_component.rb.tt +71 -0
  48. data/lib/generators/view_primitives/add/templates/dialog/dialog_controller.js +15 -0
  49. data/lib/generators/view_primitives/add/templates/drawer/drawer_component.rb.tt +62 -0
  50. data/lib/generators/view_primitives/add/templates/drawer/drawer_controller.js +15 -0
  51. data/lib/generators/view_primitives/add/templates/dropdown_menu/dropdown_controller.js +17 -0
  52. data/lib/generators/view_primitives/add/templates/dropdown_menu/dropdown_menu_component.rb.tt +53 -0
  53. data/lib/generators/view_primitives/add/templates/embed/embed_component.rb.tt +271 -0
  54. data/lib/generators/view_primitives/add/templates/embed/embed_controller.js +43 -0
  55. data/lib/generators/view_primitives/add/templates/figure/figure_component.rb.tt +24 -0
  56. data/lib/generators/view_primitives/add/templates/file_input/file_input_component.rb.tt +31 -0
  57. data/lib/generators/view_primitives/add/templates/floating_label/floating_label_component.rb.tt +54 -0
  58. data/lib/generators/view_primitives/add/templates/footer/footer_component.rb.tt +51 -0
  59. data/lib/generators/view_primitives/add/templates/form_field/form_field_component.rb.tt +51 -0
  60. data/lib/generators/view_primitives/add/templates/gallery/gallery_component.rb.tt +83 -0
  61. data/lib/generators/view_primitives/add/templates/gallery/gallery_controller.js +28 -0
  62. data/lib/generators/view_primitives/add/templates/hover_card/hover_card_component.rb.tt +43 -0
  63. data/lib/generators/view_primitives/add/templates/iframe/iframe_component.rb.tt +59 -0
  64. data/lib/generators/view_primitives/add/templates/image/image_component.rb.tt +38 -0
  65. data/lib/generators/view_primitives/add/templates/indicator/indicator_component.rb.tt +46 -0
  66. data/lib/generators/view_primitives/add/templates/input/input_component.rb.tt +28 -0
  67. data/lib/generators/view_primitives/add/templates/input_otp/input_otp_component.rb.tt +65 -0
  68. data/lib/generators/view_primitives/add/templates/input_otp/input_otp_controller.js +39 -0
  69. data/lib/generators/view_primitives/add/templates/kbd/kbd_component.rb.tt +21 -0
  70. data/lib/generators/view_primitives/add/templates/label/label_component.rb.tt +23 -0
  71. data/lib/generators/view_primitives/add/templates/list_group/list_group_component.rb.tt +16 -0
  72. data/lib/generators/view_primitives/add/templates/list_group/list_group_item_component.rb.tt +31 -0
  73. data/lib/generators/view_primitives/add/templates/map_area/map_area_component.rb.tt +72 -0
  74. data/lib/generators/view_primitives/add/templates/mega_menu/mega_menu_component.rb.tt +130 -0
  75. data/lib/generators/view_primitives/add/templates/mega_menu/mega_menu_controller.js +23 -0
  76. data/lib/generators/view_primitives/add/templates/menubar/menubar_component.rb.tt +33 -0
  77. data/lib/generators/view_primitives/add/templates/menubar/menubar_controller.js +34 -0
  78. data/lib/generators/view_primitives/add/templates/menubar/menubar_menu_component.rb.tt +34 -0
  79. data/lib/generators/view_primitives/add/templates/navbar/navbar_component.rb.tt +90 -0
  80. data/lib/generators/view_primitives/add/templates/navbar/navbar_controller.js +11 -0
  81. data/lib/generators/view_primitives/add/templates/navigation_menu/navigation_menu_component.rb.tt +132 -0
  82. data/lib/generators/view_primitives/add/templates/navigation_menu/navigation_menu_controller.js +25 -0
  83. data/lib/generators/view_primitives/add/templates/number_input/number_input_component.rb.tt +34 -0
  84. data/lib/generators/view_primitives/add/templates/pagination/pagination_component.rb.tt +97 -0
  85. data/lib/generators/view_primitives/add/templates/picture/picture_component.rb.tt +63 -0
  86. data/lib/generators/view_primitives/add/templates/popover/popover_component.rb.tt +56 -0
  87. data/lib/generators/view_primitives/add/templates/popover/popover_controller.js +17 -0
  88. data/lib/generators/view_primitives/add/templates/progress/progress_component.rb.tt +28 -0
  89. data/lib/generators/view_primitives/add/templates/qr_code/qr_code_component.rb.tt +39 -0
  90. data/lib/generators/view_primitives/add/templates/radio_group/radio_group_component.rb.tt +51 -0
  91. data/lib/generators/view_primitives/add/templates/range/range_component.rb.tt +40 -0
  92. data/lib/generators/view_primitives/add/templates/rating/rating_component.rb.tt +42 -0
  93. data/lib/generators/view_primitives/add/templates/rating_input/rating_controller.js +47 -0
  94. data/lib/generators/view_primitives/add/templates/rating_input/rating_input_component.rb.tt +79 -0
  95. data/lib/generators/view_primitives/add/templates/resizable/resizable_component.rb.tt +91 -0
  96. data/lib/generators/view_primitives/add/templates/resizable/resizable_controller.js +38 -0
  97. data/lib/generators/view_primitives/add/templates/scroll_area/scroll_area_component.rb.tt +41 -0
  98. data/lib/generators/view_primitives/add/templates/search_input/search_input_component.rb.tt +50 -0
  99. data/lib/generators/view_primitives/add/templates/select/select_component.rb.tt +45 -0
  100. data/lib/generators/view_primitives/add/templates/separator/separator_component.rb.tt +25 -0
  101. data/lib/generators/view_primitives/add/templates/sheet/sheet_component.rb.tt +78 -0
  102. data/lib/generators/view_primitives/add/templates/sheet/sheet_controller.js +15 -0
  103. data/lib/generators/view_primitives/add/templates/sidebar/sidebar_component.rb.tt +169 -0
  104. data/lib/generators/view_primitives/add/templates/sidebar/sidebar_controller.js +11 -0
  105. data/lib/generators/view_primitives/add/templates/skeleton/skeleton_component.rb.tt +16 -0
  106. data/lib/generators/view_primitives/add/templates/speed_dial/speed_dial_component.rb.tt +111 -0
  107. data/lib/generators/view_primitives/add/templates/speed_dial/speed_dial_controller.js +22 -0
  108. data/lib/generators/view_primitives/add/templates/spinner/spinner_component.rb.tt +27 -0
  109. data/lib/generators/view_primitives/add/templates/stepper/stepper_component.rb.tt +99 -0
  110. data/lib/generators/view_primitives/add/templates/switch/switch_component.rb.tt +51 -0
  111. data/lib/generators/view_primitives/add/templates/tabs/tabs_component.html.erb +50 -0
  112. data/lib/generators/view_primitives/add/templates/tabs/tabs_component.rb.tt +16 -0
  113. data/lib/generators/view_primitives/add/templates/tabs/tabs_controller.js +26 -0
  114. data/lib/generators/view_primitives/add/templates/tabs/tabs_item_component.rb.tt +15 -0
  115. data/lib/generators/view_primitives/add/templates/textarea/textarea_component.rb.tt +24 -0
  116. data/lib/generators/view_primitives/add/templates/timeline/timeline_component.rb.tt +78 -0
  117. data/lib/generators/view_primitives/add/templates/timepicker/timepicker_component.rb.tt +140 -0
  118. data/lib/generators/view_primitives/add/templates/timepicker/timepicker_controller.js +92 -0
  119. data/lib/generators/view_primitives/add/templates/toaster/toaster_component.rb.tt +152 -0
  120. data/lib/generators/view_primitives/add/templates/toaster/toaster_controller.js +88 -0
  121. data/lib/generators/view_primitives/add/templates/toggle/toggle_component.rb.tt +41 -0
  122. data/lib/generators/view_primitives/add/templates/toggle/toggle_controller.js +12 -0
  123. data/lib/generators/view_primitives/add/templates/toggle_group/toggle_group_component.rb.tt +32 -0
  124. data/lib/generators/view_primitives/add/templates/toggle_group/toggle_group_controller.js +38 -0
  125. data/lib/generators/view_primitives/add/templates/tooltip/tooltip_component.rb.tt +42 -0
  126. data/lib/generators/view_primitives/add/templates/video/video_component.rb.tt +92 -0
  127. data/lib/generators/view_primitives/add/templates/wysiwyg/wysiwyg_component.rb.tt +88 -0
  128. data/lib/generators/view_primitives/add/templates/wysiwyg/wysiwyg_controller.js +40 -0
  129. data/lib/generators/view_primitives/components.rb +62 -0
  130. data/lib/generators/view_primitives/detector.rb +43 -0
  131. data/lib/generators/view_primitives/install/install_generator.rb +65 -0
  132. data/lib/generators/view_primitives/install/templates/application_component.rb.tt +5 -0
  133. data/lib/generators/view_primitives/install/templates/view_primitives.css +67 -0
  134. data/lib/generators/view_primitives/list/list_generator.rb +25 -0
  135. data/lib/view_primitives/class_helper.rb +11 -0
  136. data/lib/view_primitives/component_helper.rb +20 -0
  137. data/lib/view_primitives/railtie.rb +21 -0
  138. data/lib/view_primitives/version.rb +5 -0
  139. data/lib/view_primitives.rb +12 -0
  140. metadata +267 -0
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class TimelineComponent < ApplicationComponent
5
+ # Vertical timeline of dated events.
6
+ #
7
+ # Usage:
8
+ # ui :timeline do |t|
9
+ # t.with_item(date: "Jan 2025", title: "Project started")
10
+ # t.with_item(date: "Feb 2025", title: "Milestone reached",
11
+ # description: "Foundation phase complete", variant: :success)
12
+ # t.with_item(date: "Mar 2025", title: "Issue detected", variant: :destructive)
13
+ # end
14
+
15
+ renders_many :items, "UI::TimelineComponent::ItemComponent"
16
+
17
+ def initialize(**html_attrs)
18
+ @extra_class = html_attrs.delete(:class)
19
+ @html_attrs = html_attrs
20
+ end
21
+
22
+ def call
23
+ content_tag(:ol,
24
+ class: cn("relative border-l border-border ml-3", @extra_class),
25
+ **@html_attrs) do
26
+ safe_join(items)
27
+ end
28
+ end
29
+
30
+ class ItemComponent < ApplicationComponent
31
+ # variant: :default | :success | :warning | :destructive | :muted
32
+ VARIANTS = {
33
+ default: "bg-primary",
34
+ success: "bg-green-500",
35
+ warning: "bg-amber-500",
36
+ destructive: "bg-destructive",
37
+ muted: "bg-muted-foreground"
38
+ }.freeze
39
+
40
+ DOT_CLS = "absolute -left-1.5 mt-1.5 size-3 rounded-full ring-4 ring-background shrink-0"
41
+ DATE_CLS = "mb-0.5 text-xs font-normal text-muted-foreground"
42
+ TITLE_CLS = "text-sm font-medium text-foreground leading-snug"
43
+ DESC_CLS = "mt-1 text-sm text-muted-foreground"
44
+
45
+ # date: optional date/time string shown above the title
46
+ # title: event label (required)
47
+ # description: optional supporting text
48
+ # variant: dot color — :default, :success, :warning, :destructive, :muted
49
+ def initialize(title:, date: nil, description: nil, variant: :default, **html_attrs)
50
+ @title = title
51
+ @date = date
52
+ @description = description
53
+ @variant = variant.to_sym
54
+ @extra_class = html_attrs.delete(:class)
55
+ @html_attrs = html_attrs
56
+ end
57
+
58
+ def call
59
+ content_tag(:li,
60
+ class: cn("mb-8 ml-4 last:mb-0", @extra_class),
61
+ **@html_attrs) do
62
+ concat dot
63
+ concat content_tag(:time, @date, class: DATE_CLS) if @date
64
+ concat content_tag(:p, @title, class: TITLE_CLS)
65
+ concat content_tag(:p, @description, class: DESC_CLS) if @description
66
+ concat content if content?
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def dot
73
+ color = VARIANTS.fetch(@variant, VARIANTS[:default])
74
+ content_tag(:span, nil, class: cn(DOT_CLS, color), "aria-hidden": "true")
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class TimepickerComponent < ApplicationComponent
5
+ # Time picker — trigger button opens a clock popover with hour/minute spinners.
6
+ #
7
+ # value: "HH:MM" string or nil
8
+ # name: form field name for the hidden input
9
+ # format: :h24 (default) | :h12
10
+ # step: minute step increment (default 1, common: 5, 15, 30)
11
+
12
+ WRAPPER = "relative inline-block"
13
+ TRIGGER = "flex h-9 w-36 cursor-pointer items-center gap-2 rounded-md border border-input " \
14
+ "bg-background px-3 text-sm text-foreground shadow-xs " \
15
+ "focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none transition " \
16
+ "aria-expanded:border-ring"
17
+ ICON_CLS = "size-4 shrink-0 text-muted-foreground"
18
+ POPOVER = "absolute left-0 top-full z-50 mt-1 hidden w-max rounded-lg border border-border " \
19
+ "bg-popover p-3 shadow-md data-[open=true]:block"
20
+ SPINNER_WRAP = "flex items-center justify-center gap-1"
21
+ COL_CLS = "flex flex-col items-center gap-1"
22
+ SPIN_BTN = "inline-flex size-7 items-center justify-center rounded-md " \
23
+ "text-muted-foreground hover:bg-accent hover:text-accent-foreground " \
24
+ "focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none transition"
25
+ NUM_CLS = "w-10 rounded-md border border-input bg-background px-1 py-0.5 text-center text-sm " \
26
+ "focus:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
27
+ SEP_CLS = "text-lg font-medium text-foreground pb-1"
28
+
29
+ def initialize(value: nil, name: nil, format: :h24, step: 1, **html_attrs)
30
+ @value = value
31
+ @name = name
32
+ @format = format.to_sym
33
+ @step = step.to_i.clamp(1, 60)
34
+ @extra_class = html_attrs.delete(:class)
35
+ @html_attrs = html_attrs
36
+ end
37
+
38
+ def call
39
+ content_tag(:div,
40
+ class: cn(WRAPPER, @extra_class),
41
+ data: {
42
+ controller: "timepicker",
43
+ timepicker_format_value: @format,
44
+ timepicker_step_value: @step
45
+ },
46
+ **@html_attrs) do
47
+ concat hidden_input if @name
48
+ concat trigger_button
49
+ concat clock_popover
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def hidden_input
56
+ tag.input(type: "hidden", name: @name,
57
+ value: @value,
58
+ data: { timepicker_target: "hidden" })
59
+ end
60
+
61
+ def trigger_button
62
+ content_tag(:button, type: "button",
63
+ class: TRIGGER,
64
+ "aria-expanded": "false",
65
+ "aria-haspopup": "dialog",
66
+ data: {
67
+ timepicker_target: "trigger",
68
+ action: "click->timepicker#toggle"
69
+ }) do
70
+ concat clock_icon
71
+ concat content_tag(:span, @value || "Pick time", data: { timepicker_target: "label" })
72
+ end
73
+ end
74
+
75
+ def clock_popover
76
+ hour_val, min_val = (@value || "00:00").split(":").map(&:to_i)
77
+
78
+ content_tag(:div,
79
+ class: POPOVER,
80
+ role: "dialog",
81
+ "aria-modal": "true",
82
+ data: { timepicker_target: "popover" }) do
83
+ content_tag(:div, class: SPINNER_WRAP) do
84
+ concat hour_column(hour_val)
85
+ concat content_tag(:span, ":", class: SEP_CLS)
86
+ concat minute_column(min_val)
87
+ concat ampm_column if @format == :h12
88
+ end
89
+ end
90
+ end
91
+
92
+ def hour_column(val)
93
+ content_tag(:div, class: COL_CLS) do
94
+ concat spin_btn("▲", "click->timepicker#hourUp")
95
+ concat tag.input(type: "text", inputmode: "numeric", class: NUM_CLS,
96
+ value: val.to_s.rjust(2, "0"), maxlength: "2",
97
+ data: { timepicker_target: "hour", action: "change->timepicker#hourChanged" })
98
+ concat spin_btn("▼", "click->timepicker#hourDown")
99
+ end
100
+ end
101
+
102
+ def minute_column(val)
103
+ content_tag(:div, class: COL_CLS) do
104
+ concat spin_btn("▲", "click->timepicker#minuteUp")
105
+ concat tag.input(type: "text", inputmode: "numeric", class: NUM_CLS,
106
+ value: val.to_s.rjust(2, "0"), maxlength: "2",
107
+ data: { timepicker_target: "minute", action: "change->timepicker#minuteChanged" })
108
+ concat spin_btn("▼", "click->timepicker#minuteDown")
109
+ end
110
+ end
111
+
112
+ def ampm_column
113
+ content_tag(:div, class: COL_CLS) do
114
+ concat spin_btn("▲", "click->timepicker#toggleAmPm")
115
+ concat content_tag(:span, "AM",
116
+ class: "w-10 rounded-md border border-input bg-background px-1 py-0.5 text-center text-sm cursor-pointer select-none",
117
+ data: { timepicker_target: "ampm", action: "click->timepicker#toggleAmPm" })
118
+ concat spin_btn("▼", "click->timepicker#toggleAmPm")
119
+ end
120
+ end
121
+
122
+ def spin_btn(label, action)
123
+ content_tag(:button, label, type: "button",
124
+ class: SPIN_BTN, "aria-hidden": "true",
125
+ tabindex: "-1", data: { action: action })
126
+ end
127
+
128
+ def clock_icon
129
+ content_tag(:svg,
130
+ safe_join([
131
+ content_tag(:circle, nil, cx: "12", cy: "12", r: "10"),
132
+ content_tag(:polyline, nil, points: "12 6 12 12 16 14")
133
+ ]),
134
+ xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
135
+ fill: "none", stroke: "currentColor", "stroke-width": "2",
136
+ "stroke-linecap": "round", "stroke-linejoin": "round",
137
+ class: ICON_CLS, "aria-hidden": "true")
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,92 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["trigger", "popover", "label", "hidden", "hour", "minute", "ampm"]
5
+ static values = {
6
+ format: { type: String, default: "h24" },
7
+ step: { type: Number, default: 1 }
8
+ }
9
+
10
+ connect() {
11
+ this.#outsideHandler = (e) => {
12
+ if (!this.element.contains(e.target)) this.close()
13
+ }
14
+ }
15
+
16
+ toggle() {
17
+ this.isOpen ? this.close() : this.open()
18
+ }
19
+
20
+ open() {
21
+ this.popoverTarget.dataset.open = "true"
22
+ this.triggerTarget.setAttribute("aria-expanded", "true")
23
+ document.addEventListener("click", this.#outsideHandler)
24
+ this.isOpen = true
25
+ }
26
+
27
+ close() {
28
+ this.popoverTarget.dataset.open = "false"
29
+ this.triggerTarget.setAttribute("aria-expanded", "false")
30
+ document.removeEventListener("click", this.#outsideHandler)
31
+ this.isOpen = false
32
+ }
33
+
34
+ hourUp() { this.#stepHour(1) }
35
+ hourDown() { this.#stepHour(-1) }
36
+
37
+ minuteUp() { this.#stepMinute(this.stepValue) }
38
+ minuteDown() { this.#stepMinute(-this.stepValue) }
39
+
40
+ toggleAmPm() {
41
+ if (!this.hasAmpmTarget) return
42
+ const current = this.ampmTarget.textContent.trim()
43
+ this.ampmTarget.textContent = current === "AM" ? "PM" : "AM"
44
+ this.#commit()
45
+ }
46
+
47
+ hourChanged() { this.#clampInput(this.hourTarget, 0, this.formatValue === "h12" ? 12 : 23); this.#commit() }
48
+ minuteChanged() { this.#clampInput(this.minuteTarget, 0, 59); this.#commit() }
49
+
50
+ #stepHour(delta) {
51
+ const max = this.formatValue === "h12" ? 12 : 23
52
+ let val = parseInt(this.hourTarget.value || "0", 10) + delta
53
+ if (val > max) val = 0
54
+ if (val < 0) val = max
55
+ this.hourTarget.value = String(val).padStart(2, "0")
56
+ this.#commit()
57
+ }
58
+
59
+ #stepMinute(delta) {
60
+ let val = parseInt(this.minuteTarget.value || "0", 10) + delta
61
+ if (val > 59) val = 0
62
+ if (val < 0) val = 59
63
+ this.minuteTarget.value = String(val).padStart(2, "0")
64
+ this.#commit()
65
+ }
66
+
67
+ #clampInput(input, min, max) {
68
+ let val = parseInt(input.value || "0", 10)
69
+ if (isNaN(val)) val = min
70
+ val = Math.min(max, Math.max(min, val))
71
+ input.value = String(val).padStart(2, "0")
72
+ }
73
+
74
+ #commit() {
75
+ const h = this.hourTarget.value.padStart(2, "0")
76
+ const m = this.minuteTarget.value.padStart(2, "0")
77
+ const ampm = this.hasAmpmTarget ? ` ${this.ampmTarget.textContent.trim()}` : ""
78
+ const display = `${h}:${m}${ampm}`
79
+ const hidden = `${h}:${m}`
80
+
81
+ this.labelTarget.textContent = display
82
+ if (this.hasHiddenTarget) this.hiddenTarget.value = hidden
83
+
84
+ this.element.dispatchEvent(new CustomEvent("timepicker:change", {
85
+ detail: { time: hidden },
86
+ bubbles: true
87
+ }))
88
+ }
89
+
90
+ #outsideHandler = null
91
+ isOpen = false
92
+ }
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class ToasterComponent < ApplicationComponent
5
+ # Fixed-position toast stack (Sonner-style).
6
+ # Place once in the application layout; trigger toasts server-side via
7
+ # the renders_many slot or client-side via a `toaster:add` window event.
8
+ #
9
+ # Usage (layout):
10
+ # ui :toaster do |t|
11
+ # t.with_toast(message: "Profile saved", variant: :success)
12
+ # end
13
+ #
14
+ # Usage (JS dispatch from any controller):
15
+ # window.dispatchEvent(new CustomEvent("toaster:add", {
16
+ # detail: { message: "Done!", variant: "success", duration: 3000 }
17
+ # }))
18
+
19
+ POSITIONS = {
20
+ bottom_right: "fixed bottom-4 right-4",
21
+ bottom_left: "fixed bottom-4 left-4",
22
+ bottom_center: "fixed bottom-4 left-1/2 -translate-x-1/2",
23
+ top_right: "fixed top-4 right-4",
24
+ top_left: "fixed top-4 left-4",
25
+ top_center: "fixed top-4 left-1/2 -translate-x-1/2"
26
+ }.freeze
27
+
28
+ CONTAINER_CLS = "z-50 flex flex-col gap-2 w-80 pointer-events-none"
29
+
30
+ renders_many :toasts, "UI::ToasterComponent::ToastComponent"
31
+
32
+ # position: corner to anchor the stack (default: :bottom_right)
33
+ def initialize(position: :bottom_right, **html_attrs)
34
+ @position = position.to_sym
35
+ @extra_class = html_attrs.delete(:class)
36
+ @html_attrs = html_attrs
37
+ end
38
+
39
+ def call
40
+ pos_cls = POSITIONS.fetch(@position, POSITIONS[:bottom_right])
41
+ content_tag(:div,
42
+ class: cn(CONTAINER_CLS, pos_cls, @extra_class),
43
+ data: {
44
+ controller: "toaster",
45
+ action: "toaster:add@window->toaster#add"
46
+ },
47
+ **@html_attrs) do
48
+ safe_join(toasts)
49
+ end
50
+ end
51
+
52
+ class ToastComponent < ApplicationComponent
53
+ VARIANTS = {
54
+ default: {border: "border-border", icon: nil, icon_color: "text-foreground"},
55
+ success: {
56
+ border: "border-green-500/40",
57
+ icon: "M9 12l2 2 4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0z",
58
+ icon_color: "text-green-500"
59
+ },
60
+ warning: {
61
+ border: "border-amber-500/40",
62
+ icon: "M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z",
63
+ icon_color: "text-amber-500"
64
+ },
65
+ destructive: {
66
+ border: "border-destructive/40",
67
+ icon: "M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 1 1-18 0 9 9 0 0 1 18 0z",
68
+ icon_color: "text-destructive"
69
+ },
70
+ info: {
71
+ border: "border-blue-500/40",
72
+ icon: "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z",
73
+ icon_color: "text-blue-500"
74
+ }
75
+ }.freeze
76
+
77
+ TOAST_CLS = "pointer-events-auto flex items-start gap-3 rounded-lg border " \
78
+ "bg-background px-4 py-3 shadow-lg text-foreground " \
79
+ "transition-all duration-300 translate-y-2 opacity-0 " \
80
+ "data-[open=true]:translate-y-0 data-[open=true]:opacity-100"
81
+
82
+ CLOSE_CLS = "ml-auto -mr-1 -mt-0.5 shrink-0 inline-flex size-6 items-center justify-center " \
83
+ "rounded-md text-muted-foreground hover:text-foreground hover:bg-accent " \
84
+ "focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none transition"
85
+
86
+ # message: toast body (required)
87
+ # title: optional bold heading
88
+ # variant: :default | :success | :warning | :destructive | :info
89
+ # duration: auto-dismiss in ms; 0 = no auto-dismiss (default: 4000)
90
+ def initialize(message:, title: nil, variant: :default, duration: 4000, **html_attrs)
91
+ @message = message
92
+ @title = title
93
+ @variant = variant.to_sym
94
+ @duration = duration.to_i
95
+ @extra_class = html_attrs.delete(:class)
96
+ @html_attrs = html_attrs
97
+ end
98
+
99
+ def call
100
+ v = VARIANTS.fetch(@variant, VARIANTS[:default])
101
+ content_tag(:div,
102
+ class: cn(TOAST_CLS, v[:border], @extra_class),
103
+ role: "alert",
104
+ "aria-live": "polite",
105
+ "data-open": "false",
106
+ data: {
107
+ toaster_target: "toast",
108
+ toaster_duration_param: @duration
109
+ },
110
+ **@html_attrs) do
111
+ concat icon_svg(v[:icon], v[:icon_color]) if v[:icon]
112
+ concat body
113
+ concat close_btn
114
+ end
115
+ end
116
+
117
+ private
118
+
119
+ def body
120
+ content_tag(:div, class: "flex-1 min-w-0") do
121
+ concat content_tag(:p, @title, class: "text-sm font-semibold leading-tight") if @title
122
+ concat content_tag(:p, @message,
123
+ class: "text-sm leading-snug#{" text-muted-foreground mt-0.5" if @title}")
124
+ end
125
+ end
126
+
127
+ def close_btn
128
+ content_tag(:button, type: "button",
129
+ class: CLOSE_CLS,
130
+ "aria-label": "Dismiss",
131
+ data: {action: "click->toaster#dismiss"}) do
132
+ content_tag(:svg,
133
+ content_tag(:path, nil, d: "M18 6 6 18M6 6l12 12",
134
+ "stroke-linecap": "round", "stroke-linejoin": "round"),
135
+ xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
136
+ fill: "none", stroke: "currentColor", "stroke-width": "2",
137
+ class: "size-3.5", "aria-hidden": "true")
138
+ end
139
+ end
140
+
141
+ def icon_svg(path, color_cls)
142
+ content_tag(:svg,
143
+ content_tag(:path, nil, d: path,
144
+ "stroke-linecap": "round", "stroke-linejoin": "round"),
145
+ xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
146
+ fill: "none", stroke: "currentColor", "stroke-width": "2",
147
+ class: "mt-0.5 size-4 shrink-0 #{color_cls}",
148
+ "aria-hidden": "true")
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,88 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["toast"]
5
+ #timers = new Map()
6
+
7
+ connect() {
8
+ this.toastTargets.forEach(toast => this.#show(toast))
9
+ }
10
+
11
+ // Triggered by: window.dispatchEvent(new CustomEvent("toaster:add", { detail: { message, title, variant, duration } }))
12
+ add({ detail }) {
13
+ const toast = this.#buildToast(detail)
14
+ this.element.appendChild(toast)
15
+ this.#show(toast)
16
+ }
17
+
18
+ dismiss({ currentTarget }) {
19
+ this.#hide(currentTarget.closest("[data-toaster-target='toast']"))
20
+ }
21
+
22
+ #show(toast) {
23
+ requestAnimationFrame(() => {
24
+ toast.dataset.open = "true"
25
+ const duration = parseInt(toast.dataset.toasterDurationParam ?? "4000")
26
+ if (duration > 0) {
27
+ this.#timers.set(toast, setTimeout(() => this.#hide(toast), duration))
28
+ }
29
+ })
30
+ }
31
+
32
+ #hide(toast) {
33
+ if (!toast) return
34
+ clearTimeout(this.#timers.get(toast))
35
+ this.#timers.delete(toast)
36
+ toast.dataset.open = "false"
37
+ toast.addEventListener("transitionend", () => toast.remove(), { once: true })
38
+ }
39
+
40
+ #buildToast({ message = "", title = "", variant = "default", duration = 4000 }) {
41
+ const borderCls = {
42
+ default: "border-border",
43
+ success: "border-green-500/40",
44
+ warning: "border-amber-500/40",
45
+ destructive: "border-destructive/40",
46
+ info: "border-blue-500/40"
47
+ }[variant] ?? "border-border"
48
+
49
+ const div = document.createElement("div")
50
+ div.setAttribute("role", "alert")
51
+ div.setAttribute("aria-live", "polite")
52
+ div.dataset.open = "false"
53
+ div.dataset.toasterTarget = "toast"
54
+ div.dataset.toasterDurationParam = duration
55
+ div.className = [
56
+ "pointer-events-auto flex items-start gap-3 rounded-lg border",
57
+ "bg-background px-4 py-3 shadow-lg text-foreground",
58
+ "transition-all duration-300 translate-y-2 opacity-0",
59
+ "data-[open=true]:translate-y-0 data-[open=true]:opacity-100",
60
+ borderCls
61
+ ].join(" ")
62
+
63
+ const bodyHtml = title
64
+ ? `<p class="text-sm font-semibold leading-tight">${this.#esc(title)}</p>
65
+ <p class="text-sm leading-snug text-muted-foreground mt-0.5">${this.#esc(message)}</p>`
66
+ : `<p class="text-sm leading-snug">${this.#esc(message)}</p>`
67
+
68
+ div.innerHTML = `
69
+ <div class="flex-1 min-w-0">${bodyHtml}</div>
70
+ <button type="button" aria-label="Dismiss"
71
+ data-action="click->toaster#dismiss"
72
+ class="ml-auto -mr-1 -mt-0.5 shrink-0 inline-flex size-6 items-center justify-center
73
+ rounded-md text-muted-foreground hover:text-foreground hover:bg-accent
74
+ focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none transition">
75
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
76
+ stroke="currentColor" stroke-width="2" class="size-3.5" aria-hidden="true">
77
+ <path d="M18 6 6 18M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"/>
78
+ </svg>
79
+ </button>`
80
+ return div
81
+ }
82
+
83
+ #esc(str) {
84
+ return String(str)
85
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;")
86
+ .replace(/>/g, "&gt;").replace(/"/g, "&quot;")
87
+ }
88
+ }
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class ToggleComponent < ApplicationComponent
5
+ BASE = "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap " \
6
+ "transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground " \
7
+ "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
8
+ "disabled:pointer-events-none disabled:opacity-50 " \
9
+ "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " \
10
+ "data-[state=on]:bg-accent data-[state=on]:text-accent-foreground " \
11
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
12
+
13
+ SIZES = {
14
+ default: "h-9 min-w-9 px-2",
15
+ sm: "h-8 min-w-8 px-1.5",
16
+ lg: "h-10 min-w-10 px-2.5"
17
+ }.freeze
18
+
19
+ def initialize(label = nil, pressed: false, size: :default, value: nil, **html_attrs)
20
+ @label = label || html_attrs.delete(:label)
21
+ @pressed = pressed
22
+ @size = size.to_sym
23
+ @value = value
24
+ @extra_class = html_attrs.delete(:class)
25
+ @html_attrs = html_attrs
26
+ end
27
+
28
+ def call
29
+ content_tag(:button,
30
+ content.presence || @label,
31
+ type: "button",
32
+ "aria-pressed": @pressed.to_s,
33
+ "data-state": @pressed ? "on" : "off",
34
+ "data-controller": "toggle",
35
+ "data-action": "click->toggle#toggle",
36
+ value: @value,
37
+ class: cn(BASE, SIZES.fetch(@size, SIZES[:default]), @extra_class),
38
+ **@html_attrs)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,12 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ toggle() {
5
+ // Defer to toggle-group controller when nested inside one
6
+ if (this.element.closest("[data-controller~='toggle-group']")) return
7
+
8
+ const on = this.element.dataset.state === "on"
9
+ this.element.dataset.state = on ? "off" : "on"
10
+ this.element.setAttribute("aria-pressed", String(!on))
11
+ }
12
+ }
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class ToggleGroupComponent < ApplicationComponent
5
+ BASE = "inline-flex gap-1"
6
+
7
+ # type: :single — only one item active at a time
8
+ # :multiple — multiple items can be active simultaneously
9
+ # value: currently active value (String) for :single,
10
+ # or array of active values for :multiple
11
+ def initialize(type: :single, value: nil, **html_attrs)
12
+ @type = type.to_sym
13
+ @value = Array(value).map(&:to_s)
14
+ @extra_class = html_attrs.delete(:class)
15
+ @html_attrs = html_attrs
16
+ end
17
+
18
+ def call
19
+ content_tag(:div,
20
+ content,
21
+ class: cn(BASE, @extra_class),
22
+ role: "group",
23
+ "data-controller": "toggle-group",
24
+ "data-toggle-group-type-value": @type,
25
+ **@html_attrs)
26
+ end
27
+
28
+ def item_pressed?(item_value)
29
+ @value.include?(item_value.to_s)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,38 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = { type: { type: String, default: "single" } }
5
+
6
+ connect() {
7
+ this.element.addEventListener("click", this.#handleClick)
8
+ }
9
+
10
+ disconnect() {
11
+ this.element.removeEventListener("click", this.#handleClick)
12
+ }
13
+
14
+ #handleClick = (event) => {
15
+ const btn = event.target.closest("button")
16
+ if (!btn || !this.element.contains(btn)) return
17
+
18
+ const alreadyOn = btn.dataset.state === "on"
19
+
20
+ if (this.typeValue === "single") {
21
+ this.#buttons.forEach(b => {
22
+ b.dataset.state = "off"
23
+ b.setAttribute("aria-pressed", "false")
24
+ })
25
+ if (!alreadyOn) {
26
+ btn.dataset.state = "on"
27
+ btn.setAttribute("aria-pressed", "true")
28
+ }
29
+ } else {
30
+ btn.dataset.state = alreadyOn ? "off" : "on"
31
+ btn.setAttribute("aria-pressed", String(!alreadyOn))
32
+ }
33
+ }
34
+
35
+ get #buttons() {
36
+ return Array.from(this.element.querySelectorAll("button"))
37
+ }
38
+ }