From ab44d6d48afc0535fe22f78f0ea2b1c27c1a755a Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Sun, 2 Nov 2025 11:56:40 +0800 Subject: [PATCH] Adding empty, field and item, working on input group --- AGENTS.md | 18 + docs/css/custom.css | 2 +- docs/css/styles.css | 138 ++++++- docs/src/_includes/layouts/base.njk | 1 - docs/src/_includes/macros/code_block.njk | 2 +- docs/src/_includes/partials/sidebar.njk | 22 +- docs/src/assets/styles.css | 471 +++++++++++++++++++++-- docs/src/components/accordion.njk | 4 +- docs/src/components/alert.njk | 17 +- docs/src/components/avatar.njk | 21 +- docs/src/components/breadcrumb.njk | 17 +- docs/src/components/button-group.njk | 12 +- docs/src/components/command.njk | 182 ++++++++- docs/src/components/dialog.njk | 4 +- docs/src/components/empty.njk | 209 ++++++++++ docs/src/components/field.njk | 316 +++++++++++++++ docs/src/components/input-group.njk | 247 ++++++++++++ docs/src/components/item.njk | 311 +++++++++++++++ docs/src/components/kbd.njk | 10 +- package-lock.json | 8 +- package.json | 3 +- packages/cli/package.json | 3 +- src/css/basecoat.css | 105 ++--- src/jinja/command.html.jinja | 96 ++--- src/jinja/dialog.html.jinja | 6 +- src/js/command.js | 117 +++--- src/js/select.js | 9 +- src/nunjucks/command.njk | 96 ++--- src/nunjucks/dialog.njk | 6 +- 29 files changed, 2154 insertions(+), 299 deletions(-) create mode 100644 docs/src/components/empty.njk create mode 100644 docs/src/components/field.njk create mode 100644 docs/src/components/input-group.njk create mode 100644 docs/src/components/item.njk diff --git a/AGENTS.md b/AGENTS.md index 754cfdc..1247087 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,24 @@ Basecoat is a vanilla JavaScript/HTML/CSS port of shadcn/ui components. It reuse - 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 `
`) + - Use `:is()` and `:has()` for compact selectors + - Use `isolation: isolate` to contain z-index stacking contexts + - Use `data-*` attributes for styling states, `aria-*` for accessibility semantics +- **Component patterns**: + - Dialog variants use wrapper classes (e.g., `.command-dialog` wraps `.command`) + - JavaScript components register with `window.basecoat.register()` + - Emit custom events for user actions (e.g., `command:select`) + - Filter disabled options (`aria-disabled="true"`) from all interactions +- **When NOT to create a component**: + - If it's just composition of existing elements (users can do it with standard HTML/Tailwind) + - If behavior is fully native (e.g., fieldset, legend) + ## Common tasks - Update a component's CSS/JS: Edit `src/css/basecoat.css` and/or `src/js/*.js`. diff --git a/docs/css/custom.css b/docs/css/custom.css index 37f4d57..44236ba 100644 --- a/docs/css/custom.css +++ b/docs/css/custom.css @@ -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; diff --git a/docs/css/styles.css b/docs/css/styles.css index 029084f..c1ddbaf 100644 --- a/docs/css/styles.css +++ b/docs/css/styles.css @@ -5,4 +5,140 @@ @import "./themes/claude.css"; @import "./themes/doom-64.css"; -@import "./themes/supabase.css"; \ No newline at end of file +@import "./themes/supabase.css"; + + +/* @theme inline { + --breakpoint-3xl: 1600px; + --breakpoint-4xl: 2000px; + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --color-surface: var(--surface); + --color-surface-foreground: var(--surface-foreground); + --color-code: var(--code); + --color-code-foreground: var(--code-foreground); + --color-code-highlight: var(--code-highlight); + --color-code-number: var(--code-number); + --color-selection: var(--selection); + --color-selection-foreground: var(--selection-foreground); + } + + :root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: var(--color-blue-300); + --chart-2: var(--color-blue-500); + --chart-3: var(--color-blue-600); + --chart-4: var(--color-blue-700); + --chart-5: var(--color-blue-800); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + --surface: oklch(0.98 0 0); + --surface-foreground: var(--foreground); + --code: var(--surface); + --code-foreground: var(--surface-foreground); + --code-highlight: oklch(0.96 0 0); + --code-number: oklch(0.56 0 0); + --selection: oklch(0.145 0 0); + --selection-foreground: oklch(1 0 0); + } + + .dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.269 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.371 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: var(--color-blue-300); + --chart-2: var(--color-blue-500); + --chart-3: var(--color-blue-600); + --chart-4: var(--color-blue-700); + --chart-5: var(--color-blue-800); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.439 0 0); + --surface: oklch(0.2 0 0); + --surface-foreground: oklch(0.708 0 0); + --code: var(--surface); + --code-foreground: var(--surface-foreground); + --code-highlight: oklch(0.27 0 0); + --code-number: oklch(0.72 0 0); + --selection: oklch(0.922 0 0); + --selection-foreground: oklch(0.205 0 0); + } */ \ No newline at end of file diff --git a/docs/src/_includes/layouts/base.njk b/docs/src/_includes/layouts/base.njk index fac8ac7..d93d3c5 100644 --- a/docs/src/_includes/layouts/base.njk +++ b/docs/src/_includes/layouts/base.njk @@ -99,7 +99,6 @@ {% if head %}{{ head | safe }}{% endif %} diff --git a/docs/src/_includes/macros/code_block.njk b/docs/src/_includes/macros/code_block.njk index af87e0a..5495c6c 100644 --- a/docs/src/_includes/macros/code_block.njk +++ b/docs/src/_includes/macros/code_block.njk @@ -1,6 +1,6 @@ {% macro code_block(code, language="html", class="my-4") %}
-
{{- code | escape -}}
+
{{- code | escape -}}
+ +
+{% endset %} +{{ code_block(code_simple | prettyHtml, "html") }}

Examples

@@ -216,7 +224,7 @@ toc: {{ code_preview("button-group-dropdown-menu", code_dropdown_menu | prettyHtml) }} -

Popover

+

Popover

{% set code_popover %}
diff --git a/docs/src/components/command.njk b/docs/src/components/command.njk index ca2be6d..f98c155 100644 --- a/docs/src/components/command.njk +++ b/docs/src/components/command.njk @@ -5,6 +5,20 @@ 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: @@ -19,15 +33,15 @@ toc: {% macro command_items(suggestions_id="suggestions", settings_id="settings") %}
Suggestions -
+
{% lucide "calendar" %} Calendar
-
+
{% lucide "smile" %} Search Emoji
-
+
{% lucide "calculator" %} Calculator
@@ -35,17 +49,17 @@ toc:
Settings -
+
{% lucide "user" %} Profile ⌘P
-
+
{% lucide "credit-card" %} Billing ⌘B
-
+
{% lucide "settings" %} Settings ⌘S @@ -66,14 +80,152 @@ toc:

Usage

+

HTML + JavaScript

+ +

Step 1: Include the JavaScript files

+ +
+

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

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

Step 2: Add your command HTML

+ +
+

You can use the command menu standalone:

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

Wrap the command menu in a <dialog class="command-dialog"> to display in a dialog:

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

HTML structure

+ +
+
+
<dialog class="command-dialog"> Optional
+
For dialog variant, wraps a <div class="command"> to display as a modal. Uses native <dialog> behavior and requires aria-label or aria-labelledby for accessibility. +
+
<div class="command">
+
Wraps around the entire command component. Can be used standalone or inside a dialog. +
+
<header>
+
Contains the search input to filter command options. +
+
<input type="text">
+
The search input. Should have: +
    +
  • role="combobox": indicates the input controls a menu with filtering
  • +
  • aria-expanded="true": the menu is always expanded
  • +
  • aria-controls="{ MENU_ID }": points to the menu's id
  • +
+
+
+
+
<div role="menu">
+
The menu containing the command items. Should have id="{ MENU_ID }" refered to by the input's aria-controls attribute. +
+
<div role="menuitem">
+
Command item that triggers an action when clicked. In a command-dialog, clicking a menuitem automatically closes the dialog unless the data-keep-command-open attribute is present. Optional attributes: +
    +
  • data-label: custom label for filtering (if different from text content)
  • +
  • data-keywords: additional keywords for enhanced filtering
  • +
  • aria-disabled="true": marks item as disabled (excluded from keyboard navigation and filtering)
  • +
+
+
<hr role="separator"> Optional
+
Separator between groups/items. Hidden when filtering is active.
+
<div role="group"> Optional
+
Group of items, can have aria-labelledby attribute to link to a heading.
+
<span role="heading">
+
Group heading, must have an id attribute if you use the aria-labelledby attribute on the group.
+
+
+
+
+
+
+
+
+ +

JavaScript events

+ +
+
+
basecoat:initialized
+
Once the component is fully initialized, it dispatches a custom (non-bubbling) basecoat:initialized event on itself.
+
+
+ +

Jinja and Nunjucks

+ +
+

You can use the command() or command_dialog() Nunjucks or Jinja macros for this component.

+
+ + + +{% set raw_code %}{% raw %}{% call command() %} +
+ ... + Calendar +
+
+ ... + Settings +
+{% endcall %}{% endraw %}{% endset %} +{{ code_block(raw_code, "jinja") }} +

Examples

Dialog

-

Press ⌘J

+

You can use some simple JavaScript to open the command menu with a keyboard shortcut (in this case ⌘J).

{% set code_dialog %} @@ -83,7 +235,7 @@ toc: onclick="document.getElementById('demo-command-dialog').showModal()" > Open command menu - ⌘ J + ⌘J {% call command_dialog(id="demo-command-dialog") %} @@ -103,19 +255,7 @@ toc: } } }); - - const cmdDialog = document.getElementById('demo-command-dialog'); - cmdDialog?.addEventListener('command:select', (e) => { - console.log('Selected command:', e.detail.value); - cmdDialog.close(); - }); {% endset %} -{{ code_preview("command-dialog", code_dialog | prettyHtml) }} - -
-

To show the command menu in a dialog, use the command_dialog macro.

-
- -{{ code_block(code_dialog | prettyHtml, "html") }} +{{ code_preview("command-dialog", code_dialog | prettyHtml) }} \ No newline at end of file diff --git a/docs/src/components/dialog.njk b/docs/src/components/dialog.njk index a734cec..dafa34c 100644 --- a/docs/src/components/dialog.njk +++ b/docs/src/components/dialog.njk @@ -69,8 +69,8 @@ toc:
  • onclick: closes the dialog when the backdrop is clicked.
  • -
    <article>
    -
    Wraps the content of the dialog. It includes an onclick attribute to prevent from closing the dialog if we have an onclick event on <dialog>. +
    <div>
    +
    Wraps the content of the dialog.
    <header>
    Contains the header for the dialog: diff --git a/docs/src/components/empty.njk b/docs/src/components/empty.njk new file mode 100644 index 0000000..0da962b --- /dev/null +++ b/docs/src/components/empty.njk @@ -0,0 +1,209 @@ +--- +layout: layouts/page.njk +title: Empty +description: Display empty states with icons, titles, descriptions, and actions. +toc: + - label: Usage + id: usage + children: + - label: HTML structure + id: html-structure + - 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 %} + +
    + {% lucide "circle-alert" %} +

    There is no dedicated Empty component in Basecoat.

    +
    + +{% set code_default %} +
    +
    +
    + {% lucide "folder-code" %} +
    +

    No Projects Yet

    +

    + You haven't created any projects yet. Get started by creating your first project. +

    +
    +
    +
    + + +
    +
    + + Learn More + {% lucide "arrow-up-right" %} + +
    +{% endset %} + +{{ code_preview("empty-default", code_default) }} + +

    Usage

    + +
    +

    The example simply uses Tailwind utility classes to create an empty state layout.

    +
    + +{{ code_block(code_default) }} + +

    HTML structure

    + +
    +
    +
    <div>
    +
    Main container with centered layout utilities: +
      +
    • flex flex-col items-center justify-center: centers content
    • +
    • gap-6: spacing between sections
    • +
    • p-6 md:p-12: responsive padding
    • +
    • text-center text-balance: centered text with better wrapping
    • +
    +
    +
    <header>
    +
    Header section containing the icon/avatar, title, and description: +
      +
    • max-w-sm: constrains width for readability
    • +
    • gap-2 or gap-3: spacing between elements
    • +
    +
    +
    <div> Optional
    +
    Media container for icon or avatar: +
      +
    • For icons: bg-muted text-foreground size-10 rounded-lg
    • +
    • For avatars: size-12 rounded-full grayscale
    • +
    • For avatar groups: flex -space-x-2 with ring utilities
    • +
    +
    +
    <h3>
    +
    Title with text-lg font-medium tracking-tight
    +
    <p>
    +
    Description with text-muted-foreground text-sm/relaxed
    +
    +
    +
    <section> Optional
    +
    Content area for actions (buttons, inputs, etc.): +
      +
    • max-w-sm or max-w-md: constrains width
    • +
    • flex flex-col items-center gap-3: vertical layout with spacing
    • +
    +
    +
    <a> or other elements Optional
    +
    Additional links or actions outside the main content section
    +
    +
    +
    +
    + +

    Examples

    + +

    Outline

    + +{% set code_outline %} +
    +
    +
    + {% lucide "cloud" %} +
    +

    Cloud Storage Empty

    +

    + Upload files to your cloud storage to access them anywhere. +

    +
    +
    + +
    +
    +{% endset %} + +{{ code_preview("empty-outline", code_outline) }} + +

    Avatar

    + +{% set code_avatar %} +
    +
    +
    + @shadcn +
    +

    User Offline

    +

    + This user is currently offline. You can leave a message to notify them or try again later. +

    +
    +
    + +
    +
    +{% endset %} + +{{ code_preview("empty-avatar", code_avatar) }} + +

    Avatar Group

    + +{% set code_avatar_group %} +
    +
    +
    +
    + @hunvreus + @shadcn + @adamwathan +
    +
    +

    No Team Members

    +

    + Invite your team to collaborate on this project. +

    +
    +
    + +
    +
    +{% endset %} + +{{ code_preview("empty-avatar-group", code_avatar_group) }} + +

    Input Group

    + +{% set code_input_group %} +
    +
    +

    404 - Not Found

    +

    + The page you're looking for doesn't exist. Try searching for what you need below. +

    +
    +
    +
    + + {% 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" } %} + / +
    +

    + Need help? Contact support +

    +
    +
    +{% endset %} + +{{ code_preview("empty-input-group", code_input_group) }} \ No newline at end of file diff --git a/docs/src/components/field.njk b/docs/src/components/field.njk new file mode 100644 index 0000000..6bf8bd4 --- /dev/null +++ b/docs/src/components/field.njk @@ -0,0 +1,316 @@ +--- +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 +--- + +{% from "macros/code_preview.njk" import code_preview %} +{% from "macros/code_block.njk" import code_block %} + +{% set code_demo %} +
    +
    + Payment Method +

    All transactions are secure and encrypted

    + +
    + + +
    + +
    + + +

    Enter your 16-digit card number

    +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + + +
    + Billing Address +

    The billing address associated with your payment method

    + +
    + + +
    +
    + +
    +
    + + +
    +
    + +
    + + +
    +
    +{% endset %} + +{{ code_preview("field-demo", code_demo) }} + +

    Usage

    + +
    +

    Field provides semantic HTML patterns for accessible forms:

    +
      +
    • <fieldset> and <legend> for grouping related fields
    • +
    • role="group" with .field class for individual fields
    • +
    • .description class for helper text
    • +
    • .error class with role="alert" for error messages
    • +
    • aria-describedby to associate descriptions and errors with inputs
    • +
    +
    + +{{ code_block(code) }} + +

    Examples

    + +

    Input

    + +{% set code_input %} +
    +
    + + +

    Choose a unique username for your account

    +
    + +
    + + +

    Must be at least 8 characters

    + +
    +
    +{% endset %} + +{{ code_preview("field-input", code_input) }} + +

    Textarea

    + +{% set code_textarea %} +
    + + +

    Brief description for your profile (max 200 characters)

    +
    +{% endset %} + +{{ code_preview("field-textarea", code_textarea) }} + +

    Select

    + +{% set code_select %} +
    + + +

    Choose your country of residence

    +
    +{% endset %} + +{{ code_preview("field-select", code_select) }} + +

    Fieldset

    + +
    +

    Group related fields with <fieldset class="fieldset"> and <legend>.

    +
    + +{% set code_fieldset %} +
    +
    + Profile +

    This information will be displayed on your profile

    + +
    + + +

    Your first and last name

    +
    + +
    + + + +
    + +
    + + +
    +
    + + +
    +{% endset %} + +{{ code_preview("field-fieldset", code_fieldset) }} + +

    Checkbox

    + +{% set code_checkbox %} +
    + Tasks +

    Select the tasks you want to receive updates for

    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +{% endset %} + +{{ code_preview("field-checkbox", code_checkbox) }} + +

    Radio Group

    + +{% set code_radio %} +
    + Notify me about +

    Choose which notifications you want to receive

    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +{% endset %} + +{{ code_preview("field-radio", code_radio) }} + +

    Switch

    + +
    +

    Use horizontal layout for switches by wrapping in a flex container.

    +
    + +{% set code_switch %} +
    + Notifications +

    Configure how you receive notifications

    + +
    +
    +
    + +

    Receive emails about new products and features

    +
    + +
    + +
    +
    + +

    Receive emails about account security

    +
    + +
    +
    +
    +{% endset %} + +{{ code_preview("field-switch", code_switch) }} + +

    Accessibility

    + +
    +
      +
    • Always use <label> elements with for attribute matching input id
    • +
    • Associate helper text and errors using aria-describedby on the input
    • +
    • Use aria-invalid="true" on inputs with errors
    • +
    • Use role="alert" on error messages for screen reader announcements
    • +
    • Group related fields with <fieldset> and <legend>
    • +
    • Use role="group" for individual field wrappers
    • +
    +
    + + + diff --git a/docs/src/components/input-group.njk b/docs/src/components/input-group.njk new file mode 100644 index 0000000..c5a89da --- /dev/null +++ b/docs/src/components/input-group.njk @@ -0,0 +1,247 @@ +--- +layout: layouts/page.njk +title: Input Group +description: Display additional information or actions alongside inputs using simple HTML patterns. +toc: + - label: Usage + id: usage + - label: Examples + id: examples + children: + - label: Icon Prefix + id: example-icon-prefix + - label: Icon Suffix + id: example-icon-suffix + - label: Text Prefix/Suffix + id: example-text + - label: Button Suffix + id: example-button + - label: Multiple Elements + id: example-multiple + - label: Textarea + id: example-textarea +--- + +{% from "macros/code_preview.njk" import code_preview %} +{% from "macros/code_block.njk" import code_block %} + +{% set code_default %} +
    + +
    + {% lucide "search" %} +
    +
    +{% endset %} + +{{ code_preview("input-group-default", code_default) }} + +

    Usage

    + +
    +

    Input groups are achieved through composition using relative positioning and Tailwind utilities. No special component needed - just wrap your input with a positioned container and add your icons, text, or buttons.

    +

    Key patterns:

    +
      +
    • Use relative wrapper around input
    • +
    • Position content with absolute + top-1/2 -translate-y-1/2
    • +
    • Add padding to input (pl-* or pr-*) to make room for content
    • +
    • Use pointer-events-none for decorative icons
    • +
    +
    + +{{ code_block(code_default) }} + +

    Examples

    + +

    Icon Prefix

    + +
    +

    Add an icon at the start of the input field.

    +
    + +{% set code_icon_prefix %} +
    +
    + +
    + {% lucide "search" %} +
    +
    + +
    + +
    + {% lucide "mail" %} +
    +
    +
    +{% endset %} + +{{ code_preview("input-group-icon-prefix", code_icon_prefix) }} + +

    Icon Suffix

    + +
    +

    Add an icon at the end of the input field.

    +
    + +{% set code_icon_suffix %} +
    +
    + +
    + {% lucide "check" %} +
    +
    + +
    + +
    + {% lucide "credit-card" %} + {% lucide "info" %} +
    +
    +
    +{% endset %} + +{{ code_preview("input-group-icon-suffix", code_icon_suffix) }} + +

    Text Prefix/Suffix

    + +
    +

    Display text before or after the input value.

    +
    + +{% set code_text %} +
    +
    + +
    + $ +
    +
    + USD +
    +
    + +
    + +
    + https:// +
    +
    + +
    + +
    + @company.com +
    +
    +
    +{% endset %} + +{{ code_preview("input-group-text", code_text) }} + +

    Button Suffix

    + +
    +

    Add interactive buttons inside the input.

    +
    + +{% set code_button %} +
    +
    + + +
    + +
    + + +
    + +
    + + +
    +
    +{% endset %} + +{{ code_preview("input-group-button", code_button) }} + +

    Multiple Elements

    + +
    +

    Combine multiple icons, text, and buttons.

    +
    + +{% set code_multiple %} +
    +
    + +
    + {% lucide "search" %} +
    +
    + ⌘K +
    +
    + +
    + +
    + https:// +
    + +
    +
    +{% endset %} + +{{ code_preview("input-group-multiple", code_multiple) }} + +

    Textarea

    + +
    +

    Add buttons to textareas for actions like submit.

    +
    + +{% set code_textarea %} +
    +
    + + +
    + +
    + +
    + 120 characters left + +
    +
    +
    +{% endset %} + +{{ code_preview("input-group-textarea", code_textarea) }} + +

    Accessibility

    + +
    +
      +
    • Use pointer-events-none for decorative icons that shouldn't be interactive
    • +
    • Ensure buttons have proper aria-label or visible text
    • +
    • Maintain sufficient padding so text doesn't overlap with icons/buttons
    • +
    • Use semantic button types (type="button" vs type="submit")
    • +
    +
    + diff --git a/docs/src/components/item.njk b/docs/src/components/item.njk new file mode 100644 index 0000000..54afdda --- /dev/null +++ b/docs/src/components/item.njk @@ -0,0 +1,311 @@ +--- +layout: layouts/page.njk +title: Item +description: A versatile component that you can use to display any content. +toc: + - 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 %} + +
    + {% lucide "circle-alert" %} +

    There is no dedicated Item component in Basecoat.

    +
    + +{% set code_basic %} +
    +
    +
    +

    Basic Item

    +

    A simple item with title and description.

    +
    + +
    + + +
    + {% lucide "badge-check" %} +
    +
    +

    Your profile has been verified.

    +
    +
    + {% lucide "chevron-right" %} +
    +
    +
    +{% endset %} + +{{ code_preview("item-basic", code_basic | prettyHtml, class="w-full max-w-md") }} + +

    Examples

    + +

    Variants

    + +{% set code_variants %} +
    +
    +
    +

    Default Variant

    +

    Standard styling with subtle background and borders.

    +
    + +
    + +
    +
    +

    Outline Variant

    +

    Outlined style with clear borders and transparent background.

    +
    + +
    + +
    +
    +

    Muted Variant

    +

    Subdued appearance with muted colors for secondary content.

    +
    + +
    +
    +{% endset %} + +{{ code_preview("item-variants", code_variants) }} + +

    Size

    + +
    +

    Use different padding and gap sizes for compact or spacious layouts.

    +
    + +{{ code_preview("item-size", code_basic) }} + +

    Icon

    + +{% set code_icon %} +
    +
    + {% lucide "shield-alert" %} +
    +
    +

    Security Alert

    +

    New login detected from unknown device.

    +
    + +
    +{% endset %} + +{{ code_preview("item-icon", code_icon, class="max-w-lg") }} + + +

    Avatar

    + +{% set code_avatar %} +
    +
    + hunvreus +
    +

    hunvreus

    +

    Last seen 5 months ago

    +
    + +
    + +
    +
    + @hunvreus + @shadcn + @adamwathan +
    +
    +

    No Team Members

    +

    Invite your team to collaborate on this project.

    +
    + +
    +
    +{% endset %} + +{{ code_preview("item-avatar", code_avatar, class="w-full max-w-lg") }} + +

    Image

    + +{% set code_image %} + +{% endset %} + +{{ code_preview("item-image", code_image) }} + + +

    Group

    + +{% set code_group %} +
    +
    + Ronan Berder +
    +

    hunvreus

    +

    hunvreus@gmail.com

    +
    + +
    + + + +
    + shadcn +
    +

    shadcn

    +

    shadcn@vercel.com

    +
    + +
    + + + +
    + Adam Wathan +
    +

    adamwathan

    +

    adam.wathan@gmail.com

    +
    + +
    +
    +{% endset %} + +{{ code_preview("item-group", code_group, class="w-full max-w-md") }} + + +

    Header

    + +{% set code_header %} +
    +
    + v0-1.5-sm +
    +

    v0-1.5-sm

    +

    Everyday tasks and UI generation.

    +
    +
    + +
    + v0-1.5-lg +
    +

    v0-1.5-lg

    +

    Advanced thinking or reasoning.

    +
    +
    + +
    + v0-2.0-mini +
    +

    v0-2.0-mini

    +

    Open Source model for everyone.

    +
    +
    +
    +{% endset %} + +{{ code_preview("item-header", code_header) }} + + + +
    +

    Items can be rendered as links by using an <a> element. Hover and focus states are applied automatically.

    +
    + +{% set code_link %} + +{% endset %} + +{{ code_preview("item-link", code_link, class="w-full max-w-md") }} \ No newline at end of file diff --git a/docs/src/components/kbd.njk b/docs/src/components/kbd.njk index e545a65..5abbb02 100644 --- a/docs/src/components/kbd.njk +++ b/docs/src/components/kbd.njk @@ -37,7 +37,15 @@ toc:

    Usage

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

    Simply use the <kbd> element:

    +
    + +{% set code_simple %} +⌘K +{% endset %} + +{{ code_block(code_simple | prettyHtml, "html") }}

    Examples

    diff --git a/package-lock.json b/package-lock.json index 525ffcb..df412f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } } diff --git a/package.json b/package.json index 3fd1c46..afc3f99 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,7 @@ "css", "html", "jinja", - "nunjucks", - "alpinejs" + "nunjucks" ], "license": "MIT", "repository": { diff --git a/packages/cli/package.json b/packages/cli/package.json index af8a3dd..4b59715 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -30,8 +30,7 @@ "css", "html", "jinja", - "nunjucks", - "alpinejs" + "nunjucks" ], "repository": { "type": "git", diff --git a/src/css/basecoat.css b/src/css/basecoat.css index a83b4f4..5d15493 100644 --- a/src/css/basecoat.css +++ b/src/css/basecoat.css @@ -146,10 +146,6 @@ border-radius: var(--scrollbar-radius); } } - /* Alpine.js */ - [x-cloak] { - display: none !important; - } } /* Alert */ @@ -385,11 +381,9 @@ /* Button Group */ .button-group { - @apply inline-flex isolate; + @apply inline-flex w-fit items-stretch isolate; - > *:hover, > *:focus-visible, - > :is(.dropdown-menu, .popover) > button:hover, > :is(.dropdown-menu, .popover) > button:focus-visible { @apply relative z-10; } @@ -401,7 +395,7 @@ &:not([data-orientation='vertical']) { > *:not(:first-child), > :is(.dropdown-menu, .popover):not(:first-child) > button { - @apply -ml-px rounded-l-none; + @apply rounded-l-none border-l-0; } > *:not(:last-child), > :is(.dropdown-menu, .popover):not(:last-child) > button { @@ -416,7 +410,7 @@ } > *:not(:first-child) { - @apply -mt-px ml-0 rounded-t-none; + @apply rounded-t-none border-t-0; } > *:not(:last-child) { @apply rounded-b-none; @@ -451,7 +445,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; @@ -494,7 +488,7 @@ &::backdrop { @apply opacity-100; } - > article { + > * { @apply scale-100; } @@ -504,7 +498,7 @@ &::backdrop { @apply opacity-0; } - > article { + > * { @apply scale-95; } } @@ -512,7 +506,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 overflow-hidden rounded-lg border shadow-lg sm:max-w-lg max-h-[min(640px,calc(100%_-_2rem))]; @apply transition-all scale-95; @@ -530,10 +524,10 @@ > header input { @apply h-12; } - [role='listbox'] { + [role='menu'] { @apply px-2; - [role='option'] { + [role='menuitem'] { @apply py-3 px-2 [&_svg]:size-5; } [role='heading'] { @@ -554,11 +548,11 @@ @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='listbox'] { + [role='menu'] { @apply max-h-[300px] overflow-y-auto overflow-x-hidden p-1; - [role='option'] { - @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; + [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; @@ -567,7 +561,7 @@ [role='heading'] { @apply text-muted-foreground flex px-2 py-1.5 text-xs font-medium; } - [role='group']:not(:has([role='option']:not([aria-hidden='true']))) { + [role='group']:not(:has([role='menuitem']:not([aria-hidden='true']))) { @apply hidden; } [role='separator'] { @@ -583,7 +577,7 @@ @apply content-['No_results_found']; } } - &:not([data-command-initialized]) [role='option'] { + &:not([data-command-initialized]) [role='menuitem'] { @apply hover:bg-accent hover:text-accent-foreground; } &:has(> header input:not(:placeholder-shown)) [role='separator'] { @@ -603,7 +597,7 @@ &::backdrop { @apply opacity-100; } - > article { + > * { @apply scale-100; } @@ -613,7 +607,7 @@ &::backdrop { @apply opacity-0; } - > article { + > * { @apply scale-95; } } @@ -621,7 +615,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; @@ -692,21 +686,46 @@ } } +/* Field */ +@layer components { + .fieldset { + @apply flex flex-col gap-6; + + > legend { + @apply text-base font-medium mb-3; + } + + > .description { + @apply text-sm text-muted-foreground; + } + } + .field { + @apply flex flex-col w-full gap-3 data-[invalid=true]:text-destructive [&>*]:w-full [&>.sr-only]:w-auto; + + .description { + @apply text-sm text-muted-foreground; + } + .error { + @apply text-sm text-destructive font-medium; + } + } +} + /* Input */ @layer components { - .form input[type='text'], - .form input[type='email'], - .form input[type='password'], - .form input[type='number'], - .form input[type='file'], - .form input[type='tel'], - .form input[type='url'], - .form input[type='search'], - .form input[type='date'], - .form input[type='datetime-local'], - .form input[type='month'], - .form input[type='week'], - .form input[type='time'], + :is(.form, .field) input[type='text'], + :is(.form, .field) input[type='email'], + :is(.form, .field) input[type='password'], + :is(.form, .field) input[type='number'], + :is(.form, .field) input[type='file'], + :is(.form, .field) input[type='tel'], + :is(.form, .field) input[type='url'], + :is(.form, .field) input[type='search'], + :is(.form, .field) input[type='date'], + :is(.form, .field) input[type='datetime-local'], + :is(.form, .field) input[type='month'], + :is(.form, .field) input[type='week'], + :is(.form, .field) input[type='time'], .input[type='text'], .input[type='email'], .input[type='password'], @@ -735,7 +754,7 @@ /* 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; @@ -820,7 +839,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; @@ -832,7 +851,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%; @@ -881,7 +900,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]; @@ -1068,7 +1087,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; @@ -1130,7 +1149,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; } @@ -1196,8 +1215,6 @@ /* Tooltip */ @layer components { - - [data-tooltip] { @apply relative; diff --git a/src/jinja/command.html.jinja b/src/jinja/command.html.jinja index 63e63e7..b05feca 100644 --- a/src/jinja/command.html.jinja +++ b/src/jinja/command.html.jinja @@ -7,7 +7,7 @@ @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 listbox_attrs {object} [optional] - Additional HTML attributes for the listbox div. + @param menu_attrs {object} [optional] - Additional HTML attributes for the menu div. #} {% macro command( id=None, @@ -16,7 +16,7 @@ empty_text="No results found.", main_attrs={}, input_attrs={}, - listbox_attrs={} + menu_attrs={} ) %} {% set id = id or ("command-" ~ range(100000, 999999) | random | string) %}
    @@ -73,7 +73,7 @@ @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 listbox_attrs {object} [optional] - Additional HTML attributes for the listbox div. + @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( @@ -83,7 +83,7 @@ empty_text="No results found.", dialog_attrs={}, input_attrs={}, - listbox_attrs={}, + menu_attrs={}, open=False ) %} {% set id = id or ("command-dialog-" ~ range(100000, 999999) | random | string) %} @@ -97,54 +97,56 @@ {% 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 %} -
    + + -
    +
    {% endmacro %} {# Renders a list of items for the command component. - @param items {array} - The array of items to render. + @param items {array} - Array of items to render. Each item can be: + - { type: "item", label: "Text", content: "HTML", keywords: "search terms", disabled: true, attrs: {} } + - { type: "group", label: "Group Title", items: [...] } + - { type: "separator" } + Note: Use attrs to add data-label for alternative search text if needed. @param parent_id_prefix {string} [optional] - The prefix for the item id. #} {% macro render_command_items(items, parent_id_prefix="items") %} @@ -169,10 +171,8 @@ {% elif item.type == "item" or not item.type %}
    -
    +
    {% if title or description %}
    {% endif %} -
    +
    {% endmacro %} \ No newline at end of file diff --git a/src/js/command.js b/src/js/command.js index 21633ee..c4970ea 100644 --- a/src/js/command.js +++ b/src/js/command.js @@ -1,32 +1,36 @@ (() => { const initCommand = (container) => { const input = container.querySelector('header input'); - const listbox = container.querySelector('[role="listbox"]'); + const menu = container.querySelector('[role="menu"]'); - if (!input || !listbox) { + if (!input || !menu) { const missing = []; if (!input) missing.push('input'); - if (!listbox) missing.push('listbox'); + if (!menu) missing.push('menu'); console.error(`Command component initialization failed. Missing element(s): ${missing.join(', ')}`, container); return; } - const options = Array.from(listbox.querySelectorAll('[role="option"]')); - let visibleOptions = [...options]; + 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 setActiveOption = (index) => { - if (activeIndex > -1 && options[activeIndex]) { - options[activeIndex].classList.remove('active'); + const setActiveItem = (index) => { + if (activeIndex > -1 && menuItems[activeIndex]) { + menuItems[activeIndex].classList.remove('active'); } activeIndex = index; if (activeIndex > -1) { - const activeOption = options[activeIndex]; - activeOption.classList.add('active'); - if (activeOption.id) { - input.setAttribute('aria-activedescendant', activeOption.id); + const activeItem = menuItems[activeIndex]; + activeItem.classList.add('active'); + if (activeItem.id) { + input.setAttribute('aria-activedescendant', activeItem.id); } else { input.removeAttribute('aria-activedescendant'); } @@ -35,39 +39,29 @@ } }; - const filterOptions = () => { + const filterMenuItems = () => { const searchTerm = input.value.trim().toLowerCase(); - setActiveOption(-1); + setActiveItem(-1); - visibleOptions = []; - options.forEach(option => { - const optionText = (option.dataset.label || option.textContent).trim().toLowerCase(); - const keywords = (option.dataset.keywords || '').toLowerCase(); - const matches = optionText.includes(searchTerm) || keywords.includes(searchTerm); - option.setAttribute('aria-hidden', String(!matches)); - if (matches) { - visibleOptions.push(option); + visibleMenuItems = []; + allMenuItems.forEach(item => { + const itemText = (item.dataset.label || item.textContent).trim().toLowerCase(); + const keywords = (item.dataset.keywords || '').toLowerCase(); + const matches = itemText.includes(searchTerm) || keywords.includes(searchTerm); + item.setAttribute('aria-hidden', String(!matches)); + if (matches && menuItems.includes(item)) { + visibleMenuItems.push(item); } }); - if (visibleOptions.length > 0) { - setActiveOption(options.indexOf(visibleOptions[0])); - visibleOptions[0].scrollIntoView({ block: 'nearest' }); + if (visibleMenuItems.length > 0) { + setActiveItem(menuItems.indexOf(visibleMenuItems[0])); + visibleMenuItems[0].scrollIntoView({ block: 'nearest' }); } }; - const selectOption = (option) => { - if (!option) return; - - const event = new CustomEvent('command:select', { - detail: { value: option.dataset.value }, - bubbles: true - }); - container.dispatchEvent(event); - }; - - input.addEventListener('input', filterOptions); + input.addEventListener('input', filterMenuItems); const handleKeyNavigation = (event) => { if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End'].includes(event.key)) { @@ -77,21 +71,21 @@ if (event.key === 'Enter') { event.preventDefault(); if (activeIndex > -1) { - selectOption(options[activeIndex]); + menuItems[activeIndex]?.click(); } return; } - if (visibleOptions.length === 0) return; + if (visibleMenuItems.length === 0) return; event.preventDefault(); - const currentVisibleIndex = activeIndex > -1 ? visibleOptions.indexOf(options[activeIndex]) : -1; + const currentVisibleIndex = activeIndex > -1 ? visibleMenuItems.indexOf(menuItems[activeIndex]) : -1; let nextVisibleIndex = currentVisibleIndex; switch (event.key) { case 'ArrowDown': - if (currentVisibleIndex < visibleOptions.length - 1) { + if (currentVisibleIndex < visibleMenuItems.length - 1) { nextVisibleIndex = currentVisibleIndex + 1; } break; @@ -106,47 +100,42 @@ nextVisibleIndex = 0; break; case 'End': - nextVisibleIndex = visibleOptions.length - 1; + nextVisibleIndex = visibleMenuItems.length - 1; break; } if (nextVisibleIndex !== currentVisibleIndex) { - const newActiveOption = visibleOptions[nextVisibleIndex]; - setActiveOption(options.indexOf(newActiveOption)); - newActiveOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + const newActiveItem = visibleMenuItems[nextVisibleIndex]; + setActiveItem(menuItems.indexOf(newActiveItem)); + newActiveItem.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); + menu.addEventListener('mousemove', (event) => { + const menuItem = event.target.closest('[role="menuitem"]'); + if (menuItem && visibleMenuItems.includes(menuItem)) { + const index = menuItems.indexOf(menuItem); if (index !== activeIndex) { - setActiveOption(index); + setActiveItem(index); } } }); - listbox.addEventListener('mouseleave', () => { - if (visibleOptions.length > 0) { - setActiveOption(options.indexOf(visibleOptions[0])); - } else { - setActiveOption(-1); - } - }); - - listbox.addEventListener('click', (event) => { - const clickedOption = event.target.closest('[role="option"]'); - if (clickedOption && visibleOptions.includes(clickedOption)) { - selectOption(clickedOption); + 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 (visibleOptions.length > 0) { - setActiveOption(options.indexOf(visibleOptions[0])); - visibleOptions[0].scrollIntoView({ block: 'nearest' }); + if (visibleMenuItems.length > 0) { + setActiveItem(menuItems.indexOf(visibleMenuItems[0])); + visibleMenuItems[0].scrollIntoView({ block: 'nearest' }); } container.dataset.commandInitialized = true; diff --git a/src/js/select.js b/src/js/select.js index 8e10dc8..352dcb8 100644 --- a/src/js/select.js +++ b/src/js/select.js @@ -16,7 +16,8 @@ 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; @@ -69,7 +70,7 @@ const resetFilter = () => { filter.value = ''; visibleOptions = [...options]; - options.forEach(opt => opt.setAttribute('aria-hidden', 'false')); + allOptions.forEach(opt => opt.setAttribute('aria-hidden', 'false')); }; if (hasTransition()) { @@ -110,11 +111,11 @@ setActiveOption(-1); visibleOptions = []; - options.forEach(option => { + allOptions.forEach(option => { const optionText = (option.dataset.label || option.textContent).trim().toLowerCase(); const matches = optionText.includes(searchTerm); option.setAttribute('aria-hidden', String(!matches)); - if (matches) { + if (matches && options.includes(option)) { visibleOptions.push(option); } }); diff --git a/src/nunjucks/command.njk b/src/nunjucks/command.njk index 46b8e50..f3b6b84 100644 --- a/src/nunjucks/command.njk +++ b/src/nunjucks/command.njk @@ -7,7 +7,7 @@ @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 listbox_attrs {object} [optional] - Additional HTML attributes for the listbox div. + @param menu_attrs {object} [optional] - Additional HTML attributes for the menu div. #} {% macro command( id=None, @@ -16,7 +16,7 @@ empty_text="No results found.", main_attrs={}, input_attrs={}, - listbox_attrs={} + menu_attrs={} ) %} {% set id = id or ("command-" + (range(100000, 999999) | random | string)) %}
    @@ -73,7 +73,7 @@ @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 listbox_attrs {object} [optional] - Additional HTML attributes for the listbox div. + @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( @@ -83,7 +83,7 @@ empty_text="No results found.", dialog_attrs={}, input_attrs={}, - listbox_attrs={}, + menu_attrs={}, open=false ) %} {% set id = id or ("command-dialog-" + (range(100000, 999999) | random | string)) %} @@ -97,54 +97,56 @@ {% 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 %} -
    + + -
    +
    {% endmacro %} {# Renders a list of items for the command component. - @param items {array} - The array of items to render. + @param items {array} - Array of items to render. Each item can be: + - { type: "item", label: "Text", content: "HTML", keywords: "search terms", disabled: true, attrs: {} } + - { type: "group", label: "Group Title", items: [...] } + - { type: "separator" } + Note: Use attrs to add data-label for alternative search text if needed. @param parent_id_prefix {string} [optional] - The prefix for the item id. #} {% macro render_command_items(items, parent_id_prefix="items") %} @@ -169,10 +171,8 @@ {% elif item.type == "item" or not item.type %}
    -
    +
    {% if title or description %}
    {% endif %} -
    +
    {% endmacro %} \ No newline at end of file