Skip to content

Commit b44d99b

Browse files
committed
Add Carousel component
1 parent 5cab82b commit b44d99b

File tree

12 files changed

+365
-0
lines changed

12 files changed

+365
-0
lines changed

app/components/ruby_ui/carousel.rb

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class Carousel < Base
5+
def initialize(orientation: :horizontal, options: {}, **user_attrs)
6+
@orientation = orientation
7+
@options = options
8+
9+
super(**user_attrs)
10+
end
11+
12+
def view_template(&)
13+
div(**attrs, &)
14+
end
15+
16+
private
17+
18+
def default_attrs
19+
{
20+
class: ["relative group", orientation_classes],
21+
role: "region",
22+
aria_roledescription: "carousel",
23+
data: {
24+
controller: "ruby-ui--carousel",
25+
ruby_ui__carousel_options_value: default_options.merge(@options).to_json,
26+
action: %w[
27+
keydown.right->ruby-ui--carousel#scrollNext:prevent
28+
keydown.left->ruby-ui--carousel#scrollPrev:prevent
29+
]
30+
}
31+
}
32+
end
33+
34+
def default_options
35+
{
36+
axis: (@orientation == :horizontal) ? "x" : "y"
37+
}
38+
end
39+
40+
def orientation_classes
41+
(@orientation == :horizontal) ? "is-horizontal" : "is-vertical"
42+
end
43+
end
44+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class CarouselContent < Base
5+
def view_template(&)
6+
div(class: "overflow-hidden", data: {ruby_ui__carousel_target: "viewport"}) do
7+
div(**attrs, &)
8+
end
9+
end
10+
11+
private
12+
13+
def default_attrs
14+
{
15+
class: [
16+
"flex",
17+
"group-[.is-horizontal]:-ml-4",
18+
"group-[.is-vertical]:-mt-4 group-[.is-vertical]:flex-col"
19+
]
20+
}
21+
end
22+
end
23+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class CarouselItem < Base
5+
def view_template(&)
6+
div(**attrs, &)
7+
end
8+
9+
private
10+
11+
def default_attrs
12+
{
13+
role: "group",
14+
aria_roledescription: "slide",
15+
class: [
16+
"min-w-0 shrink-0 grow-0 basis-full",
17+
"group-[.is-horizontal]:pl-4",
18+
"group-[.is-vertical]:pt-4"
19+
]
20+
}
21+
end
22+
end
23+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class CarouselNext < Base
5+
def view_template(&)
6+
Button(**attrs) do
7+
icon
8+
end
9+
end
10+
11+
private
12+
13+
def default_attrs
14+
{
15+
variant: :outline,
16+
icon: true,
17+
class: [
18+
"absolute h-8 w-8 rounded-full",
19+
"group-[.is-horizontal]:-right-12 group-[.is-horizontal]:top-1/2 group-[.is-horizontal]:-translate-y-1/2",
20+
"group-[.is-vertical]:-bottom-12 group-[.is-vertical]:left-1/2 group-[.is-vertical]:-translate-x-1/2 group-[.is-vertical]:rotate-90"
21+
],
22+
disabled: true,
23+
data: {
24+
action: "click->ruby-ui--carousel#scrollNext",
25+
ruby_ui__carousel_target: "nextButton"
26+
}
27+
}
28+
end
29+
30+
def icon
31+
svg(
32+
width: "24",
33+
height: "24",
34+
viewBox: "0 0 24 24",
35+
fill: "none",
36+
stroke: "currentColor",
37+
stroke_width: "2",
38+
stroke_linecap: "round",
39+
stroke_linejoin: "round",
40+
xmlns: "http://www.w3.org/2000/svg",
41+
class: "w-4 h-4"
42+
) do |s|
43+
s.path(d: "M5 12h14")
44+
s.path(d: "m12 5 7 7-7 7")
45+
end
46+
end
47+
end
48+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class CarouselPrevious < Base
5+
def view_template(&)
6+
Button(**attrs) do
7+
icon
8+
span(class: "sr-only") { "Next slide" }
9+
end
10+
end
11+
12+
private
13+
14+
def default_attrs
15+
{
16+
variant: :outline,
17+
icon: true,
18+
class: [
19+
"absolute h-8 w-8 rounded-full",
20+
"group-[.is-horizontal]:-left-12 group-[.is-horizontal]:top-1/2 group-[.is-horizontal]:-translate-y-1/2",
21+
"group-[.is-vertical]:-top-12 group-[.is-vertical]:left-1/2 group-[.is-vertical]:-translate-x-1/2 group-[.is-vertical]:rotate-90"
22+
],
23+
disabled: true,
24+
data: {
25+
action: "click->ruby-ui--carousel#scrollPrev",
26+
ruby_ui__carousel_target: "prevButton"
27+
}
28+
}
29+
end
30+
31+
def icon
32+
svg(
33+
width: "24",
34+
height: "24",
35+
viewBox: "0 0 24 24",
36+
fill: "none",
37+
stroke: "currentColor",
38+
stroke_width: "2",
39+
stroke_linecap: "round",
40+
stroke_linejoin: "round",
41+
xmlns: "http://www.w3.org/2000/svg",
42+
class: "w-4 h-4"
43+
) do |s|
44+
s.path(d: "m12 19-7-7 7-7")
45+
s.path(d: "M19 12H5")
46+
end
47+
end
48+
end
49+
end

app/controllers/docs_controller.rb

+4
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ def card
7070
render Views::Docs::Card.new
7171
end
7272

73+
def carousel
74+
render Docs::CarouselView.new
75+
end
76+
7377
def calendar
7478
render Views::Docs::Calendar.new
7579
end

app/javascript/controllers/index.js

+3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ application.register("ruby-ui--calendar", RubyUi__CalendarController)
1616
import RubyUi__CalendarInputController from "./ruby_ui/calendar_input_controller"
1717
application.register("ruby-ui--calendar-input", RubyUi__CalendarInputController)
1818

19+
import RubyUi__CarouselController from "./ruby_ui/carousel_controller"
20+
application.register("ruby-ui--carousel", RubyUi__CarouselController)
21+
1922
import RubyUi__ChartController from "./ruby_ui/chart_controller"
2023
application.register("ruby-ui--chart", RubyUi__ChartController)
2124

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Controller } from "@hotwired/stimulus";
2+
import EmblaCarousel from 'embla-carousel'
3+
4+
const DEFAULT_OPTIONS = {
5+
loop: true
6+
}
7+
8+
export default class extends Controller {
9+
static values = {
10+
options: {
11+
type: Object,
12+
default: {},
13+
}
14+
}
15+
static targets = ["viewport", "nextButton", "prevButton"]
16+
17+
connect() {
18+
this.initCarousel(this.#mergedOptions)
19+
}
20+
21+
disconnect() {
22+
this.destroyCarousel()
23+
}
24+
25+
initCarousel(options, plugins = []) {
26+
this.carousel = EmblaCarousel(this.viewportTarget, options, plugins)
27+
28+
this.carousel.on("init", this.#updateControls.bind(this))
29+
this.carousel.on("reInit", this.#updateControls.bind(this))
30+
this.carousel.on("select", this.#updateControls.bind(this))
31+
}
32+
33+
destroyCarousel() {
34+
this.carousel.destroy()
35+
}
36+
37+
scrollNext() {
38+
this.carousel.scrollNext()
39+
}
40+
41+
scrollPrev() {
42+
this.carousel.scrollPrev()
43+
}
44+
45+
#updateControls() {
46+
this.#toggleButtonsDisabledState(this.nextButtonTargets, !this.carousel.canScrollNext())
47+
this.#toggleButtonsDisabledState(this.prevButtonTargets, !this.carousel.canScrollPrev())
48+
}
49+
50+
#toggleButtonsDisabledState(buttons, isDisabled) {
51+
buttons.forEach((button) => button.disabled = isDisabled)
52+
}
53+
54+
get #mergedOptions() {
55+
return {
56+
...DEFAULT_OPTIONS,
57+
...this.optionsValue
58+
}
59+
}
60+
}

app/views/docs/carousel_view.rb

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# frozen_string_literal: true
2+
3+
class Docs::CarouselView < ApplicationView
4+
def view_template
5+
component = "Carousel"
6+
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
7+
render Docs::Header.new(title: "Carousel", description: "A carousel with motion and swipe built using Embla.")
8+
9+
Heading(level: 2) { "Usage" }
10+
11+
render Docs::VisualCodeExample.new(title: "Example", context: self) do
12+
<<~RUBY
13+
Carousel(options: {loop:false}, class: "w-full max-w-xs") do
14+
CarouselContent do
15+
5.times do |index|
16+
CarouselItem do
17+
div(class: "p-1") do
18+
Card do
19+
CardContent(class: "flex aspect-square items-center justify-center p-6") do
20+
span(class: "text-4xl font-semibold") { index + 1 }
21+
end
22+
end
23+
end
24+
end
25+
end
26+
end
27+
CarouselPrevious()
28+
CarouselNext()
29+
end
30+
RUBY
31+
end
32+
33+
render Docs::VisualCodeExample.new(title: "Sizes", context: self) do
34+
<<~RUBY
35+
Carousel(class: "w-full max-w-sm") do
36+
CarouselContent do
37+
5.times do |index|
38+
CarouselItem(class: "md:basis-1/2 lg:basis-1/3") do
39+
div(class: "p-1") do
40+
Card do
41+
CardContent(class: "flex aspect-square items-center justify-center p-6") do
42+
span(class: "text-3xl font-semibold") { index + 1 }
43+
end
44+
end
45+
end
46+
end
47+
end
48+
end
49+
CarouselPrevious()
50+
CarouselNext()
51+
end
52+
RUBY
53+
end
54+
55+
render Docs::VisualCodeExample.new(title: "Spacing", context: self) do
56+
<<~RUBY
57+
Carousel(class: "w-full max-w-sm") do
58+
CarouselContent(class: "-ml-1") do
59+
5.times do |index|
60+
CarouselItem(class: "pl-1 md:basis-1/2 lg:basis-1/3") do
61+
div(class: "p-1") do
62+
Card do
63+
CardContent(class: "flex aspect-square items-center justify-center p-6") do
64+
span(class: "text-2xl font-semibold") { index + 1 }
65+
end
66+
end
67+
end
68+
end
69+
end
70+
end
71+
CarouselPrevious()
72+
CarouselNext()
73+
end
74+
RUBY
75+
end
76+
77+
render Docs::VisualCodeExample.new(title: "Orientation", context: self) do
78+
<<~RUBY
79+
Carousel(orientation: :vertical, options: {align: "start"}, class: "w-full max-w-xs") do
80+
CarouselContent(class: "-mt-1 h-[200px]") do
81+
5.times do |index|
82+
CarouselItem(class: "pt-1 md:basis-1/2") do
83+
div(class: "p-1") do
84+
Card do
85+
CardContent(class: "flex items-center justify-center p-6") do
86+
span(class: "text-3xl font-semibold") { index + 1 }
87+
end
88+
end
89+
end
90+
end
91+
end
92+
end
93+
CarouselPrevious()
94+
CarouselNext()
95+
end
96+
RUBY
97+
end
98+
99+
render Components::ComponentSetup::Tabs.new(component_name: component)
100+
101+
render Docs::ComponentsTable.new(component_files(component))
102+
end
103+
end
104+
end

config/routes.rb

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
get "breadcrumb", to: "docs#breadcrumb", as: :docs_breadcrumb
2424
get "button", to: "docs#button", as: :docs_button
2525
get "card", to: "docs#card", as: :docs_card
26+
get "carousel", to: "docs#carousel", as: :docs_carousel
2627
get "calendar", to: "docs#calendar", as: :docs_calendar
2728
get "chart", to: "docs#chart", as: :docs_chart
2829
get "checkbox", to: "docs#checkbox", as: :docs_checkbox

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"chart.js": "^4.4.6",
1010
"class-variance-authority": "0.7.0",
1111
"clsx": "2.1.1",
12+
"embla-carousel": "^8.5.2",
1213
"esbuild": "0.23.0",
1314
"fuse.js": "^7.0.0",
1415
"maska": "^3.0.3",

0 commit comments

Comments
 (0)