view_primitives 0.1.3 → 0.2.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 +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +57 -2
- data/lib/generators/view_primitives/add/add_generator.rb +8 -62
- data/lib/generators/view_primitives/add/templates/accordion/accordion_item_component.rb.tt +30 -11
- data/lib/generators/view_primitives/add/templates/alert/alert_component.rb.tt +1 -1
- data/lib/generators/view_primitives/add/templates/alert_dialog/alert_dialog_component.rb.tt +9 -9
- data/lib/generators/view_primitives/add/templates/aspect_ratio/aspect_ratio_component.rb.tt +1 -1
- data/lib/generators/view_primitives/add/templates/audio/audio_component.rb.tt +1 -1
- data/lib/generators/view_primitives/add/templates/avatar/avatar_component.rb.tt +8 -4
- data/lib/generators/view_primitives/add/templates/badge/badge_component.rb.tt +1 -1
- data/lib/generators/view_primitives/add/templates/banner/banner_component.rb.tt +6 -6
- data/lib/generators/view_primitives/add/templates/bottom_nav/bottom_nav_component.rb.tt +11 -4
- data/lib/generators/view_primitives/add/templates/breadcrumb/breadcrumb_component.rb.tt +2 -2
- data/lib/generators/view_primitives/add/templates/button/button_component.rb.tt +8 -5
- data/lib/generators/view_primitives/add/templates/button_group/button_group_component.rb.tt +5 -5
- data/lib/generators/view_primitives/add/templates/calendar/calendar_component.rb.tt +18 -16
- data/lib/generators/view_primitives/add/templates/card/card_component.rb.tt +1 -1
- data/lib/generators/view_primitives/add/templates/card/card_footer_component.rb.tt +1 -1
- data/lib/generators/view_primitives/add/templates/carousel/carousel_component.rb.tt +26 -13
- data/lib/generators/view_primitives/add/templates/chart/chart_component.rb.tt +10 -4
- data/lib/generators/view_primitives/add/templates/chart/chart_controller.js +26 -3
- data/lib/generators/view_primitives/add/templates/chat_bubble/chat_bubble_component.rb.tt +4 -4
- data/lib/generators/view_primitives/add/templates/checkbox/checkbox_component.rb.tt +1 -1
- data/lib/generators/view_primitives/add/templates/collapsible/collapsible_component.rb.tt +12 -5
- data/lib/generators/view_primitives/add/templates/combobox/combobox_component.rb.tt +3 -6
- data/lib/generators/view_primitives/add/templates/command/command_component.rb.tt +22 -18
- 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 +9 -8
- data/lib/generators/view_primitives/add/templates/data_table/data_table_component.rb.tt +60 -29
- data/lib/generators/view_primitives/add/templates/data_table/data_table_controller.js +2 -2
- data/lib/generators/view_primitives/add/templates/date_picker/date_picker_component.rb.tt +8 -8
- data/lib/generators/view_primitives/add/templates/device_mockup/device_mockup_component.rb.tt +94 -21
- data/lib/generators/view_primitives/add/templates/dialog/dialog_component.rb.tt +13 -10
- data/lib/generators/view_primitives/add/templates/dialog/dialog_controller.js +52 -0
- data/lib/generators/view_primitives/add/templates/drawer/drawer_component.rb.tt +8 -7
- data/lib/generators/view_primitives/add/templates/dropdown_menu/dropdown_menu_component.rb.tt +5 -6
- data/lib/generators/view_primitives/add/templates/embed/embed_component.rb.tt +2 -2
- data/lib/generators/view_primitives/add/templates/figure/figure_component.rb.tt +1 -1
- data/lib/generators/view_primitives/add/templates/file_input/file_input_component.rb.tt +3 -12
- data/lib/generators/view_primitives/add/templates/floating_label/floating_label_component.rb.tt +1 -1
- data/lib/generators/view_primitives/add/templates/footer/footer_component.rb.tt +5 -4
- data/lib/generators/view_primitives/add/templates/form_field/form_field_component.rb.tt +18 -5
- data/lib/generators/view_primitives/add/templates/gallery/gallery_component.rb.tt +3 -3
- data/lib/generators/view_primitives/add/templates/gallery/gallery_controller.js +1 -1
- data/lib/generators/view_primitives/add/templates/hover_card/hover_card_component.rb.tt +6 -5
- data/lib/generators/view_primitives/add/templates/iframe/iframe_component.rb.tt +6 -4
- data/lib/generators/view_primitives/add/templates/image/image_component.rb.tt +1 -1
- data/lib/generators/view_primitives/add/templates/indicator/indicator_component.rb.tt +5 -4
- data/lib/generators/view_primitives/add/templates/input/input_component.rb.tt +2 -13
- data/lib/generators/view_primitives/add/templates/input_otp/input_otp_component.rb.tt +22 -10
- data/lib/generators/view_primitives/add/templates/kbd/kbd_component.rb.tt +3 -1
- data/lib/generators/view_primitives/add/templates/list_group/list_group_component.rb.tt +6 -2
- data/lib/generators/view_primitives/add/templates/list_group/list_group_item_component.rb.tt +6 -4
- data/lib/generators/view_primitives/add/templates/map_area/map_area_component.rb.tt +3 -2
- data/lib/generators/view_primitives/add/templates/mega_menu/mega_menu_component.rb.tt +9 -9
- data/lib/generators/view_primitives/add/templates/menubar/menubar_component.rb.tt +5 -5
- data/lib/generators/view_primitives/add/templates/menubar/menubar_menu_component.rb.tt +4 -5
- data/lib/generators/view_primitives/add/templates/navbar/navbar_component.rb.tt +51 -11
- data/lib/generators/view_primitives/add/templates/navbar/navbar_controller.js +8 -3
- data/lib/generators/view_primitives/add/templates/navigation_menu/navigation_menu_component.rb.tt +12 -16
- data/lib/generators/view_primitives/add/templates/number_input/number_input_component.rb.tt +4 -11
- data/lib/generators/view_primitives/add/templates/pagination/pagination_component.rb.tt +4 -3
- data/lib/generators/view_primitives/add/templates/picture/picture_component.rb.tt +2 -1
- data/lib/generators/view_primitives/add/templates/popover/popover_component.rb.tt +1 -2
- data/lib/generators/view_primitives/add/templates/progress/progress_component.rb.tt +3 -1
- data/lib/generators/view_primitives/add/templates/qr_code/qr_code_component.rb.tt +1 -1
- data/lib/generators/view_primitives/add/templates/radio_group/radio_group_component.rb.tt +8 -5
- data/lib/generators/view_primitives/add/templates/range/range_component.rb.tt +2 -3
- data/lib/generators/view_primitives/add/templates/rating/rating_component.rb.tt +1 -1
- data/lib/generators/view_primitives/add/templates/rating_input/rating_controller.js +1 -1
- data/lib/generators/view_primitives/add/templates/rating_input/rating_input_component.rb.tt +4 -3
- data/lib/generators/view_primitives/add/templates/resizable/resizable_component.rb.tt +27 -15
- data/lib/generators/view_primitives/add/templates/scroll_area/scroll_area_component.rb.tt +10 -11
- data/lib/generators/view_primitives/add/templates/search_input/search_input_component.rb.tt +2 -11
- data/lib/generators/view_primitives/add/templates/select/select_component.rb.tt +25 -6
- data/lib/generators/view_primitives/add/templates/separator/separator_component.rb.tt +6 -3
- data/lib/generators/view_primitives/add/templates/sheet/sheet_component.rb.tt +25 -21
- data/lib/generators/view_primitives/add/templates/sidebar/sidebar_component.rb.tt +27 -21
- data/lib/generators/view_primitives/add/templates/skeleton/skeleton_component.rb.tt +1 -1
- data/lib/generators/view_primitives/add/templates/speed_dial/speed_dial_component.rb.tt +8 -9
- data/lib/generators/view_primitives/add/templates/spinner/spinner_component.rb.tt +15 -6
- data/lib/generators/view_primitives/add/templates/stepper/stepper_component.rb.tt +17 -16
- data/lib/generators/view_primitives/add/templates/switch/switch_component.rb.tt +27 -14
- data/lib/generators/view_primitives/add/templates/tabs/tabs_component.html.erb +13 -7
- data/lib/generators/view_primitives/add/templates/tags_input/tags_input_component.rb.tt +136 -0
- data/lib/generators/view_primitives/add/templates/tags_input/tags_input_controller.js +90 -0
- data/lib/generators/view_primitives/add/templates/textarea/textarea_component.rb.tt +2 -11
- data/lib/generators/view_primitives/add/templates/timeline/timeline_component.rb.tt +9 -7
- data/lib/generators/view_primitives/add/templates/timepicker/timepicker_component.rb.tt +19 -15
- data/lib/generators/view_primitives/add/templates/toaster/toaster_component.rb.tt +10 -10
- data/lib/generators/view_primitives/add/templates/toaster/toaster_controller.js +6 -6
- data/lib/generators/view_primitives/add/templates/toggle/toggle_component.rb.tt +10 -3
- data/lib/generators/view_primitives/add/templates/toggle_group/toggle_group_component.rb.tt +6 -6
- data/lib/generators/view_primitives/add/templates/tooltip/tooltip_component.rb.tt +7 -6
- data/lib/generators/view_primitives/add/templates/video/video_component.rb.tt +1 -1
- data/lib/generators/view_primitives/add/templates/wysiwyg/wysiwyg_component.rb.tt +9 -3
- data/lib/generators/view_primitives/component_copier.rb +96 -0
- data/lib/generators/view_primitives/components.rb +16 -2
- data/lib/generators/view_primitives/install/install_generator.rb +13 -3
- data/lib/generators/view_primitives/install/templates/application_component.rb.tt +7 -0
- data/lib/generators/view_primitives/install/templates/styles.rb.tt +26 -0
- data/lib/generators/view_primitives/install/templates/view_primitives/themes/default.css +79 -0
- data/lib/generators/view_primitives/install/templates/view_primitives/themes/rose.css +57 -0
- data/lib/generators/view_primitives/install/templates/view_primitives/tokens.css +46 -0
- data/lib/generators/view_primitives/install/templates/view_primitives/utilities.css +64 -0
- data/lib/generators/view_primitives/install/templates/view_primitives.css +6 -66
- data/lib/generators/view_primitives/list/list_generator.rb +3 -1
- data/lib/generators/view_primitives/theme/theme_generator.rb +79 -0
- data/lib/generators/view_primitives/update/update_generator.rb +112 -0
- data/lib/view_primitives/class_helper.rb +4 -1
- data/lib/view_primitives/railtie.rb +1 -1
- data/lib/view_primitives/version.rb +1 -1
- metadata +12 -4
- data/lib/generators/view_primitives/add/templates/drawer/drawer_controller.js +0 -15
- data/lib/generators/view_primitives/add/templates/sheet/sheet_controller.js +0 -15
|
@@ -10,18 +10,22 @@ module UI
|
|
|
10
10
|
# c.with_slide { image_tag "slide2.jpg" }
|
|
11
11
|
# end
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
CONTENT_CLS = "overflow-hidden"
|
|
14
|
+
TRACK_CLS = "flex transition-transform duration-300 ease-in-out"
|
|
15
|
+
SLIDE_CLS = "min-w-0 shrink-0 grow-0 basis-full"
|
|
15
16
|
|
|
16
|
-
BTN_BASE = "absolute top-1/2 z-10 -translate-y-1/2 inline-flex size-
|
|
17
|
-
"rounded-full bg-background
|
|
18
|
-
"transition
|
|
19
|
-
"
|
|
17
|
+
BTN_BASE = "absolute top-1/2 z-10 -translate-y-1/2 inline-flex size-8 shrink-0 items-center " \
|
|
18
|
+
"justify-center rounded-full border border-input bg-background text-sm font-medium shadow-xs " \
|
|
19
|
+
"transition-all outline-none " \
|
|
20
|
+
"hover:bg-accent hover:text-accent-foreground " \
|
|
21
|
+
"#{UI::Styles::FOCUS_RING} " \
|
|
22
|
+
"disabled:pointer-events-none disabled:opacity-50 " \
|
|
23
|
+
"dark:border-input dark:bg-input/30 dark:hover:bg-input/50"
|
|
20
24
|
BTN_PREV = "left-2"
|
|
21
25
|
BTN_NEXT = "right-2"
|
|
22
26
|
|
|
23
|
-
DOTS_CLS = "mt-
|
|
24
|
-
DOT_CLS = "size-2 rounded-full bg-muted
|
|
27
|
+
DOTS_CLS = "mt-4 flex justify-center gap-1.5"
|
|
28
|
+
DOT_CLS = "size-2 rounded-full bg-muted transition-colors " \
|
|
25
29
|
"data-[active=true]:bg-primary data-[active=true]:w-4"
|
|
26
30
|
|
|
27
31
|
CHEVRON_L = "m15 18-6-6 6-6"
|
|
@@ -42,14 +46,17 @@ module UI
|
|
|
42
46
|
|
|
43
47
|
def call
|
|
44
48
|
content_tag(:div,
|
|
45
|
-
class: cn("relative
|
|
49
|
+
class: cn("relative", @extra_class),
|
|
50
|
+
role: "region",
|
|
51
|
+
"aria-roledescription": "carousel",
|
|
46
52
|
data: {
|
|
53
|
+
slot: "carousel",
|
|
47
54
|
controller: "carousel",
|
|
48
55
|
carousel_loop_value: @loop,
|
|
49
56
|
carousel_autoplay_value: @autoplay
|
|
50
57
|
},
|
|
51
58
|
**@html_attrs) do
|
|
52
|
-
concat track
|
|
59
|
+
concat content_tag(:div, track, class: CONTENT_CLS, data: { slot: "carousel-content" })
|
|
53
60
|
concat prev_btn
|
|
54
61
|
concat next_btn
|
|
55
62
|
concat dots if @indicators && slides.size > 1
|
|
@@ -60,7 +67,13 @@ module UI
|
|
|
60
67
|
|
|
61
68
|
def track
|
|
62
69
|
content_tag(:div, class: TRACK_CLS, data: { carousel_target: "track" }) do
|
|
63
|
-
safe_join(slides.map { |s|
|
|
70
|
+
safe_join(slides.map { |s|
|
|
71
|
+
content_tag(:div, s,
|
|
72
|
+
class: SLIDE_CLS,
|
|
73
|
+
role: "group",
|
|
74
|
+
"aria-roledescription": "slide",
|
|
75
|
+
data: { slot: "carousel-item" })
|
|
76
|
+
})
|
|
64
77
|
end
|
|
65
78
|
end
|
|
66
79
|
|
|
@@ -68,14 +81,14 @@ module UI
|
|
|
68
81
|
content_tag(:button, type: "button",
|
|
69
82
|
class: cn(BTN_BASE, BTN_PREV),
|
|
70
83
|
"aria-label": "Previous slide",
|
|
71
|
-
data: { action: "click->carousel#prev" }) { chevron(CHEVRON_L) }
|
|
84
|
+
data: { slot: "carousel-previous", action: "click->carousel#prev" }) { chevron(CHEVRON_L) }
|
|
72
85
|
end
|
|
73
86
|
|
|
74
87
|
def next_btn
|
|
75
88
|
content_tag(:button, type: "button",
|
|
76
89
|
class: cn(BTN_BASE, BTN_NEXT),
|
|
77
90
|
"aria-label": "Next slide",
|
|
78
|
-
data: { action: "click->carousel#next" }) { chevron(CHEVRON_R) }
|
|
91
|
+
data: { slot: "carousel-next", action: "click->carousel#next" }) { chevron(CHEVRON_R) }
|
|
79
92
|
end
|
|
80
93
|
|
|
81
94
|
def dots
|
|
@@ -22,22 +22,26 @@ module UI
|
|
|
22
22
|
# datasets: array of dataset hashes; snake_case keys are camelized for Chart.js
|
|
23
23
|
# (e.g. background_color: → backgroundColor:)
|
|
24
24
|
# options: hash merged into Chart.js `options` (e.g. { responsive: false })
|
|
25
|
+
# colors: optional array overriding default --chart-1…5 palette for datasets
|
|
25
26
|
|
|
26
27
|
TYPES = %w[bar line pie doughnut radar polarArea].freeze
|
|
27
28
|
|
|
28
|
-
WRAPPER_CLS = "
|
|
29
|
+
WRAPPER_CLS = "flex aspect-video w-full justify-center text-xs"
|
|
29
30
|
|
|
30
|
-
def initialize(type: :bar, labels: [], datasets: [], options: {}, **html_attrs)
|
|
31
|
+
def initialize(type: :bar, labels: [], datasets: [], options: {}, colors: nil, **html_attrs)
|
|
31
32
|
@type = TYPES.include?(type.to_s) ? type.to_s : "bar"
|
|
32
33
|
@labels = labels
|
|
33
34
|
@datasets = datasets
|
|
34
35
|
@chart_options = options
|
|
36
|
+
@colors = colors
|
|
35
37
|
@extra_class = html_attrs.delete(:class)
|
|
36
38
|
@html_attrs = html_attrs
|
|
37
39
|
end
|
|
38
40
|
|
|
39
41
|
def call
|
|
40
|
-
content_tag(:div,
|
|
42
|
+
content_tag(:div,
|
|
43
|
+
class: cn(WRAPPER_CLS, @extra_class),
|
|
44
|
+
data: { slot: "chart" }) do
|
|
41
45
|
tag.canvas(
|
|
42
46
|
data: {
|
|
43
47
|
controller: "chart",
|
|
@@ -53,7 +57,9 @@ module UI
|
|
|
53
57
|
|
|
54
58
|
def config_json
|
|
55
59
|
ds = @datasets.map { |d| camelize_keys(d).compact }
|
|
56
|
-
{labels: @labels, datasets: ds, options: @chart_options}
|
|
60
|
+
payload = {labels: @labels, datasets: ds, options: @chart_options}
|
|
61
|
+
payload[:colors] = @colors if @colors
|
|
62
|
+
payload.to_json
|
|
57
63
|
end
|
|
58
64
|
|
|
59
65
|
def camelize_keys(hash)
|
|
@@ -5,6 +5,14 @@ import { Chart, registerables } from "chart.js"
|
|
|
5
5
|
|
|
6
6
|
Chart.register(...registerables)
|
|
7
7
|
|
|
8
|
+
const DEFAULT_COLORS = [
|
|
9
|
+
"var(--chart-1)",
|
|
10
|
+
"var(--chart-2)",
|
|
11
|
+
"var(--chart-3)",
|
|
12
|
+
"var(--chart-4)",
|
|
13
|
+
"var(--chart-5)"
|
|
14
|
+
]
|
|
15
|
+
|
|
8
16
|
export default class extends Controller {
|
|
9
17
|
static values = {
|
|
10
18
|
type: { type: String, default: "bar" },
|
|
@@ -14,11 +22,26 @@ export default class extends Controller {
|
|
|
14
22
|
#chart = null
|
|
15
23
|
|
|
16
24
|
connect() {
|
|
17
|
-
const { labels, datasets, options = {} } = JSON.parse(this.configValue)
|
|
25
|
+
const { labels, datasets, options = {}, colors } = JSON.parse(this.configValue)
|
|
26
|
+
const palette = colors?.length ? colors : DEFAULT_COLORS
|
|
27
|
+
const coloredDatasets = datasets.map((dataset, index) => {
|
|
28
|
+
const color = palette[index % palette.length]
|
|
29
|
+
return {
|
|
30
|
+
...dataset,
|
|
31
|
+
backgroundColor: dataset.backgroundColor ?? color,
|
|
32
|
+
borderColor: dataset.borderColor ?? color
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
18
36
|
this.#chart = new Chart(this.element, {
|
|
19
37
|
type: this.typeValue,
|
|
20
|
-
data: { labels, datasets },
|
|
21
|
-
options: {
|
|
38
|
+
data: { labels, datasets: coloredDatasets },
|
|
39
|
+
options: {
|
|
40
|
+
responsive: true,
|
|
41
|
+
maintainAspectRatio: true,
|
|
42
|
+
color: "var(--muted-foreground)",
|
|
43
|
+
...options
|
|
44
|
+
}
|
|
22
45
|
})
|
|
23
46
|
}
|
|
24
47
|
|
|
@@ -5,9 +5,9 @@ module UI
|
|
|
5
5
|
# sent: true → right-aligned, primary-colored bubble
|
|
6
6
|
# sent: false → left-aligned, muted bubble (default)
|
|
7
7
|
|
|
8
|
-
BUBBLE_BASE = "max-w-[80%] rounded-2xl px-4 py-2 text-sm leading-relaxed"
|
|
9
|
-
BUBBLE_SENT = "bg-primary text-primary-foreground
|
|
10
|
-
BUBBLE_RECV = "bg-muted text-foreground
|
|
8
|
+
BUBBLE_BASE = "max-w-[80%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed shadow-xs"
|
|
9
|
+
BUBBLE_SENT = "rounded-br-md bg-primary text-primary-foreground"
|
|
10
|
+
BUBBLE_RECV = "rounded-bl-md #{UI::Styles::BORDER} bg-muted text-foreground"
|
|
11
11
|
|
|
12
12
|
TIMESTAMP_BASE = "mt-1 text-xs text-muted-foreground"
|
|
13
13
|
|
|
@@ -37,7 +37,7 @@ module UI
|
|
|
37
37
|
content_tag(:img, nil,
|
|
38
38
|
src: @avatar,
|
|
39
39
|
alt: "",
|
|
40
|
-
class: "size-
|
|
40
|
+
class: "size-8 shrink-0 rounded-full object-cover ring-2 ring-background",
|
|
41
41
|
"aria-hidden": "true")
|
|
42
42
|
end
|
|
43
43
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module UI
|
|
4
4
|
class CheckboxComponent < ApplicationComponent
|
|
5
5
|
BASE = "peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none " \
|
|
6
|
-
"
|
|
6
|
+
"#{UI::Styles::FOCUS_RING} " \
|
|
7
7
|
"disabled:cursor-not-allowed disabled:opacity-50 " \
|
|
8
8
|
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " \
|
|
9
9
|
"checked:border-primary checked:bg-primary checked:text-primary-foreground " \
|
|
@@ -6,9 +6,12 @@ module UI
|
|
|
6
6
|
# trigger slot: content for the summary row (button, icon, label, etc.)
|
|
7
7
|
# open: render pre-expanded (default: false)
|
|
8
8
|
|
|
9
|
-
SUMMARY_CLS = "flex cursor-pointer list-none items-center justify-between gap-2 " \
|
|
9
|
+
SUMMARY_CLS = "flex cursor-pointer list-none items-center justify-between gap-4 rounded-md py-2 " \
|
|
10
|
+
"text-left text-sm font-medium transition-all outline-none " \
|
|
11
|
+
"hover:underline " \
|
|
12
|
+
"#{UI::Styles::FOCUS_RING} " \
|
|
10
13
|
"[&::-webkit-details-marker]:hidden"
|
|
11
|
-
CONTENT_CLS = "
|
|
14
|
+
CONTENT_CLS = "pb-2 pt-0 text-sm text-muted-foreground"
|
|
12
15
|
|
|
13
16
|
renders_one :trigger
|
|
14
17
|
|
|
@@ -19,12 +22,16 @@ module UI
|
|
|
19
22
|
end
|
|
20
23
|
|
|
21
24
|
def call
|
|
22
|
-
attrs = { class: cn(@extra_class), **@html_attrs }
|
|
25
|
+
attrs = { class: cn("group", @extra_class), data: { slot: "collapsible" }, **@html_attrs }
|
|
23
26
|
attrs[:open] = true if @open
|
|
24
27
|
|
|
25
28
|
content_tag(:details, **attrs) do
|
|
26
|
-
concat content_tag(:summary, trigger,
|
|
27
|
-
|
|
29
|
+
concat content_tag(:summary, trigger,
|
|
30
|
+
class: SUMMARY_CLS,
|
|
31
|
+
data: { slot: "collapsible-trigger" })
|
|
32
|
+
concat content_tag(:div, content,
|
|
33
|
+
class: CONTENT_CLS,
|
|
34
|
+
data: { slot: "collapsible-content" })
|
|
28
35
|
end
|
|
29
36
|
end
|
|
30
37
|
end
|
|
@@ -2,13 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module UI
|
|
4
4
|
class ComboboxComponent < ApplicationComponent
|
|
5
|
-
INPUT =
|
|
6
|
-
|
|
7
|
-
PANEL = "absolute z-50 top-full left-0 mt-1 w-full overflow-hidden rounded-md border " \
|
|
8
|
-
"bg-popover text-popover-foreground shadow-md"
|
|
5
|
+
INPUT = UI::Styles::INPUT
|
|
6
|
+
PANEL = "#{UI::Styles::POPOVER_PANEL} top-full left-0 mt-1 w-full overflow-hidden"
|
|
9
7
|
LIST = "max-h-[200px] overflow-y-auto p-1"
|
|
10
|
-
OPTION = "
|
|
11
|
-
"px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground"
|
|
8
|
+
OPTION = "#{UI::Styles::MENU_ITEM} w-full cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
|
12
9
|
EMPTY = "py-4 text-center text-sm text-muted-foreground"
|
|
13
10
|
|
|
14
11
|
def initialize(name:, options: [], value: nil, placeholder: "Select...", **html_attrs)
|
|
@@ -4,10 +4,12 @@ module UI
|
|
|
4
4
|
class CommandComponent < ApplicationComponent
|
|
5
5
|
renders_one :trigger
|
|
6
6
|
|
|
7
|
-
OVERLAY =
|
|
8
|
-
DIALOG = "fixed
|
|
9
|
-
"overflow-hidden rounded-lg
|
|
10
|
-
|
|
7
|
+
OVERLAY = UI::Styles::OVERLAY
|
|
8
|
+
DIALOG = "fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] " \
|
|
9
|
+
"translate-x-[-50%] translate-y-[-50%] overflow-hidden rounded-lg #{UI::Styles::BORDER} bg-background " \
|
|
10
|
+
"p-0 shadow-lg duration-200 sm:max-w-lg"
|
|
11
|
+
COMMAND = "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground"
|
|
12
|
+
SEARCH = "flex h-9 items-center gap-2 border-b border-border px-3"
|
|
11
13
|
LIST = "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto"
|
|
12
14
|
EMPTY = "py-6 text-center text-sm text-muted-foreground"
|
|
13
15
|
|
|
@@ -16,15 +18,14 @@ module UI
|
|
|
16
18
|
# Apply to the heading element (p/span) inside a group wrapper.
|
|
17
19
|
GROUP = "px-2 py-1.5 text-xs font-medium text-muted-foreground"
|
|
18
20
|
# Apply to each actionable item button/link.
|
|
19
|
-
ITEM = "
|
|
20
|
-
"
|
|
21
|
-
"hover:bg-accent hover:text-accent-foreground " \
|
|
21
|
+
ITEM = "#{UI::Styles::MENU_ITEM} w-full cursor-default data-[selected=true]:bg-accent " \
|
|
22
|
+
"data-[selected=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground " \
|
|
22
23
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " \
|
|
23
24
|
"[&_svg:not([class*='text-'])]:text-muted-foreground"
|
|
24
25
|
# Place inside an ITEM as the last child to show a keyboard shortcut on the right.
|
|
25
26
|
SHORTCUT = "ml-auto text-xs tracking-widest text-muted-foreground"
|
|
26
|
-
# Horizontal rule between groups (
|
|
27
|
-
SEPARATOR =
|
|
27
|
+
# Horizontal rule between groups (`<div role="separator">`).
|
|
28
|
+
SEPARATOR = UI::Styles::MENU_SEPARATOR
|
|
28
29
|
|
|
29
30
|
def initialize(**html_attrs)
|
|
30
31
|
@extra_class = html_attrs.delete(:class)
|
|
@@ -51,14 +52,16 @@ module UI
|
|
|
51
52
|
role: "dialog",
|
|
52
53
|
"aria-modal": "true",
|
|
53
54
|
data: { action: "keydown.escape@window->command#close" }) {
|
|
54
|
-
concat
|
|
55
|
-
|
|
56
|
-
concat
|
|
55
|
+
concat content_tag(:div, class: COMMAND) {
|
|
56
|
+
concat search_bar
|
|
57
|
+
concat content_tag(:div, class: LIST, data: { command_target: "list" }) {
|
|
58
|
+
concat content
|
|
59
|
+
}
|
|
60
|
+
concat content_tag(:div, "No results found.",
|
|
61
|
+
class: EMPTY,
|
|
62
|
+
data: { command_target: "empty" },
|
|
63
|
+
hidden: true)
|
|
57
64
|
}
|
|
58
|
-
concat content_tag(:div, "No results found.",
|
|
59
|
-
class: EMPTY,
|
|
60
|
-
data: { command_target: "empty" },
|
|
61
|
-
hidden: true)
|
|
62
65
|
}
|
|
63
66
|
end
|
|
64
67
|
end
|
|
@@ -69,7 +72,8 @@ module UI
|
|
|
69
72
|
concat tag.input(
|
|
70
73
|
type: "text",
|
|
71
74
|
placeholder: "Type a command or search...",
|
|
72
|
-
class: "flex-
|
|
75
|
+
class: "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden " \
|
|
76
|
+
"placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
|
73
77
|
data: {
|
|
74
78
|
command_target: "input",
|
|
75
79
|
action: "input->command#filter"
|
|
@@ -79,7 +83,7 @@ module UI
|
|
|
79
83
|
end
|
|
80
84
|
|
|
81
85
|
def search_icon
|
|
82
|
-
raw('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0
|
|
86
|
+
raw('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 opacity-50" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>')
|
|
83
87
|
end
|
|
84
88
|
end
|
|
85
89
|
end
|
|
@@ -10,6 +10,7 @@ export default class extends Controller {
|
|
|
10
10
|
|
|
11
11
|
disconnect() {
|
|
12
12
|
document.removeEventListener("keydown", this._onKeydown)
|
|
13
|
+
this._releaseFocusTrap()
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
_onKeydown(event) {
|
|
@@ -22,6 +23,12 @@ export default class extends Controller {
|
|
|
22
23
|
open() {
|
|
23
24
|
this.panelTarget.hidden = false
|
|
24
25
|
document.body.style.overflow = "hidden"
|
|
26
|
+
|
|
27
|
+
this._previouslyFocused = document.activeElement
|
|
28
|
+
this._dialog = this.panelTarget.querySelector("[role=dialog]")
|
|
29
|
+
this._trapFocus = this.#trapFocus.bind(this)
|
|
30
|
+
this._dialog?.addEventListener("keydown", this._trapFocus)
|
|
31
|
+
|
|
25
32
|
this.inputTarget.value = ""
|
|
26
33
|
this.inputTarget.focus()
|
|
27
34
|
this.filter()
|
|
@@ -30,6 +37,7 @@ export default class extends Controller {
|
|
|
30
37
|
close() {
|
|
31
38
|
this.panelTarget.hidden = true
|
|
32
39
|
document.body.style.overflow = ""
|
|
40
|
+
this._releaseFocusTrap()
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
filter() {
|
|
@@ -47,4 +55,46 @@ export default class extends Controller {
|
|
|
47
55
|
const totalVisible = Array.from(items).filter(i => !i.hidden).length
|
|
48
56
|
this.emptyTarget.hidden = totalVisible > 0
|
|
49
57
|
}
|
|
58
|
+
|
|
59
|
+
#trapFocus(event) {
|
|
60
|
+
if (event.key !== "Tab" || !this._dialog) return
|
|
61
|
+
|
|
62
|
+
const focusable = this.#focusableElements(this._dialog)
|
|
63
|
+
if (focusable.length === 0) return
|
|
64
|
+
|
|
65
|
+
const first = focusable[0]
|
|
66
|
+
const last = focusable[focusable.length - 1]
|
|
67
|
+
|
|
68
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
69
|
+
event.preventDefault()
|
|
70
|
+
last.focus()
|
|
71
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
72
|
+
event.preventDefault()
|
|
73
|
+
first.focus()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#focusableElements(container) {
|
|
78
|
+
if (!container) return []
|
|
79
|
+
|
|
80
|
+
const selector = [
|
|
81
|
+
"a[href]",
|
|
82
|
+
"button:not([disabled])",
|
|
83
|
+
"input:not([disabled])",
|
|
84
|
+
"select:not([disabled])",
|
|
85
|
+
"textarea:not([disabled])",
|
|
86
|
+
"[tabindex]:not([tabindex='-1'])",
|
|
87
|
+
].join(", ")
|
|
88
|
+
|
|
89
|
+
return Array.from(container.querySelectorAll(selector)).filter(
|
|
90
|
+
(el) => !el.hasAttribute("hidden") && el.offsetParent !== null
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_releaseFocusTrap() {
|
|
95
|
+
this._dialog?.removeEventListener("keydown", this._trapFocus)
|
|
96
|
+
this._previouslyFocused?.focus?.()
|
|
97
|
+
this._previouslyFocused = null
|
|
98
|
+
this._dialog = null
|
|
99
|
+
}
|
|
50
100
|
}
|
|
@@ -4,14 +4,15 @@ module UI
|
|
|
4
4
|
class ContextMenuComponent < ApplicationComponent
|
|
5
5
|
renders_one :menu
|
|
6
6
|
|
|
7
|
-
PANEL
|
|
8
|
-
|
|
9
|
-
ITEM
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
PANEL = "#{UI::Styles::POPOVER_PANEL} fixed min-w-[8rem] overflow-x-hidden overflow-y-auto p-1"
|
|
8
|
+
|
|
9
|
+
ITEM = "#{UI::Styles::MENU_ITEM} w-full hover:bg-accent hover:text-accent-foreground " \
|
|
10
|
+
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 " \
|
|
11
|
+
"dark:data-[variant=destructive]:focus:bg-destructive/20 " \
|
|
12
|
+
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " \
|
|
13
|
+
"[&_svg:not([class*='text-'])]:text-muted-foreground"
|
|
14
|
+
|
|
15
|
+
SEPARATOR = UI::Styles::MENU_SEPARATOR
|
|
15
16
|
LABEL_CLS = "px-2 py-1.5 text-sm font-medium text-foreground"
|
|
16
17
|
|
|
17
18
|
def initialize(**html_attrs)
|
|
@@ -3,29 +3,36 @@
|
|
|
3
3
|
module UI
|
|
4
4
|
class DataTableComponent < ApplicationComponent
|
|
5
5
|
# Sortable, filterable data table with client-side pagination.
|
|
6
|
+
# Table cell styles follow shadcn/ui Table primitives.
|
|
6
7
|
#
|
|
7
8
|
# columns: array of { key:, label:, sortable: true }
|
|
8
9
|
# rows: array of hashes (keys must match column keys)
|
|
9
10
|
# per_page: rows per page (default 10, 0 = no pagination)
|
|
10
11
|
# caption: optional <caption> text
|
|
11
12
|
|
|
12
|
-
WRAPPER = "w-full overflow-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
|
|
13
|
+
WRAPPER = "w-full overflow-hidden rounded-md #{UI::Styles::BORDER} bg-background"
|
|
14
|
+
TABLE_WRAP = "relative w-full overflow-x-auto"
|
|
15
|
+
TOOLBAR = "flex items-center gap-3 border-b border-border px-4 py-3"
|
|
16
|
+
SEARCH_CLS = "flex h-9 w-full max-w-sm items-center gap-2 rounded-md border border-input bg-transparent px-3 " \
|
|
17
|
+
"text-sm shadow-xs transition-[color,box-shadow] " \
|
|
18
|
+
"focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 " \
|
|
19
|
+
"dark:bg-input/30"
|
|
20
|
+
SEARCH_INPUT = "w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
|
18
21
|
TABLE_CLS = "w-full caption-bottom text-sm"
|
|
19
|
-
THEAD_CLS = "
|
|
20
|
-
TH_CLS = "h-10 px-
|
|
21
|
-
TH_SORT = "cursor-pointer select-none hover:text-foreground
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
22
|
+
THEAD_CLS = "[&_tr]:border-b [&_tr]:border-border"
|
|
23
|
+
TH_CLS = "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground"
|
|
24
|
+
TH_SORT = "cursor-pointer select-none transition-colors hover:text-foreground/80"
|
|
25
|
+
TBODY_CLS = "[&_tr:last-child]:border-0"
|
|
26
|
+
TR_CLS = "border-b border-border transition-colors hover:bg-muted/50"
|
|
27
|
+
TD_CLS = "p-2 align-middle whitespace-nowrap"
|
|
28
|
+
CAPTION_CLS = "mt-4 text-sm text-muted-foreground"
|
|
29
|
+
FOOTER_CLS = "flex items-center justify-between gap-4 border-t border-border px-4 py-3 text-sm text-muted-foreground"
|
|
30
|
+
PAGE_BTN = "inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-input bg-background " \
|
|
31
|
+
"text-sm font-medium shadow-xs transition-all outline-none " \
|
|
32
|
+
"hover:bg-accent hover:text-accent-foreground " \
|
|
33
|
+
"#{UI::Styles::FOCUS_RING} " \
|
|
34
|
+
"disabled:pointer-events-none disabled:opacity-50 " \
|
|
35
|
+
"dark:border-input dark:bg-input/30 dark:hover:bg-input/50"
|
|
29
36
|
SORT_ASC = "▲"
|
|
30
37
|
SORT_DESC = "▼"
|
|
31
38
|
|
|
@@ -48,7 +55,7 @@ module UI
|
|
|
48
55
|
},
|
|
49
56
|
**@html_attrs) do
|
|
50
57
|
concat toolbar
|
|
51
|
-
concat
|
|
58
|
+
concat table_section
|
|
52
59
|
concat footer if @per_page > 0
|
|
53
60
|
end
|
|
54
61
|
end
|
|
@@ -74,14 +81,20 @@ module UI
|
|
|
74
81
|
end
|
|
75
82
|
end
|
|
76
83
|
|
|
77
|
-
def
|
|
78
|
-
content_tag(:
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
def table_section
|
|
85
|
+
content_tag(:div, class: TABLE_WRAP) do
|
|
86
|
+
content_tag(:table, class: TABLE_CLS) do
|
|
87
|
+
concat thead
|
|
88
|
+
concat tbody
|
|
89
|
+
concat caption_element if @caption
|
|
90
|
+
end
|
|
82
91
|
end
|
|
83
92
|
end
|
|
84
93
|
|
|
94
|
+
def caption_element
|
|
95
|
+
content_tag(:caption, @caption, class: CAPTION_CLS)
|
|
96
|
+
end
|
|
97
|
+
|
|
85
98
|
def thead
|
|
86
99
|
content_tag(:thead, class: THEAD_CLS) do
|
|
87
100
|
content_tag(:tr) do
|
|
@@ -98,7 +111,8 @@ module UI
|
|
|
98
111
|
content_tag(:th, class: cn(TH_CLS, sortable ? TH_SORT : nil),
|
|
99
112
|
data: sortable ? {
|
|
100
113
|
action: "click->data-table#sort",
|
|
101
|
-
data_table_key_param: key
|
|
114
|
+
data_table_key_param: key,
|
|
115
|
+
data_table_column_key: key
|
|
102
116
|
} : {}) do
|
|
103
117
|
content_tag(:span, class: "flex items-center gap-1") do
|
|
104
118
|
concat label
|
|
@@ -115,7 +129,7 @@ module UI
|
|
|
115
129
|
end
|
|
116
130
|
|
|
117
131
|
def tbody
|
|
118
|
-
content_tag(:tbody, data: { data_table_target: "body" }) do
|
|
132
|
+
content_tag(:tbody, class: TBODY_CLS, data: { data_table_target: "body" }) do
|
|
119
133
|
safe_join(@rows.map { |row| tr_row(row) })
|
|
120
134
|
end
|
|
121
135
|
end
|
|
@@ -132,15 +146,32 @@ module UI
|
|
|
132
146
|
|
|
133
147
|
def footer
|
|
134
148
|
content_tag(:div, class: FOOTER_CLS) do
|
|
135
|
-
concat content_tag(:span, "
|
|
149
|
+
concat content_tag(:span, "",
|
|
136
150
|
data: { data_table_target: "pageLabel" })
|
|
137
|
-
concat(content_tag(:div, class: "flex items-center gap-1") {
|
|
138
|
-
concat page_btn(
|
|
139
|
-
concat page_btn(
|
|
151
|
+
concat(content_tag(:div, class: "flex shrink-0 items-center gap-1") {
|
|
152
|
+
concat page_btn(prev_icon, "click->data-table#prevPage", "Previous page")
|
|
153
|
+
concat page_btn(next_icon, "click->data-table#nextPage", "Next page")
|
|
140
154
|
})
|
|
141
155
|
end
|
|
142
156
|
end
|
|
143
157
|
|
|
158
|
+
def prev_icon
|
|
159
|
+
chevron_svg("left")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def next_icon
|
|
163
|
+
chevron_svg("right")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def chevron_svg(direction)
|
|
167
|
+
path = direction == "left" ? "m15 18-6-6 6-6" : "m9 18 6-6-6-6"
|
|
168
|
+
content_tag(:svg, content_tag(:path, nil, d: path),
|
|
169
|
+
xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
|
|
170
|
+
fill: "none", stroke: "currentColor", "stroke-width": "2",
|
|
171
|
+
"stroke-linecap": "round", "stroke-linejoin": "round",
|
|
172
|
+
class: "size-4", "aria-hidden": "true")
|
|
173
|
+
end
|
|
174
|
+
|
|
144
175
|
def page_btn(label, action, aria)
|
|
145
176
|
content_tag(:button, label, type: "button",
|
|
146
177
|
class: PAGE_BTN,
|
|
@@ -157,7 +188,7 @@ module UI
|
|
|
157
188
|
xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
|
|
158
189
|
fill: "none", stroke: "currentColor", "stroke-width": "2",
|
|
159
190
|
"stroke-linecap": "round", "stroke-linejoin": "round",
|
|
160
|
-
class: "size-4 shrink-0", "aria-hidden": "true")
|
|
191
|
+
class: "size-4 shrink-0 text-muted-foreground", "aria-hidden": "true")
|
|
161
192
|
end
|
|
162
193
|
end
|
|
163
194
|
end
|
|
@@ -97,8 +97,8 @@ export default class extends Controller {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
#columnIndex(key) {
|
|
100
|
-
const headers = this.element.querySelectorAll("th[data-data-table-key
|
|
101
|
-
return Array.from(headers).findIndex(h => h.dataset.
|
|
100
|
+
const headers = this.element.querySelectorAll("th[data-data-table-column-key]")
|
|
101
|
+
return Array.from(headers).findIndex(h => h.dataset.dataTableColumnKey === key)
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
get #totalPages() {
|
|
@@ -10,13 +10,11 @@ module UI
|
|
|
10
10
|
# min/max: Date bounds passed to the calendar
|
|
11
11
|
|
|
12
12
|
WRAPPER = "relative inline-block"
|
|
13
|
-
TRIGGER = "
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
POPOVER = "absolute left-0 top-full z-50 mt-1 hidden w-max rounded-lg border border-border " \
|
|
19
|
-
"bg-popover p-0 shadow-md data-[open=true]:block"
|
|
13
|
+
TRIGGER = "#{UI::Styles::PICKER_TRIGGER} w-48"
|
|
14
|
+
ICON_CLS = "size-4 shrink-0 text-muted-foreground pointer-events-none"
|
|
15
|
+
LABEL_PLACEHOLDER = "text-muted-foreground"
|
|
16
|
+
# Positioning shell only — visual chrome comes from CalendarComponent::CONTAINER
|
|
17
|
+
POPOVER = "absolute left-0 top-full z-50 mt-2 hidden w-max data-[open=true]:block"
|
|
20
18
|
|
|
21
19
|
def initialize(value: nil, name: nil, placeholder: "Pick a date", min: nil, max: nil, **html_attrs)
|
|
22
20
|
@value = value
|
|
@@ -58,7 +56,9 @@ module UI
|
|
|
58
56
|
action: "click->date-picker#toggle"
|
|
59
57
|
}) do
|
|
60
58
|
concat calendar_icon
|
|
61
|
-
concat content_tag(:span, label_text,
|
|
59
|
+
concat content_tag(:span, label_text,
|
|
60
|
+
class: (@value ? nil : LABEL_PLACEHOLDER),
|
|
61
|
+
data: { date_picker_target: "label" })
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
|