Skip to content

Commit 1fe3b66

Browse files
committed
Add Carousel component
1 parent 111c050 commit 1fe3b66

9 files changed

+364
-0
lines changed

lib/generators/ruby_ui/dependencies.yml

+4
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

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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+
CarouselContext.with_context(orientation: @orientation) do
14+
div(**attrs, &)
15+
end
16+
end
17+
18+
private
19+
20+
def default_attrs
21+
{
22+
class: "relative",
23+
role: "region",
24+
aria_roledescription: "carousel",
25+
data: {
26+
controller: "ruby-ui--carousel",
27+
ruby_ui__carousel_options_value: default_options.merge(@options).to_json
28+
}
29+
}
30+
end
31+
32+
def default_options
33+
{
34+
axis: (@orientation == :horizontal) ? "x" : "y"
35+
}
36+
end
37+
end
38+
end
+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class CarouselContent < Base
5+
ORIENTATION_CLASSES = {
6+
horizontal: "-ml-4",
7+
vertical: "-mt-4 flex-col"
8+
}
9+
10+
def initialize(**attrs)
11+
@orientation = CarouselContext.orientation || :horizontal
12+
13+
super
14+
end
15+
16+
def view_template(&)
17+
div(class: "overflow-hidden", data: {ruby_ui__carousel_target: "viewport"}) do
18+
div(**attrs, &)
19+
end
20+
end
21+
22+
private
23+
24+
def default_attrs
25+
{
26+
class: ["flex", ORIENTATION_CLASSES[@orientation]]
27+
}
28+
end
29+
end
30+
end
+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class CarouselContext
5+
def self.with_context(**state)
6+
Thread.current[:ruby_ui__carousel_state] = state
7+
yield
8+
ensure
9+
Thread.current[:ruby_ui__carousel_state] = nil
10+
end
11+
12+
def self.state
13+
Thread.current[:ruby_ui__carousel_state] || {}
14+
end
15+
16+
def self.orientation
17+
state[:orientation]
18+
end
19+
end
20+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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+
this.carousel.on("init", this.#handleInit.bind(this))
28+
this.carousel.on("reInit", this.#handleReInit.bind(this))
29+
this.carousel.on("select", this.#handleSelect.bind(this))
30+
}
31+
32+
destroyCarousel() {
33+
this.carousel.destroy()
34+
}
35+
36+
scrollNext() {
37+
this.carousel.scrollNext()
38+
}
39+
40+
scrollPrev() {
41+
this.carousel.scrollPrev()
42+
}
43+
44+
#handleInit() {
45+
this.#updateControls()
46+
}
47+
48+
#handleReInit() {
49+
this.#updateControls()
50+
}
51+
52+
#handleSelect() {
53+
this.#updateControls()
54+
this.dispatch("select", { detail: { carousel: this.carousel } })
55+
}
56+
57+
#updateControls() {
58+
this.#toggleButtonsDisabledState(this.nextButtonTargets, !this.carousel.canScrollNext())
59+
this.#toggleButtonsDisabledState(this.prevButtonTargets, !this.carousel.canScrollPrev())
60+
}
61+
62+
#toggleButtonsDisabledState(buttons, isDisabled) {
63+
buttons.forEach((button) => button.disabled = isDisabled)
64+
}
65+
66+
get #mergedOptions() {
67+
return {
68+
...DEFAULT_OPTIONS,
69+
...this.optionsValue
70+
}
71+
}
72+
}

lib/ruby_ui/carousel/carousel_item.rb

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class CarouselItem < Base
5+
ORIENTATION_CLASSES = {
6+
horizontal: "pl-4",
7+
vertical: "pt-4"
8+
}
9+
10+
def initialize(**attrs)
11+
@orientation = CarouselContext.orientation || :horizontal
12+
13+
super
14+
end
15+
16+
def view_template(&)
17+
div(**attrs, &)
18+
end
19+
20+
private
21+
22+
def default_attrs
23+
{
24+
role: "group",
25+
aria_roledescription: "slide",
26+
class: ["min-w-0 shrink-0 grow-0 basis-full", ORIENTATION_CLASSES[@orientation]]
27+
}
28+
end
29+
end
30+
end

lib/ruby_ui/carousel/carousel_next.rb

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class CarouselNext < Base
5+
ORIENTATION_CLASSES = {
6+
horizontal: "-right-12 top-1/2 -translate-y-1/2",
7+
vertical: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90"
8+
}
9+
10+
def initialize(**attrs)
11+
@orientation = CarouselContext.orientation || :horizontal
12+
13+
super
14+
end
15+
16+
def view_template(&)
17+
Button(**attrs) do
18+
icon
19+
end
20+
end
21+
22+
private
23+
24+
def default_attrs
25+
{
26+
variant: :outline,
27+
icon: true,
28+
class: ["absolute h-8 w-8 rounded-full", ORIENTATION_CLASSES[@orientation]],
29+
disabled: true,
30+
data: {
31+
action: "click->ruby-ui--carousel#scrollNext",
32+
ruby_ui__carousel_target: "nextButton"
33+
}
34+
}
35+
end
36+
37+
def icon
38+
svg(
39+
width: "24",
40+
height: "24",
41+
viewBox: "0 0 24 24",
42+
fill: "none",
43+
stroke: "currentColor",
44+
stroke_width: "2",
45+
stroke_linecap: "round",
46+
stroke_linejoin: "round",
47+
xmlns: "http://www.w3.org/2000/svg",
48+
class: "w-4 h-4"
49+
) do |s|
50+
s.path(d: "M5 12h14")
51+
s.path(d: "m12 5 7 7-7 7")
52+
end
53+
end
54+
end
55+
end
+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class CarouselPrevious < Base
5+
ORIENTATION_CLASSES = {
6+
horizontal: "-left-12 top-1/2 -translate-y-1/2",
7+
vertical: "-top-12 left-1/2 -translate-x-1/2 rotate-90"
8+
}
9+
10+
def initialize(**attrs)
11+
@orientation = CarouselContext.orientation || :horizontal
12+
13+
super
14+
end
15+
16+
def view_template(&)
17+
Button(**attrs) do
18+
icon
19+
span(class: "sr-only") { "Next slide" }
20+
end
21+
end
22+
23+
private
24+
25+
def default_attrs
26+
{
27+
variant: :outline,
28+
icon: true,
29+
class: ["absolute h-8 w-8 rounded-full", ORIENTATION_CLASSES[@orientation]],
30+
disabled: true,
31+
data: {
32+
action: "click->ruby-ui--carousel#scrollPrev",
33+
ruby_ui__carousel_target: "prevButton"
34+
}
35+
}
36+
end
37+
38+
def icon
39+
svg(
40+
width: "24",
41+
height: "24",
42+
viewBox: "0 0 24 24",
43+
fill: "none",
44+
stroke: "currentColor",
45+
stroke_width: "2",
46+
stroke_linecap: "round",
47+
stroke_linejoin: "round",
48+
xmlns: "http://www.w3.org/2000/svg",
49+
class: "w-4 h-4"
50+
) do |s|
51+
s.path(d: "m12 19-7-7 7-7")
52+
s.path(d: "M19 12H5")
53+
end
54+
end
55+
end
56+
end

test/ruby_ui/carousel_test.rb

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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+
end
20+
21+
def test_render_with_vertical_orientation
22+
output = phlex do
23+
RubyUI.Carousel(orientation: :vertical) do
24+
RubyUI.CarouselContent() do
25+
RubyUI.CarouselItem() { "Item" }
26+
end
27+
RubyUI.CarouselPrevious()
28+
RubyUI.CarouselNext()
29+
end
30+
end
31+
32+
assert_match(/-mt-4 flex-col/, output)
33+
assert_match(/pt-4/, output)
34+
assert_match(/-top-12/, output)
35+
assert_match(/-bottom-12/, output)
36+
end
37+
38+
def test_sets_context_while_rendering
39+
phlex do
40+
RubyUI.Carousel(orientation: :test) do
41+
assert_equal ({orientation: :test}), Thread.current[:ruby_ui__carousel_state]
42+
end
43+
end
44+
end
45+
46+
def test_clears_context_after_render
47+
phlex do
48+
RubyUI.Carousel(orientation: :vertical) do
49+
RubyUI.CarouselContent do
50+
RubyUI.CarouselItem { "Item" }
51+
end
52+
RubyUI.CarouselPrevious()
53+
RubyUI.CarouselNext()
54+
end
55+
end
56+
57+
assert_nil Thread.current[:ruby_ui__carousel_state]
58+
end
59+
end

0 commit comments

Comments
 (0)