From 111c050981c2ec8d30f7006a0adc44130f954fe5 Mon Sep 17 00:00:00 2001 From: Lucas Sousa Date: Thu, 30 Jan 2025 00:53:48 -0300 Subject: [PATCH 1/4] Add embla package --- package.json | 1 + yarn.lock | 5 +++++ 2 files changed, 6 insertions(+) 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/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" From 002845b6d45677b509277e3fbdc7c5294979d6c4 Mon Sep 17 00:00:00 2001 From: Lucas Sousa Date: Thu, 30 Jan 2025 00:54:06 -0300 Subject: [PATCH 2/4] Add Carousel component --- lib/generators/ruby_ui/dependencies.yml | 4 ++ lib/ruby_ui/carousel/carousel.rb | 38 ++++++++++++ lib/ruby_ui/carousel/carousel_content.rb | 30 ++++++++++ lib/ruby_ui/carousel/carousel_context.rb | 20 +++++++ lib/ruby_ui/carousel/carousel_controller.js | 65 +++++++++++++++++++++ lib/ruby_ui/carousel/carousel_item.rb | 30 ++++++++++ lib/ruby_ui/carousel/carousel_next.rb | 55 +++++++++++++++++ lib/ruby_ui/carousel/carousel_previous.rb | 56 ++++++++++++++++++ test/ruby_ui/carousel_test.rb | 59 +++++++++++++++++++ 9 files changed, 357 insertions(+) create mode 100644 lib/ruby_ui/carousel/carousel.rb create mode 100644 lib/ruby_ui/carousel/carousel_content.rb create mode 100644 lib/ruby_ui/carousel/carousel_context.rb create mode 100644 lib/ruby_ui/carousel/carousel_controller.js create mode 100644 lib/ruby_ui/carousel/carousel_item.rb create mode 100644 lib/ruby_ui/carousel/carousel_next.rb create mode 100644 lib/ruby_ui/carousel/carousel_previous.rb create mode 100644 test/ruby_ui/carousel_test.rb 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..db43c1c5 --- /dev/null +++ b/lib/ruby_ui/carousel/carousel.rb @@ -0,0 +1,38 @@ +# 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(&) + CarouselContext.with_context(orientation: @orientation) do + div(**attrs, &) + end + end + + private + + def default_attrs + { + class: "relative", + role: "region", + aria_roledescription: "carousel", + data: { + controller: "ruby-ui--carousel", + ruby_ui__carousel_options_value: default_options.merge(@options).to_json + } + } + end + + def default_options + { + axis: (@orientation == :horizontal) ? "x" : "y" + } + 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..8629672d --- /dev/null +++ b/lib/ruby_ui/carousel/carousel_content.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module RubyUI + class CarouselContent < Base + ORIENTATION_CLASSES = { + horizontal: "-ml-4", + vertical: "-mt-4 flex-col" + } + + def initialize(**attrs) + @orientation = CarouselContext.orientation || :horizontal + + super + end + + def view_template(&) + div(class: "overflow-hidden", data: {ruby_ui__carousel_target: "viewport"}) do + div(**attrs, &) + end + end + + private + + def default_attrs + { + class: ["flex", ORIENTATION_CLASSES[@orientation]] + } + end + end +end diff --git a/lib/ruby_ui/carousel/carousel_context.rb b/lib/ruby_ui/carousel/carousel_context.rb new file mode 100644 index 00000000..663fb126 --- /dev/null +++ b/lib/ruby_ui/carousel/carousel_context.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module RubyUI + class CarouselContext + def self.with_context(**state) + Thread.current[:ruby_ui__carousel_state] = state + yield + ensure + Thread.current[:ruby_ui__carousel_state] = nil + end + + def self.state + Thread.current[:ruby_ui__carousel_state] || {} + end + + def self.orientation + state[:orientation] + 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..6f938f31 --- /dev/null +++ b/lib/ruby_ui/carousel/carousel_controller.js @@ -0,0 +1,65 @@ +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.#handleSelect.bind(this)) + } + + destroyCarousel() { + this.carousel.destroy() + } + + scrollNext() { + this.carousel.scrollNext() + } + + scrollPrev() { + this.carousel.scrollPrev() + } + + #handleSelect() { + this.#updateControls() + this.dispatch("select", { detail: { carousel: this.carousel } }) + } + + #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..baa9b55a --- /dev/null +++ b/lib/ruby_ui/carousel/carousel_item.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module RubyUI + class CarouselItem < Base + ORIENTATION_CLASSES = { + horizontal: "pl-4", + vertical: "pt-4" + } + + def initialize(**attrs) + @orientation = CarouselContext.orientation || :horizontal + + super + end + + 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", ORIENTATION_CLASSES[@orientation]] + } + 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..b26cbb8f --- /dev/null +++ b/lib/ruby_ui/carousel/carousel_next.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module RubyUI + class CarouselNext < Base + ORIENTATION_CLASSES = { + horizontal: "-right-12 top-1/2 -translate-y-1/2", + vertical: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90" + } + + def initialize(**attrs) + @orientation = CarouselContext.orientation || :horizontal + + super + end + + 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", ORIENTATION_CLASSES[@orientation]], + 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..229fc3fc --- /dev/null +++ b/lib/ruby_ui/carousel/carousel_previous.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module RubyUI + class CarouselPrevious < Base + ORIENTATION_CLASSES = { + horizontal: "-left-12 top-1/2 -translate-y-1/2", + vertical: "-top-12 left-1/2 -translate-x-1/2 rotate-90" + } + + def initialize(**attrs) + @orientation = CarouselContext.orientation || :horizontal + + super + end + + 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", ORIENTATION_CLASSES[@orientation]], + 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/test/ruby_ui/carousel_test.rb b/test/ruby_ui/carousel_test.rb new file mode 100644 index 00000000..c298ff1c --- /dev/null +++ b/test/ruby_ui/carousel_test.rb @@ -0,0 +1,59 @@ +# 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) + 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(/-mt-4 flex-col/, output) + assert_match(/pt-4/, output) + assert_match(/-top-12/, output) + assert_match(/-bottom-12/, output) + end + + def test_sets_context_while_rendering + phlex do + RubyUI.Carousel(orientation: :test) do + assert_equal ({orientation: :test}), Thread.current[:ruby_ui__carousel_state] + end + end + end + + def test_clears_context_after_render + phlex do + RubyUI.Carousel(orientation: :vertical) do + RubyUI.CarouselContent do + RubyUI.CarouselItem { "Item" } + end + RubyUI.CarouselPrevious() + RubyUI.CarouselNext() + end + end + + assert_nil Thread.current[:ruby_ui__carousel_state] + end +end From 754ae6b6287c0d6c6f9e7647267e113df2f4ce7c Mon Sep 17 00:00:00 2001 From: Lucas Sousa Date: Mon, 17 Feb 2025 19:16:46 -0300 Subject: [PATCH 3/4] Replace CarouselContext with Tailwind group --- lib/ruby_ui/carousel/carousel.rb | 10 +++++--- lib/ruby_ui/carousel/carousel_content.rb | 17 ++++--------- lib/ruby_ui/carousel/carousel_context.rb | 20 --------------- lib/ruby_ui/carousel/carousel_controller.js | 7 +----- lib/ruby_ui/carousel/carousel_item.rb | 17 ++++--------- lib/ruby_ui/carousel/carousel_next.rb | 17 ++++--------- lib/ruby_ui/carousel/carousel_previous.rb | 17 ++++--------- test/ruby_ui/carousel_test.rb | 28 +++++++-------------- 8 files changed, 36 insertions(+), 97 deletions(-) delete mode 100644 lib/ruby_ui/carousel/carousel_context.rb diff --git a/lib/ruby_ui/carousel/carousel.rb b/lib/ruby_ui/carousel/carousel.rb index db43c1c5..8ffbde66 100644 --- a/lib/ruby_ui/carousel/carousel.rb +++ b/lib/ruby_ui/carousel/carousel.rb @@ -10,16 +10,14 @@ def initialize(orientation: :horizontal, options: {}, **user_attrs) end def view_template(&) - CarouselContext.with_context(orientation: @orientation) do - div(**attrs, &) - end + div(**attrs, &) end private def default_attrs { - class: "relative", + class: ["relative group", orientation_classes], role: "region", aria_roledescription: "carousel", data: { @@ -34,5 +32,9 @@ 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 index 8629672d..1bbbc530 100644 --- a/lib/ruby_ui/carousel/carousel_content.rb +++ b/lib/ruby_ui/carousel/carousel_content.rb @@ -2,17 +2,6 @@ module RubyUI class CarouselContent < Base - ORIENTATION_CLASSES = { - horizontal: "-ml-4", - vertical: "-mt-4 flex-col" - } - - def initialize(**attrs) - @orientation = CarouselContext.orientation || :horizontal - - super - end - def view_template(&) div(class: "overflow-hidden", data: {ruby_ui__carousel_target: "viewport"}) do div(**attrs, &) @@ -23,7 +12,11 @@ def view_template(&) def default_attrs { - class: ["flex", ORIENTATION_CLASSES[@orientation]] + class: [ + "flex", + "group-[.is-horizontal]:-ml-4", + "group-[.is-vertical]:-mt-4 group-[.is-vertical]:flex-col" + ] } end end diff --git a/lib/ruby_ui/carousel/carousel_context.rb b/lib/ruby_ui/carousel/carousel_context.rb deleted file mode 100644 index 663fb126..00000000 --- a/lib/ruby_ui/carousel/carousel_context.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module RubyUI - class CarouselContext - def self.with_context(**state) - Thread.current[:ruby_ui__carousel_state] = state - yield - ensure - Thread.current[:ruby_ui__carousel_state] = nil - end - - def self.state - Thread.current[:ruby_ui__carousel_state] || {} - end - - def self.orientation - state[:orientation] - end - end -end diff --git a/lib/ruby_ui/carousel/carousel_controller.js b/lib/ruby_ui/carousel/carousel_controller.js index 6f938f31..cc7402c5 100644 --- a/lib/ruby_ui/carousel/carousel_controller.js +++ b/lib/ruby_ui/carousel/carousel_controller.js @@ -27,7 +27,7 @@ export default class extends Controller { this.carousel.on("init", this.#updateControls.bind(this)) this.carousel.on("reInit", this.#updateControls.bind(this)) - this.carousel.on("select", this.#handleSelect.bind(this)) + this.carousel.on("select", this.#updateControls.bind(this)) } destroyCarousel() { @@ -42,11 +42,6 @@ export default class extends Controller { this.carousel.scrollPrev() } - #handleSelect() { - this.#updateControls() - this.dispatch("select", { detail: { carousel: this.carousel } }) - } - #updateControls() { this.#toggleButtonsDisabledState(this.nextButtonTargets, !this.carousel.canScrollNext()) this.#toggleButtonsDisabledState(this.prevButtonTargets, !this.carousel.canScrollPrev()) diff --git a/lib/ruby_ui/carousel/carousel_item.rb b/lib/ruby_ui/carousel/carousel_item.rb index baa9b55a..138a44f3 100644 --- a/lib/ruby_ui/carousel/carousel_item.rb +++ b/lib/ruby_ui/carousel/carousel_item.rb @@ -2,17 +2,6 @@ module RubyUI class CarouselItem < Base - ORIENTATION_CLASSES = { - horizontal: "pl-4", - vertical: "pt-4" - } - - def initialize(**attrs) - @orientation = CarouselContext.orientation || :horizontal - - super - end - def view_template(&) div(**attrs, &) end @@ -23,7 +12,11 @@ def default_attrs { role: "group", aria_roledescription: "slide", - class: ["min-w-0 shrink-0 grow-0 basis-full", ORIENTATION_CLASSES[@orientation]] + class: [ + "min-w-0 shrink-0 grow-0 basis-full", + "group-[.is-horizontal]:pl-4", + "group-[.is-vertical]:pt-4" + ] } end end diff --git a/lib/ruby_ui/carousel/carousel_next.rb b/lib/ruby_ui/carousel/carousel_next.rb index b26cbb8f..e903bbae 100644 --- a/lib/ruby_ui/carousel/carousel_next.rb +++ b/lib/ruby_ui/carousel/carousel_next.rb @@ -2,17 +2,6 @@ module RubyUI class CarouselNext < Base - ORIENTATION_CLASSES = { - horizontal: "-right-12 top-1/2 -translate-y-1/2", - vertical: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90" - } - - def initialize(**attrs) - @orientation = CarouselContext.orientation || :horizontal - - super - end - def view_template(&) Button(**attrs) do icon @@ -25,7 +14,11 @@ def default_attrs { variant: :outline, icon: true, - class: ["absolute h-8 w-8 rounded-full", ORIENTATION_CLASSES[@orientation]], + 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", diff --git a/lib/ruby_ui/carousel/carousel_previous.rb b/lib/ruby_ui/carousel/carousel_previous.rb index 229fc3fc..166379ea 100644 --- a/lib/ruby_ui/carousel/carousel_previous.rb +++ b/lib/ruby_ui/carousel/carousel_previous.rb @@ -2,17 +2,6 @@ module RubyUI class CarouselPrevious < Base - ORIENTATION_CLASSES = { - horizontal: "-left-12 top-1/2 -translate-y-1/2", - vertical: "-top-12 left-1/2 -translate-x-1/2 rotate-90" - } - - def initialize(**attrs) - @orientation = CarouselContext.orientation || :horizontal - - super - end - def view_template(&) Button(**attrs) do icon @@ -26,7 +15,11 @@ def default_attrs { variant: :outline, icon: true, - class: ["absolute h-8 w-8 rounded-full", ORIENTATION_CLASSES[@orientation]], + 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", diff --git a/test/ruby_ui/carousel_test.rb b/test/ruby_ui/carousel_test.rb index c298ff1c..889395e2 100644 --- a/test/ruby_ui/carousel_test.rb +++ b/test/ruby_ui/carousel_test.rb @@ -16,11 +16,12 @@ def test_render_with_all_items assert_match(/Item/, output) assert_match(/button/, output) + assert_match(/ is-horizontal/, output) end - def test_render_with_vertical_orientation + def test_render_with_horizontal_orientation output = phlex do - RubyUI.Carousel(orientation: :vertical) do + RubyUI.Carousel(orientation: :horizontal) do RubyUI.CarouselContent() do RubyUI.CarouselItem() { "Item" } end @@ -29,31 +30,20 @@ def test_render_with_vertical_orientation end end - assert_match(/-mt-4 flex-col/, output) - assert_match(/pt-4/, output) - assert_match(/-top-12/, output) - assert_match(/-bottom-12/, output) - end - - def test_sets_context_while_rendering - phlex do - RubyUI.Carousel(orientation: :test) do - assert_equal ({orientation: :test}), Thread.current[:ruby_ui__carousel_state] - end - end + assert_match(/ is-horizontal/, output) end - def test_clears_context_after_render - phlex do + def test_render_with_vertical_orientation + output = phlex do RubyUI.Carousel(orientation: :vertical) do - RubyUI.CarouselContent do - RubyUI.CarouselItem { "Item" } + RubyUI.CarouselContent() do + RubyUI.CarouselItem() { "Item" } end RubyUI.CarouselPrevious() RubyUI.CarouselNext() end end - assert_nil Thread.current[:ruby_ui__carousel_state] + assert_match(/ is-vertical/, output) end end From 1dcd8c9c025d6b9994573d80eea3d90ee691cc66 Mon Sep 17 00:00:00 2001 From: Lucas Sousa Date: Mon, 17 Feb 2025 19:27:48 -0300 Subject: [PATCH 4/4] Add keyboard interactions --- lib/ruby_ui/carousel/carousel.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/ruby_ui/carousel/carousel.rb b/lib/ruby_ui/carousel/carousel.rb index 8ffbde66..ab9ff3b6 100644 --- a/lib/ruby_ui/carousel/carousel.rb +++ b/lib/ruby_ui/carousel/carousel.rb @@ -22,7 +22,11 @@ def default_attrs aria_roledescription: "carousel", data: { controller: "ruby-ui--carousel", - ruby_ui__carousel_options_value: default_options.merge(@options).to_json + 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