Compare commits

..
51 Commits
Author SHA1 Message Date
Ronan Berder 770b12f82f 0.3.3 2025-11-05 11:40:56 +08:00
Ronan Berder b8ac469755 Merge branch 'development' 2025-11-05 11:37:34 +08:00
Ronan Berder 14abbdc013 Accepting theirs changes on docs/src/assets/styles.css to avoid conflicts from Tailwind 2025-11-05 11:37:28 +08:00
Ronan Berder f367ebac2a Finishing Field 2025-11-05 11:25:13 +08:00
Ronan Berder b8d8b1741c Fixing issues with field 2025-11-04 20:41:23 +08:00
Ronan Berder f82b1d79d4 Cleaning up Button Group 2025-11-04 12:46:00 +08:00
Ronan Berder eed1938d2f Adding Icons section 2025-11-03 14:14:56 +08:00
Ronan Berder e5aed697c8 Adding spinner 2025-11-03 13:51:40 +08:00
Ronan Berder 0c16664e4b Adding Input Group, cleaning up other new components 2025-11-03 11:51:39 +08:00
Ronan Berder ab44d6d48a Adding empty, field and item, working on input group 2025-11-02 11:56:40 +08:00
Ronan Berder 15473865b4 Cleaning up button group 2025-10-31 08:56:44 +08:00
Ronan Berder 6148716f68 Adding kbd and draft command 2025-10-30 19:09:21 +08:00
Ronan Berder 045d13cd49 Added kbd and command 2025-10-30 10:03:34 +07:00
Ronan Berder 2962e980eb Minor fixes 2025-10-29 10:37:49 +07:00
Ronan Berder 382b8f6fa2 Update popover.njk 2025-08-31 20:29:09 +08:00
Ronan Berder d644cb9efd Merge pull request #90 from dmjio/patch-1
Update to sidebar.njk
2025-08-21 22:59:31 +08:00
David M. Johnson 1c1a413114 Update sidebar.njk
alphabetize
2025-08-21 08:56:35 -05:00
Ronan Berder 1e1682df21 0.3.2 2025-08-18 17:12:47 +08:00
Ronan Berder d280e21aac Remove testing message 2025-08-18 10:01:17 +08:00
Ronan Berder a03e225bd7 Merge branch 'development' 2025-08-18 09:57:07 +08:00
Ronan Berder bd23745393 Solving merging conflict with #86 2025-08-18 09:57:00 +08:00
Ronan Berder 29600bd3ac Minor improvements to select 2025-08-18 09:52:09 +08:00
Ronan Berder 97a53f1a67 Merge pull request #59 from f15u/federico/fix-kitchen-sink
docs: fix label textarea
2025-08-18 09:39:30 +08:00
Ronan Berder ea4fd16b00 Merge pull request #68 from mtt-artis/patch-1
docs: typo
2025-08-18 09:02:23 +08:00
Ronan Berder c5db76d677 Merge pull request #86 from elanfranconi/elanfranconi/patch_closeToast_blur
Blur only occurs when the active element is inside the toast.
2025-08-18 08:48:50 +08:00
Elias Lanfranconi 246aa65860 Blur only occurs when the active element is inside the toast. 2025-08-10 14:09:05 +01:00
Ronan Berder 150c20fd67 Preventing change event from being triggered when initializing 2025-08-02 02:29:25 -06:00
Ronan Berder 81acdd18cc Fixing docs (typo and navigation) 2025-07-28 18:30:52 +08:00
Ronan Berder 16219ccae6 Improving registration and initialization of components, updating docs 2025-07-28 18:16:41 +08:00
Ronan Berder a2e9775a2c Fixing minor bugs and starting work on carousel 2025-07-13 10:39:13 +08:00
mtt 2a43d10da9 docs: typo 2025-07-12 15:54:10 +02:00
Federico Biccheddu 92ebe490b3 docs: fix label textarea 2025-07-01 23:18:14 +02:00
Ronan Berder 4a44656fd9 Bumping up version to 0.2.8 2025-06-30 19:48:12 +08:00
Ronan Berder 608e3b2197 Preventing change event from being dispatched if value hasn't changed 2025-06-30 19:46:59 +08:00
Ronan Berder a1a74c220f Adding sponsor link 2025-06-29 14:04:49 +08:00
Ronan Berder 55e3c78131 Adding contributing guidelines 2025-06-29 14:03:13 +08:00
Ronan Berder 113d34a842 Bumping version number to 0.2.7 after failed npm publish 2025-06-25 09:53:07 +08:00
Ronan Berder 972719f08f Fixing bug on hover for select 2025-06-25 09:49:01 +08:00
Ronan Berder d125153921 Bumping up package version 2025-06-25 09:40:59 +08:00
Ronan Berder 2389133e12 Handle mouse and keyboard navigation on select and dropdown 2025-06-25 09:27:29 +08:00
Ronan Berder 3f12f91765 Fixing form page 2025-06-24 22:18:37 +08:00
Ronan Berder ae4019a713 Adding theme switcher page to the docs 2025-06-24 21:14:19 +08:00
Ronan Berder 0d63543fa8 Bumping up the version number 2025-06-22 09:24:52 +08:00
Ronan Berder 14c3f85214 Fixing CSS errors 2025-06-22 09:21:22 +08:00
Ronan Berder 09bab0933e Fixing CSS errors 2025-06-22 09:15:20 +08:00
Ronan Berder 87730a1bfe Typos 2025-06-21 20:02:41 +08:00
Ronan Berder 79a61a2a2b Fixing typo 2025-06-21 17:35:55 +08:00
Ronan Berder 106914f3c4 Fixing typo 2025-06-21 15:29:19 +08:00
Ronan Berder cfa7dcbd54 Removing Alpine.js entirely 2025-06-20 08:29:29 +08:00
Ronan Berder 9c01b1db6d Removing mentions of Alpine.js (RIP) 2025-06-20 06:18:30 +08:00
Ronan Berder fca594d370 Updating docs 2025-06-19 21:38:32 +08:00
70 changed files with 5605 additions and 775 deletions
+1
View File
@@ -0,0 +1 @@
docs/src/assets/styles.css merge=theirs
+1
View File
@@ -1,5 +1,6 @@
.*
!.gitignore
!.gitattributes
!.eleventy.js
_site/
+87
View File
@@ -0,0 +1,87 @@
# Basecoat – Agent Guide
## What is Basecoat?
Basecoat is a vanilla JavaScript/HTML/CSS port of shadcn/ui components. It reuses shadcn/ui's design patterns and Tailwind CSS classes while maintaining semantic HTML, native browser elements, and full accessibility (ARIA attributes, proper hierarchies, keyboard navigation). The goal is to provide shadcn-quality components without React dependencies.
## Quickstart
- Install deps: `npm i`
- Dev docs server: `npm run docs:dev`
- Build CSS/JS: `npm run build`
- Build static docs: `npm run docs:build`
## What to edit (and what not)
- Edit sources only:
- `src/css/basecoat.css` – All component CSS classes (Tailwind + custom)
- `src/js/*.js` – Individual component JS files (kebab-case, ESM) + `basecoat.js` (component registry)
- `src/nunjucks/*.njk` and `src/jinja/*.html.jinja` – Component template macros
- `docs/src/components/*.njk` – Component documentation pages with examples
- `docs/src/_includes/` – Layout templates, partials, and navigation
- Do not edit build outputs:
- `packages/*/dist` – built CSS/JS bundles
- `_site/` – generated docs site
## Project layout
- Workspaces: root npm workspace with publishable packages in `packages/*`.
- Packages:
- `packages/css` – built CSS and JS bundles under `dist/`.
- `packages/cli` – CLI; distributed assets live under `dist/`.
- Docs site: `docs/src` (Eleventy); output goes to `_site/`.
## Conventions
- ESM everywhere (`import`/`export`).
- Prettier defaults (2 spaces; single quotes per file preference).
- Filenames for assets: kebab-case (e.g., `dropdown-menu.js`, `basecoat.cdn.css`).
- JS naming: `camelCase` for vars/functions; `PascalCase` for DOM classes and exported component names when applicable.
## Design Principles
- **Minimal code**: Prefer native HTML elements and browser behavior over JavaScript abstractions
- **Accessibility-first**: Semantic HTML, proper ARIA attributes, keyboard navigation
- **CSS patterns**:
- Use `border` for lines/separators (not `background` on `<hr>`)
- Use `:is()` and `:has()` for compact selectors
- Use `isolation: isolate` to contain z-index stacking contexts
- Use `data-*` attributes for styling states, `aria-*` for accessibility semantics
- **Component patterns**:
- Dialog variants use wrapper classes (e.g., `.command-dialog` wraps `.command`)
- JavaScript components register with `window.basecoat.register()`
- Emit custom events for user actions (e.g., `command:select`)
- Filter disabled options (`aria-disabled="true"`) from all interactions
- **When NOT to create a component**:
- If it's just composition of existing elements (users can do it with standard HTML/Tailwind)
- If behavior is fully native (e.g., fieldset, legend)
## Common tasks
- Update a component's CSS/JS: Edit `src/css/basecoat.css` and/or `src/js/*.js`.
- Update a component template: Edit `src/nunjucks/*.njk` and/or `src/jinja/*.html.jinja`.
- Add a new component: Create JS, CSS, templates, and docs page under `docs/src/components/*.njk`.
- Update navigation: Edit files in `docs/src/_includes/partials/`.
## Testing
No automated testing configured. User validates manually via the docs site (`npm run docs:dev`).
## Commits and PRs
- Commits: concise, imperative (≤72 chars), optional scope.
- Example: `select: prevent change event on init`.
- PRs: include description, linked issues (e.g., `Closes #123`), and before/after screenshots or code snippets for visual/behavioral changes.
- Keep PRs focused; update docs examples when components or APIs change.
## Environment and tooling
- Node 18+ recommended.
- Eleventy config: `.eleventy.js`.
- Tailwind via `@tailwindcss/cli` v4.
- Build uses Tailwind CLI and `terser`; both are provided via devDependencies.
## Package references
- `packages/css` usage and details: see `packages/css/README.md`.
- `packages/cli` usage and details: see `packages/cli/README.md`.
+15
View File
@@ -0,0 +1,15 @@
# Contributing to Basecoat
- Support development by sponsoring the author: https://github.com/sponsors/hunvreus
- Submit pull requests (PRs) against the `development` branch, not `main`.
- 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 and https://www.npmjs.com/package/basecoat-cli.
- `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`.
- Keep changes focused: one feature or fix per PR.
- Test locally before submitting.
- Follow existing code style.
Thanks for helping!
+1 -1
View File
@@ -6,7 +6,7 @@ A collection of modern UI components built with Tailwind CSS that works with any
## Features
- **Lightweight**: no runtime JS, just CSS and tiny Alpine.js scripts for the more interactive components.
- **Lightweight**: no runtime JS, just CSS and a tiny bit of vanilla JavaScript for the more interactive components.
- **Easy to use**: add classes like `btn` or `input` and you're done.
- **Framework-agnostic**: works with any backend or frontend stack.
- **Accessible**: components follow accessibility best practices.
+1 -1
View File
@@ -27,7 +27,7 @@
@apply font-medium underline underline-offset-4;
}
code:not(pre > code) {
@apply relative rounded-md bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm;
@apply relative rounded-md bg-muted/50 px-[0.3rem] py-[0.2rem] font-mono text-sm;
}
blockquote {
@apply border-l-4 border-border pl-4;
+137 -1
View File
@@ -5,4 +5,140 @@
@import "./themes/claude.css";
@import "./themes/doom-64.css";
@import "./themes/supabase.css";
@import "./themes/supabase.css";
/* @theme inline {
--breakpoint-3xl: 1600px;
--breakpoint-4xl: 2000px;
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-surface: var(--surface);
--color-surface-foreground: var(--surface-foreground);
--color-code: var(--code);
--color-code-foreground: var(--code-foreground);
--color-code-highlight: var(--code-highlight);
--color-code-number: var(--code-number);
--color-selection: var(--selection);
--color-selection-foreground: var(--selection-foreground);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: var(--color-blue-300);
--chart-2: var(--color-blue-500);
--chart-3: var(--color-blue-600);
--chart-4: var(--color-blue-700);
--chart-5: var(--color-blue-800);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--surface: oklch(0.98 0 0);
--surface-foreground: var(--foreground);
--code: var(--surface);
--code-foreground: var(--surface-foreground);
--code-highlight: oklch(0.96 0 0);
--code-number: oklch(0.56 0 0);
--selection: oklch(0.145 0 0);
--selection-foreground: oklch(1 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.269 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: var(--color-blue-300);
--chart-2: var(--color-blue-500);
--chart-3: var(--color-blue-600);
--chart-4: var(--color-blue-700);
--chart-5: var(--color-blue-800);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
--surface: oklch(0.2 0 0);
--surface-foreground: oklch(0.708 0 0);
--code: var(--surface);
--code-foreground: var(--surface-foreground);
--code-highlight: oklch(0.27 0 0);
--code-number: oklch(0.72 0 0);
--selection: oklch(0.922 0 0);
--selection-foreground: oklch(0.205 0 0);
} */
+1 -1
View File
@@ -1,7 +1,7 @@
export default {
title: "Basecoat",
description: "A components library built with Tailwind CSS that works with any web stack.",
keywords: ["components", "component library", "component system", "UI", "UI kit", "shadcn", "shadcn/ui", "Tailwind CSS", "Tailwind", "CSS", "HTML", "Jinja", "Nunjucks", "Alpine.js"],
keywords: ["components", "component library", "component system", "UI", "UI kit", "shadcn", "shadcn/ui", "Tailwind CSS", "Tailwind", "CSS", "HTML", "Jinja", "Nunjucks", "JS", "JavaScript", "vanilla JS", "vanilla JavaScript"],
author: {
name: "Ronan Berder",
x: "@hunvreus"
+7 -2
View File
@@ -69,14 +69,14 @@
{# CSS #}
<link rel="stylesheet" href="/assets/styles.css">
{# JS #}
<script src="/assets/js/basecoat.js" defer></script>
<script src="/assets/js/command.js" defer></script>
<script src="/assets/js/dropdown-menu.js" defer></script>
<script src="/assets/js/popover.js" defer></script>
<script src="/assets/js/select.js" defer></script>
<script src="/assets/js/sidebar.js" defer></script>
<script src="/assets/js/tabs.js" defer></script>
<script src="/assets/js/toast.js" defer></script>
{# Alpine.js #}
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
{# HTMX #}
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
{# Highlight.js #}
@@ -96,6 +96,11 @@
document.addEventListener('htmx:afterSwap', highlight);
}
</script>
<script>
document.addEventListener('htmx:historyRestore', () => {
if (window.basecoat) window.basecoat.initAll();
});
</script>
{% if head %}{{ head | safe }}{% endif %}
</head>
<body>
+15 -20
View File
@@ -1,28 +1,23 @@
{% macro code_block(code, language="html", class="my-4") %}
<div
class="relative {{ class }}"
x-data="{
copied: false,
copy() {
const codeElement = $refs.code;
if (!codeElement) return;
navigator.clipboard.writeText(codeElement.textContent || '').then(() => {
this.copied = true;
setTimeout(() => { this.copied = false }, 2000);
<div class="relative {{ class }}">
<pre class="grid text-sm max-h-[650px] overflow-y-auto rounded-xl scrollbar"><code class="language-{{ language }} !bg-muted/40 !p-3.5">{{- code | escape -}}</code></pre>
<button
onclick="
const button = this;
const code = this.parentElement.querySelector('pre code');
if (!code) return;
navigator.clipboard.writeText(code.textContent || '').then(() => {
button.classList.add('copied');
setTimeout(() => { button.classList.remove('copied') }, 2000);
}).catch(err => {
console.error('Failed to copy text: ', err);
});
}
}"
>
<pre x-ref="code" class="grid text-sm max-h-[650px] overflow-y-auto rounded-xl scrollbar"><code class="language-{{ language }} !bg-muted !p-3.5">{{- code | escape -}}</code></pre>
<button
@click="copy()"
class="btn-icon-ghost size-8 absolute right-2.5 top-2 text-muted-foreground hover:text-foreground"
"
class="btn-icon-ghost size-8 absolute right-2.5 top-2 text-muted-foreground hover:text-foreground group"
>
{% lucide "clipboard", {"x-show": "!copied" } %}
{% lucide "check", {"x-show": "copied" } %}
{% lucide "copy", {"class": "group-[.copied]:hidden" } %}
{% lucide "check", {"class": "hidden group-[.copied]:block" } %}
</button>
</div>
{% endmacro %}
@@ -7,20 +7,7 @@
</header>
<div class="p-4">
<div class="group grid w-full max-w-xl gap-4">
<section
x-init
@click="
const clickedSummary = $event.target.closest('summary');
if (!clickedSummary) return;
const clickedDetails = clickedSummary.closest('details');
if (!clickedDetails) return;
$el.querySelectorAll('details').forEach(detailsEl => {
if (detailsEl !== clickedDetails) {
detailsEl.removeAttribute('open');
}
});
"
>
<section class="accordion">
<details class="group border-b last:border-b-0">
<summary class="w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] transition-all outline-none rounded-md">
<h2 class="flex flex-1 items-start justify-between gap-4 py-4 text-left text-sm font-medium hover:underline ">
@@ -55,20 +42,7 @@
</section>
</details>
</section>
<section
x-init
@click="
const clickedSummary = $event.target.closest('summary');
if (!clickedSummary) return;
const clickedDetails = clickedSummary.closest('details');
if (!clickedDetails) return;
$el.querySelectorAll('details').forEach(detailsEl => {
if (detailsEl !== clickedDetails) {
detailsEl.removeAttribute('open');
}
});
"
>
<section class="accordion">
<details class="group border-b last:border-b-0">
<summary class="w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] transition-all outline-none rounded-md">
<h2 class="flex flex-1 items-start justify-between gap-4 py-4 text-left text-sm font-medium hover:underline ">
@@ -92,6 +66,24 @@
</section>
</details>
</section>
<script>
(() => {
const accordions = document.querySelectorAll('.accordion');
accordions.forEach(accordion => {
accordion.addEventListener('click', (event) => {
const summary = event.target.closest('summary');
if (!summary) return;
const details = summary.closest('details');
if (!details) return;
accordion.querySelectorAll('details').forEach(detailsEl => {
if (detailsEl !== details) {
detailsEl.removeAttribute('open');
}
});
});
});
})();
</script>
</div>
</div>
</section>
@@ -9,8 +9,8 @@
</header>
<div class="p-4">
{% set footer %}
<button class="btn-outline" @click="open = false">Cancel</button>
<button class="btn-primary" @click="open = false">Continue</button>
<button class="btn-outline" onclick="document.getElementById('alert-dialog-demo').close()">Cancel</button>
<button class="btn-primary" onclick="document.getElementById('alert-dialog-demo').close()">Continue</button>
{% endset %}
{{ dialog(
id="alert-dialog-demo",
@@ -10,8 +10,8 @@
<div class="p-4">
<div class="flex flex-wrap items-center gap-4">
{% set footer %}
<button class="btn-outline" @click="open = false">Cancel</button>
<button class="btn" @click="open = false">Save changes</button>
<button class="btn-outline" onclick="document.getElementById('demo-dialog-edit-profile').close()">Cancel</button>
<button class="btn" onclick="document.getElementById('demo-dialog-edit-profile').close()">Save changes</button>
{% endset %}
{% call dialog(
id="demo-dialog-edit-profile",
@@ -76,9 +76,6 @@
role="menuitemcheckbox"
aria-checked="true"
class="group"
x-data="{ checked: true }"
@click="checked = !checked"
:aria-checked="checked"
>
{% lucide "check", {
"class": "invisible group-aria-checked:visible",
@@ -93,9 +90,6 @@
aria-checked="false"
class="group"
aria-disabled="true"
x-data="{ checked: false }"
@click="checked = !checked"
:aria-checked="checked"
>
{% lucide "check", {
"class": "invisible group-aria-checked:visible",
@@ -109,9 +103,6 @@
role="menuitemcheckbox"
aria-checked="false"
class="group"
x-data="{ checked: false }"
@click="checked = !checked"
:aria-checked="checked"
>
{% lucide "check", {
"class": "invisible group-aria-checked:visible",
@@ -130,6 +121,19 @@
</div>
{% endcall %}
<script>
(() => {
const dropdownMenu = document.querySelector('#dropdown-menu-checkboxes');
const checkboxes = dropdownMenu.querySelectorAll('div[role="menuitemcheckbox"]');
checkboxes.forEach(checkbox => {
checkbox.addEventListener('click', () => {
const isChecked = checkbox.getAttribute('aria-checked') === 'true';
checkbox.setAttribute('aria-checked', !isChecked);
});
});
})();
</script>
{% set profile_menu %}
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<img alt="@hunvreus" src="https://github.com/hunvreus.png" class="size-8 shrink-0 rounded-full" alt="Ronan Berder"/>
@@ -164,19 +168,18 @@
{% endset %}
{% call dropdown_menu(
id="dropdown-menu-radio-group",
trigger="Radio Group",
trigger_attrs={"class": "btn-outline"},
popover_attrs={"class": "min-w-56"}
) %}
<div role="group" aria-labelledby="position-options" x-data="{ checked: 'bottom' }">
<div role="group" aria-labelledby="position-options">
<span id="position-options" role="heading">Panel Position</span>
<hr role="separator">
<div
role="menuitemradio"
aria-checked="false"
class="group"
@click="checked = 'top'"
:aria-checked="checked == 'top'"
>
<div class="size-4 flex items-center justify-center">
<div
@@ -190,10 +193,8 @@
</div>
<div
role="menuitemradio"
aria-checked="false"
aria-checked="true"
class="group"
@click="checked = 'bottom'"
:aria-checked="checked == 'bottom'"
>
<div class="size-4 flex items-center justify-center">
<div
@@ -209,8 +210,6 @@
role="menuitemradio"
aria-checked="false"
class="group"
@click="checked = 'right'"
:aria-checked="checked == 'right'"
>
<div class="size-4 flex items-center justify-center">
<div
@@ -224,6 +223,20 @@
</div>
</div>
{% endcall %}
<script>
(() => {
const dropdownMenu = document.querySelector('#dropdown-menu-radio-group');
const radioButtons = dropdownMenu.querySelectorAll('div[role="menuitemradio"]');
radioButtons.forEach(radioButton => {
radioButton.addEventListener('click', () => {
radioButtons.forEach(radioButton => {
radioButton.setAttribute('aria-checked', 'false');
});
radioButton.setAttribute('aria-checked', 'true');
});
});
})();
</script>
{% set trigger %}
<img alt="@hunvreus" src="https://github.com/hunvreus.png" class="size-8 shrink-0 rounded-full" alt="Ronan Berder"/>
@@ -19,8 +19,8 @@
</section>
<script>
(() => {
const sliderEl = document.querySelector('input[type="range"].input');
if (!sliderEl) return;
const sliders = document.querySelectorAll('input[type="range"].input');
if (!sliders) return;
const updateSlider = (el) => {
const min = parseFloat(el.min || 0);
@@ -30,7 +30,9 @@
el.style.setProperty('--slider-value', `${percent}%`);
};
updateSlider(sliderEl);
sliderEl.addEventListener('input', (event) => updateSlider(event.target));
sliders.forEach(slider => {
updateSlider(slider);
slider.addEventListener('input', (event) => updateSlider(event.target));
});
})();
</script>
+23 -13
View File
@@ -17,16 +17,8 @@
"data-keep-mobile-sidebar-open": ""
},
popover_attrs={
"data-side": "right",
"data-align": "end",
"class": "w-80",
"x-init": "
if (window.innerWidth < 768) {
$el.setAttribute('data-side', 'top');
$el.setAttribute('data-align', 'start');
$el.classList.add('w-[271px]');
}
"
"data-side": "top",
"class": "w-[271px] md:w-[239px]"
}
) %}
<div class="grid gap-4">
@@ -69,6 +61,14 @@
"hx-swap": "outerHTML"
} %}
{% set new_link_attrs = {
"hx-boost": "true",
"hx-select": "#content",
"hx-target": "#content",
"hx-swap": "outerHTML",
"data-new-link": "true"
} %}
{% set icon_home %}{% lucide "house" %}{% endset %}
{% set icon_info %}{% lucide "info" %}{% endset %}
{% set icon_square_terminal %}{% lucide "square-terminal" %}{% endset %}
@@ -94,25 +94,35 @@
{ label: "Badge", url: "/components/badge", attrs: link_attrs },
{ label: "Breadcrumb", url: "/components/breadcrumb", attrs: link_attrs },
{ label: "Button", url: "/components/button", attrs: link_attrs },
{ label: "Button Group", url: "/components/button-group", attrs: new_link_attrs },
{ label: "Card", url: "/components/card", attrs: link_attrs },
{ label: "Checkbox", url: "/components/checkbox", attrs: link_attrs },
{ 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: "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 },
{ label: "Form", url: "/components/form", attrs: link_attrs },
{ label: "Input", url: "/components/input", attrs: link_attrs },
{ label: "Input Group", url: "/components/input-group", attrs: new_link_attrs },
{ label: "Item", url: "/components/item", attrs: new_link_attrs },
{ label: "Kbd", url: "/components/kbd", attrs: new_link_attrs },
{ label: "Label", url: "/components/label", attrs: link_attrs },
{ label: "Pagination", url: "/components/pagination", attrs: link_attrs },
{ label: "Popover", url: "/components/popover", attrs: link_attrs },
{ label: "Progress", url: "/components/progress", attrs: new_link_attrs },
{ label: "Radio Group", url: "/components/radio-group", attrs: link_attrs },
{ label: "Select", url: "/components/select", attrs: link_attrs },
{ label: "Skeleton", url: "/components/skeleton", attrs: link_attrs },
{ label: "Sidebar", url: "/components/sidebar", attrs: link_attrs },
{ label: "Skeleton", url: "/components/skeleton", attrs: link_attrs },
{ label: "Slider", url: "/components/slider", attrs: link_attrs },
{ label: "Spinner", url: "/components/spinner", attrs: new_link_attrs },
{ label: "Switch", url: "/components/switch", attrs: link_attrs },
{ label: "Table", url: "/components/table", attrs: link_attrs },
{ label: "Tabs", url: "/components/tabs", attrs: link_attrs },
{ label: "Textarea", url: "/components/textarea", attrs: link_attrs },
{ label: "Theme Switcher", url: "/components/theme-switcher", attrs: link_attrs },
{ label: "Toast", url: "/components/toast", attrs: link_attrs },
{ label: "Tooltip", url: "/components/tooltip", attrs: link_attrs }
]}
@@ -124,6 +134,6 @@
label="Sidebar navigation",
header=header,
footer=footer,
content_attrs={ "class": "scrollbar" },
content_attrs={ "class": "scrollbar [&_[data-new-link]::after]:content-['New'] [&_[data-new-link]::after]:ml-auto [&_[data-new-link]::after]:text-xs [&_[data-new-link]::after]:font-medium [&_[data-new-link]::after]:bg-sidebar-primary [&_[data-new-link]::after]:text-sidebar-primary-foreground [&_[data-new-link]::after]:px-2 [&_[data-new-link]::after]:py-0.5 [&_[data-new-link]::after]:rounded-md" },
menu=menu
) }}
) }}
+1380 -212
View File
File diff suppressed because it is too large Load Diff
+21 -16
View File
@@ -11,23 +11,10 @@ toc:
<div class="alert mb-6">
{% lucide "circle-alert" %}
<h2>There is no dedicated accordion component in Basecoat.</h2>
<h2>There is no dedicated Accordion component in Basecoat.</h2>
</div>
{% set code %}<section
x-init
@click="
const clickedSummary = $event.target.closest('summary');
if (!clickedSummary) return;
const clickedDetails = clickedSummary.closest('details');
if (!clickedDetails) return;
$el.querySelectorAll('details').forEach(detailsEl => {
if (detailsEl !== clickedDetails) {
detailsEl.removeAttribute('open');
}
});
"
>
{% set code %}<section class="accordion">
<details class="group border-b last:border-b-0 ">
<summary class="w-full focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] transition-all outline-none rounded-md">
<h2 class="flex flex-1 items-start justify-between gap-4 py-4 text-left text-sm font-medium hover:underline ">
@@ -62,9 +49,27 @@ toc:
</section>
</details>
</section>
<script>
(() => {
const accordions = document.querySelectorAll('.accordion');
accordions.forEach(accordion => {
accordion.addEventListener('click', (event) => {
const summary = event.target.closest('summary');
if (!summary) return;
const details = summary.closest('details');
if (!details) return;
accordion.querySelectorAll('details').forEach(detailsEl => {
if (detailsEl !== details) {
detailsEl.removeAttribute('open');
}
});
});
});
})();
</script>
{% endset %}
{{ code_preview("accordion", code | prettyHtml, "w-full max-w-md") }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<p class="prose">Basecoat animates <code>&lt;details&gt;</code> elements by default. The example above uses Alpine.js to handle the open/close state along with some Tailwind CSS classes.</p>
<p class="prose">Basecoat already animates <code>&lt;details&gt;</code> elements by default. The example add some Tailwind CSS utility classes for style and a bit of vanilla JavaScript to handle the open/close state.</p>
+1 -1
View File
@@ -43,7 +43,7 @@ toc:
<p>The Alert Dialog component is identical to <a href="/components/dialog" class="font-medium underline underline-offset-4">Dialog</a> except for two small differences: we do not display a close button and we do not let the user close the dialog when clicking on the backdrop (no <code>onclick</code> attribute on the <code>&lt;dialog&gt;</code> or the <code>&lt;article&gt;</code> elements).</p>
</section>
{{ code_block(code_html | prettyHtml, class="") }}
{{ code_block(code_html | prettyHtml) }}
<section class="prose">
<p>The component has the following HTML structure:</p>
+47 -14
View File
@@ -19,30 +19,63 @@ toc:
{% from "macros/code_preview.njk" import code_preview %}
{% from "macros/code_block.njk" import code_block %}
{% set code %}<div class="alert">
{% lucide "circle-check" %}
<h2>Success! Your changes have been saved</h2>
<section>This is an alert with icon, title and description.</section>
</div>{% endset %}
{{ code_preview("alert", code) }}
{% set code %}
<div class="grid w-full max-w-xl items-start gap-4">
<div class="alert">
{% lucide "circle-check" %}
<h2>Success! Your changes have been saved</h2>
<section>This is an alert with icon, title and description.</section>
</div>
<div class="alert">
{% lucide "popcorn" %}
<h2>This Alert has a title and an icon. No description.</h2>
<section>This is an alert with icon, title and description.</section>
</div>
<div class="alert-destructive">
{% lucide "circle-check" %}
<h2>Unable to process your payment.</h2>
<section>
<p>Please verify your billing information and try again.</p>
<ul class="list-inside list-disc text-sm">
<li>Check your card details</li>
<li>Ensure sufficient funds</li>
<li>Verify billing address</li>
</ul>
</section>
</div>
</div>
{% endset %}
{{ code_preview("alert", code | prettyHtml) }}
<h2 id="usage">Usage</h2>
<section class="prose">
<p>You can use one of the two classes: <code>alert</code> or <code>alert-dialog</code>.</p>
<p>Use the <code>alert</code> or <code>alert-destructive</code>.</p>
</section>
{{ code_block(code) }}
{% set code_simple %}
<div class="alert">
{% lucide "circle-check" %}
<h2>Success! Your changes have been saved</h2>
<section>This is an alert with icon, title and description.</section>
</div>
{% endset %}
{{ code_block(code_simple | prettyHtml) }}
<section class="prose">
<p>The component has the following HTML structure:</p>
<dl>
<dt><code class="highlight language-html">&lt;svg&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The icon.</dd>
<dt><code class="highlight language-html">&lt;h2&gt;</code></dt>
<dd>The title.</dd>
<dt><code class="highlight language-html">&lt;section&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The description.</dd>
<dt><code class="highlight language-html">&lt;div class="alert"&gt;</code></dt>
<dd>Main container. Use <code>alert</code> for default styling or <code>alert-destructive</code> for error states.
<dl>
<dt><code class="highlight language-html">&lt;svg&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The icon.</dd>
<dt><code class="highlight language-html">&lt;h2&gt;</code></dt>
<dd>The title.</dd>
<dt><code class="highlight language-html">&lt;section&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>The description.</dd>
</dl>
</dd>
</dl>
</section>
+21 -4
View File
@@ -12,12 +12,29 @@ toc:
<div class="alert mb-6">
{% lucide "circle-alert" %}
<h2>There is no dedicated avatar component in Basecoat.</h2>
<h2>There is no dedicated Avatar component in Basecoat.</h2>
</div>
{% set code %}<img class="size-8 shrink-0 object-cover rounded-full" alt="@hunvreus" src="https://github.com/hunvreus.png">{% endset %}
{{ code_preview("avatar", code, "max-w-md") }}
{% set code %}
<div class="flex flex-row flex-wrap items-center gap-12">
<img class="size-8 shrink-0 object-cover rounded-full" alt="@hunvreus" src="https://github.com/hunvreus.png">
<img class="size-8 shrink-0 object-cover rounded-lg" alt="@shadcn" src="https://github.com/shadcn.png">
<div class="flex -space-x-2 [&_img]:ring-background [&_img]:ring-2 [&_img]:grayscale [&_img]:size-8 [&_img]:shrink-0 [&_img]:object-cover [&_img]:rounded-full">
<img alt="@hunvreus" src="https://github.com/hunvreus.png">
<img alt="@shadcn" src="https://github.com/shadcn.png">
<img alt="@adamwathan" src="https://github.com/adamwathan.png">
</div>
</div>
{% endset %}
{{ code_preview("avatar", code | prettyHtml, "w-full max-w-md") }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<p class="prose">Avatars are fairly simple to implement with Tailwind. The example above shows how to implement one with Tailwind and an <code>&lt;img&gt;</code> element. More examples can be found in the <a href="/kitchen-sink#avatar">kitchen sink</a>.</p>
<section class="prose">
<p>Avatars are just <code>&lt;img&gt;</code> elements styled with Tailwind utility classes.</p>
</section>
{% set code_simple %}
<img class="size-8 shrink-0 object-cover rounded-full" alt="@hunvreus" src="https://github.com/hunvreus.png">
{% endset %}
{{ code_block(code_simple | prettyHtml, "html") }}
+19 -1
View File
@@ -25,7 +25,25 @@ toc:
{% from "macros/code_preview.njk" import code_preview %}
{% from "macros/code_block.njk" import code_block %}
{% set code %}<span class="badge">Badge</span>{% endset %}
{% set code %}
<div class="flex flex-col items-center gap-2">
<div class="flex w-full flex-wrap gap-2">
<span class="badge">Badge</span>
<span class="badge-secondary">Secondary</span>
<span class="badge-destructive">Destructive</span>
<span class="badge-outline">Outline</span>
</div>
<div class="flex w-full flex-wrap gap-2">
<span class="badge-secondary bg-blue-500 text-white dark:bg-blue-600">
{% lucide "badge-check" %}
Verified
</span>
<span class="badge rounded-full h-5 min-w-5 px-1 font-mono tabular-nums">8</span>
<span class="badge-destructive rounded-full h-5 min-w-5 px-1 font-mono tabular-nums">99</span>
<span class="badge-outline rounded-full h-5 min-w-5 px-1 font-mono tabular-nums">20+</span>
</div>
</div>
{% endset %}
{{ code_preview("badge", code) }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
+14 -3
View File
@@ -13,7 +13,7 @@ toc:
<div class="alert mb-6">
{% lucide "circle-alert" %}
<h2>There is no dedicated breadcrumb component in Basecoat.</h2>
<h2>There is no dedicated Breadcrumb component in Basecoat.</h2>
</div>
{% set code_html %}
@@ -54,7 +54,18 @@ toc:
<h2 id="usage"><a href="#usage">Usage</a></h2>
<section class="prose">
<p>This example uses some simple Tailwind CSS styles along with the <a href="/components/dropdown-menu">Dropdown Menu</a> component (which may require JavaScript).</p>
<p>You can style an ordered list of links combined with chevron icons as show below. The example above demonstrates a more complex use case using the <a href="/components/dropdown-menu">Dropdown Menu</a> component.</p>
</section>
{{ code_block(code_html | prettyHtml, class="") }}
{% set code_simple %}
<ol class="text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5">
<li class="inline-flex items-center gap-1.5">
<a href="#" class="hover:text-foreground transition-colors">Home</a>
</li>
<li>{% lucide "chevron-right", { "class": "size-3.5" } %}</li>
<li class="inline-flex items-center gap-1.5">
<a href="#" class="hover:text-foreground transition-colors">Components</a>
</li>
</ol>
{% endset %}
{{ code_block(code_simple | prettyHtml, "html") }}
+290
View File
@@ -0,0 +1,290 @@
---
layout: layouts/page.njk
title: Button Group
description: A container that groups related buttons together with consistent styling.
toc:
- label: Usage
id: usage
- label: Examples
id: examples
children:
- label: Orientation
id: example-orientation
- label: Nested
id: example-nested
- label: Separator
id: example-separator
- label: Input
id: example-input
- label: Dropdown Menu
id: example-dropdown-menu
- label: Select
id: example-select
- label: Popover
id: example-popover
---
{% from "dropdown-menu.njk" import dropdown_menu %}
{% from "popover.njk" import popover %}
{% from "select.njk" import select %}
{% from "macros/code_preview.njk" import code_preview %}
{% from "macros/code_block.njk" import code_block %}
{% set code_default %}
<div class="flex w-fit items-stretch gap-2">
<button type="button" class="btn-icon-outline" aria-label="Go Back">{% lucide "arrow-left" %}</button>
<div role="group" class="button-group">
<button type="button" class="btn-outline">Archive</button>
<button type="button" class="btn-outline">Report</button>
</div>
<div role="group" class="button-group">
<button type="button" class="btn-outline">Snooze</button>
{% set trigger %}
{% lucide "ellipsis" %}
{% endset %}
{% call dropdown_menu(
trigger=trigger,
trigger_attrs={"class": "btn-icon-outline"},
popover_attrs={"data-align": "end"}
) %}
<div role="menuitem">
{% lucide "mail-check" %}
Mark as Read
</div>
<div role="menuitem">
{% lucide "archive" %}
Archive
</div>
<hr role="separator">
<div role="menuitem">
{% lucide "clock" %}
Snooze
</div>
<div role="menuitem">
{% lucide "calendar" %}
Add to Calendar
</div>
<div role="menuitem">
{% lucide "list-filter-plus" %}
Add to List
</div>
<hr role="separator">
<div role="menuitem" class="text-destructive hover:bg-destructive/10 dark:hover:bg-destructive/20 focus:bg-destructive/10 dark:focus:bg-destructive/20 focus:text-destructive [&_svg]:!text-destructive">
{% lucide "trash-2" %}
Trash
</div>
{% endcall %}
</div>
{% endset %}
{{ code_preview("button-group-default", code_default | prettyHtml) }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<p class="prose">Simply wrap buttons you want to group together in a <code>&lt;div role="group" class="button-group"&gt;</code> element. You can also use the <code>data-orientation="vertical"</code> attribute to stack buttons vertically. You can also group buttons with <a href="#example-dropdown-menu">dropdown menus</a> or <a href="#example-popover">popovers</a> (provided the trigger is a button). Finally, you can use a <a href="#example-separator">separator</a> to visually divide buttons within a group (for secondary buttons for example).</p>
{% set code_simple %}
<div role="group" class="button-group">
<button type="button" class="btn-outline">Archive</button>
<button type="button" class="btn-outline">Report</button>
</div>
{% endset %}
{{ code_block(code_simple | prettyHtml, "html") }}
<h2 id="examples"><a href="#examples">Examples</a></h2>
<h3 id="example-orientation"><a href="#example-orientation">Orientation</a></h3>
<section class="prose">
<p>Use <code>data-orientation="vertical"</code> to stack buttons vertically.</p>
</section>
{% set code_orientation %}
<div role="group" class="button-group" data-orientation="vertical">
<button type="button" class="btn-icon-outline" aria-label="Zoom in">
{% lucide "plus" %}
</button>
<button type="button" class="btn-icon-outline" aria-label="Zoom out">
{% lucide "minus" %}
</button>
</div>
{% endset %}
{{ code_preview("button-group-orientation", code_orientation | prettyHtml) }}
<h3 id="example-size"><a href="#example-size">Size</a></h3>
{% set code_size %}
<div class="flex flex-col items-start gap-8">
<div role="group" class="button-group">
<button type="button" class="btn-sm-outline">Small</button>
<button type="button" class="btn-sm-outline">Button</button>
<button type="button" class="btn-sm-outline">Group</button>
<button type="button" class="btn-sm-icon-outline">{% lucide "plus" %}</button>
</div>
<div role="group" class="button-group">
<button type="button" class="btn-outline">Default</button>
<button type="button" class="btn-outline">Button</button>
<button type="button" class="btn-outline">Group</button>
<button type="button" class="btn-icon-outline">{% lucide "plus" %}</button>
</div>
<div role="group" class="button-group">
<button type="button" class="btn-lg-outline">Small</button>
<button type="button" class="btn-lg-outline">Button</button>
<button type="button" class="btn-lg-outline">Group</button>
<button type="button" class="btn-lg-icon-outline">{% lucide "plus" %}</button>
</div>
</div>
{% endset %}
{{ code_preview("button-group-size", code_size | prettyHtml) }}
<h3 id="example-nested"><a href="#example-nested">Nested</a></h3>
{% set code_nested %}
<div role="group" aria-label="Pagination controls" class="flex w-fit items-stretch gap-2">
<div role="group" class="button-group">
<button type="button" class="btn-sm-outline">1</button>
<button type="button" class="btn-sm-outline">2</button>
<button type="button" class="btn-sm-outline">3</button>
<button type="button" class="btn-sm-outline">4</button>
<button type="button" class="btn-sm-outline">5</button>
</div>
<div role="group" class="button-group">
<button type="button" class="btn-sm-icon-outline" aria-label="Previous page">
{% lucide "chevron-left" %}
</button>
<button type="button" class="btn-sm-icon-outline" aria-label="Next page">
{% lucide "chevron-right" %}
</button>
</div>
</div>
{% endset %}
{{ code_preview("button-group-nested", code_nested | prettyHtml) }}
<h3 id="example-separator"><a href="#example-separator">Separator</a></h3>
<section class="prose">
<p>Use <code>&lt;hr role="separator"&gt;</code> to visually divide buttons within a group.</p>
</section>
{% set code_separator %}
<div role="group" class="button-group">
<button type="button" class="btn-secondary">Copy</button>
<hr role="separator">
<button type="button" class="btn-secondary">Paste</button>
</div>
{% endset %}
{{ code_preview("button-group-separator", code_separator | prettyHtml) }}
<h3 id="example-input"><a href="#example-input">Input</a></h3>
{% set code_input %}
<div class="button-group">
<input type="text" class="input" placeholder="Search...">
<button type="button" class="btn-icon-outline">{% lucide "search" %}</button>
</div>
{% endset %}
{{ code_preview("button-group-input", code_input | prettyHtml) }}
<h3 id="example-dropdown-menu"><a href="#example-dropdown-menu">Dropdown Menu</a></h3>
{% set code_dropdown_menu %}
<div role="group" class="button-group">
<button type="button" class="btn-outline">Follow</button>
{% set trigger %}
{% lucide "chevron-down" %}
{% endset %}
{% call dropdown_menu(
trigger=trigger,
trigger_attrs={"class": "btn-icon-outline"},
popover_attrs={"data-align": "end"}
) %}
<div role="menuitem">
{% lucide "volume-off" %}
Mute Conversation
</div>
<div role="menuitem">
{% lucide "check" %}
Mark as Read
</div>
<div role="menuitem">
{% lucide "triangle-alert" %}
Report Conversation
</div>
<div role="menuitem">
{% lucide "user-round-x" %}
Block User
</div>
<div role="menuitem">
{% lucide "share" %}
Share Conversation
</div>
<div role="menuitem">
{% lucide "copy" %}
Copy Conversation
</div>
<hr role="separator">
<div role="menuitem" class="text-destructive hover:bg-destructive/10 dark:hover:bg-destructive/20 focus:bg-destructive/10 dark:focus:bg-destructive/20 focus:text-destructive [&_svg]:!text-destructive">
{% lucide "trash-2" %}
Delete Conversation
</div>
{% endcall %}
</div>
{% endset %}
{{ code_preview("button-group-dropdown-menu", code_dropdown_menu | prettyHtml) }}
<h3 id="example-select"><a href="#example-select">Select</a></h3>
{% set code_select %}
<div class="flex w-fit items-stretch gap-2">
<div class="button-group">
{% call select(trigger_attrs={"class": "[&_[data-name]]:hidden"}) %}
<div role="option" data-value="$">$ <span class="text-muted-foreground" data-name>US Dollar</span></div>
<div role="option" data-value="€">€ <span class="text-muted-foreground" data-name>Euro</span></div>
<div role="option" data-value="£">£ <span class="text-muted-foreground" data-name>British Pound</span></div>
{% endcall %}
<input type="text" class="input" placeholder="10.00">
</div>
<button type="button" class="btn-icon-outline">{% lucide "arrow-right" %}</button>
</div>
{% endset %}
{{ code_preview("button-group-select", code_select | prettyHtml) }}
<h3 id="example-popover"><a href="#example-popover">Popover</a></h3>
{% set code_popover %}
<div role="group" class="button-group">
<button type="button" class="btn-outline">
{% lucide "bot" %}
Copilot
</button>
{% call popover(
trigger=trigger,
trigger_attrs={"class": "btn-icon-outline"},
popover_attrs={"class": "w-72 p-0", "data-align": "end"}
) %}
<header class="px-4 py-3 text-sm font-medium border-b">Agent Tasks</header>
<section class="p-4 text-sm space-y-2">
<textarea class="textarea w-full mb-4" placeholder="Describe your taks in natural language"></textarea>
<p class="font-medium">Start a new task with Copilot</p>
<p class="text-muted-foreground">
Describe your task in natural language. Copilot will work in the
background and open a pull request for your review.
</p>
</section>
{% endcall %}
</div>
{% endset %}
{{ code_preview("button-group-popover", code_popover | prettyHtml) }}
+102
View File
@@ -0,0 +1,102 @@
---
layout: layouts/page.njk
title: Carousel
description: A carousel with motion and swipe.
toc:
- label: Usage
id: usage
---
<style>
.slider {
width: 300px;
text-align: center;
overflow: hidden;
}
.slides {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
/*
scroll-snap-points-x: repeat(300px);
scroll-snap-type: mandatory;
*/
}
.slides::-webkit-scrollbar {
display: none;
}
.slides {
scrollbar-width: none; /* Firefox */
}
.slides > div {
scroll-snap-align: start;
flex-shrink: 0;
width: 300px;
height: 300px;
margin-right: 50px;
border-radius: 10px;
background: #eee;
transform-origin: center center;
transform: scale(1);
transition: transform 0.5s;
position: relative;
display: flex;
justify-content: center;
align-items: center;
font-size: 100px;
}
</style>
<div class="slider">
<a href="#slide-1">1</a>
<a href="#slide-2">2</a>
<a href="#slide-3">3</a>
<a href="#slide-4">4</a>
<a href="#slide-5">5</a>
<a href="#slide-6">6</a>
<a href="#slide-7">7</a>
<a href="#slide-8">8</a>
<a href="#slide-9">9</a>
<div class="slides">
<div id="slide-1">
1
</div>
<div id="slide-2">
2
</div>
<div id="slide-3">
3
</div>
<div id="slide-4">
4
</div>
<div id="slide-5">
5
</div>
<div id="slide-6">
6
</div>
<div id="slide-7">
7
</div>
<div id="slide-8">
8
</div>
<div id="slide-9">
9
</div>
</div>
</div>
+38 -1
View File
@@ -15,6 +15,10 @@ toc:
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: JavaScript events
id: usage-html-js-4
- label: JavaScript methods
id: usage-html-js-5
- label: Jinja and Nunjucks
id: usage-macro
- label: Examples
@@ -56,7 +60,8 @@ toc:
<p>You can either <a href="/installation/#install-cdn-all">include the JavaScript file for all the components</a>, or just the one for this component by adding this to the <code>&lt;head&gt;</code> of your page:</p>
</section>
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/select.min.js" defer></script>{% endset %}
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/basecoat.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/select.min.js" defer></script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
@@ -130,6 +135,38 @@ toc:
</dl>
</section>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript events</a></h4>
<section class="prose">
<dl>
<dt><code>basecoat:initialized</code></dt>
<dd>Once the component is fully initialized, it dispatches a custom (non-bubbling) <code>basecoat:initialized</code> event on itself.</dd>
<dt><code>basecoat:popover</code></dt>
<dd>When the popover opens, the component dispatches a custom (non-bubbling) <code>basecoat:popover</code> event on <code>document</code>. Other popover components (Combobox, Dropdown Menu, Popover and Select) listen for this to close any open popovers.</dd>
<dt><code>change</code></dt>
<dd>When the selected value changes, the component dispatches a custom (bubbling) <code>change</code> event on itself, with the selected value in <code>event.detail</code> (e.g. <code>{detail: {value: "something"}}</code>).</dd>
</dl>
</section>
<h4 id="usage-html-js-5"><a href="#usage-html-js-4">JavaScript methods</a></h4>
<section class="prose">
<dl>
<dt><code>selectByValue</code></dt>
<dd>
<p>You can call this method on the component after it is initialized to select an option by value (i.e. the option with the matching <code>data-value</code> attribute):</p>
{% set code_script %}<script>
const comboboxComponent = document.querySelector('#my-combobox');
comboboxComponent.addEventListener('basecoat:initialized', () => {
comboboxComponent.selectByValue('apple');
});
</script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
</dd>
</dl>
</section>
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
<div class="prose">
+261
View File
@@ -0,0 +1,261 @@
---
layout: layouts/page.njk
title: Command
description: Fast, composable command menu for quick navigation and actions.
toc:
- label: Usage
id: usage
children:
- label: HTML + JavaScript
id: usage-html-js
children:
- label: "Step 1: Include the JavaScript files"
id: usage-html-js-1
- label: "Step 2: Add your command HTML"
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: JavaScript events
id: usage-html-js-4
- label: Jinja and Nunjucks
id: usage-macro
- label: Examples
id: examples
children:
- label: Dialog
id: example-dialog
---
{% from "macros/code_preview.njk" import code_preview %}
{% from "macros/code_block.njk" import code_block %}
{% from "command.njk" import command, command_dialog %}
{% macro command_items(suggestions_id="suggestions", settings_id="settings") %}
<div role="group" aria-labelledby="{{ suggestions_id }}">
<span role="heading" id="{{ suggestions_id }}">Suggestions</span>
<div role="menuitem" data-value="calendar" onclick="console.log('Open calendar')">
{% lucide "calendar" %}
<span>Calendar</span>
</div>
<div role="menuitem" data-value="search-emoji" onclick="console.log('Search emoji')">
{% lucide "smile" %}
<span>Search Emoji</span>
</div>
<div role="menuitem" data-value="calculator" aria-disabled="true">
{% lucide "calculator" %}
<span>Calculator</span>
</div>
</div>
<hr role="separator" />
<div role="group" aria-labelledby="{{ settings_id }}">
<span role="heading" id="{{ settings_id }}">Settings</span>
<div role="menuitem" data-value="profile" onclick="console.log('Open profile')">
{% lucide "user" %}
<span>Profile</span>
<kbd class="ml-auto text-muted-foreground bg-transparent tracking-widest">⌘P</kbd>
</div>
<div role="menuitem" data-value="billing" onclick="console.log('Open billing')">
{% lucide "credit-card" %}
<span>Billing</span>
<kbd class="ml-auto text-muted-foreground bg-transparent tracking-widest">⌘B</kbd>
</div>
<div role="menuitem" data-value="settings" onclick="console.log('Open settings')">
{% lucide "settings" %}
<span>Settings</span>
<kbd class="ml-auto text-muted-foreground bg-transparent tracking-widest">⌘S</kbd>
</div>
</div>
{% endmacro %}
{% set code_standalone %}
{% call command(
id="demo-command-standalone",
main_attrs={ "class": "rounded-lg border shadow-md" }
) %}
{{ command_items("suggestions", "settings") }}
{% endcall %}
{% endset %}
{{ code_preview("command-standalone", code_standalone | prettyHtml, class=" w-full max-w-[450px]") }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<h3 id="usage-html-js"><a href="#usage-html-js">HTML + JavaScript</a></h3>
<h4 id="usage-html-js-1"><a href="#usage-html-js-1">Step 1: Include the JavaScript files</a></h4>
<section class="prose">
<p>You can either <a href="/installation/#install-cdn-all">include the JavaScript file for all the components</a>, or just the one for this component by adding this to the <code>&lt;head&gt;</code> of your page:</p>
</section>
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/basecoat.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/command.min.js" defer></script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
<a class="badge-outline" href="/installation/#install-js">
Components with JavaScript
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="/installation/#install-cli">
Use the CLI
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/js/command.js" target="_blank">
command.js
{% lucide "arrow-right" %}
</a>
</div>
<h4 id="usage-html-js-2"><a href="#usage-html-js-2">Step 2: Add your command HTML</a></h4>
<section class="prose">
<p>You can use the command menu standalone:</p>
</section>
{{ code_block(code_standalone | prettyHtml, "html") }}
<section class="prose">
<p>Wrap the command menu in a <code>&lt;dialog class="command-dialog"&gt;</code> to display in a dialog:</p>
</section>
{% set code_dialog %}
<dialog class="command-dialog" aria-label="Command menu">
<div class="command">
<!-- Regular command content: header, menu, etc. -->
</div>
</dialog>
{% endset %}
{{ code_block(code_dialog | prettyHtml, "html") }}
<h4 id="usage-html-js-3"><a href="#usage-html-js-3">HTML structure</a></h4>
<section class="prose">
<dl>
<dt><code class="highlight language-html">&lt;dialog class="command-dialog"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>For dialog variant, wraps a <code>&lt;div class="command"&gt;</code> to display as a modal. Uses native <code>&lt;dialog&gt;</code> behavior and requires <code>aria-label</code> or <code>aria-labelledby</code> for accessibility.
<dl>
<dt><code class="highlight language-html">&lt;div class="command"&gt;</code></dt>
<dd>Wraps around the entire command component. Can be used standalone or inside a dialog.
<dl>
<dt><code class="highlight language-html">&lt;header&gt;</code></dt>
<dd>Contains the search input to filter command options.
<dl>
<dt><code class="highlight language-html">&lt;input type="text"&gt;</code></dt>
<dd>The search input. Should have:
<ul>
<li><code>role="combobox"</code>: indicates the input controls a menu with filtering</li>
<li><code>aria-expanded="true"</code>: the menu is always expanded</li>
<li><code>aria-controls="{ MENU_ID }"</code>: points to the menu's id</li>
</ul>
</dd>
</dl>
</dd>
<dt><code class="highlight language-html">&lt;div role="menu"&gt;</code></dt>
<dd>The menu containing the command items. Should have <code>id="{ MENU_ID }"</code> refered to by the input's <code>aria-controls</code> attribute.
<dl>
<dt><code class="highlight language-html">&lt;div role="menuitem"&gt;</code></dt>
<dd>Command item that triggers an action when clicked. In a command-dialog, clicking a menuitem automatically closes the dialog unless the <code>data-keep-command-open</code> attribute is present. Optional attributes:
<ul>
<li><code>data-label</code>: custom label for filtering (if different from text content)</li>
<li><code>data-keywords</code>: additional keywords for enhanced filtering</li>
<li><code>aria-disabled="true"</code>: marks item as disabled (excluded from keyboard navigation and filtering)</li>
</ul>
</dd>
<dt><code class="highlight language-html">&lt;hr role="separator"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>Separator between groups/items. Hidden when filtering is active.</dd>
<dt><code class="highlight language-html">&lt;div role="group"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>Group of items, can have <code>aria-labelledby</code> attribute to link to a heading.</dd>
<dt><code class="highlight language-html">&lt;span role="heading"&gt;</code></dt>
<dd>Group heading, must have an <code>id</code> attribute if you use the <code>aria-labelledby</code> attribute on the group.</dd>
</dl>
</dd>
</dl>
</dd>
</dl>
</dd>
</dl>
</section>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript events</a></h4>
<section class="prose">
<dl>
<dt><code>basecoat:initialized</code></dt>
<dd>Once the component is fully initialized, it dispatches a custom (non-bubbling) <code>basecoat:initialized</code> event on itself.</dd>
</dl>
</section>
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
<div class="prose">
<p>You can use the <code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs">command()</code> or <code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs">command_dialog()</code> Nunjucks or Jinja macros for this component.</p>
</div>
<div class="flex flex-wrap gap-2 my-6">
<a class="badge-outline" href="/installation/#install-macros" target="_blank">
Use Nunjucks or Jinja macros
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/jinja/command.html.jinja" target="_blank">
Jinja macro
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/nunjucks/command.njk" target="_blank">
Nunjucks macro
{% lucide "arrow-right" %}
</a>
</div>
{% set raw_code %}{% raw %}{% call command() %}
<div role="menuitem" data-value="calendar">
<svg>...</svg>
<span>Calendar</span>
</div>
<div role="menuitem" data-value="settings">
<svg>...</svg>
<span>Settings</span>
</div>
{% endcall %}{% endraw %}{% endset %}
{{ code_block(raw_code, "jinja") }}
<h2 id="examples"><a href="#examples">Examples</a></h2>
<h3 id="example-dialog"><a href="#example-dialog">Dialog</a></h3>
<section class="prose">
<p>You can use some simple JavaScript to open the command menu with a keyboard shortcut (in this case <kbd class="kbd">⌘J</kbd>).</p>
</section>
{% set code_dialog %}
<button
type="button"
class="btn-outline"
onclick="document.getElementById('demo-command-dialog').showModal()"
>
Open command menu
<kbd class="kbd">⌘J</kbd>
</button>
{% call command_dialog(id="demo-command-dialog") %}
{{ command_items("cmd-suggestions", "cmd-settings") }}
{% endcall %}
<script>
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'j') {
e.preventDefault();
const dialog = document.getElementById('demo-command-dialog');
if (dialog.open) {
dialog.close();
} else {
dialog.showModal();
dialog.querySelector('header input')?.focus();
}
}
});
</script>
{% endset %}
{{ code_preview("command-dialog", code_dialog | prettyHtml) }}
+4 -4
View File
@@ -69,8 +69,8 @@ toc:
<li><code>onclick</code>: closes the dialog when the backdrop is clicked.</li>
</ul>
<dl>
<dt><code class="highlight language-html">&lt;article&gt;</code></dt>
<dd>Wraps the content of the dialog. It includes an <code>onclick</code> attribute to prevent from closing the dialog if we have an <code>onclick</code> event on <code>&lt;dialog&gt;</code>.
<dt><code class="highlight language-html">&lt;div&gt;</code></dt>
<dd>Wraps the content of the dialog.
<dl>
<dt><code class="highlight language-html">&lt;header&gt;</code></dt>
<dd>Contains the header for the dialog:
@@ -85,8 +85,8 @@ toc:
<dd>For the content.</dd>
<dt><code class="highlight language-html">&lt;footer&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>Usually contains actions.</dd>
<dt><code class="highlight language-html">&lt;form method="dialog"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>A form that contains a single button to close the dialog.</dd>
<dt><code class="highlight language-html">&lt;button type="button" onclick="this.closest('dialog').close()"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>A button to close the dialog. Alternatively, you can wrap a button in a <code>&lt;form method="dialog"&gt;</code> element.</dd>
</dl>
</dd>
</dl>
+48 -21
View File
@@ -15,6 +15,8 @@ toc:
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: JavaScript events
id: usage-html-js-4
- label: Jinja and Nunjucks
id: usage-macro
- label: Examples
@@ -86,7 +88,8 @@ toc:
<p>You can either <a href="/installation/#install-cdn-all">include the JavaScript file for all the components</a>, or just the one for this component by adding this to the <code>&lt;head&gt;</code> of your page:</p>
</section>
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/dropdown-menu.min.js" defer></script>{% endset %}
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/basecoat.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/dropdown-menu.min.js" defer></script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
@@ -155,6 +158,17 @@ toc:
</dl>
</section>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript events</a></h4>
<section class="prose">
<dl>
<dt><code>basecoat:initialized</code></dt>
<dd>Once the component is fully initialized, it dispatches a custom (non-bubbling) <code>basecoat:initialized</code> event on itself.</dd>
<dt><code>basecoat:popover</code></dt>
<dd>When the popover opens, the component dispatches a custom (non-bubbling) <code>basecoat:popover</code> event on <code>document</code>. Other popover components (Combobox, Dropdown Menu, Popover and Select) listen for this to close any open popovers.</dd>
</dl>
</section>
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
<div class="prose">
@@ -254,9 +268,6 @@ toc:
role="menuitemcheckbox"
aria-checked="true"
class="group"
x-data="{ checked: true }"
@click="checked = !checked"
:aria-checked="checked"
>
{% lucide "check", {
"class": "invisible group-aria-checked:visible",
@@ -270,10 +281,7 @@ toc:
role="menuitemcheckbox"
aria-checked="false"
class="group"
disabled
x-data="{ checked: false }"
@click="checked = !checked"
:aria-checked="checked"
aria-disabled="true"
>
{% lucide "check", {
"class": "invisible group-aria-checked:visible",
@@ -287,9 +295,6 @@ toc:
role="menuitemcheckbox"
aria-checked="false"
class="group"
x-data="{ checked: false }"
@click="checked = !checked"
:aria-checked="checked"
>
{% lucide "check", {
"class": "invisible group-aria-checked:visible",
@@ -306,7 +311,20 @@ toc:
Logout
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</div>
{% endcall %}{% endset %}
{% endcall %}
<script>
(() => {
const dropdownMenu = document.querySelector('#dropdown-menu-checkboxes');
const checkboxes = dropdownMenu.querySelectorAll('div[role="menuitemcheckbox"]');
checkboxes.forEach(checkbox => {
checkbox.addEventListener('click', () => {
const isChecked = checkbox.getAttribute('aria-checked') === 'true';
checkbox.setAttribute('aria-checked', !isChecked);
});
});
})();
</script>
{% endset %}
{{ code_preview("dropdown-menu-checkboxes", code | prettyHtml) }}
<h3 id="example-radio-group"><a href="#example-radio-group">Radio Group</a></h3>
@@ -317,15 +335,13 @@ toc:
trigger_attrs={"class": "btn-outline"},
popover_attrs={"class": "min-w-56"}
) %}
<div role="group" aria-labelledby="position-options" x-data="{ checked: 'bottom' }">
<div role="group" aria-labelledby="position-options">
<span id="position-options" role="heading">Panel Position</span>
<hr role="separator">
<div
role="menuitemradio"
aria-checked="false"
class="group"
@click="checked = 'top'"
:aria-checked="checked == 'top'"
>
<div class="size-4 flex items-center justify-center">
<div
@@ -339,10 +355,8 @@ toc:
</div>
<div
role="menuitemradio"
aria-checked="false"
aria-checked="true"
class="group"
@click="checked = 'bottom'"
:aria-checked="checked == 'bottom'"
>
<div class="size-4 flex items-center justify-center">
<div
@@ -358,8 +372,6 @@ toc:
role="menuitemradio"
aria-checked="false"
class="group"
@click="checked = 'right'"
:aria-checked="checked == 'right'"
>
<div class="size-4 flex items-center justify-center">
<div
@@ -372,5 +384,20 @@ toc:
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘S</span>
</div>
</div>
{% endcall %}{% endset %}
{% endcall %}
<script>
(() => {
const dropdownMenu = document.querySelector('#dropdown-menu-radio-group');
const radioButtons = dropdownMenu.querySelectorAll('div[role="menuitemradio"]');
radioButtons.forEach(radioButton => {
radioButton.addEventListener('click', () => {
radioButtons.forEach(radioButton => {
radioButton.setAttribute('aria-checked', 'false');
});
radioButton.setAttribute('aria-checked', 'true');
});
});
})();
</script>
{% endset %}
{{ code_preview("dropdown-menu", code | prettyHtml) }}
+157
View File
@@ -0,0 +1,157 @@
---
layout: layouts/page.njk
title: Empty
description: Display empty states with icons, titles, descriptions, and actions.
toc:
- label: Usage
id: usage
- label: Examples
id: examples
children:
- label: Outline
id: example-outline
- label: Avatar
id: example-avatar
- label: Avatar Group
id: example-avatar-group
- label: Input Group
id: example-input-group
---
{% from "macros/code_preview.njk" import code_preview %}
{% from "macros/code_block.njk" import code_block %}
<div class="alert mb-6">
{% lucide "circle-alert" %}
<h2>There is no dedicated Empty component in Basecoat.</h2>
</div>
{% set code_default %}
<div class="flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12 text-neutral-800 dark:text-neutral-300">
<header class="flex max-w-sm flex-col items-center gap-2 text-center">
<div class="mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6">
{% lucide "folder-code" %}
</div>
<h3 class="text-lg font-medium tracking-tight">No Projects Yet</h3>
<p class="text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4">
You haven't created any projects yet. Get started by creating your first project.
</p>
</header>
<section class="flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance">
<div class="flex gap-2">
<button class="btn">Create Project</button>
<button class="btn-outline">Import Project</button>
</div>
</section>
<a href="#" class="inline-flex items-center justify-center whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive underline-offset-4 hover:underline h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 text-muted-foreground">
Learn More
{% lucide "arrow-up-right" %}
</a>
</div>
{% endset %}
{{ code_preview("empty-default", code_default) }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<section class="prose">
<p>Empty states are pure HTML composition using Tailwind utility classes. Use flex layout utilities to center content, and compose with semantic elements like <code>&lt;header&gt;</code>, <code>&lt;h3&gt;</code>, <code>&lt;p&gt;</code>, and <code>&lt;button&gt;</code>.</p>
<p>Browse the examples below and copy the pattern that fits your needs.</p>
</section>
<h2 id="examples"><a href="#examples">Examples</a></h2>
<h3 id="example-outline"><a href="#example-outline">Outline</a></h3>
{% set code_outline %}
<div class="flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12 text-neutral-800 dark:text-neutral-300 border">
<header class="flex max-w-sm flex-col items-center gap-3 text-center">
<div class="mb-2 bg-muted text-foreground flex size-12 shrink-0 items-center justify-center rounded-lg [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6">
{% lucide "cloud" %}
</div>
<h3 class="text-lg font-semibold tracking-tight">Cloud Storage Empty</h3>
<p class="text-muted-foreground text-sm/relaxed">
Upload files to your cloud storage to access them anywhere.
</p>
</header>
<section class="flex w-full max-w-sm min-w-0 flex-col items-center gap-3">
<button class="btn-sm-outline">Upload Files</button>
</section>
</div>
{% endset %}
{{ code_preview("empty-outline", code_outline) }}
<h3 id="example-avatar"><a href="#example-avatar">Avatar</a></h3>
{% set code_avatar %}
<div class="flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12 text-neutral-800 dark:text-neutral-300">
<header class="flex max-w-sm flex-col items-center gap-3 text-center">
<div class="mb-2 flex items-center justify-center">
<img alt="@shadcn" src="https://github.com/shadcn.png" class="grayscale size-12 shrink-0 object-cover rounded-full">
</div>
<h3 class="text-lg font-semibold tracking-tight">User Offline</h3>
<p class="text-muted-foreground text-sm/relaxed">
This user is currently offline. You can leave a message to notify them or try again later.
</p>
</header>
<section class="flex w-full max-w-sm min-w-0 flex-col items-center gap-3">
<button class="btn-sm">Leave Message</button>
</section>
</div>
{% endset %}
{{ code_preview("empty-avatar", code_avatar) }}
<h3 id="example-avatar-group"><a href="#example-avatar-group">Avatar Group</a></h3>
{% set code_avatar_group %}
<div class="flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12 text-neutral-800 dark:text-neutral-300">
<header class="flex max-w-sm flex-col items-center gap-3 text-center">
<div class="mb-2 flex items-center justify-center">
<div class="flex -space-x-2 [&amp;_img]:ring-background [&amp;_img]:ring-2 [&amp;_img]:grayscale [&amp;_img]:size-12 [&amp;_img]:shrink-0 [&amp;_img]:object-cover [&amp;_img]:rounded-full">
<img alt="@hunvreus" src="https://github.com/hunvreus.png">
<img alt="@shadcn" src="https://github.com/shadcn.png">
<img alt="@adamwathan" src="https://github.com/adamwathan.png">
</div>
</div>
<h3 class="text-lg font-semibold tracking-tight">No Team Members</h3>
<p class="text-muted-foreground text-sm/relaxed">
Invite your team to collaborate on this project.
</p>
</header>
<section class="flex w-full max-w-sm min-w-0 flex-col items-center gap-3">
<button class="btn-sm inline-flex items-center gap-2">
{% lucide "plus" %}
Invite Members
</button>
</section>
</div>
{% endset %}
{{ code_preview("empty-avatar-group", code_avatar_group) }}
<h3 id="example-input-group"><a href="#example-input-group">Input Group</a></h3>
{% set code_input_group %}
<div class="flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg p-6 text-center text-balance md:p-12 text-neutral-800 dark:text-neutral-300">
<header class="flex max-w-sm flex-col items-center gap-3 text-center">
<h3 class="text-lg font-semibold tracking-tight">404 - Not Found</h3>
<p class="text-muted-foreground text-sm/relaxed">
The page you're looking for doesn't exist. Try searching for what you need below.
</p>
</header>
<section class="flex w-full max-w-md min-w-0 flex-col items-center gap-3">
<div class="relative w-3/4">
<input type="text" class="input pl-8.5 pr-10" placeholder="Try searching for pages...">
{% lucide "search" , { "class": "absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground pointer-events-none shrink-0 size-4" } %}
<kbd class="kbd absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">/</kbd>
</div>
<p class="text-muted-foreground text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary">
Need help? <a href="#">Contact support</a>
</p>
</section>
</div>
{% endset %}
{{ code_preview("empty-input-group", code_input_group) }}
+459
View File
@@ -0,0 +1,459 @@
---
layout: layouts/page.njk
title: Field
description: Combine labels, controls, and help text to compose accessible form fields.
toc:
- label: Usage
id: usage
- label: Examples
id: examples
children:
- label: Input
id: example-input
- label: Textarea
id: example-textarea
- label: Select
id: example-select
- label: Fieldset
id: example-fieldset
- label: Checkbox
id: example-checkbox
- label: Radio Group
id: example-radio
- label: Switch
id: example-switch
- label: Responsive Layout
id: example-responsive
---
{% from "macros/code_preview.njk" import code_preview %}
{% from "macros/code_block.njk" import code_block %}
{% set code_demo %}
<form class="w-full max-w-md space-y-6">
<fieldset class="fieldset">
<legend>Payment Method</legend>
<p>All transactions are secure and encrypted</p>
<div role="group" class="field">
<label for="card-name">Name on Card</label>
<input id="card-name" type="text" placeholder="Evil Rabbit" required>
</div>
<div role="group" class="field">
<label for="card-number">Card Number</label>
<input id="card-number" type="text" placeholder="1234 5678 9012 3456" aria-describedby="card-number-desc" required>
<p id="card-number-desc">Enter your 16-digit card number</p>
</div>
<div class="grid grid-cols-3 gap-4">
<div role="group" class="field">
<label for="exp-month">Month</label>
<select id="exp-month" class="select w-full">
<option value="">MM</option>
<option value="01">01</option>
<option value="02">02</option>
<option value="03">03</option>
<option value="04">04</option>
<option value="05">05</option>
<option value="06">06</option>
<option value="07">07</option>
<option value="08">08</option>
<option value="09">09</option>
<option value="10">10</option>
<option value="11">11</option>
<option value="12">12</option>
</select>
</div>
<div role="group" class="field">
<label for="exp-year">Year</label>
<select id="exp-year" class="select w-full">
<option value="">YYYY</option>
<option value="2024">2024</option>
<option value="2025">2025</option>
<option value="2026">2026</option>
<option value="2027">2027</option>
<option value="2028">2028</option>
<option value="2029">2029</option>
</select>
</div>
<div role="group" class="field">
<label for="cvv">CVV</label>
<input id="cvv" type="text" placeholder="123" required>
</div>
</div>
</fieldset>
<hr role="separator" class="my-6 border-border">
<fieldset class="fieldset">
<legend>Billing Address</legend>
<p>The billing address associated with your payment method</p>
<div role="group" class="field">
<label for="same-as-shipping" class="gap-3">
<input type="checkbox" id="same-as-shipping" checked>
Same as shipping address
</label>
</div>
</fieldset>
<fieldset class="fieldset">
<div role="group" class="field">
<label for="comments">Comments</label>
<textarea id="comments" placeholder="Add any additional comments" rows="3"></textarea>
</div>
</fieldset>
<div class="flex gap-3">
<button type="submit" class="btn">Submit</button>
<button type="button" class="btn-outline">Cancel</button>
</div>
</form>
{% endset %}
{{ code_preview("field-demo", code_demo) }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<section class="prose">
<p>Field uses semantic HTML with minimal classes. Two main patterns can be used independently or composed together:</p>
<ul>
<li><code>&lt;div role="group" class="field"&gt;</code> for individual field containers (label, input, helper text, errors)</li>
<li><code>&lt;fieldset class="fieldset"&gt;</code> for grouping multiple related fields together</li>
</ul>
<p>Elements inside these containers are styled automatically based on their purpose. Use <code>aria-describedby</code> to connect helper text and errors to inputs for accessibility.</p>
<p><strong>Fields</strong> have the following HTML structure:</p>
<dl>
<dt><code class="highlight language-html">&lt;div role="group" class="field"&gt;</code></dt>
<dd>Field container. Add <code>data-orientation="horizontal"</code> to align controls horizontally. For responsive layouts, use standard Tailwind responsive classes.
<dl>
<dt><code class="highlight language-html">&lt;label&gt;</code></dt>
<dd>Label for the input.</dd>
<dt><code class="highlight language-html">&lt;input&gt;</code>, <code>&lt;select&gt;</code> or <code>&lt;textarea&gt;</code></dt>
<dd>Form input.</dd>
<dt><code class="highlight language-html">&lt;p&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>Helper text. Use <code>aria-describedby</code> on the input to reference this element's <code>id</code>.</dd>
<dt><code class="highlight language-html">&lt;p role="alert"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>Error message. Use <code>aria-describedby</code> on the input and <code>aria-invalid="true"</code> to reference this element's <code>id</code>.</dd>
<dt><code class="highlight language-html">&lt;section&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>Wraps label and description when the label sits beside the input (e.g. <code>data-orientation="horizontal"</code>). Not required if you have no description.</dd>
</dl>
</dd>
</dl>
<p><strong>Fieldsets</strong> have the following HTML structure:</p>
<dl>
<dt><code class="highlight language-html">&lt;fieldset class="fieldset"&gt;</code></dt>
<dd>Container for grouping related fields.
<dl>
<dt><code class="highlight language-html">&lt;legend&gt;</code></dt>
<dd>Heading for the fieldset.</dd>
<dt><code class="highlight language-html">&lt;p&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>Description text. When placed directly after <code>&lt;legend&gt;</code>, automatically styled as muted text.</dd>
<dt><code class="highlight language-html">&lt;div role="group" class="field"&gt;</code> <span class="badge-secondary">Optional</span></dt>
<dd>Individual field containers (see structure above). You can include multiple <code>.field</code> elements, or place form controls directly in the fieldset.</dd>
</dl>
</dd>
</dl>
</section>
<h2 id="examples"><a href="#examples">Examples</a></h2>
<h3 id="example-input"><a href="#example-input">Input</a></h3>
{% set code_input %}
<div class="grid gap-6">
<div role="group" class="field">
<label for="username">Username</label>
<input id="username" type="text" placeholder="evilrabbit" aria-describedby="username-desc">
<p id="username-desc">Choose a unique username for your account.</p>
</div>
<div role="group" class="field">
<label for="password">Password</label>
<p id="password-desc">Must be at least 8 characters long.</p>
<input id="password" type="password" placeholder="••••••••" aria-describedby="password-desc">
</div>
</div>
{% endset %}
{{ code_preview("field-input", code_input, class="w-full max-w-md") }}
<h3 id="example-textarea"><a href="#example-textarea">Textarea</a></h3>
{% set code_textarea %}
<div role="group" class="field">
<label for="bio">Feedback</label>
<textarea id="bio" rows="4" placeholder="Your feedback helps us improve..." aria-describedby="bio-desc"></textarea>
<p id="bio-desc">Share your thoughts about our service.</p>
</div>
{% endset %}
{{ code_preview("field-textarea", code_textarea, class="w-full max-w-md") }}
<h3 id="example-select"><a href="#example-select">Select</a></h3>
{% set code_select %}
<div role="group" class="field">
<label for="country">Department</label>
<select id="country" class="select w-full" aria-describedby="country-desc">
<option value="">Choose department</option>
<option value="engineering">Engineering</option>
<option value="design">Design</option>
<option value="marketing">Marketing</option>
<option value="sales">Sales</option>
<option value="support">Customer Support</option>
<option value="hr">Human Resources</option>
<option value="finance">Finance</option>
<option value="operations">Operations</option>
</select>
<p id="country-desc">Select your department or area of work.</p>
</div>
{% endset %}
{{ code_preview("field-select", code_select, class="w-full max-w-md") }}
<h3 id="example-slider"><a href="#example-slider">Slider</a></h3>
{% set code %}
<div role="group" class="field">
<label for="price-range">Price</label>
<p id="price-range-desc">Set your budget: $<span id="price-range-value">150</span></p>
<input id="price-range" type="range" min="0" max="500" value="150">
</div>
<script>
(() => {
const sliders = document.querySelectorAll('input[type="range"]');
if (!sliders) return;
const updateSlider = (el) => {
const min = parseFloat(el.min || 0);
const max = parseFloat(el.max || 100);
const value = parseFloat(el.value);
const percent = (max === min) ? 0 : ((value - min) / (max - min)) * 100;
el.style.setProperty('--slider-value', `${percent}%`);
};
sliders.forEach(slider => {
updateSlider(slider);
slider.addEventListener('input', (event) => updateSlider(event.target));
});
const priceSlider = document.getElementById('price-range');
const priceValue = document.getElementById('price-range-value');
priceSlider.addEventListener('input', (event) => priceValue.innerHTML = event.target.value);
})();
</script>
{% endset %}
{{ code_preview("slider", code, "w-full max-w-sm") }}
<h3 id="example-fieldset"><a href="#example-fieldset">Fieldset</a></h3>
<section class="prose">
<p>Group related fields with <code>&lt;fieldset class="fieldset"&gt;</code> and <code>&lt;legend&gt;</code>.</p>
</section>
{% set code_fieldset %}
<form class="w-full max-w-md">
<fieldset class="fieldset">
<legend>Profile</legend>
<p>This information will be displayed on your profile</p>
<div role="group" class="field">
<label for="full-name">Full name</label>
<input id="full-name" type="text" placeholder="Evil Rabbit" aria-describedby="name-desc">
<p id="name-desc">Your first and last name</p>
</div>
<div role="group" class="field">
<label for="display-name">Username</label>
<input id="display-name" type="text" placeholder="@evilrabbit" aria-invalid="true" aria-describedby="username-error">
<p id="username-error" role="alert">Username is already taken</p>
</div>
<div class="flex items-center gap-3">
<input type="checkbox" id="newsletter">
<label for="newsletter" class="font-normal">Subscribe to the newsletter</label>
</div>
</fieldset>
<button type="submit" class="btn mt-4">Save changes</button>
</form>
{% endset %}
{{ code_preview("field-fieldset", code_fieldset) }}
<h3 id="example-checkbox"><a href="#example-checkbox">Checkbox</a></h3>
{% set code_checkbox %}
<fieldset class="fieldset">
<legend>Show these items on the desktop</legend>
<p>Select the items you want to show on the desktop.</p>
<div class="flex flex-col gap-3">
<div class="field">
<label class="gap-3">
<input type="checkbox">
Hard disks
</label>
</div>
<div class="field">
<label class="gap-3">
<input type="checkbox">
External disks
</label>
</div>
<div class="field">
<label class="gap-3">
<input type="checkbox">
CDs, DVDs and iPods
</label>
</div>
<div class="field">
<label class="gap-3">
<input type="checkbox">
Connected servers
</label>
</div>
</div>
<hr role="separator">
<div class="field" data-orientation="horizontal">
{# <div class="flex w-full items-center gap-3"> #}
<input id="sync-desktop-documents" type="checkbox" checked class="input self-start">
<section>
<label for="sync-desktop-documents">Sync Desktop & Documents folders</label>
<p>Your Desktop & Documents folders are being synced with iCloud Drive. You can access them from other devices.</p>
</section>
{# </div> #}
</div>
</fieldset>
{% endset %}
{{ code_preview("field-checkbox", code_checkbox, class="w-full max-w-md") }}
<h3 id="example-radio"><a href="#example-radio">Radio Group</a></h3>
{% set code_radio %}
<fieldset class="fieldset">
<legend>Subscription plan</legend>
<p>Yearly and lifetime plans offer significant savings.</p>
<div role="radiogroup" class="grid gap-3">
<div class="field">
<label class="gap-3">
<input type="radio" name="subscription-plan" checked>
Monthly ($9.99/month)
</label>
</div>
<div class="field">
<label class="gap-3">
<input type="radio" name="subscription-plan">
Yearly ($99.99/year)
</label>
</div>
<div class="field">
<label class="gap-3">
<input type="radio" name="subscription-plan">
Lifetime ($299.99)
</label>
</div>
</div>
</fieldset>
{% endset %}
{{ code_preview("field-radio", code_radio, class="w-full max-w-md") }}
<h3 id="example-switch"><a href="#example-switch">Switch</a></h3>
<section class="prose">
<p>Set <code>data-orientation="horizontal"</code> on <code>.field</code> to align labels and controls side-by-side.</p>
</section>
{% set code_switch %}
<div role="group" class="field" data-orientation="horizontal">
<section>
<label for="multi-factor-authentication">Multi-factor authentication</label>
<p>Enable multi-factor authentication. If you do not have a two-factor device, you can use a one-time code sent to your email.</p>
</section>
<input id="multi-factor-authentication" type="checkbox" role="switch">
</div>
{% endset %}
{{ code_preview("field-switch", code_switch, class="w-full max-w-md") }}
<h3 id="example-choice-card"><a href="#example-choice-card">Choice Card</a></h3>
{% set code_choice_card %}
<fieldset class="fieldset">
<legend class="text-sm">Compute Environment</legend>
<p>Select the compute environment for your cluster.</p>
<div role="group" class="field">
<label class="flex gap-3 border rounded-md p-4 has-[:checked]:border-primary has-[:checked]:bg-primary/5 dark:has-[:checked]:bg-primary/10">
<section>
<h3>Kubernetes</h3>
<p>Run GPU workloads on a K8s configured cluster.</p>
</section>
<input id="choice-card" type="radio" name="choice-card" class="self-start" checked>
</label>
</div>
<div role="group" class="field">
<label class="flex gap-3 border rounded-md p-4 has-[:checked]:border-primary has-[:checked]:bg-primary/5 dark:has-[:checked]:bg-primary/10">
<section>
<h3>Virtual Machine</h3>
<p>Access a VM configured cluster to run GPU workloads.</p>
</section>
<input id="choice-card" type="radio" name="choice-card" class="self-start">
</label>
</div>
</fieldset>
{% endset %}
{{ code_preview("field-choice-card", code_choice_card, class="w-full max-w-md") }}
<h3 id="example-responsive"><a href="#example-responsive">Responsive Layout</a></h3>
<section class="prose">
<p>For responsive layouts, use standard Tailwind responsive classes directly on <code>.field</code>. This example stacks vertically on mobile and switches to horizontal at the <code>md</code> breakpoint.</p>
</section>
{% set code_responsive %}
<fieldset class="fieldset">
<legend>Profile</legend>
<p>Keep your profile details up to date.</p>
<hr role="separator">
<div class="flex flex-col gap-7">
<div role="group" class="field flex-col md:flex-row md:items-center">
<section class="md:flex-auto">
<label for="profile-name">Name</label>
<p id="profile-name-desc">Provide your full name for identification.</p>
</section>
<input id="profile-name" type="text" placeholder="Evil Rabbit" aria-describedby="profile-name-desc" class="md:w-auto md:min-w-80">
</div>
<hr role="separator">
<div role="group" class="field flex-col md:flex-row md:items-start">
<section class="md:flex-auto">
<label for="profile-bio">Message</label>
<p id="profile-bio-desc">You can write your message here. Keep it short, preferably under 100 characters.</p>
</section>
<textarea id="profile-bio" rows="3" placeholder="Hello, world!" aria-describedby="profile-bio-desc" class="md:w-auto md:min-w-80"></textarea>
</div>
<hr role="separator">
<div role="group" class="field flex-col md:flex-row md:items-center">
<button type="submit" class="btn md:w-auto">Submit</button>
<button type="button" class="btn-outline md:w-auto">Cancel</button>
</div>
</div>
</fieldset>
{% endset %}
{{ code_preview("field-responsive", code_responsive, class="w-full max-w-3xl") }}
+3 -3
View File
@@ -1,7 +1,7 @@
---
layout: layouts/page.njk
title: Input
description: Displays a form input field or a component that looks like an input field.
title: Form
description: Building forms with Basecoat components.
toc:
- label: Usage
id: usage
@@ -28,7 +28,7 @@ toc:
</div>
<div class="grid gap-2">
<label for="demo-form-text">Bio</label>
<label for="demo-form-textarea">Bio</label>
<textarea id="demo-form-textarea" placeholder="I like to..." rows="3"></textarea>
<p class="text-muted-foreground text-sm">You can @mention other users and organizations.</p>
</div>
+372
View File
@@ -0,0 +1,372 @@
---
layout: layouts/page.njk
title: Input Group
description: Display additional information or actions to an input or textarea.
toc:
- label: Usage
id: usage
- label: Examples
id: examples
children:
- label: Icon
id: example-icon
- label: Text
id: example-text
- label: Button
id: example-button
- label: Tooltip
id: example-tooltip
- label: Textarea
id: example-textarea
- label: Spinner
id: example-spinner
- label: Dropdown
id: example-dropdown
- label: Group
id: example-group
---
{% from "macros/code_preview.njk" import code_preview %}
{% from "macros/code_block.njk" import code_block %}
{% from "dropdown-menu.njk" import dropdown_menu %}
{% from "popover.njk" import popover %}
<div class="alert mb-6">
{% lucide "circle-alert" %}
<h2>There is no dedicated Input Group component in Basecoat.</h2>
</div>
{% set code_default %}
<div class="grid gap-6">
<div class="relative">
<input type="text" class="input pl-9 pr-20" placeholder="Search...">
<div class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground [&>svg]:size-4">
{% lucide "search" %}
</div>
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground text-sm">12 results</div>
</div>
<div class="relative">
<input type="text" class="input pl-15 pr-9" placeholder="example.com">
<div class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground text-sm">https://</div>
<div class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground [&>svg]:size-4" data-tooltip="This is content in a tooltip.">{% lucide "info" %}</div>
</div>
<div class="relative">
<textarea class="textarea pr-10 min-h-27 pb-12" placeholder="Ask, Search or Chat..."></textarea>
<footer role="group" class="absolute bottom-0 px-3 pb-3 pt-1.5 flex items-center w-full gap-2">
<button type="button" class="btn-icon-outline rounded-full size-6 text-muted-foreground hover:text-accent-foreground">
{% lucide "plus" %}
</button>
{{ dropdown_menu(
id="demo-dropdown-menu",
trigger="Auto",
trigger_attrs={"class": "btn-sm-ghost text-muted-foreground hover:text-accent-foreground h-6 p-2"},
popover_attrs={"data-side": "top", "class": "min-w-32"},
items=[
{ type: "item", label: "Auto" },
{ type: "item", label: "Agent" },
{ type: "item", label: "Manual" }
]
) }}
<div class="text-muted-foreground text-sm ml-auto">52% used</div>
<hr class="w-0 h-4 border-r border-border shrink-0 m-0">
<button type="button" class="btn-icon rounded-full size-6 bg-muted-foreground hover:bg-foreground" disabled>
{% lucide "arrow-up" %}
</button>
</footer>
</div>
<div class="relative">
<input type="text" class="input pr-9" placeholder="@shadcn">
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none bg-primary text-primary-foreground flex size-4 items-center justify-center rounded-full [&>svg]:size-3">{% lucide "check" %}</div>
</div>
</div>
{% endset %}
{{ code_preview("input-group-default", code_default, class="w-full max-w-sm") }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<section class="prose">
<p>Input groups are pure HTML composition using Tailwind utility classes. There is no dedicated component: use <code>relative</code> positioning on a wrapper, add your content with <code>absolute</code> positioning, and adjust input padding to make room.</p>
<p>Browse the examples below and copy the pattern that fits your needs.</p>
</section>
<h2 id="examples"><a href="#examples">Examples</a></h2>
<h3 id="example-icon"><a href="#example-icon">Icon</a></h3>
{% set code_icon %}
<div class="grid gap-6">
<div class="relative">
<input type="text" class="input pl-9" placeholder="Search...">
<div class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground [&>svg]:size-4">
{% lucide "search" %}
</div>
</div>
<div class="relative">
<input type="email" class="input pl-9" placeholder="Enter your email">
<div class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground [&>svg]:size-4">
{% lucide "mail" %}
</div>
</div>
<div class="relative">
<input type="text" class="input px-9" placeholder="Card number">
<div class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground [&>svg]:size-4">
{% lucide "credit-card" %}
</div>
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground [&>svg]:size-4">
{% lucide "check" %}
</div>
</div>
<div class="relative">
<input type="text" class="input pr-16" placeholder="Card number">
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground [&>svg]:size-4 flex items-center gap-2">
{% lucide "star" %}
{% lucide "info" %}
</div>
</div>
</div>
{% endset %}
{{ code_preview("input-group-icon-prefix", code_icon, class="w-full max-w-sm") }}
<h3 id="example-text"><a href="#example-text">Text</a></h3>
{% set code_text %}
<div class="grid gap-6">
<div class="relative">
<input type="text" class="input pl-7 pr-12" placeholder="0.00">
<div class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground text-sm">$</div>
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground text-sm">USD</div>
</div>
<div class="relative">
<input type="text" class="input pl-15 pr-12" placeholder="example.com">
<div class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground text-sm">https://</div>
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground text-sm">.com</div>
</div>
<div class="relative">
<input type="text" class="input pr-30" placeholder="Enter your username">
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground text-sm">@company.com</div>
</div>
<div class="relative">
<textarea class="textarea pr-10 min-h-25 pb-12" placeholder="Enter your message"></textarea>
<footer role="group" class="absolute bottom-0 px-3 pb-3 pt-1.5 flex items-center w-full gap-2">
<div class="text-muted-foreground text-sm">120 characters left</div>
</footer>
</div>
</div>
{% endset %}
{{ code_preview("input-group-text", code_text, class="w-full max-w-sm") }}
<h3 id="example-button"><a href="#example-button">Button</a></h3>
{% set code_button %}
<div class="grid gap-6">
<div class="relative">
<input type="text" readonly class="input pr-9" value="https://x.com/hunvreus">
<button
data-copied="false"
onclick="
this.dataset.copied = 'true';
setTimeout(() => this.dataset.copied = 'false', 2000);
"
class="group absolute right-1.5 top-1/2 -translate-y-1/2 btn-icon-ghost text-muted-foreground hover:text-accent-foreground size-6"
>
{% lucide "copy", { "class": "group-data-[copied=true]:hidden" } %}
{% lucide "check", { "class": "hidden group-data-[copied=true]:block" } %}
</button>
</div>
<div class="relative">
<input type="text" class="input pl-21 pr-9 rounded-full">
<button
aria-pressed="false"
onclick="this.ariaPressed = this.ariaPressed === 'true' ? 'false' : 'true'"
class="absolute right-1.5 top-1/2 -translate-y-1/2 btn-icon-ghost text-muted-foreground hover:text-accent-foreground size-6 rounded-full aria-pressed:bg-transparent aria-pressed:hover:bg-accent dark:aria-pressed:hover:bg-accent/50 aria-pressed:text-blue-600 aria-pressed:[&>svg]:fill-blue-600"
>{% lucide "star" %}</button>
<div class="absolute left-1.5 top-1/2 -translate-y-1/2 flex items-center gap-1 z-10">
{% set trigger %}
{% lucide "info" %}
{% endset %}
{% call popover(
trigger=trigger,
trigger_attrs={"class": "btn-sm-icon-ghost rounded-full size-6"},
popover_attrs={"class": "max-w-72"}
) %}
<h3 class="font-medium mb-1">Your connection is not secure.</h3>
<p>You should not enter any sensitive information on this site.</p>
{% endcall %}
<div class="text-muted-foreground text-sm pointer-events-none">https://</div>
</div>
</div>
<div class="relative">
<input type="text" readonly class="input pr-9" placeholder="Type to search...">
<button type="button" class="absolute right-1.5 top-1/2 -translate-y-1/2 btn-secondary h-6 p-2 rounded">Search</button>
</div>
</div>
{% endset %}
{{ code_preview("input-group-button", code_button, class="w-full max-w-sm") }}
<h3 id="example-tooltip"><a href="#example-tooltip">Tooltip</a></h3>
{% set code_tooltip %}
<div class="grid gap-6">
<div class="relative">
<input type="password" class="input pr-9" placeholder="Enter password">
<div class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground [&>svg]:size-4" data-tooltip="Password must be at least 8 characters long.">{% lucide "info" %}</div>
</div>
<div class="relative">
<input type="email" class="input pr-9" placeholder="Your email address">
<div class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground [&>svg]:size-4" data-tooltip="We'll use this to send your notifications.">{% lucide "circle-help" %}</div>
</div>
<div class="relative">
<input type="email" class="input pl-9" placeholder="Your email address">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground [&>svg]:size-4" data-tooltip="Click for help with API keys." data-side="left">{% lucide "circle-help" %}</div>
</div>
</div>
{% endset %}
{{ code_preview("input-group-tooltip", code_tooltip, class="w-full max-w-sm") }}
<h3 id="example-textarea"><a href="#example-textarea">Textarea</a></h3>
{% set code_textarea %}
<div class="relative">
<textarea class="textarea pt-15 pb-17 min-h-77" placeholder="console.log('Hello, world!')."></textarea>
<header class="absolute top-0 flex items-center w-full gap-2 p-3 border-b">
<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="tabler-icon tabler-icon-brand-javascript size-4 text-muted-foreground"><path d="M20 4l-2 14.5l-6 2l-6 -2l-2 -14.5z"></path><path d="M7.5 8h3v8l-2 -1"></path><path d="M16.5 8h-2.5a.5 .5 0 0 0 -.5 .5v3a.5 .5 0 0 0 .5 .5h1.423a.5 .5 0 0 1 .495 .57l-.418 2.93l-2 .5"></path></svg>
<span class="font-mono text-sm text-muted-foreground mr-auto">script.js</span>
<button class="btn-sm-icon-ghost text-muted-foreground hover:text-accent-foreground size-6">
{% lucide "refresh-ccw" %}
</button>
<button class="btn-sm-icon-ghost text-muted-foreground hover:text-accent-foreground size-6">
{% lucide "copy" %}
</button>
</header>
<footer class="absolute bottom-0 flex items-center w-full gap-2 p-3 border-t">
<span class="text-sm text-muted-foreground mr-auto">Line 1, Column 1</span>
<button type="button" class="btn-sm">
Run
{% lucide "corner-down-left" %}
</button>
</footer>
</div>
{% endset %}
{{ code_preview("input-group-textarea", code_textarea, class="w-full max-w-md") }}
<h3 id="example-spinner"><a href="#example-spinner">Spinner</a></h3>
{% set code_spinner %}
<div class="grid gap-6">
<div class="relative">
<input type="text" class="input pr-9" placeholder="Searching..." disabled>
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground [&>svg]:size-4 opacity-50">
{% lucide "loader-circle", { "class": "animate-spin" } %}
</div>
</div>
<div class="relative">
<input type="text" class="input pl-9" placeholder="Processing..." disabled>
<div class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground [&>svg]:size-4 opacity-50">
{% lucide "loader-circle", { "class": "animate-spin" } %}
</div>
</div>
<div class="relative">
<input type="text" class="input pr-24" placeholder="Saving changes..." disabled>
<div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2 text-sm text-muted-foreground opacity-50">
<div>Saving...</div>
<div class="[&>svg]:size-4">
{% lucide "loader-circle", { "class": "animate-spin" } %}
</div>
</div>
</div>
<div class="relative">
<input type="text" class="input pl-9 pr-26" placeholder="Refreshing data..." disabled>
<div class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground [&>svg]:size-4 opacity-50">
{% lucide "loader", { "class": "animate-spin" } %}
</div>
<div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2 text-sm text-muted-foreground opacity-50">
Please wait...
</div>
</div>
</div>
{% endset %}
{{ code_preview("input-group-spinner", code_spinner, class="w-full max-w-md") }}
<h3 id="example-dropdown"><a href="#example-dropdown">Dropdown</a></h3>
{% set code_dropdown %}
<div class="grid gap-6">
<div class="relative">
<input type="text" class="input pr-9" placeholder="Enter file name">
{% set trigger %}
{% lucide "ellipsis" %}
{% endset %}
{{ dropdown_menu(
main_attrs={"class": "absolute right-1.5 top-1/2 -translate-y-1/2 z-10"},
trigger=trigger,
trigger_attrs={"class": "btn-sm-icon-ghost text-muted-foreground hover:text-accent-foreground size-6"},
popover_attrs={"data-align": "end", "class": "min-w-32"},
items=[
{ type: "item", label: "Settings" },
{ type: "item", label: "Copy path" },
{ type: "item", label: "Open location" }
]
) }}
</div>
<div class="relative">
<input type="text" class="input pr-30" placeholder="Enter search query">
{% set trigger %}
Search in...
{% lucide "chevron-down", { "class": "size-3" } %}
{% endset %}
{{ dropdown_menu(
main_attrs={"class": "absolute right-1.5 top-1/2 -translate-y-1/2"},
trigger=trigger,
trigger_attrs={"class": "btn-sm-ghost text-muted-foreground hover:text-accent-foreground p-2 h-6"},
popover_attrs={"data-align": "end", "class": "min-w-32"},
items=[
{ type: "item", label: "Documentation" },
{ type: "item", label: "Blog Posts" },
{ type: "item", label: "Changelog" }
]
) }}
</div>
</div>
{% endset %}
{{ code_preview("input-group-dropdown", code_dropdown, class="w-full max-w-sm") }}
<h3 id="example-group"><a href="#example-group">Group</a></h3>
{% set code_group %}
<div class="flex w-fit items-stretch">
<label for="url" class="text-sm flex items-center bg-muted border border-r-0 px-4 rounded-l-md shadow-xs">https://</label>
<div class="relative">
<input type="text" class="input pr-9 rounded-none">
<div class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground [&>svg]:size-4">{% lucide "link-2" %}</div>
</div>
<div class="text-sm flex items-center bg-muted border border-l-0 px-4 rounded-r-md shadow-xs">.com</div>
</div>
{% endset %}
{{ code_preview("input-group-group", code_group, class="w-full max-w-sm") }}
+320
View File
@@ -0,0 +1,320 @@
---
layout: layouts/page.njk
title: Item
description: A versatile component that you can use to display any content.
toc:
- label: Usage
id: usage
- label: Examples
id: examples
children:
- label: Variants
id: example-variants
- label: Size
id: example-size
- label: Icon
id: example-icon
- label: Avatar
id: example-avatar
- label: Image
id: example-image
- label: Group
id: example-group
- label: Header
id: example-header
- label: Link
id: example-link
---
{% from "macros/code_block.njk" import code_block %}
{% from "macros/code_preview.njk" import code_preview %}
<div class="alert mb-6">
{% lucide "circle-alert" %}
<h2>There is no dedicated Item component in Basecoat.</h2>
</div>
{% set code_basic %}
<div class="flex flex-col gap-6">
<article class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-border p-4 gap-4">
<div class="flex flex-1 flex-col gap-1">
<h3 class="flex w-fit items-center gap-2 text-sm leading-snug font-medium">Basic Item</h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">A simple item with title and description.</p>
</div>
<button class="btn-sm-outline">Action</button>
</article>
<a href="#" class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-border py-3 px-4 gap-2.5">
<div class="flex shrink-0 items-center justify-center gap-2 [&_svg]:pointer-events-none bg-transparent [&_svg]:size-5">
{% lucide "badge-check" %}
</div>
<div class="flex flex-1 flex-col gap-1">
<h3 class="flex w-fit items-center gap-2 text-sm leading-snug font-medium">Your profile has been verified.</h3>
</div>
<div class="flex items-center gap-2 [&_svg]:size-4">
{% lucide "chevron-right" %}
</div>
</a>
</div>
{% endset %}
{{ code_preview("item-basic", code_basic | prettyHtml, class="w-full max-w-md") }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<section class="prose">
<p>Items are pure HTML composition using Tailwind utility classes. Use semantic elements like <code>&lt;article&gt;</code> or <code>&lt;a&gt;</code>, apply flex layout, and compose with standard HTML elements like <code>&lt;h3&gt;</code>, <code>&lt;p&gt;</code>, <code>&lt;img&gt;</code>, or <code>&lt;button&gt;</code>.</p>
<p>Browse the examples below and copy the pattern that fits your needs.</p>
</section>
<h2 id="examples"><a href="#examples">Examples</a></h2>
<h3 id="example-variants"><a href="#example-variants">Variants</a></h3>
{% set code_variants %}
<div class="flex flex-col gap-6">
<article class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-transparent p-4 gap-4">
<div class="flex flex-1 flex-col gap-1">
<h3 class="flex w-fit items-center gap-2 text-sm leading-snug font-medium">Default Variant</h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">Standard styling with subtle background and borders.</p>
</div>
<button class="btn-sm-outline">Open</button>
</article>
<article class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-border p-4 gap-4">
<div class="flex flex-1 flex-col gap-1">
<h3 class="flex w-fit items-center gap-2 text-sm leading-snug font-medium">Outline Variant</h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">Outlined style with clear borders and transparent background.</p>
</div>
<button class="btn-sm-outline">Open</button>
</article>
<article class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-transparent bg-muted/50 p-4 gap-4">
<div class="flex flex-1 flex-col gap-1">
<h3 class="flex w-fit items-center gap-2 text-sm leading-snug font-medium">Muted Variant</h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">Subdued appearance with muted colors for secondary content.</p>
</div>
<button class="btn-sm-outline">Open</button>
</article>
</div>
{% endset %}
{{ code_preview("item-variants", code_variants) }}
<h3 id="example-size"><a href="#example-size">Size</a></h3>
<section class="prose">
<p>Use different padding and gap sizes for compact or spacious layouts.</p>
</section>
{{ code_preview("item-size", code_basic) }}
<h3 id="example-icon"><a href="#example-icon">Icon</a></h3>
{% set code_icon %}
<article class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-border p-4 gap-4">
<div class="flex shrink-0 items-center justify-center gap-2 self-start [&_svg]:pointer-events-none size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4">
{% lucide "shield-alert" %}
</div>
<div class="flex flex-1 flex-col gap-1">
<h3 class="flex w-fit items-center gap-2 text-sm leading-snug font-medium">Security Alert</h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">New login detected from unknown device.</p>
</div>
<button class="btn-sm-outline">Review</button>
</article>
{% endset %}
{{ code_preview("item-icon", code_icon, class="max-w-lg") }}
<h3 id="example-avatar"><a href="#example-avatar">Avatar</a></h3>
{% set code_avatar %}
<div class="flex flex-col gap-6">
<article class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-border p-4 gap-4">
<img src="https://github.com/hunvreus.png" alt="hunvreus" class="size-10 rounded-full object-cover">
<div class="flex flex-1 flex-col gap-1">
<h3 class="flex w-fit items-center gap-2 text-sm leading-snug font-medium">hunvreus</h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">Last seen 5 months ago</p>
</div>
<button class="btn-icon-outline rounded-full">
{% lucide "plus" %}
</button>
</article>
<article class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-border p-4 gap-4">
<div class="flex -space-x-2 [&_img]:ring-background [&_img]:ring-2 [&_img]:grayscale [&_img]:size-8 [&_img]:shrink-0 [&_img]:object-cover [&_img]:rounded-full self-start">
<img alt="@hunvreus" src="https://github.com/hunvreus.png">
<img alt="@shadcn" src="https://github.com/shadcn.png">
<img alt="@adamwathan" src="https://github.com/adamwathan.png">
</div>
<div class="flex flex-1 flex-col gap-1">
<h3 class="flex w-fit items-center gap-2 text-sm leading-snug font-medium">No Team Members</h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">Invite your team to collaborate on this project.</p>
</div>
<button class="btn-outline">
Invite
</button>
</article>
</div>
{% endset %}
{{ code_preview("item-avatar", code_avatar, class="w-full max-w-lg") }}
<h3 id="example-image"><a href="#example-image">Image</a></h3>
{% set code_image %}
<div class="flex flex-col gap-6 w-full max-w-md">
<a href="#" class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-border p-4 gap-4">
<img src="https://avatar.vercel.sh/example_1" alt="Midnight City Lights" class="grayscale size-10 rounded-sm object-cover">
<div class="flex flex-1 flex-col gap-1">
<h3 class="w-fit items-center gap-2 text-sm leading-snug font-medium line-clamp-1">Midnight City Lights - <span class="text-muted-foreground">Electric Nights</span></h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">Neon Dreams</p>
</div>
<div class="flex flex-col gap-1 flex-none text-center">
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">3:45</p>
</div>
</a>
<a href="#" class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-border p-4 gap-4">
<img src="https://avatar.vercel.sh/example_2" alt="Midnight City Lights" class="grayscale size-10 rounded-sm object-cover">
<div class="flex flex-1 flex-col gap-1">
<h3 class="w-fit items-center gap-2 text-sm leading-snug font-medium line-clamp-1">Coffee Shop Conversations - <span class="text-muted-foreground">Urban Stories</span></h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">The Morning Brew</p>
</div>
<div class="flex flex-col gap-1 flex-none text-center">
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">4:05</p>
</div>
</a>
<a href="#" class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-border p-4 gap-4">
<img src="https://avatar.vercel.sh/example_3" alt="Midnight City Lights" class="grayscale size-10 rounded-sm object-cover">
<div class="flex flex-1 flex-col gap-1">
<h3 class="w-fit items-center gap-2 text-sm leading-snug font-medium line-clamp-1">Digital Rain - <span class="text-muted-foreground">Binary Beats</span></h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">Cyber Symphony</p>
</div>
<div class="flex flex-col gap-1 flex-none text-center">
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">3:30</p>
</div>
</a>
</div>
{% endset %}
{{ code_preview("item-image", code_image) }}
<h3 id="example-group"><a href="#example-group">Group</a></h3>
{% set code_group %}
<div class="flex flex-col">
<article class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-transparent p-4 gap-4">
<img src="https://github.com/hunvreus.png" alt="Ronan Berder" class="grayscale size-10 rounded-full object-cover">
<div class="flex flex-1 flex-col gap-1">
<h3 class="flex w-fit items-center gap-2 text-sm leading-snug font-medium">hunvreus</h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">hunvreus@gmail.com</p>
</div>
<button class="btn-icon-outline rounded-full">
{% lucide "plus" %}
</button>
</article>
<hr role="separator" class="border-border">
<article class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-transparent p-4 gap-4">
<img src="https://github.com/shadcn.png" alt="shadcn" class="grayscale size-10 rounded-full object-cover">
<div class="flex flex-1 flex-col gap-1">
<h3 class="flex w-fit items-center gap-2 text-sm leading-snug font-medium">shadcn</h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">shadcn@vercel.com</p>
</div>
<button class="btn-icon-outline rounded-full">
{% lucide "plus" %}
</button>
</article>
<hr role="separator" class="border-border">
<article class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-transparent p-4 gap-4">
<img src="https://github.com/adamwathan.png" alt="Adam Wathan" class="grayscale size-10 rounded-full object-cover">
<div class="flex flex-1 flex-col gap-1">
<h3 class="flex w-fit items-center gap-2 text-sm leading-snug font-medium">adamwathan</h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">adam.wathan@gmail.com</p>
</div>
<button class="btn-icon-outline rounded-full">
{% lucide "plus" %}
</button>
</article>
</div>
{% endset %}
{{ code_preview("item-group", code_group, class="w-full max-w-md") }}
<h3 id="example-header"><a href="#example-header">Header</a></h3>
{% set code_header %}
<div class="flex-col grid grid-cols-3 gap-4">
<article class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-border p-4 gap-4">
<img alt="v0-1.5-sm" loading="lazy" class="grayscale aspect-square w-full rounded-sm object-cover flex-basis" src="https://avatar.vercel.sh/v0-1.5-sm">
<div class="flex flex-1 flex-col gap-1">
<h3 class="flex w-fit items-center gap-2 text-sm leading-snug font-medium">v0-1.5-sm</h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">Everyday tasks and UI generation.</p>
</div>
</article>
<article class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-border p-4 gap-4">
<img alt="v0-1.5-lg" loading="lazy" class="grayscale aspect-square w-full rounded-sm object-cover flex-basis" src="https://avatar.vercel.sh/v0-1.5-lg">
<div class="flex flex-1 flex-col gap-1">
<h3 class="flex w-fit items-center gap-2 text-sm leading-snug font-medium">v0-1.5-lg</h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">Advanced thinking or reasoning.</p>
</div>
</article>
<article class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-border p-4 gap-4">
<img alt="v0-2.0-mini" loading="lazy" class="grayscale aspect-square w-full rounded-sm object-cover flex-basis" src="https://avatar.vercel.sh/v0-2.0-mini">
<div class="flex flex-1 flex-col gap-1">
<h3 class="flex w-fit items-center gap-2 text-sm leading-snug font-medium">v0-2.0-mini</h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">Open Source model for everyone.</p>
</div>
</article>
</div>
{% endset %}
{{ code_preview("item-header", code_header) }}
<h3 id="example-link"><a href="#example-link">Link</a></h3>
<section class="prose">
<p>Items can be rendered as links by using an <code>&lt;a&gt;</code> element. Hover and focus states are applied automatically.</p>
</section>
{% set code_link %}
<div class="flex flex-col gap-6">
<a href="#" class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-transparent p-4 gap-4">
<div class="flex flex-1 flex-col gap-1">
<h3 class="flex w-fit items-center gap-2 text-sm leading-snug font-medium">Visit our documentation</h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">Learn how to get started with our components.</p>
</div>
<div class="flex items-center gap-2 [&_svg]:size-4">
{% lucide "chevron-right" %}
</div>
</a>
<a href="#" class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-border p-4 gap-4">
<div class="flex flex-1 flex-col gap-1">
<h3 class="flex w-fit items-center gap-2 text-sm leading-snug font-medium">External resource</h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">Opens in a new tab with security attributes.</p>
</div>
<div class="flex items-center gap-2 [&_svg]:size-4">
{% lucide "external-link" %}
</div>
</a>
</div>
{% endset %}
{{ code_preview("item-link", code_link, class="w-full max-w-md") }}
+76
View File
@@ -0,0 +1,76 @@
---
layout: layouts/page.njk
title: Kbd
description: Used to display textual user input from keyboard.
toc:
- label: Usage
id: usage
- label: Examples
id: examples
children:
- label: Group
id: example-group
- label: Button
id: example-button
---
{% from "macros/code_preview.njk" import code_preview %}
{% from "macros/code_block.njk" import code_block %}
{% set code_html %}
<div class="flex flex-col items-center gap-4">
<span class="inline-flex items-center gap-1">
<kbd class="kbd">⌘</kbd>
<kbd class="kbd">⇧</kbd>
<kbd class="kbd">⌥</kbd>
<kbd class="kbd">⌃</kbd>
</span>
<span class="inline-flex items-center gap-1">
<kbd class="kbd">Ctrl</kbd>
<span>+</span>
<kbd class="kbd">B</kbd>
</span>
</div>
{% endset %}
{{ code_preview("kbd", code_html | prettyHtml) }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<section class="prose">
<p>Use the <code>&lt;kbd&gt;</code> element with the <code>kbd</code> class:</p>
</section>
{% set code_simple %}
<kbd class="kbd">⌘K</kbd>
{% endset %}
{{ code_block(code_simple | prettyHtml, "html") }}
<h2 id="examples"><a href="#examples">Examples</a></h2>
<h3 id="example-group"><a href="#example-group">Group</a></h3>
{% set group_html %}
<p class="text-muted-foreground text-sm">
Use
<span class="inline-flex items-center gap-1">
<kbd class="kbd">Ctrl + B</kbd>
<kbd class="kbd">Ctrl + K</kbd>
</span>
to open the command palette
</p>
{% endset %}
{{ code_preview("kbd-group", group_html | prettyHtml) }}
<h3 id="example-button"><a href="#example-button">Button</a></h3>
{% set button_html %}
<div class="flex flex-wrap items-center gap-x-4">
<button class="btn-sm-outline pr-2">Accept <kbd class="kbd">⏎</kbd></button>
<button class="btn-sm-outline pr-2">Cancel <kbd class="kbd">Esc</kbd></button>
</div>
{% endset %}
{{ code_preview("kbd-button", button_html | prettyHtml) }}
+18 -4
View File
@@ -15,6 +15,8 @@ toc:
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: JavaScript events
id: usage-html-js-4
- label: Jinja and Nunjucks
id: usage-macro
---
@@ -69,7 +71,8 @@ toc:
<p>You can either <a href="/installation/#install-cdn-all">include the JavaScript file for all the components</a>, or just the one for this component by adding this to the <code>&lt;head&gt;</code> of your page:</p>
</section>
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/popover.min.js" defer></script>{% endset %}
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/basecoat.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/popover.min.js" defer></script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
@@ -100,8 +103,19 @@ toc:
<li><code>onclick</code>: a simple function to update <code>aria-expanded</code>.</li>
</ul>
</dd>
<dt><code class="highlight language-html">&lt;div popover class="popover" id="{ POPOVER_ID }"&gt;</code></dt>
<dd>As with the <a href="/components/popover">Popover</a> component, you can set up the side and alignment of the popover using the <code>data-side</code> and <code>data-align</code> attributes.</dd>
<dt><code class="highlight language-html">&lt;div data-popover class="popover" id="{ POPOVER_ID }"&gt;</code></dt>
<dd>You can set up the side and alignment of the popover using the <code>data-side</code> and <code>data-align</code> attributes.</dd>
</dl>
</section>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript events</a></h4>
<section class="prose">
<dl>
<dt><code>basecoat:initialized</code></dt>
<dd>Once the component is fully initialized, it dispatches a custom (non-bubbling) <code>basecoat:initialized</code> event on itself.</dd>
<dt><code>basecoat:popover</code></dt>
<dd>When the popover opens, the component dispatches a custom (non-bubbling) <code>basecoat:popover</code> event on <code>document</code>. Other popover components (Combobox, Dropdown Menu, Popover and Select) listen for this to close any open popovers.</dd>
</dl>
</section>
@@ -157,4 +171,4 @@ toc:
</form>
</div>
{% endcall %}{% endraw %}{% endset %}
{{ code_block(raw_code, "jinja") }}
{{ code_block(raw_code, "jinja") }}
+44
View File
@@ -0,0 +1,44 @@
---
layout: layouts/page.njk
title: Progress
description: Displays an indicator showing the completion progress of a task, typically displayed as a progress bar.
toc:
- label: Usage
id: usage
---
{% from "macros/code_preview.njk" import code_preview %}
{% from "macros/code_block.njk" import code_block %}
<div class="alert mb-6">
{% lucide "circle-alert" %}
<h2>There is no dedicated Progress component in Basecoat.</h2>
</div>
{% set code_default %}
<div class="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
<div id="demo-progress" class="bg-primary h-full w-full flex-1 transition-all" style="width: 17%"></div>
</div>
<script>
setTimeout(() => {
document.getElementById('demo-progress').style.width = '66%';
}, 500);
</script>
{% endset %}
{{ code_preview("progress-default", code_default, class="w-full max-w-sm") }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<section class="prose">
<p>Progress bars are pure HTML composition using Tailwind utility classes. Use a container div for the track and an inner div for the indicator. Set the indicator's width with inline styles or via JavaScript.</p>
</section>
{% set code_usage %}
<div class="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
<div class="bg-primary h-full w-full flex-1 transition-all" style="width: 66%"></div>
</div>
{% endset %}
{{ code_block(code_usage) }}
+40 -9
View File
@@ -17,6 +17,10 @@ toc:
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: JavaScript events
id: usage-html-js-4
- label: JavaScript methods
id: usage-html-js-5
- label: Jinja and Nunjucks
id: usage-macro
- label: Examples
@@ -88,7 +92,8 @@ toc:
<p>You can either <a href="/installation/#install-cdn-all">include the JavaScript file for all the components</a>, or just the one for this component by adding this to the <code>&lt;head&gt;</code> of your page:</p>
</section>
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/select.min.js" defer></script>{% endset %}
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/basecoat.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/select.min.js" defer></script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
@@ -156,6 +161,38 @@ toc:
</dl>
</section>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript events</a></h4>
<section class="prose">
<dl>
<dt><code>basecoat:initialized</code></dt>
<dd>Once the component is fully initialized, it dispatches a custom (non-bubbling) <code>basecoat:initialized</code> event on itself.</dd>
<dt><code>basecoat:popover</code></dt>
<dd>When the popover opens, the component dispatches a custom (non-bubbling) <code>basecoat:popover</code> event on <code>document</code>. Other popover components (Combobox, Dropdown Menu, Popover and Select) listen for this to close any open popovers.</dd>
<dt><code>change</code></dt>
<dd>When the selected value changes, the component dispatches a custom (bubbling) <code>change</code> event on itself, with the selected value in <code>event.detail</code> (e.g. <code>{ detail: { value: "something" }}</code>).</dd>
</dl>
</section>
<h4 id="usage-html-js-5"><a href="#usage-html-js-5">JavaScript methods</a></h4>
<section class="prose">
<dl>
<dt><code>selectByValue</code></dt>
<dd>
<p>You can call this method on the component after it is initialized to select an option by value (i.e. the option with the matching <code>data-value</code> attribute):</p>
{% set code_script %}<script>
const selectComponent = document.querySelector('#my-select');
selectComponent.addEventListener('basecoat:initialized', () => {
selectComponent.selectByValue('apple');
});
</script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
</dd>
</dl>
</section>
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
<div class="prose">
@@ -203,7 +240,7 @@ toc:
listbox_attrs={"class": "scrollbar overflow-y-auto max-h-64"}
) %}
{% for i in range(0, 99) %}
<div role="option" data-value="item-{{ i }}">Item {{ i }}</div>
<div role="option" {% if i > 0 %}data-value="item-{{ i }}"{% endif %}>Item {{ i }}</div>
{% endfor %}
{% endcall %}
{% endset %}
@@ -250,10 +287,4 @@ toc:
{% endcall %}
{% endset %}
{{ code_preview("select-scrollable", code | prettyHtml) }}
<script>
const select = document.querySelector('#select-with-icons');
select.addEventListener('basecoat:initialized', () => {
select.selectByValue('pie');
});
</script>
{{ code_preview("select-scrollable", code | prettyHtml) }}
+27 -13
View File
@@ -15,6 +15,8 @@ toc:
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: JavaScript events
id: usage-html-js-4
- label: Jinja and Nunjucks
id: usage-macro
---
@@ -32,7 +34,8 @@ toc:
<p>You can either <a href="/installation/#install-cdn-all">include the JavaScript file for all the components</a>, or just the one for this component by adding this to the <code>&lt;head&gt;</code> of your page:</p>
</section>
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/sidebar.min.js" defer></script>{% endset %}
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/basecoat.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/sidebar.min.js" defer></script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
@@ -82,15 +85,6 @@ toc:
{% endset %}
{{ code_block(code_html | prettyHtml, "html") }}
<section class="prose">
<p>Any element on the page can toggle, open or close the sidebar by dispatching the <code>basecoat:sidebar</code>. By default, the event will toggle the sidebar, but can be used to open or close if you add an <code>action</code> to the detail:</p>
</section>
{% set code_trigger %}<button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar'));">Toggle sidebar</button>
<button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar', { detail: { action: 'open' } }));">Open sidebar</button>
<button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar', { detail: { action: 'close' } }));">Close sidebar</button>{% endset %}
{{ code_block(code_trigger | prettyHtml, "html") }}
<h4 id="usage-html-js-3"><a href="#usage-html-js-3">HTML structure</a></h4>
<section class="prose">
@@ -155,8 +149,28 @@ toc:
</dd>
<dt><code class="highlight language-html">&lt;main&gt;</code></dt>
<dd>A wrapper for the content of the page.</dd>
<dt><code class="highlight language-html">&lt;button type="button" onclick="document.dispatchEvent(new CustomEvent('sidebar:toggle'))"&gt;</code></dt>
<dd>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. <code>&lt;aside class="sidebar" id="{SIDEBAR_ID}"&gt;</code>) and refer to them in the event <code>detail</code> (i.e. <code>document.dispatchEvent(new CustomEvent('sidebar:toggle', { detail: { id: '{SIDEBAR_ID}' } }));</code>).</dd>
<dt><code class="highlight language-html">&lt;button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar'))"&gt;</code></dt>
<dd>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. <code>&lt;aside class="sidebar" id="{SIDEBAR_ID}"&gt;</code>) and refer to them in the event <code>detail</code> (i.e. <code>document.dispatchEvent(new CustomEvent('basecoat:sidebar', { detail: { id: '{SIDEBAR_ID}' } }));</code>).</dd>
</dl>
</section>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript events</a></h4>
<section class="prose">
<dl>
<dt><code>basecoat:initialized</code></dt>
<dd>Once the component is fully initialized, it dispatches a custom (non-bubbling) <code>basecoat:initialized</code> event on itself.</dd>
<dt><code>basecoat:sidebar</code></dt>
<dd>
<p>Sidebars listen for this event on <code>document</code> to open, close or toggle themselves. By default, the event will toggle the sidebar, but can be used to open or close if you add an <code>action</code> to the detail. Additionally, if you have multiple sidebars on the page, you can target a specific sidebar by adding its <code>id</code> to the detail:</p>
{% set code_trigger %}<!-- Toggles the sidebar -->
<button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar'));">Toggle sidebar</button>
<!-- Opens the `#main-navigation` sidebar -->
<button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar', { detail: { id: 'main-navigation', action: 'open' } }));">Open sidebar</button>
<!-- Closes the sidebar -->
<button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar', { detail: { action: 'close' } }));">Close sidebar</button>{% endset %}
{{ code_block(code_trigger | prettyHtml, "html") }}
</dd>
</dl>
</section>
@@ -202,4 +216,4 @@ toc:
<h1>Content</h1>
</main>
{% endraw %}{% endset%}
{{ code_block(raw_code, "jinja") }}
{{ code_block(raw_code, "jinja") }}
+15 -7
View File
@@ -16,18 +16,26 @@ toc:
min="0"
max="100"
value="50"
x-data="{
updatePercent(el) {
>
<script>
(() => {
const sliders = document.querySelectorAll('input[type="range"].input');
if (!sliders) return;
const updateSlider = (el) => {
const min = parseFloat(el.min || 0);
const max = parseFloat(el.max || 100);
const value = parseFloat(el.value);
const percent = (max === min) ? 0 : ((value - min) / (max - min)) * 100;
el.style.setProperty('--slider-value', `${percent}%`);
}
}"
x-init="updatePercent($el)"
@input="updatePercent($el)"
>{% endset %}
};
sliders.forEach(slider => {
updateSlider(slider);
slider.addEventListener('input', (event) => updateSlider(event.target));
});
})();
</script>{% endset %}
{{ code_preview("slider", code, "w-full max-w-sm") }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
+214
View File
@@ -0,0 +1,214 @@
---
layout: layouts/page.njk
title: Spinner
description: An indicator that can be used to show a loading state.
toc:
- label: Usage
id: usage
- label: Examples
id: examples
children:
- label: Size
id: example-size
- label: Color
id: example-color
- label: Button
id: example-button
- label: Badge
id: example-badge
- label: Input Group
id: example-input-group
- label: Empty
id: example-empty
- label: Item
id: example-item
---
{% from "macros/code_preview.njk" import code_preview %}
{% from "macros/code_block.njk" import code_block %}
<div class="alert mb-6">
{% lucide "circle-alert" %}
<h2>There is no dedicated Spinner component in Basecoat.</h2>
</div>
{% set code_default %}
<article class="group/item flex items-center border text-sm rounded-md transition-colors flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-transparent bg-muted/50 p-4 gap-4">
<div class="flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:size-4">
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "animate-spin" } %}
</div>
<div class="flex flex-1 flex-col gap-1">
<h3 class="flex w-fit items-center gap-2 text-sm leading-snug font-medium line-clamp-1">Processing payment...</h3>
</div>
<div class="flex flex-col gap-1 flex-none text-center">
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">$100.00</p>
</div>
</article>
{% endset %}
{{ code_preview("spinner-default", code_default, class="w-full max-w-xs") }}
<h2 id="usage"><a href="#usage">Usage</a></h2>
<section class="prose">
<p>Spinners are pure HTML using the <code>loader-circle</code> Lucide icon with the <code>animate-spin</code> Tailwind utility. Add <code>role="status"</code> and <code>aria-label="Loading"</code> for accessibility.</p>
<p>Browse the examples below and copy the pattern that fits your needs.</p>
</section>
<h2 id="examples"><a href="#examples">Examples</a></h2>
<h3 id="example-size"><a href="#example-size">Size</a></h3>
<section class="prose">
<p>Use the <code>size-*</code> utility class to change the size of the spinner.</p>
</section>
{% set code_size %}
<div class="flex items-center gap-6">
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "size-3 animate-spin" } %}
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "size-4 animate-spin" } %}
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "size-6 animate-spin" } %}
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "size-8 animate-spin" } %}
</div>
{% endset %}
{{ code_preview("spinner-size", code_size) }}
<h3 id="example-color"><a href="#example-color">Color</a></h3>
<section class="prose">
<p>Use the <code>text-*</code> utility class to change the color of the spinner.</p>
</section>
{% set code_color %}
<div class="flex items-center gap-6">
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "size-6 animate-spin text-red-500" } %}
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "size-6 animate-spin text-green-500" } %}
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "size-6 animate-spin text-blue-500" } %}
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "size-6 animate-spin text-yellow-500" } %}
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "size-6 animate-spin text-purple-500" } %}
</div>
{% endset %}
{{ code_preview("spinner-color", code_color) }}
<h3 id="example-button"><a href="#example-button">Button</a></h3>
<section class="prose">
<p>Add a spinner to a button to indicate a loading state.</p>
</section>
{% set code_button %}
<div class="flex flex-col items-center gap-4">
<button class="btn-sm" disabled>
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "animate-spin" } %}
Loading...
</button>
<button class="btn-sm-outline" disabled>
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "animate-spin" } %}
Please wait
</button>
<button class="btn-sm-secondary" disabled>
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "animate-spin" } %}
Processing
</button>
</div>
{% endset %}
{{ code_preview("spinner-button", code_button) }}
<h3 id="example-badge"><a href="#example-badge">Badge</a></h3>
{% set code_badge %}
<div class="flex items-center gap-4">
<span class="badge">
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "animate-spin" } %}
Syncing
</span>
<span class="badge-secondary">
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "animate-spin" } %}
Updating
</span>
<span class="badge-outline">
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "animate-spin" } %}
Processing
</span>
</div>
{% endset %}
{{ code_preview("spinner-badge", code_badge) }}
<h3 id="example-input-group"><a href="#example-input-group">Input Group</a></h3>
<section class="prose">
<p>Input groups can have spinners positioned inside them.</p>
</section>
{% set code_input_group %}
<div class="flex w-full max-w-md flex-col gap-4">
<div class="relative">
<input type="text" class="input pr-9" placeholder="Send a message..." disabled>
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground [&>svg]:size-4">
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "animate-spin" } %}
</div>
</div>
<div class="relative">
<textarea class="textarea pr-10 min-h-27 pb-12" placeholder="Send a message..." disabled></textarea>
<footer role="group" class="absolute bottom-0 px-3 pb-3 pt-1.5 flex items-center w-full gap-2">
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "size-4 animate-spin text-muted-foreground" } %}
<span class="text-sm text-muted-foreground">Validating...</span>
<button type="button" class="btn-sm-icon h-6 w-7.5 ml-auto rounded">
{% lucide "arrow-up" %}
</button>
</footer>
</div>
</div>
{% endset %}
{{ code_preview("spinner-input-group", code_input_group, class="w-full max-w-md") }}
<h3 id="example-empty"><a href="#example-empty">Empty</a></h3>
<section class="prose">
<p>Use a spinner in empty states to indicate a loading or processing state.</p>
</section>
{% set code_empty %}
<div class="flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg p-6 text-center text-balance md:p-12 text-neutral-800 dark:text-neutral-300">
<header class="flex max-w-sm flex-col items-center gap-3 text-center">
<div class="mb-2 bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6">
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "animate-spin size-4" } %}
</div>
<h3 class="text-lg font-semibold tracking-tight">Processing your request</h3>
<p class="text-muted-foreground text-sm/relaxed">Please wait while we process your request. Do not refresh the page.</p>
</header>
<section class="flex w-full max-w-sm min-w-0 flex-col items-center gap-3">
<button class="btn-sm-outline">Cancel</button>
</section>
</div>
{% endset %}
{{ code_preview("spinner-empty", code_empty) }}
<h3 id="example-item"><a href="#example-item">Item</a></h3>
{% set code_item %}
<article class="group/item flex items-center border text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] border-border p-4 gap-4">
<div class="flex shrink-0 items-center justify-center gap-2 self-start [&_svg]:pointer-events-none size-8 border rounded-md bg-muted [&_svg:not([class*='size-'])]:size-4">
{% lucide "loader-circle", { "role": "status", "aria-label": "Loading", "class": "animate-spin size-4 text-muted-foreground" } %}
</div>
<div class="flex flex-1 flex-col gap-1">
<h3 class="flex w-fit items-center gap-2 text-sm leading-snug font-medium">Downloading...</h3>
<p class="text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4">129 MB / 1000 MB</p>
</div>
<button class="btn-sm-outline self-start">Cancel</button>
<footer class="flex basis-full items-center justify-between gap-2">
<div class="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
<div id="demo-progress" class="bg-primary h-full w-full flex-1 transition-all" style="width: 13%"></div>
</div>
</footer>
</article>
{% endset %}
{{ code_preview("spinner-item", code_item, class="w-full max-w-md") }}
+20 -1
View File
@@ -8,6 +8,15 @@ toc:
children:
- label: HTML + JavaScript
id: usage-default
children:
- label: "Step 1: Include the JavaScript files"
id: usage-html-js-1
- label: "Step 2: Add your select HTML"
id: usage-html-js-2
- label: HTML structure
id: usage-html-js-3
- label: JavaScript events
id: usage-html-js-4
- label: Jinja and Nunjucks
id: usage-macro
---
@@ -85,7 +94,8 @@ toc:
<p>You can either <a href="/installation/#install-cdn-all">include the JavaScript file for all the components</a>, or just the one for this component by adding this to the <code>&lt;head&gt;</code> of your page:</p>
</section>
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/select.min.js" defer></script>{% endset %}
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/basecoat.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/tabs.min.js" defer></script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
@@ -130,6 +140,15 @@ toc:
</dl>
</section>
<h4 id="usage-html-js-4"><a href="#usage-html-js-4">JavaScript events</a></h4>
<section class="prose">
<dl>
<dt><code>basecoat:initialized</code></dt>
<dd>Once the component is fully initialized, it dispatches a custom (non-bubbling) <code>basecoat:initialized</code> event on itself.</dd>
</dl>
</section>
<h3 id="usage-macro"><a href="#usage-macro">Jinja and Nunjucks</a></h3>
<div class="prose">
+86 -28
View File
@@ -5,49 +5,107 @@ description: A component that allows the user to switch between light and dark m
toc:
- label: Usage
id: usage
- label: Examples
id: examples
children:
- label: Default
id: example-default
- label: Right
id: example-right
- label: Bottom + Start
id: example-bottom-start
- label: HTML + JavaScript
id: usage-html-js
children:
- label: "Step 1: Include the JavaScript script"
id: usage-html-js-1
- label: "Step 2: Add your switcher"
id: usage-html-js-2
- label: JavaScript events
id: usage-html-js-3
---
<div class="alert mb-6">
{% lucide "circle-alert" %}
<h2>There is no dedicated theme switcher component in Basecoat.</h2>
<section><p>Instead, we offer a short JavaScript <code>&lt;script&gt;</code> block you can insert in the <code>&lt;head&gt;</code> of your page.</p></section>
</div>
{% from "macros/code_preview.njk" import code_preview %}
{% from "macros/code_block.njk" import code_block %}
{% set code %}<button class="btn-outline" data-tooltip="Add to library">Hover</button>{% endset %}
{% set code %}<button
type="button"
aria-label="Toggle dark mode"
data-tooltip="Toggle dark mode"
data-side="bottom"
onclick="document.dispatchEvent(new CustomEvent('basecoat:theme'))"
class="btn-icon-outline size-8"
>
<span class="hidden dark:block">{% lucide "sun" %}</span>
<span class="block dark:hidden">{% lucide "moon" %}</span>
</button>{% endset %}
{{ code_preview("button", code) }}
<h2 id="usage">Usage</h2>
<h2 id="usage"><a href="#usage">Usage</a></h2>
<h3 id="usage-html-js"><a href="#usage-html-js">HTML + JavaScript</a></h3>
<h4 id="usage-html-js-1"><a href="#usage-html-js-1">Step 1: Include the JavaScript script</a></h4>
<section class="prose">
<p>On any element, set the value of the <code>data-tooltip</code> attribute to the text you want to display in the tooltip. Additionally, you can set:</p>
<ul>
<li><code>data-side</code> attribute to <code>top</code>, <code>bottom</code>, <code>left</code>, or <code>right</code> to change the side of the tooltip.</li>
<li><code>data-align</code> attribute to <code>start</code>, <code>center</code>, or <code>end</code> to change the alignment of the tooltip.</li>
</ul>
<p>Insert the following JavaScript block right at the beginning of the <code>&lt;head&gt;</code> your page. We do this instead of using a separate file to ensure the theme is defined before the page loads, avoiding a flash of unstyled or mist-styled content.</p>
</section>
{% set code %}<button class="btn-outline" data-tooltip="Tooltip text" data-side="bottom" data-align="center">Bottom left</button>{% endset %}
{% set code %}<script>
(() => {
try {
const stored = localStorage.getItem('themeMode');
if (stored ? stored === 'dark'
: matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
} catch (_) {}
const apply = dark => {
document.documentElement.classList.toggle('dark', dark);
try { localStorage.setItem('themeMode', dark ? 'dark' : 'light'); } catch (_) {}
};
document.addEventListener('basecoat:theme', (event) => {
const mode = event.detail?.mode;
apply(mode === 'dark' ? true
: mode === 'light' ? false
: !document.documentElement.classList.contains('dark'));
});
})();
</script>{% endset %}
{{ code_block(code) }}
<h2 id="examples"><a href="#examples">Examples</a></h2>
<h4 id="usage-html-js-2"><a href="#usage-html-js-2">Step 2: Add your switcher</a></h4>
<h3 id="example-default"><a href="#example-default">Default</a></h3>
<section class="prose">
<p>To change the theme, you need to dispatch a <code>basecoat:theme</code> event:</p>
</section>
{% set code %}<button class="btn-outline" data-tooltip="Default tooltip">Default</button>{% endset %}
{{ code_preview("button", code) }}
{% set code %}<button
type="button"
aria-label="Toggle dark mode"
data-tooltip="Toggle dark mode"
data-side="bottom"
onclick="document.dispatchEvent(new CustomEvent('basecoat:theme'))"
class="btn-icon-outline size-8"
>
<span class="hidden dark:block">{% lucide "sun" %}</span>
<span class="block dark:hidden">{% lucide "moon" %}</span>
</button>{% endset %}
{{ code_block(code) }}
<h3 id="example-right"><a href="#example-right">Right</a></h3>
<h4 id="usage-html-js-3"><a href="#usage-html-js-3">JavaScript events</a></h4>
{% set code %}<button class="btn-outline" data-tooltip="Right side tooltip" data-side="right">Right</button>{% endset %}
{{ code_preview("button", code) }}
<h3 id="example-bottom-start"><a href="#example-bottom-start">Bottom + Start</a></h3>
{% set code %}<button class="btn-outline" data-tooltip="Bottom side and start aligned tooltip" data-side="bottom" data-align="start">Bottom + Start</button>{% endset %}
{{ code_preview("button", code) }}
<section class="prose">
<dl>
<dt><code>basecoat:theme</code></dt>
<dd>
<p>By default, the event will toggle the theme, but you can also add a <code>mode</code> to the detail to set the theme explicitly:</p>
{% set code_trigger %}<!-- Toggles the theme -->
<button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:theme'));">Toggle theme</button>
<!-- Sets the theme to dark -->
<button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:theme', { detail: { mode: 'dark' } }));">Set dark theme</button>{% endset %}
{{ code_block(code_trigger | prettyHtml, "html") }}
</dd>
</dl>
</section>
+22 -6
View File
@@ -17,8 +17,10 @@ toc:
id: usage-html-js-3
- label: HTML structure
id: usage-html-js-4
- label: JavaScript config object
- label: JavaScript events
id: usage-html-js-5
- label: Toast config object
id: usage-html-js-6
- label: Jinja and Nunjucks
id: usage-macro
---
@@ -67,7 +69,8 @@ toc:
<p>You can either <a href="/installation/#install-cdn-all">include the JavaScript file for all the components</a>, or just the one for this component by adding this to the <code>&lt;head&gt;</code> of your page:</p>
</section>
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/toast.min.js" defer></script>{% endset %}
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/basecoat.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/toast.min.js" defer></script>{% endset %}
{{ code_block(code_script | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
@@ -118,7 +121,7 @@ toc:
{{ code_block(code_toast_html | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
<a class="badge-outline" href="#usage-html-js-4">
<a class="badge-outline" href="#usage-html-js-5">
HTML structure
{% lucide "arrow-right" %}
</a>
@@ -131,8 +134,8 @@ toc:
{{ code_block(code_client_side | prettyHtml, "html") }}
<div class="flex flex-wrap gap-2 my-6">
<a class="badge-outline" href="#usage-html-js-5">
JavaScript config object
<a class="badge-outline" href="#usage-html-js-6">
Toast config object
{% lucide "arrow-right" %}
</a>
</div>
@@ -176,7 +179,20 @@ toc:
</dl>
</section>
<h4 id="usage-html-js-5"><a href="#usage-html-js-5">JavaScript config object</a></h4>
<h4 id="usage-html-js-5"><a href="#usage-html-js-5">JavaScript events</a></h4>
<section class="prose">
<dl>
<dt><code>basecoat:initialized</code></dt>
<dd>Once the component is fully initialized, it dispatches a custom (non-bubbling) <code>basecoat:initialized</code> event on the <code>toaster</code> element.</dd>
<dt><code>basecoat:toast</code></dt>
<dd>
<p>The <code>toaster</code> listens for <code>basecoat:toast</code> events on <code>document</code> to create toasts. The event's <code>detail</code> object must contain a <code>config</code> object (see <a href="#usage-html-js-6">JavaScript config object</a> below).</p>
</dd>
</dl>
</section>
<h4 id="usage-html-js-6"><a href="#usage-html-js-6">Toast config object</a></h4>
<section class="prose">
<dl>
+9 -3
View File
@@ -232,9 +232,15 @@ layout: layouts/layout.njk
I can't log in.
</div>
</section>
<footer class="flex items-center gap-2" x-data="{ message: '' }">
<input type="text" class="input w-full" placeholder="Type your message here..." x-model="message" />
<button type="button" class="btn-icon" :disabled="!message">
<footer class="flex items-center gap-2">
<input type="text" class="input w-full" placeholder="Type your message here..." oninput="
if (this.value) {
this.parentElement.querySelector('button').removeAttribute('disabled');
} else {
this.parentElement.querySelector('button').setAttribute('disabled', 'true');
}
"/>
<button type="button" class="btn-icon" disabled>
{% lucide "send"%}
</button>
</footer>
+42 -4
View File
@@ -5,6 +5,11 @@ description: How to install and use Basecoat in your app.
toc:
- label: Install using the CDN
id: install-cdn
children:
- label: Install all components
id: install-cdn-all
- label: Install specific components
id: install-cdn-specific
- label: Installing using NPM
id: install-npm
children:
@@ -24,8 +29,12 @@ toc:
id: install-cli
- label: Use Nunjucks or Jinja macros
id: install-macros
- label: Icons
id: install-icons
- label: Theming
id: install-theming
- label: Customization
id: install-customization
---
{% from "macros/code_block.njk" import code_block %}
@@ -43,13 +52,14 @@ toc:
<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/all.min.js" defer></script>{% endset %}
{{ code_block(code, "html") }}
<h3 id="install-cdn-all"><a href="#install-cdn-all">Install specific components</a></h3>
<h3 id="install-cdn-specific"><a href="#install-cdn-specific">Install specific components</a></h3>
<section class="prose">
<p>While the JavaScript file for all components is small (around 3kB gzipped), you can cherry-pick the components you need as instructed in the component's page (<a href="/components/dropdown-menu">Dropdown Menu</a>, <a href="/components/popover">Popover</a>, <a href="/components/select">Select</a>, <a href="/components/sidebar">Sidebar</a>, <a href="/components/tabs">Tabs</a> and <a href="/components/toast">Toast</a>). For example, to add the <a href="/components/dropdown-menu">Dropdown Menu</a> component, add the following to the <code>&lt;head&gt;</code> of your HTML file:</p>
</section>
{% set code %}<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/basecoat.cdn.min.css">
<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/basecoat.min.js" defer></script> <!-- Only needed once to register and initialize components -->
<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/dropdown-menu.min.js" defer></script>{% endset %}
{{ code_block(code, "html") }}
@@ -112,9 +122,10 @@ import 'basecoat-css/tabs';{% endraw %}{% endset %}
{% set code %}npx copyfiles -u 1 "node_modules/basecoat-css/dist/js/**/*" public/js/basecoat{% endset %}
{{ code_block(code, "bash") }}
<div class="prose">
<p>Then reference the script you need:</p>
<p>Then reference the script you need as well as the <code>basecoat.min.js</code> file (used to register and initialize components):</p>
</div>
{% set code %}<script src="/js/basecoat/tabs.min.js" defer></script>{% endset %}
{% set code %}<script src="/js/basecoat/basecoat.min.js" defer></script>
<script src="/js/basecoat/tabs.min.js" defer></script>{% endset %}
{{ code_block(code, "html") }}
<div class="prose">
<p>See each component page for the minimal script required.</p>
@@ -138,7 +149,7 @@ import 'basecoat-css/tabs';{% endraw %}{% endset %}
<ul>
<li><b>CDN</b>: you either <a href="#install-cdn-all">add the code for all of the components</a> to the <code>&lt;head&gt;</code> of your HTML file, or just the file for the component you want to use as instructed on the component's page.</li>
<li><b>Local file</b>: you download the script as a separate file and include it in your project (<a href="#install-cli">or let the CLI do it for you</a>). Once again, you have the choice to include the fiel for all components at once (<a><code>all.min.js</code> or <code>all.js</code>).</p>
<li><b>Local file</b>: you download the script as a separate file and include it in your project (<a href="#install-cli">or let the CLI do it for you</a>). Once again, you have the choice to include the file for all components at once (<a><code>all.min.js</code> or <code>all.js</code>).</p>
</ul>
</div>
@@ -185,6 +196,33 @@ import 'basecoat-css/tabs';{% endraw %}{% endset %}
{% endcall %}{% endraw %}{% endset %}
{{ code_block(code, "jinja") }}
<h2 id="install-icons"><a href="#install-icons">Icons</a></h2>
<div class="prose">
<p>Basecoat uses <a href="https://lucide.dev" target="_blank">Lucide icons</a>. You have three options:</p>
<ul>
<li><b>Copy SVG code</b>: Visit <a href="https://lucide.dev/icons" target="_blank">lucide.dev/icons</a>, click any icon to copy the SVG, and paste it directly in your HTML. Best for simple projects or occasional icon usage.</li>
<li><b>Install lucide</b>: Follow the <a href="https://lucide.dev/guide/installation" target="_blank">installation guide</a> to install the <code>lucide</code> package and dynamically render icons. Best for projects that need many icons or want tree-shaking.</li>
<li><b>Use framework packages</b>: Browse <a href="https://lucide.dev/packages" target="_blank">framework-specific packages</a> (React, Vue, Angular, etc.) for component-based icon usage. Best if you're using a supported framework.</li>
</ul>
</div>
<div class="flex flex-wrap gap-2 my-6">
<a class="badge-outline" href="https://lucide.dev" target="_blank">
Browse icons
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="https://lucide.dev/guide/installation" target="_blank">
Installation guide
{% lucide "arrow-right" %}
</a>
<a class="badge-outline" href="https://lucide.dev/packages" target="_blank">
Packages
{% lucide "arrow-right" %}
</a>
</div>
<h2 id="install-theming"><a href="#install-theming">Theming</a></h2>
<div class="prose">
+2 -2
View File
@@ -25,7 +25,7 @@ toc:
<p><b>That's where Basecoat comes in.</b> It gives you modern, accessible components with the simplicity of plain HTML and Tailwind. Basecoat is:</p>
<ul>
<li><b>Lightweight</b>: no runtime JS, just CSS and tiny Alpine.js scripts for the more interactive components.</li>
<li><b>Lightweight</b>: no runtime JS, just CSS and a tiny bit of vanilla JavaScript for the more interactive components.</li>
<li><b>Easy to use</b>: add classes like <code>btn</code> or <code>input</code> and you're done.</li>
<li><b>Framework-agnostic</b>: works with any backend or frontend stack.</li>
<li><b>Accessible</b>: components follow accessibility best practices.</li>
@@ -42,7 +42,7 @@ toc:
<div class="prose">
<p>Add a single CSS file to your Tailwind setup. Use components by dropping in simple classes like <code>btn</code>, <code>form</code>, or <code>card</code>.</p>
<p>Some components (like modals or dropdowns) use a tiny bit of Alpine.js. You can skip it if you don't need interactivity.</p>
<p>Some components (like modals or dropdowns) use a tiny bit of vanilla JavaScript. You can skip it if you don't need interactivity.</p>
<p>There's also a CLI to help you scaffold components and get set up fast.</p>
+38 -70
View File
@@ -1,71 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anchor Positioning Test (Top, End)</title>
<script type="module">
if (!("anchorName" in document.documentElement.style)) {
console.log("Loading CSS anchor positioning polyfill...");
import("https://unpkg.com/@oddbird/css-anchor-positioning");
}
</script>
<style>
body {
font-family: sans-serif;
display: grid;
place-items: center;
height: 100vh;
margin: 0;
background: #222;
color: #eee;
}
---
layout: layouts/layout.njk
---
button {
padding: 0.5em 1em;
font-size: 1rem;
border-radius: 6px;
border: 1px solid #888;
background: #444;
color: #eee;
cursor: pointer;
}
[popover] {
border: 1px solid #888;
border-radius: 6px;
background: #333;
color: #eee;
padding: 1em;
margin: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
/* The core anchor positioning logic */
#my-popover {
position: absolute;
inset: auto; /* Required to override default popover centering */
bottom: anchor(top); /* Position bottom of popover to top of anchor */
right: anchor(right); /* Align right edge of popover with right edge of anchor */
margin-bottom: 8px; /* A little space between popover and button */
}
</style>
</head>
<body>
<button
popovertarget="my-popover"
id="my-anchor-button"
style="anchor-name: --my-anchor;">
Click Me
</button>
<div
popover
id="my-popover"
style="position-anchor: --my-anchor;">
I should be anchored above the button, aligned to the right.
</div>
</body>
</html>
{% from "dialog.njk" import dialog %}
{% set footer %}
<button class="btn-outline" onclick="this.closest('dialog').close()">Cancel</button>
<button class="btn" onclick="this.closest('dialog').close()">Save changes</button>
{% endset %}
{% call dialog(
id="demo-dialog-edit-profile",
title="Edit profile",
description="Make changes to your profile here. Click save when you're done.",
trigger="Edit Profile",
trigger_attrs={"class": "btn-outline"},
dialog_attrs={"class": "w-full sm:max-w-[425px] max-h-[612px]"},
footer=footer
) %}
<form class="flex-col gap-4">
{% from "select.njk" import select %}
{{ select(
trigger_attrs={"class": "w-[180px]"},
is_combobox=true,
items=[
{
type: "group",
label: "Fruits",
items: [
{ type: "item", value: "apple", label: "Apple" },
{ type: "item", value: "banana", label: "Banana" },
{ type: "item", value: "blueberry", label: "Blueberry" },
{ type: "item", value: "pineapple", label: "Grapes" },
{ type: "item", value: "pineapple", label: "Pineapple" }
]
}
]
) }}
</form>
{% endcall %}
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "basecoat",
"version": "0.2.0-beta.1",
"version": "0.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "basecoat",
"version": "0.2.0-beta.1",
"version": "0.3.2",
"license": "MIT",
"workspaces": [
"packages/*"
@@ -4337,7 +4337,7 @@
},
"packages/cli": {
"name": "basecoat-cli",
"version": "0.2.0-beta.1",
"version": "0.3.2",
"license": "MIT",
"dependencies": {
"commander": "^13.1.0",
@@ -4350,7 +4350,7 @@
},
"packages/css": {
"name": "basecoat-css",
"version": "0.2.0-beta.1",
"version": "0.3.2",
"license": "MIT"
}
}
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "basecoat",
"version": "0.2.4",
"version": "0.3.3",
"description": "shadcn/ui for the rest of us",
"author": "hunvreus",
"type": "module",
@@ -21,8 +21,7 @@
"css",
"html",
"jinja",
"nunjucks",
"alpinejs"
"nunjucks"
],
"license": "MIT",
"repository": {
-1
View File
@@ -8,7 +8,6 @@ This package provides a Command Line Interface (CLI) to help you quickly scaffol
- For the components to function correctly, your project should have:
- [Tailwind CSS](https://tailwindcss.com/docs/installation) installed and configured.
- The `basecoat-css` package installed and imported (or `basecoat.css` manually added).
- [Alpine.js](https://alpinejs.dev/docs/installation) installed and initialized, as Basecoat components rely on it for interactivity.
## Usage
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "basecoat-cli",
"version": "0.2.4",
"version": "0.3.3",
"description": "Add Basecoat components to your project",
"author": "hunvreus",
"license": "MIT",
@@ -30,8 +30,7 @@
"css",
"html",
"jinja",
"nunjucks",
"alpinejs"
"nunjucks"
],
"repository": {
"type": "git",
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "basecoat-css",
"version": "0.2.4",
"version": "0.3.3",
"description": "Tailwind CSS for Basecoat components",
"author": {
"name": "hunvreus",
@@ -26,8 +26,7 @@
"css",
"html",
"jinja",
"nunjucks",
"alpinejs"
"nunjucks"
],
"repository": {
"type": "git",
@@ -43,6 +42,8 @@
"./css": "./dist/basecoat.css",
"./all": "./dist/js/all.js",
"./all.min": "./dist/js/all.min.js",
"./command": "./dist/js/command.js",
"./command.min": "./dist/js/command.min.js",
"./dropdown-menu": "./dist/js/dropdown-menu.js",
"./dropdown-menu.min": "./dist/js/dropdown-menu.min.js",
"./popover": "./dist/js/popover.js",
+1 -1
View File
@@ -121,7 +121,7 @@ async function build() {
// Create combined component files
console.log('Creating combined component files...');
const componentsToCombine = ['dropdown-menu.js', 'popover.js', 'select.js', 'sidebar.js', 'tabs.js', 'toast.js'];
const componentsToCombine = ['basecoat.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
+1 -1
View File
@@ -1,2 +1,2 @@
@import "tailwindcss";
@import "tailwindcss" source(none);
@import "./basecoat.css";
+271 -60
View File
@@ -146,10 +146,6 @@
border-radius: var(--scrollbar-radius);
}
}
/* Alpine.js */
[x-cloak] {
display: none !important;
}
}
/* Alert */
@@ -158,7 +154,8 @@
.alert-destructive {
@apply relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current;
h2 {
h2,
h3 {
@apply col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight;
}
section {
@@ -181,6 +178,30 @@
}
}
/* Badge */
@layer components {
.badge,
.badge-primary,
.badge-secondary,
.badge-destructive,
.badge-outline {
@apply inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden;
}
.badge,
.badge-primary {
@apply border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90;
}
.badge-secondary {
@apply border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90;
}
.badge-destructive {
@apply border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60;
}
.badge-outline {
@apply text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground;
}
}
/* Button */
@layer components {
.btn,
@@ -359,27 +380,44 @@
}
}
/* Badge */
@layer components {
.badge,
.badge-primary,
.badge-secondary,
.badge-destructive,
.badge-outline {
@apply inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden
/* Button Group */
.button-group {
@apply inline-flex w-fit items-stretch isolate;
> *:focus-visible,
> :is(.dropdown-menu, .popover, .select) > button:focus-visible {
@apply relative z-10;
}
.badge,
.badge-primary {
@apply border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90;
> hr[role='separator'] {
@apply w-0 h-auto self-stretch border border-input shrink-0 m-0;
}
.badge-secondary {
@apply border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90;
}
.badge-destructive {
@apply border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60;
}
.badge-outline {
@apply text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground;
&:not([data-orientation='vertical']) {
> *:not(:first-child),
> :is(.dropdown-menu, .popover, .select):not(:first-child) > button {
@apply rounded-l-none border-l-0;
}
> *:not(:last-child),
> :is(.dropdown-menu, .popover, .select):not(:last-child) > button {
@apply rounded-r-none;
}
}
&[data-orientation='vertical'] {
@apply flex-col;
> hr[role='separator'] {
@apply w-auto h-px;
}
> *:not(:first-child),
> :is(.dropdown-menu, .popover, .select):not(:first-child) > button {
@apply rounded-t-none border-t-0;
}
> *:not(:last-child),
> :is(.dropdown-menu, .popover, .select):not(:last-child) > button {
@apply rounded-b-none;
}
}
}
@@ -410,7 +448,7 @@
/* Checkbox */
@layer components {
.form input[type='checkbox']:not([role='switch']),
:is(.form, .field) input[type='checkbox']:not([role='switch']),
.input[type='checkbox']:not([role='switch']) {
@apply appearance-none border-input dark:bg-input/30 checked:bg-primary dark:checked:bg-primary checked:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50;
@@ -442,6 +480,115 @@
}
}
/* Command */
@layer components {
.command-dialog {
@apply inset-y-0 opacity-0 transition-all transition-discrete;
&:is([open],:popover-open) {
@apply opacity-100;
&::backdrop {
@apply opacity-100;
}
> * {
@apply scale-100;
}
@starting-style {
@apply opacity-0;
&::backdrop {
@apply opacity-0;
}
> * {
@apply scale-95;
}
}
}
&::backdrop {
@apply bg-black/50 opacity-0 transition-all transition-discrete;
}
> * {
@apply bg-background fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%_-_2rem)] -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-lg border shadow-lg sm:max-w-lg max-h-[min(640px,calc(100%_-_2rem))];
@apply transition-all scale-95;
> button,
> form[method='dialog'] {
@apply absolute top-4 right-4;
}
> button,
> form[method='dialog'] > button {
@apply ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4;
}
}
.command {
> header input {
@apply h-12;
}
[role='menu'] {
@apply px-2;
[role='menuitem'] {
@apply py-3 px-2 [&_svg]:size-5;
}
[role='heading'] {
@apply px-2 py-1.5 font-medium text-muted-foreground;
}
}
}
}
.command {
> header {
@apply flex items-center border-b px-3 gap-2;
svg {
@apply size-4 shrink-0 opacity-50;
}
input {
@apply placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50;
}
}
[role='menu'] {
@apply max-h-[300px] overflow-y-auto overflow-x-hidden p-1;
[role='menuitem'] {
@apply aria-hidden:hidden relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none aria-disabled:opacity-50 aria-disabled:pointer-events-none disabled:opacity-50 disabled:pointer-events-none w-full truncate [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4;
&.active {
@apply bg-accent text-accent-foreground;
}
}
[role='heading'] {
@apply text-muted-foreground flex px-2 py-1.5 text-xs font-medium;
}
[role='group']:not(:has([role='menuitem']:not([aria-hidden='true']))) {
@apply hidden;
}
[role='separator'] {
@apply border-border -mx-1 my-1;
}
&:not(:has([data-value]:not([aria-hidden='true'])))::before {
@apply flex items-center justify-center py-6 px-3 text-sm truncate -m-1;
}
&[data-empty]:not(:has([data-value]:not([aria-hidden='true'])))::before {
@apply content-[attr(data-empty)];
}
&:not([data-empty]):not(:has([data-value]:not([aria-hidden='true'])))::before {
@apply content-['No_results_found'];
}
}
&:not([data-command-initialized]) [role='menuitem'] {
@apply hover:bg-accent hover:text-accent-foreground;
}
&:has(> header input:not(:placeholder-shown)) [role='separator'] {
@apply hidden;
}
}
}
/* Dialog */
@layer components {
.dialog {
@@ -453,7 +600,7 @@
&::backdrop {
@apply opacity-100;
}
> article {
> * {
@apply scale-100;
}
@@ -463,7 +610,7 @@
&::backdrop {
@apply opacity-0;
}
> article {
> * {
@apply scale-95;
}
}
@@ -471,8 +618,8 @@
&::backdrop {
@apply bg-black/50 opacity-0 transition-all transition-discrete;
}
> article {
@apply bg-background fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border p-6 shadow-lg sm:max-w-lg max-h-[calc(100%-2rem)];
> * {
@apply bg-background fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%_-_2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border p-6 shadow-lg sm:max-w-lg max-h-[calc(100%_-_2rem)];
@apply transition-all scale-95;
> header {
@@ -491,11 +638,13 @@
> footer {
@apply flex flex-col-reverse gap-2 sm:flex-row sm:justify-end;
}
> button,
> form[method='dialog'] {
@apply absolute top-4 right-4;
> button {
@apply ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4;
}
}
> button,
> form[method='dialog'] > button {
@apply ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4;
}
}
}
@@ -516,7 +665,7 @@
@apply aria-hidden:hidden [&_svg]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none [&_svg]:shrink-0 [&_svg]:size-4 aria-disabled:opacity-50 aria-disabled:pointer-events-none disabled:opacity-50 disabled:pointer-events-none w-full truncate;
&:not([aria-disabled='true']) {
@apply focus-visible:bg-accent focus-visible:text-accent-foreground hover:bg-accent hover:text-accent-foreground;
@apply focus-visible:bg-accent focus-visible:text-accent-foreground;
}
&.active {
@@ -530,24 +679,75 @@
@apply border-border -mx-1 my-1;
}
}
&:not([data-dropdown-menu-initialized]) [data-popover] {
[role='menuitem'],
[role='menuitemcheckbox'],
[role='menuitemradio'] {
@apply hover:bg-accent hover:text-accent-foreground;
}
}
}
}
/* Field */
@layer components {
.fieldset {
@apply flex flex-col gap-6;
> legend {
@apply text-base font-medium mb-3;
}
}
.field {
@apply flex flex-col w-full gap-3 data-[invalid=true]:text-destructive [&>*]:w-full [&>.sr-only]:w-auto;
h2,
h3 {
@apply flex w-fit items-center gap-2 text-sm leading-snug font-medium;
}
[role="alert"] {
@apply text-sm text-destructive font-normal;
ul {
@apply ml-4 flex list-disc flex-col gap-1;
}
}
section {
@apply flex flex-1 flex-col gap-1.5 leading-snug;
}
&[data-orientation='horizontal'] {
@apply flex-row items-center [&>label]:flex-auto has-[>section]:items-start has-[>section]:[&>[type=checkbox],[type=radio]]:mt-px [&_p]:text-balance;
}
}
.fieldset legend + p,
.field > p,
.field section > p {
@apply text-muted-foreground text-sm leading-normal font-normal [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4;
}
.fieldset legend + p {
@apply -mt-1.5;
}
.field > p {
@apply last:mt-0 nth-last-2:-mt-1;
}
}
/* Input */
@layer components {
.form input[type='text'],
.form input[type='email'],
.form input[type='password'],
.form input[type='number'],
.form input[type='file'],
.form input[type='tel'],
.form input[type='url'],
.form input[type='search'],
.form input[type='date'],
.form input[type='datetime-local'],
.form input[type='month'],
.form input[type='week'],
.form input[type='time'],
:is(.form, .field) input[type='text'],
:is(.form, .field) input[type='email'],
:is(.form, .field) input[type='password'],
:is(.form, .field) input[type='number'],
:is(.form, .field) input[type='file'],
:is(.form, .field) input[type='tel'],
:is(.form, .field) input[type='url'],
:is(.form, .field) input[type='search'],
:is(.form, .field) input[type='date'],
:is(.form, .field) input[type='datetime-local'],
:is(.form, .field) input[type='month'],
:is(.form, .field) input[type='week'],
:is(.form, .field) input[type='time'],
.input[type='text'],
.input[type='email'],
.input[type='password'],
@@ -567,9 +767,16 @@
}
}
/* Kbd */
@layer components {
.kbd {
@apply bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none [&_svg:not([class*='size-'])]:size-3;
}
}
/* Label */
@layer components {
.form label,
:is(.form, .field) label,
.label {
@apply flex items-center gap-2 text-sm leading-none font-medium select-none peer-disabled:pointer-events-none peer-disabled:opacity-50;
@@ -654,7 +861,7 @@
/* Radio */
@layer components {
.form input[type='radio'],
:is(.form, .field) input[type='radio'],
.input[type='radio'] {
@apply appearance-none border-input text-primary 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:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 relative;
@@ -666,7 +873,7 @@
/* Range */
@layer components {
.form input[type='range'],
:is(.form, .field) input[type='range'],
.input[type='range'] {
@apply appearance-none flex items-center p-0 outline-none;
--slider-value: 20%;
@@ -715,7 +922,7 @@
/* Select */
@layer components {
.form select,
:is(.form, .field) select,
select.select {
@apply appearance-none border-input 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:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent pl-3 pr-9 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 h-9;
@apply bg-[image:var(--chevron-down-icon-50)] bg-no-repeat bg-position-[center_right_0.75rem] bg-size-[1rem];
@@ -737,6 +944,10 @@
&[aria-selected='true'] {
@apply bg-[image:var(--check-icon)] bg-no-repeat bg-position-[center_right_0.5rem] bg-size-[0.875rem];
}
&.active,
&:focus-visible {
@apply bg-accent text-accent-foreground;
}
}
[role='listbox'] [role='heading'] {
@apply flex text-muted-foreground px-2 py-1.5 text-xs;
@@ -767,11 +978,11 @@
@apply content-['No_results_found'];
}
}
[role='option']:hover,
&[data-select-initialized] [role='option'].active,
&[data-select-initialized] [role='option']:focus-visible {
@apply bg-accent text-accent-foreground;
[data-popover]:has(> header input:not(:placeholder-shown)) [role='separator'] {
@apply hidden;
}
&:not([data-select-initialized]) [data-popover] [role='option'] {
@apply hover:bg-accent hover:text-accent-foreground;
}
}
}
@@ -898,7 +1109,7 @@
/* Switch */
@layer components {
.form input[type='checkbox'][role='switch'],
:is(.form, .field) input[type='checkbox'][role='switch'],
.input[type='checkbox'][role='switch'] {
@apply appearance-none focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50;
@apply bg-input dark:bg-input/80 checked:bg-primary dark:checked:bg-primary;
@@ -945,7 +1156,7 @@
@apply bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px];
[role='tab'] {
@apply focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4;
@apply focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground dark:text-muted-foreground inline-flex h-[calc(100%_-_1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4;
&[aria-selected='true'] {
@apply bg-background dark:text-foreground dark:border-input dark:bg-input/30 shadow-sm;
@@ -960,7 +1171,7 @@
/* Textarea */
@layer components {
.form textarea,
:is(.form, .field) textarea,
.textarea {
@apply border-input placeholder:text-muted-foreground 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:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm;
}
@@ -995,7 +1206,7 @@
@apply font-medium tracking-tight;
}
p {
@apply text-muted-foreground;
@apply text-muted-foreground break-all;
}
}
footer {
@@ -1030,7 +1241,7 @@
@apply relative;
&:before {
@apply absolute content-[attr(data-tooltip)] bg-primary text-primary-foreground z-50 truncate max-w-xs w-fit rounded-md px-3 py-1.5 text-xs invisible opacity-0 scale-95 transition-all pointer-events-none;
@apply absolute content-[attr(data-tooltip)] bg-primary text-primary-foreground z-[60] truncate max-w-xs w-fit rounded-md px-3 py-1.5 text-xs invisible opacity-0 scale-95 transition-all pointer-events-none;
}
&:hover:before {
@apply visible opacity-100 scale-100;
+187
View File
@@ -0,0 +1,187 @@
{#
Renders a standalone command menu component with search and keyboard navigation.
@param id {string} [optional] - Unique identifier for the command component.
@param items {array} [optional] - Array of items to render (alternative to using caller).
@param placeholder {string} [optional] [default="Type a command or search..."] - Placeholder text for the search input.
@param empty_text {string} [optional] [default="No results found."] - Text displayed when no results match the search.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param input_attrs {object} [optional] - Additional HTML attributes for the search input.
@param menu_attrs {object} [optional] - Additional HTML attributes for the menu div.
#}
{% macro command(
id=None,
items=None,
placeholder="Type a command or search...",
empty_text="No results found.",
main_attrs={},
input_attrs={},
menu_attrs={}
) %}
{% set id = id or ("command-" ~ range(100000, 999999) | random | string) %}
<div
id="{{ id }}"
class="command {{ main_attrs.class }}"
aria-label="Command menu"
{% for key, value in main_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<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="{{ id }}-input"
placeholder="{{ placeholder }}"
autocomplete="off"
autocorrect="off"
spellcheck="false"
aria-autocomplete="list"
role="combobox"
aria-expanded="true"
aria-controls="{{ id }}-menu"
{% for key, value in input_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
</header>
<div
role="menu"
id="{{ id }}-menu"
aria-orientation="vertical"
data-empty="{{ empty_text }}"
class="scrollbar {{ menu_attrs.class }}"
{% for key, value in menu_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% if items and items|length > 0 %}
{{ render_command_items(items, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</div>
</div>
{% endmacro %}
{#
Renders a command dialog (modal command palette).
@param id {string} [optional] - Unique identifier for the command dialog.
@param items {array} [optional] - Array of items to render (alternative to using caller).
@param placeholder {string} [optional] [default="Type a command or search..."] - Placeholder text for the search input.
@param empty_text {string} [optional] [default="No results found."] - Text displayed when no results match the search.
@param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog element.
@param input_attrs {object} [optional] - Additional HTML attributes for the search input.
@param menu_attrs {object} [optional] - Additional HTML attributes for the menu div.
@param open {boolean} [optional] [default=False] - Whether the command dialog should be open initially.
#}
{% macro command_dialog(
id=None,
items=None,
placeholder="Type a command or search...",
empty_text="No results found.",
dialog_attrs={},
input_attrs={},
menu_attrs={},
open=False
) %}
{% set id = id or ("command-dialog-" ~ range(100000, 999999) | random | string) %}
<dialog
id="{{ id }}"
class="dialog command-dialog {{ dialog_attrs.class }}"
aria-label="Command menu"
{% if open %}open{% endif %}
onclick="if (event.target === this) this.close()"
{% for key, value in dialog_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<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="{{ id }}-input"
placeholder="{{ placeholder }}"
autocomplete="off"
autocorrect="off"
spellcheck="false"
aria-autocomplete="list"
role="combobox"
aria-expanded="true"
aria-controls="{{ id }}-menu"
{% for key, value in input_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
</header>
<div
role="menu"
id="{{ id }}-menu"
aria-orientation="vertical"
data-empty="{{ empty_text }}"
class="scrollbar {{ menu_attrs.class }}"
{% for key, value in menu_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% if items and items|length > 0 %}
{{ render_command_items(items, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</div>
<button type="button" aria-label="Close dialog" onclick="this.closest('dialog').close()">
<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-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
</dialog>
{% endmacro %}
{#
Renders a list of items for the command component.
@param items {array} - Array of items to render. Each item can be:
- { type: "item", label: "Text", content: "HTML", keywords: "search terms", disabled: true, attrs: {} }
- { type: "group", label: "Group Title", items: [...] }
- { type: "separator" }
Note: Use attrs to add data-label for alternative search text if needed.
@param parent_id_prefix {string} [optional] - The prefix for the item id.
#}
{% macro render_command_items(items, parent_id_prefix="items") %}
{% for item in items %}
{% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
{% if item.type == "group" %}
{% set group_label_id = item.id if item.id else "group-label-" + item_id %}
<div
role="group"
aria-labelledby="{{ group_label_id }}"
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
<span role="heading" id="{{ group_label_id }}">{{ item.label }}</span>
{{ render_command_items(item.items, item_id) if item.items }}
</div>
{% elif item.type == "separator" %}
<hr role="separator" />
{% elif item.type == "item" or not item.type %}
<div
id="{{ item_id }}"
role="menuitem"
{% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %}
{% if item.disabled %}aria-disabled="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{{ item.content | safe if item.content else item.label }}
</div>
{% endif %}
{% endfor %}
{% endmacro %}
+5 -5
View File
@@ -8,7 +8,7 @@
@param footer {string} [optional] - HTML content for the dialog footer.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
@param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog content article.
@param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog content container.
@param header_attrs {object} [optional] - Additional HTML attributes for the dialog header.
@param body_attrs {object} [optional] - Additional HTML attributes for the dialog body section.
@param footer_attrs {object} [optional] - Additional HTML attributes for the dialog footer.
@@ -40,7 +40,7 @@
{{ key }}="{{ value }}"
{% endfor %}
>
{{ trigger }}
{{ trigger | safe }}
</button>
{% endif %}
<dialog
@@ -48,12 +48,12 @@
class="dialog {{ dialog_attrs.class }}"
aria-labelledby="{{ id }}-title"
{% if description %}aria-describedby="{{ id }}-description"{% endif %}
{% if close_on_overlay_click %}onclick="this.close()"{% endif %}
{% if close_on_overlay_click %}onclick="if (event.target === this) this.close()"{% endif %}
{% for key, value in dialog_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<article {% if close_on_overlay_click %}onclick="event.stopPropagation()"{% endif %}>
<div>
{% if title or description %}
<header
{% for key, value in header_attrs %}
@@ -89,6 +89,6 @@
</button>
</form>
{% endif %}
</article>
</div>
</dialog>
{% endmacro %}
+3 -4
View File
@@ -4,8 +4,7 @@
@param id {string} [optional] [default="toaster"] - Unique identifier for the toaster container.
@param toasts {array} [optional] - An array of toast objects to render initially. See the <code>toast()</code> macro for more details.
@param main_attrs {object} [optional] - Additional HTML attributes for the main toaster container div.
@param is_fragment {boolean} [optional] [default=false] - If true, renders only the toast elements with hx-swap-oob="beforeend", suitable for htmx responses. Skips script and template inclusion.
@param attrs {object} [optional] - Additional HTML attributes for the main toaster container div.
#}
{% macro toaster(
id="toaster",
@@ -39,11 +38,11 @@
@param description {string} [optional] - The secondary description text.
@param action {object} [optional] - Defines an action button.
- label {string}: Button text.
- click {string}: JavaScript code to execute on click (e.g., '$dispatch(\'custom-event\')').
- onclick {string}: JavaScript code to execute on click.
- url {string}: URL for an anchor link button.
@param cancel {object} [optional] - Defines a cancel/dismiss button (similar structure to action).
- label {string}: Button text.
- click {string}: JavaScript code to execute on click (e.g., '$dispatch(\'custom-event\')').
- onclick {string}: JavaScript code to execute on click.
- url {string}: URL for an anchor link button.
#}
{% macro toast(
+99
View File
@@ -0,0 +1,99 @@
(() => {
const componentRegistry = {};
let observer = null;
const registerComponent = (name, selector, initFunction) => {
componentRegistry[name] = {
selector,
init: initFunction
};
};
const initComponent = (element, componentName) => {
const component = componentRegistry[componentName];
if (!component) return;
try {
component.init(element);
} catch (error) {
console.error(`Failed to initialize ${componentName}:`, error);
}
};
const initAllComponents = () => {
Object.entries(componentRegistry).forEach(([name, { selector, init }]) => {
document.querySelectorAll(selector).forEach(init);
});
};
const initNewComponents = (node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
Object.entries(componentRegistry).forEach(([name, { selector, init }]) => {
if (node.matches(selector)) {
init(node);
}
node.querySelectorAll(selector).forEach(init);
});
};
const startObserver = () => {
if (observer) return;
observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach(initNewComponents);
});
});
observer.observe(document.body, { childList: true, subtree: true });
};
const stopObserver = () => {
if (observer) {
observer.disconnect();
observer = null;
}
};
const reinitComponent = (componentName) => {
const component = componentRegistry[componentName];
if (!component) {
console.warn(`Component '${componentName}' not found in registry`);
return;
}
// Clear initialization flag for this component
const flag = `data-${componentName}-initialized`;
document.querySelectorAll(`[${flag}]`).forEach(el => {
el.removeAttribute(flag);
});
document.querySelectorAll(component.selector).forEach(component.init);
};
const reinitAll = () => {
// Clear all initialization flags using the registry
Object.entries(componentRegistry).forEach(([name, { selector }]) => {
const flag = `data-${name}-initialized`;
document.querySelectorAll(`[${flag}]`).forEach(el => {
el.removeAttribute(flag);
});
});
initAllComponents();
};
window.basecoat = {
register: registerComponent,
init: reinitComponent,
initAll: reinitAll,
start: startObserver,
stop: stopObserver
};
document.addEventListener('DOMContentLoaded', () => {
initAllComponents();
startObserver();
});
})();
+148
View File
@@ -0,0 +1,148 @@
(() => {
const initCommand = (container) => {
const input = container.querySelector('header input');
const menu = container.querySelector('[role="menu"]');
if (!input || !menu) {
const missing = [];
if (!input) missing.push('input');
if (!menu) missing.push('menu');
console.error(`Command component initialization failed. Missing element(s): ${missing.join(', ')}`, container);
return;
}
const allMenuItems = Array.from(menu.querySelectorAll('[role="menuitem"]'));
const menuItems = allMenuItems.filter(item =>
!item.hasAttribute('disabled') &&
item.getAttribute('aria-disabled') !== 'true'
);
let visibleMenuItems = [...menuItems];
let activeIndex = -1;
const setActiveItem = (index) => {
if (activeIndex > -1 && menuItems[activeIndex]) {
menuItems[activeIndex].classList.remove('active');
}
activeIndex = index;
if (activeIndex > -1) {
const activeItem = menuItems[activeIndex];
activeItem.classList.add('active');
if (activeItem.id) {
input.setAttribute('aria-activedescendant', activeItem.id);
} else {
input.removeAttribute('aria-activedescendant');
}
} else {
input.removeAttribute('aria-activedescendant');
}
};
const filterMenuItems = () => {
const searchTerm = input.value.trim().toLowerCase();
setActiveItem(-1);
visibleMenuItems = [];
allMenuItems.forEach(item => {
const itemText = (item.dataset.label || item.textContent).trim().toLowerCase();
const keywords = (item.dataset.keywords || '').toLowerCase();
const matches = itemText.includes(searchTerm) || keywords.includes(searchTerm);
item.setAttribute('aria-hidden', String(!matches));
if (matches && menuItems.includes(item)) {
visibleMenuItems.push(item);
}
});
if (visibleMenuItems.length > 0) {
setActiveItem(menuItems.indexOf(visibleMenuItems[0]));
visibleMenuItems[0].scrollIntoView({ block: 'nearest' });
}
};
input.addEventListener('input', filterMenuItems);
const handleKeyNavigation = (event) => {
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End'].includes(event.key)) {
return;
}
if (event.key === 'Enter') {
event.preventDefault();
if (activeIndex > -1) {
menuItems[activeIndex]?.click();
}
return;
}
if (visibleMenuItems.length === 0) return;
event.preventDefault();
const currentVisibleIndex = activeIndex > -1 ? visibleMenuItems.indexOf(menuItems[activeIndex]) : -1;
let nextVisibleIndex = currentVisibleIndex;
switch (event.key) {
case 'ArrowDown':
if (currentVisibleIndex < visibleMenuItems.length - 1) {
nextVisibleIndex = currentVisibleIndex + 1;
}
break;
case 'ArrowUp':
if (currentVisibleIndex > 0) {
nextVisibleIndex = currentVisibleIndex - 1;
} else if (currentVisibleIndex === -1) {
nextVisibleIndex = 0;
}
break;
case 'Home':
nextVisibleIndex = 0;
break;
case 'End':
nextVisibleIndex = visibleMenuItems.length - 1;
break;
}
if (nextVisibleIndex !== currentVisibleIndex) {
const newActiveItem = visibleMenuItems[nextVisibleIndex];
setActiveItem(menuItems.indexOf(newActiveItem));
newActiveItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
};
menu.addEventListener('mousemove', (event) => {
const menuItem = event.target.closest('[role="menuitem"]');
if (menuItem && visibleMenuItems.includes(menuItem)) {
const index = menuItems.indexOf(menuItem);
if (index !== activeIndex) {
setActiveItem(index);
}
}
});
menu.addEventListener('click', (event) => {
const clickedItem = event.target.closest('[role="menuitem"]');
if (clickedItem && visibleMenuItems.includes(clickedItem)) {
const dialog = container.closest('dialog.command-dialog');
if (dialog && !clickedItem.hasAttribute('data-keep-command-open')) {
dialog.close();
}
}
});
input.addEventListener('keydown', handleKeyNavigation);
if (visibleMenuItems.length > 0) {
setActiveItem(menuItems.indexOf(visibleMenuItems[0]));
visibleMenuItems[0].scrollIntoView({ block: 'nearest' });
}
container.dataset.commandInitialized = true;
container.dispatchEvent(new CustomEvent('basecoat:initialized'));
};
if (window.basecoat) {
window.basecoat.register('command', '.command:not([data-command-initialized])', initCommand);
}
})();
+36 -23
View File
@@ -29,7 +29,7 @@
setActiveItem(-1);
};
const openPopover = () => {
const openPopover = (initialSelection = false) => {
document.dispatchEvent(new CustomEvent('basecoat:popover', {
detail: { source: dropdownMenuComponent }
}));
@@ -40,8 +40,13 @@
!item.hasAttribute('disabled') &&
item.getAttribute('aria-disabled') !== 'true'
);
if (menuItems.length > 0) {
setActiveItem(0);
if (menuItems.length > 0 && initialSelection) {
if (initialSelection === 'first') {
setActiveItem(0);
} else if (initialSelection === 'last') {
setActiveItem(menuItems.length - 1);
}
}
};
@@ -64,7 +69,7 @@
if (isExpanded) {
closePopover();
} else {
openPopover();
openPopover(false);
}
});
@@ -77,9 +82,15 @@
}
if (!isExpanded) {
if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) {
if (['Enter', ' '].includes(event.key)) {
event.preventDefault();
openPopover();
openPopover(false);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
openPopover('first');
} else if (event.key === 'ArrowUp') {
event.preventDefault();
openPopover('last');
}
return;
}
@@ -91,11 +102,11 @@
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
nextIndex = Math.min(activeIndex + 1, menuItems.length - 1);
nextIndex = activeIndex === -1 ? 0 : Math.min(activeIndex + 1, menuItems.length - 1);
break;
case 'ArrowUp':
event.preventDefault();
nextIndex = Math.max(activeIndex - 1, 0);
nextIndex = activeIndex === -1 ? menuItems.length - 1 : Math.max(activeIndex - 1, 0);
break;
case 'Home':
event.preventDefault();
@@ -118,6 +129,20 @@
}
});
menu.addEventListener('mousemove', (event) => {
const menuItem = event.target.closest('[role^="menuitem"]');
if (menuItem && menuItems.includes(menuItem)) {
const index = menuItems.indexOf(menuItem);
if (index !== activeIndex) {
setActiveItem(index);
}
}
});
menu.addEventListener('mouseleave', () => {
setActiveItem(-1);
});
menu.addEventListener('click', (event) => {
if (event.target.closest('[role^="menuitem"]')) {
closePopover();
@@ -140,19 +165,7 @@
dropdownMenuComponent.dispatchEvent(new CustomEvent('basecoat:initialized'));
};
document.querySelectorAll('.dropdown-menu:not([data-dropdown-menu-initialized])').forEach(initDropdownMenu);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.matches('.dropdown-menu:not([data-dropdown-menu-initialized])')) {
initDropdownMenu(node);
}
node.querySelectorAll('.dropdown-menu:not([data-dropdown-menu-initialized])').forEach(initDropdownMenu);
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
if (window.basecoat) {
window.basecoat.register('dropdown-menu', '.dropdown-menu:not([data-dropdown-menu-initialized])', initDropdownMenu);
}
})();
+3 -15
View File
@@ -67,19 +67,7 @@
popoverComponent.dispatchEvent(new CustomEvent('basecoat:initialized'));
};
document.querySelectorAll('.popover:not([data-popover-initialized])').forEach(initPopover);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.matches('.popover:not([data-popover-initialized])')) {
initPopover(node);
}
node.querySelectorAll('.popover:not([data-popover-initialized])').forEach(initPopover);
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
if (window.basecoat) {
window.basecoat.register('popover', '.popover:not([data-popover-initialized])', initPopover);
}
})();
+72 -67
View File
@@ -1,7 +1,7 @@
(() => {
const initSelect = (selectComponent) => {
const trigger = selectComponent.querySelector(':scope > button');
const selectedValue = trigger.querySelector(':scope > span');
const selectedLabel = trigger.querySelector(':scope > span');
const popover = selectComponent.querySelector(':scope > [data-popover]');
const listbox = popover.querySelector('[role="listbox"]');
const input = selectComponent.querySelector(':scope > input[type="hidden"]');
@@ -16,21 +16,50 @@
return;
}
const options = Array.from(listbox.querySelectorAll('[role="option"]'));
const allOptions = Array.from(listbox.querySelectorAll('[role="option"]'));
const options = allOptions.filter(opt => opt.getAttribute('aria-disabled') !== 'true');
let visibleOptions = [...options];
let activeIndex = -1;
const setActiveOption = (index) => {
if (activeIndex > -1 && options[activeIndex]) {
options[activeIndex].classList.remove('active');
}
activeIndex = index;
if (activeIndex > -1) {
const activeOption = options[activeIndex];
activeOption.classList.add('active');
if (activeOption.id) {
trigger.setAttribute('aria-activedescendant', activeOption.id);
} else {
trigger.removeAttribute('aria-activedescendant');
}
} else {
trigger.removeAttribute('aria-activedescendant');
}
};
const hasTransition = () => {
const style = getComputedStyle(popover);
return parseFloat(style.transitionDuration) > 0 || parseFloat(style.transitionDelay) > 0;
};
const updateValue = (option) => {
const updateValue = (option, triggerEvent = true) => {
if (option) {
selectedValue.innerHTML = option.dataset.label || option.innerHTML;
selectedLabel.innerHTML = option.dataset.label || option.innerHTML;
input.value = option.dataset.value;
listbox.querySelector('[role="option"][aria-selected="true"]')?.removeAttribute('aria-selected');
option.setAttribute('aria-selected', 'true');
if (triggerEvent) {
const event = new CustomEvent('change', {
detail: { value: option.dataset.value },
bubbles: true
});
selectComponent.dispatchEvent(event);
}
}
};
@@ -41,7 +70,7 @@
const resetFilter = () => {
filter.value = '';
visibleOptions = [...options];
options.forEach(opt => opt.setAttribute('aria-hidden', 'false'));
allOptions.forEach(opt => opt.setAttribute('aria-hidden', 'false'));
};
if (hasTransition()) {
@@ -54,56 +83,39 @@
if (focusOnTrigger) trigger.focus();
popover.setAttribute('aria-hidden', 'true');
trigger.setAttribute('aria-expanded', 'false');
trigger.removeAttribute('aria-activedescendant');
if (activeIndex > -1) options[activeIndex]?.classList.remove('active');
activeIndex = -1;
setActiveOption(-1);
}
const selectOption = (option) => {
if (!option) return;
if (option.dataset.value != null) {
const oldValue = input.value;
const newValue = option.dataset.value;
if (newValue != null && newValue !== oldValue) {
updateValue(option);
}
closePopover();
const event = new CustomEvent('change', {
detail: { value: option.dataset.value },
bubbles: true
});
selectComponent.dispatchEvent(event);
};
const selectByValue = (value) => {
const option = options.find(opt => opt.dataset.value === value);
if (option) {
updateValue(option);
const event = new CustomEvent('change', {
detail: { value: option.dataset.value },
bubbles: true
});
selectComponent.dispatchEvent(event);
}
selectOption(option);
};
if (filter) {
const filterOptions = () => {
const searchTerm = filter.value.trim().toLowerCase();
if (activeIndex > -1) {
options[activeIndex].classList.remove('active');
trigger.removeAttribute('aria-activedescendant');
activeIndex = -1;
}
setActiveOption(-1);
visibleOptions = [];
options.forEach(option => {
allOptions.forEach(option => {
const optionText = (option.dataset.label || option.textContent).trim().toLowerCase();
const matches = optionText.includes(searchTerm);
option.setAttribute('aria-hidden', String(!matches));
if (matches) {
if (matches && options.includes(option)) {
visibleOptions.push(option);
}
});
@@ -113,9 +125,12 @@
}
let initialOption = options.find(opt => opt.dataset.value === input.value);
if (!initialOption && options.length > 0) initialOption = options[0];
if (!initialOption) {
initialOption = options.find(opt => opt.dataset.value !== undefined) ?? options[0];
}
updateValue(initialOption);
updateValue(initialOption, false);
const handleKeyNavigation = (event) => {
const isPopoverOpen = popover.getAttribute('aria-hidden') === 'false';
@@ -173,21 +188,31 @@
}
if (nextVisibleIndex !== currentVisibleIndex) {
if (currentVisibleIndex > -1) {
visibleOptions[currentVisibleIndex].classList.remove('active');
}
const newActiveOption = visibleOptions[nextVisibleIndex];
newActiveOption.classList.add('active');
activeIndex = options.indexOf(newActiveOption);
if (newActiveOption.id) {
trigger.setAttribute('aria-activedescendant', newActiveOption.id);
}
setActiveOption(options.indexOf(newActiveOption));
newActiveOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
};
listbox.addEventListener('mousemove', (event) => {
const option = event.target.closest('[role="option"]');
if (option && visibleOptions.includes(option)) {
const index = options.indexOf(option);
if (index !== activeIndex) {
setActiveOption(index);
}
}
});
listbox.addEventListener('mouseleave', () => {
const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
if (selectedOption) {
setActiveOption(options.indexOf(selectedOption));
} else {
setActiveOption(-1);
}
});
trigger.addEventListener('keydown', handleKeyNavigation);
if (filter) {
filter.addEventListener('keydown', handleKeyNavigation);
@@ -213,14 +238,7 @@
const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
if (selectedOption) {
if (activeIndex > -1) {
options[activeIndex]?.classList.remove('active');
}
activeIndex = options.indexOf(selectedOption);
selectedOption.classList.add('active');
if (selectedOption.id) {
trigger.setAttribute('aria-activedescendant', selectedOption.id);
}
setActiveOption(options.indexOf(selectedOption));
selectedOption.scrollIntoView({ block: 'nearest' });
}
};
@@ -260,20 +278,7 @@
selectComponent.dispatchEvent(new CustomEvent('basecoat:initialized'));
};
document.querySelectorAll('div.select:not([data-select-initialized])').forEach(initSelect);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.matches('div.select:not([data-select-initialized])')) {
initSelect(node);
}
node.querySelectorAll('div.select:not([data-select-initialized])').forEach(initSelect);
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
if (window.basecoat) {
window.basecoat.register('select', 'div.select:not([data-select-initialized])', initSelect);
}
})();
+3 -16
View File
@@ -98,20 +98,7 @@
sidebarComponent.dispatchEvent(new CustomEvent('basecoat:initialized'));
};
document.querySelectorAll('.sidebar:not([data-sidebar-initialized])').forEach(initSidebar);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.matches('.sidebar:not([data-sidebar-initialized])')) {
initSidebar(node);
}
node.querySelectorAll('.sidebar:not([data-sidebar-initialized])').forEach(initSidebar);
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
if (window.basecoat) {
window.basecoat.register('sidebar', '.sidebar:not([data-sidebar-initialized])', initSidebar);
}
})();
+3 -16
View File
@@ -57,20 +57,7 @@
tabsComponent.dispatchEvent(new CustomEvent('basecoat:initialized'));
};
document.querySelectorAll('.tabs:not([data-tabs-initialized])').forEach(initTabs);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.matches('.tabs:not([data-tabs-initialized])')) {
initTabs(node);
}
node.querySelectorAll('.tabs:not([data-tabs-initialized])').forEach(initTabs);
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
if (window.basecoat) {
window.basecoat.register('tabs', '.tabs:not([data-tabs-initialized])', initTabs);
}
})();
+5 -21
View File
@@ -99,7 +99,7 @@
clearTimeout(state.timeoutId);
toasts.delete(element);
if (document.activeElement) document.activeElement.blur();
if (element.contains(document.activeElement)) document.activeElement.blur();
element.setAttribute('aria-hidden', 'true');
element.addEventListener('transitionend', () => element.remove(), { once: true });
}
@@ -164,9 +164,6 @@
return template.content.firstChild;
}
const initialToaster = document.getElementById('toaster');
if (initialToaster) initToaster(initialToaster);
document.addEventListener('basecoat:toast', (event) => {
if (!toaster) {
console.error('Cannot create toast: toaster container not found on page.');
@@ -177,21 +174,8 @@
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);
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
if (window.basecoat) {
window.basecoat.register('toaster', '#toaster:not([data-toaster-initialized])', initToaster);
window.basecoat.register('toast', '.toast:not([data-toast-initialized])', initToast);
}
})();
+187
View File
@@ -0,0 +1,187 @@
{#
Renders a standalone command menu component with search and keyboard navigation.
@param id {string} [optional] - Unique identifier for the command component.
@param items {array} [optional] - Array of items to render (alternative to using caller).
@param placeholder {string} [optional] [default="Type a command or search..."] - Placeholder text for the search input.
@param empty_text {string} [optional] [default="No results found."] - Text displayed when no results match the search.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param input_attrs {object} [optional] - Additional HTML attributes for the search input.
@param menu_attrs {object} [optional] - Additional HTML attributes for the menu div.
#}
{% macro command(
id=None,
items=None,
placeholder="Type a command or search...",
empty_text="No results found.",
main_attrs={},
input_attrs={},
menu_attrs={}
) %}
{% set id = id or ("command-" + (range(100000, 999999) | random | string)) %}
<div
id="{{ id }}"
class="command {{ main_attrs.class }}"
aria-label="Command menu"
{% for key, value in main_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<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="{{ id }}-input"
placeholder="{{ placeholder }}"
autocomplete="off"
autocorrect="off"
spellcheck="false"
aria-autocomplete="list"
role="combobox"
aria-expanded="true"
aria-controls="{{ id }}-menu"
{% for key, value in input_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
</header>
<div
role="menu"
id="{{ id }}-menu"
aria-orientation="vertical"
data-empty="{{ empty_text }}"
class="scrollbar {{ menu_attrs.class }}"
{% for key, value in menu_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% if items and items.length > 0 %}
{{ render_command_items(items, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</div>
</div>
{% endmacro %}
{#
Renders a command dialog (modal command palette).
@param id {string} [optional] - Unique identifier for the command dialog.
@param items {array} [optional] - Array of items to render (alternative to using caller).
@param placeholder {string} [optional] [default="Type a command or search..."] - Placeholder text for the search input.
@param empty_text {string} [optional] [default="No results found."] - Text displayed when no results match the search.
@param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog element.
@param input_attrs {object} [optional] - Additional HTML attributes for the search input.
@param menu_attrs {object} [optional] - Additional HTML attributes for the menu div.
@param open {boolean} [optional] [default=false] - Whether the command dialog should be open initially.
#}
{% macro command_dialog(
id=None,
items=None,
placeholder="Type a command or search...",
empty_text="No results found.",
dialog_attrs={},
input_attrs={},
menu_attrs={},
open=false
) %}
{% set id = id or ("command-dialog-" + (range(100000, 999999) | random | string)) %}
<dialog
id="{{ id }}"
class="command-dialog {{ dialog_attrs.class }}"
aria-label="Command menu"
{% if open %}open{% endif %}
onclick="if (event.target === this) this.close()"
{% for key, value in dialog_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<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="{{ id }}-input"
placeholder="{{ placeholder }}"
autocomplete="off"
autocorrect="off"
spellcheck="false"
aria-autocomplete="list"
role="combobox"
aria-expanded="true"
aria-controls="{{ id }}-menu"
{% for key, value in input_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
</header>
<div
role="menu"
id="{{ id }}-menu"
aria-orientation="vertical"
data-empty="{{ empty_text }}"
class="scrollbar {{ menu_attrs.class }}"
{% for key, value in menu_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% if items and items.length > 0 %}
{{ render_command_items(items, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</div>
<button type="button" aria-label="Close dialog" onclick="this.closest('dialog').close()">
<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-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
</dialog>
{% endmacro %}
{#
Renders a list of items for the command component.
@param items {array} - Array of items to render. Each item can be:
- { type: "item", label: "Text", content: "HTML", keywords: "search terms", disabled: true, attrs: {} }
- { type: "group", label: "Group Title", items: [...] }
- { type: "separator" }
Note: Use attrs to add data-label for alternative search text if needed.
@param parent_id_prefix {string} [optional] - The prefix for the item id.
#}
{% macro render_command_items(items, parent_id_prefix="items") %}
{% for item in items %}
{% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
{% if item.type == "group" %}
{% set group_label_id = item.id if item.id else "group-label-" + item_id %}
<div
role="group"
aria-labelledby="{{ group_label_id }}"
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
<span role="heading" id="{{ group_label_id }}">{{ item.label }}</span>
{{ render_command_items(item.items, item_id) if item.items }}
</div>
{% elif item.type == "separator" %}
<hr role="separator" />
{% elif item.type == "item" or not item.type %}
<div
id="{{ item_id }}"
role="menuitem"
{% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %}
{% if item.disabled %}aria-disabled="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% endif %}
>
{{ item.content | safe if item.content else item.label }}
</div>
{% endif %}
{% endfor %}
{% endmacro %}
+7 -9
View File
@@ -8,7 +8,7 @@
@param footer {string} [optional] - HTML content for the dialog footer.
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
@param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog content article.
@param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog content container.
@param header_attrs {object} [optional] - Additional HTML attributes for the dialog header.
@param body_attrs {object} [optional] - Additional HTML attributes for the dialog body section.
@param footer_attrs {object} [optional] - Additional HTML attributes for the dialog footer.
@@ -40,7 +40,7 @@
{{ key }}="{{ value }}"
{% endfor %}
>
{{ trigger }}
{{ trigger | safe }}
</button>
{% endif %}
<dialog
@@ -48,12 +48,12 @@
class="dialog {{ dialog_attrs.class }}"
aria-labelledby="{{ id }}-title"
{% if description %}aria-describedby="{{ id }}-description"{% endif %}
{% if close_on_overlay_click %}onclick="this.close()"{% endif %}
{% if close_on_overlay_click %}onclick="if (event.target === this) this.close()"{% endif %}
{% for key, value in dialog_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<article {% if close_on_overlay_click %}onclick="event.stopPropagation()"{% endif %}>
<div>
{% if title or description %}
<header
{% for key, value in header_attrs %}
@@ -83,12 +83,10 @@
</footer>
{% endif %}
{% if close_button %}
<form method="dialog">
<button aria-label="Close dialog">
<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-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
<button type="button" aria-label="Close dialog" onclick="this.closest('dialog').close()">
<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-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</form>
{% endif %}
</article>
</div>
</dialog>
{% endmacro %}
+3 -4
View File
@@ -4,8 +4,7 @@
@param id {string} [optional] [default="toaster"] - Unique identifier for the toaster container.
@param toasts {array} [optional] - An array of toast objects to render initially. See the <code>toast()</code> macro for more details.
@param main_attrs {object} [optional] - Additional HTML attributes for the main toaster container div.
@param is_fragment {boolean} [optional] [default=false] - If true, renders only the toast elements with hx-swap-oob="beforeend", suitable for htmx responses. Skips script and template inclusion.
@param attrs {object} [optional] - Additional HTML attributes for the main toaster container div.
#}
{% macro toaster(
id="toaster",
@@ -39,11 +38,11 @@
@param description {string} [optional] - The secondary description text.
@param action {object} [optional] - Defines an action button.
- label {string}: Button text.
- click {string}: JavaScript code to execute on click (e.g., '$dispatch(\'custom-event\')').
- onclick {string}: JavaScript code to execute on click.
- url {string}: URL for an anchor link button.
@param cancel {object} [optional] - Defines a cancel/dismiss button (similar structure to action).
- label {string}: Button text.
- click {string}: JavaScript code to execute on click (e.g., '$dispatch(\'custom-event\')').
- onclick {string}: JavaScript code to execute on click.
- url {string}: URL for an anchor link button.
#}
{% macro toast(