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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +198 -0
- data/lib/generators/view_primitives/add/add_generator.rb +110 -0
- data/lib/generators/view_primitives/add/templates/accordion/accordion_component.html.erb +10 -0
- data/lib/generators/view_primitives/add/templates/accordion/accordion_component.rb.tt +22 -0
- data/lib/generators/view_primitives/add/templates/accordion/accordion_controller.js +15 -0
- data/lib/generators/view_primitives/add/templates/accordion/accordion_item_component.rb.tt +47 -0
- data/lib/generators/view_primitives/add/templates/alert/alert_component.rb.tt +62 -0
- data/lib/generators/view_primitives/add/templates/alert_dialog/alert_dialog_component.rb.tt +55 -0
- data/lib/generators/view_primitives/add/templates/aspect_ratio/aspect_ratio_component.rb.tt +18 -0
- data/lib/generators/view_primitives/add/templates/audio/audio_component.rb.tt +51 -0
- data/lib/generators/view_primitives/add/templates/avatar/avatar_component.rb.tt +37 -0
- data/lib/generators/view_primitives/add/templates/badge/badge_component.rb.tt +35 -0
- data/lib/generators/view_primitives/add/templates/banner/banner_component.rb.tt +29 -0
- data/lib/generators/view_primitives/add/templates/bottom_nav/bottom_nav_component.rb.tt +38 -0
- data/lib/generators/view_primitives/add/templates/breadcrumb/breadcrumb_component.rb.tt +37 -0
- data/lib/generators/view_primitives/add/templates/button/button_component.rb.tt +61 -0
- data/lib/generators/view_primitives/add/templates/button_group/button_group_component.rb.tt +23 -0
- data/lib/generators/view_primitives/add/templates/calendar/calendar_component.rb.tt +121 -0
- data/lib/generators/view_primitives/add/templates/calendar/calendar_controller.js +86 -0
- data/lib/generators/view_primitives/add/templates/card/card_component.rb.tt +16 -0
- data/lib/generators/view_primitives/add/templates/card/card_content_component.rb.tt +16 -0
- data/lib/generators/view_primitives/add/templates/card/card_description_component.rb.tt +17 -0
- data/lib/generators/view_primitives/add/templates/card/card_footer_component.rb.tt +16 -0
- data/lib/generators/view_primitives/add/templates/card/card_header_component.rb.tt +17 -0
- data/lib/generators/view_primitives/add/templates/card/card_title_component.rb.tt +17 -0
- data/lib/generators/view_primitives/add/templates/carousel/carousel_component.rb.tt +102 -0
- data/lib/generators/view_primitives/add/templates/carousel/carousel_controller.js +48 -0
- data/lib/generators/view_primitives/add/templates/chart/chart_component.rb.tt +63 -0
- data/lib/generators/view_primitives/add/templates/chart/chart_controller.js +29 -0
- data/lib/generators/view_primitives/add/templates/chat_bubble/chat_bubble_component.rb.tt +53 -0
- data/lib/generators/view_primitives/add/templates/checkbox/checkbox_component.rb.tt +50 -0
- data/lib/generators/view_primitives/add/templates/collapsible/collapsible_component.rb.tt +31 -0
- data/lib/generators/view_primitives/add/templates/combobox/combobox_component.rb.tt +87 -0
- data/lib/generators/view_primitives/add/templates/combobox/combobox_controller.js +38 -0
- data/lib/generators/view_primitives/add/templates/command/command_component.rb.tt +85 -0
- data/lib/generators/view_primitives/add/templates/command/command_controller.js +50 -0
- data/lib/generators/view_primitives/add/templates/context_menu/context_menu_component.rb.tt +47 -0
- data/lib/generators/view_primitives/add/templates/context_menu/context_menu_controller.js +20 -0
- data/lib/generators/view_primitives/add/templates/data_table/data_table_component.rb.tt +163 -0
- data/lib/generators/view_primitives/add/templates/data_table/data_table_controller.js +115 -0
- data/lib/generators/view_primitives/add/templates/date_picker/date_picker_component.rb.tt +92 -0
- data/lib/generators/view_primitives/add/templates/date_picker/date_picker_controller.js +48 -0
- data/lib/generators/view_primitives/add/templates/device_mockup/device_mockup_component.rb.tt +65 -0
- data/lib/generators/view_primitives/add/templates/dialog/dialog_component.rb.tt +71 -0
- data/lib/generators/view_primitives/add/templates/dialog/dialog_controller.js +15 -0
- data/lib/generators/view_primitives/add/templates/drawer/drawer_component.rb.tt +62 -0
- data/lib/generators/view_primitives/add/templates/drawer/drawer_controller.js +15 -0
- data/lib/generators/view_primitives/add/templates/dropdown_menu/dropdown_controller.js +17 -0
- data/lib/generators/view_primitives/add/templates/dropdown_menu/dropdown_menu_component.rb.tt +53 -0
- data/lib/generators/view_primitives/add/templates/embed/embed_component.rb.tt +271 -0
- data/lib/generators/view_primitives/add/templates/embed/embed_controller.js +43 -0
- data/lib/generators/view_primitives/add/templates/figure/figure_component.rb.tt +24 -0
- data/lib/generators/view_primitives/add/templates/file_input/file_input_component.rb.tt +31 -0
- data/lib/generators/view_primitives/add/templates/floating_label/floating_label_component.rb.tt +54 -0
- data/lib/generators/view_primitives/add/templates/footer/footer_component.rb.tt +51 -0
- data/lib/generators/view_primitives/add/templates/form_field/form_field_component.rb.tt +51 -0
- data/lib/generators/view_primitives/add/templates/gallery/gallery_component.rb.tt +83 -0
- data/lib/generators/view_primitives/add/templates/gallery/gallery_controller.js +28 -0
- data/lib/generators/view_primitives/add/templates/hover_card/hover_card_component.rb.tt +43 -0
- data/lib/generators/view_primitives/add/templates/iframe/iframe_component.rb.tt +59 -0
- data/lib/generators/view_primitives/add/templates/image/image_component.rb.tt +38 -0
- data/lib/generators/view_primitives/add/templates/indicator/indicator_component.rb.tt +46 -0
- data/lib/generators/view_primitives/add/templates/input/input_component.rb.tt +28 -0
- data/lib/generators/view_primitives/add/templates/input_otp/input_otp_component.rb.tt +65 -0
- data/lib/generators/view_primitives/add/templates/input_otp/input_otp_controller.js +39 -0
- data/lib/generators/view_primitives/add/templates/kbd/kbd_component.rb.tt +21 -0
- data/lib/generators/view_primitives/add/templates/label/label_component.rb.tt +23 -0
- data/lib/generators/view_primitives/add/templates/list_group/list_group_component.rb.tt +16 -0
- data/lib/generators/view_primitives/add/templates/list_group/list_group_item_component.rb.tt +31 -0
- data/lib/generators/view_primitives/add/templates/map_area/map_area_component.rb.tt +72 -0
- data/lib/generators/view_primitives/add/templates/mega_menu/mega_menu_component.rb.tt +130 -0
- data/lib/generators/view_primitives/add/templates/mega_menu/mega_menu_controller.js +23 -0
- data/lib/generators/view_primitives/add/templates/menubar/menubar_component.rb.tt +33 -0
- data/lib/generators/view_primitives/add/templates/menubar/menubar_controller.js +34 -0
- data/lib/generators/view_primitives/add/templates/menubar/menubar_menu_component.rb.tt +34 -0
- data/lib/generators/view_primitives/add/templates/navbar/navbar_component.rb.tt +90 -0
- data/lib/generators/view_primitives/add/templates/navbar/navbar_controller.js +11 -0
- data/lib/generators/view_primitives/add/templates/navigation_menu/navigation_menu_component.rb.tt +132 -0
- data/lib/generators/view_primitives/add/templates/navigation_menu/navigation_menu_controller.js +25 -0
- data/lib/generators/view_primitives/add/templates/number_input/number_input_component.rb.tt +34 -0
- data/lib/generators/view_primitives/add/templates/pagination/pagination_component.rb.tt +97 -0
- data/lib/generators/view_primitives/add/templates/picture/picture_component.rb.tt +63 -0
- data/lib/generators/view_primitives/add/templates/popover/popover_component.rb.tt +56 -0
- data/lib/generators/view_primitives/add/templates/popover/popover_controller.js +17 -0
- data/lib/generators/view_primitives/add/templates/progress/progress_component.rb.tt +28 -0
- data/lib/generators/view_primitives/add/templates/qr_code/qr_code_component.rb.tt +39 -0
- data/lib/generators/view_primitives/add/templates/radio_group/radio_group_component.rb.tt +51 -0
- data/lib/generators/view_primitives/add/templates/range/range_component.rb.tt +40 -0
- data/lib/generators/view_primitives/add/templates/rating/rating_component.rb.tt +42 -0
- data/lib/generators/view_primitives/add/templates/rating_input/rating_controller.js +47 -0
- data/lib/generators/view_primitives/add/templates/rating_input/rating_input_component.rb.tt +79 -0
- data/lib/generators/view_primitives/add/templates/resizable/resizable_component.rb.tt +91 -0
- data/lib/generators/view_primitives/add/templates/resizable/resizable_controller.js +38 -0
- data/lib/generators/view_primitives/add/templates/scroll_area/scroll_area_component.rb.tt +41 -0
- data/lib/generators/view_primitives/add/templates/search_input/search_input_component.rb.tt +50 -0
- data/lib/generators/view_primitives/add/templates/select/select_component.rb.tt +45 -0
- data/lib/generators/view_primitives/add/templates/separator/separator_component.rb.tt +25 -0
- data/lib/generators/view_primitives/add/templates/sheet/sheet_component.rb.tt +78 -0
- data/lib/generators/view_primitives/add/templates/sheet/sheet_controller.js +15 -0
- data/lib/generators/view_primitives/add/templates/sidebar/sidebar_component.rb.tt +169 -0
- data/lib/generators/view_primitives/add/templates/sidebar/sidebar_controller.js +11 -0
- data/lib/generators/view_primitives/add/templates/skeleton/skeleton_component.rb.tt +16 -0
- data/lib/generators/view_primitives/add/templates/speed_dial/speed_dial_component.rb.tt +111 -0
- data/lib/generators/view_primitives/add/templates/speed_dial/speed_dial_controller.js +22 -0
- data/lib/generators/view_primitives/add/templates/spinner/spinner_component.rb.tt +27 -0
- data/lib/generators/view_primitives/add/templates/stepper/stepper_component.rb.tt +99 -0
- data/lib/generators/view_primitives/add/templates/switch/switch_component.rb.tt +51 -0
- data/lib/generators/view_primitives/add/templates/tabs/tabs_component.html.erb +50 -0
- data/lib/generators/view_primitives/add/templates/tabs/tabs_component.rb.tt +16 -0
- data/lib/generators/view_primitives/add/templates/tabs/tabs_controller.js +26 -0
- data/lib/generators/view_primitives/add/templates/tabs/tabs_item_component.rb.tt +15 -0
- data/lib/generators/view_primitives/add/templates/textarea/textarea_component.rb.tt +24 -0
- data/lib/generators/view_primitives/add/templates/timeline/timeline_component.rb.tt +78 -0
- data/lib/generators/view_primitives/add/templates/timepicker/timepicker_component.rb.tt +140 -0
- data/lib/generators/view_primitives/add/templates/timepicker/timepicker_controller.js +92 -0
- data/lib/generators/view_primitives/add/templates/toaster/toaster_component.rb.tt +152 -0
- data/lib/generators/view_primitives/add/templates/toaster/toaster_controller.js +88 -0
- data/lib/generators/view_primitives/add/templates/toggle/toggle_component.rb.tt +41 -0
- data/lib/generators/view_primitives/add/templates/toggle/toggle_controller.js +12 -0
- data/lib/generators/view_primitives/add/templates/toggle_group/toggle_group_component.rb.tt +32 -0
- data/lib/generators/view_primitives/add/templates/toggle_group/toggle_group_controller.js +38 -0
- data/lib/generators/view_primitives/add/templates/tooltip/tooltip_component.rb.tt +42 -0
- data/lib/generators/view_primitives/add/templates/video/video_component.rb.tt +92 -0
- data/lib/generators/view_primitives/add/templates/wysiwyg/wysiwyg_component.rb.tt +88 -0
- data/lib/generators/view_primitives/add/templates/wysiwyg/wysiwyg_controller.js +40 -0
- data/lib/generators/view_primitives/components.rb +62 -0
- data/lib/generators/view_primitives/detector.rb +43 -0
- data/lib/generators/view_primitives/install/install_generator.rb +65 -0
- data/lib/generators/view_primitives/install/templates/application_component.rb.tt +5 -0
- data/lib/generators/view_primitives/install/templates/view_primitives.css +67 -0
- data/lib/generators/view_primitives/list/list_generator.rb +25 -0
- data/lib/view_primitives/class_helper.rb +11 -0
- data/lib/view_primitives/component_helper.rb +20 -0
- data/lib/view_primitives/railtie.rb +21 -0
- data/lib/view_primitives/version.rb +5 -0
- data/lib/view_primitives.rb +12 -0
- 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
|
+
}
|
data/lib/generators/view_primitives/add/templates/navigation_menu/navigation_menu_component.rb.tt
ADDED
|
@@ -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
|
data/lib/generators/view_primitives/add/templates/navigation_menu/navigation_menu_controller.js
ADDED
|
@@ -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
|