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,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
|