diff --git a/docs/css/custom.css b/docs/css/custom.css index 0742668..13f5738 100644 --- a/docs/css/custom.css +++ b/docs/css/custom.css @@ -22,12 +22,12 @@ } .prose { @apply my-6; - + a { @apply font-medium underline underline-offset-4; } code:not(pre > code) { - @apply relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm; + @apply relative rounded-md bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm; } blockquote { @apply border-l-4 border-border pl-4; diff --git a/docs/css/highlight.css b/docs/css/highlight.css new file mode 100644 index 0000000..fbd4686 --- /dev/null +++ b/docs/css/highlight.css @@ -0,0 +1,151 @@ +/* + Custom Highlight.js theme with CSS variables + derived from github.min.css and github-dark.min.css +*/ + +:root { + /* Base */ + --hljs-bg: #fff; + --hljs-color: #24292e; + + /* Tokens */ + --hljs-keyword: #d73a49; + --hljs-title: #6f42c1; + --hljs-literal: #005cc5; + --hljs-string: #032f62; + --hljs-symbol: #e36209; + --hljs-comment: #6a737d; + --hljs-tag: #22863a; + --hljs-section: #005cc5; + --hljs-bullet: #735c0f; + + /* Diffs */ + --hljs-addition-color: #22863a; + --hljs-addition-bg: #f0fff4; + --hljs-deletion-color: #b31d28; + --hljs-deletion-bg: #ffeef0; +} + +html.dark { + /* Base */ + --hljs-bg: #0d1117; + --hljs-color: #c9d1d9; + + /* Tokens */ + --hljs-keyword: #ff7b72; + --hljs-title: #d2a8ff; + --hljs-literal: #79c0ff; + --hljs-string: #a5d6ff; + --hljs-symbol: #ffa657; + --hljs-comment: #8b949e; + --hljs-tag: #7ee787; + --hljs-section: #1f6feb; + --hljs-bullet: #f2cc60; + + /* Diffs */ + --hljs-addition-color: #aff5b4; + --hljs-addition-bg: #033a16; + --hljs-deletion-color: #ffdcd7; + --hljs-deletion-bg: #67060c; +} + +/* --- Base --- */ +pre code.hljs{ + display:block; + overflow-x:auto; + padding:1em +} +code.hljs{ + padding:3px 5px +} + +/* --- Mapped styles --- */ +.hljs { + color: var(--hljs-color); + background: var(--hljs-bg); +} + +.hljs-doctag, +.hljs-keyword, +.hljs-meta .hljs-keyword, +.hljs-template-tag, +.hljs-template-variable, +.hljs-type, +.hljs-variable.language_ { + color: var(--hljs-keyword); +} + +.hljs-title, +.hljs-title.class_, +.hljs-title.class_.inherited__, +.hljs-title.function_ { + color: var(--hljs-title); +} + +.hljs-attr, +.hljs-attribute, +.hljs-literal, +.hljs-meta, +.hljs-number, +.hljs-operator, +.hljs-selector-attr, +.hljs-selector-class, +.hljs-selector-id, +.hljs-variable { + color: var(--hljs-literal); +} + +.hljs-meta .hljs-string, +.hljs-regexp, +.hljs-string { + color: var(--hljs-string); +} + +.hljs-built_in, +.hljs-symbol { + color: var(--hljs-symbol); +} + +.hljs-code, +.hljs-comment, +.hljs-formula { + color: var(--hljs-comment); +} + +.hljs-name, +.hljs-quote, +.hljs-selector-pseudo, +.hljs-selector-tag { + color: var(--hljs-tag); +} + +.hljs-subst { + color: var(--hljs-color); +} + +.hljs-section { + color: var(--hljs-section); + font-weight: 700; +} + +.hljs-bullet { + color: var(--hljs-bullet); +} + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: 700; +} + +.hljs-addition { + color: var(--hljs-addition-color); + background-color: var(--hljs-addition-bg); +} + +.hljs-deletion { + color: var(--hljs-deletion-color); + background-color: var(--hljs-deletion-bg); +} \ No newline at end of file diff --git a/docs/css/styles.css b/docs/css/styles.css index 3a80496..029084f 100644 --- a/docs/css/styles.css +++ b/docs/css/styles.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @import "../../src/css/basecoat.css"; @import "./custom.css"; +@import "./highlight.css"; @import "./themes/claude.css"; @import "./themes/doom-64.css"; diff --git a/docs/src/_includes/layouts/base.njk b/docs/src/_includes/layouts/base.njk index 038e022..9f06e34 100644 --- a/docs/src/_includes/layouts/base.njk +++ b/docs/src/_includes/layouts/base.njk @@ -65,7 +65,7 @@ - {# #} + @@ -73,7 +73,6 @@ - -{% endset %} - -{{ code_preview("combobox", code_script | prettyHtml ~ code_html | prettyHtml) }} +{{ code_preview("combobox", code_html | prettyHtml) }}

Usage

-

HTML + JavaScript

+

HTML + JavaScript

-
- {% lucide "circle-alert" %} -

This component requires JavaScript.

-
+

Step 1: Include the JavaScript files

-
-

Combobox uses the same Alpine.js code and markup as the Select component, the only difference being the search box at the top of the listbox.

-

You can set up a few additional options on the <div data-popover> element:

- +
+

Include the select.js file. It is recommended to also include the CSS anchor positioning polyfill to support older browsers. Add this to the <head> of your page:

+
-

You can include the JavaScript code provided below, load it as an individual file or use the CLI. Some Alpine.js properties are also required on certain elements (e.g. x-bind, x-data, @click).

-
+{% set code_script %}{% raw %}{% endraw %}{% endset %} +{{ code_block(code_script | prettyHtml, "html") }}
@@ -81,17 +70,66 @@ document.addEventListener('alpine:init', () => { {% lucide "arrow-right" %} - Individual JS file (select.js) + select.js {% lucide "arrow-right" %}
-{% from "tabs.njk" import tabs %} -{% set tabsets = [ - { tab: "HTML", panel: code_block(code_html | prettyHtml, class="") }, - { tab: "Javascipt (Alpine.js)", panel: code_block(code_script | prettyHtml, class="") } -] %} -{{ tabs(id="combobox", tabsets=tabsets) }} +

Step 2: Add your combobox HTML

+ +
+

Combobox uses the same markup and JavaScript as the Select component, the only difference being the search box at the top of the listbox:

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

HTML structure

+ +
+
+
<div class="select">
+
Wraps around the entire component. +
+
<button type="button" popovertarget="{ POPOVER_ID }">
+
+

The trigger to open the popover, can also have the following attributes:

+
    +
  • id="{BUTTON_ID}": linked to by the aria-labelledby attribute of the listbox.
  • +
  • aria-haspopup="listbox": indicates that the button opens a listbox.
  • +
  • aria-controls="{ LISTBOX_ID }": points to the listbox's id.
  • +
  • aria-expanded="false": tracks the popover's state.
  • +
  • aria-activedescendant="{ OPTION_ID }": points to the active option's id.
  • +
+
+
<div popover class="popover" id="{ POPOVER_ID }">
+
As with the Popover component, you can set up the side and alignment of the popover using the data-side and data-align attributes. +
+
<header>
+
Contains the search input to filter the options in the listbox.
+
<div role="listbox">
+
The listbox containing the options. Should have the following attributes: +
    +
  • id="{ LISTBOX_ID }": refered to by the aria-controls attribute of the trigger.
  • +
  • aria-labelledby="{ BUTTON_ID }": linked to by the button's id attribute.
  • +
+
+
<div role="option" data-value="{ VALUE }">
+
Option that can be selected.Should have a unique id if you use the aria-activedescendant attribute on the trigger.
+
<hr role="separator"> Optional
+
Separator between groups/options.
+
<div role="group"> Optional
+
Group of options, can have a aria-labelledby attribute to link to a heading.
+
<span role="heading">
+
Group heading, must have an id attribute if you use the aria-labelledby attribute on the group.
+
+
+
+
+
+
<input type="hidden" name="{ NAME }" value="{ VALUE }"> Optional
+
The hidden input that holds the value of the field (if needed).
+
+

Jinja and Nunjucks

diff --git a/docs/src/components/dialog.njk b/docs/src/components/dialog.njk index 160a04f..4671108 100644 --- a/docs/src/components/dialog.njk +++ b/docs/src/components/dialog.njk @@ -8,8 +8,6 @@ toc: children: - label: HTML id: usage-html - - label: JavasScript - id: usage-html - label: Jinja and Nunjucks id: usage-macro - label: Examples @@ -25,8 +23,8 @@ toc: {% set code_html %} {% set footer %} - - + + {% endset %} {% call dialog( id="demo-dialog-edit-profile", @@ -56,35 +54,46 @@ toc:

HTML

+{{ code_block(code_html | prettyHtml, class="") }} +
-

The component has the following structure:

+

The component has the following HTML structure:

-
<dialog class="dialog">
-
Wraps around the entire component and acts as its backdrop. +
<button type="button"> Optional
+
The trigger button to open the dialog (using the native showModal() method).
+
<dialog class="dialog" id="{ DIALOG_ID }">
+
Wraps the whole component and acts as the backdrop. It can also have the following attributes: +
    +
  • aria-labelledby="{ TITLE_ID }": linked to by the aria-labelledby attribute of the <dialog>.
  • +
  • aria-describedby="{ DESCRIPTION_ID }": linked to by the aria-describedby attribute of the <dialog>.
  • +
  • onclick: closes the dialog when the backdrop is clicked.
  • +
-
<header>
-
Contains the header for the dialog: +
<article>
+
Wraps the content of the dialog. It includes an onclick attribute to prevent from closing the dialog if we have an onclick event on <dialog>.
-
<h2>
-
The title of the dialog.
-
<p> Optional
-
The description of the dialog.
+
<header>
+
Contains the header for the dialog: +
+
<h2>
+
The title of the dialog, must have an id if you use the aria-labelledby attribute on the <dialog>.
+
<p> Optional
+
The description of the dialog, must have an id if you use the aria-describedby attribute on the <dialog>.
+
+
+
<section> Optional
+
For the content.
+
<footer> Optional
+
Usually contains actions.
+
<form method="dialog"> Optional
+
A form that contains a single button to close the dialog.
-
<section> Optional
-
For the content.
-
<footer> Optional
-
Usually contains actions.
-
<form method="dialog"> Optional
-
A form that contains a single button to close the dialog.
-

The example below also include a <button type="button> to show how we can open the dialog using the showModal() method as well as aria-related attributes recommended for accessiblity:

.

-{{ code_block(code_html | prettyHtml, class="") }} -

Jinja and Nunjucks

@@ -108,8 +117,8 @@ toc: {% set raw_code %}{% raw %}{% set code_html %} {% set footer %} - - + + {% endset %} {% call dialog( id="demo-dialog-edit-profile", @@ -139,7 +148,7 @@ toc: {% set code %} {% set footer %} - + {% endset %} {% call dialog( id="dialog-example", diff --git a/docs/src/components/dropdown-menu.njk b/docs/src/components/dropdown-menu.njk index 767a287..53023f2 100644 --- a/docs/src/components/dropdown-menu.njk +++ b/docs/src/components/dropdown-menu.njk @@ -7,7 +7,14 @@ toc: id: usage children: - label: HTML + JavaScript - id: usage-default + id: usage-html-js + children: + - label: "Step 1: Include the JavaScript files" + id: usage-html-js-1 + - label: "Step 2: Add your dropdown menu HTML" + id: usage-html-js-2 + - label: HTML structure + id: usage-html-js-3 - label: Jinja and Nunjucks id: usage-macro - label: Examples @@ -25,26 +32,25 @@ toc: {% set code_html %} {% call dropdown_menu( - id="dropdown-menu", + id="demo-dropdown-menu", trigger="Open", trigger_attrs={"class": "btn-outline"}, - content_attrs={"class": "min-w-56"}, - register_on=["alpine:init", "htmx:afterSwap"] + popover_attrs={"class": "min-w-56"} ) %}
My Account - - - + +

Content

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

To use a sidebar, you will need the sidebar itself and a sibling container for the content of the page (e.g. <main>), with the following structure for the sidebar:

@@ -43,68 +100,74 @@ toc:

You can include the JavaScript code provided below, load it as an individual file or use the CLI. Some Alpine.js properties are also required on certain elements (e.g. x-bind, x-data, @click).

- +

HTML structure

-{% set code_html %} -{% set icon_square_terminal %}{% lucide "square-terminal" %}{% endset %} -{% set icon_bot %}{% lucide "bot" %}{% endset %} -{% set icon_settings %}{% lucide "settings" %}{% endset %} - -{% set menu = [ - { type: "group", label: "Getting started", items: [ - { label: "Playground", url: "#", icon: icon_square_terminal }, - { label: "Models", url: "#", icon: icon_bot }, - { label: "Settings", type: "submenu", icon: icon_settings, items: [ - { label: "General", url: "#" }, - { label: "Team", url: "#" }, - { label: "Billing", url: "#" }, - { label: "Limits", url: "#" } - ] } - ]} -] %} - -{{ sidebar( - label="Sidebar navigation", - content_attrs={ - "class": "scrollbar" - }, - menu=menu -) }} -
- -

Content

-
-{% endset %} - -{% set code_script %} - -{% endset %} - -{% from "tabs.njk" import tabs %} -{% set tabsets = [ - { tab: "HTML", panel: code_block(code_html | prettyHtml, class="") }, - { tab: "Javascipt (Alpine.js)", panel: code_block(code_script | prettyHtml, class="") } -] %} -{{ tabs(id="sidebar", tabsets=tabsets) }} +
+
+
<aside class="sidebar" aria-hidden="false">
+
Wraps around the entire component. It can have the following attributes: +
    +
  • aria-hidden="true": controls the default state of the sidebar (hidden or visible).
  • +
  • data-side="left": specifies the side of the sidebar (left or right, defaults to left).
  • +
+
+
+
<nav>
+
+

The navigation element that contains the sidebar's content. It can have the following attributes:

+
    +
  • id="{BUTTON_ID}": linked to by the aria-labelledby attribute of the listbox.
  • +
  • aria-haspopup="menu": indicates that the button opens a menu.
  • +
  • aria-controls="{ MENU_ID }": points to the menu's id.
  • +
  • aria-expanded="false": tracks the popover's state.
  • +
+
+
<header> Optional
+
The header of the sidebar.
+
<section>
+
The main navigation list. +
+
<div role="group">
+
Group of options, can have a aria-labelledby attribute to link to a heading.
+
<span role="heading">
+
Group heading, must have an id attribute if you use the aria-labelledby attribute on the group.
+
<ul>
+
List of links or buttons. +
+
<li>
+
Individual item. +
+
<a>
+
A link. By default, clicking on a link will close the sidebar on mobile unless the data-keep-mobile-sidebar-open attribute is present.
+
<button>
+
A button. By default, clicking on a button will close the sidebar on mobile unless the data-keep-mobile-sidebar-open attribute is present.
+
<details>
+
Collapsible section. +
+
<summary>
+
Summary of the collapsible section.
+
<ul>
+
List of links or buttons.
+
+
+
+
+
+
+
+
+
<footer> Optional
+
The footer of the sidebar.
+
+
+
+ +
<main>
+
A wrapper for the content of the page.
+
<button type="button" onclick="window.dispatchEvent(new CustomEvent('sidebar:toggle'))">
+
A button to toggle the sidebar. If you want to use multiple sidebars you will need to add unique ids to the sidebars (i.e. <aside class="sidebar" id="{SIDEBAR_ID}">) and refer to them in the event detail (i.e. window.dispatchEvent(new CustomEvent('sidebar:toggle', { detail: { id: '{SIDEBAR_ID}' } }));).
+
+

Jinja and Nunjucks

diff --git a/docs/src/components/tabs.njk b/docs/src/components/tabs.njk index 79c352d..b990bb2 100644 --- a/docs/src/components/tabs.njk +++ b/docs/src/components/tabs.njk @@ -76,39 +76,19 @@ toc: ) }} {% endset %} -{% set code_script %} - -{% endset %} - -{{ code_preview("tabs", code_script | prettyHtml ~ code_html | prettyHtml, "max-w-[300px] w-full") }} +{{ code_preview("tabs", code_html | prettyHtml, "max-w-[300px] w-full") }}

Usage

-

HTML + JavaScript

- -
- {% lucide "circle-alert" %} -

This component requires JavaScript.

-
+

Step 1: Include the JavaScript files

-

The component is structured as such:

- -
    -
  • A <div class="tabs"> which wraps around the entire component and holds it state (e.g. open/close).
  • -
  • A <nav role="tablist"> that holds the tab buttons (<button role="tab">).
  • -
  • A series of <div role="tabpanel"> that holds the tab panels corresponding to each tab button.
  • -
- -

You can include the JavaScript code provided below, load it as an individual file or use the CLI. Some Alpine.js properties are also required on certain elements (e.g. x-bind, x-data, @click).

+

Include the tabs.js file. Add this to the <head> of your page:

+{% set code_script %}{% raw %}{% endraw %}{% endset %} +{{ code_block(code_script | prettyHtml, "html") }} + -{% from "tabs.njk" import tabs %} -{% set tabsets = [ - { tab: "HTML", panel: code_block(code_html | prettyHtml, class="") }, - { tab: "Javascipt (Alpine.js)", panel: code_block(code_script | prettyHtml, class="") } -] %} -{{ tabs(id="combobox", tabsets=tabsets) }} +

Step 2: Add your select HTML

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

HTML structure

+ +
+
+
<div class="tabs">
+
Wraps around the entire component. +
+
<nav role="tablist" aria-orientation="horizontal">
+
The tablist containing the tab buttons +
+
<button role="tab" id="{ TAB_ID }" aria-controls="{ PANEL_ID }" aria-selected="false" tabindex="0">
+
The tab button. When active, the aria-selected attribute is set to true. tabindex="0" is required for keyboard navigation.
+
+
+
+
+
+
<div role="tabpanel" id="{ PANEL_ID }" aria-labelledby="{ TAB_ID }" tabindex="-1" aria-selected="false">
+
The tab panel. When active, the aria-selected attribute is set to true.
+
+
+

Jinja and Nunjucks

diff --git a/docs/src/components/toast.njk b/docs/src/components/toast.njk index c328077..3a6a7f4 100644 --- a/docs/src/components/toast.njk +++ b/docs/src/components/toast.njk @@ -7,12 +7,18 @@ toc: id: usage children: - label: HTML + JavaScript - id: usage-default + id: usage-html-js children: - - label: Append a toast - id: usage-append-toast - - label: Use $toast() - id: usage-use-magic-method + - label: "Step 1: Include the JavaScript file" + id: usage-html-js-1 + - label: "Step 2: Add the toaster HTML" + id: usage-html-js-2 + - label: "Step 3: Add your toasts" + id: usage-html-js-3 + - label: HTML structure + id: usage-html-js-4 + - label: JavaScript config object + id: usage-html-js-5 - label: Jinja and Nunjucks id: usage-macro --- @@ -21,52 +27,49 @@ toc: {% from "macros/code_preview.njk" import code_preview %} {% from "macros/code_block.njk" import code_block %} -{% set code_htmx %}{% endset %} -{{ code_preview("toast-htmx", code_htmx, "max-w-md") }} +{{ code_preview("toast-server-side", code_server_side, "max-w-md") }} -{% set code_alpine %} -{% endset %} -{{ code_preview("toast-alpine", code_alpine, "max-w-md") }} +{% endset %} +{{ code_preview("toast-client-side", code_client_side, "max-w-md") }}

Usage

-

HTML + JavaScript

+

HTML + JavaScript

-
- {% lucide "circle-alert" %} -

This component requires JavaScript.

-
+

Step 1: Include the JavaScript file

-

To use toasts, you will first need to set up the toaster code to the end of your <body> (see HTML below).

- -

The <template id="toast-template"> bit is only required if you plan on using the $toast() method (see below).

- -

You will also need to include the JavaScript code provided below, load it as an individual file or use the CLI. Some Alpine.js properties are also required on certain elements (e.g. x-bind, x-data, @click).

+

Include the toast.js file. Add this to the <head> of your page:

+{% set code_script %}{% raw %}{% endraw %}{% endset %} +{{ code_block(code_script | prettyHtml, "html") }} + - -{% set code_script %} -{# #} -{% endset %} - -{% set code_html_toaster %}{{ toaster() }}{% endset %} - -{% from "tabs.njk" import tabs %} -{% set tabsets = [ - { tab: "HTML", panel: code_block(code_html_toaster | prettyHtml, class="") }, - { tab: "Javascipt (Alpine.js)", panel: code_block(code_script | prettyHtml, class="") } -] %} -{{ tabs(id="popover", tabsets=tabsets) }} +

Step 2: Add the toaster HTML

-

Once that's in place, we can do one of two things:

- -
    -
  • Append a <div class="toast"> to the toaster.
  • -
  • Use the $toast() Alpine.js "magic method".
  • -
+

Toasts are displayed in a parent element, the "toaster". Add this to this to at the end of your <body>:

-

Append a toast

+{% set code_toaster %}
{% endset %} +{{ code_block(code_toaster | prettyHtml, "html") }} + +

Step 3: Add your toasts

-

To display a toast, just append the following to the toaster:

+

If you decide to server-render your toasts, or load them using asynchronoulsy with something like HTMX, you can just add the toast's markup to the toaster:

-{% set code_toast_simple %}
-
-

This is a toast.

-
-
{% endset %} - -{{ code_block(code_toast_simple | prettyHtml, "html") }} - -
-

You can implement the layout you want within the toast, but here is the standard format used in the examples above:

-
- -{% set code_toast %}{{ toast( +{% set code_toast_html %}
+ {{ toast( category="success", title="Success", description="A success toast called from the front-end.", @@ -137,66 +109,98 @@ document.addEventListener('alpine:init', () => { label: "Dismiss", click: "close()" } -) }}{% endset %} -{{ code_block(code_toast | prettyHtml, "html") }} +) }} +
{% endset %} +{{ code_block(code_toast_html | prettyHtml, "html") }} + +
-

When setting up x-data, you can pass a config object with `category` (e.g. "success", "error", "info", "warning") and `duration` (in milliseconds). By default, the duration will be 3 seconds, and 5 seconds for errors.

- -

This approach is used in the first example with HTMX: when the button is clicked, we retrieve the a fragment that contains the markup for the toast and append it to #toaster.

+

If you need to create a toast from the front-end, you can dispatch a custom basecoat:toast event as such:

-{% set code_htmx %}{% endset %} +{{ code_block(code_client_side | prettyHtml, "html") }} -

Use $toast()

+ + +

HTML structure

-

If you want to display a toast directly from the front-end, you can use the $toast() Alpine.js magic method. This is what we're doing in the second example above:

+
+
<div class="toast">
+
Wraps around the toast component. +
+
<div class="toast-content">
+
The content of the toast. +
+
<svg aria-hidden="true"> Optional
+
The toast's icon.
+
<section>
+
The toast's message. +
+
<h2>
+
The toast's title.
+
<p> Optional
+
The toast's description.
+
+
+
<footer> Optional
+
The toast's buttons. When clicked, the toast will be closed (unless the button's onclick is set to e.preventDefault()). +
+
<button type="button" class="btn" onclick="{ ONCLICK }"> or <a href="{ URL }" class="btn" > Optional
+
The toast's action button. This can either be a link (with a href attribute) or a button (with an onclick attribute).
+
+
+
<button type="button" class="btn-outline" onclick="{ ONCLICK }"> Optional
+
The toast's cancel button (with an optional onclick attribute).
+
+
+
+
+
+
+
-{% set code_magic_method %} -{% endset %} -{{ code_block(code_magic_method, "html") }} +

JavaScript config object

-

The $toast() method accepts a config object with the following properties:

- -
    -
  • duration (optional): the duration of the toast in milliseconds. By default, the duration will be 3 seconds, or 5 seconds if category is "error".
  • -
  • category (optional): "success", "error", "info", "warning" or any arbitrary string.
  • -
  • title (optional): the title of the toast.
  • -
  • description (optional): the description of the toast.
  • -
  • action (optional): an object to define an action button: -
      -
    • label: the label of the action button.
    • -
    • click (optional): a string of JavaScript to execute when the button is clicked.
    • -
    • url (optional): a URL to link to when the button is clicked.
    • -
    -
  • -
  • cancel (optional): an object to define a cancel button (similar structure to action).
  • -
+
+
category Optional
+
Category of the toast, either success, info, warning, or error.
+
title
+
The title of the toast.
+
description Optional
+
The description of the toast.
+
action Optional
+
Action button. +
+
label Optional
+
The label of the cancel button. If not provided, the default label is "Dismiss".
+
onclick Optional
+
The onclick of the cancel button.
+
+
+
cancel Optional
+
Cancel button. +
+
label Optional
+
The label of the cancel button. If not provided, the default label is "Dismiss".
+
onclick Optional
+
The onclick of the cancel button.
+
+
+

Jinja and Nunjucks

diff --git a/docs/src/fragments/toast/success.njk b/docs/src/fragments/toast/success.njk index 8dedd4a..e94e52c 100644 --- a/docs/src/fragments/toast/success.njk +++ b/docs/src/fragments/toast/success.njk @@ -2,5 +2,6 @@ {{ toast( title="Event has been created", description="Sunday, December 03, 2023 at 9:00 AM", - cancel={ label: "Undo", click: "close()" } + action={ label: "View" }, + cancel={ label: "Undo" } ) }} \ No newline at end of file diff --git a/src/css/basecoat.css b/src/css/basecoat.css index 24a40ed..e587a99 100644 --- a/src/css/basecoat.css +++ b/src/css/basecoat.css @@ -161,7 +161,6 @@ h2 { @apply col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight; } - section { @apply text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed; @@ -989,7 +988,27 @@ @apply pointer-events-auto w-full mt-4 animate-[toast-up_0.3s_ease-in-out] grid grid-rows-[1fr] transition-[grid-template-rows,opacity,margin] duration-300 ease-in-out; .toast-content { - @apply text-popover-foreground text-[13px] bg-popover border shadow-lg rounded-lg overflow-hidden; + @apply text-popover-foreground text-[13px] bg-popover border shadow-lg rounded-lg overflow-hidden flex gap-2.5 p-3 items-center; + + svg { + @apply size-4 shrink-0; + } + section { + h2 { + @apply font-medium tracking-tight; + } + p { + @apply text-muted-foreground; + } + } + footer { + @apply ml-auto flex flex-col gap-2; + + [data-toast-action], + [data-toast-cancel] { + @apply h-6 px-2.5 text-xs; + } + } } &[aria-hidden='true'] { @apply grid-rows-[0fr] opacity-0 m-0 border-0 p-0 overflow-hidden; diff --git a/src/css/cdn.css b/src/css/cdn.css new file mode 100644 index 0000000..1e5feed --- /dev/null +++ b/src/css/cdn.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; +@import "./basecoat.css"; \ No newline at end of file diff --git a/src/js/dropdown-menu.js b/src/js/dropdown-menu.js index 5345a59..dbb2b6f 100644 --- a/src/js/dropdown-menu.js +++ b/src/js/dropdown-menu.js @@ -24,7 +24,6 @@ }; const handleKeyDown = (e) => { - // If the menu isn't open, we only care about ArrowUp/Down/Enter/Space to open it. if (!popover.matches(':popover-open')) { if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) { e.preventDefault(); @@ -33,7 +32,6 @@ return; } - // If the menu IS open, we handle all navigation. let nextIndex = activeIndex; if (menuItems.length === 0) return; @@ -76,7 +74,6 @@ trigger.setAttribute('aria-expanded', e.newState === 'open'); if (e.newState === 'open') { menuItems = Array.from(menu.querySelectorAll('[role^="menuitem"]:not([disabled])')); - // Ensure all menu items have IDs for aria-activedescendant menuItems.forEach((item, index) => { if (!item.id) item.id = `${menu.id}-item-${index}`; }); diff --git a/src/js/select.js b/src/js/select.js index 9903d46..8a3fdb3 100644 --- a/src/js/select.js +++ b/src/js/select.js @@ -138,6 +138,8 @@ }); popover.addEventListener('toggle', (e) => { + trigger.setAttribute('aria-expanded', e.newState === 'open'); + if (e.newState === 'open') { if (filter) filter.focus(); diff --git a/src/js/sidebar.js b/src/js/sidebar.js index cb88a4e..4994b56 100644 --- a/src/js/sidebar.js +++ b/src/js/sidebar.js @@ -71,7 +71,7 @@ const isMobile = window.innerWidth < breakpoint; - if (isMobile && (target.closest('[data-close-mobile-sidebar]') || (target.closest('a, button') && !target.closest('[data-keep-mobile-sidebar-open]')))) { + if (isMobile && (target.closest('a, button') && !target.closest('[data-keep-mobile-sidebar-open]'))) { if (document.activeElement) document.activeElement.blur(); setState(false); return; diff --git a/src/js/toast.js b/src/js/toast.js index 672dafa..921cc50 100644 --- a/src/js/toast.js +++ b/src/js/toast.js @@ -1,75 +1,196 @@ -window.basecoat = window.basecoat || {}; -window.basecoat.registerToast = function(Alpine) { - if (Alpine.components && Alpine.components.toast) return; +(() => { + let toaster; + const toasts = new WeakMap(); + let isPaused = false; + const ICONS = { + success: '', + error: '', + info: '', + warning: '' + }; - Alpine.store('toaster', { isPaused: false }); - Alpine.data('toast', (config={}) => ({ - config: config, - open: false, - timeoutDuration: null, - timeoutId: null, + function initToaster(toasterElement) { + if (toasterElement.dataset.toasterInitialized) return; + toaster = toasterElement; - init() { - if (config.duration !== -1) { - this.timeoutDuration = config.duration || (config.category === 'error' ? 5000 : 3000); - this.timeoutId = setTimeout(() => { this.close() }, this.timeoutDuration); + toaster.addEventListener('mouseenter', pauseAllTimeouts); + toaster.addEventListener('mouseleave', resumeAllTimeouts); + toaster.addEventListener('click', (e) => { + const actionLink = e.target.closest('.toast footer a'); + const actionButton = e.target.closest('.toast footer button'); + if (actionLink || actionButton) { + closeToast(e.target.closest('.toast')); } - this.open = true; - this.$watch('$store.toaster.isPaused', (isPaused) => { - if (!this.open) return; - if (isPaused) { - this.pauseTimeout(); + }); + + toaster.querySelectorAll('.toast:not([data-toast-initialized])').forEach(initToast); + toaster.dataset.toasterInitialized = 'true'; + } + + function initToast(element) { + if (element.dataset.toastInitialized) return; + + const duration = parseInt(element.dataset.duration); + const timeoutDuration = duration !== -1 + ? duration || (element.dataset.category === 'error' ? 5000 : 3000) + : -1; + + const state = { + remainingTime: timeoutDuration, + timeoutId: null, + startTime: null, + }; + + if (timeoutDuration !== -1) { + if (isPaused) { + state.timeoutId = null; + } else { + state.startTime = Date.now(); + state.timeoutId = setTimeout(() => closeToast(element), timeoutDuration); + } + } + toasts.set(element, state); + + element.dataset.toastInitialized = 'true'; + } + + function pauseAllTimeouts() { + if (isPaused) return; + + isPaused = true; + + toaster.querySelectorAll('.toast:not([aria-hidden="true"])').forEach(element => { + if (!toasts.has(element)) return; + + const state = toasts.get(element); + if (state.timeoutId) { + clearTimeout(state.timeoutId); + state.timeoutId = null; + state.remainingTime -= Date.now() - state.startTime; + } + }); + } + + function resumeAllTimeouts() { + if (!isPaused) return; + + isPaused = false; + + toaster.querySelectorAll('.toast:not([aria-hidden="true"])').forEach(element => { + if (!toasts.has(element)) return; + + const state = toasts.get(element); + if (state.remainingTime !== -1 && !state.timeoutId) { + if (state.remainingTime > 0) { + state.startTime = Date.now(); + state.timeoutId = setTimeout(() => closeToast(element), state.remainingTime); } else { - this.resumeTimeout(); + closeToast(element); + } + } + }); + } + + function closeToast(element) { + if (!toasts.has(element)) return; + + const state = toasts.get(element); + clearTimeout(state.timeoutId); + toasts.delete(element); + + if (document.activeElement) document.activeElement.blur(); + element.setAttribute('aria-hidden', 'true'); + element.addEventListener('transitionend', () => element.remove(), { once: true }); + } + + function executeAction(button, toast) { + const actionString = button.dataset.toastAction; + if (!actionString) return; + try { + const func = new Function('close', actionString); + func(() => closeToast(toast)); + } catch (e) { + console.error('Error executing toast action:', e); + } + } + + function createToast(config) { + const { + category = 'info', + title, + description, + action, + cancel, + duration, + icon, + } = config; + + const iconHtml = icon || (category && ICONS[category]) || ''; + const titleHtml = title ? `

${title}

` : ''; + const descriptionHtml = description ? `

${description}

` : ''; + const actionHtml = action?.href + ? `${action.label}` + : action?.onclick + ? `` + : ''; + const cancelHtml = cancel + ? `` + : ''; + + const footerHtml = actionHtml || cancelHtml ? `
${actionHtml}${cancelHtml}
` : ''; + + const html = ` +
+
+ ${iconHtml} +
+ ${titleHtml} + ${descriptionHtml} +
+ ${footerHtml} +
+
+
+ `; + const template = document.createElement('template'); + template.innerHTML = html.trim(); + return template.content.firstChild; + } + + const initialToaster = document.getElementById('toaster'); + if (initialToaster) initToaster(initialToaster); + + window.addEventListener('basecoat:toast', (e) => { + if (!toaster) { + console.error('Cannot create toast: toaster container not found on page.'); + return; + } + const config = e.detail?.config || {}; + const toastElement = createToast(config); + toaster.appendChild(toastElement); + }); + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node.nodeType !== Node.ELEMENT_NODE) return; + + if (node.matches('#toaster')) { + initToaster(node); + } + + if (toaster && node.matches('.toast:not([data-toast-initialized])')) { + initToast(node); } }); - }, - pauseTimeout() { - clearTimeout(this.timeoutId); - this.timeoutId = null; - }, - resumeTimeout(index) { - if (this.open && this.timeoutId === null) { - this.timeoutId = setTimeout(() => { this.close() }, this.timeoutDuration); - } - }, - close() { - this.pauseTimeout(); - this.open = false; - this.$el.blur(); - }, - executeAction(actionString) { - if (actionString) { - Alpine.evaluate(this.$el, actionString); - } - }, - - $toastBindings: { - ['@mouseenter']() { this.$store.toaster.isPaused = true }, - ['@mouseleave']() { this.$store.toaster.isPaused = false }, - ['@keydown.escape.prevent']() { this.close() }, - [':aria-hidden']() { return !this.open } - }, - })); - - Alpine.magic('toast', (el) => (config, toasterId='toaster') => { - const toaster = document.getElementById(toasterId); - const template = document.getElementById('toast-template'); - - if (!toaster) { - console.error(`Toaster container with id #${toasterId} not found.`); - return; - } - if (!template) { - console.error('Toast template with id #toast-template not found.'); - return; - } - - const clone = template.content.firstElementChild.cloneNode(true); - - clone.setAttribute('x-data', `toast(${JSON.stringify(config)})`); - clone.removeAttribute('id'); - - toaster.appendChild(clone); + }); }); -}; \ No newline at end of file + + observer.observe(document.body, { childList: true, subtree: true }); +})(); \ No newline at end of file diff --git a/src/nunjucks/dialog.njk b/src/nunjucks/dialog.njk index 64aeb5b..369d3aa 100644 --- a/src/nunjucks/dialog.njk +++ b/src/nunjucks/dialog.njk @@ -35,7 +35,7 @@ {% if trigger %} - {% elif action.url %} - {{ action.label }} - {% endif %} + {% endif %} +
+ {% if title %}

{{ title }}

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

{{ description }}

{% endif %} +
+ {% if action or cancel %} + - {% endif %} -
+ {% endif %} + {% if cancel %} + + {% endif %} + + {% endif %} -{% endmacro %} - -{% set toast_icons = { - 'success': '', - 'error': '', - 'info': '', - 'warning': '' -} %} \ No newline at end of file +{% endmacro %} \ No newline at end of file