@neovici/cosmoz-slideout
v2.0.0
Published
A reusable top-layer slideout / drawer web component built with pionjs and lit-html
Readme
cosmoz-slideout
A top-layer slideout (drawer / sidebar) web component built with pionjs and lit-html, in a non-modal and a modal flavor.
This package ships three custom elements (two that compose, one modal wrapper):
<cosmoz-slideout>- the surface. It is itself the popover (<cosmoz-slideout popover="manual">): the element renders in the browser top-layer via the native Popover API, is non-modal (the page behind stays interactive), and slides in from the right when its reactiveopenedstate becomes true. It owns everything around the content - theopened/full-screenlifecycle, dismissal requests (Escape, hardware back), and focus management - and exposes a single blank slot. It adds no chrome of its own.<cosmoz-slideout-panel>- layout chrome: aheaderregion, a scrollable paddedbody, and afooterregion - no properties. Empty slots are completely invisible (region wrappers have no box of their own; spacing and dividers are painted on the slotted elements). The header, its title, and any close control are slotted in. It owns no open/close lifecycle - it is meant to be slotted into a<cosmoz-slideout>.<cosmoz-modal-slideout>- the modal drawer: the same slot fed by a wrapper element whose inner nativedialog(promoted withshowModal()) is the surface:aria-modal="true", a scrim backdrop that absorbs its clicks, an inert page behind, a focus trap, and platform-owned dismissal with a veto (cancelbeforeclose). See the modal drawer.
99% of the time you pair a slideout with the slideout-panel, but you don't have to: the surface owns the lifecycle, the panel owns the layout -
<cosmoz-slideout .opened="${this.supplierOpen}" aria-label="Supplier">
<cosmoz-slideout-panel>
<my-header slot="header">Supplier</my-header>
<p>…body…</p>
<div slot="footer">…actions…</div>
</cosmoz-slideout-panel>
</cosmoz-slideout>The surface does not open merely by being in the DOM - it stays in place and slides in/out as
opened toggles.
Installation
npm i @neovici/cosmoz-slideout// the surface
import '@neovici/cosmoz-slideout/cosmoz-slideout';
// the layout chrome (separate entrypoint)
import '@neovici/cosmoz-slideout/cosmoz-slideout-panel';Opening & closing
opened is a reactive, two-way value on <cosmoz-slideout> backed by the opened attribute.
Drive it however suits you - a lit property binding (.opened=${x}), a
boolean attribute binding (?opened), a bare opened in static HTML, or the open()/close()
methods - and listen for the cancelable opened-changed event (the element's intent, vetoes
included); the surface self-closes on Escape
and close(), and removing the opened attribute (e.g. from devtools) closes it too. The element
persists in the DOM across open/close cycles - there is no add-to-open / remove-to-close dance.
<!-- lit-html two-way binding -->
<cosmoz-slideout
.opened="${this.open}"
@opened-changed="${(e) => (this.open = e.detail.value)}"
>
<cosmoz-slideout-panel>
<my-header slot="header">Acme</my-header>
<p>…body…</p>
</cosmoz-slideout-panel>
</cosmoz-slideout>Or with pion's lift directive: @opened-changed=${lift(setOpen)}. Imperatively, call open() /
close() on the <cosmoz-slideout> element.
A child asks the surface to close by dispatching a bubbling, cancelable request-close event -
your slotted header's close button does exactly this, so it never needs a reference to the slideout.
Your own buttons can do the same, or call closest('cosmoz-slideout')?.close(). To guard a close
("unsaved changes"), call preventDefault() on request-close, or on the cancelable opened-changed.
The modal drawer - <cosmoz-modal-slideout>
A second element for drawers that block out the page: confirmations, detail panels over dense
tables. One blank slot, the same opened lifecycle, the same slide animation - carried by the
platform's modal primitive: the element wraps a native dialog (promoted with
showModal()), so the page behind is inert, focus is trapped (leaky only to browser
chrome, per platform), the scrim backdrop absorbs its clicks, and dismissal is vetoes-able
again: Esc and the backdrop arrive through the cancelable funnel.
<cosmoz-modal-slideout
.opened="${this.open}"
@opened-changed="${(e) => (this.open = e.detail.value)}"
>
<cosmoz-slideout-panel>
<my-header slot="header">Acme</my-header>
<p>…body…</p>
</cosmoz-slideout-panel>
</cosmoz-modal-slideout><cosmoz-modal-slideout> API
Attributes & properties
opened- show/hide the drawer; identical two-way reactive attribute as on<cosmoz-slideout>(.opened=${x},?opened, bare markup, oropen()/close()).full-screen- when present the drawer covers the whole document. Flip the attribute or calltoggleFullScreen().aria-label- the drawer's name, mirrored onto the inner dialog (the focused, top-layer element).aria-labelledbyis not offered: the label id would have to live in the dialog's shadow tree, and slotted light-DOM ids cannot resolve across that boundary - usearia-label.aria-modal="true"- set by the element itself; the type's constant, not configuration (the native dialog's modal machinery carries it). Use<cosmoz-slideout>for non-modal.- No
no-escape. Escape is the platform'scancelrequest here; disable-on-escape is not a per-instance knob (veto it throughopened-changedinstead).
Methods
open()- setopenedto true (play the slide-in;showModal()).close()- setopenedto false (play the slide-out;dialog.close()).toggleFullScreen()- toggle thefull-screenstate.
Events
opened-changed- the element's intent to changeopened, dispatched before the write, cancelable:close(),request-close, Esc (cancel) and the backdrop click all funnel through it (preventDefault()vetoes - an unsaved-changes guard works on every close source here, unlike the popover flavor's platform dismissals;detail = { value }).close- the inner dialog's own record of the flip, fired whatever the closer; the platform event (not cancelable), also reachable for post-flip timing. A dialog fires notoggle- the record here isclose; on the non-modal surface it istoggle.full-screen-changed- dispatched when thefull-screenstate changes;detail = { value }(bubbles, cancelable).request-close- listened for, not emitted; identical to the surface.
Slot
- default - the same single blank slot; drop a
<cosmoz-slideout-panel>in for the styled layout, or author your own chrome.
Dismissal model
The platform owns dismissal, and it is vetoable. Esc arrives as the dialog's cancel event
(cancelable) before the flip; the element bridges it through the funnel, so
preventDefault() on opened-changed holds the drawer open. A click on the scrim backdrop
is funneled the same way (the click's target is the dialog - the backdrop absorbs it, nothing
behind receives it). Programmatic closes, a slotted request-close, and attribute writes work
exactly as on <cosmoz-slideout>.
Modality stacks; popover light-dismisses. Modal drawers do not
light-dismiss one another (sibling-close is popover="auto" law; dialogs
stack). One Esc closes every open modal drawer (the platform's
cancel broadcast reaches each one), each recording through its own
funnel; focus rides the
platform's dialog focus restore. Open modals also inert the non-modal
surfaces and the rest of
the page - that is what "modal" means here.
Backdrop
::backdrop fades with the surface's duration/easing tokens and its color is
--cosmoz-slideout-backdrop (default color-mix(in srgb, --cz-color-bg-overlay 50%, transparent)).
It is the dialog's own ::backdrop: a click on it closes the drawer and is absorbed - the
page behind never sees it.
Accessibility
- Actually modal, per the platform:
showModal()inerts the page behind and traps focus (excepting browser chrome, platform-consensus behavior) - thearia-modal="true"on the inner dialog is the truth, not an ask. The scrim covers the page for pointers, keyboard and screen readers (content behind is removed from the a11y tree). - The drawer's name: set
aria-labelon<cosmoz-modal-slideout>(mirrored onto the dialog; the string label is the reliable name -aria-labelledbycannot resolve slotted light-DOM ids across the shadow boundary). - Focus behavior on open is the dialog's: the focusing steps honor
autofocusin your content (or on the dialog). On close the platform restores focus to the pre-show element; the non-modal surface's hook supplies the same itself.
API
<cosmoz-slideout> - the surface
Attributes & properties
opened- show/hide the slideout: a reactive, two-way attribute (pairs with the cancelableopened-changedevent). Drive it via the property (.opened=${x}), an attribute (?opened, or a bareopenedin markup), oropen()/close(); removing the attribute (e.g. from devtools) closes it. Defaultfalse(closed).full-screen- when present the surface covers the whole document. Flip the attribute or calltoggleFullScreen().no-escape- disable the built-in Escape-to-close.aria-label- label for the drawer. Set it explicitly - the panel carries no text of its own to name it with (and never reaches into the element). Note:aria-labelledbyis an IDREF and only resolves to an element in the same tree as the element, not a slotted one - usearia-label(a plain string) to name the drawer.
Methods
open()- setopenedto true (play the slide-in).close()- setopenedto false (play the slide-out).toggleFullScreen()- toggle thefull-screenstate (also settable via the attribute).
Events
opened-changed- dispatched (bubbling, cancelable, before the write) when the surface intends to changeopened(open()/close(), Escape/back-button,request-close, attribute reconcile);detail = { value }. Use it for two-way binding;preventDefault()vetoes the change (an unsaved-changes guard).toggle/beforetoggle- the platform's ownToggleEvents: the record of the popover flip, fired whatever the closer and not cancelable;newStateis'open'/'closed'. Post-flip timing rides this, or atransitionendlistener hung off the element (it is the popover).full-screen-changed- dispatched when thefull-screenstate changes;detail = { value }(bubbles, cancelable -preventDefault()vetoes the flip).request-close- listened for, not emitted: a bubbling, cancelable event from any descendant (e.g. your slotted close button) that asks the surface to close;preventDefault()vetoes.
Slot
- default - a single blank slot. Drop a
<cosmoz-slideout-panel>in for the styled layout, or author your own chrome. The single slotted child is stretched to fill the slideout, so wrap hand-composed header/body/footer in one top-level element.
<cosmoz-slideout-panel> - the layout chrome
Structural layout only; it holds no opened/close() and must live inside a
<cosmoz-slideout>.
Properties
None. The panel is pure layout: all content - including the header title and any close control - is slotted in. There is nothing to configure.
Slots
header- the non-scrolling header region (e.g. your header component with title/text and a close control dispatchingrequest-close).- default - the body / main content (scrollable).
footer- the non-scrolling footer region (e.g. actions), with a divider above it.
Empty slots are completely invisible - the wrappers render no box of their own; spacing, borders,
and the footer divider live on the slotted elements (::slotted(*)). Fill a region
only when you need it.
<!-- header with a close control + body + footer actions -->
<cosmoz-slideout .opened="${open}" aria-label="Acme">
<cosmoz-slideout-panel>
<my-header slot="header">
Acme Industries
<cosmoz-button
slot="suffix"
aria-label="Close"
@click="${fireRequestClose}"
>✕</cosmoz-button
>
</my-header>
<p>…body…</p>
<div slot="footer">…actions…</div>
</cosmoz-slideout-panel>
</cosmoz-slideout>
<!-- custom header: any slotted element with a request-close control -->
<cosmoz-slideout .opened="${open}" aria-label="Notes">
<cosmoz-slideout-panel>
<my-header slot="header">…</my-header>
<p>…body…</p>
</cosmoz-slideout-panel>
</cosmoz-slideout>
<!-- bare surface: hand-compose your own chrome in the single slot -->
<cosmoz-slideout .opened="${open}" aria-label="Edit supplier">
<div style="display: flex; flex-direction: column; height: 100%;">
<h2>Edit supplier</h2>
<div>…body…</div>
<div>…actions…</div>
</div>
</cosmoz-slideout>Render-site helpers (/helpers)
Typed template helpers so consumers never hand-write the bindings - their only purpose is typing support (they are not element-definition factories; import the elements separately to register them):
import '@neovici/cosmoz-slideout/cosmoz-slideout';
import '@neovici/cosmoz-slideout/cosmoz-slideout-panel';
import { slideout, slideoutPanel } from '@neovici/cosmoz-slideout/helpers';
slideout(
{ opened, onOpenedChanged: (e) => (opened = e.detail.value) },
slideoutPanel(
{},
html`
<my-header slot="header">Details</my-header>
<p>…body…</p>
`,
),
);slideout(props, content) accepts opened, fullScreen, noEscape, ariaLabel,
ariaLabelledby, class, style, and the event handlers onOpenedChanged /
onFullScreenChanged (so plain listeners and pion's lift both compose). Those on*
props are render-helper sugar for listening to the element's events; the
element itself has no callback properties. slideoutPanel(props, content) accepts only class and
style (the panel is property-free). The SlideoutProps type is exported from the package root.
modalSlideout(props, content) is the modal drawer's helper: the same props minus noEscape
(which does not exist on <cosmoz-modal-slideout>) and ariaLabelledby (the dialog's name is
the mirrored aria-label string); ModalSlideoutProps is exported too.
Typed lookups
HTMLElementTagNameMap is augmented by importing the element modules, so DOM queries return fully
typed elements:
import '@neovici/cosmoz-slideout/cosmoz-slideout';
import '@neovici/cosmoz-slideout/cosmoz-modal-slideout';
const view = document.querySelector('cosmoz-slideout');
view.opened; // typed
view.close(); // typed
const modal = document.querySelector('cosmoz-modal-slideout'); // typed
modal.open(); // typed
const panel = document.querySelector('cosmoz-slideout-panel'); // typed (property-free)CSS ::part()
<cosmoz-slideout>: none - the element is the surface; style it via :host-level custom
properties (below) or the cosmoz-slideout element selector.
<cosmoz-slideout-panel>: header, body, footer - the layout regions.
Accessibility
The element carries role="dialog", aria-modal="false" (non-modal by design), and
tabindex="-1" itself (set by the base class; an authored role wins) - the latter makes the
surface a valid focus target for the Popover API. Label it via aria-label on
<cosmoz-slideout> - set it explicitly; nothing fills it in for you. The close control (in your
slotted header) carries its own accessible name, e.g. "Close". On open, the browser's popover
focusing steps move focus: mark a focus target in your content with the standard autofocus
attribute (<cosmoz-input autofocus> lands on its field), or put autofocus on the surface
itself to announce the drawer by name; with no autofocus anywhere, focus does not move. On
close, focus returns to the opener when focus was still inside the drawer at the moment it
closed (that check is captured then, before the popover hides). Escape (and hardware/gesture back navigation) closes the most recently opened
slideout - each open element holds a close-request session with the browser (newest first
regardless of where focus is). Because the drawer is non-modal, the page behind stays reachable -
this is intentional (quick-glance panels).
The opener that focus is restored to is whatever was focused at the moment opened became true.
Set opened synchronously inside the opening handler (e.g. the click); if you set it after an
await, the original opener may no longer be focused and restoration is skipped.
ARIA guidance
- Give the drawer an accessible name - always set
aria-label(a string):<cosmoz-slideout aria-label="Supplier details">. Without a name the dialog is announced as bare "dialog" and screen-reader users can't tell what it contains. Preferaria-labeloveraria-labelledby: the labelled id must live in the light DOM (the surface is the element itself now, but the shadow slot content isn't addressable by IDREF reliably), whilearia-labelalways works. If the drawer has visible heading text and the heading element can carry anaria-labelledby-addressable id in the light DOM, either is acceptable - just never leave it unnamed. role="dialog"+aria-modal="false"is intentional on<cosmoz-slideout>: non-modal dialogs keep the page behind interactive, so the drawer does not trap focus. Do not fliparia-modaltotrueby hand. For a modal drawer use<cosmoz-modal-slideout>- it carriesaria-modal="true"and the backdrop (see its own section above; popover modality still does not trap focus, per the platform).- The drawer is a landmark-free dialog: don't rely on
aria-expanded/aria-controlswiring from the trigger button - instead the trigger togglesopened, and theopened-changed(intent) +toggle(record) events let you announce state if you must (aria-live). Keep the announcements minimal: drawer open/close is signaled by focus movement itself. - Focus is the state announcer: on open, the browser's popover focusing steps move focus to
your
autofocus-marked field (composite components work -<cosmoz-input autofocus>focuses its inner input) or to the surface whenautofocusis on the element itself; on close, focus returns to the opener. Noautofocusanywhere means focus does not move on open. - Loading states: announce with
aria-live="polite"on a slotted status node oraria-busyfrom the author's own slotted markup - the panel itself adds no busy/announcement attributes (property-free chrome). - Escape/close buttons: every close control should have an accessible name
(
aria-label="Close") and rely on the cancelablerequest-closecontract so an unsaved-changes veto stays available to any listener.
CSS custom properties
Set these like any custom property - e.g. <cosmoz-slideout style="--cosmoz-slideout-width: 30vw">.
Each default resolves to a @neovici/cosmoz-tokens
--cz-* token when the consumer app loads cosmoz-tokens (so a bare slideout matches the design
system and honors dark mode), and falls back to the plain value shown below otherwise. Every visual
embellishment is opt-out: override its property (e.g. --cosmoz-slideout-border: none).
<cosmoz-slideout> (the surface):
--cosmoz-slideout-width- width (defaultmin(400px, 100vw); capped at100vw, so it goes full-width once the viewport is narrower than the panel).--cosmoz-slideout-bg- background (default--cz-color-bg-primary, else#fff).--cosmoz-slideout-color- text color (default--cz-color-text-primary, elseinherit).--cosmoz-slideout-shadow- box-shadow (default--cz-shadow-xl, else-8px 0 24px rgb(10 13 18 / 18%)).--cosmoz-slideout-border- left edge / ring (default1px solid --cz-color-border-secondary; setnoneto remove).--cosmoz-slideout-full-screen-width- width whenfull-screen(default100vw).--cosmoz-slideout-duration- enter (slide-in) duration (default0.3s).--cosmoz-slideout-exit-duration- close (slide-out) duration (default: same as-duration).--cosmoz-slideout-easing- transition easing (defaultcubic-bezier(0.4, 0, 0.2, 1)).
prefers-reduced-motion: reduce disables the transition regardless.
<cosmoz-slideout-panel> (the layout chrome):
--cosmoz-slideout-panel-padding-x- slotted header/footer + body horizontal padding (default--cz-spacing * 4).--cosmoz-slideout-panel-gap- vertical gap between body children (default--cz-spacing * 6).--cosmoz-slideout-panel-divider- footer top divider color (default--cz-color-border-secondary).
Notes & caveats
openedis an observed attribute. Property (.opened), attribute (?opened/ bareopened), andopen()/close()all drive it, and removing the attribute closes the surface. Setting.openedbefore the element's module has loaded (lazy/dynamic definition) is the one gap - a pre-upgrade own-property can shadow the reactive accessor; prefer the attribute (or set the property after definition) for markup-time state.- Multiple open slideouts all render pinned to the right edge and therefore stack on top of one
another (top-layer LIFO). Escape and back navigation target the most-recently-opened one
(each instance holds its own close-request session with the browser - no shared state between
instances).
<cosmoz-modal-slideout>instead dialogs: modal drawers stack (opening one does not close another) and one Esc closes every open modal drawer - the platform'scancelbroadcast reaches each dialog (see the modal drawer section). - Close controls live in your slotted header and close by dispatching
request-close. They are independent of Escape-to-close, which is a surface behavior controlled byno-escape(on<cosmoz-slideout>) and stays active either way. <cosmoz-slideout-panel>pulls in only its layout.@neovici/cosmoz-tokensis a regulardependencyof this package; buttons/icons come from whatever you slot in.
Development
npm i
npm run storybook:startPublishing
Uses Changesets. Add a changeset with npm run changeset, then open a release PR.
