Howdy

Carousel

Carousels: slides side by side, one in view at a time, with previous and next buttons.

componentimport howdy/ui/carouselSince Howdy 2.0
gleam run -m howdy/ui add carousel

Carousel

ui.carousel("example-carousel", label: "Highlights", attributes: [], slides: [
  ui.carousel_slide([
    ui.card([], [ui.h3("Live views"), ui.muted("State on the server.")]),
  ]),
  ui.carousel_slide([
    ui.card([], [ui.h3("Themes"), ui.muted("Records, not class names.")]),
  ]),
  ui.carousel_slide([
    ui.card([], [ui.h3("Copies"), ui.muted("Components you own.")]),
  ]),
])

Vertical, looping

carousel_module.styled(
  "example-vertical-carousel",
  label: "Quotes",
  orientation: carousel_module.Vertical,
  looping: True,
  attributes: [attribute.style("height", "10rem")],
  slides: [
    ui.carousel_slide([ui.card([], [ui.p([text("“Fast to build.”")])])]),
    ui.carousel_slide([ui.card([], [ui.p([text("“Easy to own.”")])])]),
    ui.carousel_slide([ui.card([], [ui.p([text("“Works without JS.”")])])]),
  ],
)

Playing by itself

New themes are out.

Drawers snap now.

Say hello in RTL.

carousel_module.styled(
  "example-autoplay",
  label: "Announcements",
  orientation: carousel_module.Horizontal,
  looping: True,
  attributes: [carousel_module.autoplay(3000)],
  slides: [
    ui.carousel_slide([ui.card([], [ui.p([text("New themes are out.")])])]),
    ui.carousel_slide([ui.card([], [ui.p([text("Drawers snap now.")])])]),
    ui.carousel_slide([ui.card([], [ui.p([text("Say hello in RTL.")])])]),
  ],
)
Full source of howdy/ui/carousel
//// Carousels: slides side by side, one in view at a time, with previous
//// and next buttons.
////
//// ```gleam
//// carousel.carousel("tour", label: "Product tour", attributes: [], slides: [
////   carousel.slide([ui.card([], [text("One")])]),
////   carousel.slide([ui.card([], [text("Two")])]),
//// ])
//// ```
////
//// The slides scroll natively, snapping to each one, so swiping, a
//// trackpad and the keyboard all work; the buttons scroll by one slide.
//// Each slide is announced as "slide 2 of 5". Nothing moves on its own.
////
//// `styled` stacks the slides vertically, or loops from the last slide
//// back to the first. When the slide in view changes, the carousel fires a
//// `howdy-slide` event with the slide's index; a live view hears it with
//// `on_change`.
////
//// With `autoplay` in its attributes, a carousel moves on by itself,
//// wrapping round at the end. It waits while the pointer is over it or
//// focus is in it and while the page is hidden, and it has a pause
//// button. For people who ask for reduced motion it starts paused.

import gleam/dynamic/decode
import gleam/int
import gleam/list
import howdy/ui/style.{class}
import howdy/ui/theme/tokens
import lustre/attribute.{type Attribute}
import lustre/element.{type Element, text}
import lustre/element/html
import lustre/event
import lustre/server_component
import sketch/css.{type Class}
import sketch/css/length.{rem}

pub opaque type Slide(msg) {
  Slide(children: List(Element(msg)))
}

pub fn slide(children: List(Element(msg))) -> Slide(msg) {
  Slide(children)
}

pub type Orientation {
  Horizontal
  /// Slides stacked, scrolling up and down. Give the carousel a height.
  Vertical
}

pub fn carousel(
  id: String,
  label label: String,
  attributes attributes: List(Attribute(msg)),
  slides slides: List(Slide(msg)),
) -> Element(msg) {
  styled(
    id,
    label:,
    orientation: Horizontal,
    looping: False,
    attributes:,
    slides:,
  )
}

/// A carousel laid out another way. With `looping`, the next button on the
/// last slide goes back to the first, and previous on the first to the
/// last.
pub fn styled(
  id: String,
  label label: String,
  orientation orientation: Orientation,
  looping looping: Bool,
  attributes attributes: List(Attribute(msg)),
  slides slides: List(Slide(msg)),
) -> Element(msg) {
  let count = int.to_string(list.length(slides))
  let viewport = id <> "-slides"
  let looping = case looping {
    True -> [attribute.data("loop", "")]
    False -> []
  }
  let #(orientation_name, previous, next) = case orientation {
    Horizontal -> #("horizontal", "Previous slide", "Next slide")
    Vertical -> #("vertical", "Previous slide", "Next slide")
  }
  html.section(
    [
      class(carousel_class(orientation)),
      attribute.data("orientation", orientation_name),
      attribute.data("index", "0"),
      attribute.id(id),
      attribute.aria_label(label),
      attribute.attribute("aria-roledescription", "carousel"),
      attribute.data("howdy-carousel", ""),
      ..list.append(looping, attributes)
    ],
    [
      html.div(
        [
          class(viewport_class(orientation)),
          attribute.id(viewport),
          attribute.tabindex(0),
        ],
        list.index_map(slides, fn(slide, index) {
          html.div(
            [
              class(slide_class()),
              attribute.role("group"),
              attribute.attribute("aria-roledescription", "slide"),
              attribute.aria_label(int.to_string(index + 1) <> " of " <> count),
            ],
            slide.children,
          )
        }),
      ),
      html.div([class(controls_class())], [
        // Shown only when the carousel plays by itself.
        html.button(
          [
            class(play_class()),
            attribute.type_("button"),
            attribute.aria_label("Pause slides"),
            attribute.aria_controls(viewport),
            attribute.data("howdy-carousel-play", ""),
          ],
          // Drawn by the stylesheet: two bars, or a triangle once paused.
          [html.span([attribute.aria_hidden(True)], [])],
        ),
        control("previous", previous, "‹", viewport),
        control("next", next, "›", viewport),
      ]),
    ],
  )
}

fn control(
  which: String,
  label: String,
  symbol: String,
  viewport: String,
) -> Element(msg) {
  html.button(
    [
      class(control_class()),
      attribute.type_("button"),
      attribute.aria_label(label),
      attribute.aria_controls(viewport),
      attribute.data("howdy-carousel-" <> which, ""),
    ],
    [html.span([attribute.aria_hidden(True)], [text(symbol)])],
  )
}

/// Move to the next slide every `milliseconds`. Put it in the carousel's
/// attributes.
pub fn autoplay(milliseconds: Int) -> Attribute(msg) {
  attribute.data("autoplay", int.to_string(int.max(milliseconds, 1000)))
}

/// Hear which slide is in view, counting from 0, in a live view.
pub fn on_change(message: fn(Int) -> msg) -> Attribute(msg) {
  event.on("howdy-slide", {
    use index <- decode.subfield(["detail", "index"], decode.int)
    decode.success(message(index))
  })
  |> server_component.include(["detail.index"])
}

/// Every class this module uses, for `howdy/ui/export`.
pub fn classes() -> List(Class) {
  [
    carousel_class(Horizontal),
    carousel_class(Vertical),
    viewport_class(Horizontal),
    viewport_class(Vertical),
    slide_class(),
    controls_class(),
    control_class(),
    play_class(),
  ]
}

pub fn carousel_class(orientation: Orientation) -> Class {
  let arrows = case orientation {
    Horizontal -> []
    // The buttons' arrows turn to point up and down.
    Vertical -> [
      css.selector(" [data-howdy-carousel-previous] > span", [
        css.display("inline-block"),
        css.transform_("rotate(90deg)"),
      ]),
      css.selector(" [data-howdy-carousel-next] > span", [
        css.display("inline-block"),
        css.transform_("rotate(90deg)"),
      ]),
    ]
  }
  css.class([
    css.display("flex"),
    css.flex_direction("column"),
    css.gap(rem(0.75)),
    css.selector(" [data-howdy-carousel-play] > span", [
      css.display("block"),
      css.property("width", "0.625rem"),
      css.property("height", "0.75rem"),
      css.property("border-inline", "3px solid currentColor"),
    ]),
    css.selector("[data-paused] [data-howdy-carousel-play] > span", [
      css.property("width", "0"),
      css.property("height", "0"),
      css.property("border-inline", "0"),
      css.property("border-top", "0.375rem solid transparent"),
      css.property("border-bottom", "0.375rem solid transparent"),
      css.property("border-left", "0.625rem solid currentColor"),
      css.property("margin-left", "0.125rem"),
    ]),
    ..arrows
  ])
}

pub fn viewport_class(orientation: Orientation) -> Class {
  let axis = case orientation {
    Horizontal -> [
      css.flex_direction("row"),
      css.overflow_x("auto"),
      css.property("scroll-snap-type", "x mandatory"),
      css.property("overscroll-behavior-x", "contain"),
    ]
    Vertical -> [
      css.flex_direction("column"),
      css.property("flex", "1"),
      css.property("min-height", "0"),
      css.overflow_y("auto"),
      css.property("scroll-snap-type", "y mandatory"),
      css.property("overscroll-behavior-y", "contain"),
    ]
  }
  css.class(list.append(
    [
      css.display("flex"),
      css.gap(rem(1.0)),
      css.property("scrollbar-width", "none"),
      style.radius(tokens.radius_medium),
      style.focus_ring(),
    ],
    axis,
  ))
}

pub fn slide_class() -> Class {
  css.class([
    css.property("flex", "0 0 100%"),
    css.property("scroll-snap-stop", "always"),
    css.property("scroll-snap-align", "start"),
    css.property("min-width", "0"),
  ])
}

pub fn controls_class() -> Class {
  css.class([
    css.display("flex"),
    css.justify_content("flex-end"),
    css.gap(rem(0.5)),
  ])
}

pub fn control_class() -> Class {
  css.class([
    // Arrows point the way the text runs.
    css.selector(":dir(rtl) > span", [
      css.display("inline-block"),
      css.transform_("scaleX(-1)"),
    ]),
    css.display("inline-flex"),
    css.align_items("center"),
    css.justify_content("center"),
    css.property("width", "2.25rem"),
    css.property("height", "2.25rem"),
    css.padding(rem(0.0)),
    style.bordered(),
    style.pill(),
    css.background(tokens.surface),
    css.color(tokens.text),
    css.font_size(rem(1.25)),
    css.line_height("1"),
    css.cursor("pointer"),
    css.hover([css.background(tokens.muted)]),
    style.focus_ring(),
  ])
}

pub fn play_class() -> Class {
  css.class([
    css.display("inline-flex"),
    css.align_items("center"),
    css.justify_content("center"),
    css.property("width", "2.25rem"),
    css.property("height", "2.25rem"),
    css.property("margin-inline-end", "auto"),
    css.padding(rem(0.0)),
    style.bordered(),
    style.pill(),
    css.background(tokens.surface),
    css.color(tokens.text),
    css.font_size(rem(0.75)),
    css.line_height("1"),
    css.cursor("pointer"),
    css.hover([css.background(tokens.muted)]),
    style.focus_ring(),
    css.selector(":not([data-howdy-carousel][data-autoplay] *)", [
      css.display("none"),
    ]),
  ])
}