Howdy

Sidebar

An application layout with a sidebar for navigation.

componentimport howdy/ui/sidebarSince Howdy 2.0
gleam run -m howdy/ui add sidebar

Sidebar

ui.sidebar_layout(
  collapsed: False,
  attributes: [attribute.style("min-height", "16rem")],
  sidebar: ui.sidebar("example-sidebar", [], [
    ui.sidebar_header([text("Acme")]),
    ui.sidebar_content([
      ui.sidebar_group("Workspace", [
        ui.sidebar_link("#", active: True, attributes: [], children: [
          text("Home"),
        ]),
        ui.sidebar_link("#", active: False, attributes: [], children: [
          text("Orders"),
        ]),
      ]),
    ]),
  ]),
  main: [
    ui.sized_button(
      Ghost,
      Icon,
      [
        attribute.aria_label("Toggle sidebar"),
        ..ui.sidebar_trigger("example-sidebar")
      ],
      [text("☰")],
    ),
  ],
)
Full source of howdy/ui/sidebar
//// An application layout with a sidebar for navigation.
////
//// ```gleam
//// use state <- cookie.string_or(ctx, "sidebar", default: "expanded")
////
//// sidebar.layout(collapsed: state == "collapsed", attributes: [], sidebar:
////   sidebar.sidebar("nav", [], [
////     sidebar.header([text("Acme")]),
////     sidebar.content([
////       sidebar.group("Workspace", [
////         sidebar.link("/", active: True, attributes: [], children: [text("Home")]),
////         sidebar.link("/orders", active: False, attributes: [], children: [text("Orders")]),
////       ]),
////     ]),
////     sidebar.footer([text("ada@example.com")]),
////   ]),
//// main: [
////   ui.sized_button(Ghost, Icon, [attribute.aria_label("Toggle sidebar"), ..sidebar.trigger("nav")], [menu_icon]),
////   page_content,
//// ])
//// ```
////
//// On screens at least 48rem wide the sidebar is a column beside the
//// content, and the trigger collapses and expands it. The choice is kept
//// in a `sidebar` cookie holding `collapsed` or `expanded`, so read it and
//// pass `collapsed` to render the next page the same way. On narrower
//// screens the sidebar is hidden and the trigger opens it over the page,
//// where Escape or a click outside closes it.
////
//// The trigger and the sidebar are tied by id, so both must be in the
//// page or both in one live view.

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 sketch/css.{type Class}
import sketch/css/length.{px, rem}
import sketch/css/media

/// The whole screen: the sidebar and the main content beside it.
pub fn layout(
  collapsed collapsed: Bool,
  attributes attributes: List(Attribute(msg)),
  sidebar sidebar: Element(msg),
  main main: List(Element(msg)),
) -> Element(msg) {
  styled_layout(
    collapsed:,
    collapse: Offcanvas,
    variant: Plain,
    attributes:,
    sidebar:,
    main:,
  )
}

/// What collapsing does on a wide screen.
pub type Collapse {
  /// The sidebar goes away entirely.
  Offcanvas
  /// The sidebar narrows to a rail of icons; labels are hidden but still
  /// read by screen readers, and shown as a tooltip on hover. Give links
  /// icons with `icon_link`.
  Rail
}

/// How the sidebar sits beside the page.
pub type Variant {
  /// Flush with the page, with a line between them.
  Plain
  /// A card floating over the page's edge.
  Floating
  /// The page is a card set into the sidebar's background.
  Inset
}

/// A layout that collapses another way, or is drawn another way.
pub fn styled_layout(
  collapsed collapsed: Bool,
  collapse collapse: Collapse,
  variant variant: Variant,
  attributes attributes: List(Attribute(msg)),
  sidebar sidebar: Element(msg),
  main main: List(Element(msg)),
) -> Element(msg) {
  let state = case collapsed {
    True -> "collapsed"
    False -> "expanded"
  }
  let collapse = case collapse {
    Offcanvas -> "offcanvas"
    Rail -> "rail"
  }
  let variant = case variant {
    Plain -> "plain"
    Floating -> "floating"
    Inset -> "inset"
  }
  html.div(
    [
      class(layout_class()),
      attribute.data("howdy-sidebar-layout", ""),
      attribute.data("state", state),
      attribute.data("collapse", collapse),
      attribute.data("variant", variant),
      ..attributes
    ],
    [sidebar, html.main([class(main_class())], main)],
  )
}

/// Text that the icon rail hides, such as the app's name in the header.
pub fn label(children: List(Element(msg))) -> Element(msg) {
  html.span([attribute.data("howdy-sidebar-label", "")], children)
}

/// A link with an icon, which stays when the sidebar is a rail.
pub fn icon_link(
  href: String,
  active active: Bool,
  icon icon: Element(msg),
  label text_label: String,
  attributes attributes: List(Attribute(msg)),
) -> Element(msg) {
  link(
    href,
    active:,
    attributes: [attribute.title(text_label), ..attributes],
    children: [
      html.span([class(icon_class()), attribute.aria_hidden(True)], [icon]),
      label([text(text_label)]),
    ],
  )
}

/// A section of links that opens and closes, such as the pages under
/// "Settings". It is a `<details>`, so it works without scripts.
pub fn submenu(
  icon icon: Element(msg),
  label text_label: String,
  open open: Bool,
  items items: List(Element(msg)),
) -> Element(msg) {
  html.li([], [
    html.details([class(submenu_class()), attribute.open(open)], [
      html.summary([class(item_class()), attribute.title(text_label)], [
        html.span([class(icon_class()), attribute.aria_hidden(True)], [icon]),
        label([text(text_label)]),
      ]),
      html.ul([class(submenu_list_class())], items),
    ]),
  ])
}

/// Attributes for the button that collapses the sidebar with this id, or
/// opens it on a narrow screen.
pub fn trigger(id: String) -> List(Attribute(msg)) {
  [
    attribute.attribute("popovertarget", id),
    attribute.aria_controls(id),
    attribute.data("howdy-sidebar-trigger", ""),
  ]
}

pub fn sidebar(
  id: String,
  attributes: List(Attribute(msg)),
  children: List(Element(msg)),
) -> Element(msg) {
  html.aside(
    [
      class(sidebar_class()),
      attribute.id(id),
      attribute.popover("auto"),
      ..attributes
    ],
    children,
  )
}

/// The top of the sidebar, such as the app's name or a workspace switcher.
pub fn header(children: List(Element(msg))) -> Element(msg) {
  html.div([class(header_class())], children)
}

/// The part of the sidebar that scrolls.
pub fn content(children: List(Element(msg))) -> Element(msg) {
  html.nav([class(content_class())], children)
}

/// The bottom of the sidebar, such as the signed-in user.
pub fn footer(children: List(Element(msg))) -> Element(msg) {
  html.div([class(footer_class())], children)
}

/// A labelled list of links. Pass `""` for no label.
pub fn group(label: String, items: List(Element(msg))) -> Element(msg) {
  let heading = case label {
    "" -> []
    _ -> [
      html.div(
        [class(group_label_class()), attribute.data("howdy-sidebar-label", "")],
        [text(label)],
      ),
    ]
  }
  html.div(
    [class(group_class())],
    list.append(heading, [html.ul([class(menu_class())], items)]),
  )
}

/// A link in a group. `active` marks the page being shown.
pub fn link(
  href: String,
  active active: Bool,
  attributes attributes: List(Attribute(msg)),
  children children: List(Element(msg)),
) -> Element(msg) {
  let current = case active {
    True -> [attribute.aria_current("page")]
    False -> []
  }
  html.li([], [
    html.a(
      [
        class(item_class()),
        attribute.href(href),
        ..list.append(current, attributes)
      ],
      children,
    ),
  ])
}

/// A button in a group, for an action rather than a page.
pub fn button(
  attributes: List(Attribute(msg)),
  children: List(Element(msg)),
) -> Element(msg) {
  html.li([], [
    html.button(
      [class(item_class()), attribute.type_("button"), ..attributes],
      children,
    ),
  ])
}

/// Every class this module uses, for `howdy/ui/export`.
pub fn classes() -> List(Class) {
  [
    icon_class(),
    submenu_class(),
    submenu_list_class(),
    layout_class(),
    main_class(),
    sidebar_class(),
    header_class(),
    content_class(),
    footer_class(),
    group_class(),
    group_label_class(),
    menu_class(),
    item_class(),
  ]
}

const wide = 768

/// Visually hidden, but still read by screen readers.
fn screen_reader_only() -> List(css.Style) {
  [
    css.position("absolute"),
    css.property("width", "1px"),
    css.property("height", "1px"),
    css.overflow("hidden"),
    css.property("clip-path", "inset(50%)"),
    css.white_space("nowrap"),
  ]
}

pub fn layout_class() -> Class {
  let collapsed_rail = "[data-collapse=\"rail\"][data-state=\"collapsed\"]"
  css.class([
    css.display("grid"),
    css.grid_template_columns("minmax(0, 1fr)"),
    css.property("min-height", "100vh"),
    css.selector("[data-variant=\"inset\"]", [css.background(tokens.surface)]),
    css.media(media.min_width(px(wide)), [
      css.grid_template_columns("16rem minmax(0, 1fr)"),
      css.transition("grid-template-columns 150ms"),
      css.selector("[data-collapse=\"offcanvas\"][data-state=\"collapsed\"]", [
        css.grid_template_columns("minmax(0, 1fr)"),
      ]),
      css.selector(
        "[data-collapse=\"offcanvas\"][data-state=\"collapsed\"] > aside",
        [css.display("none")],
      ),
      // The rail: icons only, labels kept for screen readers.
      css.selector(collapsed_rail, [
        css.grid_template_columns("3.5rem minmax(0, 1fr)"),
      ]),
      css.selector(
        collapsed_rail <> " [data-howdy-sidebar-label]",
        screen_reader_only(),
      ),
      css.selector(collapsed_rail <> " details > ul", [css.display("none")]),
      css.selector(collapsed_rail <> " summary::after", [css.display("none")]),
      // Floating: the sidebar is a card, clear of the page's edge.
      css.selector("[data-variant=\"floating\"] > aside", [
        css.margin(rem(0.5)),
        css.property("height", "calc(100vh - 1rem)"),
        css.property("top", "0.5rem"),
        style.bordered(),
        style.radius(tokens.radius_large),
        css.box_shadow("0 4px 16px -8px rgb(0 0 0 / 0.2)"),
      ]),
      // Inset: the page is a card set into the sidebar's background.
      css.selector("[data-variant=\"inset\"] > aside", [
        css.property("border-inline-end", "0"),
      ]),
      css.selector("[data-variant=\"inset\"] > main", [
        css.margin_("0.5rem 0.5rem 0.5rem 0"),
        css.background(tokens.background),
        style.bordered(),
        style.radius(tokens.radius_large),
        css.overflow("hidden"),
      ]),
    ]),
  ])
}

pub fn main_class() -> Class {
  css.class([css.property("min-width", "0")])
}

pub fn sidebar_class() -> Class {
  css.class([
    css.flex_direction("column"),
    css.margin(rem(0.0)),
    css.padding(rem(0.0)),
    css.background(tokens.surface),
    css.color(tokens.text),
    css.border("0"),
    css.overflow("hidden"),
    // Opened over the page on a narrow screen.
    css.selector(":popover-open", [
      css.display("flex"),
      css.position("fixed"),
      css.property("inset-block", "0"),
      css.property("inset-inline", "0 auto"),
      css.property("width", "min(18rem, 85vw)"),
      css.property("height", "100%"),
      css.property("max-height", "none"),
      style.bordered_side("inline-end"),
      css.box_shadow("0 20px 50px -12px rgb(0 0 0 / 0.35)"),
    ]),
    css.backdrop([css.background("rgb(0 0 0 / 0.5)")]),
    // A column in the layout on a wide screen, whether or not it is open.
    css.media(media.min_width(px(wide)), [
      css.display("flex"),
      css.position("sticky"),
      css.property("inset-block", "0 auto"),
      css.property("inset-inline", "0 auto"),
      css.property("top", "0"),
      css.property("width", "auto"),
      css.property("height", "100vh"),
      style.bordered_side("inline-end"),
      css.box_shadow("none"),
    ]),
  ])
}

pub fn header_class() -> Class {
  css.class([
    css.padding_(tokens.space_4),
    css.font_weight("600"),
  ])
}

pub fn content_class() -> Class {
  css.class([
    css.display("flex"),
    css.flex_direction("column"),
    css.gap(rem(1.0)),
    css.property("flex", "1"),
    css.overflow_y("auto"),
    css.padding_("0 " <> tokens.space_2),
  ])
}

pub fn footer_class() -> Class {
  css.class([
    css.padding_(tokens.space_4),
    style.bordered_side("top"),
    css.font_size(rem(0.875)),
  ])
}

pub fn group_class() -> Class {
  css.class([
    css.display("flex"),
    css.flex_direction("column"),
    css.gap(rem(0.25)),
  ])
}

pub fn group_label_class() -> Class {
  css.class([
    css.padding_("0 " <> tokens.space_2),
    css.font_size(rem(0.75)),
    css.font_weight("500"),
    css.color(tokens.text_muted),
  ])
}

pub fn menu_class() -> Class {
  css.class([
    css.display("flex"),
    css.flex_direction("column"),
    css.gap(rem(0.125)),
    css.margin(rem(0.0)),
    css.padding(rem(0.0)),
    css.list_style("none"),
  ])
}

pub fn icon_class() -> Class {
  css.class([
    css.display("inline-flex"),
    css.align_items("center"),
    css.justify_content("center"),
    css.flex_shrink(0.0),
    css.property("width", "1.25rem"),
    css.property("height", "1.25rem"),
  ])
}

pub fn submenu_class() -> Class {
  css.class([
    css.selector(" > summary", [css.list_style("none")]),
    css.selector(" > summary::-webkit-details-marker", [css.display("none")]),
    css.selector(" > summary::after", [
      css.content("\"\""),
      css.property("margin-inline-start", "auto"),
      css.property("width", "0.4rem"),
      css.property("height", "0.4rem"),
      css.property("border-right", "2px solid " <> tokens.text_muted),
      css.property("border-bottom", "2px solid " <> tokens.text_muted),
      css.transform_("rotate(45deg)"),
      css.transition("transform 150ms"),
    ]),
    css.selector("[open] > summary::after", [css.transform_("rotate(-135deg)")]),
  ])
}

pub fn submenu_list_class() -> Class {
  css.class([
    css.display("flex"),
    css.flex_direction("column"),
    css.gap(rem(0.125)),
    css.margin_("0.125rem 0 0"),
    css.padding(rem(0.0)),
    css.property("padding-inline-start", "1.75rem"),
    css.list_style("none"),
  ])
}

pub fn item_class() -> Class {
  css.class([
    css.display("flex"),
    css.align_items("center"),
    css.gap(rem(0.5)),
    css.width(length.percent(100)),
    css.padding_("0.375rem " <> tokens.space_2),
    css.border("0"),
    style.radius(tokens.radius_small),
    css.background("transparent"),
    css.color(tokens.text),
    css.font_family(tokens.font_body),
    css.font_size(rem(0.875)),
    css.line_height("1.25"),
    css.text_align("start"),
    css.text_decoration("none"),
    css.cursor("pointer"),
    css.hover([css.background(tokens.muted)]),
    style.focus_ring_offset("-2px"),
    css.selector("[aria-current=\"page\"]", [
      css.background(tokens.muted),
      css.font_weight("500"),
    ]),
  ])
}