diff --git a/examples/ui-showcase/src/App.vue b/examples/ui-showcase/src/App.vue index cf66d7b8..46ac03b0 100644 --- a/examples/ui-showcase/src/App.vue +++ b/examples/ui-showcase/src/App.vue @@ -653,13 +653,19 @@ const tableOfContentsItems = [

Admin layout

Regions plus a collapsible sidebar. State lives on the root as data-cm-*, so the Razor adapter and - the runtime controller read the same thing Vue binds. + the runtime controller read the same thing Vue binds. A collapsed sidebar still opens on hover or keyboard + focus, without leaving the collapsed state.

- + Layout content
@@ -679,9 +685,13 @@ const tableOfContentsItems = [

Setup layout

-

A single-task page for onboarding steps.

+

+ A single-task page for onboarding steps. Given an aside, it sits beside the form rather than above it once + there's room -- aside-position picks which side. +

+ Workspace name diff --git a/examples/ui-showcase/src/showcase.css b/examples/ui-showcase/src/showcase.css index e1d2e0e9..8f861d10 100644 --- a/examples/ui-showcase/src/showcase.css +++ b/examples/ui-showcase/src/showcase.css @@ -7,8 +7,12 @@ system, so the fallback colors (#fff/#111) always won regardless of theme. */ .ui-showcase { + /* Wide enough that the layout demos (CmAdminLayout, CmAppShell, ...) clear their own 64rem + container-query breakpoint and render their desktop form -- narrower than that and their + container queries correctly, not incidentally, switch them to the mobile drawer they'd also + show in a real narrow window. */ margin: 0 auto; - max-width: 60rem; + max-width: 76rem; padding: 2rem 1.5rem 4rem; } @@ -45,6 +49,13 @@ resize: vertical; border: 1px solid var(--cm-color-border-subtle, #e5e5e5); border-radius: var(--cm-radius-surface, 8px); + + /* Some layouts (CmAdminLayout, CmAdminShell, ...) fix their sidebar/header to the viewport, which + is correct once they're a real page -- but "viewport" for position: fixed is the nearest + ancestor that establishes a containing block, and nothing here did, so those elements escaped + this frame entirely and pinned themselves to the actual browser window instead. `contain: layout + paint` makes this box that ancestor, the same way an iframe's document would be one on its own. */ + contain: layout paint; } .ui-showcase__inline { diff --git a/packages/css/src/components/admin-layout.css b/packages/css/src/components/admin-layout.css index cae94cad..eff8e6f9 100644 --- a/packages/css/src/components/admin-layout.css +++ b/packages/css/src/components/admin-layout.css @@ -2,26 +2,66 @@ * The layout reads its own state from attributes on the root rather than from classes a framework * toggles. That is what lets one stylesheet serve both adapters and a server-rendered page that has * not run JavaScript yet. + * + * The sidebar and header are fixed to the viewport rather than scrolling with the page -- only the + * content area scrolls. Responsiveness is a container query against the layout's own inline size, + * not the viewport: an admin layout embedded in a narrower pane (a preview frame, a split view) + * collapses to its mobile presentation on its own width, the same way it would in a full window. */ .cm-admin-layout { - display: grid; - grid-template-columns: auto 1fr; + display: block; min-block-size: 100dvb; + container: cm-admin-layout / inline-size; + background: var(--cm-color-background-canvas); } .cm-admin-layout__aside { + position: fixed; + inset-block: 0; + inset-inline-start: 0; + z-index: var(--cm-z-index-sticky, 10); display: flex; flex-direction: column; - gap: var(--cm-space-3); - inline-size: 16rem; - padding: var(--cm-space-3); - border-inline-end: var(--cm-border-width) solid var(--cm-color-border-default); + inline-size: 18rem; + overflow: hidden; background: var(--cm-color-background-surface); - transition: inline-size var(--cm-motion-duration-fast) var(--cm-motion-ease-standard); + border-inline-end: var(--cm-border-width) solid var(--cm-color-border-default); + transition: inline-size var(--cm-motion-duration-normal) var(--cm-motion-ease-standard); } .cm-admin-layout[data-cm-sidebar-collapsed='true'] .cm-admin-layout__aside { - inline-size: 4rem; + inline-size: 4.75rem; +} + +/* + * A collapsed sidebar still opens on hover or keyboard focus, wide enough to read again, without + * changing the collapsed state itself -- that state is for the layout at rest, not for whether you + * can currently see it. + */ +.cm-admin-layout[data-cm-sidebar-collapsed='true'] .cm-admin-layout__aside:is(:hover, :has(:focus-visible)) { + z-index: calc(var(--cm-z-index-sticky, 10) + 2); + inline-size: 18rem; +} + +.cm-admin-layout__brand { + display: flex; + flex-shrink: 0; + align-items: center; + gap: var(--cm-space-2); + block-size: 4rem; + padding: var(--cm-space-2) var(--cm-space-3); + overflow: hidden; +} + +.cm-admin-layout__aside:has(.cm-admin-layout__aside-content) .cm-admin-layout__brand { + border-block-end: var(--cm-border-width) solid var(--cm-color-border-default); +} + +.cm-admin-layout__aside-content { + min-block-size: 0; + flex: 1 1 auto; + overflow-y: auto; + padding: var(--cm-space-3); } .cm-admin-layout__main { @@ -30,11 +70,25 @@ min-inline-size: 0; } +.cm-admin-layout:has(> .cm-admin-layout__aside) .cm-admin-layout__main { + margin-inline-start: 18rem; + transition: margin-inline-start var(--cm-motion-duration-normal) var(--cm-motion-ease-standard); +} + +.cm-admin-layout[data-cm-sidebar-collapsed='true']:has(> .cm-admin-layout__aside) .cm-admin-layout__main { + margin-inline-start: 4.75rem; +} + .cm-admin-layout__header { + position: sticky; + inset-block-start: 0; + z-index: calc(var(--cm-z-index-sticky, 10) + 1); display: flex; align-items: center; gap: var(--cm-space-2); + block-size: 4rem; padding: var(--cm-space-2) var(--cm-space-3); + background: var(--cm-color-background-surface); border-block-end: var(--cm-border-width) solid var(--cm-color-border-default); } @@ -101,44 +155,104 @@ } /* - * Below the breakpoint the sidebar becomes a drawer: off-canvas by default, slid in when the root - * says the drawer is open, with a backdrop that closes it. + * Below the container's own inline-size threshold the sidebar becomes a drawer: off-canvas by + * default, slid in when the root says the drawer is open, with a backdrop that fades in behind it + * and closes it on click. The fade is delayed on the way out and immediate on the way in, so the + * drawer never appears to sit under a backdrop that hasn't shown up yet. */ -@media (width < 48rem) { - .cm-admin-layout { - grid-template-columns: 1fr; - } - - .cm-admin-layout__mobile-toggle, - .cm-admin-layout__mobile-brand { - display: block; - } - +@container cm-admin-layout (width < 64rem) { .cm-admin-layout__aside { - position: fixed; - z-index: 2; - inset-block: 0; - inset-inline-start: 0; - inline-size: 16rem; + inset-block-start: 0; + z-index: calc(var(--cm-z-index-sticky, 10) + 3); + inline-size: min(18rem, 85cqi); + visibility: hidden; translate: -100% 0; - transition: translate var(--cm-motion-duration-fast) var(--cm-motion-ease-standard); + transition: + translate var(--cm-motion-duration-normal) var(--cm-motion-ease-standard), + visibility 0s var(--cm-motion-duration-normal); + } + + [dir='rtl'] .cm-admin-layout__aside { + translate: 100% 0; } .cm-admin-layout[data-cm-mobile-sidebar-open='true'] .cm-admin-layout__aside { + visibility: visible; translate: 0 0; + transition: + translate var(--cm-motion-duration-normal) var(--cm-motion-ease-standard), + visibility 0s; } - .cm-admin-layout[data-cm-mobile-sidebar-open='true'] .cm-admin-layout__mobile-backdrop { + .cm-admin-layout[data-cm-sidebar-collapsed='true'] .cm-admin-layout__aside { + inline-size: min(18rem, 85cqi); + } + + .cm-admin-layout:has(> .cm-admin-layout__aside) .cm-admin-layout__main, + .cm-admin-layout[data-cm-sidebar-collapsed='true']:has(> .cm-admin-layout__aside) .cm-admin-layout__main { + margin-inline-start: 0; + } + + .cm-admin-layout__header { + z-index: calc(var(--cm-z-index-sticky, 10) + 3); + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + } + + .cm-admin-layout__mobile-toggle { + display: flex; + justify-self: start; + } + + .cm-admin-layout__mobile-brand { + display: flex; + min-inline-size: 0; + align-items: center; + justify-content: center; + grid-column: 2; + } + + .cm-admin-layout__header-content { + inline-size: auto; + display: flex; + min-inline-size: 0; + justify-self: end; + grid-column: 3; + } + + .cm-admin-layout__mobile-backdrop { position: fixed; - z-index: 1; - display: block; inset: 0; + z-index: calc(var(--cm-z-index-sticky, 10) + 2); + display: block; background: var(--cm-color-background-backdrop); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: + opacity var(--cm-motion-duration-normal) var(--cm-motion-ease-standard), + visibility 0s var(--cm-motion-duration-normal); + } + + .cm-admin-layout[data-cm-mobile-sidebar-open='true'] .cm-admin-layout__mobile-backdrop { + opacity: 1; + visibility: visible; + pointer-events: auto; + transition: + opacity var(--cm-motion-duration-normal) var(--cm-motion-ease-standard), + visibility 0s; + } + + .cm-admin-layout[data-cm-sidebar-collapsed='true'] .cm-admin-layout__aside:is(:hover, :has(:focus-visible)) { + z-index: calc(var(--cm-z-index-sticky, 10) + 4); + inline-size: min(18rem, 85cqi); } } @media (prefers-reduced-motion: reduce) { - .cm-admin-layout__aside { + .cm-admin-layout__aside, + .cm-admin-layout__main, + .cm-admin-layout__mobile-backdrop { transition: none; } } diff --git a/packages/css/src/components/admin-shell.css b/packages/css/src/components/admin-shell.css index eb1f1b91..666a9229 100644 --- a/packages/css/src/components/admin-shell.css +++ b/packages/css/src/components/admin-shell.css @@ -1,16 +1,24 @@ -/* A fixed frame: topbar above, sidebar beside the workspace. It has no state to reflect. */ +/* + * A fixed frame: topbar above, sidebar beside the workspace. It has no state to reflect. The + * workspace is a distinct, elevated card inset within the shell rather than a plain flow region -- + * that's what separates it visually from the sidebar sitting flush beside it. + */ .cm-admin-shell { display: flex; flex-direction: column; min-block-size: 100dvb; + background: var(--cm-color-background-inverse); } .cm-admin-shell__topbar { + position: sticky; + inset-block-start: 0; + z-index: var(--cm-z-index-sticky, 10); display: flex; align-items: center; gap: var(--cm-space-3); padding: var(--cm-space-2) var(--cm-space-3); - border-block-end: var(--cm-border-width) solid var(--cm-color-border-default); + color: var(--cm-color-text-inverse); } .cm-admin-shell__header { @@ -19,17 +27,24 @@ } .cm-admin-shell__body { + position: relative; display: flex; flex: 1 1 auto; min-block-size: 0; + background: var(--cm-color-background-canvas); + border-start-start-radius: var(--cm-radius-surface); + border-start-end-radius: var(--cm-radius-surface); } .cm-admin-shell__sidebar { inline-size: 16rem; - border-inline-end: var(--cm-border-width) solid var(--cm-color-border-default); } .cm-admin-shell__sidebar-content { + position: sticky; + inset-block-start: 0; + max-block-size: 100dvb; + overflow-y: auto; padding: var(--cm-space-3); } @@ -38,6 +53,12 @@ flex: 1 1 auto; flex-direction: column; min-inline-size: 0; + margin-block: var(--cm-space-3); + margin-inline-end: var(--cm-space-3); + overflow: hidden; + background: var(--cm-color-background-surface); + border: var(--cm-border-width) solid var(--cm-color-border-default); + border-radius: var(--cm-radius-surface); } .cm-admin-shell__content { @@ -46,6 +67,7 @@ } .cm-admin-shell__footer { + margin-block-start: auto; padding: var(--cm-space-2) var(--cm-space-3); border-block-start: var(--cm-border-width) solid var(--cm-color-border-default); } @@ -57,7 +79,14 @@ .cm-admin-shell__sidebar { inline-size: auto; - border-inline-end: 0; - border-block-end: var(--cm-border-width) solid var(--cm-color-border-default); + } + + .cm-admin-shell__sidebar-content { + position: static; + max-block-size: none; + } + + .cm-admin-shell__workspace { + margin-inline-start: var(--cm-space-3); } } diff --git a/packages/css/src/components/app-shell.css b/packages/css/src/components/app-shell.css index 058f2d3a..b03cbf4d 100644 --- a/packages/css/src/components/app-shell.css +++ b/packages/css/src/components/app-shell.css @@ -49,6 +49,11 @@ .cm-app-shell__aside { position: sticky; inset-block-start: var(--cm-sticky-top-offset, 0); + + /* Sticky alone still lets a sidebar taller than the viewport push past its bottom edge; the cap + plus its own scrollbar are what let it stick without doing that. */ + max-block-size: calc(100dvb - var(--cm-sticky-top-offset, 0)); + overflow-y: auto; min-inline-size: 0; } @@ -70,6 +75,21 @@ grid-template-columns: minmax(0, 1fr); } +/* + * Two stages, not one: the aside is the first casualty of a narrowing window (nice-to-have context, + * not primary navigation), so it goes before the sidebar does. Below 64em there's no room for a + * three- or even two-column grid at all, and both go static and stack with the content. + */ +@media (width < 80em) { + .cm-app-shell--sidebar-content-aside .cm-app-shell__body { + grid-template-columns: minmax(0, 16rem) minmax(0, 1fr); + } + + .cm-app-shell--sidebar-content-aside .cm-app-shell__aside { + display: none; + } +} + @media (width < 64em) { .cm-app-shell--sidebar-content .cm-app-shell__body, .cm-app-shell--sidebar-content-aside .cm-app-shell__body { @@ -79,5 +99,7 @@ .cm-app-shell__sidebar, .cm-app-shell__aside { position: static; + max-block-size: none; + overflow-y: visible; } } diff --git a/packages/css/src/components/document-layout.css b/packages/css/src/components/document-layout.css index 51fc1173..30b261a3 100644 --- a/packages/css/src/components/document-layout.css +++ b/packages/css/src/components/document-layout.css @@ -10,6 +10,14 @@ min-block-size: 100vh; } +/* A framed page, once there's room to see the frame -- borderless below that would only be a sliver + of border on either side, not an actual margin. */ +@media (width > 48rem) { + .cm-document-layout { + border-inline: var(--cm-border-width) solid var(--cm-color-border-subtle); + } +} + .cm-document-layout__header { position: sticky; inset-block-start: 0; @@ -45,6 +53,8 @@ .cm-document-layout__aside { position: sticky; inset-block-start: var(--cm-sticky-top-offset, 0); + max-block-size: calc(100dvb - var(--cm-sticky-top-offset, 0)); + overflow-y: auto; min-inline-size: 0; } @@ -56,6 +66,16 @@ border-block-start: var(--cm-border-width) solid var(--cm-color-border-subtle); } +@media (width < 80em) { + .cm-document-layout--sidebar-content-aside .cm-document-layout__content { + grid-template-columns: minmax(0, 15rem) minmax(0, 1fr); + } + + .cm-document-layout--sidebar-content-aside .cm-document-layout__aside { + display: none; + } +} + @media (width < 64em) { .cm-document-layout--sidebar-content .cm-document-layout__content, .cm-document-layout--sidebar-content-aside .cm-document-layout__content { @@ -65,5 +85,7 @@ .cm-document-layout__sidebar, .cm-document-layout__aside { position: static; + max-block-size: none; + overflow-y: visible; } } diff --git a/packages/css/src/components/setup-layout.css b/packages/css/src/components/setup-layout.css index 22449153..b448805d 100644 --- a/packages/css/src/components/setup-layout.css +++ b/packages/css/src/components/setup-layout.css @@ -52,3 +52,67 @@ .cm-setup-layout--aside-left .cm-setup-layout__aside { order: -1; } + +/* + * An aside stacks above the form by default -- the same single centered card either way. Once + * there's room, a wizard with an aside (an illustration, a summary) earns a second column instead: + * the two sit side by side, divided by one border, rather than one queued above the other. + */ +@media (width > 48rem) { + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__panel { + display: grid; + grid-template-columns: minmax(0, 1fr) 18rem; + grid-template-areas: + 'brand aside' + 'toolbar aside' + 'header aside' + 'main aside'; + align-items: start; + gap: 0; + max-inline-size: 56rem; + padding: 0; + overflow: hidden; + } + + .cm-setup-layout--aside-left:has(.cm-setup-layout__aside) .cm-setup-layout__panel { + grid-template-columns: 18rem minmax(0, 1fr); + grid-template-areas: + 'aside brand' + 'aside toolbar' + 'aside header' + 'aside main'; + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__brand { + grid-area: brand; + padding: var(--cm-space-4) var(--cm-space-4) 0; + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__toolbar { + grid-area: toolbar; + padding: var(--cm-space-3) var(--cm-space-4) 0; + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__header { + grid-area: header; + padding: var(--cm-space-4) var(--cm-space-4) 0; + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__main { + grid-area: main; + padding: var(--cm-space-4); + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__aside { + grid-area: aside; + align-self: stretch; + padding: var(--cm-space-4); + border-inline-start: var(--cm-border-width) solid var(--cm-color-border-default); + } + + .cm-setup-layout--aside-left:has(.cm-setup-layout__aside) .cm-setup-layout__aside { + order: 0; + border-inline-start: 0; + border-inline-end: var(--cm-border-width) solid var(--cm-color-border-default); + } +} diff --git a/packages/razor/resources/assets/css/css/components/admin-layout.css b/packages/razor/resources/assets/css/css/components/admin-layout.css index cae94cad..eff8e6f9 100644 --- a/packages/razor/resources/assets/css/css/components/admin-layout.css +++ b/packages/razor/resources/assets/css/css/components/admin-layout.css @@ -2,26 +2,66 @@ * The layout reads its own state from attributes on the root rather than from classes a framework * toggles. That is what lets one stylesheet serve both adapters and a server-rendered page that has * not run JavaScript yet. + * + * The sidebar and header are fixed to the viewport rather than scrolling with the page -- only the + * content area scrolls. Responsiveness is a container query against the layout's own inline size, + * not the viewport: an admin layout embedded in a narrower pane (a preview frame, a split view) + * collapses to its mobile presentation on its own width, the same way it would in a full window. */ .cm-admin-layout { - display: grid; - grid-template-columns: auto 1fr; + display: block; min-block-size: 100dvb; + container: cm-admin-layout / inline-size; + background: var(--cm-color-background-canvas); } .cm-admin-layout__aside { + position: fixed; + inset-block: 0; + inset-inline-start: 0; + z-index: var(--cm-z-index-sticky, 10); display: flex; flex-direction: column; - gap: var(--cm-space-3); - inline-size: 16rem; - padding: var(--cm-space-3); - border-inline-end: var(--cm-border-width) solid var(--cm-color-border-default); + inline-size: 18rem; + overflow: hidden; background: var(--cm-color-background-surface); - transition: inline-size var(--cm-motion-duration-fast) var(--cm-motion-ease-standard); + border-inline-end: var(--cm-border-width) solid var(--cm-color-border-default); + transition: inline-size var(--cm-motion-duration-normal) var(--cm-motion-ease-standard); } .cm-admin-layout[data-cm-sidebar-collapsed='true'] .cm-admin-layout__aside { - inline-size: 4rem; + inline-size: 4.75rem; +} + +/* + * A collapsed sidebar still opens on hover or keyboard focus, wide enough to read again, without + * changing the collapsed state itself -- that state is for the layout at rest, not for whether you + * can currently see it. + */ +.cm-admin-layout[data-cm-sidebar-collapsed='true'] .cm-admin-layout__aside:is(:hover, :has(:focus-visible)) { + z-index: calc(var(--cm-z-index-sticky, 10) + 2); + inline-size: 18rem; +} + +.cm-admin-layout__brand { + display: flex; + flex-shrink: 0; + align-items: center; + gap: var(--cm-space-2); + block-size: 4rem; + padding: var(--cm-space-2) var(--cm-space-3); + overflow: hidden; +} + +.cm-admin-layout__aside:has(.cm-admin-layout__aside-content) .cm-admin-layout__brand { + border-block-end: var(--cm-border-width) solid var(--cm-color-border-default); +} + +.cm-admin-layout__aside-content { + min-block-size: 0; + flex: 1 1 auto; + overflow-y: auto; + padding: var(--cm-space-3); } .cm-admin-layout__main { @@ -30,11 +70,25 @@ min-inline-size: 0; } +.cm-admin-layout:has(> .cm-admin-layout__aside) .cm-admin-layout__main { + margin-inline-start: 18rem; + transition: margin-inline-start var(--cm-motion-duration-normal) var(--cm-motion-ease-standard); +} + +.cm-admin-layout[data-cm-sidebar-collapsed='true']:has(> .cm-admin-layout__aside) .cm-admin-layout__main { + margin-inline-start: 4.75rem; +} + .cm-admin-layout__header { + position: sticky; + inset-block-start: 0; + z-index: calc(var(--cm-z-index-sticky, 10) + 1); display: flex; align-items: center; gap: var(--cm-space-2); + block-size: 4rem; padding: var(--cm-space-2) var(--cm-space-3); + background: var(--cm-color-background-surface); border-block-end: var(--cm-border-width) solid var(--cm-color-border-default); } @@ -101,44 +155,104 @@ } /* - * Below the breakpoint the sidebar becomes a drawer: off-canvas by default, slid in when the root - * says the drawer is open, with a backdrop that closes it. + * Below the container's own inline-size threshold the sidebar becomes a drawer: off-canvas by + * default, slid in when the root says the drawer is open, with a backdrop that fades in behind it + * and closes it on click. The fade is delayed on the way out and immediate on the way in, so the + * drawer never appears to sit under a backdrop that hasn't shown up yet. */ -@media (width < 48rem) { - .cm-admin-layout { - grid-template-columns: 1fr; - } - - .cm-admin-layout__mobile-toggle, - .cm-admin-layout__mobile-brand { - display: block; - } - +@container cm-admin-layout (width < 64rem) { .cm-admin-layout__aside { - position: fixed; - z-index: 2; - inset-block: 0; - inset-inline-start: 0; - inline-size: 16rem; + inset-block-start: 0; + z-index: calc(var(--cm-z-index-sticky, 10) + 3); + inline-size: min(18rem, 85cqi); + visibility: hidden; translate: -100% 0; - transition: translate var(--cm-motion-duration-fast) var(--cm-motion-ease-standard); + transition: + translate var(--cm-motion-duration-normal) var(--cm-motion-ease-standard), + visibility 0s var(--cm-motion-duration-normal); + } + + [dir='rtl'] .cm-admin-layout__aside { + translate: 100% 0; } .cm-admin-layout[data-cm-mobile-sidebar-open='true'] .cm-admin-layout__aside { + visibility: visible; translate: 0 0; + transition: + translate var(--cm-motion-duration-normal) var(--cm-motion-ease-standard), + visibility 0s; } - .cm-admin-layout[data-cm-mobile-sidebar-open='true'] .cm-admin-layout__mobile-backdrop { + .cm-admin-layout[data-cm-sidebar-collapsed='true'] .cm-admin-layout__aside { + inline-size: min(18rem, 85cqi); + } + + .cm-admin-layout:has(> .cm-admin-layout__aside) .cm-admin-layout__main, + .cm-admin-layout[data-cm-sidebar-collapsed='true']:has(> .cm-admin-layout__aside) .cm-admin-layout__main { + margin-inline-start: 0; + } + + .cm-admin-layout__header { + z-index: calc(var(--cm-z-index-sticky, 10) + 3); + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + } + + .cm-admin-layout__mobile-toggle { + display: flex; + justify-self: start; + } + + .cm-admin-layout__mobile-brand { + display: flex; + min-inline-size: 0; + align-items: center; + justify-content: center; + grid-column: 2; + } + + .cm-admin-layout__header-content { + inline-size: auto; + display: flex; + min-inline-size: 0; + justify-self: end; + grid-column: 3; + } + + .cm-admin-layout__mobile-backdrop { position: fixed; - z-index: 1; - display: block; inset: 0; + z-index: calc(var(--cm-z-index-sticky, 10) + 2); + display: block; background: var(--cm-color-background-backdrop); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: + opacity var(--cm-motion-duration-normal) var(--cm-motion-ease-standard), + visibility 0s var(--cm-motion-duration-normal); + } + + .cm-admin-layout[data-cm-mobile-sidebar-open='true'] .cm-admin-layout__mobile-backdrop { + opacity: 1; + visibility: visible; + pointer-events: auto; + transition: + opacity var(--cm-motion-duration-normal) var(--cm-motion-ease-standard), + visibility 0s; + } + + .cm-admin-layout[data-cm-sidebar-collapsed='true'] .cm-admin-layout__aside:is(:hover, :has(:focus-visible)) { + z-index: calc(var(--cm-z-index-sticky, 10) + 4); + inline-size: min(18rem, 85cqi); } } @media (prefers-reduced-motion: reduce) { - .cm-admin-layout__aside { + .cm-admin-layout__aside, + .cm-admin-layout__main, + .cm-admin-layout__mobile-backdrop { transition: none; } } diff --git a/packages/razor/resources/assets/css/css/components/admin-shell.css b/packages/razor/resources/assets/css/css/components/admin-shell.css index eb1f1b91..666a9229 100644 --- a/packages/razor/resources/assets/css/css/components/admin-shell.css +++ b/packages/razor/resources/assets/css/css/components/admin-shell.css @@ -1,16 +1,24 @@ -/* A fixed frame: topbar above, sidebar beside the workspace. It has no state to reflect. */ +/* + * A fixed frame: topbar above, sidebar beside the workspace. It has no state to reflect. The + * workspace is a distinct, elevated card inset within the shell rather than a plain flow region -- + * that's what separates it visually from the sidebar sitting flush beside it. + */ .cm-admin-shell { display: flex; flex-direction: column; min-block-size: 100dvb; + background: var(--cm-color-background-inverse); } .cm-admin-shell__topbar { + position: sticky; + inset-block-start: 0; + z-index: var(--cm-z-index-sticky, 10); display: flex; align-items: center; gap: var(--cm-space-3); padding: var(--cm-space-2) var(--cm-space-3); - border-block-end: var(--cm-border-width) solid var(--cm-color-border-default); + color: var(--cm-color-text-inverse); } .cm-admin-shell__header { @@ -19,17 +27,24 @@ } .cm-admin-shell__body { + position: relative; display: flex; flex: 1 1 auto; min-block-size: 0; + background: var(--cm-color-background-canvas); + border-start-start-radius: var(--cm-radius-surface); + border-start-end-radius: var(--cm-radius-surface); } .cm-admin-shell__sidebar { inline-size: 16rem; - border-inline-end: var(--cm-border-width) solid var(--cm-color-border-default); } .cm-admin-shell__sidebar-content { + position: sticky; + inset-block-start: 0; + max-block-size: 100dvb; + overflow-y: auto; padding: var(--cm-space-3); } @@ -38,6 +53,12 @@ flex: 1 1 auto; flex-direction: column; min-inline-size: 0; + margin-block: var(--cm-space-3); + margin-inline-end: var(--cm-space-3); + overflow: hidden; + background: var(--cm-color-background-surface); + border: var(--cm-border-width) solid var(--cm-color-border-default); + border-radius: var(--cm-radius-surface); } .cm-admin-shell__content { @@ -46,6 +67,7 @@ } .cm-admin-shell__footer { + margin-block-start: auto; padding: var(--cm-space-2) var(--cm-space-3); border-block-start: var(--cm-border-width) solid var(--cm-color-border-default); } @@ -57,7 +79,14 @@ .cm-admin-shell__sidebar { inline-size: auto; - border-inline-end: 0; - border-block-end: var(--cm-border-width) solid var(--cm-color-border-default); + } + + .cm-admin-shell__sidebar-content { + position: static; + max-block-size: none; + } + + .cm-admin-shell__workspace { + margin-inline-start: var(--cm-space-3); } } diff --git a/packages/razor/resources/assets/css/css/components/app-shell.css b/packages/razor/resources/assets/css/css/components/app-shell.css index 058f2d3a..b03cbf4d 100644 --- a/packages/razor/resources/assets/css/css/components/app-shell.css +++ b/packages/razor/resources/assets/css/css/components/app-shell.css @@ -49,6 +49,11 @@ .cm-app-shell__aside { position: sticky; inset-block-start: var(--cm-sticky-top-offset, 0); + + /* Sticky alone still lets a sidebar taller than the viewport push past its bottom edge; the cap + plus its own scrollbar are what let it stick without doing that. */ + max-block-size: calc(100dvb - var(--cm-sticky-top-offset, 0)); + overflow-y: auto; min-inline-size: 0; } @@ -70,6 +75,21 @@ grid-template-columns: minmax(0, 1fr); } +/* + * Two stages, not one: the aside is the first casualty of a narrowing window (nice-to-have context, + * not primary navigation), so it goes before the sidebar does. Below 64em there's no room for a + * three- or even two-column grid at all, and both go static and stack with the content. + */ +@media (width < 80em) { + .cm-app-shell--sidebar-content-aside .cm-app-shell__body { + grid-template-columns: minmax(0, 16rem) minmax(0, 1fr); + } + + .cm-app-shell--sidebar-content-aside .cm-app-shell__aside { + display: none; + } +} + @media (width < 64em) { .cm-app-shell--sidebar-content .cm-app-shell__body, .cm-app-shell--sidebar-content-aside .cm-app-shell__body { @@ -79,5 +99,7 @@ .cm-app-shell__sidebar, .cm-app-shell__aside { position: static; + max-block-size: none; + overflow-y: visible; } } diff --git a/packages/razor/resources/assets/css/css/components/document-layout.css b/packages/razor/resources/assets/css/css/components/document-layout.css index 51fc1173..30b261a3 100644 --- a/packages/razor/resources/assets/css/css/components/document-layout.css +++ b/packages/razor/resources/assets/css/css/components/document-layout.css @@ -10,6 +10,14 @@ min-block-size: 100vh; } +/* A framed page, once there's room to see the frame -- borderless below that would only be a sliver + of border on either side, not an actual margin. */ +@media (width > 48rem) { + .cm-document-layout { + border-inline: var(--cm-border-width) solid var(--cm-color-border-subtle); + } +} + .cm-document-layout__header { position: sticky; inset-block-start: 0; @@ -45,6 +53,8 @@ .cm-document-layout__aside { position: sticky; inset-block-start: var(--cm-sticky-top-offset, 0); + max-block-size: calc(100dvb - var(--cm-sticky-top-offset, 0)); + overflow-y: auto; min-inline-size: 0; } @@ -56,6 +66,16 @@ border-block-start: var(--cm-border-width) solid var(--cm-color-border-subtle); } +@media (width < 80em) { + .cm-document-layout--sidebar-content-aside .cm-document-layout__content { + grid-template-columns: minmax(0, 15rem) minmax(0, 1fr); + } + + .cm-document-layout--sidebar-content-aside .cm-document-layout__aside { + display: none; + } +} + @media (width < 64em) { .cm-document-layout--sidebar-content .cm-document-layout__content, .cm-document-layout--sidebar-content-aside .cm-document-layout__content { @@ -65,5 +85,7 @@ .cm-document-layout__sidebar, .cm-document-layout__aside { position: static; + max-block-size: none; + overflow-y: visible; } } diff --git a/packages/razor/resources/assets/css/css/components/setup-layout.css b/packages/razor/resources/assets/css/css/components/setup-layout.css index 22449153..b448805d 100644 --- a/packages/razor/resources/assets/css/css/components/setup-layout.css +++ b/packages/razor/resources/assets/css/css/components/setup-layout.css @@ -52,3 +52,67 @@ .cm-setup-layout--aside-left .cm-setup-layout__aside { order: -1; } + +/* + * An aside stacks above the form by default -- the same single centered card either way. Once + * there's room, a wizard with an aside (an illustration, a summary) earns a second column instead: + * the two sit side by side, divided by one border, rather than one queued above the other. + */ +@media (width > 48rem) { + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__panel { + display: grid; + grid-template-columns: minmax(0, 1fr) 18rem; + grid-template-areas: + 'brand aside' + 'toolbar aside' + 'header aside' + 'main aside'; + align-items: start; + gap: 0; + max-inline-size: 56rem; + padding: 0; + overflow: hidden; + } + + .cm-setup-layout--aside-left:has(.cm-setup-layout__aside) .cm-setup-layout__panel { + grid-template-columns: 18rem minmax(0, 1fr); + grid-template-areas: + 'aside brand' + 'aside toolbar' + 'aside header' + 'aside main'; + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__brand { + grid-area: brand; + padding: var(--cm-space-4) var(--cm-space-4) 0; + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__toolbar { + grid-area: toolbar; + padding: var(--cm-space-3) var(--cm-space-4) 0; + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__header { + grid-area: header; + padding: var(--cm-space-4) var(--cm-space-4) 0; + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__main { + grid-area: main; + padding: var(--cm-space-4); + } + + .cm-setup-layout:has(.cm-setup-layout__aside) .cm-setup-layout__aside { + grid-area: aside; + align-self: stretch; + padding: var(--cm-space-4); + border-inline-start: var(--cm-border-width) solid var(--cm-color-border-default); + } + + .cm-setup-layout--aside-left:has(.cm-setup-layout__aside) .cm-setup-layout__aside { + order: 0; + border-inline-start: 0; + border-inline-end: var(--cm-border-width) solid var(--cm-color-border-default); + } +} diff --git a/packages/razor/resources/assets/manifest.json b/packages/razor/resources/assets/manifest.json index c0be8136..3e0c527c 100644 --- a/packages/razor/resources/assets/manifest.json +++ b/packages/razor/resources/assets/manifest.json @@ -114,7 +114,7 @@ "css:components/admin-layout.css": { "path": "css/css/components/admin-layout.css", "mediaType": "text/css", - "sha256": "954a8b362b15c76bbc22e9910e0e44e54aabbdf15a53aa0a0f8e9864ad8836c3", + "sha256": "bfd1ca47a9b86359a5f704b5d4afd8645cfd45ba324b8bb8c8723ea9aaa81691", "source": { "package": "@codemonster-ru/ui-css", "version": "2.0.0-dev.0", @@ -124,7 +124,7 @@ "css:components/admin-shell.css": { "path": "css/css/components/admin-shell.css", "mediaType": "text/css", - "sha256": "59a0d6c65ad3a9c2194413e516b6d4019e1e819f04f0e429e5915917778da906", + "sha256": "18622f3fe0ac95fe7f559e4c360e35f645002aae2862cf4dab954a694537f239", "source": { "package": "@codemonster-ru/ui-css", "version": "2.0.0-dev.0", @@ -144,7 +144,7 @@ "css:components/app-shell.css": { "path": "css/css/components/app-shell.css", "mediaType": "text/css", - "sha256": "9bfdaa294df92b1083ba748311b6062713b6f68f9c80f92d1ff2730b828db3b3", + "sha256": "02ef5a296b291b7facb43faff5ff936eeae1f90caab7333245128c5f77548c63", "source": { "package": "@codemonster-ru/ui-css", "version": "2.0.0-dev.0", @@ -284,7 +284,7 @@ "css:components/document-layout.css": { "path": "css/css/components/document-layout.css", "mediaType": "text/css", - "sha256": "3354af03dcb0782e7090560b48c7095fc2c9f37bf44f024b8718ddc02c8d772c", + "sha256": "70622e2f555352d091da40cf06e9327bf9ecb8d4d4b2eba6e142204a8472180d", "source": { "package": "@codemonster-ru/ui-css", "version": "2.0.0-dev.0", @@ -484,7 +484,7 @@ "css:components/setup-layout.css": { "path": "css/css/components/setup-layout.css", "mediaType": "text/css", - "sha256": "b237f6f9684cdb47ca6bb85f992ea81ae3aafe725cada03e969fc51a782ef548", + "sha256": "00ec6d285e68df94611b534542a73481852c26a270b23b41b82b28f4fc16e6e6", "source": { "package": "@codemonster-ru/ui-css", "version": "2.0.0-dev.0",