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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class AudioComponent < ApplicationComponent
5
+ # Add <source> elements via a.with_source(src:, type:)
6
+ renders_many :sources, "UI::AudioComponent::SourceComponent"
7
+
8
+ # controls: show native browser controls (default: true)
9
+ # autoplay: start playing automatically — requires muted: true in some browsers
10
+ # muted: mute the audio track
11
+ # loop: loop playback
12
+ # preload: :auto | :metadata (default) | :none
13
+ def initialize(controls: true, autoplay: false, muted: false,
14
+ loop: false, preload: :metadata, **html_attrs)
15
+ @controls = controls
16
+ @autoplay = autoplay
17
+ @muted = muted
18
+ @loop = loop
19
+ @preload = preload
20
+ @extra_class = html_attrs.delete(:class)
21
+ @html_attrs = html_attrs
22
+ end
23
+
24
+ def call
25
+ attrs = { preload: @preload, class: @extra_class }
26
+ attrs[:controls] = true if @controls
27
+ attrs[:autoplay] = true if @autoplay
28
+ attrs[:muted] = true if @muted
29
+ attrs[:loop] = true if @loop
30
+
31
+ content_tag(:audio, **attrs, **@html_attrs) do
32
+ sources.each { |s| concat s }
33
+ concat content if content?
34
+ end
35
+ end
36
+
37
+ # Represents a <source> element inside <audio>.
38
+ # a.with_source(src: "audio.mp3", type: "audio/mpeg")
39
+ class SourceComponent < ApplicationComponent
40
+ def initialize(src:, type:, **html_attrs)
41
+ @src = src
42
+ @type = type
43
+ @html_attrs = html_attrs
44
+ end
45
+
46
+ def call
47
+ tag.source(src: @src, type: @type, **@html_attrs)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class AvatarComponent < ApplicationComponent
5
+ SIZES = { sm: "size-6", default: "size-8", lg: "size-12" }.freeze
6
+ BASE = "relative flex shrink-0 overflow-hidden rounded-full select-none"
7
+ IMAGE = "aspect-square size-full object-cover"
8
+ FALLBACK = "flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground"
9
+
10
+ def initialize(src: nil, alt: "", fallback: nil, size: :default, **html_attrs)
11
+ @src = src
12
+ @alt = alt
13
+ @fallback = fallback
14
+ @size = size.to_sym
15
+ @extra_class = html_attrs.delete(:class)
16
+ @html_attrs = html_attrs
17
+ end
18
+
19
+ def call
20
+ content_tag(:div, class: cn(BASE, SIZES[@size], @extra_class), **@html_attrs) do
21
+ if @src
22
+ content_tag(:img, nil, src: @src, alt: @alt, class: IMAGE)
23
+ else
24
+ content_tag(:span, initials(@fallback || @alt), class: FALLBACK)
25
+ end
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def initials(text)
32
+ return "" if text.nil? || text.strip.empty?
33
+
34
+ text.split.first(2).map { |word| word[0] }.join.upcase
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class BadgeComponent < ApplicationComponent
5
+ BASE = "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full " \
6
+ "border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap " \
7
+ "transition-[color,box-shadow] " \
8
+ "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
9
+ "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " \
10
+ "[&>svg]:pointer-events-none [&>svg]:size-3"
11
+
12
+ VARIANTS = {
13
+ default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
14
+ secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
15
+ destructive: "bg-destructive text-white focus-visible:ring-destructive/20 " \
16
+ "dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
17
+ outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
18
+ ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
19
+ link: "text-primary underline-offset-4 [a&]:hover:underline"
20
+ }.freeze
21
+
22
+ def initialize(label = nil, variant: :default, **html_attrs)
23
+ @label = label || html_attrs.delete(:label)
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, content.presence || @label,
31
+ class: cn(BASE, VARIANTS.fetch(@variant, VARIANTS[:default]), @extra_class),
32
+ **@html_attrs)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class BannerComponent < ApplicationComponent
5
+ BASE = "flex items-center gap-3 rounded-lg border p-4 text-sm"
6
+
7
+ VARIANTS = {
8
+ default: "bg-background text-foreground",
9
+ info: "border-blue-200 bg-blue-50 text-blue-900",
10
+ warning: "border-yellow-200 bg-yellow-50 text-yellow-900",
11
+ destructive: "border-destructive/40 bg-destructive/10 text-destructive",
12
+ success: "border-green-200 bg-green-50 text-green-900"
13
+ }.freeze
14
+
15
+ def initialize(message = nil, variant: :default, **html_attrs)
16
+ @message = message || html_attrs.delete(:message) || html_attrs.delete(:label)
17
+ @variant = variant.to_sym
18
+ @extra_class = html_attrs.delete(:class)
19
+ @html_attrs = html_attrs
20
+ end
21
+
22
+ def call
23
+ content_tag(:div,
24
+ content.presence || @message,
25
+ class: cn(BASE, VARIANTS.fetch(@variant, VARIANTS[:default]), @extra_class),
26
+ **@html_attrs)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class BottomNavComponent < ApplicationComponent
5
+ BASE = "fixed bottom-0 left-0 z-50 w-full border-t bg-background"
6
+
7
+ # items: [{ label:, href:, active: (optional), icon: (optional HTML string) }]
8
+ def initialize(items: [], **html_attrs)
9
+ @items = items
10
+ @extra_class = html_attrs.delete(:class)
11
+ @html_attrs = html_attrs
12
+ end
13
+
14
+ def call
15
+ content_tag(:nav, class: cn(BASE, @extra_class), **@html_attrs) do
16
+ content_tag(:div, class: "mx-auto flex h-16 max-w-lg items-center justify-around") do
17
+ safe_join(@items.map { |item| nav_item(item) })
18
+ end
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def nav_item(item)
25
+ active = item[:active]
26
+ content_tag(:a,
27
+ href: item[:href],
28
+ class: cn(
29
+ "flex flex-col items-center justify-center gap-1 px-4 py-2 text-xs font-medium transition-colors",
30
+ active ? "text-primary" : "text-muted-foreground hover:text-foreground"
31
+ ),
32
+ "aria-current": (active ? "page" : nil)) do
33
+ concat raw(item[:icon]) if item[:icon]
34
+ concat content_tag(:span, item[:label])
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class BreadcrumbComponent < ApplicationComponent
5
+ LINK = "text-muted-foreground hover:text-foreground transition-colors"
6
+ CURRENT = "text-foreground font-medium"
7
+
8
+ # items: [{ label:, href: }, ..., { label: }] — last item is the current page (no href)
9
+ def initialize(items: [], separator: "/", **html_attrs)
10
+ @items = items
11
+ @separator = separator
12
+ @extra_class = html_attrs.delete(:class)
13
+ @html_attrs = html_attrs
14
+ end
15
+
16
+ def call
17
+ content_tag(:nav, "aria-label": "Breadcrumb", **@html_attrs) do
18
+ content_tag(:ol, class: cn("flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5", @extra_class)) do
19
+ safe_join(@items.each_with_index.map { |item, i| crumb(item, i == @items.size - 1) })
20
+ end
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def crumb(item, is_last)
27
+ content_tag(:li, class: "inline-flex items-center gap-1.5") do
28
+ if is_last
29
+ content_tag(:span, item[:label], class: CURRENT, "aria-current": "page")
30
+ else
31
+ concat content_tag(:a, item[:label], href: item[:href], class: LINK)
32
+ concat content_tag(:span, @separator, class: "text-muted-foreground select-none", "aria-hidden": "true")
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class ButtonComponent < ApplicationComponent
5
+ BASE_CLASSES = "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm " \
6
+ "font-medium transition-all outline-none " \
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
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
11
+
12
+ VARIANTS = {
13
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
14
+ destructive: "bg-destructive text-white hover:bg-destructive/90 " \
15
+ "focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
16
+ outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground " \
17
+ "dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
18
+ secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19
+ ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
20
+ link: "text-primary underline-offset-4 hover:underline"
21
+ }.freeze
22
+
23
+ SIZES = {
24
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
25
+ xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
26
+ sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
27
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28
+ icon: "size-9"
29
+ }.freeze
30
+
31
+ # label — positional or keyword shorthand for plain-text buttons without a block.
32
+ # href — renders an <a> tag; sets tag: :a automatically.
33
+ def initialize(label = nil, variant: :default, size: :default, href: nil, **html_attrs)
34
+ @label = label || html_attrs.delete(:label)
35
+ @variant = variant.to_sym
36
+ @size = size.to_sym
37
+ @tag = html_attrs.delete(:tag)
38
+ @extra_class = html_attrs.delete(:class)
39
+ @html_attrs = html_attrs
40
+
41
+ if href
42
+ @html_attrs[:href] = href
43
+ @tag ||= :a
44
+ end
45
+ end
46
+
47
+ def call
48
+ body = content.presence || @label
49
+ tag = @tag || :button
50
+ attrs = @html_attrs.merge(class: component_classes)
51
+ attrs[:type] ||= "button" if tag == :button && !attrs.key?(:type)
52
+ content_tag(tag, body, **attrs)
53
+ end
54
+
55
+ private
56
+
57
+ def component_classes
58
+ cn(BASE_CLASSES, VARIANTS.fetch(@variant, VARIANTS[:default]), SIZES.fetch(@size, SIZES[:default]), @extra_class)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class ButtonGroupComponent < ApplicationComponent
5
+ BASE = "inline-flex rounded-md shadow-sm " \
6
+ "[&>*]:rounded-none " \
7
+ "[&>*:first-child]:rounded-l-md " \
8
+ "[&>*:last-child]:rounded-r-md " \
9
+ "[&>*:not(:first-child)]:-ml-px"
10
+
11
+ def initialize(**html_attrs)
12
+ @extra_class = html_attrs.delete(:class)
13
+ @html_attrs = html_attrs
14
+ end
15
+
16
+ def call
17
+ content_tag(:div, content,
18
+ class: cn(BASE, @extra_class),
19
+ role: "group",
20
+ **@html_attrs)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class CalendarComponent < ApplicationComponent
5
+ # Month-grid calendar. Renders a full month; selected/today dates are highlighted.
6
+ #
7
+ # selected: Date or nil — highlighted day
8
+ # month: Date — controls which month is shown (defaults to today)
9
+ # name: form field name for the hidden input (if used in a form)
10
+ # min/max: Date bounds for disabled days
11
+
12
+ CONTAINER = "w-fit rounded-lg border border-border bg-popover p-4 text-sm shadow"
13
+ HEADER_CLS = "mb-3 flex items-center justify-between"
14
+ MONTH_CLS = "font-medium text-foreground"
15
+ NAV_BTN = "inline-flex size-7 items-center justify-center rounded-md " \
16
+ "text-muted-foreground hover:bg-accent hover:text-accent-foreground " \
17
+ "focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none transition"
18
+ GRID_CLS = "grid grid-cols-7 gap-px"
19
+ DOW_CLS = "py-1.5 text-center text-xs text-muted-foreground font-medium"
20
+ DAY_BASE = "h-9 w-9 rounded-md text-center text-sm transition-colors outline-none " \
21
+ "focus-visible:ring-[3px] focus-visible:ring-ring/50"
22
+ DAY_NORMAL = "hover:bg-accent hover:text-accent-foreground"
23
+ DAY_TODAY = "font-semibold text-foreground ring-1 ring-border"
24
+ DAY_SEL = "bg-primary text-primary-foreground hover:bg-primary/90"
25
+ DAY_MUTED = "text-muted-foreground/50"
26
+ DAY_DISABLED = "pointer-events-none opacity-30"
27
+
28
+ DAYS_OF_WEEK = %w[Su Mo Tu We Th Fr Sa].freeze
29
+ CHEVRON_L = "m15 18-6-6 6-6"
30
+ CHEVRON_R = "m9 18 6-6-6-6"
31
+
32
+ def initialize(selected: nil, month: nil, name: nil, min: nil, max: nil, **html_attrs)
33
+ @selected = selected
34
+ @month = (month || Date.today).beginning_of_month
35
+ @name = name
36
+ @min = min
37
+ @max = max
38
+ @extra_class = html_attrs.delete(:class)
39
+ @html_attrs = html_attrs
40
+ end
41
+
42
+ def call
43
+ content_tag(:div,
44
+ class: cn(CONTAINER, @extra_class),
45
+ data: { controller: "calendar", calendar_month_value: @month.iso8601 },
46
+ **@html_attrs) do
47
+ concat hidden_input if @name && @selected
48
+ concat header_row
49
+ concat day_of_week_row
50
+ concat day_grid
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def hidden_input
57
+ tag.input(type: "hidden", name: @name, value: @selected&.iso8601)
58
+ end
59
+
60
+ def header_row
61
+ content_tag(:div, class: HEADER_CLS) do
62
+ concat nav_btn(CHEVRON_L, "Previous month", "click->calendar#prevMonth")
63
+ concat content_tag(:span, @month.strftime("%B %Y"), class: MONTH_CLS,
64
+ data: { calendar_target: "monthLabel" })
65
+ concat nav_btn(CHEVRON_R, "Next month", "click->calendar#nextMonth")
66
+ end
67
+ end
68
+
69
+ def nav_btn(path, label, action)
70
+ content_tag(:button, type: "button", class: NAV_BTN,
71
+ "aria-label": label, data: { action: action }) { chevron(path) }
72
+ end
73
+
74
+ def day_of_week_row
75
+ content_tag(:div, class: GRID_CLS) do
76
+ safe_join(DAYS_OF_WEEK.map { |d| content_tag(:div, d, class: DOW_CLS) })
77
+ end
78
+ end
79
+
80
+ def day_grid
81
+ today = Date.today
82
+ first = @month.beginning_of_month
83
+ start = first - first.wday
84
+ days = (start..(start + 41)).to_a
85
+
86
+ content_tag(:div, class: GRID_CLS, data: { calendar_target: "grid" }) do
87
+ safe_join(days.map { |d| day_cell(d, today, first) })
88
+ end
89
+ end
90
+
91
+ def day_cell(date, today, first)
92
+ outside = date.month != first.month
93
+ selected = @selected && date == @selected
94
+ is_today = date == today
95
+ disabled = (@min && date < @min) || (@max && date > @max)
96
+
97
+ classes = cn(DAY_BASE,
98
+ selected ? DAY_SEL : DAY_NORMAL,
99
+ is_today ? DAY_TODAY : nil,
100
+ outside ? DAY_MUTED : nil,
101
+ disabled ? DAY_DISABLED : nil)
102
+
103
+ content_tag(:button,
104
+ date.day.to_s,
105
+ type: "button",
106
+ class: classes,
107
+ "aria-label": date.strftime("%B %-d, %Y"),
108
+ "aria-pressed": selected.to_s,
109
+ disabled: disabled || nil,
110
+ data: { action: "click->calendar#selectDay", calendar_date_param: date.iso8601 })
111
+ end
112
+
113
+ def chevron(path)
114
+ content_tag(:svg,
115
+ content_tag(:path, nil, d: path, "stroke-linecap": "round", "stroke-linejoin": "round"),
116
+ xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
117
+ fill: "none", stroke: "currentColor", "stroke-width": "2",
118
+ class: "size-4", "aria-hidden": "true")
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,86 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["monthLabel", "grid"]
5
+ static values = { month: String }
6
+
7
+ selectDay({ params: { date } }) {
8
+ const selected = date
9
+ this.element.dataset.selected = selected
10
+
11
+ this.gridTarget.querySelectorAll("button[data-calendar-date-param]").forEach(btn => {
12
+ const isSelected = btn.dataset.calendarDateParam === selected
13
+ btn.dataset.state = isSelected ? "on" : "off"
14
+ btn.setAttribute("aria-pressed", String(isSelected))
15
+ })
16
+
17
+ const hidden = this.element.querySelector("input[type=hidden]")
18
+ if (hidden) hidden.value = selected
19
+
20
+ this.element.dispatchEvent(new CustomEvent("calendar:change", {
21
+ detail: { date: selected },
22
+ bubbles: true
23
+ }))
24
+ }
25
+
26
+ prevMonth() {
27
+ this.#shiftMonth(-1)
28
+ }
29
+
30
+ nextMonth() {
31
+ this.#shiftMonth(1)
32
+ }
33
+
34
+ #shiftMonth(delta) {
35
+ const [year, month] = this.monthValue.split("-").map(Number)
36
+ const d = new Date(year, month - 1 + delta, 1)
37
+ this.monthValue = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`
38
+ }
39
+
40
+ monthValueChanged(value) {
41
+ if (!value) return
42
+ const [year, month] = value.split("-").map(Number)
43
+ const date = new Date(year, month - 1, 1)
44
+
45
+ this.monthLabelTarget.textContent = date.toLocaleString("default", {
46
+ month: "long",
47
+ year: "numeric"
48
+ })
49
+
50
+ this.#rebuildGrid(year, month)
51
+ }
52
+
53
+ #rebuildGrid(year, month) {
54
+ const selected = this.element.dataset.selected || null
55
+ const today = new Date()
56
+ const todayIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`
57
+
58
+ const first = new Date(year, month - 1, 1)
59
+ const last = new Date(year, month, 0)
60
+ const startOffset = first.getDay()
61
+ const totalCells = 42
62
+
63
+ const buttons = this.gridTarget.querySelectorAll("button[data-calendar-date-param]")
64
+ const start = new Date(first)
65
+ start.setDate(start.getDate() - startOffset)
66
+
67
+ buttons.forEach((btn, i) => {
68
+ const d = new Date(start)
69
+ d.setDate(d.getDate() + i)
70
+ const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
71
+
72
+ btn.dataset.calendarDateParam = iso
73
+ btn.setAttribute("aria-label", d.toLocaleDateString("default", { month: "long", day: "numeric", year: "numeric" }))
74
+ btn.textContent = String(d.getDate())
75
+
76
+ const isSelected = iso === selected
77
+ const isToday = iso === todayIso
78
+ const isOutside = d.getMonth() + 1 !== month
79
+
80
+ btn.dataset.state = isSelected ? "on" : "off"
81
+ btn.setAttribute("aria-pressed", String(isSelected))
82
+ btn.dataset.today = String(isToday)
83
+ btn.dataset.outside = String(isOutside)
84
+ })
85
+ }
86
+ }
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class CardComponent < ApplicationComponent
5
+ BASE = "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm"
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(:div, content, class: cn(BASE, @extra_class), **@html_attrs)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class CardContentComponent < ApplicationComponent
5
+ BASE = "px-6"
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(:div, content, class: cn(BASE, @extra_class), **@html_attrs)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class CardDescriptionComponent < ApplicationComponent
5
+ BASE = "text-muted-foreground text-sm"
6
+
7
+ def initialize(text = nil, **html_attrs)
8
+ @text = text || html_attrs.delete(:label) || html_attrs.delete(:text)
9
+ @extra_class = html_attrs.delete(:class)
10
+ @html_attrs = html_attrs
11
+ end
12
+
13
+ def call
14
+ content_tag(:p, content.presence || @text, class: cn(BASE, @extra_class), **@html_attrs)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class CardFooterComponent < ApplicationComponent
5
+ BASE = "flex items-center px-6"
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(:div, content, class: cn(BASE, @extra_class), **@html_attrs)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class CardHeaderComponent < ApplicationComponent
5
+ BASE = "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 " \
6
+ "has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6"
7
+
8
+ def initialize(**html_attrs)
9
+ @extra_class = html_attrs.delete(:class)
10
+ @html_attrs = html_attrs
11
+ end
12
+
13
+ def call
14
+ content_tag(:div, content, class: cn(BASE, @extra_class), **@html_attrs)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class CardTitleComponent < ApplicationComponent
5
+ BASE = "leading-none font-semibold"
6
+
7
+ def initialize(title = nil, **html_attrs)
8
+ @title = title || html_attrs.delete(:label) || html_attrs.delete(:title)
9
+ @extra_class = html_attrs.delete(:class)
10
+ @html_attrs = html_attrs
11
+ end
12
+
13
+ def call
14
+ content_tag(:h3, content.presence || @title, class: cn(BASE, @extra_class), **@html_attrs)
15
+ end
16
+ end
17
+ end