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,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class TimelineComponent < ApplicationComponent
|
|
5
|
+
# Vertical timeline of dated events.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# ui :timeline do |t|
|
|
9
|
+
# t.with_item(date: "Jan 2025", title: "Project started")
|
|
10
|
+
# t.with_item(date: "Feb 2025", title: "Milestone reached",
|
|
11
|
+
# description: "Foundation phase complete", variant: :success)
|
|
12
|
+
# t.with_item(date: "Mar 2025", title: "Issue detected", variant: :destructive)
|
|
13
|
+
# end
|
|
14
|
+
|
|
15
|
+
renders_many :items, "UI::TimelineComponent::ItemComponent"
|
|
16
|
+
|
|
17
|
+
def initialize(**html_attrs)
|
|
18
|
+
@extra_class = html_attrs.delete(:class)
|
|
19
|
+
@html_attrs = html_attrs
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
content_tag(:ol,
|
|
24
|
+
class: cn("relative border-l border-border ml-3", @extra_class),
|
|
25
|
+
**@html_attrs) do
|
|
26
|
+
safe_join(items)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class ItemComponent < ApplicationComponent
|
|
31
|
+
# variant: :default | :success | :warning | :destructive | :muted
|
|
32
|
+
VARIANTS = {
|
|
33
|
+
default: "bg-primary",
|
|
34
|
+
success: "bg-green-500",
|
|
35
|
+
warning: "bg-amber-500",
|
|
36
|
+
destructive: "bg-destructive",
|
|
37
|
+
muted: "bg-muted-foreground"
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
DOT_CLS = "absolute -left-1.5 mt-1.5 size-3 rounded-full ring-4 ring-background shrink-0"
|
|
41
|
+
DATE_CLS = "mb-0.5 text-xs font-normal text-muted-foreground"
|
|
42
|
+
TITLE_CLS = "text-sm font-medium text-foreground leading-snug"
|
|
43
|
+
DESC_CLS = "mt-1 text-sm text-muted-foreground"
|
|
44
|
+
|
|
45
|
+
# date: optional date/time string shown above the title
|
|
46
|
+
# title: event label (required)
|
|
47
|
+
# description: optional supporting text
|
|
48
|
+
# variant: dot color — :default, :success, :warning, :destructive, :muted
|
|
49
|
+
def initialize(title:, date: nil, description: nil, variant: :default, **html_attrs)
|
|
50
|
+
@title = title
|
|
51
|
+
@date = date
|
|
52
|
+
@description = description
|
|
53
|
+
@variant = variant.to_sym
|
|
54
|
+
@extra_class = html_attrs.delete(:class)
|
|
55
|
+
@html_attrs = html_attrs
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def call
|
|
59
|
+
content_tag(:li,
|
|
60
|
+
class: cn("mb-8 ml-4 last:mb-0", @extra_class),
|
|
61
|
+
**@html_attrs) do
|
|
62
|
+
concat dot
|
|
63
|
+
concat content_tag(:time, @date, class: DATE_CLS) if @date
|
|
64
|
+
concat content_tag(:p, @title, class: TITLE_CLS)
|
|
65
|
+
concat content_tag(:p, @description, class: DESC_CLS) if @description
|
|
66
|
+
concat content if content?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def dot
|
|
73
|
+
color = VARIANTS.fetch(@variant, VARIANTS[:default])
|
|
74
|
+
content_tag(:span, nil, class: cn(DOT_CLS, color), "aria-hidden": "true")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class TimepickerComponent < ApplicationComponent
|
|
5
|
+
# Time picker — trigger button opens a clock popover with hour/minute spinners.
|
|
6
|
+
#
|
|
7
|
+
# value: "HH:MM" string or nil
|
|
8
|
+
# name: form field name for the hidden input
|
|
9
|
+
# format: :h24 (default) | :h12
|
|
10
|
+
# step: minute step increment (default 1, common: 5, 15, 30)
|
|
11
|
+
|
|
12
|
+
WRAPPER = "relative inline-block"
|
|
13
|
+
TRIGGER = "flex h-9 w-36 cursor-pointer items-center gap-2 rounded-md border border-input " \
|
|
14
|
+
"bg-background px-3 text-sm text-foreground shadow-xs " \
|
|
15
|
+
"focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none transition " \
|
|
16
|
+
"aria-expanded:border-ring"
|
|
17
|
+
ICON_CLS = "size-4 shrink-0 text-muted-foreground"
|
|
18
|
+
POPOVER = "absolute left-0 top-full z-50 mt-1 hidden w-max rounded-lg border border-border " \
|
|
19
|
+
"bg-popover p-3 shadow-md data-[open=true]:block"
|
|
20
|
+
SPINNER_WRAP = "flex items-center justify-center gap-1"
|
|
21
|
+
COL_CLS = "flex flex-col items-center gap-1"
|
|
22
|
+
SPIN_BTN = "inline-flex size-7 items-center justify-center rounded-md " \
|
|
23
|
+
"text-muted-foreground hover:bg-accent hover:text-accent-foreground " \
|
|
24
|
+
"focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none transition"
|
|
25
|
+
NUM_CLS = "w-10 rounded-md border border-input bg-background px-1 py-0.5 text-center text-sm " \
|
|
26
|
+
"focus:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
|
27
|
+
SEP_CLS = "text-lg font-medium text-foreground pb-1"
|
|
28
|
+
|
|
29
|
+
def initialize(value: nil, name: nil, format: :h24, step: 1, **html_attrs)
|
|
30
|
+
@value = value
|
|
31
|
+
@name = name
|
|
32
|
+
@format = format.to_sym
|
|
33
|
+
@step = step.to_i.clamp(1, 60)
|
|
34
|
+
@extra_class = html_attrs.delete(:class)
|
|
35
|
+
@html_attrs = html_attrs
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call
|
|
39
|
+
content_tag(:div,
|
|
40
|
+
class: cn(WRAPPER, @extra_class),
|
|
41
|
+
data: {
|
|
42
|
+
controller: "timepicker",
|
|
43
|
+
timepicker_format_value: @format,
|
|
44
|
+
timepicker_step_value: @step
|
|
45
|
+
},
|
|
46
|
+
**@html_attrs) do
|
|
47
|
+
concat hidden_input if @name
|
|
48
|
+
concat trigger_button
|
|
49
|
+
concat clock_popover
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def hidden_input
|
|
56
|
+
tag.input(type: "hidden", name: @name,
|
|
57
|
+
value: @value,
|
|
58
|
+
data: { timepicker_target: "hidden" })
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def trigger_button
|
|
62
|
+
content_tag(:button, type: "button",
|
|
63
|
+
class: TRIGGER,
|
|
64
|
+
"aria-expanded": "false",
|
|
65
|
+
"aria-haspopup": "dialog",
|
|
66
|
+
data: {
|
|
67
|
+
timepicker_target: "trigger",
|
|
68
|
+
action: "click->timepicker#toggle"
|
|
69
|
+
}) do
|
|
70
|
+
concat clock_icon
|
|
71
|
+
concat content_tag(:span, @value || "Pick time", data: { timepicker_target: "label" })
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def clock_popover
|
|
76
|
+
hour_val, min_val = (@value || "00:00").split(":").map(&:to_i)
|
|
77
|
+
|
|
78
|
+
content_tag(:div,
|
|
79
|
+
class: POPOVER,
|
|
80
|
+
role: "dialog",
|
|
81
|
+
"aria-modal": "true",
|
|
82
|
+
data: { timepicker_target: "popover" }) do
|
|
83
|
+
content_tag(:div, class: SPINNER_WRAP) do
|
|
84
|
+
concat hour_column(hour_val)
|
|
85
|
+
concat content_tag(:span, ":", class: SEP_CLS)
|
|
86
|
+
concat minute_column(min_val)
|
|
87
|
+
concat ampm_column if @format == :h12
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def hour_column(val)
|
|
93
|
+
content_tag(:div, class: COL_CLS) do
|
|
94
|
+
concat spin_btn("▲", "click->timepicker#hourUp")
|
|
95
|
+
concat tag.input(type: "text", inputmode: "numeric", class: NUM_CLS,
|
|
96
|
+
value: val.to_s.rjust(2, "0"), maxlength: "2",
|
|
97
|
+
data: { timepicker_target: "hour", action: "change->timepicker#hourChanged" })
|
|
98
|
+
concat spin_btn("▼", "click->timepicker#hourDown")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def minute_column(val)
|
|
103
|
+
content_tag(:div, class: COL_CLS) do
|
|
104
|
+
concat spin_btn("▲", "click->timepicker#minuteUp")
|
|
105
|
+
concat tag.input(type: "text", inputmode: "numeric", class: NUM_CLS,
|
|
106
|
+
value: val.to_s.rjust(2, "0"), maxlength: "2",
|
|
107
|
+
data: { timepicker_target: "minute", action: "change->timepicker#minuteChanged" })
|
|
108
|
+
concat spin_btn("▼", "click->timepicker#minuteDown")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def ampm_column
|
|
113
|
+
content_tag(:div, class: COL_CLS) do
|
|
114
|
+
concat spin_btn("▲", "click->timepicker#toggleAmPm")
|
|
115
|
+
concat content_tag(:span, "AM",
|
|
116
|
+
class: "w-10 rounded-md border border-input bg-background px-1 py-0.5 text-center text-sm cursor-pointer select-none",
|
|
117
|
+
data: { timepicker_target: "ampm", action: "click->timepicker#toggleAmPm" })
|
|
118
|
+
concat spin_btn("▼", "click->timepicker#toggleAmPm")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def spin_btn(label, action)
|
|
123
|
+
content_tag(:button, label, type: "button",
|
|
124
|
+
class: SPIN_BTN, "aria-hidden": "true",
|
|
125
|
+
tabindex: "-1", data: { action: action })
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def clock_icon
|
|
129
|
+
content_tag(:svg,
|
|
130
|
+
safe_join([
|
|
131
|
+
content_tag(:circle, nil, cx: "12", cy: "12", r: "10"),
|
|
132
|
+
content_tag(:polyline, nil, points: "12 6 12 12 16 14")
|
|
133
|
+
]),
|
|
134
|
+
xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
|
|
135
|
+
fill: "none", stroke: "currentColor", "stroke-width": "2",
|
|
136
|
+
"stroke-linecap": "round", "stroke-linejoin": "round",
|
|
137
|
+
class: ICON_CLS, "aria-hidden": "true")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["trigger", "popover", "label", "hidden", "hour", "minute", "ampm"]
|
|
5
|
+
static values = {
|
|
6
|
+
format: { type: String, default: "h24" },
|
|
7
|
+
step: { type: Number, default: 1 }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
connect() {
|
|
11
|
+
this.#outsideHandler = (e) => {
|
|
12
|
+
if (!this.element.contains(e.target)) this.close()
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
toggle() {
|
|
17
|
+
this.isOpen ? this.close() : this.open()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
open() {
|
|
21
|
+
this.popoverTarget.dataset.open = "true"
|
|
22
|
+
this.triggerTarget.setAttribute("aria-expanded", "true")
|
|
23
|
+
document.addEventListener("click", this.#outsideHandler)
|
|
24
|
+
this.isOpen = true
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
close() {
|
|
28
|
+
this.popoverTarget.dataset.open = "false"
|
|
29
|
+
this.triggerTarget.setAttribute("aria-expanded", "false")
|
|
30
|
+
document.removeEventListener("click", this.#outsideHandler)
|
|
31
|
+
this.isOpen = false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
hourUp() { this.#stepHour(1) }
|
|
35
|
+
hourDown() { this.#stepHour(-1) }
|
|
36
|
+
|
|
37
|
+
minuteUp() { this.#stepMinute(this.stepValue) }
|
|
38
|
+
minuteDown() { this.#stepMinute(-this.stepValue) }
|
|
39
|
+
|
|
40
|
+
toggleAmPm() {
|
|
41
|
+
if (!this.hasAmpmTarget) return
|
|
42
|
+
const current = this.ampmTarget.textContent.trim()
|
|
43
|
+
this.ampmTarget.textContent = current === "AM" ? "PM" : "AM"
|
|
44
|
+
this.#commit()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
hourChanged() { this.#clampInput(this.hourTarget, 0, this.formatValue === "h12" ? 12 : 23); this.#commit() }
|
|
48
|
+
minuteChanged() { this.#clampInput(this.minuteTarget, 0, 59); this.#commit() }
|
|
49
|
+
|
|
50
|
+
#stepHour(delta) {
|
|
51
|
+
const max = this.formatValue === "h12" ? 12 : 23
|
|
52
|
+
let val = parseInt(this.hourTarget.value || "0", 10) + delta
|
|
53
|
+
if (val > max) val = 0
|
|
54
|
+
if (val < 0) val = max
|
|
55
|
+
this.hourTarget.value = String(val).padStart(2, "0")
|
|
56
|
+
this.#commit()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#stepMinute(delta) {
|
|
60
|
+
let val = parseInt(this.minuteTarget.value || "0", 10) + delta
|
|
61
|
+
if (val > 59) val = 0
|
|
62
|
+
if (val < 0) val = 59
|
|
63
|
+
this.minuteTarget.value = String(val).padStart(2, "0")
|
|
64
|
+
this.#commit()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#clampInput(input, min, max) {
|
|
68
|
+
let val = parseInt(input.value || "0", 10)
|
|
69
|
+
if (isNaN(val)) val = min
|
|
70
|
+
val = Math.min(max, Math.max(min, val))
|
|
71
|
+
input.value = String(val).padStart(2, "0")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#commit() {
|
|
75
|
+
const h = this.hourTarget.value.padStart(2, "0")
|
|
76
|
+
const m = this.minuteTarget.value.padStart(2, "0")
|
|
77
|
+
const ampm = this.hasAmpmTarget ? ` ${this.ampmTarget.textContent.trim()}` : ""
|
|
78
|
+
const display = `${h}:${m}${ampm}`
|
|
79
|
+
const hidden = `${h}:${m}`
|
|
80
|
+
|
|
81
|
+
this.labelTarget.textContent = display
|
|
82
|
+
if (this.hasHiddenTarget) this.hiddenTarget.value = hidden
|
|
83
|
+
|
|
84
|
+
this.element.dispatchEvent(new CustomEvent("timepicker:change", {
|
|
85
|
+
detail: { time: hidden },
|
|
86
|
+
bubbles: true
|
|
87
|
+
}))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#outsideHandler = null
|
|
91
|
+
isOpen = false
|
|
92
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class ToasterComponent < ApplicationComponent
|
|
5
|
+
# Fixed-position toast stack (Sonner-style).
|
|
6
|
+
# Place once in the application layout; trigger toasts server-side via
|
|
7
|
+
# the renders_many slot or client-side via a `toaster:add` window event.
|
|
8
|
+
#
|
|
9
|
+
# Usage (layout):
|
|
10
|
+
# ui :toaster do |t|
|
|
11
|
+
# t.with_toast(message: "Profile saved", variant: :success)
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Usage (JS dispatch from any controller):
|
|
15
|
+
# window.dispatchEvent(new CustomEvent("toaster:add", {
|
|
16
|
+
# detail: { message: "Done!", variant: "success", duration: 3000 }
|
|
17
|
+
# }))
|
|
18
|
+
|
|
19
|
+
POSITIONS = {
|
|
20
|
+
bottom_right: "fixed bottom-4 right-4",
|
|
21
|
+
bottom_left: "fixed bottom-4 left-4",
|
|
22
|
+
bottom_center: "fixed bottom-4 left-1/2 -translate-x-1/2",
|
|
23
|
+
top_right: "fixed top-4 right-4",
|
|
24
|
+
top_left: "fixed top-4 left-4",
|
|
25
|
+
top_center: "fixed top-4 left-1/2 -translate-x-1/2"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
CONTAINER_CLS = "z-50 flex flex-col gap-2 w-80 pointer-events-none"
|
|
29
|
+
|
|
30
|
+
renders_many :toasts, "UI::ToasterComponent::ToastComponent"
|
|
31
|
+
|
|
32
|
+
# position: corner to anchor the stack (default: :bottom_right)
|
|
33
|
+
def initialize(position: :bottom_right, **html_attrs)
|
|
34
|
+
@position = position.to_sym
|
|
35
|
+
@extra_class = html_attrs.delete(:class)
|
|
36
|
+
@html_attrs = html_attrs
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call
|
|
40
|
+
pos_cls = POSITIONS.fetch(@position, POSITIONS[:bottom_right])
|
|
41
|
+
content_tag(:div,
|
|
42
|
+
class: cn(CONTAINER_CLS, pos_cls, @extra_class),
|
|
43
|
+
data: {
|
|
44
|
+
controller: "toaster",
|
|
45
|
+
action: "toaster:add@window->toaster#add"
|
|
46
|
+
},
|
|
47
|
+
**@html_attrs) do
|
|
48
|
+
safe_join(toasts)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class ToastComponent < ApplicationComponent
|
|
53
|
+
VARIANTS = {
|
|
54
|
+
default: {border: "border-border", icon: nil, icon_color: "text-foreground"},
|
|
55
|
+
success: {
|
|
56
|
+
border: "border-green-500/40",
|
|
57
|
+
icon: "M9 12l2 2 4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0z",
|
|
58
|
+
icon_color: "text-green-500"
|
|
59
|
+
},
|
|
60
|
+
warning: {
|
|
61
|
+
border: "border-amber-500/40",
|
|
62
|
+
icon: "M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z",
|
|
63
|
+
icon_color: "text-amber-500"
|
|
64
|
+
},
|
|
65
|
+
destructive: {
|
|
66
|
+
border: "border-destructive/40",
|
|
67
|
+
icon: "M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 1 1-18 0 9 9 0 0 1 18 0z",
|
|
68
|
+
icon_color: "text-destructive"
|
|
69
|
+
},
|
|
70
|
+
info: {
|
|
71
|
+
border: "border-blue-500/40",
|
|
72
|
+
icon: "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z",
|
|
73
|
+
icon_color: "text-blue-500"
|
|
74
|
+
}
|
|
75
|
+
}.freeze
|
|
76
|
+
|
|
77
|
+
TOAST_CLS = "pointer-events-auto flex items-start gap-3 rounded-lg border " \
|
|
78
|
+
"bg-background px-4 py-3 shadow-lg text-foreground " \
|
|
79
|
+
"transition-all duration-300 translate-y-2 opacity-0 " \
|
|
80
|
+
"data-[open=true]:translate-y-0 data-[open=true]:opacity-100"
|
|
81
|
+
|
|
82
|
+
CLOSE_CLS = "ml-auto -mr-1 -mt-0.5 shrink-0 inline-flex size-6 items-center justify-center " \
|
|
83
|
+
"rounded-md text-muted-foreground hover:text-foreground hover:bg-accent " \
|
|
84
|
+
"focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none transition"
|
|
85
|
+
|
|
86
|
+
# message: toast body (required)
|
|
87
|
+
# title: optional bold heading
|
|
88
|
+
# variant: :default | :success | :warning | :destructive | :info
|
|
89
|
+
# duration: auto-dismiss in ms; 0 = no auto-dismiss (default: 4000)
|
|
90
|
+
def initialize(message:, title: nil, variant: :default, duration: 4000, **html_attrs)
|
|
91
|
+
@message = message
|
|
92
|
+
@title = title
|
|
93
|
+
@variant = variant.to_sym
|
|
94
|
+
@duration = duration.to_i
|
|
95
|
+
@extra_class = html_attrs.delete(:class)
|
|
96
|
+
@html_attrs = html_attrs
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def call
|
|
100
|
+
v = VARIANTS.fetch(@variant, VARIANTS[:default])
|
|
101
|
+
content_tag(:div,
|
|
102
|
+
class: cn(TOAST_CLS, v[:border], @extra_class),
|
|
103
|
+
role: "alert",
|
|
104
|
+
"aria-live": "polite",
|
|
105
|
+
"data-open": "false",
|
|
106
|
+
data: {
|
|
107
|
+
toaster_target: "toast",
|
|
108
|
+
toaster_duration_param: @duration
|
|
109
|
+
},
|
|
110
|
+
**@html_attrs) do
|
|
111
|
+
concat icon_svg(v[:icon], v[:icon_color]) if v[:icon]
|
|
112
|
+
concat body
|
|
113
|
+
concat close_btn
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def body
|
|
120
|
+
content_tag(:div, class: "flex-1 min-w-0") do
|
|
121
|
+
concat content_tag(:p, @title, class: "text-sm font-semibold leading-tight") if @title
|
|
122
|
+
concat content_tag(:p, @message,
|
|
123
|
+
class: "text-sm leading-snug#{" text-muted-foreground mt-0.5" if @title}")
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def close_btn
|
|
128
|
+
content_tag(:button, type: "button",
|
|
129
|
+
class: CLOSE_CLS,
|
|
130
|
+
"aria-label": "Dismiss",
|
|
131
|
+
data: {action: "click->toaster#dismiss"}) do
|
|
132
|
+
content_tag(:svg,
|
|
133
|
+
content_tag(:path, nil, d: "M18 6 6 18M6 6l12 12",
|
|
134
|
+
"stroke-linecap": "round", "stroke-linejoin": "round"),
|
|
135
|
+
xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
|
|
136
|
+
fill: "none", stroke: "currentColor", "stroke-width": "2",
|
|
137
|
+
class: "size-3.5", "aria-hidden": "true")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def icon_svg(path, color_cls)
|
|
142
|
+
content_tag(:svg,
|
|
143
|
+
content_tag(:path, nil, d: path,
|
|
144
|
+
"stroke-linecap": "round", "stroke-linejoin": "round"),
|
|
145
|
+
xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
|
|
146
|
+
fill: "none", stroke: "currentColor", "stroke-width": "2",
|
|
147
|
+
class: "mt-0.5 size-4 shrink-0 #{color_cls}",
|
|
148
|
+
"aria-hidden": "true")
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["toast"]
|
|
5
|
+
#timers = new Map()
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.toastTargets.forEach(toast => this.#show(toast))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Triggered by: window.dispatchEvent(new CustomEvent("toaster:add", { detail: { message, title, variant, duration } }))
|
|
12
|
+
add({ detail }) {
|
|
13
|
+
const toast = this.#buildToast(detail)
|
|
14
|
+
this.element.appendChild(toast)
|
|
15
|
+
this.#show(toast)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
dismiss({ currentTarget }) {
|
|
19
|
+
this.#hide(currentTarget.closest("[data-toaster-target='toast']"))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#show(toast) {
|
|
23
|
+
requestAnimationFrame(() => {
|
|
24
|
+
toast.dataset.open = "true"
|
|
25
|
+
const duration = parseInt(toast.dataset.toasterDurationParam ?? "4000")
|
|
26
|
+
if (duration > 0) {
|
|
27
|
+
this.#timers.set(toast, setTimeout(() => this.#hide(toast), duration))
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#hide(toast) {
|
|
33
|
+
if (!toast) return
|
|
34
|
+
clearTimeout(this.#timers.get(toast))
|
|
35
|
+
this.#timers.delete(toast)
|
|
36
|
+
toast.dataset.open = "false"
|
|
37
|
+
toast.addEventListener("transitionend", () => toast.remove(), { once: true })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#buildToast({ message = "", title = "", variant = "default", duration = 4000 }) {
|
|
41
|
+
const borderCls = {
|
|
42
|
+
default: "border-border",
|
|
43
|
+
success: "border-green-500/40",
|
|
44
|
+
warning: "border-amber-500/40",
|
|
45
|
+
destructive: "border-destructive/40",
|
|
46
|
+
info: "border-blue-500/40"
|
|
47
|
+
}[variant] ?? "border-border"
|
|
48
|
+
|
|
49
|
+
const div = document.createElement("div")
|
|
50
|
+
div.setAttribute("role", "alert")
|
|
51
|
+
div.setAttribute("aria-live", "polite")
|
|
52
|
+
div.dataset.open = "false"
|
|
53
|
+
div.dataset.toasterTarget = "toast"
|
|
54
|
+
div.dataset.toasterDurationParam = duration
|
|
55
|
+
div.className = [
|
|
56
|
+
"pointer-events-auto flex items-start gap-3 rounded-lg border",
|
|
57
|
+
"bg-background px-4 py-3 shadow-lg text-foreground",
|
|
58
|
+
"transition-all duration-300 translate-y-2 opacity-0",
|
|
59
|
+
"data-[open=true]:translate-y-0 data-[open=true]:opacity-100",
|
|
60
|
+
borderCls
|
|
61
|
+
].join(" ")
|
|
62
|
+
|
|
63
|
+
const bodyHtml = title
|
|
64
|
+
? `<p class="text-sm font-semibold leading-tight">${this.#esc(title)}</p>
|
|
65
|
+
<p class="text-sm leading-snug text-muted-foreground mt-0.5">${this.#esc(message)}</p>`
|
|
66
|
+
: `<p class="text-sm leading-snug">${this.#esc(message)}</p>`
|
|
67
|
+
|
|
68
|
+
div.innerHTML = `
|
|
69
|
+
<div class="flex-1 min-w-0">${bodyHtml}</div>
|
|
70
|
+
<button type="button" aria-label="Dismiss"
|
|
71
|
+
data-action="click->toaster#dismiss"
|
|
72
|
+
class="ml-auto -mr-1 -mt-0.5 shrink-0 inline-flex size-6 items-center justify-center
|
|
73
|
+
rounded-md text-muted-foreground hover:text-foreground hover:bg-accent
|
|
74
|
+
focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none transition">
|
|
75
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
|
|
76
|
+
stroke="currentColor" stroke-width="2" class="size-3.5" aria-hidden="true">
|
|
77
|
+
<path d="M18 6 6 18M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"/>
|
|
78
|
+
</svg>
|
|
79
|
+
</button>`
|
|
80
|
+
return div
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#esc(str) {
|
|
84
|
+
return String(str)
|
|
85
|
+
.replace(/&/g, "&").replace(/</g, "<")
|
|
86
|
+
.replace(/>/g, ">").replace(/"/g, """)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class ToggleComponent < ApplicationComponent
|
|
5
|
+
BASE = "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap " \
|
|
6
|
+
"transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground " \
|
|
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
|
+
"data-[state=on]:bg-accent data-[state=on]:text-accent-foreground " \
|
|
11
|
+
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
|
12
|
+
|
|
13
|
+
SIZES = {
|
|
14
|
+
default: "h-9 min-w-9 px-2",
|
|
15
|
+
sm: "h-8 min-w-8 px-1.5",
|
|
16
|
+
lg: "h-10 min-w-10 px-2.5"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(label = nil, pressed: false, size: :default, value: nil, **html_attrs)
|
|
20
|
+
@label = label || html_attrs.delete(:label)
|
|
21
|
+
@pressed = pressed
|
|
22
|
+
@size = size.to_sym
|
|
23
|
+
@value = value
|
|
24
|
+
@extra_class = html_attrs.delete(:class)
|
|
25
|
+
@html_attrs = html_attrs
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call
|
|
29
|
+
content_tag(:button,
|
|
30
|
+
content.presence || @label,
|
|
31
|
+
type: "button",
|
|
32
|
+
"aria-pressed": @pressed.to_s,
|
|
33
|
+
"data-state": @pressed ? "on" : "off",
|
|
34
|
+
"data-controller": "toggle",
|
|
35
|
+
"data-action": "click->toggle#toggle",
|
|
36
|
+
value: @value,
|
|
37
|
+
class: cn(BASE, SIZES.fetch(@size, SIZES[:default]), @extra_class),
|
|
38
|
+
**@html_attrs)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
toggle() {
|
|
5
|
+
// Defer to toggle-group controller when nested inside one
|
|
6
|
+
if (this.element.closest("[data-controller~='toggle-group']")) return
|
|
7
|
+
|
|
8
|
+
const on = this.element.dataset.state === "on"
|
|
9
|
+
this.element.dataset.state = on ? "off" : "on"
|
|
10
|
+
this.element.setAttribute("aria-pressed", String(!on))
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class ToggleGroupComponent < ApplicationComponent
|
|
5
|
+
BASE = "inline-flex gap-1"
|
|
6
|
+
|
|
7
|
+
# type: :single — only one item active at a time
|
|
8
|
+
# :multiple — multiple items can be active simultaneously
|
|
9
|
+
# value: currently active value (String) for :single,
|
|
10
|
+
# or array of active values for :multiple
|
|
11
|
+
def initialize(type: :single, value: nil, **html_attrs)
|
|
12
|
+
@type = type.to_sym
|
|
13
|
+
@value = Array(value).map(&:to_s)
|
|
14
|
+
@extra_class = html_attrs.delete(:class)
|
|
15
|
+
@html_attrs = html_attrs
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
content_tag(:div,
|
|
20
|
+
content,
|
|
21
|
+
class: cn(BASE, @extra_class),
|
|
22
|
+
role: "group",
|
|
23
|
+
"data-controller": "toggle-group",
|
|
24
|
+
"data-toggle-group-type-value": @type,
|
|
25
|
+
**@html_attrs)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def item_pressed?(item_value)
|
|
29
|
+
@value.include?(item_value.to_s)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static values = { type: { type: String, default: "single" } }
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
this.element.addEventListener("click", this.#handleClick)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
disconnect() {
|
|
11
|
+
this.element.removeEventListener("click", this.#handleClick)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
#handleClick = (event) => {
|
|
15
|
+
const btn = event.target.closest("button")
|
|
16
|
+
if (!btn || !this.element.contains(btn)) return
|
|
17
|
+
|
|
18
|
+
const alreadyOn = btn.dataset.state === "on"
|
|
19
|
+
|
|
20
|
+
if (this.typeValue === "single") {
|
|
21
|
+
this.#buttons.forEach(b => {
|
|
22
|
+
b.dataset.state = "off"
|
|
23
|
+
b.setAttribute("aria-pressed", "false")
|
|
24
|
+
})
|
|
25
|
+
if (!alreadyOn) {
|
|
26
|
+
btn.dataset.state = "on"
|
|
27
|
+
btn.setAttribute("aria-pressed", "true")
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
btn.dataset.state = alreadyOn ? "off" : "on"
|
|
31
|
+
btn.setAttribute("aria-pressed", String(!alreadyOn))
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get #buttons() {
|
|
36
|
+
return Array.from(this.element.querySelectorAll("button"))
|
|
37
|
+
}
|
|
38
|
+
}
|