diff --git a/docs/src/_includes/layouts/base.njk b/docs/src/_includes/layouts/base.njk index 966026b..1e631d4 100644 --- a/docs/src/_includes/layouts/base.njk +++ b/docs/src/_includes/layouts/base.njk @@ -70,6 +70,7 @@ {# JS #} + @@ -109,4 +110,4 @@ {% from "toast.njk" import toaster %} {{ toaster() }} - \ No newline at end of file + diff --git a/docs/src/_includes/partials/kitchen-sink/drawer.njk b/docs/src/_includes/partials/kitchen-sink/drawer.njk new file mode 100644 index 0000000..df067c5 --- /dev/null +++ b/docs/src/_includes/partials/kitchen-sink/drawer.njk @@ -0,0 +1,30 @@ +{% from "drawer.njk" import drawer %} + +
+
+

Drawer

+ + {% lucide "book-open", { "class": "size-4" } %} + +
+
+
+ {% set footer %} + + {% endset %} + {% call drawer( + id="kitchen-sink-drawer", + trigger="Open drawer", + trigger_attrs={"class": "btn-outline"}, + title="Kitchen sink drawer", + description="A modal drawer component.", + footer=footer, + side="bottom" + ) %} +
+ Drag the handle to close or snap. +
+ {% endcall %} +
+
+
diff --git a/docs/src/_includes/partials/sidebar.njk b/docs/src/_includes/partials/sidebar.njk index 470dc65..b038a40 100644 --- a/docs/src/_includes/partials/sidebar.njk +++ b/docs/src/_includes/partials/sidebar.njk @@ -100,6 +100,7 @@ { label: "Command", url: "/components/command", attrs: new_link_attrs }, { label: "Combobox", url: "/components/combobox", attrs: link_attrs }, { label: "Dialog", url: "/components/dialog", attrs: link_attrs }, + { label: "Drawer", url: "/components/drawer", attrs: new_link_attrs }, { label: "Dropdown Menu", url: "/components/dropdown-menu", attrs: link_attrs }, { label: "Empty", url: "/components/empty", attrs: new_link_attrs }, { label: "Field", url: "/components/field", attrs: new_link_attrs }, diff --git a/docs/src/assets/styles.css b/docs/src/assets/styles.css index fae88c8..a7e7fc3 100644 --- a/docs/src/assets/styles.css +++ b/docs/src/assets/styles.css @@ -70,6 +70,7 @@ --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); + --ease-out: cubic-bezier(0, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --animate-spin: spin 1s linear infinite; --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; @@ -272,6 +273,9 @@ .absolute { position: absolute; } + .fixed { + position: fixed; + } .relative { position: relative; } @@ -511,6 +515,12 @@ .h-14 { height: calc(var(--spacing) * 14); } + .h-48 { + height: calc(var(--spacing) * 48); + } + .h-\[80vh\] { + height: 80vh; + } .h-full { height: 100%; } @@ -795,6 +805,13 @@ margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); } } + .space-y-3 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse))); + } + } .space-y-4 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; @@ -1060,6 +1077,9 @@ .py-\[0\.2rem\] { padding-block: 0.2rem; } + .pt-0 { + padding-top: calc(var(--spacing) * 0); + } .pt-1\.5 { padding-top: calc(var(--spacing) * 1.5); } @@ -3491,6 +3511,183 @@ } } } +@layer components { + .drawer { + position: fixed; + inset: calc(var(--spacing) * 0); + z-index: 50; + margin: calc(var(--spacing) * 0); + border-style: var(--tw-border-style); + border-width: 0px; + background-color: transparent; + padding: calc(var(--spacing) * 0); + --bc-drawer-backdrop-opacity: 0; + } + .drawer::backdrop { + background-color: color-mix(in srgb, #000 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-black) 50%, transparent); + } + transition-property: all; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + transition-behavior: allow-discrete; + opacity: var(--bc-drawer-backdrop-opacity); + } + .drawer > * { + position: fixed; + z-index: 50; + display: flex; + height: auto; + flex-direction: column; + background-color: var(--color-background); + --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-duration: 200ms; + transition-duration: 200ms; + --tw-ease: var(--ease-out); + transition-timing-function: var(--ease-out); + overflow: hidden; + overscroll-behavior: contain; + isolation: isolate; + } + .drawer[data-drawer-dragging='true'] > * { + transition-property: none; + } + .drawer[data-side='bottom'] > * { + inset-inline: calc(var(--spacing) * 0); + bottom: calc(var(--spacing) * 0); + margin-top: calc(var(--spacing) * 24); + max-height: 80vh; + width: 100%; + border-top-left-radius: var(--radius-lg); + border-top-right-radius: var(--radius-lg); + border-top-style: var(--tw-border-style); + border-top-width: 1px; + padding-bottom: env(safe-area-inset-bottom); + --bc-drawer-translate: 100%; + transform: translate3d(0, var(--bc-drawer-translate), 0); + } + .drawer[data-side='top'] > * { + inset-inline: calc(var(--spacing) * 0); + top: calc(var(--spacing) * 0); + margin-bottom: calc(var(--spacing) * 24); + max-height: 80vh; + width: 100%; + border-bottom-right-radius: var(--radius-lg); + border-bottom-left-radius: var(--radius-lg); + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + padding-top: env(safe-area-inset-top); + --bc-drawer-translate: -100%; + transform: translate3d(0, var(--bc-drawer-translate), 0); + } + .drawer[data-side='right'] > * { + inset-block: calc(var(--spacing) * 0); + right: calc(var(--spacing) * 0); + height: 100%; + width: calc(3/4 * 100%); + border-left-style: var(--tw-border-style); + border-left-width: 1px; + @media (width >= 40rem) { + max-width: var(--container-sm); + } + --bc-drawer-translate: 100%; + transform: translate3d(var(--bc-drawer-translate), 0, 0); + } + .drawer[data-side='left'] > * { + inset-block: calc(var(--spacing) * 0); + left: calc(var(--spacing) * 0); + height: 100%; + width: calc(3/4 * 100%); + border-right-style: var(--tw-border-style); + border-right-width: 1px; + @media (width >= 40rem) { + max-width: var(--container-sm); + } + --bc-drawer-translate: -100%; + transform: translate3d(var(--bc-drawer-translate), 0, 0); + } + .drawer:is([open], :popover-open) { + --bc-drawer-backdrop-opacity: 1; + } + .drawer:is([open], :popover-open) > * { + --bc-drawer-translate: 0%; + } + .drawer > * > header { + display: flex; + flex-direction: column; + gap: calc(var(--spacing) * 0.5); + padding: calc(var(--spacing) * 4); + text-align: center; + @media (width >= 48rem) { + gap: calc(var(--spacing) * 1.5); + } + @media (width >= 48rem) { + text-align: left; + } + > h2 { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + color: var(--color-foreground); + } + > p { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + color: var(--color-muted-foreground); + } + } + .drawer[data-side='left'] > * > header, .drawer[data-side='right'] > * > header { + text-align: left; + } + .drawer > * > footer { + margin-top: auto; + display: flex; + flex-direction: column; + gap: calc(var(--spacing) * 2); + padding: calc(var(--spacing) * 4); + } + .drawer > * > section { + flex: 1; + overflow-y: auto; + } + .drawer > * > [data-drawer-handle] { + border-radius: calc(infinity * 1px); + background-color: var(--color-muted); + opacity: 80%; + touch-action: none; + -webkit-user-select: none; + user-select: none; + cursor: grab; + } + .drawer[data-drawer-dragging='true'] > * > [data-drawer-handle] { + cursor: grabbing; + } + .drawer[data-side='bottom'] > * > [data-drawer-handle], .drawer[data-side='top'] > * > [data-drawer-handle] { + margin-inline: auto; + margin-top: calc(var(--spacing) * 4); + height: calc(var(--spacing) * 2); + width: 100px; + flex-shrink: 0; + } + .drawer[data-side='left'] > * > [data-drawer-handle], .drawer[data-side='right'] > * > [data-drawer-handle] { + position: absolute; + top: calc(1/2 * 100%); + height: 100px; + width: calc(var(--spacing) * 2); + --tw-translate-y: calc(calc(1/2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .drawer[data-side='left'] > * > [data-drawer-handle] { + right: calc(var(--spacing) * 2); + } + .drawer[data-side='right'] > * > [data-drawer-handle] { + left: calc(var(--spacing) * 2); + } +} @layer components { .dropdown-menu { position: relative; diff --git a/docs/src/components/drawer.njk b/docs/src/components/drawer.njk new file mode 100644 index 0000000..7b5713c --- /dev/null +++ b/docs/src/components/drawer.njk @@ -0,0 +1,233 @@ +--- +layout: layouts/page.njk +title: Drawer +description: A mobile-friendly modal drawer that slides from any side. +toc: + - label: Usage + id: usage + children: + - label: HTML + JavaScript + id: usage-html-js + children: + - label: "Step 1: Include the JavaScript file" + id: usage-html-js-1 + - label: "Step 2: Add your drawer HTML" + id: usage-html-js-2 + - label: HTML structure + id: usage-html-js-3 + - label: Data attributes + id: usage-html-js-4 + - label: JavaScript events + id: usage-html-js-5 + - label: Jinja and Nunjucks + id: usage-macro + - label: Examples + id: examples + children: + - label: Sides + id: example-sides + - label: Snap points + id: example-snap-points +--- + +{% from "macros/code_preview.njk" import code_preview %} +{% from "macros/code_block.njk" import code_block %} +{% from "drawer.njk" import drawer %} + +{% set code_html %} +{% set footer %} + + +{% endset %} +{% call drawer( + id="demo-drawer", + trigger="Open drawer", + trigger_attrs={"class": "btn-outline"}, + title="Are you absolutely sure?", + description="This action cannot be undone.", + footer=footer, + side="bottom" +) %} +
+

+ Drag the handle to close or snap. Press Esc to close. +

+
+{% endcall %} +{% endset %} + +{{ code_preview("drawer", code_html | prettyHtml) }} + +

Usage

+ +

HTML + JavaScript

+ +

Step 1: Include the JavaScript file

+ +
+

You can either include the JavaScript file for all the components, or just the one for this component by adding this to the <head> of your page:

+
+ +{% set code_script %} +{% endset %} +{{ code_block(code_script | prettyHtml, "html") }} + +
+ + Components with JavaScript + {% lucide "arrow-right" %} + + + Use the CLI + {% lucide "arrow-right" %} + +
+ +

Step 2: Add your drawer HTML

+ +{{ code_block(code_html | prettyHtml, "html") }} + +

HTML structure

+ +
+
+
<button type="button" data-drawer-trigger="{ DRAWER_ID }">
+
Opens the drawer with the given id.
+
<dialog class="drawer" id="{ DRAWER_ID }">
+
The drawer modal element. It uses <dialog> for native modality and focus handling.
+
<button type="button" data-drawer-close>
+
Closes the currently open drawer (use this for close buttons and footer actions).
+
+
+ +

Data attributes

+ +
+ +
+ +

JavaScript events

+ +
+
+
basecoat:initialized
+
Dispatched on the drawer once it is initialized.
+
basecoat:drawer
+
Dispatched on document when a drawer opens (other drawers listen for this to close).
+
drawer:open, drawer:close, drawer:snap
+
Dispatched on the drawer element for state changes.
+
+
+ +

Jinja and Nunjucks

+ +
+

You can use the drawer() Nunjucks or Jinja macro for this component.

+
+ +
+ + Use Nunjucks or Jinja macros + {% lucide "arrow-right" %} + + + Jinja macro + {% lucide "arrow-right" %} + + + Nunjucks macro + {% lucide "arrow-right" %} + +
+ +{% set raw_code %}{% raw %}{% set footer %} + + +{% endset %} +{% call drawer( + id="demo-drawer", + trigger="Open drawer", + trigger_attrs={"class": "btn-outline"}, + title="Are you absolutely sure?", + description="This action cannot be undone.", + footer=footer, + side="bottom" +) %} +
+ Drawer content… +
+{% endcall %}{% endraw %}{% endset %} +{{ code_block(raw_code, "jinja") }} + +

Examples

+ +

Sides

+ +{% set code_sides %} +
+ + + + +
+ +{% set footer %} + +{% endset %} + +{% call drawer(id="drawer-bottom", title="Bottom drawer", footer=footer, side="bottom") %} +
Bottom drawer content.
+{% endcall %} + +{% call drawer(id="drawer-top", title="Top drawer", footer=footer, side="top") %} +
Top drawer content.
+{% endcall %} + +{% call drawer(id="drawer-left", title="Left drawer", footer=footer, side="left") %} +
Left drawer content.
+{% endcall %} + +{% call drawer(id="drawer-right", title="Right drawer", footer=footer, side="right") %} +
Right drawer content.
+{% endcall %} +{% endset %} + +{{ code_preview("drawer-sides", code_sides | prettyHtml) }} + +

Snap points

+ +{% set code_snap %} + + +{% set footer %} + +{% endset %} + +{% call drawer( + id="drawer-snap", + title="Snap points", + description="Opens at 50% and can snap to 25%/50%/100%.", + footer=footer, + side="bottom", + snap_points="0.25,0.5,1", + default_snap=0.5, + content_attrs={"class": "h-[80vh]"} +) %} +
+

Drag the handle to snap between points.

+
+ Content placeholder +
+
+{% endcall %} +{% endset %} + +{{ code_preview("drawer-snap-points", code_snap | prettyHtml) }} + diff --git a/docs/src/kitchen-sink.njk b/docs/src/kitchen-sink.njk index 915ac1c..3520af5 100644 --- a/docs/src/kitchen-sink.njk +++ b/docs/src/kitchen-sink.njk @@ -15,6 +15,7 @@ description: A collection of all the components available in Basecoat. { label: "Checkbox", id: "checkbox" }, { label: "Combobox", id: "combobox" }, { label: "Dialog", id: "dialog" }, + { label: "Drawer", id: "drawer" }, { label: "Dropdown Menu", id: "dropdown-menu" }, { label: "Form", id: "form" }, { label: "Input", id: "input" }, diff --git a/package-lock.json b/package-lock.json index df412f2..49bc00d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "basecoat", - "version": "0.3.2", + "version": "0.3.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "basecoat", - "version": "0.3.2", + "version": "0.3.9", "license": "MIT", "workspaces": [ "packages/*" @@ -3435,6 +3435,7 @@ "integrity": "sha512-JcEmHlyLK/o0uGAlj65vgg+7LIms0xKXe60lcDOTU7oVX/3LuEuLwrQpW3VJ7de5TaFKiW4kWkaIpJL42FEgxQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "posthtml-parser": "^0.11.0", "posthtml-render": "^3.0.0" @@ -4337,7 +4338,7 @@ }, "packages/cli": { "name": "basecoat-cli", - "version": "0.3.2", + "version": "0.3.9", "license": "MIT", "dependencies": { "commander": "^13.1.0", @@ -4350,7 +4351,7 @@ }, "packages/css": { "name": "basecoat-css", - "version": "0.3.2", + "version": "0.3.9", "license": "MIT" } } diff --git a/packages/css/package.json b/packages/css/package.json index 110f906..7ef3bdc 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -44,6 +44,8 @@ "./basecoat.min": "./dist/js/basecoat.min.js", "./all": "./dist/js/all.js", "./all.min": "./dist/js/all.min.js", + "./drawer": "./dist/js/drawer.js", + "./drawer.min": "./dist/js/drawer.min.js", "./command": "./dist/js/command.js", "./command.min": "./dist/js/command.min.js", "./dropdown-menu": "./dist/js/dropdown-menu.js", @@ -60,4 +62,4 @@ "./toast.min": "./dist/js/toast.min.js", "./package.json": "./package.json" } -} \ No newline at end of file +} diff --git a/scripts/build.js b/scripts/build.js index 261c373..806b519 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -121,7 +121,7 @@ async function build() { // Create combined component files console.log('Creating combined component files...'); - const componentsToCombine = ['basecoat.js', 'command.js', 'dropdown-menu.js', 'popover.js', 'select.js', 'sidebar.js', 'tabs.js', 'toast.js']; + const componentsToCombine = ['basecoat.js', 'drawer.js', 'command.js', 'dropdown-menu.js', 'popover.js', 'select.js', 'sidebar.js', 'tabs.js', 'toast.js']; const componentPaths = componentsToCombine.map(f => path.join(srcJsDir, f)); // Create non-minified bundle diff --git a/src/css/basecoat.css b/src/css/basecoat.css index b07860a..93fb143 100644 --- a/src/css/basecoat.css +++ b/src/css/basecoat.css @@ -657,6 +657,116 @@ } } +/* Drawer */ +@layer components { + .drawer { + @apply fixed inset-0 z-50 m-0 p-0 border-0 bg-transparent; + --bc-drawer-backdrop-opacity: 0; + } + + .drawer::backdrop { + @apply bg-black/50 transition-all transition-discrete; + opacity: var(--bc-drawer-backdrop-opacity); + } + + .drawer > * { + @apply bg-background fixed z-50 flex h-auto flex-col shadow-lg; + @apply transition-transform duration-200 ease-out; + @apply overflow-hidden; + @apply overscroll-contain; + isolation: isolate; + } + + .drawer[data-drawer-dragging='true'] > * { + @apply transition-none; + } + + .drawer[data-side='bottom'] > * { + @apply inset-x-0 bottom-0 mt-24 max-h-[80vh] w-full rounded-t-lg border-t; + padding-bottom: env(safe-area-inset-bottom); + --bc-drawer-translate: 100%; + transform: translate3d(0, var(--bc-drawer-translate), 0); + } + + .drawer[data-side='top'] > * { + @apply inset-x-0 top-0 mb-24 max-h-[80vh] w-full rounded-b-lg border-b; + padding-top: env(safe-area-inset-top); + --bc-drawer-translate: -100%; + transform: translate3d(0, var(--bc-drawer-translate), 0); + } + + .drawer[data-side='right'] > * { + @apply inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm; + --bc-drawer-translate: 100%; + transform: translate3d(var(--bc-drawer-translate), 0, 0); + } + + .drawer[data-side='left'] > * { + @apply inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm; + --bc-drawer-translate: -100%; + transform: translate3d(var(--bc-drawer-translate), 0, 0); + } + + .drawer:is([open], :popover-open) { + --bc-drawer-backdrop-opacity: 1; + } + .drawer:is([open], :popover-open) > * { + --bc-drawer-translate: 0%; + } + + .drawer > * > header { + @apply flex flex-col gap-0.5 p-4 text-center md:gap-1.5 md:text-left; + + > h2 { + @apply text-foreground font-semibold; + } + > p { + @apply text-muted-foreground text-sm; + } + } + + .drawer[data-side='left'] > * > header, + .drawer[data-side='right'] > * > header { + @apply text-left; + } + + .drawer > * > footer { + @apply mt-auto flex flex-col gap-2 p-4; + } + + .drawer > * > section { + @apply flex-1 overflow-y-auto; + } + + .drawer > * > [data-drawer-handle] { + @apply bg-muted rounded-full opacity-80; + @apply touch-none select-none; + cursor: grab; + } + + .drawer[data-drawer-dragging='true'] > * > [data-drawer-handle] { + cursor: grabbing; + } + + .drawer[data-side='bottom'] > * > [data-drawer-handle], + .drawer[data-side='top'] > * > [data-drawer-handle] { + @apply mx-auto mt-4 h-2 w-[100px] shrink-0; + } + + .drawer[data-side='left'] > * > [data-drawer-handle], + .drawer[data-side='right'] > * > [data-drawer-handle] { + @apply absolute top-1/2 -translate-y-1/2 h-[100px] w-2; + } + + .drawer[data-side='left'] > * > [data-drawer-handle] { + @apply right-2; + } + + .drawer[data-side='right'] > * > [data-drawer-handle] { + @apply left-2; + } +} + /* Dropdown Menu */ @layer components { .dropdown-menu { diff --git a/src/jinja/drawer.html.jinja b/src/jinja/drawer.html.jinja new file mode 100644 index 0000000..e59f90c --- /dev/null +++ b/src/jinja/drawer.html.jinja @@ -0,0 +1,122 @@ +{# + Renders a drawer component (modal sheet). + + @param id {string} [optional] - Unique identifier for the drawer component. + @param trigger {string} [optional] - Text or HTML for the button that opens the drawer. + @param title {string} [optional] - Title text displayed in the drawer header. + @param description {string} [optional] - Description text displayed below the title. + @param footer {string} [optional] - HTML content for the drawer footer. + @param side {string} [optional] [default="bottom"] - Side the drawer opens from ("bottom", "top", "left", "right"). + @param snap_points {string} [optional] - Comma-separated snap points as fractions (e.g. "0.25,0.5,1"). + @param default_snap {number} [optional] - Default snap point when opening (0..1). + @param velocity_threshold {number} [optional] - Velocity threshold for snapping (px/ms). + @param handle {boolean} [optional] [default=true] - Whether to render a drag handle. + @param handle_only {boolean} [optional] [default=true] - Whether dragging is only possible from the handle. + @param close_on_overlay_click {boolean} [optional] [default=true] - Whether clicking the overlay closes the drawer. + @param close_button {boolean} [optional] [default=false] - Whether to include a close button. + @param drawer_attrs {object} [optional] - Additional HTML attributes for the . + @param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button. + @param content_attrs {object} [optional] - Additional HTML attributes for the drawer content wrapper. + @param header_attrs {object} [optional] - Additional HTML attributes for the header element. + @param body_attrs {object} [optional] - Additional HTML attributes for the body section. + @param footer_attrs {object} [optional] - Additional HTML attributes for the footer element. +#} +{% macro drawer( + id=None, + trigger=None, + title=None, + description=None, + footer=None, + side="bottom", + snap_points=None, + default_snap=None, + velocity_threshold=None, + handle=true, + handle_only=true, + close_on_overlay_click=true, + close_button=false, + drawer_attrs={}, + trigger_attrs={}, + content_attrs={}, + header_attrs={}, + body_attrs={}, + footer_attrs={} +) %} +{% set id = id or ("drawer-" + (range(100000, 999999) | random | string)) %} +{% if trigger %} + +{% endif %} + +
+ {% if handle %} + + {% endif %} + + {% if title or description %} +
+ {% if title %}

{{ title | safe }}

{% endif %} + {% if description %}

{{ description | safe }}

{% endif %} +
+ {% endif %} + + {% if caller %} +
+ {{ caller() }} +
+ {% endif %} + + {% if footer %} +
+ {{ footer | safe }} +
+ {% endif %} + + {% if close_button %} + + {% endif %} +
+
+{% endmacro %} diff --git a/src/js/drawer.js b/src/js/drawer.js new file mode 100644 index 0000000..9ee7acb --- /dev/null +++ b/src/js/drawer.js @@ -0,0 +1,500 @@ +(() => { + const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + + const parseBoolean = (value, defaultValue) => { + if (value == null) return defaultValue; + if (value === '') return true; + if (value === 'true') return true; + if (value === 'false') return false; + return defaultValue; + }; + + const parseNumber = (value, defaultValue) => { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : defaultValue; + }; + + const parseSnapPoints = (value) => { + const raw = (value || '') + .split(',') + .map(s => Number.parseFloat(s.trim())) + .filter(n => Number.isFinite(n)) + .map(n => clamp(n, 0, 1)); + + const points = Array.from(new Set([0, ...raw, 1])).sort((a, b) => a - b); + return points.length ? points : [0, 1]; + }; + + const getSide = (drawer) => (drawer.dataset.side || 'bottom').toLowerCase(); + + const getSideConfig = (side) => { + switch (side) { + case 'top': + return { axis: 'y', closeSign: -1 }; + case 'bottom': + return { axis: 'y', closeSign: 1 }; + case 'left': + return { axis: 'x', closeSign: -1 }; + case 'right': + return { axis: 'x', closeSign: 1 }; + default: + return { axis: 'y', closeSign: 1 }; + } + }; + + const getClosedTranslate = (side) => { + switch (side) { + case 'top': + case 'left': + return '-100%'; + case 'bottom': + case 'right': + default: + return '100%'; + } + }; + + const getPanel = (drawer) => drawer.querySelector(':scope > div'); + + const setTranslatePercent = (drawer, openFraction) => { + const panel = getPanel(drawer); + if (!panel) return; + + const side = getSide(drawer); + const { closeSign } = getSideConfig(side); + + const open = clamp(openFraction, 0, 1); + let translate = (1 - open) * 100 * closeSign; + if (Math.abs(translate) < 0.001) translate = 0; + + panel.style.setProperty('--bc-drawer-translate', `${translate}%`); + drawer.__basecoatDrawerSnap = open; + }; + + const setBackdropOpacity = (drawer, value) => { + drawer.style.setProperty( + '--bc-drawer-backdrop-opacity', + clamp(value, 0, 1).toString() + ); + }; + + const getFocusable = (root) => { + const candidates = root.querySelectorAll( + [ + 'a[href]:not([tabindex="-1"])', + 'area[href]:not([tabindex="-1"])', + 'button:not([disabled]):not([tabindex="-1"])', + 'input:not([disabled]):not([tabindex="-1"])', + 'select:not([disabled]):not([tabindex="-1"])', + 'textarea:not([disabled]):not([tabindex="-1"])', + 'iframe:not([tabindex="-1"])', + '[tabindex]:not([tabindex="-1"])', + '[contenteditable="true"]:not([tabindex="-1"])', + ].join(',') + ); + + return Array.from(candidates).filter(el => { + const styles = window.getComputedStyle(el); + if (styles.display === 'none' || styles.visibility === 'hidden') return false; + return el.offsetParent !== null || styles.position === 'fixed'; + }); + }; + + const focusAutofocus = (drawer) => { + const panel = getPanel(drawer); + if (!panel) return; + + const autofocus = panel.querySelector('[autofocus]'); + if (autofocus) { + autofocus.focus(); + return; + } + + const focusables = getFocusable(panel); + if (focusables.length) { + focusables[0].focus(); + } + }; + + const setTriggersExpanded = (drawer, expanded) => { + const id = drawer.id; + if (!id) return; + document + .querySelectorAll(`[data-drawer-trigger="${CSS.escape(id)}"]`) + .forEach(trigger => trigger.setAttribute('aria-expanded', expanded ? 'true' : 'false')); + }; + + let scrollLockCount = 0; + let prevOverflow = null; + let prevPaddingRight = null; + + const lockScroll = () => { + scrollLockCount += 1; + if (scrollLockCount !== 1) return; + + const root = document.documentElement; + prevOverflow = root.style.overflow; + prevPaddingRight = root.style.paddingRight; + + const scrollbarWidth = window.innerWidth - root.clientWidth; + root.style.overflow = 'hidden'; + if (scrollbarWidth > 0) { + root.style.paddingRight = `${scrollbarWidth}px`; + } + }; + + const unlockScroll = () => { + scrollLockCount = Math.max(0, scrollLockCount - 1); + if (scrollLockCount !== 0) return; + + const root = document.documentElement; + root.style.overflow = prevOverflow || ''; + root.style.paddingRight = prevPaddingRight || ''; + prevOverflow = null; + prevPaddingRight = null; + }; + + const waitForTransformTransition = (panel, callback) => { + let done = false; + + const finish = () => { + if (done) return; + done = true; + panel.removeEventListener('transitionend', onEnd); + callback(); + }; + + const onEnd = (event) => { + if (event.target !== panel) return; + if (event.propertyName !== 'transform') return; + finish(); + }; + + panel.addEventListener('transitionend', onEnd); + + const parseTimeMs = (time) => { + const value = time.trim(); + if (!value) return 0; + if (value.endsWith('ms')) return Number.parseFloat(value) || 0; + if (value.endsWith('s')) return (Number.parseFloat(value) || 0) * 1000; + return (Number.parseFloat(value) || 0) * 1000; + }; + + const styles = window.getComputedStyle(panel); + const durations = styles.transitionDuration.split(',').map(parseTimeMs); + const delays = styles.transitionDelay.split(',').map(parseTimeMs); + const maxMs = Math.max( + ...durations.map((d, i) => d + (delays[i] || 0)) + ); + window.setTimeout(finish, Math.max(0, maxMs) + 50); + }; + + const openDrawer = (drawer, trigger = null) => { + if (!(drawer instanceof HTMLDialogElement)) return; + if (drawer.open) return; + + const panel = getPanel(drawer); + if (!panel) { + console.error('Drawer initialization failed. Missing panel element.', drawer); + return; + } + + const side = getSide(drawer); + const snapPoints = parseSnapPoints(drawer.dataset.snapPoints); + const defaultSnap = clamp( + parseNumber(drawer.dataset.defaultSnap, Math.max(...snapPoints)), + 0, + 1 + ); + + drawer.__basecoatDrawerLastTrigger = trigger || document.activeElement; + drawer.__basecoatDrawerFocusOnClose = true; + + drawer.dataset.drawerDragging = 'false'; + drawer.dataset.drawerState = 'opening'; + + panel.style.setProperty('--bc-drawer-translate', getClosedTranslate(side)); + setBackdropOpacity(drawer, 0); + + try { + drawer.showModal(); + } catch (error) { + console.error('Failed to open drawer with showModal().', error, drawer); + return; + } + + lockScroll(); + setTriggersExpanded(drawer, true); + + document.dispatchEvent(new CustomEvent('basecoat:drawer', { detail: { source: drawer } })); + drawer.dispatchEvent(new CustomEvent('drawer:open', { detail: { snap: defaultSnap } })); + + requestAnimationFrame(() => { + drawer.dataset.drawerState = 'open'; + setTranslatePercent(drawer, defaultSnap); + setBackdropOpacity(drawer, defaultSnap); + focusAutofocus(drawer); + drawer.dispatchEvent(new CustomEvent('drawer:snap', { detail: { snap: defaultSnap } })); + }); + }; + + const closeDrawer = (drawer, { focusOnTrigger = true } = {}) => { + if (!(drawer instanceof HTMLDialogElement)) return; + if (!drawer.open) return; + + const panel = getPanel(drawer); + if (!panel) return; + + drawer.__basecoatDrawerFocusOnClose = focusOnTrigger; + drawer.dataset.drawerDragging = 'false'; + drawer.dataset.drawerState = 'closing'; + + setBackdropOpacity(drawer, 0); + panel.style.setProperty('--bc-drawer-translate', getClosedTranslate(getSide(drawer))); + + waitForTransformTransition(panel, () => { + if (!drawer.open) return; + drawer.close(); + }); + }; + + const snapDrawer = (drawer, snap, { closeOnSnapToZero = true } = {}) => { + const snapPoints = parseSnapPoints(drawer.dataset.snapPoints); + const target = clamp(snap, 0, 1); + + if (closeOnSnapToZero && target === 0) { + closeDrawer(drawer); + return; + } + + drawer.dataset.drawerState = 'open'; + setTranslatePercent(drawer, target); + setBackdropOpacity(drawer, target); + drawer.dispatchEvent(new CustomEvent('drawer:snap', { detail: { snap: target, snapPoints } })); + }; + + const chooseSnapPoint = (snapPoints, currentOpen, velocityClose, velocityThreshold) => { + const points = [...snapPoints].sort((a, b) => a - b); + const current = clamp(currentOpen, 0, 1); + const threshold = Math.max(0, velocityThreshold); + const epsilon = 0.001; + + if (velocityClose > threshold) { + const lower = points.filter(p => p < current - epsilon); + return lower.length ? lower[lower.length - 1] : 0; + } + + if (velocityClose < -threshold) { + const higher = points.filter(p => p > current + epsilon); + return higher.length ? higher[0] : 1; + } + + return points.reduce((nearest, p) => { + if (nearest == null) return p; + return Math.abs(p - current) < Math.abs(nearest - current) ? p : nearest; + }, null); + }; + + const initDrawer = (drawer) => { + if (!(drawer instanceof HTMLDialogElement)) { + console.error('Drawer must be a element.', drawer); + return; + } + + const panel = getPanel(drawer); + if (!panel) { + console.error('Drawer initialization failed. Missing panel element.', drawer); + return; + } + + const handle = drawer.querySelector('[data-drawer-handle]'); + const id = drawer.id; + + drawer.dataset.drawerState = drawer.open ? 'open' : 'closed'; + drawer.dataset.drawerDragging = 'false'; + drawer.__basecoatDrawerSnap = 1; + + setTriggersExpanded(drawer, drawer.open); + setBackdropOpacity(drawer, drawer.open ? 1 : 0); + + drawer.addEventListener('cancel', (event) => { + event.preventDefault(); + closeDrawer(drawer); + }); + + drawer.addEventListener('click', (event) => { + if (event.target !== drawer) return; + const closeOnOverlayClick = parseBoolean(drawer.dataset.closeOnOverlayClick, true); + if (!closeOnOverlayClick) return; + closeDrawer(drawer); + }); + + drawer.addEventListener('close', () => { + drawer.dataset.drawerState = 'closed'; + drawer.dataset.drawerDragging = 'false'; + + unlockScroll(); + setTriggersExpanded(drawer, false); + setBackdropOpacity(drawer, 0); + + const lastTrigger = drawer.__basecoatDrawerLastTrigger; + const focusOnClose = drawer.__basecoatDrawerFocusOnClose !== false; + + if (focusOnClose && lastTrigger instanceof HTMLElement && document.contains(lastTrigger)) { + lastTrigger.focus(); + } + + drawer.dispatchEvent(new CustomEvent('drawer:close', { detail: { id } })); + }); + + drawer.addEventListener('basecoat:drawer', (event) => { + const action = event.detail?.action; + if (!action) return; + if (event.detail?.id && event.detail.id !== drawer.id) return; + if (action === 'open') openDrawer(drawer); + if (action === 'close') closeDrawer(drawer, { focusOnTrigger: false }); + }); + + const startDrag = (event) => { + if (!drawer.open) return; + if (event.button != null && event.button !== 0) return; + + const handleOnly = parseBoolean(drawer.dataset.handleOnly, true); + if (handleOnly && handle && event.target !== handle && !event.target.closest('[data-drawer-handle]')) { + return; + } + if (!handleOnly && event.target.closest('button, a, input, textarea, select, [role="button"], [data-drawer-no-drag]')) { + return; + } + + drawer.dataset.drawerDragging = 'true'; + drawer.dataset.drawerState = 'dragging'; + + const side = getSide(drawer); + const { axis } = getSideConfig(side); + const rect = panel.getBoundingClientRect(); + const size = axis === 'y' ? rect.height : rect.width; + + const startX = event.clientX; + const startY = event.clientY; + const snapPoints = parseSnapPoints(drawer.dataset.snapPoints); + const startOpen = clamp(drawer.__basecoatDrawerSnap ?? 1, 0, 1); + const startCloseOffset = (1 - startOpen) * size; + + let lastTime = performance.now(); + let lastCloseOffset = startCloseOffset; + let velocityClose = 0; + + const dragTarget = handleOnly && handle ? handle : panel; + dragTarget.setPointerCapture(event.pointerId); + + const onMove = (moveEvent) => { + const dx = moveEvent.clientX - startX; + const dy = moveEvent.clientY - startY; + + const deltaClose = side === 'bottom' + ? dy + : side === 'top' + ? -dy + : side === 'right' + ? dx + : -dx; + + const closeOffset = clamp(startCloseOffset + deltaClose, 0, size); + const open = size > 0 ? 1 - closeOffset / size : 1; + + setTranslatePercent(drawer, open); + setBackdropOpacity(drawer, open); + + const now = performance.now(); + const dt = now - lastTime; + if (dt > 0) { + velocityClose = (closeOffset - lastCloseOffset) / dt; + lastTime = now; + lastCloseOffset = closeOffset; + } + + moveEvent.preventDefault(); + }; + + const finishDrag = () => { + drawer.dataset.drawerDragging = 'false'; + + const open = clamp(drawer.__basecoatDrawerSnap ?? 1, 0, 1); + const velocityThreshold = parseNumber(drawer.dataset.velocityThreshold, 1); + const targetSnap = chooseSnapPoint(snapPoints, open, velocityClose, velocityThreshold); + + drawer.dispatchEvent(new CustomEvent('drawer:snap', { detail: { snap: targetSnap } })); + + if (targetSnap === 0) { + closeDrawer(drawer); + return; + } + + drawer.dataset.drawerState = 'open'; + snapDrawer(drawer, targetSnap, { closeOnSnapToZero: false }); + }; + + const onUp = () => { + dragTarget.removeEventListener('pointermove', onMove); + dragTarget.removeEventListener('pointerup', onUp); + dragTarget.removeEventListener('pointercancel', onUp); + finishDrag(); + }; + + dragTarget.addEventListener('pointermove', onMove); + dragTarget.addEventListener('pointerup', onUp); + dragTarget.addEventListener('pointercancel', onUp); + }; + + panel.addEventListener('pointerdown', startDrag); + + drawer.addEventListener('click', (event) => { + const closeButton = event.target.closest('[data-drawer-close]'); + if (!closeButton) return; + if (!drawer.contains(closeButton)) return; + closeDrawer(drawer); + }); + + drawer.dataset.drawerInitialized = true; + drawer.dispatchEvent(new CustomEvent('basecoat:initialized')); + }; + + const installGlobalHandlers = () => { + if (window.__basecoatDrawerGlobalHandlersInstalled) return; + window.__basecoatDrawerGlobalHandlersInstalled = true; + + document.addEventListener('click', (event) => { + const trigger = event.target.closest('[data-drawer-trigger]'); + if (!trigger) return; + + const id = trigger.getAttribute('data-drawer-trigger'); + if (!id) return; + + const drawer = document.getElementById(id); + if (!drawer || !drawer.classList.contains('drawer')) return; + + if (!drawer.dataset.drawerInitialized) { + initDrawer(drawer); + } + + openDrawer(drawer, trigger); + }); + + document.addEventListener('basecoat:drawer', (event) => { + const source = event.detail?.source; + if (!source) return; + + document.querySelectorAll('dialog.drawer[open]').forEach((drawer) => { + if (drawer === source) return; + closeDrawer(drawer, { focusOnTrigger: false }); + }); + }); + }; + + installGlobalHandlers(); + + if (window.basecoat) { + window.basecoat.register('drawer', '.drawer:not([data-drawer-initialized])', initDrawer); + } +})(); diff --git a/src/nunjucks/drawer.njk b/src/nunjucks/drawer.njk new file mode 100644 index 0000000..a1c3ce6 --- /dev/null +++ b/src/nunjucks/drawer.njk @@ -0,0 +1,122 @@ +{# + Renders a drawer component (modal sheet). + + @param id {string} [optional] - Unique identifier for the drawer component. + @param trigger {string} [optional] - Text or HTML for the button that opens the drawer. + @param title {string} [optional] - Title text displayed in the drawer header. + @param description {string} [optional] - Description text displayed below the title. + @param footer {string} [optional] - HTML content for the drawer footer. + @param side {string} [optional] [default="bottom"] - Side the drawer opens from ("bottom", "top", "left", "right"). + @param snap_points {string} [optional] - Comma-separated snap points as fractions (e.g. "0.25,0.5,1"). + @param default_snap {number} [optional] - Default snap point when opening (0..1). + @param velocity_threshold {number} [optional] - Velocity threshold for snapping (px/ms). + @param handle {boolean} [optional] [default=true] - Whether to render a drag handle. + @param handle_only {boolean} [optional] [default=true] - Whether dragging is only possible from the handle. + @param close_on_overlay_click {boolean} [optional] [default=true] - Whether clicking the overlay closes the drawer. + @param close_button {boolean} [optional] [default=false] - Whether to include a close button. + @param drawer_attrs {object} [optional] - Additional HTML attributes for the . + @param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button. + @param content_attrs {object} [optional] - Additional HTML attributes for the drawer content wrapper. + @param header_attrs {object} [optional] - Additional HTML attributes for the header element. + @param body_attrs {object} [optional] - Additional HTML attributes for the body section. + @param footer_attrs {object} [optional] - Additional HTML attributes for the footer element. +#} +{% macro drawer( + id=None, + trigger=None, + title=None, + description=None, + footer=None, + side="bottom", + snap_points=None, + default_snap=None, + velocity_threshold=None, + handle=true, + handle_only=true, + close_on_overlay_click=true, + close_button=false, + drawer_attrs={}, + trigger_attrs={}, + content_attrs={}, + header_attrs={}, + body_attrs={}, + footer_attrs={} +) %} +{% set id = id or ("drawer-" + (range(100000, 999999) | random | string)) %} +{% if trigger %} + +{% endif %} + +
+ {% if handle %} + + {% endif %} + + {% if title or description %} +
+ {% if title %}

{{ title | safe }}

{% endif %} + {% if description %}

{{ description | safe }}

{% endif %} +
+ {% endif %} + + {% if caller %} +
+ {{ caller() }} +
+ {% endif %} + + {% if footer %} +
+ {{ footer | safe }} +
+ {% endif %} + + {% if close_button %} + + {% endif %} +
+
+{% endmacro %}