Skip to content
Merged
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
124 changes: 124 additions & 0 deletions app/assets/stylesheets/hamburgers.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*!
* Hamburgers - Spring variant only
* @description Tasty CSS-animated hamburgers
* @author Jonathan Suh @jonsuh
* @site https://jonsuh.com/hamburgers
* @link https://github.com/jonsuh/hamburgers
* @license MIT
*
* Compiled to plain CSS with default variables for use without Sass.
* Layer width: 24px, height: 2px, spacing: 5px (scaled for nav bar)
*/

.hamburger {
padding: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition-property: opacity, filter;
transition-duration: 0.15s;
transition-timing-function: linear;
font: inherit;
color: inherit;
text-transform: none;
background-color: transparent;
border: 0;
margin: 0;
overflow: visible;
min-height: 44px;
min-width: 44px;
}

.hamburger:hover {
opacity: 0.7;
}

.hamburger.is-active:hover {
opacity: 0.7;
}

.hamburger.is-active .hamburger-inner,
.hamburger.is-active .hamburger-inner::before,
.hamburger.is-active .hamburger-inner::after {
background-color: #000;
}

.hamburger-box {
width: 24px;
height: 14px; /* 2*3 + 5*2 - but with top offset: layer-height*2 + spacing*2 */
display: inline-block;
position: relative;
}

.hamburger-inner {
display: block;
top: 50%;
margin-top: -1px; /* layer-height / -2 */
}

.hamburger-inner,
.hamburger-inner::before,
.hamburger-inner::after {
width: 24px;
height: 2px;
background-color: #000;
border-radius: 2px;
position: absolute;
transition-property: transform;
transition-duration: 0.15s;
transition-timing-function: ease;
}

.hamburger-inner::before,
.hamburger-inner::after {
content: "";
display: block;
}

.hamburger-inner::before {
top: -7px; /* -(spacing + layer-height) = -(5+2) */
}

.hamburger-inner::after {
bottom: -7px; /* -(spacing + layer-height) = -(5+2) */
}

/*
* Spring
*/
.hamburger--spring .hamburger-inner {
top: 1px; /* layer-height / 2 */
transition: background-color 0s 0.13s linear;
}

.hamburger--spring .hamburger-inner::before {
top: 7px; /* layer-height + spacing = 2 + 5 */
transition: top 0.1s 0.2s cubic-bezier(0.33333, 0.66667, 0.66667, 1),
transform 0.13s cubic-bezier(0.55, 0.055, 0.675, 0.19);
}

.hamburger--spring .hamburger-inner::after {
top: 14px; /* (layer-height * 2) + (spacing * 2) = 4 + 10 */
transition: top 0.2s 0.2s cubic-bezier(0.33333, 0.66667, 0.66667, 1),
transform 0.13s cubic-bezier(0.55, 0.055, 0.675, 0.19);
}

.hamburger--spring.is-active .hamburger-inner {
transition-delay: 0.22s;
background-color: transparent !important;
}

.hamburger--spring.is-active .hamburger-inner::before {
top: 0;
transition: top 0.1s 0.15s cubic-bezier(0.33333, 0, 0.66667, 0.33333),
transform 0.13s 0.22s cubic-bezier(0.215, 0.61, 0.355, 1);
transform: translate3d(0, 7px, 0) rotate(45deg); /* spacing + layer-height = 5 + 2 */
}

.hamburger--spring.is-active .hamburger-inner::after {
top: 0;
transition: top 0.2s cubic-bezier(0.33333, 0, 0.66667, 0.33333),
transform 0.13s 0.22s cubic-bezier(0.215, 0.61, 0.355, 1);
transform: translate3d(0, 7px, 0) rotate(-45deg); /* spacing + layer-height = 5 + 2 */
}
52 changes: 47 additions & 5 deletions app/components/layouts/mobile_menu.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,24 @@ def view_template
div(class: 'md:hidden') do
render RubyUI::Sheet.new do
render RubyUI::SheetTrigger.new do
render RubyUI::Button.new(variant: :ghost, size: :icon, aria: { label: 'Open menu' }) do
render Icons::Menu.new(class: 'h-6 w-6')
button(
type: 'button',
class: 'hamburger hamburger--spring',
aria: { label: 'Open menu', expanded: 'false' }
) do
span(class: 'hamburger-box') do
span(class: 'hamburger-inner')
end
end
end

render RubyUI::SheetContent.new(side: :left, class: 'w-[300px] sm:w-[400px]') do
render RubyUI::SheetHeader.new do
render RubyUI::SheetContent.new(side: :left) do
render RubyUI::SheetHeader.new(class: 'flex flex-row items-center justify-between') do
render(RubyUI::SheetTitle.new { 'MedTracker' })
close_drawer_button
end

div(class: 'grid gap-4 py-4') do
div(class: 'py-4') do
render_navigation_links
end

Expand All @@ -41,6 +48,41 @@ def view_template

private

def close_drawer_button
button(
type: 'button',
class: 'rounded-sm p-2 opacity-70 ring-offset-background transition-opacity ' \
'hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ' \
'min-h-[44px] min-w-[44px] flex items-center justify-center',
data_action: 'click->ruby-ui--sheet-content#close',
aria: { label: 'Close menu' }
) do
svg(
width: '15',
height: '15',
viewbox: '0 0 15 15',
fill: 'none',
xmlns: 'http://www.w3.org/2000/svg',
class: 'h-4 w-4',
aria_hidden: 'true'
) do |s|
s.path(
d: 'M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 ' \
'11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 ' \
'3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 ' \
'7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 ' \
'3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 ' \
'12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 ' \
'7.49999L11.7816 4.03157Z',
fill: 'currentColor',
fill_rule: 'evenodd',
clip_rule: 'evenodd'
)
end
span(class: 'sr-only') { 'Close menu' }
end
end

def render_navigation_links
div(class: 'flex flex-col gap-2') do
render RubyUI::Link.new(href: medicines_path, variant: :ghost, size: :xl,
Expand Down
35 changes: 23 additions & 12 deletions app/components/ruby_ui/sheet/sheet_content.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,31 +62,42 @@ def close_button

def background
div(
data_state: 'open',
class: 'fixed inset-0 z-50 bg-black/80 backdrop-blur-sm data-[state=open]:animate-in',
data_testid: 'drawer-backdrop',
data_action: 'click->ruby-ui--sheet-content#close',
class: 'fixed inset-0 z-50 bg-black/80 backdrop-blur-sm transition-opacity duration-300 ' \
'data-[state=open]:opacity-100 data-[state=closed]:opacity-0',
style: 'pointer-events:auto',
data_aria_hidden: 'true',
aria_hidden: 'true'
)
end

def container(&)
div(
role: 'dialog',
data_state: 'open',
class: 'flex flex-col fixed left-[50%] top-[50%] z-50 w-full max-w-lg max-h-screen overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:rounded-lg md:w-full',
aria_modal: 'true',
aria_label: 'Navigation menu',
tabindex: '-1',
class: [
'flex flex-col fixed z-50 h-full w-[80vw] max-w-[300px] overflow-y-auto bg-background p-6 shadow-lg transition-transform duration-300 ease-in-out',
'data-[state=open]:translate-x-0',
side_transform_class
],
style: 'pointer-events:auto',
&
)
end

def backdrop
div(
data_state: 'open',
data_action: 'click->ruby-ui--sheet-content#close',
class:
'fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0'
)
def side_transform_class
case @side
when :left
'top-0 left-0 border-r data-[state=closed]:-translate-x-full'
when :right
'top-0 right-0 border-l data-[state=closed]:translate-x-full'
when :top
'top-0 left-0 w-full h-auto border-b data-[state=closed]:-translate-y-full'
when :bottom
'bottom-0 left-0 w-full h-auto border-t data-[state=closed]:translate-y-full'
end
end
end
end
38 changes: 37 additions & 1 deletion app/javascript/controllers/ruby_ui/sheet_content_controller.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,43 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
connect() {
this.handleKeydown = this.handleKeydown.bind(this)
document.addEventListener('keydown', this.handleKeydown)
}

disconnect() {
document.removeEventListener('keydown', this.handleKeydown)
}

handleKeydown(event) {
if (event.key === 'Escape') {
this.close()
}
}

close() {
this.element.remove()
const backdrop = this.element.querySelector('[data-testid="drawer-backdrop"]')
const panel = this.element.querySelector('[role="dialog"]')

if (backdrop) backdrop.setAttribute('data-state', 'closed')
if (panel) panel.setAttribute('data-state', 'closed')

const trigger = document.querySelector('.hamburger.is-active')
if (trigger) {
trigger.classList.remove('is-active')
trigger.setAttribute('aria-expanded', 'false')
}

const sheetController = this.application.getControllerForElementAndIdentifier(
document.querySelector('[data-controller="ruby-ui--sheet"]'),
'ruby-ui--sheet'
)
if (sheetController) sheetController.close()

const duration = 300
setTimeout(() => {
this.element.remove()
}, duration)
}
}
55 changes: 41 additions & 14 deletions app/javascript/controllers/ruby_ui/sheet_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,51 @@ export default class extends Controller {
static targets = ["content"]

open() {
// Insert the sheet content into the body
document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML)
if (this.wrapper && this.wrapper.isConnected) return

// Get the newly inserted sheet content
const sheetContent = document.body.lastElementChild
const sheetPanel = sheetContent.querySelector('[data-state]')
const trigger = this.element.querySelector('.hamburger')
if (trigger) {
trigger.classList.add('is-active')
trigger.setAttribute('aria-expanded', 'true')
}

const wrapper = document.createElement("div")
wrapper.setAttribute("data-controller", "ruby-ui--sheet-content")
wrapper.setAttribute("data-ruby-ui--sheet-content-sheet-id", this.element.id || "")
wrapper.style.cssText = "position:fixed;inset:0;z-index:50;pointer-events:none;"
wrapper.innerHTML = this.contentTarget.innerHTML
document.body.appendChild(wrapper)
this.wrapper = wrapper

const backdrop = wrapper.querySelector('[data-testid="drawer-backdrop"]')
const panel = wrapper.querySelector('[role="dialog"]')

if (backdrop) {
backdrop.setAttribute('data-state', 'closed')
backdrop.offsetHeight
}

if (panel) {
panel.setAttribute('data-state', 'closed')
panel.offsetHeight
}

if (sheetPanel) {
// Start with closed state
sheetPanel.setAttribute('data-state', 'closed')
requestAnimationFrame(() => {
if (backdrop) backdrop.setAttribute('data-state', 'open')
if (panel) {
panel.setAttribute('data-state', 'open')
panel.focus()
}
})
}

// Force a reflow to ensure the closed state is applied
sheetPanel.offsetHeight
close() {
this.wrapper = null

// Trigger the animation by changing to open state
requestAnimationFrame(() => {
sheetPanel.setAttribute('data-state', 'open')
})
const trigger = this.element.querySelector('.hamburger')
if (trigger) {
trigger.classList.remove('is-active')
trigger.setAttribute('aria-expanded', 'false')
}
}
}
1 change: 1 addition & 0 deletions app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<%= favicon_link_tag "/favicon.svg", rel: "apple-touch-icon", type: "image/svg+xml" %>
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "hamburgers", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>

<body>
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading