Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/app/components/shared/components_list.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def components
{name: "Data Table", path: docs_data_table_path},
{name: "Date Picker", path: docs_date_picker_path},
{name: "Dialog / Modal", path: docs_dialog_path},
{name: "Drawer", path: docs_drawer_path},
{name: "Dropdown Menu", path: docs_dropdown_menu_path},
{name: "Empty", path: docs_empty_path},
{name: "Form", path: docs_form_path},
Expand Down
4 changes: 4 additions & 0 deletions docs/app/controllers/docs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ def dialog
render Views::Docs::Dialog.new
end

def drawer
render Views::Docs::Drawer.new
end

def dropdown_menu
render Views::Docs::DropdownMenu.new
end
Expand Down
6 changes: 6 additions & 0 deletions docs/app/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ application.register("ruby-ui--data-table-search", RubyUi__DataTableSearchContro
import RubyUi__DialogController from "./ruby_ui/dialog_controller"
application.register("ruby-ui--dialog", RubyUi__DialogController)

import RubyUi__DrawerContentController from "./ruby_ui/drawer_content_controller"
application.register("ruby-ui--drawer-content", RubyUi__DrawerContentController)

import RubyUi__DrawerController from "./ruby_ui/drawer_controller"
application.register("ruby-ui--drawer", RubyUi__DrawerController)

import RubyUi__DropdownMenuController from "./ruby_ui/dropdown_menu_controller"
application.register("ruby-ui--dropdown-menu", RubyUi__DropdownMenuController)

Expand Down
1 change: 1 addition & 0 deletions docs/app/lib/site_files.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ class SiteFiles
{title: "Data Table", path: "/docs/data_table", description: "Data table primitives for search, sorting, pagination, visibility, and bulk actions."},
{title: "Date Picker", path: "/docs/date_picker", description: "Date picker component with input."},
{title: "Dialog", path: "/docs/dialog", description: "Modal window that renders background content inert."},
{title: "Drawer", path: "/docs/drawer", description: "Bottom sheet with a swipe handle, drag, snap points and swipe-to-dismiss."},
{title: "Dropdown Menu", path: "/docs/dropdown_menu", description: "Button-triggered menu for actions or functions."},
{title: "Empty", path: "/docs/empty", description: "Empty state for when there is no data or content."},
{title: "Form", path: "/docs/form", description: "Form fields with built-in client-side validations."},
Expand Down
145 changes: 145 additions & 0 deletions docs/app/views/docs/drawer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# frozen_string_literal: true

class Views::Docs::Drawer < Views::Base
def view_template
component = "Drawer"

div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
render Docs::Header.new(title: "Drawer", description: "A bottom sheet you drag between snap points and swipe down to close. It is built on the native <dialog> element and CSS scroll-snap and needs no JavaScript packages.")

Heading(level: 2) { "Usage" }

render Docs::VisualCodeExample.new(title: "Example", description: "The drawer opens at 60% of the viewport. Drag the handle up and it rests at 92%. Drag it down past the lowest snap point, or flick it down, and it closes.", context: self) do
<<~RUBY
Drawer do
DrawerTrigger do
Button(variant: :outline) { "Open Drawer" }
end
DrawerContent do
DrawerHeader do
DrawerTitle { "Edit profile" }
DrawerDescription { "Make changes to your profile here. Click save when you're done." }
end
DrawerMiddle do
label { "Name" }
Input(placeholder: "Joel Drapper") { "Joel Drapper" }
label { "Email" }
Input(placeholder: "joel@drapper.me")
end
DrawerFooter do
Button(type: "submit", class: "w-full") { "Save" }
DrawerClose do
Button(variant: :outline, class: "w-full") { "Cancel" }
end
end
end
end
RUBY
end

render Docs::VisualCodeExample.new(title: "Snap points", description: "snap_points lists the heights the panel can rest at, in shadcn's units: up to 1 is a fraction of the viewport, above 1 is pixels, and a string is any CSS length. initial is the index of the one it opens at, and a flick moves to the next snap point in its direction.", context: self) do
<<~RUBY
Drawer do
DrawerTrigger do
Button(variant: :outline) { "Open at 35%" }
end
DrawerContent(snap_points: [0.35, 0.7, 1]) do
DrawerHeader do
DrawerTitle { "Snap points" }
DrawerDescription { "Rests at 35%, 70% or 100% of the viewport." }
end
DrawerMiddle do
div(class: "space-y-3") do
12.times { div(class: "h-12 rounded-md bg-muted") }
end
end
end
end
RUBY
end

render Docs::VisualCodeExample.new(title: "Non-modal", description: "With modal: false there is no scrim and the page behind stays usable, the same as shadcn's modal={false}.", context: self) do
<<~RUBY
Drawer do
DrawerTrigger do
Button(variant: :outline) { "Open non-modal" }
end
DrawerContent(modal: false, snap_points: [0.4]) do
DrawerHeader do
DrawerTitle { "Now playing" }
DrawerDescription { "No scrim, so the page behind stays interactive." }
end
DrawerFooter do
DrawerClose do
Button(variant: :outline, class: "w-full") { "Close" }
end
end
end
end
RUBY
end

render Docs::VisualCodeExample.new(title: "Non-dismissible", description: "With dismissible: false, clicking the scrim, pressing Escape and swiping the drawer down do nothing. Only DrawerClose closes it.", context: self) do
<<~RUBY
Drawer do
DrawerTrigger do
Button(variant: :outline) { "Open non-dismissible" }
end
DrawerContent(dismissible: false, snap_points: [0.45]) do
DrawerHeader do
DrawerTitle { "Delete account?" }
DrawerDescription { "This cannot be undone. Choose one of the options below." }
end
DrawerFooter do
DrawerClose do
Button(variant: :destructive, class: "w-full") { "Delete" }
end
DrawerClose do
Button(variant: :outline, class: "w-full") { "Keep my account" }
end
end
end
end
RUBY
end

render Docs::VisualCodeExample.new(title: "Custom swipe handle", description: "handle: false leaves the handle out. Render DrawerSwipeHandle yourself to restyle it or put it somewhere else.", context: self) do
<<~RUBY
Drawer do
DrawerTrigger do
Button(variant: :outline) { "Open with a custom handle" }
end
DrawerContent(handle: false, snap_points: ["24rem"]) do
DrawerSwipeHandle(class: "w-20 bg-primary")
DrawerHeader do
DrawerTitle { "Custom handle" }
DrawerDescription { "Wider, in the primary color, and the drawer opens 24rem tall." }
end
end
end
RUBY
end

Heading(level: 2) { "Styling and events" }
p(class: "text-muted-foreground text-sm leading-relaxed") { "The panel carries data-snap-points when the drawer has snap points, data-expanded once it reaches the largest one and data-swiping while a drag is in flight, so each state can be styled with a data-* variant. The controller fires opened, snap and closed events, and the snap event names the index it came to rest at." }

div(class: "rounded-md border bg-muted/30 p-4 mt-2") do
Codeblock(<<~RUBY, syntax: :ruby)
DrawerContent(
class: "data-expanded:rounded-none",
data: {action: "ruby-ui--drawer-content:snap->player#trackSnap"}
) do
# ...
end
RUBY
end

Heading(level: 2) { "Drawer or Sheet?" }
p(class: "text-muted-foreground text-sm leading-relaxed") { "Sheet slides a panel in from any edge and leaves it there. Drawer is a bottom sheet: you drag it between snap points by the handle and swipe it down to close it. The modal variant is a native <dialog> opened with showModal(), so the browser provides the focus trap, aria-modal and the inert page. Drawer only comes up from the bottom edge. There is no equivalent of shadcn's swipeDirection for the other sides." }

render Components::ComponentSetup::Tabs.new(component_name: component)

render Docs::ComponentsTable.new(component_files(component))
end
end
end
1 change: 1 addition & 0 deletions docs/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
get "context_menu", to: "docs#context_menu", as: :docs_context_menu
get "date_picker", to: "docs#date_picker", as: :docs_date_picker
get "dialog", to: "docs#dialog", as: :docs_dialog
get "drawer", to: "docs#drawer", as: :docs_drawer
get "dropdown_menu", to: "docs#dropdown_menu", as: :docs_dropdown_menu
get "empty", to: "docs#empty", as: :docs_empty
get "form", to: "docs#form", as: :docs_form
Expand Down
5 changes: 5 additions & 0 deletions docs/public/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,11 @@ This file expands the curated /llms.txt map into a compact reference that can be
- URL: https://rubyui.com/docs/dialog
- Summary: Modal window that renders background content inert.

### Drawer

- URL: https://rubyui.com/docs/drawer
- Summary: Bottom sheet with a swipe handle, drag, snap points and swipe-to-dismiss.

### Dropdown Menu

- URL: https://rubyui.com/docs/dropdown_menu
Expand Down
1 change: 1 addition & 0 deletions docs/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Use the core docs first for installation, theming, dark mode, and customization
- [Data Table](https://rubyui.com/docs/data_table): Data table primitives for search, sorting, pagination, visibility, and bulk actions.
- [Date Picker](https://rubyui.com/docs/date_picker): Date picker component with input.
- [Dialog](https://rubyui.com/docs/dialog): Modal window that renders background content inert.
- [Drawer](https://rubyui.com/docs/drawer): Bottom sheet with a swipe handle, drag, snap points and swipe-to-dismiss.
- [Dropdown Menu](https://rubyui.com/docs/dropdown_menu): Button-triggered menu for actions or functions.
- [Empty](https://rubyui.com/docs/empty): Empty state for when there is no data or content.
- [Form](https://rubyui.com/docs/form): Form fields with built-in client-side validations.
Expand Down
5 changes: 5 additions & 0 deletions docs/public/sitemap.xml
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://rubyui.com/docs/drawer</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://rubyui.com/docs/dropdown_menu</loc>
<changefreq>monthly</changefreq>
Expand Down
25 changes: 25 additions & 0 deletions gem/lib/ruby_ui/drawer/drawer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

module RubyUI
class Drawer < Base
def initialize(open: false, **attrs)
@open = open
super(**attrs)
end

def view_template(&)
div(**attrs, &)
end

private

def default_attrs
{
data: {
controller: "ruby-ui--drawer",
ruby_ui__drawer_open_value: @open.to_s
}
}
end
end
end
17 changes: 17 additions & 0 deletions gem/lib/ruby_ui/drawer/drawer_close.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

module RubyUI
class DrawerClose < Base
def view_template(&)
div(**attrs, &)
end

private

def default_attrs
{
data: {action: "click->ruby-ui--drawer-content#close"}
}
end
end
end
116 changes: 116 additions & 0 deletions gem/lib/ruby_ui/drawer/drawer_content.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# frozen_string_literal: true

module RubyUI
class DrawerContent < Base
# snap_points: how far up the panel comes at each rest position, in shadcn's units; initial: index it opens at.
# dismissible: false ignores the scrim click, Escape and a swipe below the lowest snap point.
# initial_focus: false focuses the panel instead of the first field, so a form does not raise the keyboard on open.
def initialize(snap_points: [0.6, 0.92], initial: 0, modal: true, dismissible: true, handle: true, initial_focus: true, **attrs)
@snap_points = snap_points
@initial = initial
@modal = modal
@dismissible = dismissible
@handle = handle
@initial_focus = initial_focus
super(**attrs)
end

def view_template(&block)
template(data: {ruby_ui__drawer_target: "content"}) do
dialog(**dialog_attrs) do
backdrop if @modal
scroller do
@snap_points.each { |point| snap_marker(point) }
# Full-screen spacer: makes the scroll range, and scrollTop 0 the dismissed rest position.
div(class: "h-full snap-start")
div(**attrs) do
RubyUI.DrawerSwipeHandle() if @handle
block&.call
end
end
end
end
end

private

# The panel is full-height; only the band above the fold (--drawer-band, tracked by the controller) is laid out.
# The dialog's focusing steps prefer an autofocus element, so a focusable panel keeps focus off the fields.
def default_attrs
{
class: "group/drawer relative flex h-full flex-col overflow-clip rounded-t-lg bg-background text-foreground shadow-[0_-8px_30px_rgb(0_0_0/0.12)] snap-start pointer-events-auto after:flex-none after:h-[calc(100%_-_var(--drawer-band))] after:content-['']",
tabindex: ("-1" unless @initial_focus),
autofocus: (true unless @initial_focus),
data: {
ruby_ui__drawer_content_target: "panel",
# Style hooks, as in shadcn: the controller adds data-expanded and data-swiping.
snap_points: (true if @snap_points.any?)
}
}
end

# Full-screen transparent <dialog> with pointer events off (the non-modal page stays reachable); backdrop and panel opt back in.
def dialog_attrs
{
class: "fixed inset-0 z-50 m-0 h-full w-full max-h-none max-w-none border-0 bg-transparent p-0 pointer-events-none backdrop:bg-transparent",
style: "--drawer-band: #{open_length}",
data: {
controller: "ruby-ui--drawer-content",
turbo_temporary: true,
ruby_ui__drawer_content_initial_value: @initial,
ruby_ui__drawer_content_modal_value: @modal.to_s,
ruby_ui__drawer_content_dismissible_value: @dismissible.to_s
}
}
end

# Out of range opens at the first snap point, as the controller does; without any, the panel is full height.
def open_length
point = @snap_points[@initial.clamp(0..)] || @snap_points.first
point ? css_length(point) : "100%"
end

# shadcn's units: up to 1 is a fraction of the viewport, above 1 is pixels, and a string passes through ("24rem").
def css_length(point)
return point if point.is_a?(String)

(point > 1) ? "#{css_number(point)}px" : "#{css_number(point * 100)}%"
end

# 0.6 * 100 is 60.00000000000001 in binary floating point, and CSS should read 60%.
# Kernel#format would be the obvious tool, but phlex-rails gives every component its own zero-argument #format.
def css_number(number)
rounded = number.round(6)
((rounded % 1) == 0) ? rounded.to_i : rounded
end

def backdrop
div(
class: "fixed inset-0 z-50 pointer-events-auto bg-background/80 backdrop-blur-sm transition-none duration-200 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards",
data: {
state: "open",
action: "click->ruby-ui--drawer-content#dismiss",
ruby_ui__drawer_content_target: "backdrop"
}
)
end

# Vertical snap scroller: scrolling up reveals the panel and the browser snaps to the markers.
def scroller(&)
div(
class: "fixed inset-0 z-50 overflow-y-scroll overscroll-y-none snap-y snap-mandatory pointer-events-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
data: {ruby_ui__drawer_content_target: "scroller"},
&
)
end

# Sentinel out of the flow: its ::before is the snap target, and its offsetTop is the scroll position the controller animates to.
def snap_marker(point)
div(
class: "relative -mb-px h-px before:absolute before:inset-x-0 before:top-0 before:h-px before:snap-start before:content-['']",
style: "top: #{css_length(point)}",
data: {ruby_ui__drawer_content_target: "snap"}
)
end
end
end
Loading