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,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class MenubarComponent < ApplicationComponent
5
+ renders_many :menus, "UI::MenubarMenuComponent"
6
+
7
+ BAR = "flex h-9 items-center gap-1 rounded-md border bg-background p-1 shadow-xs"
8
+ ITEM = "relative flex cursor-default select-none items-center gap-2 rounded-sm " \
9
+ "px-2 py-1.5 text-sm outline-none " \
10
+ "hover:bg-accent hover:text-accent-foreground " \
11
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " \
12
+ "[&_svg:not([class*='text-'])]:text-muted-foreground"
13
+ SEPARATOR = "-mx-1 my-1 h-px bg-border"
14
+ LABEL_CLS = "px-2 py-1.5 text-sm font-medium"
15
+
16
+ def initialize(**html_attrs)
17
+ @extra_class = html_attrs.delete(:class)
18
+ @html_attrs = html_attrs
19
+ end
20
+
21
+ def call
22
+ content_tag(:div,
23
+ class: cn(BAR, @extra_class),
24
+ data: {
25
+ controller: "menubar",
26
+ action: "click@document->menubar#closeOnClickOutside keydown.escape@document->menubar#closeAll"
27
+ },
28
+ **@html_attrs) do
29
+ menus.each { |m| concat m }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,34 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["menu", "panel"]
5
+
6
+ get openIndex() {
7
+ return this.panelTargets.findIndex(p => !p.hidden)
8
+ }
9
+
10
+ toggle(event) {
11
+ const menu = event.currentTarget.closest("[data-menubar-target='menu']")
12
+ const index = this.menuTargets.indexOf(menu)
13
+ const panel = this.panelTargets[index]
14
+ const wasOpen = !panel.hidden
15
+ this.closeAll()
16
+ if (!wasOpen) panel.hidden = false
17
+ }
18
+
19
+ openOnHover(event) {
20
+ if (this.openIndex === -1) return
21
+ const menu = event.currentTarget.closest("[data-menubar-target='menu']")
22
+ const index = this.menuTargets.indexOf(menu)
23
+ this.closeAll()
24
+ this.panelTargets[index].hidden = false
25
+ }
26
+
27
+ closeAll() {
28
+ this.panelTargets.forEach(p => p.hidden = true)
29
+ }
30
+
31
+ closeOnClickOutside({ target }) {
32
+ if (!this.element.contains(target)) this.closeAll()
33
+ }
34
+ }
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class MenubarMenuComponent < ApplicationComponent
5
+ TRIGGER = "flex cursor-default select-none items-center rounded-sm px-2 py-1 text-sm font-medium " \
6
+ "outline-none transition-colors " \
7
+ "hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
8
+
9
+ PANEL = "absolute left-0 top-full z-50 mt-1 min-w-[12rem] overflow-hidden rounded-md border " \
10
+ "bg-popover p-1 text-popover-foreground shadow-md"
11
+
12
+ def initialize(label:, **html_attrs)
13
+ @label = label
14
+ @extra_class = html_attrs.delete(:class)
15
+ @html_attrs = html_attrs
16
+ end
17
+
18
+ def call
19
+ content_tag(:div, class: "relative", data: { menubar_target: "menu" }, **@html_attrs) do
20
+ concat content_tag(:button,
21
+ @label,
22
+ type: "button",
23
+ class: TRIGGER,
24
+ data: { action: "click->menubar#toggle mouseenter->menubar#openOnHover" })
25
+ concat content_tag(:div,
26
+ data: { menubar_target: "panel" },
27
+ hidden: true,
28
+ class: cn(PANEL, @extra_class)) {
29
+ concat content
30
+ }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class NavbarComponent < ApplicationComponent
5
+ LINK_BASE = "text-sm font-medium transition-colors hover:text-foreground"
6
+ LINK_IDLE = "text-muted-foreground"
7
+ LINK_ACTIVE = "text-foreground"
8
+
9
+ # items: [{ label:, href:, active: (optional) }]
10
+ # Block content is placed in the right action area (e.g. a Sign in button).
11
+ def initialize(brand: nil, brand_href: "/", items: [], **html_attrs)
12
+ @brand = brand
13
+ @brand_href = brand_href
14
+ @items = items
15
+ @extra_class = html_attrs.delete(:class)
16
+ @html_attrs = html_attrs
17
+ end
18
+
19
+ def call
20
+ content_tag(:nav,
21
+ class: cn("sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60", @extra_class),
22
+ data: { controller: "navbar" },
23
+ **@html_attrs) do
24
+ content_tag(:div, class: "container mx-auto flex h-14 items-center gap-4 px-4") do
25
+ concat brand_link
26
+ concat desktop_menu
27
+ concat spacer
28
+ concat action_area
29
+ concat hamburger
30
+ end
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def brand_link
37
+ return "" unless @brand
38
+
39
+ content_tag(:a, @brand,
40
+ href: @brand_href,
41
+ class: "flex items-center font-semibold text-foreground mr-2")
42
+ end
43
+
44
+ def desktop_menu
45
+ return "" if @items.empty?
46
+
47
+ content_tag(:div, class: "hidden md:flex items-center gap-1") do
48
+ safe_join(@items.map { |item| nav_link(item) })
49
+ end
50
+ end
51
+
52
+ def nav_link(item)
53
+ content_tag(:a, item[:label],
54
+ href: item[:href],
55
+ class: cn(LINK_BASE, item[:active] ? LINK_ACTIVE : LINK_IDLE))
56
+ end
57
+
58
+ def spacer
59
+ content_tag(:div, nil, class: "flex-1")
60
+ end
61
+
62
+ def action_area
63
+ return "" unless content?
64
+
65
+ content_tag(:div, content, class: "hidden md:flex items-center gap-2")
66
+ end
67
+
68
+ def hamburger
69
+ return "" if @items.empty?
70
+
71
+ content_tag(:button, nil,
72
+ type: "button",
73
+ class: "md:hidden inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground",
74
+ data: { action: "click->navbar#toggle", navbar_target: "toggle" },
75
+ "aria-label": "Toggle menu") do
76
+ hamburger_icon
77
+ end
78
+ end
79
+
80
+ def hamburger_icon
81
+ raw(<<~SVG)
82
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
83
+ <line x1="4" x2="20" y1="6" y2="6"/>
84
+ <line x1="4" x2="20" y1="12" y2="12"/>
85
+ <line x1="4" x2="20" y1="18" y2="18"/>
86
+ </svg>
87
+ SVG
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,11 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["menu", "toggle"]
5
+
6
+ toggle() {
7
+ const menu = this.element.nextElementSibling
8
+ if (!menu?.dataset.navbarTarget?.includes("menu")) return
9
+ menu.hidden = !menu.hidden
10
+ }
11
+ }
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class NavigationMenuComponent < ApplicationComponent
5
+ ROOT = "relative flex max-w-max flex-1 items-center justify-center"
6
+ LIST = "flex flex-1 list-none items-center justify-center gap-1"
7
+
8
+ # Trigger button style (item with flyout content)
9
+ TRIGGER = "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background " \
10
+ "px-4 py-2 text-sm font-medium transition-[color,box-shadow] outline-none " \
11
+ "hover:bg-accent hover:text-accent-foreground " \
12
+ "focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
13
+ "disabled:pointer-events-none disabled:opacity-50 " \
14
+ "data-[state=open]:bg-accent/50 data-[state=open]:text-accent-foreground"
15
+
16
+ # Plain link style (item without flyout)
17
+ LINK_CLS = "inline-flex h-9 w-max items-center justify-center rounded-md bg-background " \
18
+ "px-4 py-2 text-sm font-medium transition-[color,box-shadow] outline-none " \
19
+ "hover:bg-accent hover:text-accent-foreground " \
20
+ "focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
21
+ "aria-[current]:bg-accent/50 aria-[current]:text-accent-foreground"
22
+
23
+ # Flyout panel
24
+ CONTENT = "absolute top-full left-0 z-50 mt-1.5 min-w-48 overflow-hidden rounded-md border " \
25
+ "bg-popover p-1 text-popover-foreground shadow"
26
+
27
+ # Styled link inside a flyout panel
28
+ PANEL_LINK = "flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none " \
29
+ "hover:bg-accent hover:text-accent-foreground " \
30
+ "focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
31
+ "aria-[current]:bg-accent/50 aria-[current]:text-accent-foreground"
32
+
33
+ CHEVRON_PATH = "m6 9 6 6 6-6"
34
+
35
+ renders_many :items, "UI::NavigationMenuComponent::ItemComponent"
36
+
37
+ def initialize(**html_attrs)
38
+ @extra_class = html_attrs.delete(:class)
39
+ @html_attrs = html_attrs
40
+ end
41
+
42
+ def call
43
+ content_tag(:nav, class: cn(ROOT, @extra_class), **@html_attrs) do
44
+ content_tag(:ul, class: LIST) do
45
+ safe_join(items.map { |item| content_tag(:li, item, class: "relative") })
46
+ end
47
+ end
48
+ end
49
+
50
+ # Represents one entry in the navigation bar.
51
+ # href: present → plain styled link
52
+ # href: absent → trigger button + flyout (add content via block)
53
+ class ItemComponent < ApplicationComponent
54
+ CHEVRON_PATH = "m6 9 6 6 6-6"
55
+
56
+ def initialize(label:, href: nil, active: false, **html_attrs)
57
+ @label = label
58
+ @href = href
59
+ @active = active
60
+ @extra_class = html_attrs.delete(:class)
61
+ @html_attrs = html_attrs
62
+ end
63
+
64
+ def call
65
+ if @href
66
+ link_item
67
+ else
68
+ trigger_item
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def link_item
75
+ content_tag(:a, @label,
76
+ href: @href,
77
+ class: cn(NavigationMenuComponent::LINK_CLS, @extra_class),
78
+ "aria-current": (@active ? "page" : nil),
79
+ **@html_attrs)
80
+ end
81
+
82
+ def trigger_item
83
+ content_tag(:div,
84
+ class: "relative",
85
+ data: {
86
+ controller: "navigation-menu",
87
+ action: "mouseenter->navigation-menu#open mouseleave->navigation-menu#scheduleClose " \
88
+ "click@document->navigation-menu#closeOnClickOutside"
89
+ }) do
90
+ concat trigger_btn
91
+ concat flyout
92
+ end
93
+ end
94
+
95
+ def trigger_btn
96
+ content_tag(:button,
97
+ type: "button",
98
+ class: cn(NavigationMenuComponent::TRIGGER, @extra_class),
99
+ "aria-expanded": "false",
100
+ data: { navigation_menu_target: "trigger", state: "closed" },
101
+ **@html_attrs) do
102
+ concat @label
103
+ concat chevron
104
+ end
105
+ end
106
+
107
+ def flyout
108
+ content_tag(:div,
109
+ content,
110
+ class: NavigationMenuComponent::CONTENT,
111
+ hidden: true,
112
+ data: {
113
+ navigation_menu_target: "content",
114
+ action: "mouseenter->navigation-menu#open mouseleave->navigation-menu#scheduleClose"
115
+ })
116
+ end
117
+
118
+ def chevron
119
+ content_tag(:svg,
120
+ content_tag(:path, nil, d: CHEVRON_PATH, "stroke-linecap": "round", "stroke-linejoin": "round"),
121
+ xmlns: "http://www.w3.org/2000/svg",
122
+ viewBox: "0 0 24 24",
123
+ fill: "none",
124
+ stroke: "currentColor",
125
+ "stroke-width": "2",
126
+ class: "relative top-[1px] ml-1 size-3 transition-transform duration-200 " \
127
+ "group-data-[state=open]:rotate-180",
128
+ "aria-hidden": "true")
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,25 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["trigger", "content"]
5
+
6
+ open() {
7
+ clearTimeout(this._closeTimer)
8
+ this._setOpen(true)
9
+ }
10
+
11
+ scheduleClose() {
12
+ this._closeTimer = setTimeout(() => this._setOpen(false), 150)
13
+ }
14
+
15
+ closeOnClickOutside(event) {
16
+ if (!this.element.contains(event.target)) this._setOpen(false)
17
+ }
18
+
19
+ _setOpen(open) {
20
+ if (!this.hasContentTarget) return
21
+ this.contentTarget.hidden = !open
22
+ this.triggerTarget.setAttribute("aria-expanded", String(open))
23
+ this.triggerTarget.dataset.state = open ? "open" : "closed"
24
+ }
25
+ }
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class NumberInputComponent < 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
+ "placeholder:text-muted-foreground " \
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
+ "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 " \
11
+ "[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none " \
12
+ "md:text-sm dark:bg-input/30"
13
+
14
+ # min / max / step: native number input attributes
15
+ # value: initial value
16
+ def initialize(min: nil, max: nil, step: nil, value: nil, **html_attrs)
17
+ @min = min
18
+ @max = max
19
+ @step = step
20
+ @value = value
21
+ @extra_class = html_attrs.delete(:class)
22
+ @html_attrs = html_attrs
23
+ end
24
+
25
+ def call
26
+ attrs = { type: "number", class: cn(BASE, @extra_class) }
27
+ attrs[:min] = @min unless @min.nil?
28
+ attrs[:max] = @max unless @max.nil?
29
+ attrs[:step] = @step unless @step.nil?
30
+ attrs[:value] = @value unless @value.nil?
31
+ content_tag(:input, nil, **attrs, **@html_attrs)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class PaginationComponent < ApplicationComponent
5
+ ITEM = "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium " \
6
+ "transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " \
7
+ "h-9 w-9 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground"
8
+ ACTIVE = "bg-primary text-primary-foreground shadow hover:bg-primary/90 border-transparent"
9
+ MUTED = "cursor-not-allowed opacity-50 pointer-events-none"
10
+
11
+ # url: callable — receives a page number, returns a path string
12
+ # url: ->(page) { posts_path(page: page) }
13
+ def initialize(current_page:, total_pages:, url:, window: 2, **html_attrs)
14
+ @current = current_page.to_i
15
+ @total = total_pages.to_i
16
+ @url = url
17
+ @window = window
18
+ @extra_class = html_attrs.delete(:class)
19
+ @html_attrs = html_attrs
20
+ end
21
+
22
+ def call
23
+ return "" if @total <= 1
24
+
25
+ content_tag(:nav, "aria-label": "Pagination", **@html_attrs) do
26
+ content_tag(:ul, class: cn("flex items-center gap-1", @extra_class)) do
27
+ safe_join([prev_item, *page_items, next_item])
28
+ end
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def prev_item
35
+ content_tag(:li) do
36
+ if @current > 1
37
+ content_tag(:a, prev_svg, href: @url.call(@current - 1), class: ITEM, "aria-label": "Previous page")
38
+ else
39
+ content_tag(:span, prev_svg, class: cn(ITEM, MUTED), "aria-disabled": "true")
40
+ end
41
+ end
42
+ end
43
+
44
+ def next_item
45
+ content_tag(:li) do
46
+ if @current < @total
47
+ content_tag(:a, next_svg, href: @url.call(@current + 1), class: ITEM, "aria-label": "Next page")
48
+ else
49
+ content_tag(:span, next_svg, class: cn(ITEM, MUTED), "aria-disabled": "true")
50
+ end
51
+ end
52
+ end
53
+
54
+ def page_items
55
+ pages.map { |page| page == :ellipsis ? ellipsis_item : page_item(page) }
56
+ end
57
+
58
+ def page_item(page)
59
+ is_current = page == @current
60
+ content_tag(:li) do
61
+ if is_current
62
+ content_tag(:span, page.to_s, class: cn(ITEM, ACTIVE), "aria-current": "page")
63
+ else
64
+ content_tag(:a, page.to_s, href: @url.call(page), class: ITEM)
65
+ end
66
+ end
67
+ end
68
+
69
+ def ellipsis_item
70
+ content_tag(:li) do
71
+ content_tag(:span, "…", class: "flex h-9 w-9 items-center justify-center text-sm text-muted-foreground")
72
+ end
73
+ end
74
+
75
+ def pages
76
+ return (1..@total).to_a if @total <= (@window * 2 + 5)
77
+
78
+ left = [@current - @window, 1].max
79
+ right = [@current + @window, @total].min
80
+
81
+ result = [1]
82
+ result << :ellipsis if left > 2
83
+ result.concat((left..right).to_a)
84
+ result << :ellipsis if right < @total - 1
85
+ result << @total
86
+ result.uniq
87
+ end
88
+
89
+ def prev_svg
90
+ raw('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>')
91
+ end
92
+
93
+ def next_svg
94
+ raw('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>')
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class PictureComponent < ApplicationComponent
5
+ # Each source is added via p.with_source(srcset:, type:, media:, sizes:)
6
+ renders_many :sources, "UI::PictureComponent::SourceComponent"
7
+
8
+ # src: fallback <img> URL (required)
9
+ # alt: alternative text on the fallback <img> (required)
10
+ # loading: :lazy (default) | :eager
11
+ # width / height: applied to the fallback <img>
12
+ def initialize(src:, alt:, loading: :lazy, width: nil, height: nil, **html_attrs)
13
+ @src = src
14
+ @alt = alt
15
+ @loading = loading.to_sym
16
+ @width = width
17
+ @height = height
18
+ @extra_class = html_attrs.delete(:class)
19
+ @html_attrs = html_attrs
20
+ end
21
+
22
+ def call
23
+ content_tag(:picture, **@html_attrs) do
24
+ sources.each { |s| concat s }
25
+ concat fallback_img
26
+ end
27
+ end
28
+
29
+ # Represents a <source> element inside <picture>.
30
+ # Declare via: p.with_source(srcset: "img.avif", type: "image/avif")
31
+ # Optional: media:, sizes:, width:, height:
32
+ class SourceComponent < ApplicationComponent
33
+ def initialize(srcset:, type: nil, media: nil, sizes: nil, width: nil, height: nil, **html_attrs)
34
+ @srcset = srcset
35
+ @type = type
36
+ @media = media
37
+ @sizes = sizes
38
+ @width = width
39
+ @height = height
40
+ @html_attrs = html_attrs
41
+ end
42
+
43
+ def call
44
+ attrs = { srcset: @srcset }
45
+ attrs[:type] = @type if @type
46
+ attrs[:media] = @media if @media
47
+ attrs[:sizes] = @sizes if @sizes
48
+ attrs[:width] = @width if @width
49
+ attrs[:height] = @height if @height
50
+ tag.source(**attrs, **@html_attrs)
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def fallback_img
57
+ attrs = { src: @src, alt: @alt, loading: @loading, class: cn("max-w-full", @extra_class) }
58
+ attrs[:width] = @width if @width
59
+ attrs[:height] = @height if @height
60
+ tag.img(**attrs)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class PopoverComponent < ApplicationComponent
5
+ renders_one :trigger
6
+
7
+ PANEL_BASE = "absolute z-50 w-72 rounded-md border bg-popover p-4 " \
8
+ "text-sm text-popover-foreground shadow-md outline-none"
9
+
10
+ ALIGN = {
11
+ start: "left-0",
12
+ center: "left-1/2 -translate-x-1/2",
13
+ end: "right-0"
14
+ }.freeze
15
+
16
+ SIDE = {
17
+ bottom: "top-full mt-2",
18
+ top: "bottom-full mb-2",
19
+ left: "right-full mr-2 top-0",
20
+ right: "left-full ml-2 top-0"
21
+ }.freeze
22
+
23
+ def initialize(align: :start, side: :bottom, **html_attrs)
24
+ @align = align.to_sym
25
+ @side = side.to_sym
26
+ @extra_class = html_attrs.delete(:class)
27
+ @html_attrs = html_attrs
28
+ end
29
+
30
+ def call
31
+ content_tag(:div,
32
+ class: "relative inline-block",
33
+ data: { controller: "popover", action: "click@document->popover#closeOnClickOutside" },
34
+ **@html_attrs) do
35
+ concat content_tag(:span, trigger, data: { action: "click->popover#toggle" }, class: "contents") if trigger
36
+ concat panel
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def panel
43
+ content_tag(:div,
44
+ class: cn(
45
+ PANEL_BASE,
46
+ ALIGN.fetch(@align, ALIGN[:start]),
47
+ SIDE.fetch(@side, SIDE[:bottom]),
48
+ @extra_class
49
+ ),
50
+ data: { popover_target: "panel" },
51
+ hidden: true) do
52
+ content
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,17 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["panel"]
5
+
6
+ toggle() {
7
+ this.panelTarget.hidden = !this.panelTarget.hidden
8
+ }
9
+
10
+ close() {
11
+ this.panelTarget.hidden = true
12
+ }
13
+
14
+ closeOnClickOutside({ target }) {
15
+ if (!this.element.contains(target)) this.close()
16
+ }
17
+ }
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class ProgressComponent < ApplicationComponent
5
+ TRACK = "relative h-2 w-full overflow-hidden rounded-full bg-primary/20"
6
+ BAR = "h-full w-full flex-1 bg-primary transition-all"
7
+
8
+ def initialize(value: 0, max: 100, **html_attrs)
9
+ @value = value
10
+ @max = max
11
+ @pct = [[@value.to_f / @max * 100, 0].max, 100].min
12
+ @extra_class = html_attrs.delete(:class)
13
+ @html_attrs = html_attrs
14
+ end
15
+
16
+ def call
17
+ content_tag(:div,
18
+ class: cn(TRACK, @extra_class),
19
+ role: "progressbar",
20
+ "aria-valuenow": @value,
21
+ "aria-valuemin": 0,
22
+ "aria-valuemax": @max,
23
+ **@html_attrs) do
24
+ content_tag(:div, nil, class: BAR, style: "width: #{@pct.round(2)}%")
25
+ end
26
+ end
27
+ end
28
+ end