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,28 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ open({ params: { src, alt } }) {
5
+ if (this._overlay) return
6
+ const overlay = document.createElement("div")
7
+ overlay.className = "fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
8
+ overlay.dataset.galleryOverlay = ""
9
+
10
+ const img = document.createElement("img")
11
+ img.src = src
12
+ img.alt = alt || ""
13
+ img.className = "max-h-[90vh] max-w-[90vw] rounded-md object-contain"
14
+ overlay.appendChild(img)
15
+
16
+ document.body.appendChild(overlay)
17
+ this._overlay = overlay
18
+ }
19
+
20
+ close() {
21
+ this._overlay?.remove()
22
+ this._overlay = null
23
+ }
24
+
25
+ closeOnClickOutside(event) {
26
+ if (event.target === this._overlay) this.close()
27
+ }
28
+ }
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class HoverCardComponent < ApplicationComponent
5
+ renders_one :trigger
6
+
7
+ CARD_BASE = "absolute z-50 w-64 rounded-lg border bg-popover p-4 text-sm " \
8
+ "text-popover-foreground shadow-md " \
9
+ "opacity-0 group-hover:opacity-100 pointer-events-none " \
10
+ "transition-opacity duration-200"
11
+
12
+ POSITIONS = {
13
+ bottom: "top-full left-0 mt-2",
14
+ top: "bottom-full left-0 mb-2",
15
+ left: "right-full top-0 mr-2",
16
+ right: "left-full top-0 ml-2"
17
+ }.freeze
18
+
19
+ def initialize(side: :bottom, **html_attrs)
20
+ @side = side.to_sym
21
+ @extra_class = html_attrs.delete(:class)
22
+ @html_attrs = html_attrs
23
+ end
24
+
25
+ def call
26
+ content_tag(:span,
27
+ class: cn("relative inline-block group", @extra_class),
28
+ **@html_attrs) do
29
+ concat trigger if trigger
30
+ concat card_panel
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def card_panel
37
+ content_tag(:div,
38
+ class: cn(CARD_BASE, POSITIONS.fetch(@side, POSITIONS[:bottom]))) do
39
+ content
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class IframeComponent < ApplicationComponent
5
+ BASE = "w-full border-0"
6
+
7
+ # src: URL to embed (required)
8
+ # title: accessible label (required — describes the iframe content)
9
+ # loading: :lazy (default) | :eager
10
+ # sandbox: space-separated token string or true for strict defaults
11
+ # pass false to disable sandboxing entirely (not recommended)
12
+ # aspect: CSS aspect-ratio value, e.g. "16/9", "4/3" (wraps in a div)
13
+ # omit if you set explicit width/height
14
+ # width / height: explicit pixel dimensions (applied to <iframe>)
15
+ def initialize(src:, title:, loading: :lazy, sandbox: true,
16
+ aspect: nil, width: nil, height: nil, **html_attrs)
17
+ @src = src
18
+ @title = title
19
+ @loading = loading.to_sym
20
+ @sandbox = sandbox
21
+ @aspect = aspect
22
+ @width = width
23
+ @height = height
24
+ @extra_class = html_attrs.delete(:class)
25
+ @html_attrs = html_attrs
26
+ end
27
+
28
+ def call
29
+ if @aspect
30
+ content_tag(:div, style: "aspect-ratio: #{@aspect}", class: "w-full overflow-hidden") do
31
+ iframe_tag
32
+ end
33
+ else
34
+ iframe_tag
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def iframe_tag
41
+ attrs = {
42
+ src: @src,
43
+ title: @title,
44
+ loading: @loading,
45
+ class: cn(BASE, (@aspect ? "h-full" : nil), @extra_class)
46
+ }
47
+ attrs[:sandbox] = sandbox_value if @sandbox != false
48
+ attrs[:width] = @width if @width
49
+ attrs[:height] = @height if @height
50
+ tag.iframe(**attrs, **@html_attrs)
51
+ end
52
+
53
+ def sandbox_value
54
+ return "allow-scripts allow-same-origin allow-forms allow-popups" if @sandbox == true
55
+
56
+ @sandbox
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class ImageComponent < ApplicationComponent
5
+ BASE = "max-w-full"
6
+
7
+ LOADING_MODES = %i[lazy eager auto].freeze
8
+
9
+ # src: image URL (required)
10
+ # alt: alternative text (required for accessibility)
11
+ # srcset: responsive image set, e.g. "img-sm.jpg 640w, img-lg.jpg 1280w"
12
+ # sizes: media conditions, e.g. "(max-width: 640px) 100vw, 50vw"
13
+ # loading: :lazy (default) | :eager | :auto
14
+ # width/height: native dimensions (prevents layout shift)
15
+ def initialize(src:, alt:, srcset: nil, sizes: nil, loading: :lazy,
16
+ width: nil, height: nil, **html_attrs)
17
+ @src = src
18
+ @alt = alt
19
+ @srcset = srcset
20
+ @sizes = sizes
21
+ @loading = LOADING_MODES.include?(loading.to_sym) ? loading.to_sym : :lazy
22
+ @width = width
23
+ @height = height
24
+ @extra_class = html_attrs.delete(:class)
25
+ @html_attrs = html_attrs
26
+ end
27
+
28
+ def call
29
+ attrs = { src: @src, alt: @alt, loading: @loading,
30
+ class: cn(BASE, @extra_class) }
31
+ attrs[:srcset] = @srcset if @srcset
32
+ attrs[:sizes] = @sizes if @sizes
33
+ attrs[:width] = @width if @width
34
+ attrs[:height] = @height if @height
35
+ tag.img(**attrs, **@html_attrs)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class IndicatorComponent < ApplicationComponent
5
+ DOT_BASE = "absolute flex items-center justify-center rounded-full text-[10px] font-medium leading-none"
6
+
7
+ VARIANTS = {
8
+ default: "bg-primary text-primary-foreground",
9
+ destructive: "bg-destructive text-white",
10
+ success: "bg-green-500 text-white",
11
+ warning: "bg-yellow-500 text-foreground"
12
+ }.freeze
13
+
14
+ POSITIONS = {
15
+ top_right: "-top-1 -right-1",
16
+ top_left: "-top-1 -left-1",
17
+ bottom_right: "-bottom-1 -right-1",
18
+ bottom_left: "-bottom-1 -left-1"
19
+ }.freeze
20
+
21
+ def initialize(count: nil, position: :top_right, variant: :default, **html_attrs)
22
+ @count = count
23
+ @position = position.to_sym
24
+ @variant = variant.to_sym
25
+ @extra_class = html_attrs.delete(:class)
26
+ @html_attrs = html_attrs
27
+ end
28
+
29
+ def call
30
+ content_tag(:span, class: cn("relative inline-flex", @extra_class), **@html_attrs) do
31
+ concat content
32
+ concat dot
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def dot
39
+ dot_size = @count ? "size-5 min-w-5 px-0.5" : "size-2"
40
+ content_tag(:span, @count,
41
+ class: cn(DOT_BASE, dot_size,
42
+ VARIANTS.fetch(@variant, VARIANTS[:default]),
43
+ POSITIONS.fetch(@position, POSITIONS[:top_right])))
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class InputComponent < ApplicationComponent
5
+ BASE = "h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs " \
6
+ "transition-[color,box-shadow] outline-none " \
7
+ "selection:bg-primary selection:text-primary-foreground " \
8
+ "file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground " \
9
+ "placeholder:text-muted-foreground " \
10
+ "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
11
+ "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " \
12
+ "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 " \
13
+ "md:text-sm dark:bg-input/30"
14
+
15
+ def initialize(type: "text", **html_attrs)
16
+ @type = type
17
+ @extra_class = html_attrs.delete(:class)
18
+ @html_attrs = html_attrs
19
+ end
20
+
21
+ def call
22
+ content_tag(:input, nil,
23
+ type: @type,
24
+ class: cn(BASE, @extra_class),
25
+ **@html_attrs)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class InputOtpComponent < ApplicationComponent
5
+ # One-time-password digit input group.
6
+ # Renders N individual single-character inputs that auto-advance on entry.
7
+ #
8
+ # Usage:
9
+ # <%= ui :input_otp, length: 6, name: "otp" %>
10
+
11
+ CELL_CLS = "h-12 w-10 rounded-md border border-input bg-transparent text-center text-lg font-medium " \
12
+ "shadow-xs transition-[color,box-shadow] outline-none " \
13
+ "caret-transparent selection:bg-primary selection:text-primary-foreground " \
14
+ "focus:border-ring focus:ring-[3px] focus:ring-ring/50 " \
15
+ "aria-invalid:border-destructive aria-invalid:ring-destructive/20 " \
16
+ "disabled:pointer-events-none disabled:opacity-50"
17
+
18
+ WRAPPER_CLS = "flex items-center gap-2"
19
+ SEPARATOR_CLS = "text-muted-foreground text-lg font-medium"
20
+
21
+ # length: number of OTP digits (default: 6)
22
+ # name: form field name (individual cells get name[0], name[1], …)
23
+ # separator: position (Integer) or Hash { position => char }, e.g. 3 or { 3 => "-" }
24
+ def initialize(length: 6, name: "otp", separator: nil, **html_attrs)
25
+ @length = length.to_i
26
+ @name = name
27
+ @separator = case separator
28
+ when Integer then { separator => "-" }
29
+ when Hash then separator
30
+ end
31
+ @extra_class = html_attrs.delete(:class)
32
+ @html_attrs = html_attrs
33
+ end
34
+
35
+ def call
36
+ content_tag(:div,
37
+ class: cn(WRAPPER_CLS, @extra_class),
38
+ data: { controller: "input-otp" },
39
+ **@html_attrs) do
40
+ @length.times do |i|
41
+ sep = @separator&.fetch(i, nil)
42
+ concat content_tag(:span, sep, class: SEPARATOR_CLS) if sep
43
+ concat digit_input(i)
44
+ end
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def digit_input(index)
51
+ content_tag(:input, nil,
52
+ type: "text",
53
+ inputmode: "numeric",
54
+ maxlength: 1,
55
+ autocomplete: index.zero? ? "one-time-code" : "off",
56
+ name: "#{@name}[#{index}]",
57
+ class: CELL_CLS,
58
+ "aria-label": "Digit #{index + 1}",
59
+ data: {
60
+ input_otp_target: "cell",
61
+ action: "input->input-otp#onInput keydown->input-otp#onKeydown paste->input-otp#onPaste"
62
+ })
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,39 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["cell"]
5
+
6
+ onInput(event) {
7
+ const cell = event.currentTarget
8
+ const idx = this.cellTargets.indexOf(cell)
9
+ if (cell.value && idx < this.cellTargets.length - 1) {
10
+ this.cellTargets[idx + 1].focus()
11
+ }
12
+ }
13
+
14
+ onKeydown(event) {
15
+ const cell = event.currentTarget
16
+ const idx = this.cellTargets.indexOf(cell)
17
+ if (event.key === "Backspace" && !cell.value && idx > 0) {
18
+ this.cellTargets[idx - 1].focus()
19
+ }
20
+ if (event.key === "ArrowLeft" && idx > 0) {
21
+ this.cellTargets[idx - 1].focus()
22
+ }
23
+ if (event.key === "ArrowRight" && idx < this.cellTargets.length - 1) {
24
+ this.cellTargets[idx + 1].focus()
25
+ }
26
+ }
27
+
28
+ onPaste(event) {
29
+ event.preventDefault()
30
+ const text = (event.clipboardData || window.clipboardData).getData("text").replace(/\D/g, "")
31
+ const start = this.cellTargets.indexOf(event.currentTarget)
32
+ text.split("").forEach((char, i) => {
33
+ const cell = this.cellTargets[start + i]
34
+ if (cell) cell.value = char
35
+ })
36
+ const next = this.cellTargets[start + text.length]
37
+ if (next) next.focus()
38
+ }
39
+ }
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class KbdComponent < ApplicationComponent
5
+ BASE = "pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 " \
6
+ "rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none " \
7
+ "[&_svg:not([class*='size-'])]:size-3"
8
+
9
+ def initialize(key = nil, **html_attrs)
10
+ @key = key || html_attrs.delete(:label)
11
+ @extra_class = html_attrs.delete(:class)
12
+ @html_attrs = html_attrs
13
+ end
14
+
15
+ def call
16
+ content_tag(:kbd, content.presence || @key,
17
+ class: cn(BASE, @extra_class),
18
+ **@html_attrs)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class LabelComponent < ApplicationComponent
5
+ BASE = "flex items-center gap-2 text-sm leading-none font-medium select-none " \
6
+ "group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 " \
7
+ "peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
8
+
9
+ def initialize(text = nil, for: nil, **html_attrs)
10
+ @text = text || html_attrs.delete(:label)
11
+ @for = binding.local_variable_get(:for)
12
+ @extra_class = html_attrs.delete(:class)
13
+ @html_attrs = html_attrs
14
+ end
15
+
16
+ def call
17
+ content_tag(:label, content.presence || @text,
18
+ class: cn(BASE, @extra_class),
19
+ for: @for,
20
+ **@html_attrs)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class ListGroupComponent < ApplicationComponent
5
+ BASE = "divide-y divide-border overflow-hidden rounded-lg border"
6
+
7
+ def initialize(**html_attrs)
8
+ @extra_class = html_attrs.delete(:class)
9
+ @html_attrs = html_attrs
10
+ end
11
+
12
+ def call
13
+ content_tag(:ul, content, class: cn(BASE, @extra_class), **@html_attrs)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class ListGroupItemComponent < ApplicationComponent
5
+ BASE = "flex items-center justify-between px-4 py-3 text-sm"
6
+
7
+ VARIANTS = {
8
+ default: "text-foreground hover:bg-muted",
9
+ active: "bg-primary text-primary-foreground",
10
+ muted: "text-muted-foreground hover:bg-muted"
11
+ }.freeze
12
+
13
+ def initialize(label = nil, href: nil, active: false, variant: :default, **html_attrs)
14
+ @label = label || html_attrs.delete(:label)
15
+ @href = href
16
+ @variant = active ? :active : variant.to_sym
17
+ @extra_class = html_attrs.delete(:class)
18
+ @html_attrs = html_attrs
19
+ end
20
+
21
+ def call
22
+ tag_name = @href ? :a : :li
23
+ extra = @href ? { href: @href } : {}
24
+ content_tag(tag_name,
25
+ content.presence || @label,
26
+ class: cn(BASE, VARIANTS.fetch(@variant, VARIANTS[:default]), @extra_class),
27
+ **extra,
28
+ **@html_attrs)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class MapAreaComponent < ApplicationComponent
5
+ # Image map — renders <img usemap> + <map> + <area> elements.
6
+ #
7
+ # Usage:
8
+ # ui :map_area,
9
+ # src: "/map.png", alt: "Office floor plan",
10
+ # width: 800, height: 600,
11
+ # areas: [
12
+ # { shape: :rect, coords: "0,0,200,150", href: "/room/1", alt: "Room 1" },
13
+ # { shape: :circle, coords: "400,300,50", href: "/room/2", alt: "Room 2" },
14
+ # { shape: :poly, coords: "10,10,50,10,30,40", href: "/room/3", alt: "Room 3" }
15
+ # ]
16
+ #
17
+ # area keys:
18
+ # shape: :rect | :circle | :poly | :default (required)
19
+ # coords: coordinate string (required for rect/circle/poly)
20
+ # href: link target (omit or "#" for non-interactive areas)
21
+ # alt: accessible label for the area (required for links)
22
+ # title: tooltip text
23
+ # target: link target, e.g. "_blank"
24
+ # rel: link rel attribute
25
+
26
+ WRAPPER_CLS = "relative inline-block"
27
+
28
+ def initialize(src:, alt:, areas: [], width: nil, height: nil,
29
+ loading: :lazy, map_name: nil, **html_attrs)
30
+ @src = src
31
+ @alt = alt
32
+ @areas = areas
33
+ @width = width
34
+ @height = height
35
+ @loading = loading
36
+ @map_name = map_name || "map-#{SecureRandom.hex(4)}"
37
+ @extra_class = html_attrs.delete(:class)
38
+ @html_attrs = html_attrs
39
+ end
40
+
41
+ def call
42
+ content_tag(:div, class: cn(WRAPPER_CLS, @extra_class), **@html_attrs) do
43
+ safe_join([img_tag, map_tag])
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def img_tag
50
+ attrs = { src: @src, alt: @alt, usemap: "##{@map_name}", loading: @loading }
51
+ attrs[:width] = @width if @width
52
+ attrs[:height] = @height if @height
53
+ tag.img(**attrs)
54
+ end
55
+
56
+ def map_tag
57
+ content_tag(:map, name: @map_name) do
58
+ safe_join(@areas.map { |area| area_tag(area) })
59
+ end
60
+ end
61
+
62
+ def area_tag(area)
63
+ attrs = { shape: area.fetch(:shape, :rect).to_s, alt: area.fetch(:alt, "") }
64
+ attrs[:coords] = area[:coords] if area[:coords]
65
+ attrs[:href] = area[:href] if area[:href]
66
+ attrs[:title] = area[:title] if area[:title]
67
+ attrs[:target] = area[:target] if area[:target]
68
+ attrs[:rel] = area[:rel] if area[:rel]
69
+ tag.area(**attrs)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class MegaMenuComponent < ApplicationComponent
5
+ # Full-width dropdown panel anchored to a trigger button.
6
+ # Columns are rendered via with_column blocks.
7
+
8
+ TRIGGER_CLS = "inline-flex h-9 items-center justify-center gap-1.5 rounded-md bg-background " \
9
+ "px-4 py-2 text-sm font-medium transition-[color,box-shadow] outline-none " \
10
+ "hover:bg-accent hover:text-accent-foreground " \
11
+ "focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
12
+ "data-[state=open]:bg-accent/50 data-[state=open]:text-accent-foreground"
13
+
14
+ PANEL_CLS = "absolute left-0 top-full z-50 mt-1.5 w-full overflow-hidden rounded-md border " \
15
+ "bg-popover text-popover-foreground shadow-lg"
16
+
17
+ INNER_CLS = "container mx-auto grid gap-6 p-6"
18
+
19
+ COLUMN_HEADING = "mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
20
+
21
+ ITEM_CLS = "group flex items-start gap-3 rounded-sm p-2 text-sm transition-colors outline-none " \
22
+ "hover:bg-accent hover:text-accent-foreground " \
23
+ "focus-visible:ring-[3px] focus-visible:ring-ring/50"
24
+
25
+ ITEM_TITLE = "font-medium leading-none"
26
+ ITEM_DESC = "mt-1 text-xs text-muted-foreground group-hover:text-accent-foreground/70"
27
+
28
+ CHEVRON_PATH = "m6 9 6 6 6-6"
29
+
30
+ renders_many :columns, "UI::MegaMenuComponent::ColumnComponent"
31
+
32
+ # label: trigger button text
33
+ # cols: number of columns in the grid (default: auto based on column count)
34
+ def initialize(label:, cols: nil, **html_attrs)
35
+ @label = label
36
+ @cols = cols
37
+ @extra_class = html_attrs.delete(:class)
38
+ @html_attrs = html_attrs
39
+ end
40
+
41
+ def call
42
+ content_tag(:div,
43
+ class: cn("relative", @extra_class),
44
+ data: {
45
+ controller: "mega-menu",
46
+ action: "click@document->mega-menu#closeOnClickOutside"
47
+ },
48
+ **@html_attrs) do
49
+ concat trigger_btn
50
+ concat panel
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def trigger_btn
57
+ content_tag(:button,
58
+ type: "button",
59
+ class: TRIGGER_CLS,
60
+ "aria-expanded": "false",
61
+ data: { mega_menu_target: "trigger", state: "closed",
62
+ action: "click->mega-menu#toggle" }) do
63
+ concat @label
64
+ concat chevron
65
+ end
66
+ end
67
+
68
+ def panel
69
+ col_count = @cols || [columns.size, 1].max
70
+ grid_cls = "grid-cols-#{col_count}"
71
+
72
+ content_tag(:div,
73
+ hidden: true,
74
+ class: PANEL_CLS,
75
+ data: { mega_menu_target: "panel" }) do
76
+ content_tag(:div, class: cn(INNER_CLS, grid_cls)) do
77
+ safe_join(columns)
78
+ end
79
+ end
80
+ end
81
+
82
+ def chevron
83
+ content_tag(:svg,
84
+ content_tag(:path, nil, d: CHEVRON_PATH, "stroke-linecap": "round", "stroke-linejoin": "round"),
85
+ xmlns: "http://www.w3.org/2000/svg",
86
+ viewBox: "0 0 24 24",
87
+ fill: "none",
88
+ stroke: "currentColor",
89
+ "stroke-width": "2",
90
+ class: "size-3 transition-transform duration-200 data-[state=open]:rotate-180",
91
+ "aria-hidden": "true",
92
+ data: { mega_menu_target: "chevron" })
93
+ end
94
+
95
+ # A single column inside the mega menu panel.
96
+ # heading: optional column title
97
+ # items: array of { title:, description:, href: } hashes
98
+ class ColumnComponent < ApplicationComponent
99
+ def initialize(heading: nil, items: [], **html_attrs)
100
+ @heading = heading
101
+ @items = items
102
+ @html_attrs = html_attrs
103
+ end
104
+
105
+ def call
106
+ content_tag(:div, **@html_attrs) do
107
+ concat content_tag(:p, @heading, class: MegaMenuComponent::COLUMN_HEADING) if @heading
108
+ concat(content_tag(:ul, class: "space-y-1") {
109
+ safe_join(@items.map { |item| render_item(item) })
110
+ })
111
+ end
112
+ end
113
+
114
+ private
115
+
116
+ def render_item(item)
117
+ content_tag(:li) do
118
+ content_tag(:a,
119
+ href: item.fetch(:href, "#"),
120
+ class: MegaMenuComponent::ITEM_CLS) do
121
+ content_tag(:div) do
122
+ concat content_tag(:p, item[:title], class: MegaMenuComponent::ITEM_TITLE)
123
+ concat content_tag(:p, item[:description], class: MegaMenuComponent::ITEM_DESC) if item[:description]
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,23 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["trigger", "panel", "chevron"]
5
+
6
+ toggle() {
7
+ const open = this.panelTarget.hidden
8
+ this._setOpen(open)
9
+ }
10
+
11
+ closeOnClickOutside(event) {
12
+ if (!this.element.contains(event.target)) this._setOpen(false)
13
+ }
14
+
15
+ _setOpen(open) {
16
+ this.panelTarget.hidden = !open
17
+ this.triggerTarget.setAttribute("aria-expanded", String(open))
18
+ this.triggerTarget.dataset.state = open ? "open" : "closed"
19
+ if (this.hasChevronTarget) {
20
+ this.chevronTarget.dataset.state = open ? "open" : "closed"
21
+ }
22
+ }
23
+ }