Compare commits

...
Author SHA1 Message Date
grugbraindev c2ac210002 Use Geist as the default sans font family name
Google Fonts, Bunny Fonts and Fontsource all publish the font as "Geist". "Geist Sans" is only on Fontsource, so the default token fell back to system fonts with the other providers. "Geist Sans" stays as a fallback for existing Fontsource setups. Matches shadcn/ui, which loads "Geist".
2026-10-23 09:28:43 +08:00
Grug b7d33eee04 Add single-file .bundle.css outputs for Tailwind CLI without npm (#206)
Emit dist/basecoat-{style}.bundle.css for every style pack, plus
dist/basecoat.bundle.css (Vega alias) and dist/basecoat-base.bundle.css.
Each is the uncompiled source with relative @imports inlined, so it can
be downloaded as one file (e.g. from jsDelivr) and imported after
tailwindcss. Compiling a bundle produces byte-identical output to the
split entrypoint.

Document the bundles on the installation page and fix the description
of basecoat-css/all.css, which is an alias for the default stylesheet.

Refs #150
2026-10-23 09:23:51 +08:00
GrugandMaurits van Riezen 30e5bdcf3a Escape toast text by default, add html option (#203)
Toast title, description and action labels were inserted into the page
as HTML, so passing user input could inject markup or script. They are
now escaped by default, and `html: true` keeps the old raw-HTML
behaviour. The action href, onclick strings, category and duration are
escaped as attribute values. `icon` stays raw markup.

Documents the `html` and `icon` options, adds a warning about `html`
and `onclick`, and notes that the Jinja and Nunjucks macros rely on
template autoescaping.

Co-authored-by: Maurits van Riezen <12109031+mousetail@users.noreply.github.com>
2026-10-23 09:23:05 +08:00
GrugandPhilipdeRijk cafffa0597 Don't close dialogs when a drag ends on the backdrop (#202)
Only close a dialog or command dialog on backdrop click when the press
also started on the backdrop, so selecting text inside and releasing
outside no longer closes it.

Co-authored-by: PhilipdeRijk <derijk.pjtm@gmail.com>
2026-10-23 09:22:42 +08:00
Grugandboratatouille 2a2c64c651 Deploy the site from a release branch moved on each published release (#207)
Co-authored-by: boratatouille <178141691+boratatouille@users.noreply.github.com>
2026-10-23 09:09:26 +08:00
Alexander Posztosandboratatouille 0b28009e38 Fix iOS auto-zoom on combobox input (#185)
* Fix iOS auto-zoom on select and command search inputs

* Match shadcn/ui input sizing for combobox and keep command input as is

---------

Co-authored-by: boratatouille <178141691+boratatouille@users.noreply.github.com>
2026-10-22 23:32:56 +08:00
Радоје Јездић d942c8b31d fix(toast): prevent htmx from pushing toast fragment path (#187)
close #186
2026-10-22 23:27:46 +08:00
Ronan Berder d826637eb9 Merge pull request #201 from hunvreus/docs/contributing-target-main
Point contributors at main instead of development
2026-10-22 22:36:45 +08:00
boratatouille 2ad4f6a43b Point contributors at main instead of development 2026-10-22 21:09:07 +08:00
Ronan Berder 6953a4adbe Prepare 1.0.2 release 2026-07-06 15:21:53 +07:00
Ronan Berder f49eaa8513 Update ReallySimpleDocs dependency 2026-06-30 15:33:34 +08:00
Ronan Berder fcea07fc3d Update docs dependency 2026-06-29 22:39:24 +08:00
Ronan Berder aa09ddb271 Add manual release announcement dispatch 2026-06-28 10:46:28 +08:00
Ronan Berder 05ea49ccc5 Prepare 1.0.1 release 2026-06-28 10:06:30 +08:00
Ronan Berder 5f84c842f7 Harden Discord release workflow 2026-06-28 09:54:30 +08:00
Ronan Berder 7dc70daa83 Add force init lifecycle option 2026-06-28 09:54:05 +08:00
Ronan Berder b4424e9e8b Add Discord release announcement workflow 2026-06-28 09:51:48 +08:00
Ronan Berder d431873be7 Fix docs sidebar after history restore 2026-06-27 21:12:05 +08:00
Ronan Berder 537f77507e Install site dependencies during docs build 2026-06-27 19:45:17 +08:00
Ronan Berder 521bd93705 Regenerate lockfiles for deploy 2026-06-27 19:33:57 +08:00
38 changed files with 3978 additions and 619 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Deploy site on release
# Cloudflare Workers Builds deploys the site from the `release` branch.
# Publishing a GitHub release moves `release` to the released tag.
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: Tag to deploy (for example 1.1.0)
required: true
type: string
permissions:
contents: write
jobs:
move-release-branch:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Point release branch at tag
env:
TAG: ${{ inputs.tag || github.event.release.tag_name }}
run: |
SHA=$(git rev-parse "refs/tags/$TAG^{commit}")
git push --force origin "$SHA:refs/heads/release"
+72
View File
@@ -0,0 +1,72 @@
name: Announce release on Discord
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: Release tag to announce
required: true
type: string
permissions:
contents: read
jobs:
announce:
runs-on: ubuntu-latest
steps:
- name: Send Discord announcement
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
RELEASE_TAG_INPUT: ${{ inputs.tag }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
run: |
test -n "$DISCORD_WEBHOOK_URL"
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
test -n "$RELEASE_TAG_INPUT"
curl --fail-with-body \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"$GITHUB_API_URL/repos/$REPO/releases/tags/$RELEASE_TAG_INPUT" \
> release.json
else
jq '.release' "$GITHUB_EVENT_PATH" > release.json
fi
if [ "$(jq -r '.prerelease' release.json)" = "true" ]; then
echo "Skipping prerelease."
exit 0
fi
jq -n \
--arg repo "$REPO" \
--arg name "$(jq -r '.name // ""' release.json)" \
--arg tag "$(jq -r '.tag_name' release.json)" \
--arg url "$(jq -r '.html_url' release.json)" \
--arg body "$(jq -r '.body // ""' release.json)" \
'($body | if length > 1500 then .[:1500] + "...\n\n[Read more](" + $url + ")" else . end) as $description |
{
username: "Basecoat Releases",
allowed_mentions: { parse: [] },
embeds: [{
title: ($repo + " " + $tag),
url: $url,
description: ((if $name != "" then "**" + $name + "**\n\n" else "" end) + $description),
color: 5793266
}]
}' > payload.json
curl --fail-with-body \
--retry 3 \
--retry-all-errors \
--retry-delay 2 \
-H "Content-Type: application/json" \
-X POST \
-d @payload.json \
"$DISCORD_WEBHOOK_URL"
+4
View File
@@ -1,6 +1,10 @@
.*
!.gitignore
!.gitattributes
!.github/
!.github/workflows/
!.github/workflows/*.yml
!.github/workflows/*.yaml
_site/
node_modules/
+1
View File
@@ -48,6 +48,7 @@
## JavaScript
- JavaScript is behavior only; do not use JS to paper over CSS architecture issues.
- Register JS components with `window.basecoat.register()` and make initialization idempotent with `data-*-initialized` flags.
- Component init functions must return before attaching listeners or mutating DOM when the root already has its initialized flag.
- Keep generated DOM minimal and aligned with documented HTML.
- Prefer native browser APIs and events.
- Keep scroll management scoped to component containers. Do not use `scrollIntoView()` when it can scroll the page unexpectedly.
+20
View File
@@ -2,6 +2,26 @@
## Unreleased
## [1.0.2] - 2026-07-06
### Added
- Added `data-size="sm"` support to Alert Dialog for the compact upstream size.
### Changed
- Removed global font smoothing from Basecoat's package CSS so `antialiased` remains an application-level choice.
## [1.0.1] - 2026-06-28
### Added
- Added explicit `force: true` support to `window.basecoat.init()` and `window.basecoat.initAll()` for rehydrating restored DOM after framework or navigation cache restores.
### Fixed
- Reinitialized Basecoat components after HTMX history restores in the docs site.
## [1.0.0] - 2026-06-27
### Breaking Changes
+15 -4
View File
@@ -1,13 +1,12 @@
# Contributing to Basecoat
- Support development by sponsoring the author: https://github.com/sponsors/hunvreus
- Submit pull requests (PRs) against the `development` branch, not `main`.
- Submit pull requests (PRs) against the `main` branch.
- For branches, we have:
- `main` is our production and default branch. This is what we deploy to https://basecoatui.com and publish to https://www.npmjs.com/package/basecoat-css.
- `development` is for staging. We will deploy it soon at https://dev.basecoatui.com.
- New features are worked in `feature/name-of-the-feature` branches.
- Issues are addressed in `issue/123-main-isse` branches.
- When ready, we PR against `development`, test it and then finally merge to `main`.
- Issues are addressed in `issue/123-main-issue` branches.
- When ready, open a PR against `main`.
- Keep changes focused: one feature or fix per PR.
- Test locally before submitting.
- Follow existing code style.
@@ -29,3 +28,15 @@ Workers Builds settings:
- Enable builds for non-production branches for branch preview URLs.
Thanks for helping!
## Releases and the docs site
The site at basecoatui.com deploys from the `release` branch, not `main`, so the docs always match the version on npm. Nobody commits to `release` directly.
To release:
1. Bump the version in `package.json`, commit, tag (for example `1.1.0`) and push.
2. Run `npm publish`.
3. Publish a GitHub release for the tag, for example `gh release create 1.1.0 --generate-notes`.
Publishing the release runs `.github/workflows/deploy-site.yml`, which points `release` at the tag. Cloudflare then builds and deploys the site. To redeploy or roll back, run that workflow manually with the tag you want live.
+1
View File
@@ -92,6 +92,7 @@ These entrypoints are generated by `scripts/generate-css-entrypoints.js` and by
- `window.basecoat.init(name)` initializes uninitialized components for one registered component.
- `window.basecoat.initAll()` initializes all uninitialized registered components.
- `window.basecoat.init(name, { force: true })` and `window.basecoat.initAll({ force: true })` destroy and reinitialize matching components. Use this when a framework or navigation library restores previously initialized DOM from a cache. Force mode resets transient component state such as open menus, focus state, and active interactions.
- `window.basecoat.refresh(element)` asks an initialized component to rescan dynamic children when it supports refresh.
- Components use internal destroy hooks to clean up listeners when initialized roots are removed from the DOM.
+800 -70
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "basecoat-css",
"version": "1.0.0",
"version": "1.0.2",
"description": "Tailwind CSS for Basecoat components",
"author": {
"name": "hunvreus",
@@ -123,12 +123,12 @@
"scripts": {
"build": "node ./scripts/build.js",
"prepack": "node ./scripts/build.js",
"docs:build": "npm run build && npm --prefix site run build",
"docs:build": "npm run build && npm --prefix site clean-install --progress=false && npm --prefix site run build",
"docs:dev": "npm --prefix site run dev",
"docs:preview": "npm run docs:build && npm --prefix site run preview",
"workers:dev": "npm run build && npm --prefix site run workers:dev",
"workers:deploy": "npm run build && npm --prefix site run workers:deploy",
"workers:preview": "npm run build && npm --prefix site run workers:preview"
"workers:dev": "npm run build && npm --prefix site clean-install --progress=false && npm --prefix site run workers:dev",
"workers:deploy": "npm run build && npm --prefix site clean-install --progress=false && npm --prefix site run workers:deploy",
"workers:preview": "npm run build && npm --prefix site clean-install --progress=false && npm --prefix site run workers:preview"
},
"devDependencies": {
"@tailwindcss/cli": "^4.1.17",
+45
View File
@@ -64,6 +64,37 @@ async function copyDirRecursive(src, dest) {
}
}
// Recursively inlines relative `@import "./...";` lines into a single CSS string.
// Each file is inlined at most once per bundle (tracked in `seen`).
async function inlineCssImports(filePath, seen) {
const resolved = path.resolve(filePath);
if (seen.has(resolved)) return '';
seen.add(resolved);
const source = await fs.readFile(resolved, 'utf-8');
const lines = source.split('\n');
const out = [];
for (const line of lines) {
const match = line.match(/^\s*@import\s+["'](\.{1,2}\/[^"']+)["']\s*;\s*$/);
if (match) {
const inlined = await inlineCssImports(path.join(path.dirname(resolved), match[1]), seen);
if (inlined) out.push(inlined.replace(/\n+$/, ''));
} else if (/^\s*@import\s+["']\.{1,2}\//.test(line)) {
throw new Error(`Unsupported relative @import (with modifiers) in ${resolved}: ${line.trim()}`);
} else {
out.push(line);
}
}
return out.join('\n');
}
// Writes an uncompiled single-file bundle for a dist CSS entrypoint.
async function writeCssBundle(entryPath, outPath, label, version) {
const body = await inlineCssImports(entryPath, new Set());
const header = `/* Basecoat v${version}, ${label}, single-file source. Import after tailwindcss. */\n`;
await fs.writeFile(outPath, `${header}${body.replace(/\n+$/, '')}\n`);
}
// Main build function to prepare the package for publishing.
async function build() {
console.log('Starting build process...');
@@ -155,6 +186,20 @@ async function build() {
await copyDirRecursive(srcCssCompatDir, cssCompatDistDir);
console.log(`Copied split CSS folders to ${cssDistDir}`);
// Create uncompiled single-file bundles for Tailwind users without a package manager.
const { version } = JSON.parse(await fs.readFile(path.join(projectRoot, 'package.json'), 'utf-8'));
for (const style of styles) {
await writeCssBundle(
path.join(cssDistDir, `basecoat-${style}.css`),
path.join(cssDistDir, `basecoat-${style}.bundle.css`),
style,
version,
);
}
await writeCssBundle(path.join(cssDistDir, 'basecoat.css'), path.join(cssDistDir, 'basecoat.bundle.css'), 'vega', version);
await writeCssBundle(path.join(cssDistDir, 'basecoat-base.css'), path.join(cssDistDir, 'basecoat-base.bundle.css'), 'base (no style pack)', version);
console.log(`Generated single-file CSS bundles in ${cssDistDir}`);
await copyDirRecursive(srcNunjucksDir, path.join(cssTemplatesDir, 'nunjucks'));
await copyDirRecursive(srcJinjaDir, path.join(cssTemplatesDir, 'jinja'));
console.log(`Copied template assets to ${cssTemplatesDir}`);
+1
View File
@@ -31,6 +31,7 @@ export default defineConfig({
style: 'vega',
css: false,
bodyAttrs: {
class: 'antialiased',
'hx-boost': 'true',
'hx-target': '#content',
'hx-select': '#content',
+2764 -485
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -15,7 +15,7 @@
"htmx.org": "^2.0.10",
"lucide-static": "^1.17.0",
"nunjucks": "^3.2.4",
"reallysimpledocs": "^1.0.6"
"reallysimpledocs": "^1.0.12"
},
"devDependencies": {
"astro": "^6.4.6",
+30 -4
View File
@@ -24,28 +24,28 @@ const styleUrls = {
<style is:inline>
@font-face {
font-family: "Geist Sans";
font-family: "Geist";
src: url("/assets/fonts/geist/geist-sans-latin-400-normal.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Geist Sans";
font-family: "Geist";
src: url("/assets/fonts/geist/geist-sans-latin-500-normal.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Geist Sans";
font-family: "Geist";
src: url("/assets/fonts/geist/geist-sans-latin-600-normal.woff2") format("woff2");
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Geist Sans";
font-family: "Geist";
src: url("/assets/fonts/geist/geist-sans-latin-700-normal.woff2") format("woff2");
font-weight: 700;
font-style: normal;
@@ -159,7 +159,33 @@ const styleUrls = {
}
};
const clearSerializedRuntimeState = (root) => {
for (const element of root.querySelectorAll?.("*") || []) {
delete element.dataset.basecoatComponent;
for (const attribute of Array.from(element.attributes)) {
if (/^data-[a-z0-9-]+-initialized$/.test(attribute.name)) {
element.removeAttribute(attribute.name);
}
}
}
};
const cleanHistoryItem = (event) => {
const item = event.detail?.item;
if (typeof item?.content !== "string") return;
const template = document.createElement("template");
template.innerHTML = item.content;
clearSerializedRuntimeState(template.content);
item.content = template.innerHTML;
};
document.addEventListener("DOMContentLoaded", syncSidebar);
document.addEventListener("htmx:historyItemCreated", cleanHistoryItem);
document.addEventListener("htmx:historyRestore", () => {
window.basecoat?.initAll?.({ force: true });
syncSidebar();
});
document.addEventListener("htmx:afterSettle", () => {
window.basecoat?.initAll?.();
syncSidebar();
+22 -1
View File
@@ -74,7 +74,7 @@ Alert Dialog uses a native `<dialog class="alert-dialog">` element. Omit the clo
<dd>Trigger button. Basecoat uses the native <code>HTMLDialogElement.showModal()</code> method.</dd>
<dt><code>&lt;dialog class="alert-dialog" id="&#123; DIALOG_ID &#125;"&gt;</code></dt>
<dd>
Native modal dialog. Add <code>aria-labelledby</code> and <code>aria-describedby</code> when title and description are present.
Native modal dialog. Add <code>aria-labelledby</code> and <code>aria-describedby</code> when title and description are present. Use <code>data-size="sm"</code> for the compact upstream size.
<dl>
<dt><code>&lt;div&gt;</code></dt>
<dd>
@@ -164,6 +164,27 @@ Use a destructive action button when the alert dialog confirms a destructive ope
</dialog>
</Preview>
### Small
Use `data-size="sm"` for a compact alert dialog. The small size keeps media, title, and description stacked and centers the action buttons in a two-column footer.
<Preview>
<button type="button" onclick="document.getElementById('alert-dialog-small').showModal()" class="btn" data-variant="outline">Invite Members</button>
<dialog id="alert-dialog-small" class="alert-dialog" data-size="sm" aria-labelledby="alert-dialog-small-title" aria-describedby="alert-dialog-small-description">
<div>
<header>
<figure><svg class="lucide lucide-users" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><path d="M16 3.128a4 4 0 0 1 0 7.744" /><path d="M22 21v-2a4 4 0 0 0-3-3.87" /><circle cx="9" cy="7" r="4" /></svg></figure>
<h2 id="alert-dialog-small-title">Invite team members?</h2>
<p id="alert-dialog-small-description">Send an invitation link to your selected teammates.</p>
</header>
<footer>
<button class="btn" data-variant="outline" onclick="this.closest('dialog').close()">Cancel</button>
<button class="btn" onclick="this.closest('dialog').close()">Invite</button>
</footer>
</div>
</dialog>
</Preview>
### RTL
Alert Dialog supports document direction. Set `dir="rtl"` on the dialog or a parent element.
+4 -4
View File
@@ -197,7 +197,7 @@ A simple command menu in a dialog.
<Preview>
<button type="button" class="btn" data-variant="outline" onclick="document.getElementById('command-basic').showModal()">Open Menu</button>
<dialog id="command-basic" class="command-dialog" aria-label="Command menu" onclick="if (event.target === this) this.close()">
<dialog id="command-basic" class="command-dialog" aria-label="Command menu" onpointerdown="this.dataset.backdropPress = event.target === this" onclick="if (event.target === this && this.dataset.backdropPress === 'true') this.close()">
<div class="command">
<header><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></svg>
<input type="text" id="command-basic-input" placeholder="Type a command or search..." autocomplete="off" autocorrect="off" spellcheck="false" aria-autocomplete="list" role="combobox" aria-expanded="true" aria-controls="command-basic-menu" />
@@ -218,7 +218,7 @@ A simple command menu in a dialog.
<Preview>
<button type="button" class="btn" data-variant="outline" onclick="document.getElementById('command-shortcuts-dialog').showModal()">Open Menu</button>
<dialog id="command-shortcuts-dialog" class="command-dialog" aria-label="Command menu" onclick="if (event.target === this) this.close()">
<dialog id="command-shortcuts-dialog" class="command-dialog" aria-label="Command menu" onpointerdown="this.dataset.backdropPress = event.target === this" onclick="if (event.target === this && this.dataset.backdropPress === 'true') this.close()">
<div class="command">
<header><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></svg>
<input type="text" id="command-shortcuts-input" placeholder="Type a command or search..." autocomplete="off" autocorrect="off" spellcheck="false" aria-autocomplete="list" role="combobox" aria-expanded="true" aria-controls="command-shortcuts-menu" />
@@ -248,7 +248,7 @@ A simple command menu in a dialog.
<Preview>
<button type="button" class="btn" data-variant="outline" onclick="document.getElementById('command-groups-dialog').showModal()">Open Menu</button>
<dialog id="command-groups-dialog" class="command-dialog" aria-label="Command menu" onclick="if (event.target === this) this.close()">
<dialog id="command-groups-dialog" class="command-dialog" aria-label="Command menu" onpointerdown="this.dataset.backdropPress = event.target === this" onclick="if (event.target === this && this.dataset.backdropPress === 'true') this.close()">
<div class="command">
<header><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></svg>
<input type="text" id="command-groups-input" placeholder="Type a command or search..." autocomplete="off" autocorrect="off" spellcheck="false" aria-autocomplete="list" role="combobox" aria-expanded="true" aria-controls="command-groups-menu" />
@@ -291,7 +291,7 @@ A simple command menu in a dialog.
<Preview>
<button type="button" class="btn" data-variant="outline" onclick="document.getElementById('command-scrollable-dialog').showModal()">Open Menu</button>
<dialog id="command-scrollable-dialog" class="command-dialog" aria-label="Command menu" onclick="if (event.target === this) this.close()">
<dialog id="command-scrollable-dialog" class="command-dialog" aria-label="Command menu" onpointerdown="this.dataset.backdropPress = event.target === this" onclick="if (event.target === this && this.dataset.backdropPress === 'true') this.close()">
<div class="command">
<header><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></svg>
<input type="text" id="command-scrollable-input" placeholder="Type a command or search..." autocomplete="off" autocorrect="off" spellcheck="false" aria-autocomplete="list" role="combobox" aria-expanded="true" aria-controls="command-scrollable-menu" />
+7 -7
View File
@@ -2,7 +2,7 @@
<Preview>
<button type="button" onclick="document.getElementById('demo-dialog-edit-profile').showModal()" class="btn" data-variant="outline">Open Dialog</button>
<dialog id="demo-dialog-edit-profile" class="dialog" aria-labelledby="demo-dialog-edit-profile-title" aria-describedby="demo-dialog-edit-profile-description" onclick="if (event.target === this) this.close()">
<dialog id="demo-dialog-edit-profile" class="dialog" aria-labelledby="demo-dialog-edit-profile-title" aria-describedby="demo-dialog-edit-profile-description" onpointerdown="this.dataset.backdropPress = event.target === this" onclick="if (event.target === this && this.dataset.backdropPress === 'true') this.close()">
<div class="sm:max-w-sm">
<header>
<h2 id="demo-dialog-edit-profile-title">Edit profile</h2>
@@ -63,7 +63,7 @@ Basecoat uses the native `<dialog>` element and `showModal()`. This differs from
```html
<button type="button" onclick="document.getElementById('demo-dialog-edit-profile').showModal()" class="btn" data-variant="outline">Open Dialog</button>
<dialog id="demo-dialog-edit-profile" class="dialog" aria-labelledby="demo-dialog-edit-profile-title" aria-describedby="demo-dialog-edit-profile-description" onclick="if (event.target === this) this.close()">
<dialog id="demo-dialog-edit-profile" class="dialog" aria-labelledby="demo-dialog-edit-profile-title" aria-describedby="demo-dialog-edit-profile-description" onpointerdown="this.dataset.backdropPress = event.target === this" onclick="if (event.target === this && this.dataset.backdropPress === 'true') this.close()">
<div class="sm:max-w-sm">
<header>
<h2 id="demo-dialog-edit-profile-title">Edit profile</h2>
@@ -134,7 +134,7 @@ Basecoat uses the native `<dialog>` element and `showModal()`. This differs from
<Preview>
<button type="button" onclick="document.getElementById('dialog-custom-close').showModal()" class="btn" data-variant="outline">Share</button>
<dialog id="dialog-custom-close" class="dialog" aria-labelledby="dialog-custom-close-title" aria-describedby="dialog-custom-close-description" onclick="if (event.target === this) this.close()">
<dialog id="dialog-custom-close" class="dialog" aria-labelledby="dialog-custom-close-title" aria-describedby="dialog-custom-close-description" onpointerdown="this.dataset.backdropPress = event.target === this" onclick="if (event.target === this && this.dataset.backdropPress === 'true') this.close()">
<div class="sm:max-w-md">
<header>
<h2 id="dialog-custom-close-title">Share link</h2>
@@ -157,7 +157,7 @@ Basecoat uses the native `<dialog>` element and `showModal()`. This differs from
<Preview>
<button type="button" onclick="document.getElementById('dialog-no-close').showModal()" class="btn" data-variant="outline">No Close Button</button>
<dialog id="dialog-no-close" class="dialog" aria-labelledby="dialog-no-close-title" aria-describedby="dialog-no-close-description" onclick="if (event.target === this) this.close()">
<dialog id="dialog-no-close" class="dialog" aria-labelledby="dialog-no-close-title" aria-describedby="dialog-no-close-description" onpointerdown="this.dataset.backdropPress = event.target === this" onclick="if (event.target === this && this.dataset.backdropPress === 'true') this.close()">
<div>
<header>
<h2 id="dialog-no-close-title">No Close Button</h2>
@@ -171,7 +171,7 @@ Basecoat uses the native `<dialog>` element and `showModal()`. This differs from
<Preview>
<button type="button" onclick="document.getElementById('dialog-sticky-footer').showModal()" class="btn" data-variant="outline">Sticky Footer</button>
<dialog id="dialog-sticky-footer" class="dialog" aria-labelledby="dialog-sticky-footer-title" aria-describedby="dialog-sticky-footer-description" onclick="if (event.target === this) this.close()">
<dialog id="dialog-sticky-footer" class="dialog" aria-labelledby="dialog-sticky-footer-title" aria-describedby="dialog-sticky-footer-description" onpointerdown="this.dataset.backdropPress = event.target === this" onclick="if (event.target === this && this.dataset.backdropPress === 'true') this.close()">
<div>
<header>
<h2 id="dialog-sticky-footer-title">Sticky Footer</h2>
@@ -199,7 +199,7 @@ Basecoat uses the native `<dialog>` element and `showModal()`. This differs from
<Preview>
<button type="button" onclick="document.getElementById('dialog-scrollable').showModal()" class="btn" data-variant="outline">Scrollable Content</button>
<dialog id="dialog-scrollable" class="dialog" aria-labelledby="dialog-scrollable-title" aria-describedby="dialog-scrollable-description" onclick="if (event.target === this) this.close()">
<dialog id="dialog-scrollable" class="dialog" aria-labelledby="dialog-scrollable-title" aria-describedby="dialog-scrollable-description" onpointerdown="this.dataset.backdropPress = event.target === this" onclick="if (event.target === this && this.dataset.backdropPress === 'true') this.close()">
<div>
<header>
<h2 id="dialog-scrollable-title">Scrollable Content</h2>
@@ -229,7 +229,7 @@ Dialog positioning and close button placement use logical properties. Set `dir="
<Preview>
<div dir="rtl">
<button type="button" onclick="document.getElementById('dialog-rtl').showModal()" class="btn" data-variant="outline">فتح الحوار</button>
<dialog id="dialog-rtl" class="dialog" aria-labelledby="dialog-rtl-title" aria-describedby="dialog-rtl-description" onclick="if (event.target === this) this.close()">
<dialog id="dialog-rtl" class="dialog" aria-labelledby="dialog-rtl-title" aria-describedby="dialog-rtl-description" onpointerdown="this.dataset.backdropPress = event.target === this" onclick="if (event.target === this && this.dataset.backdropPress === 'true') this.close()">
<div class="sm:max-w-sm">
<header>
<h2 id="dialog-rtl-title">تعديل الملف الشخصي</h2>
+11
View File
@@ -10,6 +10,7 @@
hx-get="/fragments/toast/success"
hx-target="#toaster"
hx-swap="beforeend"
hx-push-url="false"
>Toast from backend (with HTMX)</button>
</Preview>
@@ -163,6 +164,10 @@ Append server-rendered toast markup to the toaster, or create one from JavaScrip
### Config object
<Callout type="warning" title="Text is escaped by default" icon="triangle-alert">
`title`, `description`, and action labels are inserted as plain text. With `html: true` they are inserted as HTML, so never pass untrusted user input with it. `onclick` is JavaScript code and must never contain user input. `icon` is always inserted as raw HTML (it is meant for SVG markup).
</Callout>
<dl>
<dt><code>duration</code> <span class="badge" data-variant="secondary">Optional</span></dt>
<dd>Timeout in milliseconds. Defaults to <code>3000</code>, or <code>5000</code> for error toasts. Use <code>-1</code> to keep it open.</dd>
@@ -176,4 +181,10 @@ Append server-rendered toast markup to the toaster, or create one from JavaScrip
<dd>Primary action. Supports <code>label</code> with either <code>href</code> or <code>onclick</code>.</dd>
<dt><code>cancel</code> <span class="badge" data-variant="secondary">Optional</span></dt>
<dd>Secondary action. Supports <code>label</code> and optional <code>onclick</code>.</dd>
<dt><code>icon</code> <span class="badge" data-variant="secondary">Optional</span></dt>
<dd>Custom icon markup, usually an SVG. Inserted as raw HTML. Defaults to the category icon.</dd>
<dt><code>html</code> <span class="badge" data-variant="secondary">Optional</span></dt>
<dd>Set to <code>true</code> to insert <code>title</code>, <code>description</code>, and action labels as HTML instead of plain text. Defaults to <code>false</code>. Only use it with trusted content.</dd>
</dl>
The Jinja and Nunjucks `toast()` macros render on your server, so your template engine's autoescaping applies to `title`, `description`, and labels there when it is enabled. Nunjucks enables it by default. With Jinja, make sure your environment's `autoescape` setting covers the Basecoat template files (for example `.html.jinja`). `icon` is output with `safe`, and `onclick` is output as an attribute, so neither should contain user input.
+6 -6
View File
@@ -45,12 +45,12 @@ Learn more in the [shadcn/ui theming docs](https://ui.shadcn.com/docs/theming).
## Fonts
Basecoat does not ship web font files by default. Its font tokens prefer [Geist Sans](https://fonts.google.com/specimen/Geist) and [Geist Mono](https://fonts.google.com/specimen/Geist+Mono) when those fonts are available, then fall back to the full Tailwind default sans and mono stacks.
Basecoat does not ship web font files by default. Its font tokens prefer [Geist](https://fonts.google.com/specimen/Geist) and [Geist Mono](https://fonts.google.com/specimen/Geist+Mono) when those fonts are available, then fall back to the full Tailwind default sans and mono stacks.
Install the fonts with Fontsource:
```bash
npm install @fontsource/geist-sans @fontsource/geist-mono
npm install @fontsource/geist @fontsource/geist-mono
```
Then import the font files. Basecoat's default font tokens already reference Geist with full system fallbacks:
@@ -58,10 +58,10 @@ Then import the font files. Basecoat's default font tokens already reference Gei
```css
@import "tailwindcss";
@import "basecoat-css/sera";
@import "@fontsource/geist-sans/400.css";
@import "@fontsource/geist-sans/500.css";
@import "@fontsource/geist-sans/600.css";
@import "@fontsource/geist-sans/700.css";
@import "@fontsource/geist/400.css";
@import "@fontsource/geist/500.css";
@import "@fontsource/geist/600.css";
@import "@fontsource/geist/700.css";
@import "@fontsource/geist-mono/400.css";
@import "@fontsource/geist-mono/500.css";
@import "@fontsource/geist-mono/600.css";
+15 -1
View File
@@ -64,6 +64,8 @@ Or load the Basecoat runtime once, then only the component scripts you use.
| `dist/basecoat-{style}.cdn.min.css` | Standalone CDN stylesheet for `vega`, `nova`, `maia`, `lyra`, `mira`, `luma`, `sera`, or `rhea`. |
| `dist/basecoat-base.cdn.min.css` | Base tokens, utilities, and component structure without a style pack. |
| `dist/basecoat-compat.cdn.min.css` | Optional pre-1.0 compatibility aliases. Load after Basecoat. |
| `dist/basecoat.bundle.css` | Uncompiled single-file source for use with your own Tailwind build. Alias for the Vega style. |
| `dist/basecoat-{style}.bundle.css` | Uncompiled single-file source for use with your own Tailwind build, for `vega`, `nova`, `maia`, `lyra`, `mira`, `luma`, `sera`, or `rhea`. |
| `dist/js/all.min.js` | All auto-initialized JavaScript components except Chart. |
| `dist/js/basecoat.min.js` | Runtime only. Load once before individual component scripts. |
| `dist/js/{component}.min.js` | Individual JavaScript component file. |
@@ -129,6 +131,10 @@ Or import a named style bundle.
@import "basecoat-css/maia";
```
<Callout title="Using the Tailwind CLI without npm?" icon="info">
Download a single-file bundle, for example <a href={`https://cdn.jsdelivr.net/npm/basecoat-css@${basecoatVersion}/dist/basecoat-maia.bundle.css`}>{`basecoat-maia.bundle.css`}</a>. Save it next to your CSS and import it after Tailwind: `@import "tailwindcss"; @import "./basecoat-maia.bundle.css";`. Use `basecoat.bundle.css` for the default Vega style.
</Callout>
<Callout type="warning" title="Stylesheet order matters" icon="triangle-alert">
Load Basecoat after any stylesheet that includes Tailwind base/preflight. Loading another full Tailwind build after Basecoat can reset borders, inputs, and other component defaults.
</Callout>
@@ -195,7 +201,7 @@ See [Templates](/templates) for the available macros and arguments.
| `basecoat-css/components` | All component structure without a style pack. |
| `basecoat-css/components/button` | Individual component CSS. Replace `button` with any component file. |
| `basecoat-css/styles/maia` | Individual style-pack CSS. Use with `basecoat-css/base` or `basecoat-css/components`. |
| `basecoat-css/all.css` | Full default stylesheet plus all style-pack CSS. Use only when you need every style pack available at runtime. |
| `basecoat-css/all.css` | Alias for `basecoat-css`, the default Vega stylesheet. It does not include the other style packs. |
| `basecoat-css/compat` | Optional pre-1.0 compatibility aliases. Load after Basecoat. |
### Template files
@@ -243,6 +249,14 @@ Chart uses a separate helper and is not included in `basecoat-css/all`.
Each component page shows the minimal scripts or imports required for that component.
### JavaScript lifecycle
Basecoat initializes registered components on page load and when new DOM is inserted.
Use `window.basecoat.initAll()` after manually inserting HTML that contains Basecoat components. It only initializes components that are not already initialized.
Use `window.basecoat.initAll({ force: true })` when a framework or navigation library restores previously initialized DOM from a cache, such as an HTMX history restore. Force mode destroys existing component instances before reinitializing them, so it also resets transient state such as open menus, focus state, and active interactions.
## Compatibility
Basecoat 1.0 introduces new style packs and a smaller public API based on root classes plus semantic HTML and documented attributes. If you need pre-1.0 class aliases while migrating, load the compatibility stylesheet after your Basecoat stylesheet.
+2 -3
View File
@@ -1,6 +1,3 @@
import "../../../src/js/basecoat.js";
import "../../../src/js/accordion.js";
const initPriceRange = () => {
const input = document.getElementById("price-range");
const output = document.getElementById("price-range-output");
@@ -104,3 +101,5 @@ if (document.readyState === "loading") {
} else {
initDemos();
}
document.addEventListener("htmx:afterSettle", initDemos);
+2 -2
View File
@@ -84,7 +84,7 @@
}
@theme {
--font-sans: "Geist Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-sans: "Geist", "Geist Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
@@ -131,6 +131,6 @@
@apply scroll-smooth;
}
body {
@apply bg-background text-foreground overscroll-none antialiased;
@apply bg-background text-foreground overscroll-none;
}
}
+20
View File
@@ -77,5 +77,25 @@
> * > footer {
@apply flex flex-col-reverse sm:flex-row sm:justify-end;
}
&:is([data-size='sm']) > * > header,
> [data-size='sm'] > header {
@apply sm:place-items-center sm:text-center;
}
&:is([data-size='sm']) > * > header:has(> figure),
> [data-size='sm'] > header:has(> figure) {
@apply sm:grid-rows-[auto_auto_1fr];
}
&:is([data-size='sm']) > * > header:has(> figure) > :is(h2, p),
> [data-size='sm'] > header:has(> figure) > :is(h2, p) {
@apply sm:col-start-auto;
}
&:is([data-size='sm']) > * > footer,
> [data-size='sm'] > footer {
@apply grid grid-cols-2 sm:grid sm:grid-cols-2;
}
}
}
+6 -1
View File
@@ -415,7 +415,7 @@
/* Combobox */
.combobox > input[role='combobox'] {
@apply bg-input/50 border-transparent focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-3xl border py-2 ps-3 pe-8 text-sm transition-[color,box-shadow,background-color] focus-visible:ring-3 aria-invalid:ring-3;
@apply bg-input/50 border-transparent focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-3xl border py-2 ps-3 pe-8 text-base md:text-sm transition-[color,box-shadow,background-color] focus-visible:ring-3 aria-invalid:ring-3;
}
.combobox > svg,
@@ -540,6 +540,11 @@
@apply bg-popover text-popover-foreground ring-foreground/5 dark:ring-foreground/10 max-w-xs gap-6 rounded-4xl p-6 shadow-xl ring-1 duration-100 sm:max-w-md;
}
.alert-dialog:is([data-size='sm']) > *,
.alert-dialog > [data-size='sm'] {
@apply sm:max-w-xs;
}
.dialog > * {
@apply bg-popover text-popover-foreground ring-foreground/5 dark:ring-foreground/10 max-w-[calc(100%-2rem)] gap-6 rounded-4xl p-6 text-sm shadow-xl ring-1 duration-100 sm:max-w-md;
}
+5
View File
@@ -520,6 +520,11 @@
@apply bg-popover text-popover-foreground ring-foreground/10 max-w-xs gap-4 rounded-none p-4 ring-1 duration-100 sm:max-w-sm;
}
.alert-dialog:is([data-size='sm']) > *,
.alert-dialog > [data-size='sm'] {
@apply sm:max-w-xs;
}
.dialog > * {
@apply bg-popover text-popover-foreground ring-foreground/10 max-w-[calc(100%-2rem)] gap-4 rounded-none p-4 text-xs/relaxed ring-1 duration-100 sm:max-w-sm;
}
+6 -1
View File
@@ -407,7 +407,7 @@
/* Combobox */
.combobox > input[role='combobox'] {
@apply border-input bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-4xl border py-2 ps-3 pe-8 text-sm transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px];
@apply border-input bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-4xl border py-2 ps-3 pe-8 text-base md:text-sm transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px];
}
.combobox > svg,
@@ -532,6 +532,11 @@
@apply bg-popover text-popover-foreground ring-foreground/5 max-w-xs gap-6 rounded-4xl p-6 ring-1 duration-100 sm:max-w-md;
}
.alert-dialog:is([data-size='sm']) > *,
.alert-dialog > [data-size='sm'] {
@apply sm:max-w-xs;
}
.dialog > * {
@apply bg-popover text-popover-foreground ring-foreground/5 max-w-[calc(100%-2rem)] gap-6 rounded-4xl p-6 text-sm ring-1 duration-100 sm:max-w-md;
}
+6 -1
View File
@@ -407,7 +407,7 @@
/* Combobox */
.combobox > input[role='combobox'] {
@apply border-input bg-input/20 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 rounded-md border py-1 ps-2 pe-6 text-xs/relaxed transition-colors focus-visible:ring-2 aria-invalid:ring-2;
@apply border-input bg-input/20 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 rounded-md border py-1 ps-2 pe-6 text-sm md:text-xs/relaxed transition-colors focus-visible:ring-2 aria-invalid:ring-2;
}
.combobox > svg,
@@ -536,6 +536,11 @@
@apply bg-popover text-popover-foreground ring-foreground/10 max-w-xs gap-3 rounded-xl p-4 ring-1 duration-100 sm:max-w-sm;
}
.alert-dialog:is([data-size='sm']) > *,
.alert-dialog > [data-size='sm'] {
@apply max-w-64 sm:max-w-64;
}
.dialog > * {
@apply bg-popover text-popover-foreground ring-foreground/10 max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-xs/relaxed ring-1 duration-100 sm:max-w-sm;
}
+6 -1
View File
@@ -403,7 +403,7 @@
/* Combobox */
.combobox > input[role='combobox'] {
@apply border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent py-1 ps-2.5 pe-8 text-sm transition-colors focus-visible:ring-3 aria-invalid:ring-3;
@apply border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent py-1 ps-2.5 pe-8 text-base md:text-sm transition-colors focus-visible:ring-3 aria-invalid:ring-3;
}
.combobox > svg,
@@ -528,6 +528,11 @@
@apply bg-popover text-popover-foreground ring-foreground/10 max-w-xs gap-4 rounded-xl p-4 ring-1 duration-100 sm:max-w-sm;
}
.alert-dialog:is([data-size='sm']) > *,
.alert-dialog > [data-size='sm'] {
@apply sm:max-w-xs;
}
.dialog > * {
@apply bg-popover text-popover-foreground ring-foreground/10 max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm;
}
+6 -1
View File
@@ -415,7 +415,7 @@
/* Combobox */
.combobox > input[role='combobox'] {
@apply bg-input/50 border-transparent focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-8 rounded-2xl border py-1 ps-2.5 pe-8 text-sm transition-[color,box-shadow] duration-200 focus-visible:ring-3 aria-invalid:ring-3;
@apply bg-input/50 border-transparent focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-8 rounded-2xl border py-1 ps-2.5 pe-8 text-base md:text-sm transition-[color,box-shadow] duration-200 focus-visible:ring-3 aria-invalid:ring-3;
}
.combobox > svg,
@@ -540,6 +540,11 @@
@apply bg-popover text-popover-foreground ring-foreground/5 dark:ring-foreground/10 max-w-xs gap-6 rounded-3xl p-6 shadow-xl ring-1 duration-100 sm:max-w-md;
}
.alert-dialog:is([data-size='sm']) > *,
.alert-dialog > [data-size='sm'] {
@apply sm:max-w-xs;
}
.dialog > * {
@apply bg-popover text-popover-foreground ring-foreground/5 dark:ring-foreground/10 max-w-[calc(100%-2rem)] gap-6 rounded-3xl p-6 text-sm shadow-xl ring-1 duration-100 sm:max-w-md;
}
+6 -1
View File
@@ -419,7 +419,7 @@
/* Combobox */
.combobox > input[role='combobox'] {
@apply border-transparent border-b-input bg-transparent focus-visible:border-b-ring aria-invalid:border-b-destructive dark:aria-invalid:border-b-destructive/50 h-10 rounded-none border py-2 ps-0 pe-8 text-sm transition-[color,border-color];
@apply border-transparent border-b-input bg-transparent focus-visible:border-b-ring aria-invalid:border-b-destructive dark:aria-invalid:border-b-destructive/50 h-10 rounded-none border py-2 ps-0 pe-8 text-base md:text-sm transition-[color,border-color];
}
.combobox > svg,
@@ -544,6 +544,11 @@
@apply bg-popover text-popover-foreground ring-foreground/10 max-w-xs gap-6 rounded-none p-6 shadow-md ring-1 duration-100 sm:max-w-md;
}
.alert-dialog:is([data-size='sm']) > *,
.alert-dialog > [data-size='sm'] {
@apply sm:max-w-xs;
}
.dialog > * {
@apply bg-popover text-popover-foreground ring-foreground/10 max-w-[calc(100%-2rem)] gap-6 rounded-none p-6 text-sm shadow-md ring-1 duration-100 sm:max-w-md;
}
+6 -1
View File
@@ -403,7 +403,7 @@
/* Combobox */
.combobox > input[role='combobox'] {
@apply border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-md border bg-transparent py-2 ps-2.5 pe-8 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-3 aria-invalid:ring-3;
@apply border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-md border bg-transparent py-2 ps-2.5 pe-8 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-3 aria-invalid:ring-3;
}
.combobox > svg,
@@ -528,6 +528,11 @@
@apply bg-popover text-popover-foreground ring-foreground/10 max-w-xs gap-6 rounded-xl p-6 ring-1 duration-100 sm:max-w-lg;
}
.alert-dialog:is([data-size='sm']) > *,
.alert-dialog > [data-size='sm'] {
@apply sm:max-w-xs;
}
.dialog > * {
@apply bg-popover text-popover-foreground ring-foreground/10 max-w-[calc(100%-2rem)] gap-6 rounded-xl p-6 text-sm ring-1 duration-100 sm:max-w-md;
}
+26 -6
View File
@@ -63,9 +63,24 @@
node.querySelectorAll('[data-basecoat-component]').forEach(destroyComponent);
};
const initAllComponents = () => {
const uniqueElements = (elements) => Array.from(new Set(elements));
const getComponentElements = (componentName, selector, force = false) => {
const elements = Array.from(document.querySelectorAll(selector));
if (force) {
elements.push(...document.querySelectorAll(`[data-basecoat-component="${componentName}"]`));
}
return uniqueElements(elements);
};
const initAllComponents = (options = {}) => {
const force = options.force === true;
Object.entries(componentRegistry).forEach(([name, { selector }]) => {
document.querySelectorAll(selector).forEach(element => initComponent(element, name));
getComponentElements(name, selector, force).forEach((element) => {
const wasComponent = element.dataset?.basecoatComponent === name;
if (force) destroyComponent(element);
if (wasComponent || element.matches(selector)) initComponent(element, name);
});
});
};
@@ -111,18 +126,23 @@
observer = null;
};
const initRegisteredComponent = (componentName) => {
const initRegisteredComponent = (componentName, options = {}) => {
const component = componentRegistry[componentName];
if (!component) {
console.warn(`Component '${componentName}' not found in registry`);
return;
}
document.querySelectorAll(component.selector).forEach(element => initComponent(element, componentName));
const force = options.force === true;
getComponentElements(componentName, component.selector, force).forEach((element) => {
const wasComponent = element.dataset?.basecoatComponent === componentName;
if (force) destroyComponent(element);
if (wasComponent || element.matches(component.selector)) initComponent(element, componentName);
});
};
const initAllRegisteredComponents = () => {
initAllComponents();
const initAllRegisteredComponents = (options = {}) => {
initAllComponents(options);
};
const setTheme = (mode) => {
+2 -2
View File
@@ -1,6 +1,6 @@
(() => {
const initSidebar = (sidebarComponent) => {
if (sidebarComponent.dataset.sidebarInitialized) return;
if (sidebarComponent.dataset.sidebarInitialized && typeof sidebarComponent.toggle === 'function') return;
const initialOpen = sidebarComponent.dataset.initialOpen !== 'false';
const initialMobileOpen = sidebarComponent.dataset.initialMobileOpen === 'true';
@@ -60,6 +60,6 @@
};
if (window.basecoat) {
window.basecoat.register('sidebar', '.sidebar:not([data-sidebar-initialized])', initSidebar);
window.basecoat.register('sidebar', '.sidebar', initSidebar);
}
})();
+18 -7
View File
@@ -131,6 +131,13 @@
element.addEventListener('transitionend', () => element.remove(), { once: true });
}
const escapeHtml = (value) => String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
function createToast(config) {
const {
category = 'info',
@@ -140,18 +147,22 @@
cancel,
duration,
icon,
html: allowHtml = false,
} = config;
// Text is escaped unless `html: true`. `icon` is always raw markup (SVG).
const text = (value) => (allowHtml ? String(value) : escapeHtml(value));
const iconHtml = icon || (category && ICONS[category]) || '';
const titleHtml = title ? `<h2>${title}</h2>` : '';
const descriptionHtml = description ? `<p>${description}</p>` : '';
const titleHtml = title ? `<h2>${text(title)}</h2>` : '';
const descriptionHtml = description ? `<p>${text(description)}</p>` : '';
const actionHtml = action?.href
? `<a href="${action.href}" class="btn" data-toast-action>${action.label}</a>`
? `<a href="${escapeHtml(action.href)}" class="btn" data-toast-action>${text(action.label)}</a>`
: action?.onclick
? `<button type="button" class="btn" data-toast-action onclick="${action.onclick}">${action.label}</button>`
? `<button type="button" class="btn" data-toast-action onclick="${escapeHtml(action.onclick)}">${text(action.label)}</button>`
: '';
const cancelHtml = cancel
? `<button type="button" class="btn h-6 text-xs px-2.5 rounded-sm" data-variant="outline" data-toast-cancel onclick="${cancel?.onclick || ''}">${cancel.label}</button>`
? `<button type="button" class="btn h-6 text-xs px-2.5 rounded-sm" data-variant="outline" data-toast-cancel onclick="${escapeHtml(cancel?.onclick || '')}">${text(cancel.label)}</button>`
: '';
const footerHtml = actionHtml || cancelHtml ? `<footer>${actionHtml}${cancelHtml}</footer>` : '';
@@ -161,8 +172,8 @@
class="toast"
role="${category === 'error' ? 'alert' : 'status'}"
aria-atomic="true"
${category ? `data-category="${category}"` : ''}
${duration !== undefined ? `data-duration="${duration}"` : ''}
${category ? `data-category="${escapeHtml(category)}"` : ''}
${duration !== undefined ? `data-duration="${escapeHtml(duration)}"` : ''}
>
<div class="toast-content">
${iconHtml}
+1 -1
View File
@@ -98,7 +98,7 @@
class="command-dialog {{ dialog_attrs.class | default('') }}"
aria-label="Command menu"
{% if open %}open{% endif %}
onclick="if (event.target === this) this.close()"
onpointerdown="this.dataset.backdropPress = event.target === this" onclick="if (event.target === this && this.dataset.backdropPress === 'true') this.close()"
{% for key, value in dialog_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
+1 -1
View File
@@ -53,7 +53,7 @@
{% if title %}aria-labelledby="{{ id }}-title"{% endif %}
{% if description %}aria-describedby="{{ id }}-description"{% endif %}
{% if open %}open{% endif %}
{% if close_on_overlay_click %}onclick="if (event.target === this) this.close()"{% endif %}
{% if close_on_overlay_click %}onpointerdown="this.dataset.backdropPress = event.target === this" onclick="if (event.target === this && this.dataset.backdropPress === 'true') this.close()"{% endif %}
{% for key, value in dialog_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
+1 -1
View File
@@ -92,7 +92,7 @@
class="command-dialog {{ dialog_attrs.class }}"
aria-label="Command menu"
{% if open %}open{% endif %}
onclick="if (event.target === this) this.close()"
onpointerdown="this.dataset.backdropPress = event.target === this" onclick="if (event.target === this && this.dataset.backdropPress === 'true') this.close()"
{% for key, value in dialog_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
+1 -1
View File
@@ -48,7 +48,7 @@
{% if title %}aria-labelledby="{{ id }}-title"{% endif %}
{% if description %}aria-describedby="{{ id }}-description"{% endif %}
{% if open %}open{% endif %}
{% if close_on_overlay_click %}onclick="if (event.target === this) this.close()"{% endif %}
{% if close_on_overlay_click %}onpointerdown="this.dataset.backdropPress = event.target === this" onclick="if (event.target === this && this.dataset.backdropPress === 'true') this.close()"{% endif %}
{% for key, value in dialog_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}