diff --git a/lib/generators/ruby_ui/dependencies.yml b/lib/generators/ruby_ui/dependencies.yml index 9db5eca0..31ee03a5 100644 --- a/lib/generators/ruby_ui/dependencies.yml +++ b/lib/generators/ruby_ui/dependencies.yml @@ -10,6 +10,10 @@ calendar: js_packages: - "mustache" +carousel: + js_packages: + - "embla-carousel" + chart: js_packages: - "chart.js" diff --git a/lib/ruby_ui/carousel/carousel.rb b/lib/ruby_ui/carousel/carousel.rb new file mode 100644 index 00000000..ab9ff3b6 --- /dev/null +++ b/lib/ruby_ui/carousel/carousel.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module RubyUI + class Carousel < Base + def initialize(orientation: :horizontal, options: {}, **user_attrs) + @orientation = orientation + @options = options + + super(**user_attrs) + end + + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: ["relative group", orientation_classes], + role: "region", + aria_roledescription: "carousel", + data: { + controller: "ruby-ui--carousel", + ruby_ui__carousel_options_value: default_options.merge(@options).to_json, + action: %w[ + keydown.right->ruby-ui--carousel#scrollNext:prevent + keydown.left->ruby-ui--carousel#scrollPrev:prevent + ] + } + } + end + + def default_options + { + axis: (@orientation == :horizontal) ? "x" : "y" + } + end + + def orientation_classes + (@orientation == :horizontal) ? "is-horizontal" : "is-vertical" + end + end +end diff --git a/lib/ruby_ui/carousel/carousel_content.rb b/lib/ruby_ui/carousel/carousel_content.rb new file mode 100644 index 00000000..1bbbc530 --- /dev/null +++ b/lib/ruby_ui/carousel/carousel_content.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module RubyUI + class CarouselContent < Base + def view_template(&) + div(class: "overflow-hidden", data: {ruby_ui__carousel_target: "viewport"}) do + div(**attrs, &) + end + end + + private + + def default_attrs + { + class: [ + "flex", + "group-[.is-horizontal]:-ml-4", + "group-[.is-vertical]:-mt-4 group-[.is-vertical]:flex-col" + ] + } + end + end +end diff --git a/lib/ruby_ui/carousel/carousel_controller.js b/lib/ruby_ui/carousel/carousel_controller.js new file mode 100644 index 00000000..cc7402c5 --- /dev/null +++ b/lib/ruby_ui/carousel/carousel_controller.js @@ -0,0 +1,60 @@ +import { Controller } from "@hotwired/stimulus"; +import EmblaCarousel from 'embla-carousel' + +const DEFAULT_OPTIONS = { + loop: true +} + +export default class extends Controller { + static values = { + options: { + type: Object, + default: {}, + } + } + static targets = ["viewport", "nextButton", "prevButton"] + + connect() { + this.initCarousel(this.#mergedOptions) + } + + disconnect() { + this.destroyCarousel() + } + + initCarousel(options, plugins = []) { + this.carousel = EmblaCarousel(this.viewportTarget, options, plugins) + + this.carousel.on("init", this.#updateControls.bind(this)) + this.carousel.on("reInit", this.#updateControls.bind(this)) + this.carousel.on("select", this.#updateControls.bind(this)) + } + + destroyCarousel() { + this.carousel.destroy() + } + + scrollNext() { + this.carousel.scrollNext() + } + + scrollPrev() { + this.carousel.scrollPrev() + } + + #updateControls() { + this.#toggleButtonsDisabledState(this.nextButtonTargets, !this.carousel.canScrollNext()) + this.#toggleButtonsDisabledState(this.prevButtonTargets, !this.carousel.canScrollPrev()) + } + + #toggleButtonsDisabledState(buttons, isDisabled) { + buttons.forEach((button) => button.disabled = isDisabled) + } + + get #mergedOptions() { + return { + ...DEFAULT_OPTIONS, + ...this.optionsValue + } + } +} diff --git a/lib/ruby_ui/carousel/carousel_item.rb b/lib/ruby_ui/carousel/carousel_item.rb new file mode 100644 index 00000000..138a44f3 --- /dev/null +++ b/lib/ruby_ui/carousel/carousel_item.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module RubyUI + class CarouselItem < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + role: "group", + aria_roledescription: "slide", + class: [ + "min-w-0 shrink-0 grow-0 basis-full", + "group-[.is-horizontal]:pl-4", + "group-[.is-vertical]:pt-4" + ] + } + end + end +end diff --git a/lib/ruby_ui/carousel/carousel_next.rb b/lib/ruby_ui/carousel/carousel_next.rb new file mode 100644 index 00000000..e903bbae --- /dev/null +++ b/lib/ruby_ui/carousel/carousel_next.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module RubyUI + class CarouselNext < Base + def view_template(&) + Button(**attrs) do + icon + end + end + + private + + def default_attrs + { + variant: :outline, + icon: true, + class: [ + "absolute h-8 w-8 rounded-full", + "group-[.is-horizontal]:-right-12 group-[.is-horizontal]:top-1/2 group-[.is-horizontal]:-translate-y-1/2", + "group-[.is-vertical]:-bottom-12 group-[.is-vertical]:left-1/2 group-[.is-vertical]:-translate-x-1/2 group-[.is-vertical]:rotate-90" + ], + disabled: true, + data: { + action: "click->ruby-ui--carousel#scrollNext", + ruby_ui__carousel_target: "nextButton" + } + } + end + + def icon + svg( + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + xmlns: "http://www.w3.org/2000/svg", + class: "w-4 h-4" + ) do |s| + s.path(d: "M5 12h14") + s.path(d: "m12 5 7 7-7 7") + end + end + end +end diff --git a/lib/ruby_ui/carousel/carousel_previous.rb b/lib/ruby_ui/carousel/carousel_previous.rb new file mode 100644 index 00000000..166379ea --- /dev/null +++ b/lib/ruby_ui/carousel/carousel_previous.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module RubyUI + class CarouselPrevious < Base + def view_template(&) + Button(**attrs) do + icon + span(class: "sr-only") { "Next slide" } + end + end + + private + + def default_attrs + { + variant: :outline, + icon: true, + class: [ + "absolute h-8 w-8 rounded-full", + "group-[.is-horizontal]:-left-12 group-[.is-horizontal]:top-1/2 group-[.is-horizontal]:-translate-y-1/2", + "group-[.is-vertical]:-top-12 group-[.is-vertical]:left-1/2 group-[.is-vertical]:-translate-x-1/2 group-[.is-vertical]:rotate-90" + ], + disabled: true, + data: { + action: "click->ruby-ui--carousel#scrollPrev", + ruby_ui__carousel_target: "prevButton" + } + } + end + + def icon + svg( + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + xmlns: "http://www.w3.org/2000/svg", + class: "w-4 h-4" + ) do |s| + s.path(d: "m12 19-7-7 7-7") + s.path(d: "M19 12H5") + end + end + end +end diff --git a/package.json b/package.json index b9f31a4f..53028e7a 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@hotwired/stimulus": "^3.2.2", "chart.js": "^4.4.1", "date-fns": "^2.30.0", + "embla-carousel": "8.5.2", "fuse.js": "^7.0.0", "maska": "^3.0.3", "motion": "^10.16.4", diff --git a/test/ruby_ui/carousel_test.rb b/test/ruby_ui/carousel_test.rb new file mode 100644 index 00000000..889395e2 --- /dev/null +++ b/test/ruby_ui/carousel_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::CarouselTest < ComponentTest + def test_render_with_all_items + output = phlex do + RubyUI.Carousel do + RubyUI.CarouselContent do + RubyUI.CarouselItem { "Item" } + end + RubyUI.CarouselPrevious() + RubyUI.CarouselNext() + end + end + + assert_match(/Item/, output) + assert_match(/button/, output) + assert_match(/ is-horizontal/, output) + end + + def test_render_with_horizontal_orientation + output = phlex do + RubyUI.Carousel(orientation: :horizontal) do + RubyUI.CarouselContent() do + RubyUI.CarouselItem() { "Item" } + end + RubyUI.CarouselPrevious() + RubyUI.CarouselNext() + end + end + + assert_match(/ is-horizontal/, output) + end + + def test_render_with_vertical_orientation + output = phlex do + RubyUI.Carousel(orientation: :vertical) do + RubyUI.CarouselContent() do + RubyUI.CarouselItem() { "Item" } + end + RubyUI.CarouselPrevious() + RubyUI.CarouselNext() + end + end + + assert_match(/ is-vertical/, output) + end +end diff --git a/yarn.lock b/yarn.lock index 4a1b700a..8e213b76 100644 --- a/yarn.lock +++ b/yarn.lock @@ -111,6 +111,11 @@ date-fns@^2.30.0: dependencies: "@babel/runtime" "^7.21.0" +embla-carousel@8.5.2: + version "8.5.2" + resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-8.5.2.tgz#95eb936d14a1b9a67b9207a0fde1f25259a5d692" + integrity sha512-xQ9oVLrun/eCG/7ru3R+I5bJ7shsD8fFwLEY7yPe27/+fDHCNj0OT5EoG5ZbFyOxOcG6yTwW8oTz/dWyFnyGpg== + fuse.js@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.0.0.tgz#6573c9fcd4c8268e403b4fc7d7131ffcf99a9eb2"