Skip to content

Commit 67b8695

Browse files
authored
Add carousel component (#219)
* Add embla package * Add Carousel component * Replace CarouselContext with Tailwind group * Add keyboard interactions
1 parent 4d474b0 commit 67b8695

File tree

10 files changed

+306
-0
lines changed

10 files changed

+306
-0
lines changed

lib/generators/ruby_ui/dependencies.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ calendar:
1010
js_packages:
1111
- "mustache"
1212

13+
carousel:
14+
js_packages:
15+
- "embla-carousel"
16+
1317
chart:
1418
js_packages:
1519
- "chart.js"

lib/ruby_ui/carousel/carousel.rb

Lines changed: 44 additions & 0 deletions
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
Lines changed: 23 additions & 0 deletions
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
Lines changed: 60 additions & 0 deletions
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+
}

lib/ruby_ui/carousel/carousel_item.rb

Lines changed: 23 additions & 0 deletions
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

lib/ruby_ui/carousel/carousel_next.rb

Lines changed: 48 additions & 0 deletions
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
Lines changed: 49 additions & 0 deletions
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

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@hotwired/stimulus": "^3.2.2",
2828
"chart.js": "^4.4.1",
2929
"date-fns": "^2.30.0",
30+
"embla-carousel": "8.5.2",
3031
"fuse.js": "^7.0.0",
3132
"maska": "^3.0.3",
3233
"motion": "^10.16.4",

test/ruby_ui/carousel_test.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
class RubyUI::CarouselTest < ComponentTest
6+
def test_render_with_all_items
7+
output = phlex do
8+
RubyUI.Carousel do
9+
RubyUI.CarouselContent do
10+
RubyUI.CarouselItem { "Item" }
11+
end
12+
RubyUI.CarouselPrevious()
13+
RubyUI.CarouselNext()
14+
end
15+
end
16+
17+
assert_match(/Item/, output)
18+
assert_match(/button/, output)
19+
assert_match(/ is-horizontal/, output)
20+
end
21+
22+
def test_render_with_horizontal_orientation
23+
output = phlex do
24+
RubyUI.Carousel(orientation: :horizontal) do
25+
RubyUI.CarouselContent() do
26+
RubyUI.CarouselItem() { "Item" }
27+
end
28+
RubyUI.CarouselPrevious()
29+
RubyUI.CarouselNext()
30+
end
31+
end
32+
33+
assert_match(/ is-horizontal/, output)
34+
end
35+
36+
def test_render_with_vertical_orientation
37+
output = phlex do
38+
RubyUI.Carousel(orientation: :vertical) do
39+
RubyUI.CarouselContent() do
40+
RubyUI.CarouselItem() { "Item" }
41+
end
42+
RubyUI.CarouselPrevious()
43+
RubyUI.CarouselNext()
44+
end
45+
end
46+
47+
assert_match(/ is-vertical/, output)
48+
end
49+
end

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ date-fns@^2.30.0:
111111
dependencies:
112112
"@babel/runtime" "^7.21.0"
113113

114+
115+
version "8.5.2"
116+
resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-8.5.2.tgz#95eb936d14a1b9a67b9207a0fde1f25259a5d692"
117+
integrity sha512-xQ9oVLrun/eCG/7ru3R+I5bJ7shsD8fFwLEY7yPe27/+fDHCNj0OT5EoG5ZbFyOxOcG6yTwW8oTz/dWyFnyGpg==
118+
114119
fuse.js@^7.0.0:
115120
version "7.0.0"
116121
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.0.0.tgz#6573c9fcd4c8268e403b4fc7d7131ffcf99a9eb2"

0 commit comments

Comments
 (0)