mirror of
https://github.com/hunvreus/basecoat.git
synced 2026-11-11 12:48:19 +00:00
Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee63071f87 | ||
|
|
def943d74e | ||
|
|
f43c20948b | ||
|
|
3ec06205a0 | ||
|
|
06e7c1ec84 | ||
|
|
09f868d474 | ||
|
|
b9274702d3 | ||
|
|
83c09c1197 | ||
|
|
36bca0c6a7 | ||
|
|
167b98e573 | ||
|
|
4035870578 | ||
|
|
dea0602b03 | ||
|
|
19941561d3 | ||
|
|
f708994df9 | ||
|
|
6fcbb31e30 | ||
|
|
0dfc5e061c | ||
|
|
c771c8256b | ||
|
|
2adb4c4667 | ||
|
|
cbb1119699 | ||
|
|
88db2e84ce | ||
|
|
a7f6f717d1 | ||
|
|
91c3932fe7 | ||
|
|
f977492bf6 | ||
|
|
21ba8ecb9e | ||
|
|
c0bf373890 | ||
|
|
77680f9e2b | ||
|
|
2afa2ae02e | ||
|
|
23585e3161 | ||
|
|
7e3324edf2 | ||
|
|
cda08f3c95 | ||
|
|
0b46e05a29 | ||
|
|
7322220788 | ||
|
|
4c86ce9bfc | ||
|
|
bff89833d4 | ||
|
|
770b12f82f | ||
|
|
b8ac469755 | ||
|
|
14abbdc013 | ||
|
|
f367ebac2a | ||
|
|
b8d8b1741c | ||
|
|
f82b1d79d4 | ||
|
|
eed1938d2f | ||
|
|
e5aed697c8 | ||
|
|
0c16664e4b | ||
|
|
ab44d6d48a | ||
|
|
15473865b4 | ||
|
|
6148716f68 | ||
|
|
045d13cd49 | ||
|
|
2962e980eb | ||
|
|
530224c587 | ||
|
|
382b8f6fa2 | ||
|
|
d644cb9efd | ||
|
|
1c1a413114 | ||
|
|
1e1682df21 | ||
|
|
d280e21aac | ||
|
|
a03e225bd7 | ||
|
|
bd23745393 | ||
|
|
29600bd3ac | ||
|
|
97a53f1a67 | ||
|
|
ea4fd16b00 | ||
|
|
c5db76d677 | ||
|
|
246aa65860 | ||
|
|
150c20fd67 | ||
|
|
81acdd18cc | ||
|
|
16219ccae6 | ||
|
|
a2e9775a2c | ||
|
|
2a43d10da9 | ||
|
|
92ebe490b3 | ||
|
|
4a44656fd9 | ||
|
|
608e3b2197 | ||
|
|
a1a74c220f | ||
|
|
55e3c78131 | ||
|
|
113d34a842 | ||
|
|
972719f08f | ||
|
|
d125153921 | ||
|
|
2389133e12 | ||
|
|
3f12f91765 | ||
|
|
ae4019a713 |
+2
-1
@@ -9,6 +9,7 @@ export default async function(eleventyConfig) {
|
||||
eleventyConfig.setInputDirectory("./docs/src");
|
||||
eleventyConfig.addPassthroughCopy({"docs/src/assets": "assets"});
|
||||
eleventyConfig.addPassthroughCopy({ "src/js": "assets/js" });
|
||||
eleventyConfig.addPassthroughCopy({ "docs/src/llms.txt": "llms.txt" });
|
||||
eleventyConfig.addPlugin(eleventyLucideicons);
|
||||
eleventyConfig.addShortcode("fetchCode", function(filePath) {
|
||||
const absolutePath = path.resolve(process.cwd(), filePath);
|
||||
@@ -43,4 +44,4 @@ export default async function(eleventyConfig) {
|
||||
{ autoescape: true }
|
||||
);
|
||||
eleventyConfig.setLibrary("njk", nunjucksEnvironment);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
docs/src/assets/styles.css merge=theirs
|
||||
@@ -1 +0,0 @@
|
||||
github: [hunvreus]
|
||||
@@ -1,5 +1,6 @@
|
||||
.*
|
||||
!.gitignore
|
||||
!.gitattributes
|
||||
!.eleventy.js
|
||||
|
||||
_site/
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# 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)
|
||||
|
||||
## Adding new components
|
||||
|
||||
Before creating a component, ask:
|
||||
1. Does shadcn/ui have it? → Port it
|
||||
2. Is it pure composition? → Document pattern, no component
|
||||
3. Does it need JS behavior? → Create component
|
||||
4. Is it just CSS? → Add to basecoat.css, document usage
|
||||
|
||||
Examples:
|
||||
- Field/Fieldset: CSS-only patterns (no JS component)
|
||||
- Empty/Item/Input Group: Pure composition (docs only)
|
||||
- Command/Select: JS behavior required (full component)
|
||||
|
||||
## 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.
|
||||
|
||||
## Git workflow
|
||||
|
||||
- `.gitattributes` configures merge strategies for generated files
|
||||
- `docs/src/assets/styles.css` auto-resolves to incoming changes (regenerated by Tailwind)
|
||||
- Always run `npm run build` before publishing packages
|
||||
|
||||
## Publishing packages
|
||||
|
||||
1. Bump version in root `package.json`
|
||||
2. Run `npm run build` to generate dist files
|
||||
3. Run `npm publish --workspaces` to publish both packages
|
||||
|
||||
## Package references
|
||||
|
||||
- `packages/css` usage and details: see `packages/css/README.md`.
|
||||
- `packages/cli` usage and details: see `packages/cli/README.md`.
|
||||
@@ -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!
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
## Now
|
||||
|
||||
- [ ] Add Drawer component
|
||||
- [ ] Make utility components (e.g., Item) into proper components (i.e., .item)
|
||||
- [ ] Split CSS into components
|
||||
|
||||
## Next
|
||||
|
||||
- [ ] Make Command load dynamic items (e.g. search results from remote endpoint)
|
||||
- [ ] Make Combobox load dynamic items (e.g. search results from remote endpoint)
|
||||
- [ ] Add Toggle component
|
||||
- [ ] Add Carousel component
|
||||
- [ ] Add Sheet component
|
||||
- [ ] Add Separator component
|
||||
- [ ] Adding Menubar component
|
||||
|
||||
## Later
|
||||
|
||||
- [ ] Add Calendar component (https://flatpickr.js.org/)
|
||||
- [ ] Finish Chart component (https://www.chartjs.org/)
|
||||
+2
-2
@@ -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;
|
||||
@@ -50,7 +50,7 @@
|
||||
@apply border-t pt-3 mt-3 first:mt-0;
|
||||
}
|
||||
dd {
|
||||
@apply mt-2;
|
||||
@apply mt-2 space-y-2;
|
||||
}
|
||||
dl {
|
||||
@apply mt-4 border-b-0 pb-0 pl-8 my-0;
|
||||
|
||||
@@ -69,12 +69,14 @@
|
||||
{# CSS #}
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
{# JS #}
|
||||
<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>
|
||||
<script src="/assets/js/basecoat.js?v={{ pkg.version }}" defer></script>
|
||||
<script src="/assets/js/command.js?v={{ pkg.version }}" defer></script>
|
||||
<script src="/assets/js/dropdown-menu.js?v={{ pkg.version }}" defer></script>
|
||||
<script src="/assets/js/popover.js?v={{ pkg.version }}" defer></script>
|
||||
<script src="/assets/js/select.js?v={{ pkg.version }}" defer></script>
|
||||
<script src="/assets/js/sidebar.js?v={{ pkg.version }}" defer></script>
|
||||
<script src="/assets/js/tabs.js?v={{ pkg.version }}" defer></script>
|
||||
<script src="/assets/js/toast.js?v={{ pkg.version }}" defer></script>
|
||||
{# HTMX #}
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
{# Highlight.js #}
|
||||
@@ -94,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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% macro code_block(code, language="html", class="my-4") %}
|
||||
<div class="relative {{ class }}">
|
||||
<pre 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>
|
||||
<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="
|
||||
@@ -16,7 +16,7 @@
|
||||
"
|
||||
class="btn-icon-ghost size-8 absolute right-2.5 top-2 text-muted-foreground hover:text-foreground group"
|
||||
>
|
||||
{% lucide "clipboard", {"class": "group-[.copied]:hidden" } %}
|
||||
{% lucide "copy", {"class": "group-[.copied]:hidden" } %}
|
||||
{% lucide "check", {"class": "hidden group-[.copied]:block" } %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -61,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 %}
|
||||
@@ -86,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 }
|
||||
]}
|
||||
@@ -116,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
|
||||
) }}
|
||||
|
||||
+1714
-562
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ 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 class="accordion">
|
||||
@@ -72,4 +72,4 @@ toc:
|
||||
|
||||
<h2 id="usage"><a href="#usage">Usage</a></h2>
|
||||
|
||||
<p class="prose">Basecoat animates <code><details></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><details></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>
|
||||
@@ -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><dialog></code> or the <code><article></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>
|
||||
|
||||
@@ -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"><svg></code> <span class="badge-secondary">Optional</span></dt>
|
||||
<dd>The icon.</dd>
|
||||
<dt><code class="highlight language-html"><h2></code></dt>
|
||||
<dd>The title.</dd>
|
||||
<dt><code class="highlight language-html"><section></code> <span class="badge-secondary">Optional</span></dt>
|
||||
<dd>The description.</dd>
|
||||
<dt><code class="highlight language-html"><div class="alert"></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"><svg></code> <span class="badge-secondary">Optional</span></dt>
|
||||
<dd>The icon.</dd>
|
||||
<dt><code class="highlight language-html"><h2></code></dt>
|
||||
<dd>The title.</dd>
|
||||
<dt><code class="highlight language-html"><section></code> <span class="badge-secondary">Optional</span></dt>
|
||||
<dd>The description.</dd>
|
||||
</dl>
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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><img></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><img></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") }}
|
||||
@@ -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>
|
||||
|
||||
@@ -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") }}
|
||||
@@ -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><div role="group" class="button-group"></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">Large</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><hr role="separator"></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) }}
|
||||
@@ -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>
|
||||
@@ -60,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><head></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">
|
||||
@@ -117,7 +118,14 @@ toc:
|
||||
</ul>
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><div role="option" data-value="{ VALUE }"></code></dt>
|
||||
<dd>Option that can be selected.Should have a unique id if you use the <code>aria-activedescendant</code> attribute on the trigger.</dd>
|
||||
<dd>Option that can be selected. Should have a unique id if you use the <code>aria-activedescendant</code> attribute on the trigger. Optional attributes:
|
||||
<ul>
|
||||
<li><code>data-filter</code>: custom text used for filtering (if different from displayed content, e.g., <code>data-filter="united states"</code>)</li>
|
||||
<li><code>data-keywords</code>: additional keywords for enhanced filtering (e.g., <code>data-keywords="bread fruit"</code>)</li>
|
||||
<li><code>data-force</code>: forces the option to always display, regardless of filter (e.g., <code>data-force</code>)</li>
|
||||
<li><code>aria-disabled="true"</code>: marks option as disabled (excluded from selection and keyboard navigation)</li>
|
||||
</ul>
|
||||
</dd>
|
||||
<dt><code class="highlight language-html"><hr role="separator"></code> <span class="badge-secondary">Optional</span></dt>
|
||||
<dd>Separator between groups/options.</dd>
|
||||
<dt><code class="highlight language-html"><div role="group"></code> <span class="badge-secondary">Optional</span></dt>
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
---
|
||||
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" onclick="console.log('Open calendar')">
|
||||
{% lucide "calendar" %}
|
||||
<span>Calendar</span>
|
||||
</div>
|
||||
<div role="menuitem" onclick="console.log('Search emoji')">
|
||||
{% lucide "smile" %}
|
||||
<span>Search Emoji</span>
|
||||
</div>
|
||||
<div role="menuitem" 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" onclick="console.log('Open profile')" data-filter="Profile">
|
||||
{% lucide "user" %}
|
||||
<span>Profile</span>
|
||||
<kbd class="ml-auto text-muted-foreground bg-transparent tracking-widest">⌘P</kbd>
|
||||
</div>
|
||||
<div role="menuitem" onclick="console.log('Open billing')" data-filter="Billing">
|
||||
{% lucide "credit-card" %}
|
||||
<span>Billing</span>
|
||||
<kbd class="ml-auto text-muted-foreground bg-transparent tracking-widest">⌘B</kbd>
|
||||
</div>
|
||||
<div role="menuitem" onclick="console.log('Open settings')" data-filter="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><head></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><dialog class="command-dialog"></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"><dialog class="command-dialog"></code> <span class="badge-secondary">Optional</span></dt>
|
||||
<dd>For dialog variant, wraps a <code><div class="command"></code> to display as a modal. Uses native <code><dialog></code> behavior and requires <code>aria-label</code> or <code>aria-labelledby</code> for accessibility.
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><div class="command"></code></dt>
|
||||
<dd>Wraps around the entire command component. Can be used standalone or inside a dialog.
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><header></code></dt>
|
||||
<dd>Contains the search input to filter command options.
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><input type="text"></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"><div role="menu"></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"><div role="menuitem"></code> or <code class="highlight language-html"><a role="menuitem"></code></dt>
|
||||
<dd>Command item that triggers an action when clicked. Use <code><a></code> for navigation links, or <code><div></code> for actions. 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>href</code>: If present on an <code><a></code>, the item navigates to that URL</li>
|
||||
<li><code>data-filter</code>: custom text used for filtering (if different from displayed content, e.g., <code>data-filter="calendar event"</code>)</li>
|
||||
<li><code>data-keywords</code>: additional keywords for enhanced filtering (e.g., <code>data-keywords="calendar event date"</code>)</li>
|
||||
<li><code>data-force</code>: forces the item to always display, regardless of filter (e.g., <code>data-force</code>)</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"><hr role="separator"></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"><div role="group"></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"><span role="heading"></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">
|
||||
<svg>...</svg>
|
||||
<span>Calendar</span>
|
||||
</div>
|
||||
<div role="menuitem" data-keywords="config preferences">
|
||||
<svg>...</svg>
|
||||
<span>Settings</span>
|
||||
</div>
|
||||
{% endcall %}{% endraw %}{% endset %}
|
||||
{{ code_block(raw_code, "jinja") }}
|
||||
|
||||
<section class="prose">
|
||||
<p>You can also use the <code>items</code> parameter to pass an array of items:</p>
|
||||
</section>
|
||||
|
||||
{% set icon_calendar %}{% lucide "calendar" %}{% endset %}
|
||||
{% set icon_settings %}{% lucide "settings" %}{% endset %}
|
||||
{% set raw_code_items %}{% raw %}{% set icon_calendar %}{% lucide "calendar" %}{% endset %}
|
||||
{% set icon_settings %}{% lucide "settings" %}{% endset %}
|
||||
{% set items = [
|
||||
{ type: "group", label: "Suggestions", items: [
|
||||
{ label: "Calendar", icon: icon_calendar, keywords: "date event" },
|
||||
{ label: "Search Emoji", icon: icon_settings },
|
||||
{ label: "Calculator", disabled: true }
|
||||
]},
|
||||
{ type: "separator" },
|
||||
{ type: "group", label: "Settings", items: [
|
||||
{ label: "Profile", url: "/profile", icon: icon_settings, keywords: "user account" },
|
||||
{ label: "Billing", url: "/billing", icon: icon_settings },
|
||||
{ label: "Settings", url: "/settings", icon: icon_settings }
|
||||
]}
|
||||
] %}
|
||||
|
||||
{% call command(items=items) %}{% endcall %}{% endraw %}{% endset %}
|
||||
{{ code_block(raw_code_items, "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) }}
|
||||
@@ -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"><article></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><dialog></code>.
|
||||
<dt><code class="highlight language-html"><div></code></dt>
|
||||
<dd>Wraps the content of the dialog.
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><header></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"><footer></code> <span class="badge-secondary">Optional</span></dt>
|
||||
<dd>Usually contains actions.</dd>
|
||||
<dt><code class="highlight language-html"><form method="dialog"></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"><button type="button" onclick="this.closest('dialog').close()"></code> <span class="badge-secondary">Optional</span></dt>
|
||||
<dd>A button to close the dialog. Alternatively, you can wrap a button in a <code><form method="dialog"></code> element.</dd>
|
||||
</dl>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
@@ -88,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><head></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">
|
||||
|
||||
@@ -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><header></code>, <code><h3></code>, <code><p></code>, and <code><button></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 [&_img]:ring-background [&_img]:ring-2 [&_img]:grayscale [&_img]:size-12 [&_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>
|
||||
<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) }}
|
||||
@@ -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><div role="group" class="field"></code> for individual field containers (label, input, helper text, errors)</li>
|
||||
<li><code><fieldset class="fieldset"></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"><div role="group" class="field"></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"><label></code></dt>
|
||||
<dd>Label for the input.</dd>
|
||||
<dt><code class="highlight language-html"><input></code>, <code><select></code> or <code><textarea></code></dt>
|
||||
<dd>Form input.</dd>
|
||||
<dt><code class="highlight language-html"><p></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"><p role="alert"></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"><section></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"><fieldset class="fieldset"></code></dt>
|
||||
<dd>Container for grouping related fields.
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><legend></code></dt>
|
||||
<dd>Heading for the fieldset.</dd>
|
||||
<dt><code class="highlight language-html"><p></code> <span class="badge-secondary">Optional</span></dt>
|
||||
<dd>Description text. When placed directly after <code><legend></code>, automatically styled as muted text.</dd>
|
||||
<dt><code class="highlight language-html"><div role="group" class="field"></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><fieldset class="fieldset"></code> and <code><legend></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") }}
|
||||
@@ -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>
|
||||
|
||||
@@ -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") }}
|
||||
@@ -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><article></code> or <code><a></code>, apply flex layout, and compose with standard HTML elements like <code><h3></code>, <code><p></code>, <code><img></code>, or <code><button></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><a></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") }}
|
||||
@@ -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><kbd></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) }}
|
||||
@@ -71,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><head></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">
|
||||
@@ -102,8 +103,8 @@ toc:
|
||||
<li><code>onclick</code>: a simple function to update <code>aria-expanded</code>.</li>
|
||||
</ul>
|
||||
</dd>
|
||||
<dt><code class="highlight language-html"><div popover class="popover" id="{ POPOVER_ID }"></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"><div data-popover class="popover" id="{ POPOVER_ID }"></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>
|
||||
|
||||
@@ -170,4 +171,4 @@ toc:
|
||||
</form>
|
||||
</div>
|
||||
{% endcall %}{% endraw %}{% endset %}
|
||||
{{ code_block(raw_code, "jinja") }}
|
||||
{{ code_block(raw_code, "jinja") }}
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
+194
-26
@@ -32,6 +32,8 @@ toc:
|
||||
id: example-disabled
|
||||
- label: With icon
|
||||
id: example-with-icon
|
||||
- label: Multiple
|
||||
id: example-multiple
|
||||
---
|
||||
|
||||
{% from "macros/code_preview.njk" import code_preview %}
|
||||
@@ -53,14 +55,15 @@ toc:
|
||||
{{ select(
|
||||
trigger_attrs={"class": "w-[180px]"},
|
||||
is_combobox=true,
|
||||
multiple=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", label: "Apple", attrs: {"data-force": "true"} },
|
||||
{ type: "item", label: "Banana", attrs: {"data-keywords": "bread"} },
|
||||
{ type: "item", label: "Blueberry" },
|
||||
{ type: "item", value: "pineapple", label: "Grapes" },
|
||||
{ type: "item", value: "pineapple", label: "Pineapple" }
|
||||
]
|
||||
@@ -92,7 +95,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><head></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">
|
||||
@@ -119,44 +123,69 @@ toc:
|
||||
<section class="prose">
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><div class="select"></code></dt>
|
||||
<dd>Wraps around the entire component.
|
||||
<dd>
|
||||
<p>Wraps around the entire component. Can have the following attributes:</p>
|
||||
<ul>
|
||||
<li><code>data-placeholder="{ TEXT }"</code> <span class="badge-secondary">Optional</span>: placeholder text shown when no options are selected (multiselect only).</li>
|
||||
</ul>
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><button type="button" popovertarget="{ POPOVER_ID }"></code></dt>
|
||||
<dt><code class="highlight language-html"><button type="button"></code></dt>
|
||||
<dd>
|
||||
<p>The trigger to open the popover, can also have the following attributes:</p>
|
||||
<p>The trigger to open the popover. Should have the following attributes:</p>
|
||||
<ul>
|
||||
<li><code>id="{BUTTON_ID}"</code>: linked to by the <code>aria-labelledby</code> attribute of the listbox.</li>
|
||||
<li><code>aria-haspopup="listbox"</code>: indicates that the button opens a listbox.</li>
|
||||
<li><code>aria-controls="{ LISTBOX_ID }"</code>: points to the listbox's id.</li>
|
||||
<li><code>aria-expanded="false"</code>: tracks the popover's state.</li>
|
||||
<li><code>aria-activedescendant="{ OPTION_ID }"</code>: points to the active option's id.</li>
|
||||
<li><code>aria-activedescendant="{ OPTION_ID }"</code> <span class="badge-secondary">Optional</span>: points to the active option's id.</li>
|
||||
</ul>
|
||||
</dd>
|
||||
<dt><code class="highlight language-html"><div popover class="popover" id="{ POPOVER_ID }"></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.
|
||||
<dt><code class="highlight language-html"><div data-popover aria-hidden="true" id="{ POPOVER_ID }"></code></dt>
|
||||
<dd>The popover content. You can set up the side and alignment using the <code>data-side</code> and <code>data-align</code> attributes (see <a href="/components/popover">Popover</a> component).
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><div role="listbox"></code></dt>
|
||||
<dd>The listbox containing the options. Should have the following attributes:
|
||||
<dd>
|
||||
<p>The listbox containing the options. Should have the following attributes:</p>
|
||||
<ul>
|
||||
<li><code>id="{ LISTBOX_ID }"</code>: refered to by the <code>aria-controls</code> attribute of the trigger.</li>
|
||||
<li><code>aria-labelledby="{ BUTTON_ID }"</code>: linked to by the button's <code>id</code> attribute.</li>
|
||||
<li><code>aria-multiselectable="true"</code> <span class="badge-secondary">Optional</span>: enables multiple selection mode.</li>
|
||||
</ul>
|
||||
<dl>
|
||||
<dt><code class="highlight language-html"><div role="option" data-value="{ VALUE }"></code></dt>
|
||||
<dd>Option that can be selected.Should have a unique id if you use the <code>aria-activedescendant</code> attribute on the trigger.</dd>
|
||||
<dd>
|
||||
<p>Option that can be selected. Should have the following attributes:</p>
|
||||
<ul>
|
||||
<li><code>id="{ OPTION_ID }"</code> <span class="badge-secondary">Optional</span>: unique id for this option (needed if you use <code>aria-activedescendant</code> on the trigger).</li>
|
||||
<li><code>data-value="{ VALUE }"</code>: the value for this option.</li>
|
||||
<li><code>data-label="{ LABEL }"</code> <span class="badge-secondary">Optional</span>: the text label to use for this option when selected in multiple mode. If not provided, the option's text content will be used (HTML stripped).</li>
|
||||
<li><code>aria-selected="true"</code> <span class="badge-secondary">Optional</span>: indicates this option is selected.</li>
|
||||
</ul>
|
||||
</dd>
|
||||
<dt><code class="highlight language-html"><hr role="separator"></code> <span class="badge-secondary">Optional</span></dt>
|
||||
<dd>Separator between groups/options.</dd>
|
||||
<dt><code class="highlight language-html"><div role="group"></code> <span class="badge-secondary">Optional</span></dt>
|
||||
<dd>Group of options, can have a <code>aria-labelledby</code> attribute to link to a heading.</dd>
|
||||
<dt><code class="highlight language-html"><span role="heading"></code></dt>
|
||||
<dt><code class="highlight language-html"><span role="heading"></code> <span class="badge-secondary">Optional</span></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>
|
||||
</dl>
|
||||
</dd>
|
||||
<dt><code class="highlight language-html"><input type="hidden" name="{ NAME }" value="{ VALUE }"></code> <span class="badge-secondary">Optional</span></dt>
|
||||
<dd>The hidden input that holds the value of the field (if needed).</dd>
|
||||
<dt><code class="highlight language-html"><input type="hidden" name="{ NAME }" value="{ VALUE }"></code></dt>
|
||||
<dd>
|
||||
<p>The hidden input that holds the selected value.</p>
|
||||
<p>For single-select: contains the selected option's value as a string.</p>
|
||||
<p>For multiselect: contains a <strong>JSON array</strong> of selected values (e.g., <code>["apple","banana"]</code>). When no options are selected, contains an empty array (<code>[]</code>).</p>
|
||||
<p><strong>Backend handling:</strong> Parse the JSON value on the server side. For example:</p>
|
||||
<ul>
|
||||
<li><strong>Python/Flask:</strong> <code>values = json.loads(request.form.get('field', '[]'))</code></li>
|
||||
<li><strong>PHP:</strong> <code>$values = json_decode($_POST['field'] ?? '[]', true);</code></li>
|
||||
<li><strong>Node.js:</strong> <code>const values = JSON.parse(req.body.field || '[]');</code></li>
|
||||
<li><strong>Ruby/Rails:</strong> <code>values = JSON.parse(params[:field] || '[]')</code></li>
|
||||
</ul>
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
@@ -169,25 +198,103 @@ toc:
|
||||
<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>
|
||||
<dd>
|
||||
<p>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.value</code>:</p>
|
||||
<ul>
|
||||
<li>Single select: <code>{ detail: { value: "something" }}</code> (string)</li>
|
||||
<li>Multiple select: <code>{ detail: { value: ["item1", "item2"] }}</code> (array)</li>
|
||||
</ul>
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<h4 id="usage-html-js-5"><a href="#usage-html-js-5">JavaScript methods</a></h4>
|
||||
<h4 id="usage-html-js-5"><a href="#usage-html-js-5">JavaScript methods and properties</a></h4>
|
||||
|
||||
<section class="prose">
|
||||
<dl>
|
||||
<dt><code>selectByValue</code></dt>
|
||||
<dt><code>value</code> (property)</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>
|
||||
<p>Get or set the current value. For single-select, this is a string. For multiselect, this is an array. The multiselect setter accepts both strings and arrays.</p>
|
||||
|
||||
{% set code_script %}<script>
|
||||
{% set code_value %}<script>
|
||||
const selectComponent = document.querySelector('#my-select');
|
||||
selectComponent.addEventListener('basecoat:initialized', () => {
|
||||
selectComponent.selectByValue('apple');
|
||||
// Get value
|
||||
console.log(selectComponent.value); // 'apple'
|
||||
|
||||
// Set value (single-select)
|
||||
selectComponent.value = 'banana';
|
||||
|
||||
// Set value (multiselect - accepts string or array)
|
||||
selectComponent.value = ['apple', 'banana'];
|
||||
selectComponent.value = 'apple'; // normalized to ['apple']
|
||||
});
|
||||
</script>{% endset %}
|
||||
{{ code_block(code_script | prettyHtml, "html") }}
|
||||
{{ code_block(code_value | prettyHtml, "html") }}
|
||||
</dd>
|
||||
<dt><code>select(value)</code></dt>
|
||||
<dd>
|
||||
<p>Selects an option by value (i.e. the option with the matching <code>data-value</code> attribute). For single-select, this will close the popover. For multiselect, this adds the value to the selection if not already selected.</p>
|
||||
|
||||
{% set code_select %}<script>
|
||||
const selectComponent = document.querySelector('#my-select');
|
||||
selectComponent.addEventListener('basecoat:initialized', () => {
|
||||
selectComponent.select('apple');
|
||||
});
|
||||
</script>{% endset %}
|
||||
{{ code_block(code_select | prettyHtml, "html") }}
|
||||
</dd>
|
||||
<dt><code>deselect(value)</code> <span class="badge-secondary">Multiselect only</span></dt>
|
||||
<dd>
|
||||
<p>Removes a specific value from the selection. Only available when the component is in multiselect mode (<code>aria-multiselectable="true"</code>).</p>
|
||||
|
||||
{% set code_deselect %}<script>
|
||||
const selectComponent = document.querySelector('#my-multiselect');
|
||||
selectComponent.addEventListener('basecoat:initialized', () => {
|
||||
selectComponent.deselect('apple');
|
||||
});
|
||||
</script>{% endset %}
|
||||
{{ code_block(code_deselect | prettyHtml, "html") }}
|
||||
</dd>
|
||||
<dt><code>toggle(value)</code> <span class="badge-secondary">Multiselect only</span></dt>
|
||||
<dd>
|
||||
<p>Toggles a specific value in the selection (adds if not present, removes if present). Only available when the component is in multiselect mode (<code>aria-multiselectable="true"</code>).</p>
|
||||
|
||||
{% set code_toggle %}<script>
|
||||
const selectComponent = document.querySelector('#my-multiselect');
|
||||
selectComponent.addEventListener('basecoat:initialized', () => {
|
||||
selectComponent.toggle('apple'); // adds if not selected, removes if selected
|
||||
});
|
||||
</script>{% endset %}
|
||||
{{ code_block(code_toggle | prettyHtml, "html") }}
|
||||
</dd>
|
||||
<dt><code>selectAll()</code> <span class="badge-secondary">Multiselect only</span></dt>
|
||||
<dd>
|
||||
<p>Selects all available options. Only available when the component is in multiselect mode (<code>aria-multiselectable="true"</code>).</p>
|
||||
|
||||
{% set code_script_all %}<script>
|
||||
const selectComponent = document.querySelector('#my-multiselect');
|
||||
selectComponent.addEventListener('basecoat:initialized', () => {
|
||||
selectComponent.selectAll();
|
||||
});
|
||||
</script>{% endset %}
|
||||
{{ code_block(code_script_all | prettyHtml, "html") }}
|
||||
</dd>
|
||||
<dt><code>selectNone()</code> <span class="badge-secondary">Multiselect only</span></dt>
|
||||
<dd>
|
||||
<p>Deselects all options. Only available when the component is in multiselect mode (<code>aria-multiselectable="true"</code>).</p>
|
||||
|
||||
{% set code_script_none %}<script>
|
||||
const selectComponent = document.querySelector('#my-multiselect');
|
||||
selectComponent.addEventListener('basecoat:initialized', () => {
|
||||
selectComponent.selectNone();
|
||||
});
|
||||
</script>{% endset %}
|
||||
{{ code_block(code_script_none | prettyHtml, "html") }}
|
||||
</dd>
|
||||
<dt><code>selectByValue(value)</code></dt>
|
||||
<dd>
|
||||
<p><strong>Deprecated:</strong> Alias for <code>select(value)</code>. Kept for backward compatibility.</p>
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
@@ -203,11 +310,11 @@ toc:
|
||||
Use Nunjucks or Jinja macros
|
||||
{% lucide "arrow-right" %}
|
||||
</a>
|
||||
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/jinja/dialog.html.jinja" target="_blank">
|
||||
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/jinja/select.html.jinja" target="_blank">
|
||||
Jinja macro
|
||||
{% lucide "arrow-right" %}
|
||||
</a>
|
||||
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/nunjucks/dialog.njk" target="_blank">
|
||||
<a class="badge-outline" href="https://github.com/hunvreus/basecoat/blob/main/src/nunjucks/select.njk" target="_blank">
|
||||
Nunjucks macro
|
||||
{% lucide "arrow-right" %}
|
||||
</a>
|
||||
@@ -239,7 +346,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 %}
|
||||
@@ -258,6 +365,23 @@ toc:
|
||||
|
||||
{{ code_preview("select-disabled", code | prettyHtml) }}
|
||||
|
||||
<h3 id="example-invalid-state"><a href="#example-invalid-state">Invalid State</a></h3>
|
||||
|
||||
{% set code %}
|
||||
{{ select(
|
||||
trigger_attrs={"aria-invalid": "true"},
|
||||
items=[
|
||||
{ value: "", label: "Select a role" },
|
||||
{ value: "admin", label: "Admin" },
|
||||
{ value: "editor", label: "Editor" },
|
||||
{ value: "viewer", label: "Viewer" },
|
||||
{ value: "guest", label: "Guest" }
|
||||
]
|
||||
) }}
|
||||
{% endset %}
|
||||
|
||||
{{ code_preview("select-disabled", code | prettyHtml) }}
|
||||
|
||||
<h3 id="example-with-icon"><a href="#example-with-icon">With icon</a></h3>
|
||||
|
||||
{% set code %}
|
||||
@@ -286,4 +410,48 @@ toc:
|
||||
{% endcall %}
|
||||
{% endset %}
|
||||
|
||||
{{ code_preview("select-scrollable", code | prettyHtml) }}
|
||||
{{ code_preview("select-with-icons", code | prettyHtml) }}
|
||||
|
||||
<h3 id="example-multiple"><a href="#example-multiple">Multiple</a></h3>
|
||||
|
||||
<section class="prose">
|
||||
<p>Enable multiple selection by setting adding <code>aria-multiselectable="true"</code> to the <code>listbox</code>. The selected options are displayed as a comma-separated list in the trigger button. Use <code>data-label</code> to specify clean text labels for options that contain HTML (like icons), otherwise the text content will be extracted automatically.</p>
|
||||
</section>
|
||||
|
||||
{% set code %}
|
||||
{% call select(
|
||||
id="select-multiple",
|
||||
name="chart-types",
|
||||
multiple=true,
|
||||
placeholder="Select chart types...",
|
||||
selected=["bar", "line"],
|
||||
trigger_attrs={"class": "w-[220px]"}
|
||||
) %}
|
||||
<div role="option" data-value="bar" data-label="Bar">
|
||||
<span class="flex items-center gap-2">
|
||||
{% lucide "chart-bar", {"class": "text-muted-foreground"} %}
|
||||
Bar
|
||||
</span>
|
||||
</div>
|
||||
<div role="option" data-value="line" data-label="Line">
|
||||
<span class="flex items-center gap-2">
|
||||
{% lucide "chart-line", {"class": "text-muted-foreground"} %}
|
||||
Line
|
||||
</span>
|
||||
</div>
|
||||
<div role="option" data-value="pie" data-label="Pie">
|
||||
<span class="flex items-center gap-2">
|
||||
{% lucide "chart-pie", {"class": "text-muted-foreground"} %}
|
||||
Pie
|
||||
</span>
|
||||
</div>
|
||||
<div role="option" data-value="area" data-label="Area">
|
||||
<span class="flex items-center gap-2">
|
||||
{% lucide "chart-area", {"class": "text-muted-foreground"} %}
|
||||
Area
|
||||
</span>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endset %}
|
||||
|
||||
{{ code_preview("select-multiple", code | prettyHtml) }}
|
||||
|
||||
@@ -34,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><head></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">
|
||||
@@ -165,7 +166,7 @@ toc:
|
||||
{% 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: 'close' } }));">Close sidebar</button>
|
||||
<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") }}
|
||||
@@ -215,4 +216,4 @@ toc:
|
||||
<h1>Content</h1>
|
||||
</main>
|
||||
{% endraw %}{% endset%}
|
||||
{{ code_block(raw_code, "jinja") }}
|
||||
{{ code_block(raw_code, "jinja") }}
|
||||
|
||||
@@ -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") }}
|
||||
@@ -94,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><head></code> of your page:</p>
|
||||
</section>
|
||||
|
||||
{% set code_script %}<script src="https://cdn.jsdelivr.net/npm/basecoat-css@{{ pkg.version }}/dist/js/tabs.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">
|
||||
|
||||
@@ -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><script></code> block you can insert in the <code><head></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><head></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>
|
||||
@@ -69,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><head></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">
|
||||
|
||||
@@ -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><head></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") }}
|
||||
|
||||
@@ -102,7 +112,8 @@ toc:
|
||||
<div class="prose">
|
||||
<p>Or cherry-pick specific components, for example:</p>
|
||||
</div>
|
||||
{% set code %}{% raw %}import 'basecoat-css/popover';
|
||||
{% set code %}{% raw %}import 'basecoat-css/basecoat';
|
||||
import 'basecoat-css/popover';
|
||||
import 'basecoat-css/tabs';{% endraw %}{% endset %}
|
||||
{{ code_block(code, "js") }}
|
||||
<div class="prose">
|
||||
@@ -112,9 +123,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>
|
||||
@@ -132,13 +144,13 @@ import 'basecoat-css/tabs';{% endraw %}{% endset %}
|
||||
<h2 id="install-js"><a href="#install-js">Components with JavaScript</a></h2>
|
||||
|
||||
<div class="prose">
|
||||
<p><b>A handful of components require JavaScript code to work.</b>, specifically <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></p>
|
||||
<p><b>A handful of components require JavaScript code to work</b>, specifically <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></p>
|
||||
|
||||
<p>If a component requires JavaScript, the documentation page will provide instructions. There are 2 options to add the JavaScript code to your project:</p>
|
||||
|
||||
<ul>
|
||||
<li><b>CDN</b>: you either <a href="#install-cdn-all">add the code for all of the components</a> to the <code><head></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 +197,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">
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
Basecoat – LLMs Guide
|
||||
|
||||
About
|
||||
- Basecoat is a Tailwind‑first component library using semantic HTML and small vanilla JS for interactivity. No React required. Framework‑agnostic.
|
||||
|
||||
Install
|
||||
- Tailwind project: `npm i basecoat-css`
|
||||
- In CSS: `@import "tailwindcss";` then `@import "basecoat-css";`
|
||||
|
||||
CDN
|
||||
- CSS: `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/basecoat-css@latest/dist/basecoat.cdn.min.css">`
|
||||
- JS (all): `<script src="https://cdn.jsdelivr.net/npm/basecoat-css@latest/dist/js/all.min.js" defer></script>`
|
||||
- JS (per component): include `basecoat.min.js` then `tabs.min.js`, `select.min.js`, etc.
|
||||
|
||||
JavaScript
|
||||
- Auto‑init on `DOMContentLoaded`; observes DOM for new nodes.
|
||||
- Manual: `window.basecoat.init('<name>')` or `window.basecoat.initAll()`.
|
||||
- Packages to import with ESM: `import 'basecoat-css/all'` or `import 'basecoat-css/tabs'`.
|
||||
|
||||
Usage
|
||||
- CSS‑only components: use classes like `btn`, `card`, `input`, etc.
|
||||
- JS components: include the JS and use required roles/attributes (see below).
|
||||
|
||||
Theming
|
||||
- Tailwind projects can override design tokens (e.g., `primary`, `muted-foreground`).
|
||||
- Without Tailwind, use CDN CSS and add overrides after the link tag.
|
||||
|
||||
Common pitfalls
|
||||
- Do not invent class names; use documented classes only.
|
||||
- The package name is `basecoat-css` (not `basecoat`, not `@basecoat/css`).
|
||||
- Interactive components require specific roles/attributes; include them.
|
||||
- Basecoat is framework‑agnostic; do not mention React/Radix dependencies.
|
||||
|
||||
Components
|
||||
- Button (CSS): `.btn`, variants like `.btn-secondary`, sizes `.btn-sm`, `.btn-lg`.
|
||||
- Button Group (CSS): `.button-group` to group adjacent buttons; supports horizontal/vertical.
|
||||
- Input (CSS): `.input` for text inputs; pairs with `.label` and `.field` patterns.
|
||||
- Textarea (CSS): `.textarea` styled multiline input.
|
||||
- Label (CSS): `.label` for associated form labels.
|
||||
- Field (CSS): `.field` wrapper for label, input, hint, error.
|
||||
- Form (CSS): `.form` patterns for layout and validation styling.
|
||||
- Checkbox (CSS): styled native checkbox; use `input[type='checkbox']`.
|
||||
- Radio Group (CSS): `.radio-group` pattern using native radios.
|
||||
- Switch (CSS): styled checkbox with `role="switch"` if needed.
|
||||
- Select (JS): `.select` custom select; trigger button + popover + `[role=listbox]` and `[role=option]`. Dispatches `change` with `detail.value`.
|
||||
- Combobox (JS): `.combobox` typeahead; similar roles to Select.
|
||||
- Command (JS): `.command` with search input and `[role=menu]`/`[role=menuitem]`; keyboard nav.
|
||||
- Tabs (JS): `.tabs` with `[role=tablist]`, `[role=tab]`, `aria-controls` panels; arrow keys switch.
|
||||
- Popover (JS): `.popover` wraps a button and `[data-popover]` content; toggles `aria-hidden`.
|
||||
- Dropdown Menu (JS): `.dropdown-menu` trigger + menu with `[role=menu]`/`[role=menuitem]`.
|
||||
- Dialog (CSS/Native): `<dialog>` styled; `.command-dialog` variant wraps `.command`.
|
||||
- Alert Dialog (CSS/Native): confirm/cancel flow using `<dialog>` semantics.
|
||||
- Drawer (CSS/Native): off‑canvas using `<dialog>`/popover; class names documented in examples.
|
||||
- Tooltip (CSS/JS-lite): `.tooltip` small info overlays.
|
||||
- Toast (JS): global notifications container `#toaster`; dispatch `basecoat:toast` with `{ detail: { config } }`.
|
||||
- Sidebar (JS): `.sidebar` with collapsible items; tracks current page via `aria-current`.
|
||||
- Breadcrumb (CSS): `.breadcrumb` list of links.
|
||||
- Pagination (CSS): `.pagination` navigation controls.
|
||||
- Card (CSS): `.card` container with header/section/footer slots.
|
||||
- Alert (CSS): `.alert` contextual banners.
|
||||
- Badge (CSS): `.badge` and `.badge-outline`.
|
||||
- Avatar (CSS): `.avatar` image + fallback.
|
||||
- Skeleton (CSS): `.skeleton` loading placeholder.
|
||||
- Progress (CSS): `.progress` bar.
|
||||
- Slider (CSS): `.slider` styled input range.
|
||||
- Spinner (CSS): `.spinner` loading indicator.
|
||||
- Kbd (CSS): `.kbd` keyboard key style.
|
||||
- Table (CSS): `.table` basic and striped tables.
|
||||
- Item (CSS): `.item` list rows with icons and actions.
|
||||
- Empty State (CSS): `.empty` placeholder content.
|
||||
- Theme Switcher (JS-lite): `.theme-switcher` toggles themes (example in docs).
|
||||
|
||||
Events
|
||||
- `basecoat:toast` (document): show a toast by dispatching `{ detail: { config } }`.
|
||||
- `basecoat:initialized` (component root): emitted after a component initializes.
|
||||
- `basecoat:popover` (document): coordination event to close other popovers.
|
||||
|
||||
Accessibility
|
||||
- Use semantic roles/attributes from examples (tabs, menus, listbox/options).
|
||||
- Disabled items: use `disabled` or `aria-disabled="true"`; JS will ignore them.
|
||||
|
||||
Troubleshooting
|
||||
- If a component doesn’t work, ensure its JS is loaded and required roles/IDs match examples.
|
||||
- For dynamic content, call `window.basecoat.init('<name>')` or `initAll()` after insertion.
|
||||
|
||||
License
|
||||
- MIT (see LICENSE.md)
|
||||
---
|
||||
description: Basecoat
|
||||
alwaysApply: true
|
||||
applyTo: "**"
|
||||
downloadedFrom: https://basecoatui.com/llms.txt
|
||||
version: 0.3.x
|
||||
---
|
||||
|
||||
# Basecoat
|
||||
Basecoat is a Tailwind CSS v4‑first UI library using semantic HTML and small vanilla JS for interactivity. No React required; framework‑agnostic.
|
||||
|
||||
- Basecoat docs: https://basecoatui.com
|
||||
- Package CSS: `basecoat-css`
|
||||
|
||||
## Basecoat install notes
|
||||
1. Basecoat targets Tailwind CSS v4.
|
||||
2. Import Tailwind in your CSS, then import Basecoat:
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
@import "basecoat-css";
|
||||
```
|
||||
3. For interactive components, include JS. With ESM you can load all or cherry‑pick:
|
||||
```js
|
||||
import 'basecoat-css/all';
|
||||
// or cherry‑pick
|
||||
import 'basecoat-css/tabs';
|
||||
import 'basecoat-css/select';
|
||||
import 'basecoat-css/popover';
|
||||
import 'basecoat-css/dropdown-menu';
|
||||
import 'basecoat-css/sidebar';
|
||||
import 'basecoat-css/toast';
|
||||
```
|
||||
4. CDN usage (no build tool):
|
||||
```html
|
||||
<link href="https://cdn.jsdelivr.net/npm/basecoat-css@latest/dist/basecoat.cdn.min.css" rel="stylesheet" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/basecoat-css@latest/dist/js/all.min.js" defer></script>
|
||||
```
|
||||
5. JS auto‑initializes on `DOMContentLoaded` and observes the DOM for added nodes. You can manually re‑init:
|
||||
```js
|
||||
window.basecoat.init('tabs');
|
||||
window.basecoat.initAll();
|
||||
```
|
||||
|
||||
## Basecoat usage rules
|
||||
1. Add Basecoat class names to semantic HTML. Combine with Tailwind utilities for layout/spacing.
|
||||
2. Interactive components require specific roles/attributes (see each component). Keep `aria-*` for semantics and `data-*` for visual states.
|
||||
3. Disabled elements must use `disabled` or `aria-disabled="true"`. Basecoat JS filters them from interactions.
|
||||
4. Only use documented Basecoat classes or Tailwind utilities. Don’t invent new class names.
|
||||
5. Prefer native elements (button, input, dialog) and keep markup minimal.
|
||||
|
||||
Basecoat class name categories (for reference)
|
||||
- `component`: the main component class
|
||||
- `part`: a child element/slot of a component
|
||||
- `style`: variants changing appearance
|
||||
- `size`: size variants
|
||||
- `behavior`: classes that change behavior
|
||||
- `modifier`: boolean/conditional changes
|
||||
|
||||
## Config
|
||||
Basecoat has no Tailwind plugin config. Import the CSS after Tailwind. Customize design tokens via your Tailwind theme or overrides.
|
||||
|
||||
Minimal CSS setup:
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
@import "basecoat-css";
|
||||
```
|
||||
|
||||
## Basecoat tokens (commonly used)
|
||||
These semantic tokens are referenced by classes (examples): `background`, `foreground`, `primary`, `primary-foreground`, `secondary`, `secondary-foreground`, `accent`, `accent-foreground`, `muted`, `muted-foreground`, `card`, `card-foreground`, `popover`, `popover-foreground`, `destructive`, `ring`, `input`, `border`.
|
||||
|
||||
Rules
|
||||
- Tokens should be defined in your Tailwind theme or be available via your design system. You can override colors using Tailwind utilities (e.g., `bg-primary`).
|
||||
- Prefer utilities for one‑off overrides; keep semantic tokens consistent.
|
||||
|
||||
## Basecoat components
|
||||
|
||||
### alert
|
||||
Alerts inform users about important events.
|
||||
|
||||
#### Class names
|
||||
- component: `alert`, `alert-destructive`
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<div role="alert" class="alert">{CONTENT}</div>
|
||||
```
|
||||
|
||||
#### Rules
|
||||
- Use `alert-destructive` for destructive context.
|
||||
|
||||
### avatar
|
||||
Avatar shows a user thumbnail.
|
||||
|
||||
#### Class names
|
||||
- component: `avatar`
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<div class="avatar"><img src="{image-url}" alt="{alt}" /></div>
|
||||
```
|
||||
|
||||
#### Rules
|
||||
- Provide meaningful `alt` text or decorative as appropriate.
|
||||
|
||||
### badge
|
||||
Badges display statuses or counts.
|
||||
|
||||
#### Class names
|
||||
- component: `badge`
|
||||
- style: `badge-outline`, `badge-destructive`
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<span class="badge {MODIFIER}">{TEXT}</span>
|
||||
```
|
||||
|
||||
#### Rules
|
||||
- {MODIFIER} is optional; combine with utilities for spacing.
|
||||
|
||||
### button
|
||||
Buttons trigger actions.
|
||||
|
||||
#### Class names
|
||||
- component: `btn`
|
||||
- style: `btn-primary`, `btn-secondary`, `btn-outline`, `btn-ghost`, `btn-link`, `btn-destructive`
|
||||
- size: `btn-sm`, `btn-lg`
|
||||
- icon: `btn-icon`, `btn-sm-icon`, `btn-lg-icon`
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<button class="btn {MODIFIER}">{CONTENT}</button>
|
||||
```
|
||||
|
||||
#### Rules
|
||||
- Use `disabled` attribute for disabled state. Use `aria-pressed="true"` for toggle buttons.
|
||||
|
||||
### button-group
|
||||
Groups adjacent buttons into a single control.
|
||||
|
||||
#### Class names
|
||||
- component: `button-group`
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<div class="button-group" data-orientation="{horizontal|vertical}">{BUTTONS}</div>
|
||||
```
|
||||
|
||||
#### Rules
|
||||
- Use separators with `<hr role="separator">` inside the group if needed.
|
||||
|
||||
### card
|
||||
Card container with optional header/section/footer.
|
||||
|
||||
#### Class names
|
||||
- component: `card`
|
||||
- part: `> header`, `> section`, `> footer`
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<article class="card">
|
||||
<header><h2>{TITLE}</h2><p>{SUBTITLE}</p></header>
|
||||
<section>{CONTENT}</section>
|
||||
<footer>{ACTIONS}</footer>
|
||||
</article>
|
||||
```
|
||||
|
||||
#### Rules
|
||||
- Parts are optional; structure with semantic elements.
|
||||
|
||||
### checkbox (CSS)
|
||||
Styled native checkbox.
|
||||
|
||||
#### Class names
|
||||
- component: `.input[type='checkbox']:not([role='switch'])`
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<input type="checkbox" class="input" />
|
||||
```
|
||||
|
||||
#### Rules
|
||||
- Use standard checkbox semantics; pair with `<label>`.
|
||||
|
||||
### command (JS)
|
||||
Command palette with search and results.
|
||||
|
||||
#### Class names
|
||||
- component: `command`, wrapper variant: `command-dialog`
|
||||
- part: `> header input`, `[role='menu']`, `[role='menuitem']`
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<div class="command">
|
||||
<header><input type="text" placeholder="Search" /></header>
|
||||
<div role="menu">
|
||||
<button role="menuitem">{ITEM}</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Rules
|
||||
- Requires JS. Keyboard navigation supported. Disabled items must use `aria-disabled="true"` or `disabled` and are ignored.
|
||||
|
||||
### dropdown-menu (JS)
|
||||
Contextual menu triggered by a button.
|
||||
|
||||
#### Class names
|
||||
- component: `dropdown-menu`
|
||||
- part: `> button` (trigger), `[role='menu']`, `[role='menuitem']`
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<div class="dropdown-menu">
|
||||
<button type="button">{TRIGGER}</button>
|
||||
<div role="menu"> <button role="menuitem">{ITEM}</button> </div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Rules
|
||||
- Requires JS. Uses `basecoat:popover` to coordinate with other popovers.
|
||||
|
||||
### input (CSS)
|
||||
Styled text input fields.
|
||||
|
||||
#### Class names
|
||||
- component: `input` (applied to native `<input>` types)
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<input type="text" class="input" placeholder="{Placeholder}" />
|
||||
```
|
||||
|
||||
#### Rules
|
||||
- Supports common input types; pair with `.label` and `.field` patterns.
|
||||
|
||||
### kbd (CSS)
|
||||
Keyboard key visual.
|
||||
|
||||
#### Class names
|
||||
- component: `kbd`
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<kbd class="kbd">⌘K</kbd>
|
||||
```
|
||||
|
||||
### label (CSS)
|
||||
Styled form labels.
|
||||
|
||||
#### Class names
|
||||
- component: `label`
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<label class="label" for="{id}">{TEXT}</label>
|
||||
```
|
||||
|
||||
### popover (JS)
|
||||
Small overlay anchored to a trigger.
|
||||
|
||||
#### Class names
|
||||
- component: `popover`
|
||||
- part: `> button` (trigger), `[data-popover]` (content)
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<div class="popover">
|
||||
<button type="button">{TRIGGER}</button>
|
||||
<section data-popover aria-hidden="true">{CONTENT}</section>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Rules
|
||||
- Requires JS. Toggling sets `aria-hidden` on content and `aria-expanded` on trigger.
|
||||
|
||||
### radio-group (CSS)
|
||||
Styled native radio inputs.
|
||||
|
||||
#### Class names
|
||||
- component: `.input[type='radio']`
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<label><input type="radio" class="input" name="{name}" /> {LABEL}</label>
|
||||
```
|
||||
|
||||
### select (JS)
|
||||
Custom select with searchable popover and listbox.
|
||||
|
||||
#### Class names
|
||||
- component: `select`
|
||||
- part: `> button` (trigger with label span), `[data-popover]` (panel), `[role='listbox']`, `[role='option']`, `> input[type='hidden']`, optional `header input[type='text']` filter
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<div class="select">
|
||||
<button type="button" aria-expanded="false"><span>{LABEL}</span></button>
|
||||
<input type="hidden" name="{name}" value="{value}" />
|
||||
<section data-popover aria-hidden="true">
|
||||
<header><input type="text" placeholder="Filter" /></header>
|
||||
<div role="listbox">
|
||||
<div role="option" data-value="{value}" data-label="{label}">{label}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Rules
|
||||
- Requires JS. Dispatches `change` on root with `detail.value`. Disabled options use `aria-disabled="true"`.
|
||||
|
||||
### sidebar (JS)
|
||||
Collapsible navigation sidebar.
|
||||
|
||||
#### Class names
|
||||
- component: `sidebar`
|
||||
|
||||
#### Rules
|
||||
- Requires JS. Uses `aria-current="page"` for active link styling.
|
||||
|
||||
### switch (CSS)
|
||||
Styled switch using checkbox with `role="switch"`.
|
||||
|
||||
#### Class names
|
||||
- component: `.input[type='checkbox'][role='switch']`
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<input type="checkbox" role="switch" class="input" />
|
||||
```
|
||||
|
||||
### table (CSS)
|
||||
Styled tables.
|
||||
|
||||
#### Class names
|
||||
- component: `table`
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<table class="table">{ROWS}</table>
|
||||
```
|
||||
|
||||
### tabs (JS)
|
||||
Tabbed interface with keyboard navigation.
|
||||
|
||||
#### Class names
|
||||
- component: `tabs`
|
||||
- part: `[role='tablist']`, `[role='tab']`, `[role='tabpanel']`
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<div class="tabs">
|
||||
<div role="tablist">
|
||||
<button role="tab" aria-controls="panel-1" aria-selected="true" tabindex="0">Tab 1</button>
|
||||
<button role="tab" aria-controls="panel-2" aria-selected="false" tabindex="-1">Tab 2</button>
|
||||
</div>
|
||||
<section id="panel-1" role="tabpanel">...</section>
|
||||
<section id="panel-2" role="tabpanel" hidden>...</section>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Rules
|
||||
- Requires JS. Arrow keys move focus; keep `aria-controls` and `aria-selected` accurate.
|
||||
|
||||
### textarea (CSS)
|
||||
Styled multiline text input.
|
||||
|
||||
#### Class names
|
||||
- component: `textarea`
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<textarea class="textarea" rows="{n}"></textarea>
|
||||
```
|
||||
|
||||
### toast (JS)
|
||||
Global notifications.
|
||||
|
||||
#### Class names
|
||||
- component: `#toaster` (container), `toast` (item)
|
||||
|
||||
#### Syntax
|
||||
```html
|
||||
<section id="toaster"></section>
|
||||
```
|
||||
Show a toast:
|
||||
```js
|
||||
document.dispatchEvent(new CustomEvent('basecoat:toast', { detail: { config: { title: 'Saved', description: 'Changes persisted', category: 'success' } } }))
|
||||
```
|
||||
|
||||
#### Rules
|
||||
- Requires JS. Uses timeouts; hovering pauses timers.
|
||||
|
||||
### popover/dialog variants (CSS/JS‑lite)
|
||||
Dialog and drawer patterns built on native elements, styled by Basecoat.
|
||||
|
||||
#### Class names
|
||||
- wrapper: `command-dialog` (for Command palette in a dialog)
|
||||
|
||||
#### Rules
|
||||
- Use native `<dialog>` or popover attributes where applicable.
|
||||
|
||||
### other CSS components
|
||||
Additional CSS components include: `progress`, `skeleton`, `slider`, `spinner`, `pagination`, `breadcrumb`, `item`, `empty`, `theme-switcher`, `form`, `field`, `input-group`, `radio-group`, `switch`, `avatar`, `badge`, `alert`, `card`, `kbd`, `table`.
|
||||
|
||||
## Events
|
||||
- `basecoat:initialized` (component root): emitted after a component initializes.
|
||||
- `basecoat:popover` (document): used to close other popovers when one opens.
|
||||
- `basecoat:toast` (document): creates a toast using `{ detail: { config } }`.
|
||||
|
||||
## Accessibility
|
||||
- Use semantic roles/labels per component. Keep `aria-*` for semantics and `data-*` for visual states.
|
||||
- Disabled items must use `disabled` or `aria-disabled="true"` to be filtered from interactions.
|
||||
|
||||
## Troubleshooting
|
||||
- If interactivity doesn’t work, ensure the component’s JS is included and required roles/IDs match the examples.
|
||||
- For dynamic content, call `window.basecoat.init('<name>')` or `window.basecoat.initAll()` after insertion.
|
||||
|
||||
## License
|
||||
MIT (see LICENSE.md)
|
||||
+38
-70
@@ -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 %}
|
||||
Generated
+4
-4
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "basecoat",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.10-beta.2",
|
||||
"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,6 +1,6 @@
|
||||
{
|
||||
"name": "basecoat-cli",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.10-beta.2",
|
||||
"description": "Add Basecoat components to your project",
|
||||
"author": "hunvreus",
|
||||
"license": "MIT",
|
||||
@@ -30,8 +30,7 @@
|
||||
"css",
|
||||
"html",
|
||||
"jinja",
|
||||
"nunjucks",
|
||||
"alpinejs"
|
||||
"nunjucks"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "basecoat-css",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.10-beta.2",
|
||||
"description": "Tailwind CSS for Basecoat components",
|
||||
"author": {
|
||||
"name": "hunvreus",
|
||||
@@ -26,8 +26,7 @@
|
||||
"css",
|
||||
"html",
|
||||
"jinja",
|
||||
"nunjucks",
|
||||
"alpinejs"
|
||||
"nunjucks"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -41,8 +40,12 @@
|
||||
"exports": {
|
||||
".": "./dist/basecoat.css",
|
||||
"./css": "./dist/basecoat.css",
|
||||
"./basecoat": "./dist/js/basecoat.js",
|
||||
"./basecoat.min": "./dist/js/basecoat.min.js",
|
||||
"./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
@@ -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', 'command.js', 'dropdown-menu.js', 'popover.js', 'select.js', 'sidebar.js', 'tabs.js', 'toast.js'];
|
||||
const componentPaths = componentsToCombine.map(f => path.join(srcJsDir, f));
|
||||
|
||||
// Create non-minified bundle
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss" source(none);
|
||||
@import "./basecoat.css";
|
||||
+388
-160
@@ -146,10 +146,6 @@
|
||||
border-radius: var(--scrollbar-radius);
|
||||
}
|
||||
}
|
||||
/* Alpine.js */
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Alert */
|
||||
@@ -158,10 +154,16 @@
|
||||
.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,
|
||||
> h4,
|
||||
> h5,
|
||||
> h6,
|
||||
> strong,
|
||||
> [data-title] {
|
||||
@apply col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight;
|
||||
}
|
||||
section {
|
||||
> section {
|
||||
@apply text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed;
|
||||
|
||||
ul {
|
||||
@@ -175,12 +177,36 @@
|
||||
.alert-destructive {
|
||||
@apply text-destructive bg-card [&>svg]:text-current;
|
||||
|
||||
section {
|
||||
> section {
|
||||
@apply text-destructive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 +385,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
/* Button Group */
|
||||
@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;
|
||||
}
|
||||
.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-group {
|
||||
@apply inline-flex w-fit items-stretch isolate;
|
||||
|
||||
> *:focus-visible,
|
||||
> :is(.dropdown-menu, .popover, .select) > button:focus-visible {
|
||||
@apply relative z-10;
|
||||
}
|
||||
|
||||
> hr[role='separator'] {
|
||||
@apply w-0 h-auto self-stretch border border-input shrink-0 m-0;
|
||||
}
|
||||
|
||||
&: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 +455,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 +487,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([role='menuitem']:not([aria-hidden='true'])))::before {
|
||||
@apply flex items-center justify-center py-6 px-3 text-sm truncate -m-1;
|
||||
}
|
||||
&[data-empty]:not(:has([role='menuitem']:not([aria-hidden='true'])))::before {
|
||||
@apply content-[attr(data-empty)];
|
||||
}
|
||||
&:not([data-empty]):not(:has([role='menuitem']: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 +607,7 @@
|
||||
&::backdrop {
|
||||
@apply opacity-100;
|
||||
}
|
||||
> article {
|
||||
> * {
|
||||
@apply scale-100;
|
||||
}
|
||||
|
||||
@@ -463,7 +617,7 @@
|
||||
&::backdrop {
|
||||
@apply opacity-0;
|
||||
}
|
||||
> article {
|
||||
> * {
|
||||
@apply scale-95;
|
||||
}
|
||||
}
|
||||
@@ -471,7 +625,7 @@
|
||||
&::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 transition-all scale-95;
|
||||
|
||||
@@ -491,11 +645,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 +672,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 +686,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']:not(:is(.select [data-popover] > header input[role='combobox'], .command > header input, .dialog > * > header input)),
|
||||
: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']:not(:is(.select [data-popover] > header input[role='combobox'], .command > header input, .dialog > * > header input)),
|
||||
: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 +774,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 +868,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 +880,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 +929,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];
|
||||
@@ -727,6 +941,14 @@
|
||||
}
|
||||
*:not(select).select {
|
||||
@apply relative inline-flex;
|
||||
|
||||
& > button {
|
||||
@apply justify-between font-normal;
|
||||
|
||||
&[aria-invalid='true'] {
|
||||
@apply ring-destructive/20 dark:ring-destructive/40 border-destructive;
|
||||
}
|
||||
}
|
||||
|
||||
[data-popover] {
|
||||
@apply p-1;
|
||||
@@ -737,6 +959,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,129 +993,131 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
&:not([data-sidebar-initialized]) {
|
||||
@apply max-md:hidden;
|
||||
}
|
||||
&:not([aria-hidden]),
|
||||
&[aria-hidden=false] {
|
||||
@apply max-md:bg-black/50 max-md:fixed max-md:inset-0 max-md:z-40;
|
||||
}
|
||||
nav {
|
||||
@apply bg-sidebar text-sidebar-foreground flex flex-col w-(--sidebar-mobile-width) md:w-(--sidebar-width) fixed inset-y-0 z-50 transition-transform ease-in-out duration-300;
|
||||
}
|
||||
& + * {
|
||||
@apply transition-[margin] ease-in-out duration-300;
|
||||
}
|
||||
&:not([data-side]),
|
||||
&[data-side=left] {
|
||||
@layer components {
|
||||
.sidebar {
|
||||
&:not([data-sidebar-initialized]) {
|
||||
@apply max-md:hidden;
|
||||
}
|
||||
&:not([aria-hidden]),
|
||||
&[aria-hidden=false] {
|
||||
@apply max-md:bg-black/50 max-md:fixed max-md:inset-0 max-md:z-40;
|
||||
}
|
||||
nav {
|
||||
@apply left-0 border-r;
|
||||
@apply bg-sidebar text-sidebar-foreground flex flex-col w-(--sidebar-mobile-width) md:w-(--sidebar-width) fixed inset-y-0 z-50 transition-transform ease-in-out duration-300;
|
||||
}
|
||||
& + * {
|
||||
@apply relative md:ml-(--sidebar-width);
|
||||
@apply transition-[margin] ease-in-out duration-300;
|
||||
}
|
||||
&[aria-hidden=true] {
|
||||
&:not([data-side]),
|
||||
&[data-side=left] {
|
||||
nav {
|
||||
@apply -translate-x-full;
|
||||
@apply left-0 border-r;
|
||||
}
|
||||
& + * {
|
||||
@apply md:ml-0;
|
||||
@apply relative md:ml-(--sidebar-width);
|
||||
}
|
||||
&[aria-hidden=true] {
|
||||
nav {
|
||||
@apply -translate-x-full;
|
||||
}
|
||||
& + * {
|
||||
@apply md:ml-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
&[data-side=right] {
|
||||
nav {
|
||||
@apply right-0 border-l;
|
||||
}
|
||||
& + * {
|
||||
@apply relative md:mr-(--sidebar-width);
|
||||
}
|
||||
&[aria-hidden=true] {
|
||||
nav {
|
||||
@apply translate-x-full;
|
||||
}
|
||||
& + * {
|
||||
@apply md:mr-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&[data-side=right] {
|
||||
nav {
|
||||
@apply right-0 border-l;
|
||||
}
|
||||
& + * {
|
||||
@apply relative md:mr-(--sidebar-width);
|
||||
}
|
||||
&[aria-hidden=true] {
|
||||
nav {
|
||||
@apply translate-x-full;
|
||||
> header,
|
||||
> footer {
|
||||
@apply flex flex-col gap-2 p-2;
|
||||
}
|
||||
& + * {
|
||||
@apply md:mr-0;
|
||||
[role=separator] {
|
||||
@apply border-sidebar-border mx-2 w-auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
nav {
|
||||
> header,
|
||||
> footer {
|
||||
@apply flex flex-col gap-2 p-2;
|
||||
}
|
||||
[role=separator] {
|
||||
@apply border-sidebar-border mx-2 w-auto;
|
||||
}
|
||||
> section {
|
||||
@apply flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto;
|
||||
> section {
|
||||
@apply flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto;
|
||||
|
||||
> [role=group] {
|
||||
@apply relative flex w-full min-w-0 flex-col p-2;
|
||||
}
|
||||
h3 {
|
||||
@apply text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0;
|
||||
}
|
||||
ul {
|
||||
@apply flex w-full min-w-0 flex-col gap-1;
|
||||
|
||||
li {
|
||||
@apply relative;
|
||||
|
||||
> a,
|
||||
> details > summary {
|
||||
@apply flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&[aria-current=page]]:bg-sidebar-accent [&[aria-current=page]]:font-medium [&[aria-current=page]]:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0;
|
||||
|
||||
&:not([data-variant]),
|
||||
&[data-variant=default] {
|
||||
@apply hover:bg-sidebar-accent hover:text-sidebar-accent-foreground;
|
||||
}
|
||||
&[data-variant=outline] {
|
||||
@apply bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))];
|
||||
}
|
||||
&:not([data-size]),
|
||||
&[data-size=default] {
|
||||
@apply h-8 text-sm;
|
||||
}
|
||||
&[data-size=sm] {
|
||||
@apply h-7 text-xs;
|
||||
}
|
||||
&[data-size=lg] {
|
||||
@apply h-12 text-sm group-data-[collapsible=icon]:p-0!;
|
||||
}
|
||||
}
|
||||
> details {
|
||||
&:not([open]) {
|
||||
> summary {
|
||||
&::after {
|
||||
@apply -rotate-90;
|
||||
}
|
||||
}
|
||||
}
|
||||
> summary {
|
||||
&::after {
|
||||
@apply content-[''] block size-3.5 bg-primary ml-auto transition-transform ease-linear;
|
||||
@apply mask-[image:var(--chevron-down-icon)] mask-size-[1rem] mask-no-repeat mask-center;
|
||||
}
|
||||
}
|
||||
&::details-content {
|
||||
@apply px-3.5;
|
||||
}
|
||||
}
|
||||
> [role=group] {
|
||||
@apply relative flex w-full min-w-0 flex-col p-2;
|
||||
}
|
||||
h3 {
|
||||
@apply text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0;
|
||||
}
|
||||
ul {
|
||||
@apply border-sidebar-border flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5 w-full;
|
||||
@apply flex w-full min-w-0 flex-col gap-1;
|
||||
|
||||
li {
|
||||
@apply relative;
|
||||
|
||||
> a,
|
||||
> details > summary {
|
||||
@apply flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&[aria-current=page]]:bg-sidebar-accent [&[aria-current=page]]:font-medium [&[aria-current=page]]:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0;
|
||||
|
||||
&:not([data-variant]),
|
||||
&[data-variant=default] {
|
||||
@apply hover:bg-sidebar-accent hover:text-sidebar-accent-foreground;
|
||||
}
|
||||
&[data-variant=outline] {
|
||||
@apply bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))];
|
||||
}
|
||||
&:not([data-size]),
|
||||
&[data-size=default] {
|
||||
@apply h-8 text-sm;
|
||||
}
|
||||
&[data-size=sm] {
|
||||
@apply h-7 text-xs;
|
||||
}
|
||||
&[data-size=lg] {
|
||||
@apply h-12 text-sm group-data-[collapsible=icon]:p-0!;
|
||||
}
|
||||
}
|
||||
> details {
|
||||
&:not([open]) {
|
||||
> summary {
|
||||
&::after {
|
||||
@apply -rotate-90;
|
||||
}
|
||||
}
|
||||
}
|
||||
> summary {
|
||||
&::after {
|
||||
@apply content-[''] block size-3.5 bg-primary ml-auto transition-transform ease-linear;
|
||||
@apply mask-[image:var(--chevron-down-icon)] mask-size-[1rem] mask-no-repeat mask-center;
|
||||
}
|
||||
}
|
||||
&::details-content {
|
||||
@apply px-3.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
ul {
|
||||
@apply border-sidebar-border flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5 w-full;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -898,7 +1126,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;
|
||||
@@ -960,7 +1188,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 +1223,7 @@
|
||||
@apply font-medium tracking-tight;
|
||||
}
|
||||
p {
|
||||
@apply text-muted-foreground;
|
||||
@apply text-muted-foreground break-all;
|
||||
}
|
||||
}
|
||||
footer {
|
||||
@@ -1030,7 +1258,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-foreground text-background 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;
|
||||
@@ -1079,4 +1307,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
{#
|
||||
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 or HTML", icon: "HTML", url: "href", 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. If url is provided, renders as <a role="menuitem"> instead of <div role="menuitem">. Label supports HTML (marked as safe).
|
||||
@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 | safe }}</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 %}
|
||||
{% if item.url %}
|
||||
<a
|
||||
id="{{ item_id }}"
|
||||
href="{{ item.url }}"
|
||||
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() %}
|
||||
{% if key != "href" %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
>
|
||||
{% if item.icon %}{{ item.icon | safe }}{% endif %}
|
||||
{{ item.label | safe }}
|
||||
</a>
|
||||
{% else %}
|
||||
<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 %}
|
||||
>
|
||||
{% if item.icon %}{{ item.icon | safe }}{% endif %}
|
||||
{{ item.label | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
@@ -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,15 +48,15 @@
|
||||
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 %}
|
||||
{% for key, value in header_attrs.items() %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
@@ -89,6 +89,6 @@
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</article>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endmacro %}
|
||||
@@ -86,7 +86,7 @@
|
||||
{% endif %}
|
||||
>
|
||||
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
|
||||
{{ render_dropdown_items(item.items, item_id) if item.items }}
|
||||
{{ render_dropdown_items(item['items'], item_id) if item['items'] }}
|
||||
</div>
|
||||
{% elif item.type == "separator" %}
|
||||
<hr role="separator" />
|
||||
@@ -102,6 +102,7 @@
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
>
|
||||
{% if item.icon %}{{ item.icon | safe }}{% endif %}
|
||||
{{ item.label | safe }}
|
||||
</a>
|
||||
{% else %}
|
||||
@@ -114,9 +115,10 @@
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
>
|
||||
{% if item.icon %}{{ item.icon | safe }}{% endif %}
|
||||
{{ item.label | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
{% endmacro %}
|
||||
|
||||
+37
-17
@@ -2,8 +2,10 @@
|
||||
Renders a select or combobox component.
|
||||
|
||||
@param id {string} [optional] - Unique identifier for the select component.
|
||||
@param selected {string} [optional] - The initially selected value.
|
||||
@param selected {string|array} [optional] - The initially selected value(s).
|
||||
@param name {string} [optional] - The name attribute for the hidden input storing the selected value.
|
||||
@param multiple {boolean} [optional] [default=false] - Enables multiple selection mode.
|
||||
@param placeholder {string} [optional] - Placeholder text shown when no options are selected (multiple mode only).
|
||||
@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 popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
|
||||
@@ -17,6 +19,8 @@
|
||||
selected=None,
|
||||
name=None,
|
||||
items=[],
|
||||
multiple=false,
|
||||
placeholder=None,
|
||||
main_attrs={},
|
||||
trigger_attrs={},
|
||||
popover_attrs={},
|
||||
@@ -27,43 +31,58 @@
|
||||
) %}
|
||||
{% set id = id or ("select-" + (range(100000, 999999) | random | string)) %}
|
||||
|
||||
{% if selected is defined and selected is iterable and selected is not string %}
|
||||
{% set selectedArray = selected %}
|
||||
{% elif selected is defined %}
|
||||
{% set selectedArray = [selected] %}
|
||||
{% else %}
|
||||
{% set selectedArray = [] %}
|
||||
{% endif %}
|
||||
{% set first_option = namespace(item=None) %}
|
||||
{% set selected_option = namespace(item=None) %}
|
||||
{% set selected_options = namespace(items=[]) %}
|
||||
|
||||
{% if items %}
|
||||
{% for item in items %}
|
||||
{% if item.type == "group" %}
|
||||
{% for sub_item in item.items %}
|
||||
{% for sub_item in item['items'] %}
|
||||
{% if not first_option.item %}
|
||||
{% set first_option.item = sub_item %}
|
||||
{% endif %}
|
||||
{% if selected and sub_item.value == selected and not selected_option.item %}
|
||||
{% set selected_option.item = sub_item %}
|
||||
{% if sub_item.value in selectedArray %}
|
||||
{% set selected_options.items = selected_options.items + [sub_item] %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% if not first_option.item %}
|
||||
{% set first_option.item = item %}
|
||||
{% endif %}
|
||||
{% if selected and item.value == selected and not selected_option.item %}
|
||||
{% set selected_option.item = item %}
|
||||
{% if item.value in selectedArray %}
|
||||
{% set selected_options.items = selected_options.items + [item] %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% set default_option = selected_option.item or first_option.item or None %}
|
||||
{% set default_option = (selected_options.items[0] if selected_options.items else first_option.item) %}
|
||||
{% set labels = [] %}
|
||||
{% if multiple and selected_options.items %}
|
||||
{% for opt in selected_options.items %}
|
||||
{% set labels = labels + [opt.label] %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% set default_label = (', '.join(labels) if (multiple and selected_options.items) else ((placeholder or '') if (multiple and not selected_options.items) else (default_option.label if default_option else ''))) %}
|
||||
|
||||
<div
|
||||
id="{{ id }}"
|
||||
class="select {{ main_attrs.class }}"
|
||||
{% if multiple and placeholder %}data-placeholder="{{ placeholder }}"{% endif %}
|
||||
{% for key, value in main_attrs.items() %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline justify-between font-normal {{ trigger_attrs.class }}"
|
||||
class="btn-outline {{ trigger_attrs.class }}"
|
||||
id="{{ id }}-trigger"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="false"
|
||||
@@ -72,7 +91,7 @@
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
<span class="truncate">{{ default_option.label }}</span>
|
||||
<span class="truncate">{{ default_label }}</span>
|
||||
{% if is_combobox %}
|
||||
<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-chevrons-up-down-icon lucide-chevrons-up-down text-muted-foreground opacity-50 shrink-0"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
|
||||
{% else %}
|
||||
@@ -110,12 +129,13 @@
|
||||
id="{{ id }}-listbox"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="{{ id }}-trigger"
|
||||
{% if multiple %}aria-multiselectable="true"{% endif %}
|
||||
{% for key, value in listbox_attrs.items() %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
{% if items %}
|
||||
{{ render_select_items(items, default_option.value, id ~ "-items" if id else "items") }}
|
||||
{{ render_select_items(items, selectedArray, id ~ "-items" if id else "items") }}
|
||||
{% else %}
|
||||
{{ caller() if caller }}
|
||||
{% endif %}
|
||||
@@ -124,7 +144,7 @@
|
||||
<input
|
||||
type="hidden"
|
||||
name="{{ name or id ~ '-value' }}"
|
||||
value="{{ (default_option.value if default_option) or '' }}"
|
||||
value="{% if multiple %}{{ selectedArray | tojson }}{% else %}{{ (default_option.value if default_option) or '' }}{% endif %}"
|
||||
{% for key, value in input_attrs.items() %}
|
||||
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
@@ -138,7 +158,7 @@
|
||||
@param items {array} - The array of items to render.
|
||||
@param parent_id_prefix {string} [optional] - The prefix for the item id.
|
||||
#}
|
||||
{% macro render_select_items(items, selected, parent_id_prefix="items") %}
|
||||
{% macro render_select_items(items, selectedArray, parent_id_prefix="items") %}
|
||||
{% for item in items %}
|
||||
{% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
|
||||
{% if item.type == "group" %}
|
||||
@@ -153,7 +173,7 @@
|
||||
{% endif %}
|
||||
>
|
||||
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
|
||||
{{ render_select_items(item.items, selected, item_id) if item.items }}
|
||||
{{ render_select_items(item['items'], selectedArray, item_id) if item['items'] }}
|
||||
</div>
|
||||
{% elif item.type == "separator" %}
|
||||
<hr role="separator" />
|
||||
@@ -161,8 +181,8 @@
|
||||
<div
|
||||
id="{{ item_id }}"
|
||||
role="option"
|
||||
data-value="{{ item.value }}"
|
||||
{% if selected == item.value %}aria-selected="true"{% endif %}
|
||||
{% if item.value is defined and item.value is not none %}data-value="{{ item.value }}"{% endif %}
|
||||
{% if item.value in selectedArray %}aria-selected="true"{% endif %}
|
||||
{% if item.attrs %}
|
||||
{% for key, value in item.attrs.items() %}
|
||||
{{ key }}="{{ value }}"
|
||||
@@ -173,4 +193,4 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
{% endmacro %}
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
>
|
||||
<summary aria-controls="submenu-{{ item_id }}-content">
|
||||
{% if item.icon %}{{ item.icon | safe }}{% endif %}
|
||||
{{ item.label }}
|
||||
{{ item.label | safe }}
|
||||
</summary>
|
||||
<ul id="submenu-{{ item_id }}-content">
|
||||
{{ render_sidebar_content(item["items"], item_id) if item["items"] }}
|
||||
@@ -136,7 +136,7 @@
|
||||
{% endif %}
|
||||
>
|
||||
{% if item.icon %}{{ item.icon | safe }}{% endif %}
|
||||
<span>{{ item.label }}</span>
|
||||
<span>{{ item.label | safe }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
@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 attrs {object} [optional] - Additional HTML attributes for the main toaster container div.
|
||||
|
||||
Supports caller() for custom toast markup.
|
||||
#}
|
||||
{% macro toaster(
|
||||
id="toaster",
|
||||
@@ -24,9 +26,11 @@
|
||||
title=item.title,
|
||||
description=item.description,
|
||||
action=item.action,
|
||||
cancel=item.cancel
|
||||
cancel=item.cancel,
|
||||
attrs=item.attrs
|
||||
) }}
|
||||
{% endfor %}
|
||||
{{ caller() if caller }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -44,20 +48,25 @@
|
||||
- label {string}: Button text.
|
||||
- onclick {string}: JavaScript code to execute on click.
|
||||
- url {string}: URL for an anchor link button.
|
||||
@param attrs {object} [optional] - Additional HTML attributes for the toast div.
|
||||
#}
|
||||
{% macro toast(
|
||||
category="success",
|
||||
title="",
|
||||
description="",
|
||||
action=None,
|
||||
cancel=None
|
||||
cancel=None,
|
||||
attrs={}
|
||||
) %}
|
||||
<div
|
||||
class="toast"
|
||||
class="toast {{ attrs.class }}"
|
||||
role="{{ 'alert' if category == 'error' else 'status' }}"
|
||||
aria-atomic="true"
|
||||
aria-hidden="false"
|
||||
{% if category %}data-category="{{ category }}"{% endif %}
|
||||
{% for key, value in attrs.items() %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
<div class="toast-content">
|
||||
{% if category in ["error", "success", "info", "warning"] %}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,160 @@
|
||||
(() => {
|
||||
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 => {
|
||||
if (item.hasAttribute('data-force')) {
|
||||
item.setAttribute('aria-hidden', 'false');
|
||||
if (menuItems.includes(item)) {
|
||||
visibleMenuItems.push(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const itemText = (item.dataset.filter || item.textContent).trim().toLowerCase();
|
||||
const keywordList = (item.dataset.keywords || '')
|
||||
.toLowerCase()
|
||||
.split(/[\s,]+/)
|
||||
.filter(Boolean);
|
||||
const matchesKeyword = keywordList.some(keyword => keyword.includes(searchTerm));
|
||||
const matches = itemText.includes(searchTerm) || matchesKeyword;
|
||||
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
@@ -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
@@ -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);
|
||||
}
|
||||
})();
|
||||
|
||||
+255
-102
@@ -1,121 +1,225 @@
|
||||
(() => {
|
||||
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 listbox = popover ? popover.querySelector('[role="listbox"]') : null;
|
||||
const input = selectComponent.querySelector(':scope > input[type="hidden"]');
|
||||
const filter = selectComponent.querySelector('header input[type="text"]');
|
||||
|
||||
if (!trigger || !popover || !listbox || !input) {
|
||||
const missing = [];
|
||||
if (!trigger) missing.push('trigger');
|
||||
if (!popover) missing.push('popover');
|
||||
if (!listbox) missing.push('listbox');
|
||||
if (!input) missing.push('input');
|
||||
if (!input) missing.push('input');
|
||||
console.error(`Select component initialisation failed. Missing element(s): ${missing.join(', ')}`, selectComponent);
|
||||
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 isMultiple = listbox.getAttribute('aria-multiselectable') === 'true';
|
||||
const selectedOptions = isMultiple ? new Set() : null;
|
||||
const placeholder = isMultiple ? (selectComponent.dataset.placeholder || '') : null;
|
||||
|
||||
const getValue = (opt) => opt.dataset.value ?? opt.textContent.trim();
|
||||
|
||||
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) => {
|
||||
if (option) {
|
||||
selectedValue.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');
|
||||
const updateValue = (optionOrOptions, triggerEvent = true) => {
|
||||
let value;
|
||||
|
||||
if (isMultiple) {
|
||||
const opts = Array.isArray(optionOrOptions) ? optionOrOptions : [];
|
||||
selectedOptions.clear();
|
||||
opts.forEach(opt => selectedOptions.add(opt));
|
||||
|
||||
// Get selected options in DOM order
|
||||
const selected = options.filter(opt => selectedOptions.has(opt));
|
||||
if (selected.length === 0) {
|
||||
selectedLabel.textContent = placeholder;
|
||||
selectedLabel.classList.add('text-muted-foreground');
|
||||
} else {
|
||||
selectedLabel.textContent = selected.map(opt => opt.dataset.label || opt.textContent.trim()).join(', ');
|
||||
selectedLabel.classList.remove('text-muted-foreground');
|
||||
}
|
||||
|
||||
value = selected.map(getValue);
|
||||
input.value = JSON.stringify(value);
|
||||
} else {
|
||||
const option = optionOrOptions;
|
||||
if (!option) return;
|
||||
selectedLabel.innerHTML = option.innerHTML;
|
||||
value = getValue(option);
|
||||
input.value = value;
|
||||
}
|
||||
|
||||
options.forEach(opt => {
|
||||
const isSelected = isMultiple ? selectedOptions.has(opt) : opt === optionOrOptions;
|
||||
if (isSelected) {
|
||||
opt.setAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
opt.removeAttribute('aria-selected');
|
||||
}
|
||||
});
|
||||
|
||||
if (triggerEvent) {
|
||||
selectComponent.dispatchEvent(new CustomEvent('change', {
|
||||
detail: { value },
|
||||
bubbles: true
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const closePopover = (focusOnTrigger = true) => {
|
||||
if (popover.getAttribute('aria-hidden') === 'true') return;
|
||||
|
||||
|
||||
if (filter) {
|
||||
const resetFilter = () => {
|
||||
filter.value = '';
|
||||
visibleOptions = [...options];
|
||||
options.forEach(opt => opt.setAttribute('aria-hidden', 'false'));
|
||||
allOptions.forEach(opt => opt.setAttribute('aria-hidden', 'false'));
|
||||
};
|
||||
|
||||
|
||||
if (hasTransition()) {
|
||||
popover.addEventListener('transitionend', resetFilter, { once: true });
|
||||
} else {
|
||||
resetFilter();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const selectOption = (option) => {
|
||||
if (!option) return;
|
||||
|
||||
if (option.dataset.value != null) {
|
||||
updateValue(option);
|
||||
}
|
||||
|
||||
closePopover();
|
||||
|
||||
const event = new CustomEvent('change', {
|
||||
detail: { value: option.dataset.value },
|
||||
bubbles: true
|
||||
});
|
||||
selectComponent.dispatchEvent(event);
|
||||
setActiveOption(-1);
|
||||
};
|
||||
|
||||
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);
|
||||
const toggleMultipleValue = (option) => {
|
||||
if (selectedOptions.has(option)) {
|
||||
selectedOptions.delete(option);
|
||||
} else {
|
||||
selectedOptions.add(option);
|
||||
}
|
||||
updateValue(options.filter(opt => selectedOptions.has(opt)));
|
||||
};
|
||||
|
||||
const select = (value) => {
|
||||
if (isMultiple) {
|
||||
const option = options.find(opt => getValue(opt) === value && !selectedOptions.has(opt));
|
||||
if (!option) return;
|
||||
selectedOptions.add(option);
|
||||
updateValue(options.filter(opt => selectedOptions.has(opt)));
|
||||
} else {
|
||||
const option = options.find(opt => getValue(opt) === value);
|
||||
if (!option) return;
|
||||
if (input.value !== value) {
|
||||
updateValue(option);
|
||||
}
|
||||
closePopover();
|
||||
}
|
||||
};
|
||||
|
||||
const deselect = (value) => {
|
||||
if (!isMultiple) return;
|
||||
const option = options.find(opt => getValue(opt) === value && selectedOptions.has(opt));
|
||||
if (!option) return;
|
||||
selectedOptions.delete(option);
|
||||
updateValue(options.filter(opt => selectedOptions.has(opt)));
|
||||
};
|
||||
|
||||
const toggle = (value) => {
|
||||
if (!isMultiple) return;
|
||||
const option = options.find(opt => getValue(opt) === value);
|
||||
if (!option) return;
|
||||
toggleMultipleValue(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 => {
|
||||
const optionText = (option.dataset.label || option.textContent).trim().toLowerCase();
|
||||
const matches = optionText.includes(searchTerm);
|
||||
allOptions.forEach(option => {
|
||||
if (option.hasAttribute('data-force')) {
|
||||
option.setAttribute('aria-hidden', 'false');
|
||||
if (options.includes(option)) {
|
||||
visibleOptions.push(option);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const optionText = (option.dataset.filter || option.textContent).trim().toLowerCase();
|
||||
const keywordList = (option.dataset.keywords || '')
|
||||
.toLowerCase()
|
||||
.split(/[\s,]+/)
|
||||
.filter(Boolean);
|
||||
const matchesKeyword = keywordList.some(keyword => keyword.includes(searchTerm));
|
||||
const matches = optionText.includes(searchTerm) || matchesKeyword;
|
||||
option.setAttribute('aria-hidden', String(!matches));
|
||||
if (matches) {
|
||||
if (matches && options.includes(option)) {
|
||||
visibleOptions.push(option);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
filter.addEventListener('input', filterOptions);
|
||||
}
|
||||
|
||||
let initialOption = options.find(opt => opt.dataset.value === input.value);
|
||||
if (!initialOption && options.length > 0) initialOption = options[0];
|
||||
// Initialization
|
||||
if (isMultiple) {
|
||||
const ariaSelected = options.filter(opt => opt.getAttribute('aria-selected') === 'true');
|
||||
try {
|
||||
const parsed = JSON.parse(input.value || '[]');
|
||||
const validValues = new Set(options.map(getValue));
|
||||
const initialValues = Array.isArray(parsed) ? parsed.filter(v => validValues.has(v)) : [];
|
||||
|
||||
updateValue(initialOption);
|
||||
const initialOptions = [];
|
||||
if (initialValues.length > 0) {
|
||||
// Match values to options in order, allowing duplicates
|
||||
initialValues.forEach(val => {
|
||||
const opt = options.find(o => getValue(o) === val && !initialOptions.includes(o));
|
||||
if (opt) initialOptions.push(opt);
|
||||
});
|
||||
} else {
|
||||
initialOptions.push(...ariaSelected);
|
||||
}
|
||||
|
||||
updateValue(initialOptions, false);
|
||||
} catch (e) {
|
||||
updateValue(ariaSelected, false);
|
||||
}
|
||||
} else {
|
||||
const initialOption = options.find(opt => getValue(opt) === input.value) || options[0];
|
||||
if (initialOption) updateValue(initialOption, false);
|
||||
}
|
||||
|
||||
const handleKeyNavigation = (event) => {
|
||||
const isPopoverOpen = popover.getAttribute('aria-hidden') === 'false';
|
||||
@@ -131,17 +235,25 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
closePopover();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
if (activeIndex > -1) {
|
||||
selectOption(options[activeIndex]);
|
||||
const option = options[activeIndex];
|
||||
if (isMultiple) {
|
||||
toggleMultipleValue(option);
|
||||
} else {
|
||||
if (input.value !== getValue(option)) {
|
||||
updateValue(option);
|
||||
}
|
||||
closePopover();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -173,21 +285,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);
|
||||
@@ -197,7 +319,7 @@
|
||||
document.dispatchEvent(new CustomEvent('basecoat:popover', {
|
||||
detail: { source: selectComponent }
|
||||
}));
|
||||
|
||||
|
||||
if (filter) {
|
||||
if (hasTransition()) {
|
||||
popover.addEventListener('transitionend', () => {
|
||||
@@ -210,17 +332,10 @@
|
||||
|
||||
popover.setAttribute('aria-hidden', 'false');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
|
||||
|
||||
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' });
|
||||
}
|
||||
};
|
||||
@@ -236,8 +351,24 @@
|
||||
|
||||
listbox.addEventListener('click', (event) => {
|
||||
const clickedOption = event.target.closest('[role="option"]');
|
||||
if (clickedOption) {
|
||||
selectOption(clickedOption);
|
||||
if (!clickedOption) return;
|
||||
|
||||
const option = options.find(opt => opt === clickedOption);
|
||||
if (!option) return;
|
||||
|
||||
if (isMultiple) {
|
||||
toggleMultipleValue(option);
|
||||
setActiveOption(options.indexOf(option));
|
||||
if (filter) {
|
||||
filter.focus();
|
||||
} else {
|
||||
trigger.focus();
|
||||
}
|
||||
} else {
|
||||
if (input.value !== getValue(option)) {
|
||||
updateValue(option);
|
||||
}
|
||||
closePopover();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -254,26 +385,48 @@
|
||||
});
|
||||
|
||||
popover.setAttribute('aria-hidden', 'true');
|
||||
|
||||
selectComponent.selectByValue = selectByValue;
|
||||
|
||||
// Public API
|
||||
Object.defineProperty(selectComponent, 'value', {
|
||||
get: () => {
|
||||
if (isMultiple) {
|
||||
return options.filter(opt => selectedOptions.has(opt)).map(getValue);
|
||||
} else {
|
||||
return input.value;
|
||||
}
|
||||
},
|
||||
set: (val) => {
|
||||
if (isMultiple) {
|
||||
const values = Array.isArray(val) ? val : (val != null ? [val] : []);
|
||||
const opts = [];
|
||||
values.forEach(v => {
|
||||
const opt = options.find(o => getValue(o) === v && !opts.includes(o));
|
||||
if (opt) opts.push(opt);
|
||||
});
|
||||
updateValue(opts);
|
||||
} else {
|
||||
const option = options.find(opt => getValue(opt) === val);
|
||||
if (option) {
|
||||
updateValue(option);
|
||||
closePopover();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
selectComponent.select = select;
|
||||
selectComponent.selectByValue = select; // Backward compatibility alias
|
||||
if (isMultiple) {
|
||||
selectComponent.deselect = deselect;
|
||||
selectComponent.toggle = toggle;
|
||||
selectComponent.selectAll = () => updateValue(options);
|
||||
selectComponent.selectNone = () => updateValue([]);
|
||||
}
|
||||
selectComponent.dataset.selectInitialized = true;
|
||||
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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,206 @@
|
||||
{#
|
||||
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 or HTML", icon: "HTML", url: "href", 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. If url is provided, renders as <a role="menuitem"> instead of <div role="menuitem">. Label supports HTML (marked as safe).
|
||||
@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 | safe }}</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 %}
|
||||
{% if item.url %}
|
||||
<a
|
||||
id="{{ item_id }}"
|
||||
href="{{ item.url }}"
|
||||
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 %}
|
||||
{% if key != "href" %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
>
|
||||
{% if item.icon %}{{ item.icon | safe }}{% endif %}
|
||||
{{ item.label | safe }}
|
||||
</a>
|
||||
{% else %}
|
||||
<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 %}
|
||||
>
|
||||
{% if item.icon %}{{ item.icon | safe }}{% endif %}
|
||||
{{ item.label | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
@@ -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 %}
|
||||
@@ -102,6 +102,7 @@
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
>
|
||||
{% if item.icon %}{{ item.icon | safe }}{% endif %}
|
||||
{{ item.label | safe }}
|
||||
</a>
|
||||
{% else %}
|
||||
@@ -114,9 +115,10 @@
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
>
|
||||
{% if item.icon %}{{ item.icon | safe }}{% endif %}
|
||||
{{ item.label | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
{% endmacro %}
|
||||
|
||||
+36
-16
@@ -2,8 +2,10 @@
|
||||
Renders a select or combobox component.
|
||||
|
||||
@param id {string} [optional] - Unique identifier for the select component.
|
||||
@param selected {string} [optional] - The initially selected value.
|
||||
@param selected {string|array} [optional] - The initially selected value(s).
|
||||
@param name {string} [optional] - The name attribute for the hidden input storing the selected value.
|
||||
@param multiple {boolean} [optional] [default=false] - Enables multiple selection mode.
|
||||
@param placeholder {string} [optional] - Placeholder text shown when no options are selected (multiple mode only).
|
||||
@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 popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
|
||||
@@ -17,6 +19,8 @@
|
||||
selected=None,
|
||||
name=None,
|
||||
items=None,
|
||||
multiple=false,
|
||||
placeholder=None,
|
||||
main_attrs={},
|
||||
trigger_attrs={},
|
||||
popover_attrs={},
|
||||
@@ -27,8 +31,15 @@
|
||||
) %}
|
||||
{% set id = id or ("select-" + (range(100000, 999999) | random | string)) %}
|
||||
|
||||
{% if selected is defined and selected is iterable and selected is not string %}
|
||||
{% set selectedArray = selected %}
|
||||
{% elif selected is defined %}
|
||||
{% set selectedArray = [selected] %}
|
||||
{% else %}
|
||||
{% set selectedArray = [] %}
|
||||
{% endif %}
|
||||
{% set first_option = [] %}
|
||||
{% set selected_option = [] %}
|
||||
{% set selected_options = [] %}
|
||||
|
||||
{% if items %}
|
||||
{% for item in items %}
|
||||
@@ -37,33 +48,41 @@
|
||||
{% if not first_option[0] %}
|
||||
{% set first_option = (first_option.push(sub_item), first_option) %}
|
||||
{% endif %}
|
||||
{% if selected and sub_item.value == selected and not selected_option[0] %}
|
||||
{% set selected_option = (selected_option.push(sub_item), selected_option) %}
|
||||
{% if sub_item.value in selectedArray %}
|
||||
{% set selected_options = (selected_options.push(sub_item), selected_options) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% if not first_option[0] %}
|
||||
{% set first_option = (first_option.push(item), first_option) %}
|
||||
{% endif %}
|
||||
{% if selected and item.value == selected and not selected_option[0] %}
|
||||
{% set selected_option = (selected_option.push(item), selected_option) %}
|
||||
{% if item.value in selectedArray %}
|
||||
{% set selected_options = (selected_options.push(item), selected_options) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% set default_option = selected_option[0] or first_option[0] or None %}
|
||||
{% set default_option = (selected_options[0] if selected_options[0] else (first_option[0] if first_option[0] else None)) %}
|
||||
{% set labels = [] %}
|
||||
{% if multiple and selected_options %}
|
||||
{% for opt in selected_options %}
|
||||
{% set labels = (labels.push(opt.label), labels) %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% set default_label = (labels | join(', ') if (multiple and selected_options) else ((placeholder or '') if (multiple and not selected_options) else (default_option.label if default_option else ''))) %}
|
||||
|
||||
<div
|
||||
id="{{ id }}"
|
||||
class="select {{ main_attrs.class }}"
|
||||
{% if multiple and placeholder %}data-placeholder="{{ placeholder }}"{% endif %}
|
||||
{% for key, value in main_attrs %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-outline justify-between font-normal {{ trigger_attrs.class }}"
|
||||
class="btn-outline {{ trigger_attrs.class }}"
|
||||
id="{{ id }}-trigger"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="false"
|
||||
@@ -72,7 +91,7 @@
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
<span class="truncate">{{ default_option.label }}</span>
|
||||
<span class="truncate">{{ default_label }}</span>
|
||||
{% if is_combobox %}
|
||||
<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-chevrons-up-down-icon lucide-chevrons-up-down text-muted-foreground opacity-50 shrink-0"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
|
||||
{% else %}
|
||||
@@ -110,12 +129,13 @@
|
||||
id="{{ id }}-listbox"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="{{ id }}-trigger"
|
||||
{% if multiple %}aria-multiselectable="true"{% endif %}
|
||||
{% for key, value in listbox_attrs %}
|
||||
{{ key }}="{{ value }}"
|
||||
{% endfor %}
|
||||
>
|
||||
{% if items and items.length > 0 %}
|
||||
{{ render_select_items(items, default_option.value, id ~ "-items" if id else "items") }}
|
||||
{{ render_select_items(items, selectedArray, id ~ "-items" if id else "items") }}
|
||||
{% else %}
|
||||
{{ caller() if caller }}
|
||||
{% endif %}
|
||||
@@ -124,7 +144,7 @@
|
||||
<input
|
||||
type="hidden"
|
||||
name="{{ name or id ~ '-value' }}"
|
||||
value="{{ (default_option.value if default_option) or '' }}"
|
||||
value="{% if multiple %}{{ selectedArray | dump }}{% else %}{{ (default_option.value if default_option) or '' }}{% endif %}"
|
||||
{% for key, value in input_attrs %}
|
||||
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
@@ -138,7 +158,7 @@
|
||||
@param items {array} - The array of items to render.
|
||||
@param parent_id_prefix {string} [optional] - The prefix for the item id.
|
||||
#}
|
||||
{% macro render_select_items(items, selected, parent_id_prefix="items") %}
|
||||
{% macro render_select_items(items, selectedArray, parent_id_prefix="items") %}
|
||||
{% for item in items %}
|
||||
{% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
|
||||
{% if item.type == "group" %}
|
||||
@@ -153,7 +173,7 @@
|
||||
{% endif %}
|
||||
>
|
||||
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
|
||||
{{ render_select_items(item.items, selected, item_id) if item.items }}
|
||||
{{ render_select_items(item.items, selectedArray, item_id) if item.items }}
|
||||
</div>
|
||||
{% elif item.type == "separator" %}
|
||||
<hr role="separator" />
|
||||
@@ -161,8 +181,8 @@
|
||||
<div
|
||||
id="{{ item_id }}"
|
||||
role="option"
|
||||
data-value="{{ item.value }}"
|
||||
{% if selected == item.value %}aria-selected="true"{% endif %}
|
||||
{% if item.value is defined and item.value is not none %}data-value="{{ item.value }}"{% endif %}
|
||||
{% if item.value in selectedArray %}aria-selected="true"{% endif %}
|
||||
{% if item.attrs %}
|
||||
{% for key, value in item.attrs %}
|
||||
{{ key }}="{{ value }}"
|
||||
@@ -173,4 +193,4 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
{% endmacro %}
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
>
|
||||
<summary aria-controls="submenu-{{ item_id }}-content">
|
||||
{% if item.icon %}{{ item.icon | safe }}{% endif %}
|
||||
{{ item.label }}
|
||||
{{ item.label | safe }}
|
||||
</summary>
|
||||
<ul id="submenu-{{ item_id }}-content">
|
||||
{{ render_sidebar_content(item.items, item_id) if item.items }}
|
||||
@@ -136,7 +136,7 @@
|
||||
{% endif %}
|
||||
>
|
||||
{% if item.icon %}{{ item.icon | safe }}{% endif %}
|
||||
<span>{{ item.label }}</span>
|
||||
<span>{{ item.label | safe }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
+12
-3
@@ -5,6 +5,8 @@
|
||||
@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 attrs {object} [optional] - Additional HTML attributes for the main toaster container div.
|
||||
|
||||
Supports caller() for custom toast markup.
|
||||
#}
|
||||
{% macro toaster(
|
||||
id="toaster",
|
||||
@@ -24,9 +26,11 @@
|
||||
title=item.title,
|
||||
description=item.description,
|
||||
action=item.action,
|
||||
cancel=item.cancel
|
||||
cancel=item.cancel,
|
||||
attrs=item.attrs
|
||||
) }}
|
||||
{% endfor %}
|
||||
{{ caller() if caller }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -44,20 +48,25 @@
|
||||
- label {string}: Button text.
|
||||
- onclick {string}: JavaScript code to execute on click.
|
||||
- url {string}: URL for an anchor link button.
|
||||
@param attrs {object} [optional] - Additional HTML attributes for the toast div.
|
||||
#}
|
||||
{% macro toast(
|
||||
category="success",
|
||||
title="",
|
||||
description="",
|
||||
action=None,
|
||||
cancel=None
|
||||
cancel=None,
|
||||
attrs={}
|
||||
) %}
|
||||
<div
|
||||
class="toast"
|
||||
class="toast {{ attrs.class }}"
|
||||
role="{{ 'alert' if category == 'error' else 'status' }}"
|
||||
aria-atomic="true"
|
||||
aria-hidden="false"
|
||||
{% if category %}data-category="{{ category }}"{% endif %}
|
||||
{% for key, value in attrs %}
|
||||
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
<div class="toast-content">
|
||||
{% if category in ["error", "success", "info", "warning"] %}
|
||||
|
||||
Reference in New Issue
Block a user