diff --git a/app/assets/stylesheets/hamburgers.css b/app/assets/stylesheets/hamburgers.css new file mode 100644 index 00000000..91eab57c --- /dev/null +++ b/app/assets/stylesheets/hamburgers.css @@ -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 */ +} diff --git a/app/components/layouts/mobile_menu.rb b/app/components/layouts/mobile_menu.rb index 5c205dce..c8a6e038 100644 --- a/app/components/layouts/mobile_menu.rb +++ b/app/components/layouts/mobile_menu.rb @@ -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 @@ -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, diff --git a/app/components/ruby_ui/sheet/sheet_content.rb b/app/components/ruby_ui/sheet/sheet_content.rb index 3e18e970..4a82f0ee 100644 --- a/app/components/ruby_ui/sheet/sheet_content.rb +++ b/app/components/ruby_ui/sheet/sheet_content.rb @@ -62,10 +62,11 @@ 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 @@ -73,20 +74,30 @@ def background 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 diff --git a/app/javascript/controllers/ruby_ui/sheet_content_controller.js b/app/javascript/controllers/ruby_ui/sheet_content_controller.js index 8df0712b..bea3fe15 100644 --- a/app/javascript/controllers/ruby_ui/sheet_content_controller.js +++ b/app/javascript/controllers/ruby_ui/sheet_content_controller.js @@ -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) } } diff --git a/app/javascript/controllers/ruby_ui/sheet_controller.js b/app/javascript/controllers/ruby_ui/sheet_controller.js index d4f83808..de900a3b 100644 --- a/app/javascript/controllers/ruby_ui/sheet_controller.js +++ b/app/javascript/controllers/ruby_ui/sheet_controller.js @@ -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') } } } diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 78acf4c9..b5a98687 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -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 %> diff --git a/doc/screenshots/post-implementation/01-mobile-nav-closed.png b/doc/screenshots/post-implementation/01-mobile-nav-closed.png new file mode 100644 index 00000000..a15aae0c Binary files /dev/null and b/doc/screenshots/post-implementation/01-mobile-nav-closed.png differ diff --git a/doc/screenshots/post-implementation/02-mobile-drawer-open.png b/doc/screenshots/post-implementation/02-mobile-drawer-open.png new file mode 100644 index 00000000..b434ed3f Binary files /dev/null and b/doc/screenshots/post-implementation/02-mobile-drawer-open.png differ diff --git a/doc/screenshots/post-implementation/03-mobile-drawer-dismissed.png b/doc/screenshots/post-implementation/03-mobile-drawer-dismissed.png new file mode 100644 index 00000000..10a5ec5a Binary files /dev/null and b/doc/screenshots/post-implementation/03-mobile-drawer-dismissed.png differ diff --git a/doc/screenshots/post-implementation/04-mobile-drawer-reopened.png b/doc/screenshots/post-implementation/04-mobile-drawer-reopened.png new file mode 100644 index 00000000..b434ed3f Binary files /dev/null and b/doc/screenshots/post-implementation/04-mobile-drawer-reopened.png differ diff --git a/doc/screenshots/post-implementation/mobile-nav-after-escape.png b/doc/screenshots/post-implementation/mobile-nav-after-escape.png new file mode 100644 index 00000000..6b9a12e9 Binary files /dev/null and b/doc/screenshots/post-implementation/mobile-nav-after-escape.png differ diff --git a/doc/screenshots/post-implementation/mobile-nav-closed.png b/doc/screenshots/post-implementation/mobile-nav-closed.png new file mode 100644 index 00000000..6a77f7d4 Binary files /dev/null and b/doc/screenshots/post-implementation/mobile-nav-closed.png differ diff --git a/doc/screenshots/post-implementation/mobile-nav-drawer-open.png b/doc/screenshots/post-implementation/mobile-nav-drawer-open.png new file mode 100644 index 00000000..848414f6 Binary files /dev/null and b/doc/screenshots/post-implementation/mobile-nav-drawer-open.png differ diff --git a/spec/system/mobile_navigation_spec.rb b/spec/system/mobile_navigation_spec.rb index 03c01fd9..aa842f98 100644 --- a/spec/system/mobile_navigation_spec.rb +++ b/spec/system/mobile_navigation_spec.rb @@ -47,4 +47,99 @@ expect(page).to have_button('Logout') end end + + scenario 'Slide-out drawer is positioned on the left side' do + page.current_window.resize_to(375, 667) + visit root_path + + find('button[aria-label="Open menu"]').click + + drawer = find('[role="dialog"]') + drawer_left = drawer.evaluate_script('this.getBoundingClientRect().left') + expect(drawer_left).to eq(0) + end + + scenario 'Slide-out drawer has a semi-transparent backdrop' do + page.current_window.resize_to(375, 667) + visit root_path + + find('button[aria-label="Open menu"]').click + + expect(page).to have_css('[data-testid="drawer-backdrop"]') + end + + scenario 'Tapping backdrop dismisses the drawer' do + page.current_window.resize_to(375, 667) + visit root_path + + find('button[aria-label="Open menu"]').click + expect(page).to have_css('[role="dialog"]') + + page.execute_script("document.querySelector('[data-testid=\"drawer-backdrop\"]').click()") + expect(page).to have_no_css('[role="dialog"]') + end + + scenario 'Pressing Escape dismisses the drawer' do + page.current_window.resize_to(375, 667) + visit root_path + + find('button[aria-label="Open menu"]').click + expect(page).to have_css('[role="dialog"]') + + find('body').send_keys(:escape) + expect(page).to have_no_css('[role="dialog"]') + end + + scenario 'Drawer width is approximately 75-80% of viewport' do + page.current_window.resize_to(375, 667) + visit root_path + + find('button[aria-label="Open menu"]').click + + drawer = find('[role="dialog"]') + drawer_width = drawer.evaluate_script('this.getBoundingClientRect().width') + viewport_width = 375.0 + + ratio = drawer_width / viewport_width + expect(ratio).to be_between(0.70, 0.85) + end + + scenario 'Drawer has accessible aria attributes' do + page.current_window.resize_to(375, 667) + visit root_path + + find('button[aria-label="Open menu"]').click + + drawer = find('[role="dialog"]') + expect(drawer[:'aria-modal']).to eq('true') + expect(drawer[:'aria-label']).to be_present + end + + scenario 'Drawer can be reopened after dismissal' do + page.current_window.resize_to(375, 667) + visit root_path + + find('button[aria-label="Open menu"]').click + expect(page).to have_css('[role="dialog"]') + + find('body').send_keys(:escape) + expect(page).to have_no_css('[role="dialog"]') + + find('button[aria-label="Open menu"]').click + expect(page).to have_css('[role="dialog"]') + end + + scenario 'Touch targets in drawer meet WCAG minimum size' do + page.current_window.resize_to(375, 667) + visit root_path + + find('button[aria-label="Open menu"]').click + + within('[role="dialog"]') do + all('a, button').each do |target| + height = target.evaluate_script('this.getBoundingClientRect().height') + expect(height).to be >= 24, "Touch target '#{target.text}' height #{height}px < 24px minimum" + end + end + end end