mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-23 22:47:19 +00:00
Polish mobile dashboard sidebar drawer UX
Refactors the dashboard’s mobile sidebar into a modal-style drawer with a coordinated state helper, scrim overlay, and dedicated close button. Adds accessibility improvements (`aria-label`, `aria-expanded`) and stronger dismissal behavior (scrim tap, close button, Escape, and swipe-left with velocity/threshold handling). Updates mobile CSS for safe-area-aware positioning, improved touch targets/pressed states, frosted toggle styling, drawer shadows/transitions, and drag-state handling for smoother interactions.
This commit is contained in:
@@ -92,10 +92,13 @@ async function UIDashboard (options) {
|
||||
h += '<div class="dashboard">';
|
||||
|
||||
// Mobile sidebar toggle
|
||||
h += '<button class="dashboard-sidebar-toggle">';
|
||||
h += '<button class="dashboard-sidebar-toggle" aria-label="Open menu" aria-expanded="false">';
|
||||
h += '<span></span><span></span><span></span>';
|
||||
h += '</button>';
|
||||
|
||||
// Scrim behind the mobile drawer; tap closes it
|
||||
h += '<div class="dashboard-sidebar-scrim"></div>';
|
||||
|
||||
// Sidebar
|
||||
h += '<div class="dashboard-sidebar hide-scrollbar">';
|
||||
// Sidebar header with logo and collapse toggle
|
||||
@@ -105,6 +108,10 @@ async function UIDashboard (options) {
|
||||
h += `<img class="sidebar-toggle-close" src="${window.icons['sidebar-close.svg']}">`;
|
||||
h += `<img class="sidebar-toggle-open" src="${window.icons['sidebar-open.svg']}">`;
|
||||
h += '</button>';
|
||||
// Mobile-only close button (the collapse toggle is desktop-only)
|
||||
h += '<button class="dashboard-sidebar-close" aria-label="Close menu">';
|
||||
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
|
||||
h += '</button>';
|
||||
h += '</div>';
|
||||
// Navigation items container
|
||||
h += '<div class="dashboard-sidebar-nav">';
|
||||
@@ -491,16 +498,100 @@ async function UIDashboard (options) {
|
||||
$el_window.find('.dashboard-content').scrollTop(0);
|
||||
|
||||
// Close sidebar on mobile after selection
|
||||
$el_window.find('.dashboard-sidebar').removeClass('open');
|
||||
$el_window.find('.dashboard-sidebar-toggle').removeClass('open');
|
||||
setMobileSidebar(false);
|
||||
});
|
||||
|
||||
// Mobile toggle handler
|
||||
// =========================================================================
|
||||
// Mobile drawer
|
||||
// The sidebar becomes a modal drawer below 768px: scrim behind it, close
|
||||
// button in its header, Escape and swipe-left dismiss it. One helper keeps
|
||||
// the drawer, scrim, and toggle (incl. aria-expanded) in sync.
|
||||
// =========================================================================
|
||||
const $sidebar = $el_window.find('.dashboard-sidebar');
|
||||
const $scrim = $el_window.find('.dashboard-sidebar-scrim');
|
||||
const $sidebar_toggle = $el_window.find('.dashboard-sidebar-toggle');
|
||||
const setMobileSidebar = (open) => {
|
||||
$sidebar.toggleClass('open', open);
|
||||
$scrim.toggleClass('open', open);
|
||||
$sidebar_toggle.toggleClass('open', open).attr('aria-expanded', open ? 'true' : 'false');
|
||||
};
|
||||
|
||||
$el_window.on('click', '.dashboard-sidebar-toggle', function () {
|
||||
$(this).toggleClass('open');
|
||||
$el_window.find('.dashboard-sidebar').toggleClass('open');
|
||||
setMobileSidebar(!$sidebar.hasClass('open'));
|
||||
});
|
||||
|
||||
$el_window.on('click', '.dashboard-sidebar-close, .dashboard-sidebar-scrim', function () {
|
||||
setMobileSidebar(false);
|
||||
});
|
||||
|
||||
$(document).on('keydown.dashboard-mobile-sidebar', function (e) {
|
||||
if ( e.key === 'Escape' && $sidebar.hasClass('open') ) {
|
||||
setMobileSidebar(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Swipe-to-close: the drawer tracks a leftward drag 1:1 (scrim fading in
|
||||
// step), then commits past 35% width or a quick flick, else snaps back.
|
||||
// Transitions are suspended during the drag (.dragging) so releasing
|
||||
// animates from the finger's last position, not from fully-open.
|
||||
{
|
||||
const sidebar = $sidebar[0];
|
||||
const scrim = $scrim[0];
|
||||
let startX = 0, startY = 0, dx = 0, lastX = 0, lastT = 0, velocity = 0;
|
||||
let tracking = false, axis = null;
|
||||
|
||||
sidebar.addEventListener('touchstart', (e) => {
|
||||
if ( ! sidebar.classList.contains('open') ) return;
|
||||
const t = e.touches[0];
|
||||
startX = lastX = t.clientX;
|
||||
startY = t.clientY;
|
||||
lastT = e.timeStamp;
|
||||
dx = 0; velocity = 0; axis = null;
|
||||
tracking = true;
|
||||
}, { passive: true });
|
||||
|
||||
sidebar.addEventListener('touchmove', (e) => {
|
||||
if ( ! tracking ) return;
|
||||
const t = e.touches[0];
|
||||
const moveX = t.clientX - startX;
|
||||
const moveY = t.clientY - startY;
|
||||
// Lock to an axis on the first meaningful movement so vertical
|
||||
// scrolling inside the drawer never drags it sideways.
|
||||
if ( axis === null && (Math.abs(moveX) > 8 || Math.abs(moveY) > 8) ) {
|
||||
axis = Math.abs(moveX) > Math.abs(moveY) ? 'x' : 'y';
|
||||
if ( axis === 'x' ) {
|
||||
sidebar.classList.add('dragging');
|
||||
scrim.classList.add('dragging');
|
||||
}
|
||||
}
|
||||
if ( axis !== 'x' ) return;
|
||||
dx = Math.min(0, moveX);
|
||||
const dt = e.timeStamp - lastT;
|
||||
if ( dt > 0 ) velocity = (t.clientX - lastX) / dt;
|
||||
lastX = t.clientX;
|
||||
lastT = e.timeStamp;
|
||||
sidebar.style.transform = `translateX(${dx}px)`;
|
||||
scrim.style.opacity = Math.max(0, 1 + dx / sidebar.offsetWidth);
|
||||
}, { passive: true });
|
||||
|
||||
const endDrag = () => {
|
||||
if ( ! tracking ) return;
|
||||
tracking = false;
|
||||
const shouldClose = axis === 'x'
|
||||
&& (dx < -sidebar.offsetWidth * 0.35 || velocity < -0.4);
|
||||
// Re-enable transitions and drop inline styles in the same frame
|
||||
// as the class change so the drawer animates from where it is.
|
||||
sidebar.classList.remove('dragging');
|
||||
scrim.classList.remove('dragging');
|
||||
sidebar.style.transform = '';
|
||||
scrim.style.opacity = '';
|
||||
if ( shouldClose ) setMobileSidebar(false);
|
||||
axis = null; dx = 0;
|
||||
};
|
||||
sidebar.addEventListener('touchend', endDrag);
|
||||
sidebar.addEventListener('touchcancel', endDrag);
|
||||
}
|
||||
|
||||
// Desktop sidebar collapse toggle
|
||||
$el_window.on('click', '.dashboard-sidebar-collapse-toggle', function () {
|
||||
const $sidebar = $el_window.find('.dashboard-sidebar');
|
||||
@@ -528,9 +619,8 @@ async function UIDashboard (options) {
|
||||
$el_window.on('mousedown touchstart', function (e) {
|
||||
if ( !$(e.target).closest('.dashboard-sidebar').length
|
||||
&& !$(e.target).closest('.dashboard-sidebar-toggle').length
|
||||
&& $el_window.find('.dashboard-sidebar').hasClass('open') ) {
|
||||
$el_window.find('.dashboard-sidebar').removeClass('open');
|
||||
$el_window.find('.dashboard-sidebar-toggle').removeClass('open');
|
||||
&& $sidebar.hasClass('open') ) {
|
||||
setMobileSidebar(false);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+154
-31
@@ -85,6 +85,8 @@
|
||||
--dashboard-shadow-light: rgba(0, 0, 0, 0.06);
|
||||
--dashboard-shadow-medium: rgba(0, 0, 0, 0.1);
|
||||
--dashboard-shadow-overlay: rgba(0, 0, 0, 0.5);
|
||||
--dashboard-scrim: rgba(15, 18, 24, 0.42);
|
||||
--dashboard-drawer-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04), 24px 0 64px rgba(0, 0, 0, 0.24);
|
||||
|
||||
--dashboard-gradient-indigo: rgba(99, 102, 241, 0.08);
|
||||
--dashboard-gradient-purple: rgba(168, 85, 247, 0.06);
|
||||
@@ -180,6 +182,8 @@ body {
|
||||
--dashboard-shadow-light: rgba(0, 0, 0, 0.3);
|
||||
--dashboard-shadow-medium: rgba(0, 0, 0, 0.4);
|
||||
--dashboard-shadow-overlay: rgba(0, 0, 0, 0.7);
|
||||
--dashboard-scrim: rgba(0, 0, 0, 0.6);
|
||||
--dashboard-drawer-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 24px 0 64px rgba(0, 0, 0, 0.55);
|
||||
|
||||
--dashboard-gradient-indigo: rgba(99, 102, 241, 0.15);
|
||||
--dashboard-gradient-purple: rgba(168, 85, 247, 0.12);
|
||||
@@ -365,11 +369,43 @@ body {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Hide desktop collapse toggle on mobile */
|
||||
/* Mobile drawer close button — hidden on desktop, where the collapse toggle
|
||||
occupies the same header slot */
|
||||
.dashboard-sidebar-close {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 8px;
|
||||
color: var(--dashboard-text-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.dashboard-sidebar-close svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.dashboard-sidebar-close:active {
|
||||
background: var(--dashboard-hover);
|
||||
color: var(--dashboard-text);
|
||||
}
|
||||
|
||||
/* Hide desktop collapse toggle on mobile; show the drawer close button */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-sidebar-collapse-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-sidebar-close {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-sidebar-nav {
|
||||
@@ -1366,6 +1402,12 @@ body.myapps-reordering .myapps-tile {
|
||||
.myapps-drag-ghost {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.dashboard-sidebar,
|
||||
.dashboard-sidebar-scrim,
|
||||
.dashboard-sidebar-toggle {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dashboard files */
|
||||
@@ -2518,76 +2560,157 @@ body.myapps-reordering .myapps-tile {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Mobile sidebar toggle */
|
||||
/* Mobile sidebar toggle — frosted so scrolling content reads through it,
|
||||
aligned to the 16px content gutter */
|
||||
.dashboard-sidebar-toggle {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
top: max(12px, env(safe-area-inset-top));
|
||||
left: max(16px, env(safe-area-inset-left));
|
||||
z-index: 100;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
background: var(--dashboard-background);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 6px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
/* Extend the tap target to ~52px without growing the button visually */
|
||||
.dashboard-sidebar-toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -6px;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.dashboard-sidebar-toggle:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
/* The open drawer covers the toggle; fade it so it can't peek out from
|
||||
behind the drawer's rounded corner or catch stray taps */
|
||||
.dashboard-sidebar-toggle.open {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dashboard-sidebar-toggle span {
|
||||
display: block;
|
||||
width: 18px;
|
||||
width: 16px;
|
||||
height: 2px;
|
||||
background: var(--dashboard-text);
|
||||
border-radius: 1px;
|
||||
transition: transform 0.2s, opacity 0.2s;
|
||||
}
|
||||
|
||||
.dashboard-sidebar-toggle.open span:nth-child(1) {
|
||||
transform: rotate(45deg) translate(4px, 4px);
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.dashboard-sidebar-toggle {
|
||||
background: rgba(37, 37, 37, 0.8);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-sidebar-toggle.open span:nth-child(2) {
|
||||
opacity: 0;
|
||||
/* Scrim behind the mobile drawer */
|
||||
.dashboard-sidebar-scrim {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-sidebar-toggle.open span:nth-child(3) {
|
||||
transform: rotate(-45deg) translate(4px, -4px);
|
||||
}
|
||||
|
||||
/* Responsive: tablet and below */
|
||||
/* Responsive: tablet and below — the sidebar becomes a modal drawer */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-sidebar-header {
|
||||
padding-left: 45px;
|
||||
border-bottom: 0;
|
||||
margin-bottom: 2px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.dashboard-sidebar-header .dashboard-sidebar-logo {
|
||||
margin-left: auto;
|
||||
padding: 2px 2px 12px 6px;
|
||||
}
|
||||
|
||||
.dashboard-sidebar-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dashboard-sidebar-scrim {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 98;
|
||||
background: var(--dashboard-scrim);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
transition: opacity 0.32s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.dashboard-sidebar-scrim.open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.dashboard-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
bottom: 0;
|
||||
height: auto;
|
||||
width: min(304px, calc(100vw - 56px));
|
||||
min-width: 0;
|
||||
z-index: 99;
|
||||
padding: max(16px, env(safe-area-inset-top)) 14px max(14px, env(safe-area-inset-bottom)) calc(14px + env(safe-area-inset-left));
|
||||
border-right: none;
|
||||
border-radius: 0 16px 16px 0;
|
||||
transform: translateX(-110%);
|
||||
transition: transform 0.2s ease;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
visibility: hidden;
|
||||
box-shadow: var(--dashboard-drawer-shadow);
|
||||
overscroll-behavior: contain;
|
||||
/* visibility flips only after the slide-out finishes, keeping the
|
||||
closed drawer out of the tab order */
|
||||
transition: transform 0.32s cubic-bezier(0.32, 0.72, 0, 1), visibility 0s linear 0.32s;
|
||||
}
|
||||
|
||||
.dashboard-sidebar.open {
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
transition: transform 0.32s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
/* Mid-swipe: the drawer follows the finger, so no transitions */
|
||||
.dashboard-sidebar.dragging,
|
||||
.dashboard-sidebar-scrim.dragging {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Roomier touch targets inside the drawer, with pressed-state feedback
|
||||
in place of hover */
|
||||
.dashboard-sidebar-item {
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
font-size: 15px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: 10px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.dashboard-sidebar-item svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.dashboard-sidebar-item:active {
|
||||
background: var(--dashboard-hover);
|
||||
}
|
||||
|
||||
.dashboard-user-btn {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.dashboard-user-btn:active {
|
||||
background: var(--dashboard-hover);
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
@@ -2600,7 +2723,7 @@ body.myapps-reordering .myapps-tile {
|
||||
the first folder row (the column is hidden below 480px, so this only
|
||||
affects the tablet range). */
|
||||
.dashboard-section-files .directories {
|
||||
padding-top: 52px;
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.myapps-search-wrap {
|
||||
|
||||
Reference in New Issue
Block a user