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,28 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
open({ params: { src, alt } }) {
|
|
5
|
+
if (this._overlay) return
|
|
6
|
+
const overlay = document.createElement("div")
|
|
7
|
+
overlay.className = "fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
|
8
|
+
overlay.dataset.galleryOverlay = ""
|
|
9
|
+
|
|
10
|
+
const img = document.createElement("img")
|
|
11
|
+
img.src = src
|
|
12
|
+
img.alt = alt || ""
|
|
13
|
+
img.className = "max-h-[90vh] max-w-[90vw] rounded-md object-contain"
|
|
14
|
+
overlay.appendChild(img)
|
|
15
|
+
|
|
16
|
+
document.body.appendChild(overlay)
|
|
17
|
+
this._overlay = overlay
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
close() {
|
|
21
|
+
this._overlay?.remove()
|
|
22
|
+
this._overlay = null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
closeOnClickOutside(event) {
|
|
26
|
+
if (event.target === this._overlay) this.close()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class HoverCardComponent < ApplicationComponent
|
|
5
|
+
renders_one :trigger
|
|
6
|
+
|
|
7
|
+
CARD_BASE = "absolute z-50 w-64 rounded-lg border bg-popover p-4 text-sm " \
|
|
8
|
+
"text-popover-foreground shadow-md " \
|
|
9
|
+
"opacity-0 group-hover:opacity-100 pointer-events-none " \
|
|
10
|
+
"transition-opacity duration-200"
|
|
11
|
+
|
|
12
|
+
POSITIONS = {
|
|
13
|
+
bottom: "top-full left-0 mt-2",
|
|
14
|
+
top: "bottom-full left-0 mb-2",
|
|
15
|
+
left: "right-full top-0 mr-2",
|
|
16
|
+
right: "left-full top-0 ml-2"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(side: :bottom, **html_attrs)
|
|
20
|
+
@side = side.to_sym
|
|
21
|
+
@extra_class = html_attrs.delete(:class)
|
|
22
|
+
@html_attrs = html_attrs
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
content_tag(:span,
|
|
27
|
+
class: cn("relative inline-block group", @extra_class),
|
|
28
|
+
**@html_attrs) do
|
|
29
|
+
concat trigger if trigger
|
|
30
|
+
concat card_panel
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def card_panel
|
|
37
|
+
content_tag(:div,
|
|
38
|
+
class: cn(CARD_BASE, POSITIONS.fetch(@side, POSITIONS[:bottom]))) do
|
|
39
|
+
content
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class IframeComponent < ApplicationComponent
|
|
5
|
+
BASE = "w-full border-0"
|
|
6
|
+
|
|
7
|
+
# src: URL to embed (required)
|
|
8
|
+
# title: accessible label (required — describes the iframe content)
|
|
9
|
+
# loading: :lazy (default) | :eager
|
|
10
|
+
# sandbox: space-separated token string or true for strict defaults
|
|
11
|
+
# pass false to disable sandboxing entirely (not recommended)
|
|
12
|
+
# aspect: CSS aspect-ratio value, e.g. "16/9", "4/3" (wraps in a div)
|
|
13
|
+
# omit if you set explicit width/height
|
|
14
|
+
# width / height: explicit pixel dimensions (applied to <iframe>)
|
|
15
|
+
def initialize(src:, title:, loading: :lazy, sandbox: true,
|
|
16
|
+
aspect: nil, width: nil, height: nil, **html_attrs)
|
|
17
|
+
@src = src
|
|
18
|
+
@title = title
|
|
19
|
+
@loading = loading.to_sym
|
|
20
|
+
@sandbox = sandbox
|
|
21
|
+
@aspect = aspect
|
|
22
|
+
@width = width
|
|
23
|
+
@height = height
|
|
24
|
+
@extra_class = html_attrs.delete(:class)
|
|
25
|
+
@html_attrs = html_attrs
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call
|
|
29
|
+
if @aspect
|
|
30
|
+
content_tag(:div, style: "aspect-ratio: #{@aspect}", class: "w-full overflow-hidden") do
|
|
31
|
+
iframe_tag
|
|
32
|
+
end
|
|
33
|
+
else
|
|
34
|
+
iframe_tag
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def iframe_tag
|
|
41
|
+
attrs = {
|
|
42
|
+
src: @src,
|
|
43
|
+
title: @title,
|
|
44
|
+
loading: @loading,
|
|
45
|
+
class: cn(BASE, (@aspect ? "h-full" : nil), @extra_class)
|
|
46
|
+
}
|
|
47
|
+
attrs[:sandbox] = sandbox_value if @sandbox != false
|
|
48
|
+
attrs[:width] = @width if @width
|
|
49
|
+
attrs[:height] = @height if @height
|
|
50
|
+
tag.iframe(**attrs, **@html_attrs)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def sandbox_value
|
|
54
|
+
return "allow-scripts allow-same-origin allow-forms allow-popups" if @sandbox == true
|
|
55
|
+
|
|
56
|
+
@sandbox
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class ImageComponent < ApplicationComponent
|
|
5
|
+
BASE = "max-w-full"
|
|
6
|
+
|
|
7
|
+
LOADING_MODES = %i[lazy eager auto].freeze
|
|
8
|
+
|
|
9
|
+
# src: image URL (required)
|
|
10
|
+
# alt: alternative text (required for accessibility)
|
|
11
|
+
# srcset: responsive image set, e.g. "img-sm.jpg 640w, img-lg.jpg 1280w"
|
|
12
|
+
# sizes: media conditions, e.g. "(max-width: 640px) 100vw, 50vw"
|
|
13
|
+
# loading: :lazy (default) | :eager | :auto
|
|
14
|
+
# width/height: native dimensions (prevents layout shift)
|
|
15
|
+
def initialize(src:, alt:, srcset: nil, sizes: nil, loading: :lazy,
|
|
16
|
+
width: nil, height: nil, **html_attrs)
|
|
17
|
+
@src = src
|
|
18
|
+
@alt = alt
|
|
19
|
+
@srcset = srcset
|
|
20
|
+
@sizes = sizes
|
|
21
|
+
@loading = LOADING_MODES.include?(loading.to_sym) ? loading.to_sym : :lazy
|
|
22
|
+
@width = width
|
|
23
|
+
@height = height
|
|
24
|
+
@extra_class = html_attrs.delete(:class)
|
|
25
|
+
@html_attrs = html_attrs
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call
|
|
29
|
+
attrs = { src: @src, alt: @alt, loading: @loading,
|
|
30
|
+
class: cn(BASE, @extra_class) }
|
|
31
|
+
attrs[:srcset] = @srcset if @srcset
|
|
32
|
+
attrs[:sizes] = @sizes if @sizes
|
|
33
|
+
attrs[:width] = @width if @width
|
|
34
|
+
attrs[:height] = @height if @height
|
|
35
|
+
tag.img(**attrs, **@html_attrs)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class IndicatorComponent < ApplicationComponent
|
|
5
|
+
DOT_BASE = "absolute flex items-center justify-center rounded-full text-[10px] font-medium leading-none"
|
|
6
|
+
|
|
7
|
+
VARIANTS = {
|
|
8
|
+
default: "bg-primary text-primary-foreground",
|
|
9
|
+
destructive: "bg-destructive text-white",
|
|
10
|
+
success: "bg-green-500 text-white",
|
|
11
|
+
warning: "bg-yellow-500 text-foreground"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
POSITIONS = {
|
|
15
|
+
top_right: "-top-1 -right-1",
|
|
16
|
+
top_left: "-top-1 -left-1",
|
|
17
|
+
bottom_right: "-bottom-1 -right-1",
|
|
18
|
+
bottom_left: "-bottom-1 -left-1"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def initialize(count: nil, position: :top_right, variant: :default, **html_attrs)
|
|
22
|
+
@count = count
|
|
23
|
+
@position = position.to_sym
|
|
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, class: cn("relative inline-flex", @extra_class), **@html_attrs) do
|
|
31
|
+
concat content
|
|
32
|
+
concat dot
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def dot
|
|
39
|
+
dot_size = @count ? "size-5 min-w-5 px-0.5" : "size-2"
|
|
40
|
+
content_tag(:span, @count,
|
|
41
|
+
class: cn(DOT_BASE, dot_size,
|
|
42
|
+
VARIANTS.fetch(@variant, VARIANTS[:default]),
|
|
43
|
+
POSITIONS.fetch(@position, POSITIONS[:top_right])))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class InputComponent < 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
|
+
"selection:bg-primary selection:text-primary-foreground " \
|
|
8
|
+
"file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground " \
|
|
9
|
+
"placeholder:text-muted-foreground " \
|
|
10
|
+
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
|
|
11
|
+
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " \
|
|
12
|
+
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 " \
|
|
13
|
+
"md:text-sm dark:bg-input/30"
|
|
14
|
+
|
|
15
|
+
def initialize(type: "text", **html_attrs)
|
|
16
|
+
@type = type
|
|
17
|
+
@extra_class = html_attrs.delete(:class)
|
|
18
|
+
@html_attrs = html_attrs
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
content_tag(:input, nil,
|
|
23
|
+
type: @type,
|
|
24
|
+
class: cn(BASE, @extra_class),
|
|
25
|
+
**@html_attrs)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class InputOtpComponent < ApplicationComponent
|
|
5
|
+
# One-time-password digit input group.
|
|
6
|
+
# Renders N individual single-character inputs that auto-advance on entry.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# <%= ui :input_otp, length: 6, name: "otp" %>
|
|
10
|
+
|
|
11
|
+
CELL_CLS = "h-12 w-10 rounded-md border border-input bg-transparent text-center text-lg font-medium " \
|
|
12
|
+
"shadow-xs transition-[color,box-shadow] outline-none " \
|
|
13
|
+
"caret-transparent selection:bg-primary selection:text-primary-foreground " \
|
|
14
|
+
"focus:border-ring focus:ring-[3px] focus:ring-ring/50 " \
|
|
15
|
+
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 " \
|
|
16
|
+
"disabled:pointer-events-none disabled:opacity-50"
|
|
17
|
+
|
|
18
|
+
WRAPPER_CLS = "flex items-center gap-2"
|
|
19
|
+
SEPARATOR_CLS = "text-muted-foreground text-lg font-medium"
|
|
20
|
+
|
|
21
|
+
# length: number of OTP digits (default: 6)
|
|
22
|
+
# name: form field name (individual cells get name[0], name[1], …)
|
|
23
|
+
# separator: position (Integer) or Hash { position => char }, e.g. 3 or { 3 => "-" }
|
|
24
|
+
def initialize(length: 6, name: "otp", separator: nil, **html_attrs)
|
|
25
|
+
@length = length.to_i
|
|
26
|
+
@name = name
|
|
27
|
+
@separator = case separator
|
|
28
|
+
when Integer then { separator => "-" }
|
|
29
|
+
when Hash then separator
|
|
30
|
+
end
|
|
31
|
+
@extra_class = html_attrs.delete(:class)
|
|
32
|
+
@html_attrs = html_attrs
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call
|
|
36
|
+
content_tag(:div,
|
|
37
|
+
class: cn(WRAPPER_CLS, @extra_class),
|
|
38
|
+
data: { controller: "input-otp" },
|
|
39
|
+
**@html_attrs) do
|
|
40
|
+
@length.times do |i|
|
|
41
|
+
sep = @separator&.fetch(i, nil)
|
|
42
|
+
concat content_tag(:span, sep, class: SEPARATOR_CLS) if sep
|
|
43
|
+
concat digit_input(i)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def digit_input(index)
|
|
51
|
+
content_tag(:input, nil,
|
|
52
|
+
type: "text",
|
|
53
|
+
inputmode: "numeric",
|
|
54
|
+
maxlength: 1,
|
|
55
|
+
autocomplete: index.zero? ? "one-time-code" : "off",
|
|
56
|
+
name: "#{@name}[#{index}]",
|
|
57
|
+
class: CELL_CLS,
|
|
58
|
+
"aria-label": "Digit #{index + 1}",
|
|
59
|
+
data: {
|
|
60
|
+
input_otp_target: "cell",
|
|
61
|
+
action: "input->input-otp#onInput keydown->input-otp#onKeydown paste->input-otp#onPaste"
|
|
62
|
+
})
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["cell"]
|
|
5
|
+
|
|
6
|
+
onInput(event) {
|
|
7
|
+
const cell = event.currentTarget
|
|
8
|
+
const idx = this.cellTargets.indexOf(cell)
|
|
9
|
+
if (cell.value && idx < this.cellTargets.length - 1) {
|
|
10
|
+
this.cellTargets[idx + 1].focus()
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
onKeydown(event) {
|
|
15
|
+
const cell = event.currentTarget
|
|
16
|
+
const idx = this.cellTargets.indexOf(cell)
|
|
17
|
+
if (event.key === "Backspace" && !cell.value && idx > 0) {
|
|
18
|
+
this.cellTargets[idx - 1].focus()
|
|
19
|
+
}
|
|
20
|
+
if (event.key === "ArrowLeft" && idx > 0) {
|
|
21
|
+
this.cellTargets[idx - 1].focus()
|
|
22
|
+
}
|
|
23
|
+
if (event.key === "ArrowRight" && idx < this.cellTargets.length - 1) {
|
|
24
|
+
this.cellTargets[idx + 1].focus()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
onPaste(event) {
|
|
29
|
+
event.preventDefault()
|
|
30
|
+
const text = (event.clipboardData || window.clipboardData).getData("text").replace(/\D/g, "")
|
|
31
|
+
const start = this.cellTargets.indexOf(event.currentTarget)
|
|
32
|
+
text.split("").forEach((char, i) => {
|
|
33
|
+
const cell = this.cellTargets[start + i]
|
|
34
|
+
if (cell) cell.value = char
|
|
35
|
+
})
|
|
36
|
+
const next = this.cellTargets[start + text.length]
|
|
37
|
+
if (next) next.focus()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class KbdComponent < ApplicationComponent
|
|
5
|
+
BASE = "pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 " \
|
|
6
|
+
"rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none " \
|
|
7
|
+
"[&_svg:not([class*='size-'])]:size-3"
|
|
8
|
+
|
|
9
|
+
def initialize(key = nil, **html_attrs)
|
|
10
|
+
@key = key || html_attrs.delete(:label)
|
|
11
|
+
@extra_class = html_attrs.delete(:class)
|
|
12
|
+
@html_attrs = html_attrs
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
content_tag(:kbd, content.presence || @key,
|
|
17
|
+
class: cn(BASE, @extra_class),
|
|
18
|
+
**@html_attrs)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class LabelComponent < ApplicationComponent
|
|
5
|
+
BASE = "flex items-center gap-2 text-sm leading-none font-medium select-none " \
|
|
6
|
+
"group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 " \
|
|
7
|
+
"peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
|
|
8
|
+
|
|
9
|
+
def initialize(text = nil, for: nil, **html_attrs)
|
|
10
|
+
@text = text || html_attrs.delete(:label)
|
|
11
|
+
@for = binding.local_variable_get(:for)
|
|
12
|
+
@extra_class = html_attrs.delete(:class)
|
|
13
|
+
@html_attrs = html_attrs
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
content_tag(:label, content.presence || @text,
|
|
18
|
+
class: cn(BASE, @extra_class),
|
|
19
|
+
for: @for,
|
|
20
|
+
**@html_attrs)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class ListGroupComponent < ApplicationComponent
|
|
5
|
+
BASE = "divide-y divide-border overflow-hidden rounded-lg border"
|
|
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(:ul, content, class: cn(BASE, @extra_class), **@html_attrs)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class ListGroupItemComponent < ApplicationComponent
|
|
5
|
+
BASE = "flex items-center justify-between px-4 py-3 text-sm"
|
|
6
|
+
|
|
7
|
+
VARIANTS = {
|
|
8
|
+
default: "text-foreground hover:bg-muted",
|
|
9
|
+
active: "bg-primary text-primary-foreground",
|
|
10
|
+
muted: "text-muted-foreground hover:bg-muted"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(label = nil, href: nil, active: false, variant: :default, **html_attrs)
|
|
14
|
+
@label = label || html_attrs.delete(:label)
|
|
15
|
+
@href = href
|
|
16
|
+
@variant = active ? :active : variant.to_sym
|
|
17
|
+
@extra_class = html_attrs.delete(:class)
|
|
18
|
+
@html_attrs = html_attrs
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
tag_name = @href ? :a : :li
|
|
23
|
+
extra = @href ? { href: @href } : {}
|
|
24
|
+
content_tag(tag_name,
|
|
25
|
+
content.presence || @label,
|
|
26
|
+
class: cn(BASE, VARIANTS.fetch(@variant, VARIANTS[:default]), @extra_class),
|
|
27
|
+
**extra,
|
|
28
|
+
**@html_attrs)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class MapAreaComponent < ApplicationComponent
|
|
5
|
+
# Image map — renders <img usemap> + <map> + <area> elements.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# ui :map_area,
|
|
9
|
+
# src: "/map.png", alt: "Office floor plan",
|
|
10
|
+
# width: 800, height: 600,
|
|
11
|
+
# areas: [
|
|
12
|
+
# { shape: :rect, coords: "0,0,200,150", href: "/room/1", alt: "Room 1" },
|
|
13
|
+
# { shape: :circle, coords: "400,300,50", href: "/room/2", alt: "Room 2" },
|
|
14
|
+
# { shape: :poly, coords: "10,10,50,10,30,40", href: "/room/3", alt: "Room 3" }
|
|
15
|
+
# ]
|
|
16
|
+
#
|
|
17
|
+
# area keys:
|
|
18
|
+
# shape: :rect | :circle | :poly | :default (required)
|
|
19
|
+
# coords: coordinate string (required for rect/circle/poly)
|
|
20
|
+
# href: link target (omit or "#" for non-interactive areas)
|
|
21
|
+
# alt: accessible label for the area (required for links)
|
|
22
|
+
# title: tooltip text
|
|
23
|
+
# target: link target, e.g. "_blank"
|
|
24
|
+
# rel: link rel attribute
|
|
25
|
+
|
|
26
|
+
WRAPPER_CLS = "relative inline-block"
|
|
27
|
+
|
|
28
|
+
def initialize(src:, alt:, areas: [], width: nil, height: nil,
|
|
29
|
+
loading: :lazy, map_name: nil, **html_attrs)
|
|
30
|
+
@src = src
|
|
31
|
+
@alt = alt
|
|
32
|
+
@areas = areas
|
|
33
|
+
@width = width
|
|
34
|
+
@height = height
|
|
35
|
+
@loading = loading
|
|
36
|
+
@map_name = map_name || "map-#{SecureRandom.hex(4)}"
|
|
37
|
+
@extra_class = html_attrs.delete(:class)
|
|
38
|
+
@html_attrs = html_attrs
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call
|
|
42
|
+
content_tag(:div, class: cn(WRAPPER_CLS, @extra_class), **@html_attrs) do
|
|
43
|
+
safe_join([img_tag, map_tag])
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def img_tag
|
|
50
|
+
attrs = { src: @src, alt: @alt, usemap: "##{@map_name}", loading: @loading }
|
|
51
|
+
attrs[:width] = @width if @width
|
|
52
|
+
attrs[:height] = @height if @height
|
|
53
|
+
tag.img(**attrs)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def map_tag
|
|
57
|
+
content_tag(:map, name: @map_name) do
|
|
58
|
+
safe_join(@areas.map { |area| area_tag(area) })
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def area_tag(area)
|
|
63
|
+
attrs = { shape: area.fetch(:shape, :rect).to_s, alt: area.fetch(:alt, "") }
|
|
64
|
+
attrs[:coords] = area[:coords] if area[:coords]
|
|
65
|
+
attrs[:href] = area[:href] if area[:href]
|
|
66
|
+
attrs[:title] = area[:title] if area[:title]
|
|
67
|
+
attrs[:target] = area[:target] if area[:target]
|
|
68
|
+
attrs[:rel] = area[:rel] if area[:rel]
|
|
69
|
+
tag.area(**attrs)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class MegaMenuComponent < ApplicationComponent
|
|
5
|
+
# Full-width dropdown panel anchored to a trigger button.
|
|
6
|
+
# Columns are rendered via with_column blocks.
|
|
7
|
+
|
|
8
|
+
TRIGGER_CLS = "inline-flex h-9 items-center justify-center gap-1.5 rounded-md bg-background " \
|
|
9
|
+
"px-4 py-2 text-sm font-medium transition-[color,box-shadow] outline-none " \
|
|
10
|
+
"hover:bg-accent hover:text-accent-foreground " \
|
|
11
|
+
"focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
|
|
12
|
+
"data-[state=open]:bg-accent/50 data-[state=open]:text-accent-foreground"
|
|
13
|
+
|
|
14
|
+
PANEL_CLS = "absolute left-0 top-full z-50 mt-1.5 w-full overflow-hidden rounded-md border " \
|
|
15
|
+
"bg-popover text-popover-foreground shadow-lg"
|
|
16
|
+
|
|
17
|
+
INNER_CLS = "container mx-auto grid gap-6 p-6"
|
|
18
|
+
|
|
19
|
+
COLUMN_HEADING = "mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
|
20
|
+
|
|
21
|
+
ITEM_CLS = "group flex items-start gap-3 rounded-sm p-2 text-sm transition-colors outline-none " \
|
|
22
|
+
"hover:bg-accent hover:text-accent-foreground " \
|
|
23
|
+
"focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
|
24
|
+
|
|
25
|
+
ITEM_TITLE = "font-medium leading-none"
|
|
26
|
+
ITEM_DESC = "mt-1 text-xs text-muted-foreground group-hover:text-accent-foreground/70"
|
|
27
|
+
|
|
28
|
+
CHEVRON_PATH = "m6 9 6 6 6-6"
|
|
29
|
+
|
|
30
|
+
renders_many :columns, "UI::MegaMenuComponent::ColumnComponent"
|
|
31
|
+
|
|
32
|
+
# label: trigger button text
|
|
33
|
+
# cols: number of columns in the grid (default: auto based on column count)
|
|
34
|
+
def initialize(label:, cols: nil, **html_attrs)
|
|
35
|
+
@label = label
|
|
36
|
+
@cols = cols
|
|
37
|
+
@extra_class = html_attrs.delete(:class)
|
|
38
|
+
@html_attrs = html_attrs
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call
|
|
42
|
+
content_tag(:div,
|
|
43
|
+
class: cn("relative", @extra_class),
|
|
44
|
+
data: {
|
|
45
|
+
controller: "mega-menu",
|
|
46
|
+
action: "click@document->mega-menu#closeOnClickOutside"
|
|
47
|
+
},
|
|
48
|
+
**@html_attrs) do
|
|
49
|
+
concat trigger_btn
|
|
50
|
+
concat panel
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def trigger_btn
|
|
57
|
+
content_tag(:button,
|
|
58
|
+
type: "button",
|
|
59
|
+
class: TRIGGER_CLS,
|
|
60
|
+
"aria-expanded": "false",
|
|
61
|
+
data: { mega_menu_target: "trigger", state: "closed",
|
|
62
|
+
action: "click->mega-menu#toggle" }) do
|
|
63
|
+
concat @label
|
|
64
|
+
concat chevron
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def panel
|
|
69
|
+
col_count = @cols || [columns.size, 1].max
|
|
70
|
+
grid_cls = "grid-cols-#{col_count}"
|
|
71
|
+
|
|
72
|
+
content_tag(:div,
|
|
73
|
+
hidden: true,
|
|
74
|
+
class: PANEL_CLS,
|
|
75
|
+
data: { mega_menu_target: "panel" }) do
|
|
76
|
+
content_tag(:div, class: cn(INNER_CLS, grid_cls)) do
|
|
77
|
+
safe_join(columns)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def chevron
|
|
83
|
+
content_tag(:svg,
|
|
84
|
+
content_tag(:path, nil, d: CHEVRON_PATH, "stroke-linecap": "round", "stroke-linejoin": "round"),
|
|
85
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
86
|
+
viewBox: "0 0 24 24",
|
|
87
|
+
fill: "none",
|
|
88
|
+
stroke: "currentColor",
|
|
89
|
+
"stroke-width": "2",
|
|
90
|
+
class: "size-3 transition-transform duration-200 data-[state=open]:rotate-180",
|
|
91
|
+
"aria-hidden": "true",
|
|
92
|
+
data: { mega_menu_target: "chevron" })
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A single column inside the mega menu panel.
|
|
96
|
+
# heading: optional column title
|
|
97
|
+
# items: array of { title:, description:, href: } hashes
|
|
98
|
+
class ColumnComponent < ApplicationComponent
|
|
99
|
+
def initialize(heading: nil, items: [], **html_attrs)
|
|
100
|
+
@heading = heading
|
|
101
|
+
@items = items
|
|
102
|
+
@html_attrs = html_attrs
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def call
|
|
106
|
+
content_tag(:div, **@html_attrs) do
|
|
107
|
+
concat content_tag(:p, @heading, class: MegaMenuComponent::COLUMN_HEADING) if @heading
|
|
108
|
+
concat(content_tag(:ul, class: "space-y-1") {
|
|
109
|
+
safe_join(@items.map { |item| render_item(item) })
|
|
110
|
+
})
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def render_item(item)
|
|
117
|
+
content_tag(:li) do
|
|
118
|
+
content_tag(:a,
|
|
119
|
+
href: item.fetch(:href, "#"),
|
|
120
|
+
class: MegaMenuComponent::ITEM_CLS) do
|
|
121
|
+
content_tag(:div) do
|
|
122
|
+
concat content_tag(:p, item[:title], class: MegaMenuComponent::ITEM_TITLE)
|
|
123
|
+
concat content_tag(:p, item[:description], class: MegaMenuComponent::ITEM_DESC) if item[:description]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["trigger", "panel", "chevron"]
|
|
5
|
+
|
|
6
|
+
toggle() {
|
|
7
|
+
const open = this.panelTarget.hidden
|
|
8
|
+
this._setOpen(open)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
closeOnClickOutside(event) {
|
|
12
|
+
if (!this.element.contains(event.target)) this._setOpen(false)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
_setOpen(open) {
|
|
16
|
+
this.panelTarget.hidden = !open
|
|
17
|
+
this.triggerTarget.setAttribute("aria-expanded", String(open))
|
|
18
|
+
this.triggerTarget.dataset.state = open ? "open" : "closed"
|
|
19
|
+
if (this.hasChevronTarget) {
|
|
20
|
+
this.chevronTarget.dataset.state = open ? "open" : "closed"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|