# Introduction > What Ndop UI is, and how it differs from a component library you install as a package. Ndop UI is a set of accessible Angular components that you add to your app as source files. You run `ndop add dialog`, and the dialog's code lands in your project, ready to read, restyle or change. Nothing hides behind a package boundary. ## How it works Ndop has two layers: - **Primitives** in `@ndop/primitives`: headless directives that implement the behavior, keyboard support and ARIA of each pattern. You install them as an npm package and update them like any dependency. - **Styled items** from the registry: thin directives that wrap a primitive with Tailwind classes. These are the files `ndop add` copies into your project. They look like [shadcn/ui](https://ui.shadcn.com) and use the same design tokens. The CLI records the version and a hash of every file it writes in `ndop.lock.json`. That record is what lets a later version merge upstream fixes into files you changed, instead of overwriting them. Coding agents get the same components through an MCP server, guidance in your project, and `ndop verify`, which checks their work and says what to fix. ## What it's built on - Angular 21 or 22, standalone, signals-first and zoneless. Apps that still use zone.js work too. - Tailwind CSS v4, configured in CSS. - The Angular CDK for overlays, focus management and directionality. - Server-side rendering and hydration, tested on every component. ## Credits Ndop's styled items and its default theme start from [shadcn/ui](https://ui.shadcn.com): the classes of its new-york-v4 style and its neutral palette, MIT licensed, copyright (c) 2023 shadcn. The behavior underneath is Ndop's own, on the Angular CDK. The registry serves its [license and third-party notices](/r/LICENSE.txt) with the items. ## Next steps - [Install Ndop](/docs/getting-started/installation) in an Angular app. - Learn how [theming](/docs/getting-started/theming) and [dark mode](/docs/getting-started/dark-mode) work. - See what the [CLI](/docs/getting-started/cli) can do. - Set up your coding agent with [Agents & MCP](/docs/getting-started/agents-mcp). --- # Installation > Set up Ndop UI in an Angular CLI, Nx or Analog app, then add your first component. Ndop works in standalone Angular 21 and 22 apps with an `app.config.ts`. In Angular CLI workspaces, `ndop init` sets everything up, including Tailwind CSS v4 if your app doesn't have it yet. ## Angular CLI Create an app, or use an existing one: ```bash npx @angular/cli new my-app --style tailwind ``` Move into its folder: ```bash cd my-app ``` Set it up for Ndop: ```bash npx @ndop/cli init ``` Then add a component: ```bash npx @ndop/cli add dialog ``` `ndop init` asks before it changes anything. Pass `--yes` to skip the question, or `--dry-run` to see every change as a diff first. `ndop init` also adds `@ndop/cli` to your dev dependencies, so later commands can run your project's own copy, at the version your lockfile pins: `npx ndop add dialog`, or `pnpm exec ndop add dialog`. Pass `--no-cli` to leave it out; see [CLI](/docs/getting-started/cli). ## Nx and Analog `ndop init` reads `angular.json`, so it sets up Angular CLI workspaces only for now. Nx and Analog apps can use Ndop with a few manual steps, after which `ndop add` works as usual. Run every command from the workspace root. ### 1. Write `ndop.json` At the root, pointing at your app's folders: `ndop.json`: ```json { "$schema": "https://ndop.dev/schema/config.json", "style": "default", "tailwind": { "css": "apps/my-app/src/styles.css" }, "aliases": { "ui": "apps/my-app/src/app/ui", "lib": "apps/my-app/src/app/lib", "blocks": "apps/my-app/src/app/blocks" }, "importAlias": "@/", "iconLibrary": "lucide" } ``` ### 2. Import Tailwind CSS and Ndop's styles The stylesheet needs Tailwind CSS v4 and Ndop's token mechanics: `apps/my-app/src/styles.css`: ```css @import 'tailwindcss'; @import '@ndop/core/styles.css'; ``` ### 3. Add the theme and the helper files ```bash npx @ndop/cli add default utils icons ``` ### 4. Register Ndop and the icons In the app's `app.config.ts`: `apps/my-app/src/app/app.config.ts`: ```ts import { ApplicationConfig } from '@angular/core'; import { provideNdop } from '@ndop/core'; import { provideIcons } from '@/lib/icons'; export const appConfig: ApplicationConfig = { providers: [provideNdop(), provideIcons()], }; ``` Without an `@/*` path alias in the root `tsconfig.json`, `ndop add` rewrites each component's `@/` imports to relative paths. ## What `init` changes `ndop init` makes these changes, and running it again changes nothing: - writes `ndop.json`, which records where components go; - adds the `@/*` path alias to `tsconfig.json`, keeping its comments; - sets up Tailwind CSS v4, if needed; - imports `@ndop/core/styles.css` and the default theme's tokens into your global stylesheet; - adds `src/app/lib/utils.ts` and `src/app/lib/icons.ts`; - registers `provideNdop()` and `provideIcons()` in `app.config.ts`; - installs `@ndop/core` and the theme's fonts with your package manager, and `@ndop/cli` as a dev dependency unless you pass `--no-cli`. ## Your first component After `ndop add dialog`, import the parts you need from the file it wrote: `src/app/app.ts`: ```ts import { Component } from '@angular/core'; import { DialogContent, DialogTitle, Dialog, DialogPortal, DialogTrigger } from '@/ui/dialog/dialog'; ``` Every component page shows complete, tested examples. --- # Theming > How Ndop's design tokens work, and how to change colors, radius and fonts. Ndop styles every component with CSS custom properties that follow shadcn/ui's names, such as `--background`, `--primary` and `--radius`. `ndop init` writes the default theme's values into your global stylesheet, so the theme is yours to edit. ## Where the tokens live Your stylesheet ends up with four blocks: `src/styles.css`: ```css @import 'tailwindcss'; @import '@ndop/core/styles.css'; @theme { --font-sans: 'Geist Variable', sans-serif; } :root { --background: oklch(1 0 0); --primary: oklch(0.205 0 0); --radius: 0.625rem; } .dark { --background: oklch(0.145 0 0); --primary: oklch(0.922 0 0); } ``` - `@ndop/core/styles.css` maps the tokens to Tailwind colors, so classes like `bg-primary` and `text-muted-foreground` work everywhere. - `:root` holds the light values, `.dark` the dark ones. - `@theme` sets Tailwind theme values such as fonts. ## Changing the theme Edit the values in place. Colors use OKLCH, which keeps lightness steady across hues: `src/styles.css`: ```css :root { --primary: oklch(0.55 0.2 260); /* [!code ++] */ --radius: 0.375rem; /* [!code ++] */ } ``` Every component picks up the change, because none of them hard-code a color. ## Tokens | Token | Used for | | ------------------------------------ | -------------------------------- | | `--background`, `--foreground` | The page and its text. | | `--primary`, `--primary-foreground` | Main actions. | | `--secondary`, `--muted`, `--accent` | Quieter surfaces and hovers. | | `--destructive` | Actions that delete. | | `--border`, `--input`, `--ring` | Borders, fields and focus rings. | | `--radius` | The base corner radius. | | `--popover`, `--card` | Floating and raised surfaces. | --- # Dark mode > Switch between light, dark and the operating system's scheme, without a flash on load. Ndop supports three color schemes: light, dark, and the operating system's preference. `provideNdop()` applies one when the app starts, on the server as well as in the browser, so server-rendered pages arrive in the right colors. ## Choosing the starting scheme `src/app/app.config.ts`: ```ts import { ApplicationConfig } from '@angular/core'; import { provideNdop } from '@ndop/core'; export const appConfig: ApplicationConfig = { providers: [provideNdop({ theme: 'system' })], }; ``` `system` is the default. It sets `data-ndop-theme="system"` on the root element, and the `dark:` variant then follows `prefers-color-scheme` in CSS alone, without any script. ## Switching at runtime Inject `NdopTheme` and call `set`: `src/app/theme-toggle.ts`: ```ts import { Component, inject } from '@angular/core'; import { NdopTheme } from '@ndop/core'; import { Button } from '@/ui/button/button'; @Component({ selector: 'app-theme-toggle', imports: [Button], template: ``, }) export class ThemeToggle { private readonly theme = inject(NdopTheme); protected toggle(): void { this.theme.set(this.theme.mode() === 'dark' ? 'light' : 'dark'); } } ``` `dark` adds the `dark` class to the root element, which turns on the `.dark` token block and every `dark:` utility. ## Remembering the choice Ndop doesn't store the choice, because where to keep it is up to your app. If you save it, apply it again when the app starts, and before the first paint if your pages are prerendered, as this site does. --- # CLI > The ndop command, its options, and how it keeps every change previewable and repeatable. The `ndop` CLI sets up your app and adds components to it. Every command plans its changes first, then applies them: `--dry-run` shows the plan as diffs, and running a command twice changes nothing the second time. Run it with your package manager's runner, as in `npx @ndop/cli init`; the tabs on each command show the pnpm, Yarn and Bun forms. `ndop init` adds `@ndop/cli` to your dev dependencies, as `ng new` adds `@angular/cli`, so your project gets its own `ndop` command at the version your lockfile pins. From then on, run it as `npx ndop add dialog`, which finds the installed command with npm, pnpm and Bun alike, or as `pnpm exec ndop add dialog`. The tables below write it as `ndop`. Pass `--no-cli` to `init` to keep it out of your dependencies and run every command through `npx @ndop/cli`. ## Commands | Command | What it does | | ------------------------ | -------------------------------------------------------------------------------------- | | `ndop init` | Sets up an Angular app for Ndop. | | `ndop add ` | Adds components and everything they depend on. | | `ndop list [words...]` | Lists the registry's components, blocks, themes and libraries. | | `ndop view ` | Shows an item's API, keyboard map and examples, and how to add it. | | `ndop diff [items...]` | Shows your changes to installed components, and what changed upstream since. | | `ndop update [items...]` | Updates installed components, merging upstream's changes into your edits. | | `ndop doctor` | Checks your setup, and fixes what is safe to fix. | | `ndop verify [paths...]` | Checks components and pages that use Ndop; see [Verify](/docs/getting-started/verify). | | `ndop build` | For registry authors: validates items and writes the registry. | ## Options `init`, `add` and `update` accept: | Option | Effect | | ------------------ | --------------------------------------------------------- | | `--cwd ` | The project root, by default the current folder. | | `--dry-run` | Prints the changes as diffs; writes and installs nothing. | | `--json` | Prints one machine-readable result and nothing else. | | `-y, --yes` | Applies without asking. | | `--registry ` | Uses another registry; the URL contains `{name}`. | `init` also takes `--theme `, `--project ` and `--no-cli`; `add` takes `--overwrite`, and installs a specific version when you write one after the name, as in `ndop add button@0.1.0`; `update` takes `--force`, `--continue` and `--interactive`. `doctor` takes the same options, with `--dry-run` and `--yes` applying to `--fix`, plus `--offline` and `--project`. For coding agents, `init` takes `--agent`, `--mcp` and `--verify`; see [Agents & MCP](/docs/getting-started/agents-mcp). `list`, `view` and `diff` write nothing. They take `--cwd`, `--registry` and `--json`; `list` also takes `--type`, `view` `--code`, and `diff` `--to`. ## Browsing the registry `ndop list` shows what you can add, grouped by type, with each item's latest version and description. Words after it keep the items that contain them, and `--type` keeps one type: `ui`, `block`, `theme`, `lib` or `example`. Examples are listed only with `--type example`, since every component has several: ```bash npx @ndop/cli list --type block ``` ```text Blocks (12) dashboard-01 1.0.0 A dashboard of stat cards, an area char… dashboard-02 1.0.0 An analytics dashboard with tabs for ov… data-table-01 1.0.0 A table of tasks to search, filter, sor… hero-01 1.0.0 A hero section with a headline, two act… login-01 1.0.0 A sign-in form in a card, with email an… … ``` It works outside an app, on Ndop's registry. In an app, it also lists the items of the registries in your `ndop.json`, as `@acme/table`, and shows which items you installed and at which version. Descriptions are cut to fit the terminal, 64 columns wide above, and printed in full when the output goes to a file or another program. `ndop view` prints what you need to use an item without leaving the terminal: the command that adds it, its docs page, its dependencies and files, and for a component its usage, every part's selector and inputs, its keyboard map, focus rules and examples. The start of its output, with `…` where lines were left out: ```bash npx @ndop/cli view button ``` ```text Button button 1.0.0, component Displays a button or a component that looks like a button. … Install npx @ndop/cli add button Docs https://ndop.dev/docs/components/button … API Button button[ndButton], a[ndButton] … Role native (button / link) [variant] 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link' = 'default' Visual style. … Keyboard Enter Activates the button, or follows the link; does nothing while disabled. Space Activates a `button`; does nothing while disabled. Links don't react to Space. ``` `--code` adds the examples' source. An unknown name fails with the closest ones, and `--json` prints the item with its files and metadata. ## Themes and URLs `init --theme` and `add` take a name, a namespaced name such as `@acme/table`, or the URL of an item's JSON. URLs must use HTTPS, except on `localhost`. `theme:` in front of any of them checks that the item is a theme: ```bash npx @ndop/cli add theme:https://example.com/r/ocean.json ``` Adding a theme to a set-up app replaces the theme variables in your stylesheet, light and dark. shadcn themes, `registry:style` items with `cssVars` such as tweakcn's, install the same way. `ndop.lock.json` records an item added by URL under that URL. `ndop update` fetches the URL again, and merges your edits using `@.json` as the base, which `ndop build` writes. If the site has no such file, files you didn't edit are replaced, and edited ones are kept and reported, for you to update by hand. ## Your edits are safe `ndop add` records a hash of every file it writes in `ndop.lock.json`. If you edit a file and run `add` again, the file is kept and reported as skipped, unless you pass `--overwrite`. To bring a new version's changes into a file you edited, use `ndop update`. ## What changed `ndop diff` shows, for each installed component, two diffs per file: your changes since you installed it, and what changed upstream since that version. It writes nothing. ```bash npx @ndop/cli diff button ``` ## Updating `ndop update` brings components to their latest version without losing your edits. A file you never changed is replaced. A file you changed is merged with upstream's changes, the way `git merge` does, using the version you installed as the common base: ```bash npx @ndop/cli update ``` When you and upstream changed the same lines, the file gets conflict markers, with your lines first, and the command exits with code 2. Edit the file to keep what you want, then record it: ```bash npx @ndop/cli update --continue ``` The files an update writes must be committed first, so `git checkout` undoes it; `--force` skips that check. Theme variables you changed keep your values, new files and dependencies are added, and files a new version drops stay in your project as yours. When a new version moves a file, your edited copy moves with it. When it renames something you use, such as an input, `update` renames it in your own components and templates as well. To pick which of upstream's changes to take, file by file, add `--interactive`. Every component's page links its changelog: what changed in each version, what `ndop update` does for it, and the diffs. Its update preview merges your copy of a file in the browser, so you can see the result before running anything. ## Checking your setup `ndop doctor` checks everything Ndop relies on and prints each problem with its fix: the Node and Angular versions, the packages installed components need, `ndop.json`, the `@/` alias, Tailwind, the theme tokens components use, `provideNdop()` and the icon provider, the lockfile, zoneless and SSR support, and the agent files. It writes nothing: ```bash npx @ndop/cli doctor ``` `--fix` applies the safe fixes, the same edits `init` and `add` make: installing missing packages, adding the `tsconfig.json` path, adding missing tokens from your theme, registering the providers and refreshing the agent files. Add `--dry-run` to see them as diffs first. It never changes your components or resolves conflicts for you, and running it again changes nothing. When `@ndop/cli` isn't in your dependencies, because `init` ran with `--no-cli` or before it added the package, `doctor` warns, and `--fix` adds it. A warning doesn't fail the command, so you can leave it out. It exits with code 1 while any error remains, so CI can run it. `--json` prints the checks, and `--offline` skips the ones that need the registry, such as whether your components are behind it. ## Building a registry `ndop build` reads `registry.json` in `--cwd` and writes the static registry under `r/` in `--output` (`dist/registry` by default). It fails, writing nothing, when an item is invalid or doesn't compile. Items that list `@ndop/` dependencies can import their files; `ndop build` fetches them from `--registry `, else `NDOP_REGISTRY`, else ndop.dev, and type-checks against them. See [Build your own registry](/docs/patterns/build-your-own-registry). With `--compat shadcn`, it also writes `r/compat/.json` for every component, block, library and theme, which the shadcn CLI installs in an Angular app without a `components.json`: ```bash npx shadcn add https://ndop.dev/r/compat/dialog.json ``` Files go where `ndop init` would put them, under `src/app/ui`, `src/app/lib` and `src/app/blocks`, with imports between them made relative. A theme becomes `src/ndop-theme.css`. shadcn then prints the setup `ndop init` would have done: add a theme and the icons, import `@ndop/core/styles.css` and the theme in `src/styles.css`, and register `provideNdop()` and `provideIcons()`. Updates, aliases and agent files stay `ndop` features. | Option | Effect | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `--compat shadcn` | Also writes the shadcn-compatible items. | | `--compat-base-url ` | The absolute URL `r/compat/` is served from, which items' dependencies point at. By default `https://ndop.dev/r/compat/`. | For `--compat shadcn` to build, every dependency in `registryDependencies` must be an item of the same registry or `@ndop/`, which points at Ndop's compat item. That is `https://ndop.dev/r/compat/.json`, or next to the `--registry` URL when it ends in `{name}.json`. ## Package managers The CLI installs dependencies with the package manager your project uses, detected from its lockfile or `packageManager` field. npm, pnpm and bun are tested on every change. ## For agents and CI Commands ask for confirmation only in an interactive terminal, and `--json` prints a single result that a script or a coding agent can read: ```bash npx @ndop/cli add dialog --yes --json ``` `ndop add` also refreshes the Ndop section of your agents' instructions, and installs `@angular/cdk` for your Angular version when a component needs it. The instructions tell agents to run `npx ndop`, or `npx @ndop/cli` when the project doesn't depend on `@ndop/cli`. --- # ndop.json > The project configuration ndop init writes, and every field it can contain. `ndop init` writes `ndop.json` at the root of your project. The CLI reads it on every command to know where files go and which registries to use. `ndop.json`: ```json { "$schema": "https://ndop.dev/schema/config.json", "style": "default", "tailwind": { "css": "src/styles.css" }, "aliases": { "ui": "src/app/ui", "lib": "src/app/lib", "blocks": "src/app/blocks" }, "importAlias": "@/", "iconLibrary": "lucide" } ``` ## Fields | Field | Meaning | | -------------- | ---------------------------------------------------------------------------------- | | `style` | The theme the project was set up with. | | `tailwind.css` | Your global stylesheet, where tokens and imports go. | | `aliases` | Folders for components (`ui`), helpers (`lib`) and examples and blocks (`blocks`). | | `importAlias` | The path alias that points at your app's source, `@/` by default. | | `iconLibrary` | The icon set `icons.ts` maps names to. | | `registry` | Another default registry, as a URL containing `{name}`. | | `registries` | Namespaced registries, for `ndop add @acme/table`, with optional headers. | | `agents` | The coding agents whose instructions the CLI writes, or `false` for none. | | `mcp` | Whether the Ndop MCP server is registered in those agents' settings. | ## Private registries A namespaced registry can send headers, for example a token from an environment variable: `ndop.json`: ```json { "registries": { "@acme": { "url": "https://ui.acme.dev/r/{name}.json", "headers": { "Authorization": "Bearer ${ACME_TOKEN}" } } } } ``` The CLI expands `${ACME_TOKEN}` from the environment. Installing from `@acme` when the variable isn't set fails with a message naming it; other registries keep working. To publish a registry of your own, see [Build your own registry](/docs/patterns/build-your-own-registry). --- # Agents & MCP > How Ndop helps coding agents use its components correctly, with project guidance, an MCP server and a verify loop. Coding agents write better UI when they don't have to guess an API. Ndop gives them three things: guidance in your project that's always true for it, an MCP server with the components' real documentation, and `ndop verify`, which checks their work and says exactly what to fix. ## Guidance in your project `ndop init` writes an Ndop section into `AGENTS.md`, which most agents read. It lists the installed components, the theme's tokens, where files go, and the workflow: use an installed component first, read its documentation, give every field a visible label, style with tokens and logical utilities, then verify. `ndop add` refreshes it every time you install something. Pick the agents you use with `--agent`, or let `init` detect them: ```bash npx @ndop/cli init --agent claude-code,cursor ``` | Agent | What `init` writes | | ------------- | ---------------------------------------------------------------------- | | Every agent | A section in `AGENTS.md` | | `claude-code` | The same section in `CLAUDE.md`, and a skill in `.claude/skills/ndop/` | | `cursor` | A rule in `.cursor/rules/ndop.mdc` | | `copilot` | The same section in `.github/copilot-instructions.md` | The CLI only rewrites the text between `` and ``, so your own instructions in those files are safe. The skill and the rule belong to Ndop; once you edit one, Ndop leaves it alone. Pass `--agent none` to write nothing. ## The MCP server The Ndop MCP server gives an agent the registry's components, examples, blocks and theme tokens, read the way `ndop add` reads them, and can install items and run `ndop verify` for it. It runs on your machine, started by the agent. Let `init` register it for your agents: ```bash npx @ndop/cli init --agent claude-code --mcp ``` Or add it by hand, as a stdio server that runs `npx -y @ndop/mcp`. For Claude Code, in `.mcp.json`: `.mcp.json`: ```json { "mcpServers": { "ndop": { "type": "stdio", "command": "npx", "args": ["-y", "@ndop/mcp"] } } } ``` Cursor reads the same shape from `.cursor/mcp.json`. VS Code reads it from `.vscode/mcp.json`, under `servers` instead of `mcpServers`. ### Tools | Tool | What it does | | --------------------- | -------------------------------------------------------------------------------------------- | | `get_project_context` | Describes your app: aliases, installed items and versions, files you edited, token names. | | `search_components` | Finds components by name, purpose or other words for them, such as "modal". | | `get_component` | A component's documentation: usage, examples, parts, inputs, required nesting, keyboard map. | | `get_example` | An example's full source. | | `list_blocks` | Page-level compositions, by category. | | `get_block` | A block's files and everything it depends on. | | `add_items` | Installs components or blocks, as `ndop add` does; with `dryRun`, returns the diffs. | | `validate_usage` | Checks a file's templates against the components' metadata, in a second. | | `verify` | Runs `ndop verify` and returns every problem with its file, line, rule and fix. | It also serves each component's documentation as the `ndop://components/{name}` resource, the theme's tokens as `ndop://tokens`, and a `build-screen` prompt that walks an agent through building a whole screen. ### Over HTTP For agents that connect to a URL, serve it over Streamable HTTP. It listens on loopback only, and refuses requests from other origins: ```bash npx -y @ndop/mcp --http ``` The server is then at `http://127.0.0.1:3333/mcp`. `--port` and `--host` change it, and `--registry` points it at another registry. ## The verify loop After writing UI, an agent runs [`ndop verify`](/docs/getting-started/verify) on its files, fixes every problem it reports, and runs it again until it passes. Each problem names a file, a line and a rule, often with the fix: ```bash npx @ndop/cli verify src/app/settings --json ``` ## Documentation agents can read Every page of these docs is also Markdown at its URL plus `.md`, such as [/docs/components/dialog.md](/docs/components/dialog.md). [/llms.txt](/llms.txt) lists every page, and [/llms-full.txt](/llms-full.txt) has them all in one file. Each page's menu copies it as Markdown or opens it in Claude or ChatGPT. --- # Verify > ndop verify checks components and pages that use Ndop, for people and for agents, and says exactly what to fix. `ndop verify` checks the components and pages you or an agent wrote with Ndop. It reports every problem with its file, line and rule, often with the fix, and exits with an error until there are none. ```bash npx @ndop/cli verify src/app/settings ``` Paths can be files or folders; `src/app` by default. ## Install The checks need a linter and a browser driver, so they live in a separate dev dependency, `@ndop/verify`, which `ndop init --verify` adds. The render step also needs Chromium: ```bash npm install --save-dev @ndop/verify ``` ```bash npx playwright install chromium ``` Without Chromium, every other step still runs, and the result says why rendering was skipped. ## What it checks ### 1. Usage Your templates, against the components' metadata: a selector no component has, with the closest real one; a component that isn't installed, with its `ndop add` command; a misspelled input; a value outside an input's allowed ones; a required input left unbound; a part outside the part it must sit in, such as a dialog title outside its dialog. ### 2. Build The checked components, built through your app's own `build` target, so your Angular version, styles, path aliases and strict template checks apply. Type and template errors come back with their file and line. ### 3. Lint Ndop's ESLint rules, with your project's own ESLint config when it has one. See [the rules](#lint-rules). ### 4. Render Each component in Chromium, in light and dark: axe checks its accessibility, console errors are reported, and a screenshot of each is saved to `.ndop/verify/`, which `init` adds to `.gitignore`. Components with required inputs are skipped, since verify can't invent their data; verify the page that uses them. Only `.ndop/verify/` is written to; your files and `angular.json` are never changed. ## Options | Option | Effect | | ------------------ | ------------------------------------------------------------------------- | | `--skip ` | Skip `usage`, `build`, `lint` or `render`; repeatable or comma-separated. | | `--json` | Prints one JSON result. | | `--project ` | The Angular project, when `angular.json` has several. | | `--cwd ` | The project folder, by default the current one. | `--skip render` is the quick loop while iterating; the full run takes a few seconds per component. ## JSON output With `--json`, verify prints one object an agent can act on: ```json { "ok": false, "steps": [ { "name": "usage", "ok": false, "durationMs": 5 }, { "name": "build", "ok": true, "durationMs": 2292 }, { "name": "lint", "ok": true, "durationMs": 21 }, { "name": "render", "ok": true, "durationMs": 2000 } ], "problems": [ { "file": "src/app/settings/settings.ts", "line": 12, "column": 9, "rule": "usage/wrong-nesting", "severity": "error", "message": "DialogTitle must be inside [ndDialog]." } ], "screenshots": [".ndop/verify/Settings.light.png", ".ndop/verify/Settings.dark.png"] } ``` Rules are namespaced by step: `usage/…`, `build/TS2322` or `build/NG8002`, `ndop/…` for the lint rules, and `axe/…` for accessibility. ## Lint rules `@ndop/eslint-plugin` holds the rules verify runs. You can also add them to your own ESLint config, alongside angular-eslint's: `eslint.config.mjs`: ```js import angular from 'angular-eslint'; import ndop from '@ndop/eslint-plugin'; import tseslint from 'typescript-eslint'; export default tseslint.config( { files: ['**/*.ts'], extends: [...tseslint.configs.recommended, ...angular.configs.tsRecommended], processor: angular.processInlineTemplates, }, { files: ['**/*.html'], extends: [...angular.configs.templateRecommended], }, ...ndop.configs.recommended, ); ``` | Rule | Checks | | ----------------------- | --------------------------------------------------------------------------------------- | | `icon-button-name` | A button whose only content is an icon has `aria-label` or visually hidden text. | | `dialog-title` | Every dialog has a title. | | `form-control-label` | Every form control has a label. | | `logical-properties` | Direction utilities are logical (`ms-2`, not `ml-2`), so layouts mirror; it fixes them. | | `no-raw-colors` | Colors come from the theme's tokens, not palette colors like `bg-red-500`. | | `no-private-primitives` | `@ndop/primitives` is imported only through its entry points. | --- # Migrating from Angular Material > Move an Angular Material app to Ndop one screen at a time, with a map of components, the theming equivalents and the CDK both build on. Angular Material and Ndop can run in the same app, so you can move one screen at a time. Both build their overlays, focus handling and keyboard support on the Angular CDK, so the two behave alike while they share a page. This guide was checked against `@angular/material` 22.2.1 and `@angular/cdk` 22.2.1, read from their published packages. ## What changes Material's components are installed from npm and styled by its Sass theme. Ndop's styled items are source files in your app: directives that put Tailwind classes on elements you write. ```angular-html ``` - **Directives, not elements.** `` renders its own control and label. `ndCheckbox` goes on your `button`, and its label is a separate `label` element. - **Templates, not services.** A Material dialog is a component passed to `MatDialog.open()`. An Ndop dialog's content is an `ng-template` in the page, opened by its trigger or by binding `[(open)]`. - **Models for state.** Open, value and checked state are two-way bindable models, such as `[(value)]` on Tabs, which takes a string, not an index. - **Styles are yours.** There are no `*-overrides` mixins: edit the classes in your copy of the item, or pass `class`, which is merged last. ## Components | Angular Material | Ndop | | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `matButton`, `matIconButton`, `matFab`, `matMiniFab` | [Button](/docs/components/button): `ndButton`. `variant` is `default`, `destructive`, `outline`, `secondary`, `ghost` or `link`, not an appearance. `size="icon"` replaces icon buttons. No FAB. | | `mat-button-toggle-group`, `mat-button-toggle` | [Toggle Group](/docs/components/toggle-group): `ndToggleGroup`, `ndToggleGroupItem`. `type="single"` or `"multiple"` replaces `multiple`; `value` is always an array. A lone toggle is [Toggle](/docs/components/toggle): `ndToggle` with `[(pressed)]`. | | `mat-form-field`, `mat-label`, `mat-hint`, `mat-error` | [Form Field](/docs/components/form-field): `ndField`, `ndFieldLabel`, `ndFieldDescription`, `ndFieldError`. No floating label or appearances. `invalid` on the field and its control replaces the error state matcher. | | `matInput` on `input`, `textarea` | [Input](/docs/components/input): `ndInput`; [Textarea](/docs/components/textarea): `ndTextarea`. The value stays with your forms library: Signal Forms, reactive or template-driven. | | `mat-checkbox` | [Checkbox](/docs/components/checkbox): `ndCheckbox`, `ndCheckboxIndicator`. On a `button`, with a separate `label`. `[(checked)]` and `[(indeterminate)]` are models. | | `mat-radio-group`, `mat-radio-button` | [Radio Group](/docs/components/radio-group): `ndRadioGroup`, `ndRadioGroupItem`. `[(value)]` on the group; each item is a `button` with a `value`. | | `mat-slide-toggle` | [Switch](/docs/components/switch): `ndSwitch`, `ndSwitchThumb`. On a `button`, with `[(checked)]`. | | `mat-slider`, `matSliderThumb` | [Slider](/docs/components/slider): `ndSlider`, `ndSliderTrack`, `ndSliderRange`, `ndSliderThumb`. Each thumb holds its own `[(value)]`; a range has two thumbs. | | `mat-select`, `mat-option`, `mat-optgroup` | [Select](/docs/components/select): `ndSelect`, `ndSelectTrigger`, `ndSelectContent`, `ndSelectItem`. Single choice only; for several, use Combobox with `multiple`. | | `matAutocomplete`, `mat-autocomplete` | [Combobox](/docs/components/combobox): `ndCombobox`, `ndComboboxInput`, `ndComboboxItem`. Filtering is built in, through the `filter` input. | | `mat-chip-grid`, `matChipInputFor` | [Combobox](/docs/components/combobox) with `multiple`: `ndComboboxChips`, `ndComboboxChip`. Chips show the chosen values; `[(values)]` holds them. For chips that only display, use [Badge](/docs/components/badge). | | `mat-datepicker`, `matDatepicker`, `mat-date-range-picker` | [Date Picker](/docs/components/date-picker): Popover, `ndDatePickerTrigger` and Calendar. Values are `Date`s, with no `DateAdapter`. `min`, `max` and `disabledDates` on the calendar replace the filter. No typed input. | | `mat-calendar` | [Calendar](/docs/components/calendar): `ndCalendar`. `mode` is `single`, `multiple` or `range`, with `[(value)]`, `[(values)]` or `[(range)]`. | | `MatDialog`, `mat-dialog-title`, `mat-dialog-content`, `mat-dialog-actions`, `mat-dialog-close` | [Dialog](/docs/components/dialog): `ndDialog`, `ndDialogContent`, `ndDialogTitle`, `ndDialogFooter`. Declared in the template, not opened by a service. `[closeOnEscape]` and `[closeOnOverlayClick]` replace `disableClose`. Confirmations use [Alert Dialog](/docs/components/alert-dialog). | | `MatBottomSheet` | [Drawer](/docs/components/drawer) or [Sheet](/docs/components/sheet) with `side="bottom"`. Declared in the template, like Dialog. | | `mat-sidenav-container`, `mat-sidenav`, `mat-drawer` | [Sidebar](/docs/components/sidebar): `ndSidebarProvider`, `ndSidebar`, `ndSidebarInset`. `collapsible` is `offcanvas`, `icon` or `none`; on phones the sidebar opens as a modal sheet. For a panel over the page, use Sheet. | | `mat-menu`, `matMenuTriggerFor`, `mat-menu-item` | [Dropdown Menu](/docs/components/dropdown-menu): `[ndDropdownMenuTrigger]="menu"`, `ndDropdownMenuItem`. The trigger takes an `ng-template`, as `matMenuTriggerFor` takes a menu; items emit `(triggered)`. | | `matContextMenuTriggerFor` | [Context Menu](/docs/components/context-menu): `[ndContextMenuTrigger]="menu"`. Same shape as Dropdown Menu. | | `matTooltip` | [Tooltip](/docs/components/tooltip): `ndTooltip`, `ndTooltipTrigger`, `ndTooltipContent`. The content is a template, not a string. `side` and `align` replace `matTooltipPosition`; `openDelay` and `closeDelay` replace the show and hide delays. | | `mat-tab-group`, `mat-tab` | [Tabs](/docs/components/tabs): `ndTabs`, `ndTabsList`, `ndTabsTrigger`, `ndTabsContent`. Tabs are named by `value`, not by index. Tab links (`mat-tab-nav-bar`) have no equivalent. | | `mat-accordion`, `mat-expansion-panel` | [Accordion](/docs/components/accordion): `ndAccordion`, `ndAccordionItem`, `ndAccordionTrigger`. `multiple` replaces `multi`. A single panel is [Collapsible](/docs/components/collapsible). | | `mat-card` and its parts | [Card](/docs/components/card): `ndCard`, `ndCardHeader`, `ndCardTitle`, `ndCardContent`, `ndCardFooter`. `ndCardDescription` replaces the subtitle; `ndCardFooter` holds the actions. | | `mat-divider` | [Separator](/docs/components/separator): `ndSeparator`. `orientation="vertical"` replaces `vertical`. | | `mat-progress-bar` | [Progress](/docs/components/progress): `ndProgress`, `ndProgressIndicator`. Indeterminate while `value` is `null`. No buffer mode. | | `mat-progress-spinner`, `mat-spinner` | [Spinner](/docs/components/spinner): `ndSpinner`. Indeterminate only, with a `label` for screen readers. | | `MatSnackBar` | [Toast](/docs/components/toast): `Toaster`, `ndToaster`. `inject(Toaster).show({ title, action })`; the toast region goes once in your app's shell. | | `matBadge` | [Badge](/docs/components/badge): `ndBadge`. A label of its own, not an overlay on another element; on an avatar, use `ndAvatarBadge`. | | `mat-table`, `matSort`, `mat-sort-header` | [Table](/docs/components/table) and [Data Table](/docs/components/data-table): `createDataTable`. A native `table` with `@for` over `rows()`; sorting, search, pages and selection are signals. | | `mat-paginator` | [Pagination](/docs/components/pagination): `ndPagination`, `ndPaginationLink`. Links or buttons you render; a data table's `pageIndex` and `pageCount` drive them. | | `mat-icon` | `ndIcon` from `@ndop/core`. `` with Lucide icons by default, or any icon component you prefer. | | `mat-toolbar`, `mat-list`, `mat-grid-list`, `mat-stepper`, `mat-tree`, `mat-timepicker`, `matRipple` | None. Use plain markup with Tailwind classes, or keep the Material component. | Ndop also has components Material doesn't: [Command](/docs/components/command), [Popover](/docs/components/popover), [Hover Card](/docs/components/hover-card), [Navigation Menu](/docs/components/navigation-menu), [Resizable](/docs/components/resizable) and [Chart](/docs/components/chart), among others. ### A dialog, before and after With Material, the dialog is a component that a service opens: `delete-dialog.html`: ```angular-html

Delete project?

Its tasks are deleted for everyone. ``` With Ndop, a confirmation is an [Alert Dialog](/docs/components/alert-dialog) in the page that asks: `delete-project.ts`: ```ts import { Component, output } from '@angular/core'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger } from '@/ui/alert-dialog/alert-dialog'; import { Button } from '@/ui/button/button'; @Component({ selector: 'app-delete-project', imports: [AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, Button], template: `

Delete project?

Its tasks are deleted for everyone.

`, }) export class DeleteProject { readonly deleted = output(); } ``` Both buttons close the dialog, and focus returns to the trigger. Where Material's caller reads the result from `afterClosed()`, the page listens to `(deleted)`. ## Theming Material builds its theme in Sass, with `mat.theme()`, and exposes it as `--mat-sys-*` CSS variables. Ndop's theme is plain CSS variables with shadcn/ui's names, written into your stylesheet by `ndop init`; see [Theming](/docs/getting-started/theming). | Angular Material | Ndop | | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | | `mat.theme()` with `color`, `typography` and `density` | The `:root` and `.dark` blocks in your stylesheet; edit them, or add a gallery theme. | | Prebuilt themes, such as `azure-blue.css` | [Gallery themes](/themes), such as `ndop add theme:rose`. | | `mat.-overrides()` | The classes in your copy of the item, or its `class` input. | | `typography: Roboto` | `--font-sans` in the `@theme` block. | | `density: -1` and lower | `data-density="compact"` on any element, for its subtree; also `comfortable`. | | `color-scheme` and `light-dark()` | The `.dark` class, managed by `provideNdop()`; see [Dark mode](/docs/getting-started/dark-mode). | To carry your palette over, set each Ndop token to the Material color in the same role: | Material system variable | Ndop token | | ------------------------------------------------------------------- | -------------------------------------------------- | | `--mat-sys-surface`, `--mat-sys-on-surface` | `--background`, `--foreground` | | `--mat-sys-primary`, `--mat-sys-on-primary` | `--primary`, `--primary-foreground` | | `--mat-sys-secondary-container`, `--mat-sys-on-secondary-container` | `--secondary`, `--secondary-foreground` | | `--mat-sys-surface-container` | `--card`, `--popover` | | `--mat-sys-surface-container-high` | `--muted`, `--accent` | | `--mat-sys-on-surface-variant` | `--muted-foreground` | | `--mat-sys-error`, `--mat-sys-on-error` | `--destructive`, `--destructive-foreground` | | `--mat-sys-outline-variant`, `--mat-sys-outline` | `--border`, `--input` | | `--mat-sys-corner-*` | `--radius`, from which the radius scale is derived | The [theme studio](/themes/studio) shows the contrast of each pair as you edit, and exports the result as CSS or as a theme URL for `ndop add`. ## The CDK they share Ndop's primitives use the same CDK as Material: overlays and positioning, focus traps and key managers from `a11y`, `bidi` for text direction, and CDK menu, listbox and accordion. In one app: - Both libraries' overlays open in the CDK's one overlay container, and both read `dir` through `Directionality`, so a right-to-left page is right-to-left everywhere. - Material 22.2.1 requires `@angular/cdk` at exactly its own version. `ndop add` installs the CDK only when `package.json` doesn't list it, so it keeps the version Material needs. - CDK features you use directly, such as drag and drop, virtual scrolling and `BreakpointObserver`, don't depend on Material and keep working. - When you remove `@angular/material`, keep `@angular/cdk`: `@ndop/primitives` needs it. ## Migrating step by step ### 1. Give Tailwind a CSS stylesheet `ndop init` writes Tailwind and the theme into your app's first global stylesheet, which must be a `.css` file. If yours is `styles.scss`, list a CSS file before it in the build's `styles` in `angular.json`, and keep Material's Sass where it is. `init` creates the file: `angular.json`: ```json "styles": ["src/tailwind.css", "src/styles.scss"] ``` ### 2. Set up Ndop Commit your work, then preview the changes and apply them: ```bash npx @ndop/cli init --dry-run ``` ```bash npx @ndop/cli init ``` ### 3. Follow one color scheme Material's colors follow `color-scheme`, and Ndop's follow the `.dark` class and `data-ndop-theme="system"` that `provideNdop()` sets on the root element. Replace the `color-scheme` that Material's `ng add` set on `body` with rules that follow Ndop's: `src/tailwind.css`: ```css html { color-scheme: light; } html.dark { color-scheme: dark; } html[data-ndop-theme='system'] { color-scheme: light dark; } ``` ### 4. Move a screen at a time Add the components a screen needs, and replace its Material components with the map above: ```bash npx @ndop/cli add button dialog form-field input ``` Material's global styles aren't in a CSS layer, so they win over Tailwind's, which are. While Material is in the app, the `body` rule its setup wrote sets the page's background, text color and font. If your Sass includes `mat.typography-hierarchy()`, its `.mat-typography` class also styles the headings below it, Ndop's included. ### 5. Remove Material Once no template uses Material, uninstall `@angular/material`, and delete `mat.theme()`, the `body` rule and any typography hierarchy from your Sass. Ndop's base styles then set the page's background, text color and font from your theme. ## Next steps - [Theming](/docs/getting-started/theming) and [Dark mode](/docs/getting-started/dark-mode) cover the tokens in detail. - [CLI](/docs/getting-started/cli) explains `ndop update`, which merges later versions into the components you've edited. --- # Migrating from spartan/shadcn > Bring Ndop into an app that uses spartan/ui or shadcn-style components, keep your theme, and let ndop take over files you copied. Ndop follows shadcn/ui closely: its styled items use the classes of shadcn/ui's new-york-v4 style, its themes use shadcn's token names, and its registry format is a superset of shadcn's. Coming from spartan/ui or from shadcn itself, most of what you know carries over. What Ndop adds is `ndop update`, which merges new versions into the files you've edited. This guide was checked against spartan/ui 1.6.3 (`@spartan-ng/brain` and `@spartan-ng/cli`), read from their published packages, and the shadcn CLI 4.21.4, the version Ndop's install test runs. ## What carries over - **Tokens.** `--background`, `--primary`, `--radius` and the rest have shadcn's names, in `:root` and `.dark`, so a shadcn or spartan theme's values paste in unchanged. `ndop add` also installs shadcn themes published as `registry:style` items, such as tweakcn's: ```bash npx @ndop/cli add theme:https://example.com/r/ocean.json ``` - **Classes.** Each styled item's `variants()` table holds the class strings of the shadcn/ui new-york-v4 item it follows, and a test per item compares the two. spartan 1.6 keeps its classes in a stylesheet per design style instead (`.style-vega` by default), so expect small visual differences from spartan. - **`cn()` and variants.** `cn()` merges classes with clsx and tailwind-merge, as shadcn's does. `variants()` takes the same parts as cva: `base`, `variants`, `compoundVariants` and `defaults`. - **Names.** Parts keep shadcn's names: `DialogContent`, `CardHeader`, `SelectTrigger`. In templates they're attributes with the `nd` prefix, such as `ndDialogContent`. - **The registry.** Items have shadcn's fields (`files`, `dependencies`, `registryDependencies`, `cssVars`) plus Ndop's own, and `ndop.json` takes namespaced `registries` as `components.json` does; see [ndop.json](/docs/getting-started/ndop-json). ## What changes - **Directives only.** spartan renders some parts as elements, such as ``, `` and ``. Every Ndop part is a directive on an element you write: ` * *
Ships in two days.
* * * ``` */ @Directive({ selector: '[ndAccordion]', hostDirectives: [ { directive: NdAccordion, inputs: ['value', 'defaultValue', 'multiple', 'collapsible', 'disabled'], outputs: ['valueChange'], }, ], host: { 'data-slot': 'accordion' }, }) export class Accordion {} /** * One section of an accordion, with a line under it. * * @example * ```html *
…
* ``` */ @Directive({ selector: '[ndAccordionItem]', hostDirectives: [ { directive: NdAccordionItem, inputs: ['value', 'disabled'] }, ], host: { 'data-slot': 'accordion-item', '[class]': 'classes()' }, }) export class AccordionItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('border-b last:border-b-0', this.class()), ); } /** * The heading around a section's trigger: an `h2` to `h6` that fits the * page's outline. * * @example * ```html *

…

* ``` */ @Directive({ selector: '[ndAccordionHeader]', hostDirectives: [NdAccordionHeader], host: { 'data-slot': 'accordion-header', '[class]': 'classes()' }, }) export class AccordionHeader { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex', this.class())); } /** * The button that opens and closes its section. Put the chevron icon in it, * last; it turns over while the section is open. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndAccordionTrigger]', hostDirectives: [NdAccordionTrigger], host: { 'data-slot': 'accordion-trigger', '[class]': 'classes()' }, }) export class AccordionTrigger { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-start text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180', '[&>svg]:pointer-events-none [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:translate-y-0.5 [&>svg]:text-muted-foreground [&>svg]:transition-transform [&>svg]:duration-200', this.class(), ), ); } /** * The content its trigger shows and hides, sliding open and closed. * * @example * ```html *
Ships in two days.
* ``` */ @Directive({ selector: '[ndAccordionContent]', hostDirectives: [NdAccordionContent], host: { 'data-slot': 'accordion-content', '[class]': 'classes()' }, }) export class AccordionContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down', // tw-animate-css's accordion keyframes read this height. '[--radix-accordion-content-height:var(--nd-accordion-content-height)]', // Space below the text as a pseudo-element, which the height // animation clips; padding would stay visible at height 0. 'after:block after:h-4', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Accordion, AccordionContent, AccordionHeader, AccordionItem, AccordionTrigger, } from '@/ui/accordion/accordion'; ``` ```html
@for (question of questions; track question.value) {

{{ question.answer }}
}
``` ## Examples ### Accordion demo Frequently asked questions, one open at a time. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Accordion, AccordionContent, AccordionHeader, AccordionItem, AccordionTrigger, } from '@/ui/accordion/accordion'; /** * Frequently asked questions, one open at a time. The open one closes * again on its own button, since the accordion is collapsible. */ @Component({ selector: 'example-accordion-demo', imports: [ Accordion, AccordionContent, AccordionHeader, AccordionItem, AccordionTrigger, NdIcon, ], template: `
@for (question of questions; track question.value) {

{{ question.answer }}
}
`, }) export class AccordionDemoExample { protected readonly questions = [ { value: 'shipping', title: 'How long does shipping take?', answer: 'Orders ship within two business days, and arrive in three to five.', }, { value: 'returns', title: 'What is your return policy?', answer: 'Return any item within 30 days of delivery for a full refund.', }, { value: 'support', title: 'How do I reach support?', answer: 'Email us any time; we answer within a day.', }, ]; } ``` ### Accordion multiple Sections that open independently, with one disabled. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Accordion, AccordionContent, AccordionHeader, AccordionItem, AccordionTrigger, } from '@/ui/accordion/accordion'; /** * Sections that open independently, with `multiple`. One is disabled, * and the open sections are bound to a signal. */ @Component({ selector: 'example-accordion-multiple', imports: [ Accordion, AccordionContent, AccordionHeader, AccordionItem, AccordionTrigger, NdIcon, ], template: `

Your name, photo and bio.

Managed by your organization.

Password and two-factor authentication.
`, }) export class AccordionMultipleExample { protected readonly open = signal(['profile', 'security']); } ``` ## Accessibility Implements the [Accordion](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Tab` | Moves focus through the triggers and any open content, in document order. | | `Enter` or `Space` | Opens or closes the focused trigger's item. | | `ArrowDown` | Moves focus to the next enabled trigger, wrapping to the first. | | `ArrowUp` | Moves focus to the previous enabled trigger, wrapping to the last. | | `Home` | Moves focus to the first enabled trigger. | | `End` | Moves focus to the last enabled trigger. | ### Focus - Every enabled trigger is in the tab order; opening or closing an item keeps focus on its trigger. - Disabled triggers are skipped by the arrow keys. ### Right-to-left Nothing mirrors: the arrow keys follow the vertical order of the triggers. ## API reference ### Accordion Selector: `[ndAccordion]` An accordion (`@ndop/primitives/accordion`): a stack of headed sections that each show or hide their content. One section opens at a time unless it's `multiple`; `collapsible` lets the open one close. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[defaultValue]` | `string \| readonly string[] \| null` | `null` | Initially open items when `value` isn't bound: one value or a list. | | `[multiple]` | `boolean` | `false` | Lets several items be open at once. | | `[collapsible]` | `boolean` | `false` | Lets the open item close when only one item may be open. | | `[disabled]` | `boolean` | `false` | Disables every item. | | `[(value)]` | `readonly string[]` | `[]` | The open items' values. Two-way bindable with `[(value)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `accordion` | ### AccordionItem Selector: `[ndAccordionItem]` One section of an accordion, with a line under it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[value]` (required) | `string` | | The value `value` lists while it's open. | | `[disabled]` | `boolean` | `false` | Disables its trigger. | | Data attribute | Values | | --- | --- | | `data-slot` | `accordion-item` | | `data-state` | | | `data-disabled` | | ### AccordionHeader Selector: `[ndAccordionHeader]` The heading around a section's trigger: an `h2` to `h6` that fits the page's outline. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `accordion-header` | | `data-state` | | | `data-disabled` | | ### AccordionTrigger Selector: `button[ndAccordionTrigger]` The button that opens and closes its section. Put the chevron icon in it, last; it turns over while the section is open. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `accordion-trigger` | | `data-state` | | | `data-disabled` | | ### AccordionContent Selector: `[ndAccordionContent]` · Role: `region` The content its trigger shows and hides, sliding open and closed. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `accordion-content` | | `data-state` | | | `data-disabled` | | | `data-animating` | | ## Agent notes ### Do - Put each trigger in a heading of the level that fits the page. - Keep the headings short enough to scan. - Use `collapsible` unless one section must always stay open. ### Don't - Don't hide content everyone needs; show it. - Don't use it for a single section; use Collapsible. --- # Alert > Displays a callout that draws the user's attention. Version 1.0.0 · SSR · Zoneless · 0.8 kB gzip An alert is a callout that draws attention to a message: a warning, an error, a confirmation. It has an optional icon, a title and a description, laid out in a grid. It's announced with the `alert` role by default, which interrupts screen readers; set `role="status"` for news that can wait, or `role="none"` for a callout that's part of the page. ## Installation ```bash npx @ndop/cli add alert ``` Or by hand: install `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/alert/alert.ts ```ts import { computed, Directive, input } from '@angular/core'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * Class table for {@link Alert}. */ export const alertVariants = variants({ base: 'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', variants: { variant: { default: 'bg-card text-card-foreground', // The description is full destructive: shadcn's 90% is 4.49:1 on // the card, under WCAG's 4.5:1. destructive: 'bg-card text-destructive *:data-[slot=alert-description]:text-destructive [&>svg]:text-current', }, }, defaults: { variant: 'default' }, }); /** Visual style of an {@link Alert}. */ export type AlertVariant = VariantOf; /** * A callout: a message that draws attention, with an optional icon, * title and description. It's announced as an `alert` by default; use * `role="status"` for messages that can wait, or `role="none"` for ones * that are part of the page from the start. * * @example * ```html *
* *
Payment failed
*
Check your card details and try again.
*
* ``` */ @Directive({ selector: '[ndAlert]', host: { 'data-slot': 'alert', '[attr.role]': 'role()', '[attr.data-variant]': 'variant()', '[class]': 'classes()', }, }) export class Alert { /** Visual style. */ readonly variant = input('default'); /** * How assistive technology treats it: `alert` interrupts, `status` * waits for a pause, `none` is read with the page. */ readonly role = input<'alert' | 'status' | 'none'>('alert'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn(alertVariants({ variant: this.variant() }), this.class()), ); } /** * An alert's title. * * @example * ```html *
Payment failed
* ``` */ @Directive({ selector: '[ndAlertTitle]', host: { 'data-slot': 'alert-title', '[class]': 'classes()' }, }) export class AlertTitle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', this.class(), ), ); } /** * An alert's description, under its title. * * @example * ```html *
Check your card details and try again.
* ``` */ @Directive({ selector: '[ndAlertDescription]', host: { 'data-slot': 'alert-description', '[class]': 'classes()' }, }) export class AlertDescription { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Alert, AlertDescription, AlertTitle } from '@/ui/alert/alert'; ``` ```html
Your changes have been saved
Everyone on the team sees the new settings right away.
``` ## Examples ### Alert demo A success message with an icon. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Alert, AlertDescription, AlertTitle } from '@/ui/alert/alert'; /** A success message with an icon, shown with the page. */ @Component({ selector: 'example-alert-demo', imports: [Alert, AlertDescription, AlertTitle, NdIcon], template: `
Your changes have been saved
Everyone on the team sees the new settings right away.
`, }) export class AlertDemoExample {} ``` ### Alert destructive An error, with what to do about it. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Alert, AlertDescription, AlertTitle } from '@/ui/alert/alert'; /** An error, with what to do about it. */ @Component({ selector: 'example-alert-destructive', imports: [Alert, AlertDescription, AlertTitle, NdIcon], template: `
Unable to process your payment

Please check your billing information and try again.

  • Check your card details
  • Make sure there are enough funds
  • Verify the billing address
`, }) export class AlertDestructiveExample {} ``` ## Accessibility Alert has no keyboard interaction or focus handling of its own; it keeps the semantics of the element it's on. ## API reference ### Alert Selector: `[ndAlert]` A callout: a message that draws attention, with an optional icon, title and description. It's announced as an `alert` by default; use `role="status"` for messages that can wait, or `role="none"` for ones that are part of the page from the start. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[variant]` | `'default' \| 'destructive'` | `'default'` | Visual style. | | `[role]` | `'alert' \| 'status' \| 'none'` | `'alert'` | How assistive technology treats it: `alert` interrupts, `status` waits for a pause, `none` is read with the page. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `alert` | | `data-variant` | | ### AlertTitle Selector: `[ndAlertTitle]` An alert's title. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `alert-title` | ### AlertDescription Selector: `[ndAlertDescription]` An alert's description, under its title. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `alert-description` | ## Agent notes ### Do - Write a short title that says what happened, and a description that says what to do. - Use `variant="destructive"` for errors, and say it in the text too. - Use `role="none"` for alerts rendered with the page, and keep `alert` for ones that appear after something the user did. ### Don't - Don't use an alert for a notification that should disappear on its own; use a toast. - Don't put several `role="alert"` messages on a page at once. --- # Alert Dialog > A modal dialog that interrupts with an important question, such as confirming a deletion, and expects an answer. Version 1.0.0 · WAI-ARIA pattern: [Dialog (Modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) · SSR · Zoneless · 4.1 kB gzip An alert dialog interrupts with a question the user must answer before going on, such as confirming a deletion. It's announced as an alert dialog, labelled by its question and described by what answering means. Clicking outside doesn't close it; Escape and Cancel do, and focus starts on Cancel, the safe choice. Use `size="sm"` for a short, centered question with buttons side by side. ## Installation ```bash npx @ndop/cli add alert-dialog ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/alert-dialog/alert-dialog.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdDialog } from '@ndop/primitives/dialog'; import { NdAlertDialog, NdAlertDialogAction, NdAlertDialogCancel, NdAlertDialogContent, NdAlertDialogDescription, NdAlertDialogOverlay, NdAlertDialogPortal, NdAlertDialogTitle, NdAlertDialogTrigger, } from '@ndop/primitives/alert-dialog'; import { buttonVariants, type ButtonSize, type ButtonVariant, } from '@/ui/button/button'; import { cn } from '@/lib/utils'; /** * An alert dialog (`@ndop/primitives/alert-dialog`): a modal question the * user must answer. Clicking outside doesn't close it; Escape and Cancel * do. Put it on an `ng-container`; `open` is two-way bindable. * * @example * ```html * … * ``` */ @Directive({ selector: '[ndAlertDialog]', hostDirectives: [ { directive: NdDialog, inputs: ['open', 'defaultOpen'], outputs: ['openChange'], }, ], }) export class AlertDialog {} /** * The button that opens its alert dialog. * * @example * ```html * * ``` */ @Directive({ selector: '[ndAlertDialogTrigger]', hostDirectives: [NdAlertDialogTrigger], host: { 'data-slot': 'alert-dialog-trigger' }, }) export class AlertDialogTrigger {} /** * Marks the `ng-template` its alert dialog renders while open. * * @example * ```html * … * ``` */ @Directive({ selector: 'ng-template[ndAlertDialogPortal]', hostDirectives: [NdAlertDialogPortal], }) export class AlertDialogPortal {} /** * The dimmed backdrop behind an alert dialog. Clicking it does nothing: * the user must answer. * * @example * ```html *
* ``` */ @Directive({ selector: '[ndAlertDialogOverlay]', hostDirectives: [NdAlertDialogOverlay], host: { 'data-slot': 'alert-dialog-overlay', '[class]': 'classes()' }, }) export class AlertDialogOverlay { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0', this.class(), ), ); } /** * The alert dialog box (`@ndop/primitives/alert-dialog`): an * `alertdialog` labelled by its title, with focus starting on Cancel. * `size="sm"` is a compact, centered layout with side-by-side buttons. * * @example * ```html *
*
*

Delete this project?

*

This can't be undone.

*
*
* * *
*
* ``` */ @Directive({ selector: '[ndAlertDialogContent]', hostDirectives: [NdAlertDialogContent], host: { 'data-slot': 'alert-dialog-content', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class AlertDialogContent { /** `default`, or `sm` for a compact, centered dialog. */ readonly size = input<'default' | 'sm'>('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg', this.class(), ), ); } /** * Lays out an alert dialog's media, title and description. * * @example * ```html *
…
* ``` */ @Directive({ selector: '[ndAlertDialogHeader]', host: { 'data-slot': 'alert-dialog-header', '[class]': 'classes()' }, }) export class AlertDialogHeader { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-start sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]', this.class(), ), ); } /** * Lays out an alert dialog's buttons: stacked on small screens, in a row * at the end side otherwise. * * @example * ```html *
…
* ``` */ @Directive({ selector: '[ndAlertDialogFooter]', host: { 'data-slot': 'alert-dialog-footer', '[class]': 'classes()' }, }) export class AlertDialogFooter { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end', this.class(), ), ); } /** * The question an alert dialog asks; it labels the dialog. * * @example * ```html *

Delete this project?

* ``` */ @Directive({ selector: '[ndAlertDialogTitle]', hostDirectives: [NdAlertDialogTitle], host: { 'data-slot': 'alert-dialog-title', '[class]': 'classes()' }, }) export class AlertDialogTitle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2', this.class(), ), ); } /** * What answering means; it describes the dialog. * * @example * ```html *

This can't be undone.

* ``` */ @Directive({ selector: '[ndAlertDialogDescription]', hostDirectives: [NdAlertDialogDescription], host: { 'data-slot': 'alert-dialog-description', '[class]': 'classes()' }, }) export class AlertDialogDescription { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('text-sm text-muted-foreground', this.class()), ); } /** * An icon above or beside the title, hidden from assistive technology. * * @example * ```html *
* ``` */ @Directive({ selector: '[ndAlertDialogMedia]', host: { 'data-slot': 'alert-dialog-media', 'aria-hidden': 'true', '[class]': 'classes()', }, }) export class AlertDialogMedia { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8", this.class(), ), ); } /** * The button that confirms, styled as a Button: its `(click)` runs, then * the dialog closes. Use `variant="destructive"` for destructive actions. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndAlertDialogAction]', hostDirectives: [NdAlertDialogAction], host: { 'data-slot': 'alert-dialog-action', '[class]': 'classes()', }, }) export class AlertDialogAction { /** Button style. */ readonly variant = input('default'); /** Button size. */ readonly size = input('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: this.variant(), size: this.size() }), this.class(), ), ); } /** * The button that cancels and closes, styled as an outline Button; focus * starts here. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndAlertDialogCancel]', hostDirectives: [NdAlertDialogCancel], host: { 'data-slot': 'alert-dialog-cancel', '[class]': 'classes()', }, }) export class AlertDialogCancel { /** Button style. */ readonly variant = input('outline'); /** Button size. */ readonly size = input('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: this.variant(), size: this.size() }), this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ### src/app/ui/button/button.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdButton } from '@ndop/primitives/button'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * Class table for {@link Button}; also usable to style other elements like * a button. */ export const buttonVariants = variants({ base: "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-disabled:pointer-events-none data-disabled:opacity-50", variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', }, size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', icon: 'size-9', 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", 'icon-sm': 'size-8', 'icon-lg': 'size-10', }, }, defaults: { variant: 'default', size: 'default' }, }); /** Visual style of a {@link Button}. */ export type ButtonVariant = VariantOf; /** Size of a {@link Button}. */ export type ButtonSize = VariantOf; /** * Styled button for a native `button` or an `a`, with the * `@ndop/primitives/button` behavior (safe default type, disabled links). * * @example * ```html * *
Docs * ``` */ @Directive({ selector: 'button[ndButton], a[ndButton]', hostDirectives: [{ directive: NdButton, inputs: ['disabled'] }], host: { 'data-slot': 'button', '[attr.data-variant]': 'variant()', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class Button { /** Visual style. */ readonly variant = input('default'); /** Height, padding and icon size. */ readonly size = input('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: this.variant(), size: this.size() }), this.class(), ), ); } ``` ## Usage ```ts import { AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogTitle, AlertDialog, AlertDialogPortal, AlertDialogTrigger, } from '@/ui/alert-dialog/alert-dialog'; import { Button } from '@/ui/button/button'; ``` ```html

Sign out of every device?

You'll need to sign in again on your phone and other computers.

``` ## Examples ### Alert Dialog demo A confirmation before signing out, with Cancel focused first. ```ts import { Component } from '@angular/core'; import { AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogTitle, AlertDialog, AlertDialogPortal, AlertDialogTrigger, } from '@/ui/alert-dialog/alert-dialog'; import { Button } from '@/ui/button/button'; /** A confirmation before signing out, with Cancel focused first. */ @Component({ selector: 'example-alert-dialog-demo', imports: [ AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogTitle, Button, AlertDialog, AlertDialogPortal, AlertDialogTrigger, ], template: `

Sign out of every device?

You'll need to sign in again on your phone and other computers.

`, }) export class AlertDialogDemoExample {} ``` ### Alert Dialog destructive Deleting a project, with an icon and a destructive action that runs before the dialog closes. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogMedia, AlertDialogOverlay, AlertDialogTitle, AlertDialog, AlertDialogPortal, AlertDialogTrigger, } from '@/ui/alert-dialog/alert-dialog'; import { Button } from '@/ui/button/button'; /** * Deleting a project: an icon, a destructive action whose handler runs * before the dialog closes, and the result reported on the page. */ @Component({ selector: 'example-alert-dialog-destructive', imports: [ AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogMedia, AlertDialogOverlay, AlertDialogTitle, Button, AlertDialog, AlertDialogPortal, AlertDialogTrigger, NdIcon, ], template: `

Delete "Website redesign"?

Its 24 tasks and their comments are deleted for everyone. This can't be undone.

@if (deleted()) { Project deleted. }

`, }) export class AlertDialogDestructiveExample { protected readonly deleted = signal(false); } ``` ### Alert Dialog small The small size: a short question with side-by-side buttons. ```ts import { Component } from '@angular/core'; import { AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogTitle, AlertDialogDescription, AlertDialog, AlertDialogPortal, AlertDialogTrigger, } from '@/ui/alert-dialog/alert-dialog'; import { Button } from '@/ui/button/button'; /** The small size: a short question with side-by-side buttons. */ @Component({ selector: 'example-alert-dialog-small', imports: [ AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogTitle, Button, AlertDialog, AlertDialogPortal, AlertDialogTrigger, ], template: `

Discard draft?

Your changes will be lost.

`, }) export class AlertDialogSmallExample {} ``` ## Accessibility Implements the [Dialog (Modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Tab` | Moves focus to the next focusable element inside the dialog; from the last one, wraps to the first. | | `Shift + Tab` | Moves focus to the previous focusable element inside the dialog; from the first one, wraps to the last. | | `Escape` | Closes the dialog, unless `closeOnEscape` is `false`. | | `Tab` | Moves focus to the next focusable element inside; wraps from last to first. | | `Shift + Tab` | Moves focus to the previous focusable element inside; wraps from first to last. | | `Escape` | Closes the dialog, as Cancel does. | ### Focus - On open: focus moves to the first focusable element in the content, or to the content element itself when it has none. - While open: focus is trapped in the content. - On close: focus returns to the element that had it before opening, usually the trigger. - On open: focus moves to AlertDialogCancel. - While open: focus is trapped in the content. - On close: focus returns to the element that had it before opening, usually the trigger. ### Right-to-left Nothing mirrors: the dialog is centered, and Tab order follows the DOM. Nothing mirrors. ## API reference ### AlertDialog Selector: `[ndAlertDialog]` An alert dialog (`@ndop/primitives/alert-dialog`): a modal question the user must answer. Clicking outside doesn't close it; Escape and Cancel do. Put it on an `ng-container`; `open` is two-way bindable. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[defaultOpen]` | `boolean` | `false` | Initial open state when `open` is not bound. | | `[(open)]` | `boolean` | `false` | Whether the dialog is open. Two-way bindable with `[(open)]`. | ### AlertDialogTrigger Selector: `[ndAlertDialogTrigger]` The button that opens its alert dialog. | Data attribute | Values | | --- | --- | | `data-slot` | `alert-dialog-trigger` | | `data-state` | | ### AlertDialogPortal Selector: `ng-template[ndAlertDialogPortal]` Marks the `ng-template` its alert dialog renders while open. ### AlertDialogOverlay Selector: `[ndAlertDialogOverlay]` The dimmed backdrop behind an alert dialog. Clicking it does nothing: the user must answer. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `alert-dialog-overlay` | | `data-state` | | ### AlertDialogContent Selector: `[ndAlertDialogContent]` · Role: `alertdialog` The alert dialog box (`@ndop/primitives/alert-dialog`): an `alertdialog` labelled by its title, with focus starting on Cancel. `size="sm"` is a compact, centered layout with side-by-side buttons. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[size]` | `'default' \| 'sm'` | `'default'` | `default`, or `sm` for a compact, centered dialog. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `alert-dialog-content` | | `data-size` | | | `data-state` | | ### AlertDialogHeader Selector: `[ndAlertDialogHeader]` Lays out an alert dialog's media, title and description. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `alert-dialog-header` | ### AlertDialogFooter Selector: `[ndAlertDialogFooter]` Lays out an alert dialog's buttons: stacked on small screens, in a row at the end side otherwise. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `alert-dialog-footer` | ### AlertDialogTitle Selector: `[ndAlertDialogTitle]` The question an alert dialog asks; it labels the dialog. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `alert-dialog-title` | ### AlertDialogDescription Selector: `[ndAlertDialogDescription]` What answering means; it describes the dialog. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `alert-dialog-description` | ### AlertDialogMedia Selector: `[ndAlertDialogMedia]` An icon above or beside the title, hidden from assistive technology. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `alert-dialog-media` | ### AlertDialogAction Selector: `button[ndAlertDialogAction]` The button that confirms, styled as a Button: its `(click)` runs, then the dialog closes. Use `variant="destructive"` for destructive actions. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[variant]` | `'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link'` | `'default'` | Button style. | | `[size]` | `'default' \| 'xs' \| 'sm' \| 'lg' \| 'icon' \| 'icon-xs' \| 'icon-sm' \| 'icon-lg'` | `'default'` | Button size. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `alert-dialog-action` | ### AlertDialogCancel Selector: `button[ndAlertDialogCancel]` The button that cancels and closes, styled as an outline Button; focus starts here. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[variant]` | `'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link'` | `'outline'` | Button style. | | `[size]` | `'default' \| 'xs' \| 'sm' \| 'lg' \| 'icon' \| 'icon-xs' \| 'icon-sm' \| 'icon-lg'` | `'default'` | Button size. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `alert-dialog-cancel` | ## Agent notes ### Do - Ask a question with a clear answer, and name the buttons after what they do, such as "Delete project" rather than "OK". - Use the destructive variant for the action when it destroys something. - Say what can't be undone. ### Don't - Don't use it for information that needs no answer; use a Toast or an Alert. - Don't put forms in it; use a Dialog. --- # Aspect Ratio > Displays content within a chosen width-to-height ratio. Version 1.0.0 · SSR · Zoneless · 0.4 kB gzip Aspect Ratio keeps its content at a width-to-height ratio as the width changes, for images, video and maps. It sets the CSS `aspect-ratio` property from `ratio`, such as `16 / 9`. Size the media inside to fill it. ## Installation ```bash npx @ndop/cli add aspect-ratio ``` Or by hand: install `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/aspect-ratio/aspect-ratio.ts ```ts import { computed, Directive, input, numberAttribute } from '@angular/core'; import { cn } from '@/lib/utils'; /** * Keeps its content at a width-to-height ratio, with the CSS * `aspect-ratio` property. Put an image or media inside, sized to fill it. * * @example * ```html *
* … *
* ``` */ @Directive({ selector: '[ndAspectRatio]', host: { 'data-slot': 'aspect-ratio', '[style.aspect-ratio]': 'ratio()', '[class]': 'classes()', }, }) export class AspectRatio { /** Width divided by height, such as `16 / 9`. */ readonly ratio = input(1, { transform: numberAttribute }); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('relative w-full', this.class()), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { AspectRatio } from '@/ui/aspect-ratio/aspect-ratio'; ``` ```html
``` ## Examples ### Aspect Ratio demo An illustration kept at 16:9. ```ts import { Component } from '@angular/core'; import { AspectRatio } from '@/ui/aspect-ratio/aspect-ratio'; /** An illustration kept at 16:9 whatever the width. */ @Component({ selector: 'example-aspect-ratio-demo', imports: [AspectRatio], template: `
`, }) export class AspectRatioDemoExample {} ``` ### Aspect Ratio square Square tiles in a grid. ```ts import { Component } from '@angular/core'; import { AspectRatio } from '@/ui/aspect-ratio/aspect-ratio'; /** Square tiles in a grid that stay square as the grid resizes. */ @Component({ selector: 'example-aspect-ratio-square', imports: [AspectRatio], template: `
    @for (tile of tiles; track tile) {
  • {{ tile }}
  • }
`, }) export class AspectRatioSquareExample { protected readonly tiles = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; } ``` ## Accessibility Aspect Ratio has no keyboard interaction or focus handling of its own; it keeps the semantics of the element it's on. ## API reference ### AspectRatio Selector: `[ndAspectRatio]` Keeps its content at a width-to-height ratio, with the CSS `aspect-ratio` property. Put an image or media inside, sized to fill it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[ratio]` | `number` | `1` | Width divided by height, such as `16 / 9`. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `aspect-ratio` | ## Agent notes ### Do - Put an `img` or `video` inside with `class="size-full object-cover"`. - Give images a meaningful `alt`, or `alt=""` when they're decorative. - Use it to stop the page shifting while media loads. ### Don't - Don't put text that can grow inside it; it will overflow. - Don't use it for things with a natural size, such as icons. --- # Avatar > Displays a user's image, with a fallback while it loads or when it fails. Version 1.0.0 · WAI-ARIA pattern: [`img` element's](https://www.w3.org/WAI/tutorials/images/) · SSR · Zoneless · 1.4 kB gzip An avatar shows a user's picture, with a fallback such as their initials until the image loads, or if it fails, so there's never a broken image. The image's `alt` names the avatar; once it has loaded, the fallback is hidden from screen readers, so the avatar is announced once. Avatars come in three sizes, and a group overlaps them with a count of the rest. ## Installation ```bash npx @ndop/cli add avatar ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/avatar/avatar.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdAvatar, NdAvatarFallback, NdAvatarImage, } from '@ndop/primitives/avatar'; import { cn } from '@/lib/utils'; /** Size of an {@link Avatar}. */ export type AvatarSize = 'default' | 'sm' | 'lg'; /** * A user's picture, with a fallback, such as initials, shown until the * image loads or when it fails (`@ndop/primitives/avatar`). * * @example * ```html * * * VM * * ``` */ @Directive({ selector: '[ndAvatar]', hostDirectives: [NdAvatar], host: { 'data-slot': 'avatar', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class Avatar { /** Size. */ readonly size = input('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6', this.class(), ), ); } /** * An avatar's image. It covers the fallback once loaded; its `alt` names * the avatar. * * @example * ```html * Valery Melou * ``` */ @Directive({ selector: 'img[ndAvatarImage]', hostDirectives: [{ directive: NdAvatarImage, inputs: ['src'] }], host: { 'data-slot': 'avatar-image', '[class]': 'classes()' }, }) export class AvatarImage { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'aspect-square size-full', 'absolute inset-0 data-[status=error]:hidden data-[status=loading]:opacity-0', this.class(), ), ); } /** * What an avatar shows until its image loads, or when it fails. * * @example * ```html * VM * ``` */ @Directive({ selector: '[ndAvatarFallback]', hostDirectives: [NdAvatarFallback], host: { 'data-slot': 'avatar-fallback', '[class]': 'classes()' }, }) export class AvatarFallback { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( // Foreground text: shadcn's muted text on the muted fill is 4.3:1, // under WCAG's 4.5:1 for text this size. 'flex size-full items-center justify-center rounded-full bg-muted text-sm text-foreground group-data-[size=sm]/avatar:text-xs', 'data-[status=loaded]:invisible', this.class(), ), ); } /** * A status dot on an avatar's corner, such as "online". Say the status in * text too, for screen readers. * * @example * ```html * Online * ``` */ @Directive({ selector: '[ndAvatarBadge]', host: { 'data-slot': 'avatar-badge', '[class]': 'classes()' }, }) export class AvatarBadge { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'absolute end-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none', 'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden', 'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2', 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2', this.class(), ), ); } /** * Overlapping avatars, such as a team's. * * @example * ```html *
…
* ``` */ @Directive({ selector: '[ndAvatarGroup]', host: { 'data-slot': 'avatar-group', '[class]': 'classes()' }, }) export class AvatarGroup { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background', 'rtl:space-x-reverse', this.class(), ), ); } /** * The count of avatars left out of a group, such as "+3". * * @example * ```html * +3 * ``` */ @Directive({ selector: '[ndAvatarGroupCount]', host: { 'data-slot': 'avatar-group-count', '[class]': 'classes()' }, }) export class AvatarGroupCount { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Avatar, AvatarFallback, AvatarImage } from '@/ui/avatar/avatar'; ``` ```html
Ada Lovelace AL Grace Hopper GH KJ
``` ## Examples ### Avatar demo An avatar with an image, and one that falls back to initials. ```ts import { Component } from '@angular/core'; import { Avatar, AvatarFallback, AvatarImage } from '@/ui/avatar/avatar'; /** * A picture kept with the code, so the example loads nothing from the * network. Use your users' image URLs. */ const PICTURE = 'data:image/svg+xml,' + encodeURIComponent( '', ); /** An avatar with an image, and one that falls back to initials. */ @Component({ selector: 'example-avatar-demo', imports: [Avatar, AvatarFallback, AvatarImage], template: `
Ada Lovelace AL Grace Hopper GH KJ
`, }) export class AvatarDemoExample { protected readonly picture = PICTURE; } ``` ### Avatar group A team's avatars, with a count and a status badge. ```ts import { Component } from '@angular/core'; import { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, } from '@/ui/avatar/avatar'; /** * A team's avatars overlapping, with a count of the others and a status * badge that says its meaning in text too. */ @Component({ selector: 'example-avatar-group', imports: [Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount], template: `
@for (member of team; track member) { {{ member }} } +3
VM Online
`, }) export class AvatarGroupExample { protected readonly team = ['AL', 'GH', 'KJ']; } ``` ## Accessibility Implements the [`img` element's](https://www.w3.org/WAI/tutorials/images/) pattern of the WAI-ARIA Authoring Practices. ### Focus - The avatar never receives focus and never changes focus. ### Right-to-left Nothing mirrors. ## API reference ### Avatar Selector: `[ndAvatar]` A user's picture, with a fallback, such as initials, shown until the image loads or when it fails (`@ndop/primitives/avatar`). | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[size]` | `'default' \| 'sm' \| 'lg'` | `'default'` | Size. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `avatar` | | `data-size` | | | `data-status` | | ### AvatarImage Selector: `img[ndAvatarImage]` An avatar's image. It covers the fallback once loaded; its `alt` names the avatar. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[src]` | `string \| null \| undefined` | | The image's URL; without one, the fallback shows. | | Data attribute | Values | | --- | --- | | `data-slot` | `avatar-image` | | `data-status` | | ### AvatarFallback Selector: `[ndAvatarFallback]` What an avatar shows until its image loads, or when it fails. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `avatar-fallback` | | `data-status` | | ### AvatarBadge Selector: `[ndAvatarBadge]` A status dot on an avatar's corner, such as "online". Say the status in text too, for screen readers. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `avatar-badge` | ### AvatarGroup Selector: `[ndAvatarGroup]` Overlapping avatars, such as a team's. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `avatar-group` | ### AvatarGroupCount Selector: `[ndAvatarGroupCount]` The count of avatars left out of a group, such as "+3". | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `avatar-group-count` | ## Agent notes ### Do - Give the image an `alt` with the person's name, or `alt=""` when their name is already next to it. - Use initials or an icon as the fallback. - Say an avatar badge's meaning in text too, such as an `sr-only` "Online". ### Don't - Don't use an avatar as the only way to tell people apart. - Don't put interactive controls inside it; wrap it in a link or button. --- # Badge > Displays a badge or a component that looks like a badge. Version 1.0.0 · SSR · Zoneless · 0.8 kB gzip A badge marks something with a short status, count or label, such as "Beta", "3 new" or a version. `ndBadge` only styles its host, so a badge is as accessible as the element it's on: plain text in a `span`, a link in an `a`, an item in a list. Six variants set the emphasis, from `default` to `link`. ## Installation ```bash npx @ndop/cli add badge ``` Or by hand: install `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/badge/badge.ts ```ts import { computed, Directive, input } from '@angular/core'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * Class table for {@link Badge}; also usable to style other elements like * a badge. */ export const badgeVariants = variants({ base: 'inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3', variants: { variant: { default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90', secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', destructive: 'bg-destructive text-destructive-foreground focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90', outline: 'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', ghost: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground', link: 'text-primary underline-offset-4 [a&]:hover:underline', }, }, defaults: { variant: 'default' }, }); /** Visual style of a {@link Badge}. */ export type BadgeVariant = VariantOf; /** * Styled badge: a short status, count or label. It only styles its host, * so the host keeps its own semantics: a `span` for text, an `a` when it * links somewhere, an `li` in a list of badges. * * @example * ```html * Beta * v1.2 * ``` */ @Directive({ selector: '[ndBadge]', host: { 'data-slot': 'badge', '[attr.data-variant]': 'variant()', '[class]': 'classes()', }, }) export class Badge { /** Visual style. */ readonly variant = input('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn(badgeVariants({ variant: this.variant() }), this.class()), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Badge } from '@/ui/badge/badge'; ``` ```html
Badge Secondary Destructive Outline
8 99 20+
``` ## Examples ### Badge demo The four main Badge variants, then badges used as counts. ```ts import { Component } from '@angular/core'; import { Badge } from '@/ui/badge/badge'; /** The four main Badge variants, then badges used as counts. */ @Component({ selector: 'example-badge-demo', imports: [Badge], template: `
Badge Secondary Destructive Outline
8 99 20+
`, }) export class BadgeDemoExample {} ``` ### Badge link A badge that links somewhere, with a hover style. ```ts import { Component } from '@angular/core'; import { Badge } from '@/ui/badge/badge'; /** A badge that links somewhere, with a hover style. */ @Component({ selector: 'example-badge-link', imports: [Badge], template: `Changelog`, }) export class BadgeLinkExample {} ``` ### Badge variants Every Badge variant side by side. ```ts import { Component } from '@angular/core'; import { Badge } from '@/ui/badge/badge'; /** Every Badge variant side by side. */ @Component({ selector: 'example-badge-variants', imports: [Badge], template: `
Default Secondary Destructive Outline Ghost Link
`, }) export class BadgeVariantsExample {} ``` ## Accessibility Badge has no keyboard interaction or focus handling of its own; it keeps the semantics of the element it's on. ## API reference ### Badge Selector: `[ndBadge]` Styled badge: a short status, count or label. It only styles its host, so the host keeps its own semantics: a `span` for text, an `a` when it links somewhere, an `li` in a list of badges. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[variant]` | `'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link'` | `'default'` | Visual style. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `badge` | | `data-variant` | | ## Agent notes ### Do - Keep the text to a word or two, or a number. - Put `ndBadge` on an `a` with an `href` when the badge leads somewhere; it then gets a hover style. - When a badge's meaning depends on its color, also say it in the text, such as "Failed" rather than a red dot. - Give a group of badges a list (`ul` with `li[ndBadge]`) and a label when they belong together. ### Don't - Don't put a click handler on a `span[ndBadge]`; use a button for actions so keyboard and screen reader users can operate it. - Don't use a badge for long text or sentences; use an alert or plain text. --- # Breadcrumb > Shows the path to the current page in a hierarchy of pages. Version 1.0.0 · SSR · Zoneless · 0.7 kB gzip A breadcrumb shows where the current page sits in the site's hierarchy, with links back up. It's a `nav` landmark named "Breadcrumb" holding an ordered list; the last item is the current page, marked `aria-current="page"`, and separators are hidden from screen readers. ## Installation ```bash npx @ndop/cli add breadcrumb ``` Or by hand: install `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/breadcrumb/breadcrumb.ts ```ts import { computed, Directive, input } from '@angular/core'; import { cn } from '@/lib/utils'; /** * A breadcrumb trail: the path from the top of a site to the current page, * as a `nav` landmark. * * @example * ```html * * ``` */ @Directive({ selector: 'nav[ndBreadcrumb]', host: { 'data-slot': 'breadcrumb', '[attr.aria-label]': 'label()' }, }) export class Breadcrumb { /** Name of the landmark. */ readonly label = input('Breadcrumb'); } /** * The ordered list of a breadcrumb's items and separators. * * @example * ```html *
    …
* ``` */ @Directive({ selector: 'ol[ndBreadcrumbList]', host: { 'data-slot': 'breadcrumb-list', '[class]': 'classes()' }, }) export class BreadcrumbList { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5', this.class(), ), ); } /** * One step of a breadcrumb trail. * * @example * ```html *
  • Docs
  • * ``` */ @Directive({ selector: 'li[ndBreadcrumbItem]', host: { 'data-slot': 'breadcrumb-item', '[class]': 'classes()' }, }) export class BreadcrumbItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('inline-flex items-center gap-1.5', this.class()), ); } /** * A link to an ancestor page. With the router, add `routerLink` to the * same `a`. * * @example * ```html * Docs * ``` */ @Directive({ selector: 'a[ndBreadcrumbLink]', host: { 'data-slot': 'breadcrumb-link', '[class]': 'classes()' }, }) export class BreadcrumbLink { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('transition-colors hover:text-foreground', this.class()), ); } /** * The current page, last in the trail: plain text marked * `aria-current="page"`. * * @example * ```html * Settings * ``` */ @Directive({ selector: '[ndBreadcrumbPage]', host: { 'data-slot': 'breadcrumb-page', 'aria-current': 'page', '[class]': 'classes()', }, }) export class BreadcrumbPage { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('font-normal text-foreground', this.class()), ); } /** * The mark between two items, hidden from assistive technology. Put an * icon in it, such as `chevron-right`, which turns around in right-to-left * layouts. * * @example * ```html *
  • * ``` */ @Directive({ selector: 'li[ndBreadcrumbSeparator]', host: { 'data-slot': 'breadcrumb-separator', role: 'presentation', 'aria-hidden': 'true', '[class]': 'classes()', }, }) export class BreadcrumbSeparator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('[&>svg]:size-3.5', 'rtl:[&>svg]:rotate-180', this.class()), ); } /** * Stands for collapsed items in a long trail. Put an icon in it, such as * `ellipsis`; wrap it in a menu trigger to reveal the hidden items. * * @example * ```html * * ``` */ @Directive({ selector: '[ndBreadcrumbEllipsis]', host: { 'data-slot': 'breadcrumb-ellipsis', role: 'presentation', 'aria-hidden': 'true', '[class]': 'classes()', }, }) export class BreadcrumbEllipsis { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex size-9 items-center justify-center', '[&>svg]:size-4', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, } from '@/ui/breadcrumb/breadcrumb'; ``` ```html ``` ## Examples ### Breadcrumb demo A trail with collapsed items. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, } from '@/ui/breadcrumb/breadcrumb'; /** A trail to the current page, with middle items collapsed. */ @Component({ selector: 'example-breadcrumb-demo', imports: [ Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, NdIcon, ], template: ` `, }) export class BreadcrumbDemoExample {} ``` ### Breadcrumb separator A trail with slash separators. ```ts import { Component } from '@angular/core'; import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, } from '@/ui/breadcrumb/breadcrumb'; /** A trail with slashes between the items. */ @Component({ selector: 'example-breadcrumb-separator', imports: [ Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, ], template: ` `, }) export class BreadcrumbSeparatorExample {} ``` ## Accessibility Breadcrumb has no keyboard interaction or focus handling of its own; it keeps the semantics of the element it's on. ## API reference ### Breadcrumb Selector: `nav[ndBreadcrumb]` A breadcrumb trail: the path from the top of a site to the current page, as a `nav` landmark. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[label]` | `string` | `'Breadcrumb'` | Name of the landmark. | | Data attribute | Values | | --- | --- | | `data-slot` | `breadcrumb` | ### BreadcrumbList Selector: `ol[ndBreadcrumbList]` The ordered list of a breadcrumb's items and separators. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `breadcrumb-list` | ### BreadcrumbItem Selector: `li[ndBreadcrumbItem]` One step of a breadcrumb trail. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `breadcrumb-item` | ### BreadcrumbLink Selector: `a[ndBreadcrumbLink]` A link to an ancestor page. With the router, add `routerLink` to the same `a`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `breadcrumb-link` | ### BreadcrumbPage Selector: `[ndBreadcrumbPage]` The current page, last in the trail: plain text marked `aria-current="page"`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `breadcrumb-page` | ### BreadcrumbSeparator Selector: `li[ndBreadcrumbSeparator]` The mark between two items, hidden from assistive technology. Put an icon in it, such as `chevron-right`, which turns around in right-to-left layouts. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `breadcrumb-separator` | ### BreadcrumbEllipsis Selector: `[ndBreadcrumbEllipsis]` Stands for collapsed items in a long trail. Put an icon in it, such as `ellipsis`; wrap it in a menu trigger to reveal the hidden items. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `breadcrumb-ellipsis` | ## Agent notes ### Do - Use `nav[ndBreadcrumb]` with `ol[ndBreadcrumbList]`, and `li` for items and separators. - End with `ndBreadcrumbPage` for the current page, as text, not a link. - Put a `chevron-right` or `/` in each separator; chevrons turn around in right-to-left layouts. ### Don't - Don't use a breadcrumb as the site's main navigation. - Don't show it on flat sites, where every page sits at the top level. --- # Button > Displays a button or a component that looks like a button. Version 1.0.0 · WAI-ARIA pattern: [Button](https://www.w3.org/WAI/ARIA/apg/patterns/button/) · SSR · Zoneless · 1.4 kB gzip Buttons start actions: submitting a form, opening a dialog, saving a change. `ndButton` styles a native `button`, or an `a` when the action navigates, so both keep their native keyboard behavior and semantics. Six variants set the emphasis, from `default` for the main action to `link` for an action that reads like text, and eight sizes cover text and icon-only buttons. ## Installation ```bash npx @ndop/cli add button ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/button/button.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdButton } from '@ndop/primitives/button'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * Class table for {@link Button}; also usable to style other elements like * a button. */ export const buttonVariants = variants({ base: "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-disabled:pointer-events-none data-disabled:opacity-50", variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', }, size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', icon: 'size-9', 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", 'icon-sm': 'size-8', 'icon-lg': 'size-10', }, }, defaults: { variant: 'default', size: 'default' }, }); /** Visual style of a {@link Button}. */ export type ButtonVariant = VariantOf; /** Size of a {@link Button}. */ export type ButtonSize = VariantOf; /** * Styled button for a native `button` or an `a`, with the * `@ndop/primitives/button` behavior (safe default type, disabled links). * * @example * ```html * * Docs * ``` */ @Directive({ selector: 'button[ndButton], a[ndButton]', hostDirectives: [{ directive: NdButton, inputs: ['disabled'] }], host: { 'data-slot': 'button', '[attr.data-variant]': 'variant()', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class Button { /** Visual style. */ readonly variant = input('default'); /** Height, padding and icon size. */ readonly size = input('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: this.variant(), size: this.size() }), this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Button } from '@/ui/button/button'; ``` ```html
    ``` ## Examples ### Button variants Every Button variant, side by side. ```ts import { Component } from '@angular/core'; import { Button } from '@/ui/button/button'; /** Every Button variant, side by side. */ @Component({ selector: 'example-button-variants', imports: [Button], template: `
    `, }) export class ButtonVariantsExample {} ``` ### Button anchor Button styles on links, where shadcn uses `asChild`: an enabled link, and a disabled one that stays focusable and announces itself as disabled. ```ts import { Component, signal } from '@angular/core'; import { Button } from '@/ui/button/button'; /** * Button styles on links, where shadcn uses `asChild`: an enabled link, and * a disabled one that stays focusable and announces itself as disabled. */ @Component({ selector: 'example-button-anchor', imports: [Button], template: ` `, }) export class ButtonAnchorExample { protected readonly locked = signal(true); } ``` ### Button link The `link` variant: a button that looks like a text link. ```ts import { Component } from '@angular/core'; import { Button } from '@/ui/button/button'; /** The `link` variant: a button that looks like a text link. */ @Component({ selector: 'example-button-link', imports: [Button], template: ``, }) export class ButtonLinkExample {} ``` ### Button sizes Button sizes, including an icon-only button with an accessible name. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; /** Button sizes, including an icon-only button with an accessible name. */ @Component({ selector: 'example-button-sizes', imports: [Button, NdIcon], template: `
    `, }) export class ButtonSizesExample {} ``` ## Accessibility Implements the [Button](https://www.w3.org/WAI/ARIA/apg/patterns/button/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Enter` | Activates the button, or follows the link; does nothing while disabled. | | `Space` | Activates a `button`; does nothing while disabled. Links don't react to Space. | ### Focus - A disabled `button` and a disabled `a` are removed from the tab order. - Activation does not move focus. ### Right-to-left Nothing mirrors. ## API reference ### Button Selector: `button[ndButton], a[ndButton]` · Role: `native (button / link)` Styled button for a native `button` or an `a`, with the `@ndop/primitives/button` behavior (safe default type, disabled links). | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[variant]` | `'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link'` | `'default'` | Visual style. | | `[size]` | `'default' \| 'xs' \| 'sm' \| 'lg' \| 'icon' \| 'icon-xs' \| 'icon-sm' \| 'icon-lg'` | `'default'` | Height, padding and icon size. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[disabled]` | `boolean` | `false` | Whether the button or link is disabled. | | Data attribute | Values | | --- | --- | | `data-slot` | `button` | | `data-variant` | | | `data-size` | | | `data-disabled` | | ## Agent notes ### Do - Use one `default` button for the main action of a view, and `outline`, `secondary` or `ghost` for the others. - Put `ndButton` on an `a` with an `href` when the action navigates, so it opens in a new tab and shows its URL like any link. - Give icon-only buttons (`size="icon"`) an `aria-label`. - Use `destructive` for actions that delete or can't be undone, and confirm them in a dialog. ### Don't - Don't put `ndButton` on a `div` or `span`; it only applies to `button` and `a`, which keyboard and screen reader users can operate. - Don't disable a submit button to signal invalid input; keep it enabled and show the errors when it is pressed. - Don't use `link` for a primary action; it is meant to read like text. --- # Calendar > Month grids to choose a date, a range or several dates. Version 1.0.0 · WAI-ARIA pattern: [Date grid](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/datepicker-dialog/) · SSR · Zoneless · 5.7 kB gzip A calendar shows month grids to choose a date, a range of days or several dates. Only one day is in the tab order; the arrow keys move between days, Page Up and Page Down between months, and Enter or Space chooses a day. Month names, weekdays and day labels come from `Intl` in the app's locale, and weeks start on the locale's first day. It binds forms like any control. ## Installation ```bash npx @ndop/cli add calendar ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/calendar/calendar.ts ```ts import { computed, Directive, inject, input } from '@angular/core'; import { NdCalendar, NdCalendarCell, NdCalendarDay, NdCalendarGrid, NdCalendarHeading, NdCalendarMonth, NdCalendarNext, NdCalendarPrevious, NdCalendarWeekday, } from '@ndop/primitives/calendar'; import { buttonVariants } from '@/ui/button/button'; import { cn } from '@/lib/utils'; export type { NdCalendarDayView as CalendarDayView, NdCalendarMode as CalendarMode, NdCalendarMonthView as CalendarMonthView, NdCalendarWeekdayView as CalendarWeekdayView, NdCalendarWeekView as CalendarWeekView, NdDateRange as DateRange, } from '@ndop/primitives/calendar'; /** * A calendar (`@ndop/primitives/calendar`): month grids to choose a date, * a range or several dates, with the arrow keys moving between days. Its * structure is your template: loop over `months()` and `weekdays()`. * * @example * ```html *
    *
    *
    …
    * @for (month of calendar.months(); track month.key) { *
    …
    * } *
    *
    * ``` */ @Directive({ selector: '[ndCalendar]', exportAs: 'ndCalendar', hostDirectives: [ { directive: NdCalendar, inputs: [ 'mode', 'value', 'range', 'values', 'month', 'defaultMonth', 'numberOfMonths', 'min', 'max', 'disabledDates', 'weekStartsOn', 'locale', 'showOutsideDays', ], outputs: ['valueChange', 'rangeChange', 'valuesChange', 'monthChange'], }, ], host: { 'data-slot': 'calendar', '[class]': 'classes()' }, }) export class Calendar { /** Extra classes, merged last so they win. */ readonly class = input(''); private readonly calendar = inject(NdCalendar); /** The shown months, with their weeks and days. */ readonly months = this.calendar.months; /** The weekdays, in the order the columns show them. */ readonly weekdays = this.calendar.weekdays; protected readonly classes = computed(() => cn( 'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent', 'w-fit rtl:**:[[data-slot=calendar-previous]>svg]:rotate-180 rtl:**:[[data-slot=calendar-next]>svg]:rotate-180', this.class(), ), ); } /** * The row of shown months, which the navigation buttons sit above. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndCalendarMonths]', host: { 'data-slot': 'calendar-months', '[class]': 'classes()' }, }) export class CalendarMonths { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('relative flex flex-col gap-4 md:flex-row', this.class()), ); } /** * The bar that holds the previous and next buttons, across the top of the * months. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndCalendarNav]', host: { 'data-slot': 'calendar-nav', '[class]': 'classes()' }, }) export class CalendarNav { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', this.class(), ), ); } /** Classes of the previous and next buttons. */ const navButton = 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50'; /** * The button that shows the previous month. Name it with `aria-label` and * put the chevron icon in it. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndCalendarPrevious]', hostDirectives: [NdCalendarPrevious], host: { 'data-slot': 'calendar-previous', '[class]': 'classes()' }, }) export class CalendarPrevious { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn(buttonVariants({ variant: 'ghost' }), navButton, this.class()), ); } /** * The button that shows the next month. Name it with `aria-label` and put * the chevron icon in it. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndCalendarNext]', hostDirectives: [NdCalendarNext], host: { 'data-slot': 'calendar-next', '[class]': 'classes()' }, }) export class CalendarNext { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn(buttonVariants({ variant: 'ghost' }), navButton, this.class()), ); } /** * One shown month: its caption and grid. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndCalendarMonth]', hostDirectives: [{ directive: NdCalendarMonth, inputs: ['month'] }], host: { 'data-slot': 'calendar-month', '[class]': 'classes()' }, }) export class CalendarMonth { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex w-full flex-col gap-4', this.class()), ); } /** * The row of a month's name, between the navigation buttons. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndCalendarCaption]', host: { 'data-slot': 'calendar-caption', '[class]': 'classes()' }, }) export class CalendarCaption { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)', this.class(), ), ); } /** * A month's name and year, which labels its grid and is announced when the * month changes. * * @example * ```html *
    {{ month.label }}
    * ``` */ @Directive({ selector: '[ndCalendarHeading]', hostDirectives: [NdCalendarHeading], host: { 'data-slot': 'calendar-heading', '[class]': 'classes()' }, }) export class CalendarHeading { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('font-medium select-none', 'text-sm', this.class()), ); } /** * A month's grid of days, on a `table`. * * @example * ```html * …
    * ``` */ @Directive({ selector: 'table[ndCalendarGrid]', hostDirectives: [NdCalendarGrid], host: { 'data-slot': 'calendar-grid', '[class]': 'classes()' }, }) export class CalendarGrid { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('w-full border-collapse', this.class()), ); } /** * The row of weekday headers, on a `tr` in the grid's `thead`. * * @example * ```html * … * ``` */ @Directive({ selector: 'tr[ndCalendarWeekdays]', host: { 'data-slot': 'calendar-weekdays', '[class]': 'classes()' }, }) export class CalendarWeekdays { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex', this.class())); } /** * A weekday's column header, with its full name for screen readers. * * @example * ```html * {{ weekday.short }} * ``` */ @Directive({ selector: 'th[ndCalendarWeekday]', hostDirectives: [{ directive: NdCalendarWeekday, inputs: ['weekday'] }], host: { 'data-slot': 'calendar-weekday', '[class]': 'classes()' }, }) export class CalendarWeekday { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none', this.class(), ), ); } /** * A week's row of days, on a `tr` in the grid's `tbody`. * * @example * ```html * … * ``` */ @Directive({ selector: 'tr[ndCalendarWeek]', host: { 'data-slot': 'calendar-week', '[class]': 'classes()' }, }) export class CalendarWeek { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('mt-2 flex w-full', this.class()), ); } /** * A day's cell, which shapes the band of a chosen range. * * @example * ```html * … * ``` */ @Directive({ selector: 'td[ndCalendarCell]', hostDirectives: [{ directive: NdCalendarCell, inputs: ['day'] }], host: { 'data-slot': 'calendar-cell', '[class]': 'classes()' }, }) export class CalendarCell { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-e-md', '[&:first-child[data-selected=true]_button]:rounded-s-md', 'data-range-start:rounded-s-md data-range-start:bg-accent', 'data-range-middle:rounded-none', 'data-range-end:rounded-e-md data-range-end:bg-accent', 'data-today:rounded-md data-today:bg-accent data-today:text-accent-foreground data-today:data-[selected=true]:rounded-none', 'data-outside:text-muted-foreground data-outside:aria-selected:text-muted-foreground', 'data-disabled:text-muted-foreground data-disabled:opacity-50', 'data-hidden:invisible', this.class(), ), ); } /** * A day's button: chosen days fill with the primary color, and a range's * ends round off its band. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndCalendarDay]', hostDirectives: [{ directive: NdCalendarDay, inputs: ['day'] }], host: { 'data-slot': 'calendar-day', '[class]': 'classes()' }, }) export class CalendarDay { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: 'ghost', size: 'icon' }), 'flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal focus-visible:relative focus-visible:z-10 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-e-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-s-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70', this.class(), ), ); } ``` ### src/app/ui/button/button.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdButton } from '@ndop/primitives/button'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * Class table for {@link Button}; also usable to style other elements like * a button. */ export const buttonVariants = variants({ base: "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-disabled:pointer-events-none data-disabled:opacity-50", variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', }, size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', icon: 'size-9', 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", 'icon-sm': 'size-8', 'icon-lg': 'size-10', }, }, defaults: { variant: 'default', size: 'default' }, }); /** Visual style of a {@link Button}. */ export type ButtonVariant = VariantOf; /** Size of a {@link Button}. */ export type ButtonSize = VariantOf; /** * Styled button for a native `button` or an `a`, with the * `@ndop/primitives/button` behavior (safe default type, disabled links). * * @example * ```html * * Docs * ``` */ @Directive({ selector: 'button[ndButton], a[ndButton]', hostDirectives: [{ directive: NdButton, inputs: ['disabled'] }], host: { 'data-slot': 'button', '[attr.data-variant]': 'variant()', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class Button { /** Visual style. */ readonly variant = input('default'); /** Height, padding and icon size. */ readonly size = input('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: this.variant(), size: this.size() }), this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Calendar, CalendarCaption, CalendarCell, CalendarDay, CalendarGrid, CalendarHeading, CalendarMonth, CalendarMonths, CalendarNav, CalendarNext, CalendarPrevious, CalendarWeek, CalendarWeekday, CalendarWeekdays, } from '@/ui/calendar/calendar'; ``` ```html
    @for (month of calendar.months(); track month.key) {
    {{ month.label }}
    @for (weekday of calendar.weekdays(); track weekday.long) { } @for (week of month.weeks; track week.key) { @for (day of week.days; track day.key) { } }
    {{ weekday.short }}
    }
    ``` ## Examples ### Calendar demo A calendar to choose one date, with today marked. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Calendar, CalendarCaption, CalendarCell, CalendarDay, CalendarGrid, CalendarHeading, CalendarMonth, CalendarMonths, CalendarNav, CalendarNext, CalendarPrevious, CalendarWeek, CalendarWeekday, CalendarWeekdays, } from '@/ui/calendar/calendar'; /** * A calendar to choose one date. Today is marked; the arrow keys move between * days and Page Up and Page Down between months. */ @Component({ selector: 'example-calendar-demo', imports: [ Calendar, CalendarCaption, CalendarCell, CalendarDay, CalendarGrid, CalendarHeading, CalendarMonth, CalendarMonths, CalendarNav, CalendarNext, CalendarPrevious, CalendarWeek, CalendarWeekday, CalendarWeekdays, NdIcon, ], template: `
    @for (month of calendar.months(); track month.key) {
    {{ month.label }}
    @for (weekday of calendar.weekdays(); track weekday.long) { } @for (week of month.weeks; track week.key) { @for (day of week.days; track day.key) { } }
    {{ weekday.short }}
    }
    `, }) export class CalendarDemoExample { protected readonly date = signal(new Date()); } ``` ### Calendar range Two months side by side to choose a range of days. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Calendar, CalendarCaption, CalendarCell, CalendarDay, CalendarGrid, CalendarHeading, CalendarMonth, CalendarMonths, CalendarNav, CalendarNext, CalendarPrevious, CalendarWeek, CalendarWeekday, CalendarWeekdays, type DateRange, } from '@/ui/calendar/calendar'; /** * Two months side by side to choose a range of days: the first click * starts it, the second ends it. */ @Component({ selector: 'example-calendar-range', imports: [ Calendar, CalendarCaption, CalendarCell, CalendarDay, CalendarGrid, CalendarHeading, CalendarMonth, CalendarMonths, CalendarNav, CalendarNext, CalendarPrevious, CalendarWeek, CalendarWeekday, CalendarWeekdays, NdIcon, ], template: `
    @for (month of calendar.months(); track month.key) {
    {{ month.label }}
    @for (weekday of calendar.weekdays(); track weekday.long) { } @for (week of month.weeks; track week.key) { @for (day of week.days; track day.key) { } }
    {{ weekday.short }}
    }
    `, }) export class CalendarRangeExample { protected readonly range = signal({ from: new Date(), to: new Date(Date.now() + 6 * 24 * 60 * 60 * 1000), }); } ``` ### Calendar multiple Several dates chosen at once. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Calendar, CalendarCaption, CalendarCell, CalendarDay, CalendarGrid, CalendarHeading, CalendarMonth, CalendarMonths, CalendarNav, CalendarNext, CalendarPrevious, CalendarWeek, CalendarWeekday, CalendarWeekdays, } from '@/ui/calendar/calendar'; /** * Several dates chosen at once; choosing a date again removes it. */ @Component({ selector: 'example-calendar-multiple', imports: [ Calendar, CalendarCaption, CalendarCell, CalendarDay, CalendarGrid, CalendarHeading, CalendarMonth, CalendarMonths, CalendarNav, CalendarNext, CalendarPrevious, CalendarWeek, CalendarWeekday, CalendarWeekdays, NdIcon, ], template: `
    @for (month of calendar.months(); track month.key) {
    {{ month.label }}
    @for (weekday of calendar.weekdays(); track weekday.long) { } @for (week of month.weeks; track week.key) { @for (day of week.days; track day.key) { } }
    {{ weekday.short }}
    }

    {{ dates().length }} dates chosen.

    `, }) export class CalendarMultipleExample { protected readonly dates = signal([]); } ``` ### Calendar disabled Weekends and past days that can't be chosen. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Calendar, CalendarCaption, CalendarCell, CalendarDay, CalendarGrid, CalendarHeading, CalendarMonth, CalendarMonths, CalendarNav, CalendarNext, CalendarPrevious, CalendarWeek, CalendarWeekday, CalendarWeekdays, } from '@/ui/calendar/calendar'; /** * Weekends and past days that can't be chosen. They stay focusable, so * the arrow keys move across them. */ @Component({ selector: 'example-calendar-disabled', imports: [ Calendar, CalendarCaption, CalendarCell, CalendarDay, CalendarGrid, CalendarHeading, CalendarMonth, CalendarMonths, CalendarNav, CalendarNext, CalendarPrevious, CalendarWeek, CalendarWeekday, CalendarWeekdays, NdIcon, ], template: `
    @for (month of calendar.months(); track month.key) {
    {{ month.label }}
    @for (weekday of calendar.weekdays(); track weekday.long) { } @for (week of month.weeks; track week.key) { @for (day of week.days; track day.key) { } }
    {{ weekday.short }}
    }
    `, }) export class CalendarDisabledExample { protected readonly today = new Date(); protected readonly weekends = (date: Date) => date.getDay() === 0 || date.getDay() === 6; } ``` ### Calendar locale Month and weekday names in French, with weeks from Monday. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Calendar, CalendarCaption, CalendarCell, CalendarDay, CalendarGrid, CalendarHeading, CalendarMonth, CalendarMonths, CalendarNav, CalendarNext, CalendarPrevious, CalendarWeek, CalendarWeekday, CalendarWeekdays, } from '@/ui/calendar/calendar'; /** * Month and weekday names in French, with weeks from Monday, as French * calendars start them. Names and labels come from Intl. */ @Component({ selector: 'example-calendar-locale', imports: [ Calendar, CalendarCaption, CalendarCell, CalendarDay, CalendarGrid, CalendarHeading, CalendarMonth, CalendarMonths, CalendarNav, CalendarNext, CalendarPrevious, CalendarWeek, CalendarWeekday, CalendarWeekdays, NdIcon, ], template: `
    @for (month of calendar.months(); track month.key) {
    {{ month.label }}
    @for (weekday of calendar.weekdays(); track weekday.long) { } @for (week of month.weeks; track week.key) { @for (day of week.days; track day.key) { } }
    {{ weekday.short }}
    }
    `, }) export class CalendarLocaleExample { protected readonly date = signal(null); } ``` ## Accessibility Implements the [Date grid](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/datepicker-dialog/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Tab` | Moves focus to the day in the tab order, then out of the grid. | | `ArrowRight` or `ArrowLeft` | Moves focus to the next or previous day, changing month when needed. | | `ArrowDown` or `ArrowUp` | Moves focus to the same day of the next or previous week. | | `Home` or `End` | Moves focus to the first or last day of the week. | | `PageDown` or `PageUp` | Moves focus to the same day of the next or previous month. | | `Shift+PageDown` or `Shift+PageUp` | Moves focus to the same day of the next or previous year. | | `Enter` or `Space` | Chooses the focused day, unless it's disabled. | ### Focus - Focus never leaves the shown months on its own: moving past their edge shows the next or previous month and focuses the day there. - `min` and `max` stop the keys at their bounds. - Choosing a day keeps focus on it. ### Right-to-left In right-to-left layouts, the days run from right to left, so `ArrowLeft` moves to the next day and `ArrowRight` to the previous one. Direction comes from the nearest `dir` attribute. ## API reference ### Calendar Selector: `[ndCalendar]` A calendar (`@ndop/primitives/calendar`): month grids to choose a date, a range or several dates, with the arrow keys moving between days. Its structure is your template: loop over `months()` and `weekdays()`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[mode]` | `'single' \| 'multiple' \| 'range'` | `'single'` | How it chooses dates. | | `[defaultMonth]` | `Date \| null` | `null` | The first month shown when `month` isn't bound. | | `[numberOfMonths]` | `number` | `1` | How many months show side by side. | | `[min]` | `Date \| null` | `null` | The earliest day that can be chosen. | | `[max]` | `Date \| null` | `null` | The latest day that can be chosen. | | `[disabledDates]` | `((date: Date) => boolean) \| null` | `null` | Disables days it returns `true` for, such as weekends. | | `[weekStartsOn]` | `number \| null` | `null` | The first weekday, 0 for Sunday; defaults to the locale's. | | `[locale]` | `string` | `inject(LOCALE_ID)` | The locale for names and labels; defaults to the app's `LOCALE_ID`. | | `[showOutsideDays]` | `boolean` | `true` | Shows the neighboring months' days that fill the first and last weeks. | | `[(value)]` | `Date \| null` | `null` | The chosen date in `single` mode. Two-way bindable with `[(value)]`. | | `[(range)]` | `NdDateRange` | `{ from: null, to: null }` | The chosen days in `range` mode. Two-way bindable with `[(range)]`. | | `[(values)]` | `readonly Date[]` | `[]` | The chosen dates in `multiple` mode. Two-way bindable with `[(values)]`. | | `[(month)]` | `Date \| null` | `null` | The first month shown. Two-way bindable with `[(month)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `calendar` | | `data-mode` | | ### CalendarMonths Selector: `[ndCalendarMonths]` The row of shown months, which the navigation buttons sit above. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `calendar-months` | ### CalendarNav Selector: `[ndCalendarNav]` The bar that holds the previous and next buttons, across the top of the months. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `calendar-nav` | ### CalendarPrevious Selector: `button[ndCalendarPrevious]` The button that shows the previous month. Name it with `aria-label` and put the chevron icon in it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `calendar-previous` | | `data-disabled` | | ### CalendarNext Selector: `button[ndCalendarNext]` The button that shows the next month. Name it with `aria-label` and put the chevron icon in it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `calendar-next` | | `data-disabled` | | ### CalendarMonth Selector: `[ndCalendarMonth]` One shown month: its caption and grid. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[month]` (required) | `NdCalendarMonthView` | | The month, from `NdCalendar.months`. | | Data attribute | Values | | --- | --- | | `data-slot` | `calendar-month` | ### CalendarCaption Selector: `[ndCalendarCaption]` The row of a month's name, between the navigation buttons. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `calendar-caption` | ### CalendarHeading Selector: `[ndCalendarHeading]` A month's name and year, which labels its grid and is announced when the month changes. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `calendar-heading` | ### CalendarGrid Selector: `table[ndCalendarGrid]` · Role: `grid` A month's grid of days, on a `table`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `calendar-grid` | ### CalendarWeekdays Selector: `tr[ndCalendarWeekdays]` The row of weekday headers, on a `tr` in the grid's `thead`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `calendar-weekdays` | ### CalendarWeekday Selector: `th[ndCalendarWeekday]` A weekday's column header, with its full name for screen readers. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[weekday]` (required) | `NdCalendarWeekdayView` | | The weekday, from `NdCalendar.weekdays`. | | Data attribute | Values | | --- | --- | | `data-slot` | `calendar-weekday` | ### CalendarWeek Selector: `tr[ndCalendarWeek]` A week's row of days, on a `tr` in the grid's `tbody`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `calendar-week` | ### CalendarCell Selector: `td[ndCalendarCell]` · Role: `gridcell` A day's cell, which shapes the band of a chosen range. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[day]` (required) | `NdCalendarDayView` | | The day, from a week of `NdCalendar.months`. | | Data attribute | Values | | --- | --- | | `data-slot` | `calendar-cell` | | `data-selected` | | | `data-today` | | | `data-outside` | | | `data-disabled` | | | `data-range-start` | | | `data-range-middle` | | | `data-range-end` | | ### CalendarDay Selector: `button[ndCalendarDay]` A day's button: chosen days fill with the primary color, and a range's ends round off its band. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[day]` (required) | `NdCalendarDayView` | | The day, from a week of `NdCalendar.months`. | | Data attribute | Values | | --- | --- | | `data-slot` | `calendar-day` | | `data-selected-single` | | | `data-range-start` | | | `data-range-middle` | | | `data-range-end` | | | `data-today` | | | `data-outside` | | | `data-disabled` | | ## Agent notes ### Do - Name the previous and next buttons with `aria-label`. - Use `min`, `max` and `disabledDates` for days that can't be chosen; they stay reachable with the keyboard. - Show two months with `numberOfMonths="2"` for ranges. ### Don't - Don't use it for dates far from today, such as a birth date; use a text field. - Don't put it in a narrow space; put it in a Date Picker's popover. --- # Card > Displays a card with a header, content and footer. Version 1.0.0 · SSR · Zoneless · 0.7 kB gzip A card groups related content and actions on a bordered surface: a summary, a form, a setting. `ndCard` and its parts only style the elements they're on, so you choose the semantics: a `section` with a heading, an `article` in a feed, or a plain `div`. ## Installation ```bash npx @ndop/cli add card ``` Or by hand: install `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/card/card.ts ```ts import { computed, Directive, input } from '@angular/core'; import { cn } from '@/lib/utils'; /** * A card: a bordered surface grouping related content, with optional * header, content and footer parts. * * @example * ```html *
    *
    *

    Notifications

    *

    You have 3 unread messages.

    *
    *
    …
    *
    …
    *
    * ``` */ @Directive({ selector: '[ndCard]', host: { 'data-slot': 'card', '[class]': 'classes()' }, }) export class Card { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm', this.class(), ), ); } /** * A card's header: its title, description and an optional action, laid * out in a grid. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndCardHeader]', host: { 'data-slot': 'card-header', '[class]': 'classes()' }, }) export class CardHeader { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', this.class(), ), ); } /** * A card's title. Put it on the heading element that fits the page's * outline. * * @example * ```html *

    Notifications

    * ``` */ @Directive({ selector: '[ndCardTitle]', host: { 'data-slot': 'card-title', '[class]': 'classes()' }, }) export class CardTitle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('leading-none font-semibold', this.class()), ); } /** * Supporting text under a card's title. * * @example * ```html *

    You have 3 unread messages.

    * ``` */ @Directive({ selector: '[ndCardDescription]', host: { 'data-slot': 'card-description', '[class]': 'classes()' }, }) export class CardDescription { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('text-sm text-muted-foreground', this.class()), ); } /** * An action in a card's header, at its end, such as a button or menu. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndCardAction]', host: { 'data-slot': 'card-action', '[class]': 'classes()' }, }) export class CardAction { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'col-start-2 row-span-2 row-start-1 self-start justify-self-end', this.class(), ), ); } /** * A card's main content. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndCardContent]', host: { 'data-slot': 'card-content', '[class]': 'classes()' }, }) export class CardContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('px-6', this.class())); } /** * A card's footer, usually its actions. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndCardFooter]', host: { 'data-slot': 'card-footer', '[class]': 'classes()' }, }) export class CardFooter { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex items-center px-6 [.border-t]:pt-6', this.class()), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Button } from '@/ui/button/button'; import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/ui/card/card'; import { Input } from '@/ui/input/input'; import { Label } from '@/ui/label/label'; ``` ```html

    Sign in to your account

    Enter your email below to sign in.

    ``` ## Examples ### Card demo A sign-in form in a card. ```ts import { Component } from '@angular/core'; import { Button } from '@/ui/button/button'; import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/ui/card/card'; import { Input } from '@/ui/input/input'; import { Label } from '@/ui/label/label'; /** A sign-in form in a card. */ @Component({ selector: 'example-card-demo', imports: [ Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Input, Label, ], template: `

    Sign in to your account

    Enter your email below to sign in.

    `, }) export class CardDemoExample {} ``` ### Card with action A card with a header action, content and footer. ```ts import { Component } from '@angular/core'; import { Badge } from '@/ui/badge/badge'; import { Button } from '@/ui/button/button'; import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/ui/card/card'; /** A card with a header action, content and a footer. */ @Component({ selector: 'example-card-with-action', imports: [ Badge, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, ], template: `

    Pro plan

    Billed monthly, renews on November 1.

    Current

    $24 / month

    • Unlimited projects
    • 10 team members
    • Priority support
    `, }) export class CardWithActionExample {} ``` ## Accessibility Card has no keyboard interaction or focus handling of its own; it keeps the semantics of the element it's on. ## API reference ### Card Selector: `[ndCard]` A card: a bordered surface grouping related content, with optional header, content and footer parts. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `card` | ### CardHeader Selector: `[ndCardHeader]` A card's header: its title, description and an optional action, laid out in a grid. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `card-header` | ### CardTitle Selector: `[ndCardTitle]` A card's title. Put it on the heading element that fits the page's outline. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `card-title` | ### CardDescription Selector: `[ndCardDescription]` Supporting text under a card's title. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `card-description` | ### CardAction Selector: `[ndCardAction]` An action in a card's header, at its end, such as a button or menu. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `card-action` | ### CardContent Selector: `[ndCardContent]` A card's main content. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `card-content` | ### CardFooter Selector: `[ndCardFooter]` A card's footer, usually its actions. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `card-footer` | ## Agent notes ### Do - Give each card a heading with `ndCardTitle`, at the right level for the page. - Use `section` with `aria-labelledby` pointing at the title when the card is a meaningful region, or `article` when it stands alone. - Put the card's main action in `ndCardFooter` or `ndCardAction`. ### Don't - Don't make a whole card clickable with a handler on the `div`; put a link or button inside it. - Don't nest cards; use separators or headings inside one card. --- # Chart > SVG charts you own: bar, line, area, pie and radial, with tooltips and legends. Version 1.0.0 · SSR · Zoneless · 2.2 kB gzip Charts are SVG you own: bar, line, area, pie, donut and radial charts drawn in your component's template from the chart lib's scales, ticks and path builders, with no outside dependency, so they render on the server. `ChartContainer` sets a color per series from a config; the tooltip and legend parts style the rest. Shapes are SVG, scaled to the chart's width, while axis and value labels are HTML (`ChartAxis` and `ChartLabel`), placed by percentage, so they keep the text's size at every width. The plot is one tab stop: the arrow keys move between data points, showing the tooltip, while a polite live region reads each value and a hidden table holds them all. ## Installation ```bash npx @ndop/cli add chart ``` Or by hand: install `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/chart/chart.ts ```ts import { booleanAttribute, computed, Directive, ElementRef, inject, input, model, } from '@angular/core'; import { cn } from '@/lib/utils'; export { arcPath, areaPath, extent, linePath, niceTicks, pieArcs, scaleBand, scaleLinear, scaleTime, stack, type BandPadding, type BandScale, type ChartArc, type ChartCurve, type ChartPoint, type LinearScale, type PieOptions, type TimeScale, } from '@/lib/chart'; /** A series' label and color, by its key. */ export type ChartConfig = Record< string, { /** The series' name, for tooltips and legends. */ readonly label?: string; /** A CSS color, such as `var(--chart-1)`. */ readonly color?: string; /** Colors for the light and dark themes, used with `light-dark()`. */ readonly theme?: { readonly light: string; readonly dark: string }; } >; /** * A chart's frame: it sets `--color-` for each series in `config`, * so the SVG, tooltip and legend use `var(--color-desktop)`, and keeps * a video aspect ratio. Put it on a `figure` with a `figcaption`. * * @example * ```html *
    *
    Visitors
    * … *
    …
    *
    * ``` */ @Directive({ selector: '[ndChartContainer]', host: { 'data-slot': 'chart', '[style]': 'colors()', '[class]': 'classes()', }, }) export class ChartContainer { /** Each series' label and color, by key. */ readonly config = input({}); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly colors = computed(() => Object.fromEntries( Object.entries(this.config()).flatMap(([key, item]) => { const color = item.theme ? `light-dark(${item.theme.light}, ${item.theme.dark})` : item.color; return color ? [[`--color-${key}`, color]] : []; }), ), ); protected readonly classes = computed(() => cn( 'relative flex aspect-video justify-center text-xs [&_svg]:overflow-visible', this.class(), ), ); } /** * The plot, on the chart's `svg`: one tab stop where the arrow keys, * `Home` and `End` move between data points and the pointer picks the * nearest one, setting `active`. Bind a tooltip and a polite live region * to `active`, so the keyboard and screen readers reach each value. Give * it `xs`, each point's horizontal position in `viewBox` units, and name * it with `aria-label` or `aria-labelledby`. * * It spans the chart's width at its `viewBox`'s aspect ratio, so a * position in `viewBox` units is the same percentage of the plot's box, * where the HTML labels and the tooltip go. For a fixed height instead, * set one with a class and `preserveAspectRatio="none"`, and give your * lines and outlines `vector-effect="non-scaling-stroke"` so they keep * their width, as the grid and cursor do. * * @example * ```html * … * ``` */ @Directive({ selector: 'svg[ndChartPlot]', host: { 'data-slot': 'chart-plot', role: 'group', tabindex: '0', '[attr.data-active]': 'active()', '[class]': 'classes()', '(keydown)': 'keydown($event)', '(pointermove)': 'pointer($event)', '(pointerleave)': 'leave()', '(focus)': 'focus()', '(blur)': 'active.set(null)', }, }) export class ChartPlot { /** Each data point's horizontal position, in `viewBox` units. */ readonly xs = input([]); /** The index of the active data point, or `null`. Two-way bindable. */ readonly active = model(null); /** * Whether the pointer picks the point nearest it horizontally; turn it * off for pies, whose slices set `active` on hover themselves. */ readonly trackPointer = input(true, { transform: booleanAttribute }); /** Extra classes, merged last so they win. */ readonly class = input(''); private readonly element = inject>(ElementRef).nativeElement; protected readonly classes = computed(() => cn( 'block h-auto w-full rounded-md outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring/50', this.class(), ), ); /** * Moves between data points with the arrow keys, `Home` and `End`. * * @param event - The keydown event. */ protected keydown(event: KeyboardEvent): void { const last = this.xs().length - 1; const current = this.active() ?? 0; let next: number; switch (event.key) { case 'ArrowRight': case 'ArrowDown': next = Math.min(last, current + 1); break; case 'ArrowLeft': case 'ArrowUp': next = Math.max(0, current - 1); break; case 'Home': next = 0; break; case 'End': next = last; break; case 'Escape': this.active.set(null); return; default: return; } event.preventDefault(); this.active.set(next); } /** * Picks the data point nearest the pointer. * * @param event - The pointermove event. */ protected pointer(event: PointerEvent): void { const matrix = this.element.getScreenCTM?.(); const xs = this.xs(); if (!this.trackPointer() || !matrix || xs.length === 0) { return; } // From screen pixels to viewBox units, whatever the SVG's size. const x = (event.clientX - matrix.e) / matrix.a; let nearest = 0; xs.forEach((value, index) => { if (Math.abs(value - x) < Math.abs((xs[nearest] as number) - x)) { nearest = index; } }); this.active.set(nearest); } /** Clears the active point when the pointer leaves, unless focused. */ protected leave(): void { if (this.element.ownerDocument.activeElement !== this.element) { this.active.set(null); } } /** Activates the first point when focus arrives from the keyboard. */ protected focus(): void { if (this.active() === null && this.xs().length > 0) { this.active.set(0); } } } /** * The grid lines behind the data, on a `g`. They stay a pixel wide * whatever the plot's size. * * @example * ```html * … * ``` */ @Directive({ selector: '[ndChartGrid]', host: { 'data-slot': 'chart-grid', '[class]': 'classes()' }, }) export class ChartGrid { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( '[&_line]:stroke-border/50 [&_line]:[vector-effect:non-scaling-stroke]', this.class(), ), ); } /** * The x axis' labels, in a band under the plot. They're HTML rather than * SVG text, so they keep their size at every width instead of scaling * with the plot. Put a `ChartLabel` in it for each tick, at the tick's * position across the plot in percent. Screen readers skip it: the * chart's table holds the same labels. * * @example * ```html *
    * Jan *
    * ``` */ @Directive({ selector: '[ndChartAxis]', host: { 'data-slot': 'chart-axis', 'aria-hidden': 'true', '[class]': 'classes()', }, }) export class ChartAxis { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('relative h-4 text-muted-foreground', this.class()), ); } /** * A label at a point of the chart, such as an axis tick or a value, in * HTML so it keeps its size at every width. It sits at its `left`, a * percentage of the plot's width, centered on it by default; a label at * the plot's edge aligns `left` or `right` instead, so it stays within * the plot. In a `ChartAxis` it sits at the top of the band. Over the * plot, put the plot and the label in a `relative` box and set its `top` * too, centering it with `-translate-y-1/2`. * * Like the plot, it isn't mirrored in right-to-left layouts, so it's * placed with `left` and aligns to physical sides, not `start` and `end`. * * @example * ```html * Jan * Apr * Jun * ``` */ @Directive({ selector: '[ndChartLabel]', host: { 'data-slot': 'chart-label', '[attr.data-align]': 'align()', '[class]': 'classes()', }, }) export class ChartLabel { /** * Which of its sides is at its `left`: `center` for its middle, `left` * for its left edge (it extends right), `right` for its right edge. */ readonly align = input<'center' | 'left' | 'right'>('center'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'pointer-events-none absolute top-0 whitespace-nowrap', this.align() === 'center' && '-translate-x-1/2', this.align() === 'right' && '-translate-x-full', this.class(), ), ); } /** * The marker of the active data point: a line, which stays a pixel * wide whatever the plot's size, or a band behind bars. * * @example * ```html * * ``` */ @Directive({ selector: '[ndChartCursor]', host: { 'data-slot': 'chart-cursor', 'aria-hidden': 'true', '[attr.data-kind]': 'kind()', '[class]': 'classes()', }, }) export class ChartCursor { /** `line` for line and area charts, `band` for bars. */ readonly kind = input<'line' | 'band'>('line'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'pointer-events-none data-[kind=band]:fill-muted data-[kind=line]:stroke-border data-[kind=line]:[vector-effect:non-scaling-stroke]', this.class(), ), ); } /** * The tooltip of the active data point, drawn over the chart. It's for * sighted users; a polite live region tells screen readers the same. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndChartTooltip]', host: { 'data-slot': 'chart-tooltip', 'aria-hidden': 'true', '[class]': 'classes()', }, }) export class ChartTooltip { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl', 'pointer-events-none absolute top-0 z-10 -translate-x-1/2', this.class(), ), ); } /** * The tooltip's heading, such as the month. * * @example * ```html *
    January
    * ``` */ @Directive({ selector: '[ndChartTooltipLabel]', host: { 'data-slot': 'chart-tooltip-label', '[class]': 'classes()' }, }) export class ChartTooltipLabel { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('font-medium', this.class())); } /** * The list of a tooltip's series. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndChartTooltipList]', host: { 'data-slot': 'chart-tooltip-list', '[class]': 'classes()' }, }) export class ChartTooltipList { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('grid gap-1.5', this.class())); } /** * One series in a tooltip: its indicator, name and value. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndChartTooltipItem]', host: { 'data-slot': 'chart-tooltip-item', '[class]': 'classes()' }, }) export class ChartTooltipItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground', 'items-center', this.class(), ), ); } /** * A series' color swatch in a tooltip: a `dot`, a `line` or a `dashed` * line. * * @example * ```html * * ``` */ @Directive({ selector: '[ndChartTooltipIndicator]', host: { 'data-slot': 'chart-tooltip-indicator', '[style.--color-bg]': 'color()', '[style.--color-border]': 'color()', '[class]': 'classes()', }, }) export class ChartTooltipIndicator { /** Its shape. */ readonly indicator = input<'dot' | 'line' | 'dashed'>('dot'); /** The series' color. */ readonly color = input('currentColor'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)', this.indicator() === 'dot' && 'h-2.5 w-2.5', this.indicator() === 'line' && 'w-1', this.indicator() === 'dashed' && 'w-0 border-[1.5px] border-dashed bg-transparent', this.class(), ), ); } /** * The name and value of a series in a tooltip, spread across its width. * * @example * ```html *
    * Desktop * 186 *
    * ``` */ @Directive({ selector: '[ndChartTooltipBody]', host: { 'data-slot': 'chart-tooltip-body', '[class]': 'classes()' }, }) export class ChartTooltipBody { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex flex-1 justify-between leading-none', 'items-center', this.class(), ), ); } /** * A series' name in a tooltip. * * @example * ```html * Desktop * ``` */ @Directive({ selector: '[ndChartTooltipName]', host: { 'data-slot': 'chart-tooltip-name', '[class]': 'classes()' }, }) export class ChartTooltipName { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('text-muted-foreground', this.class()), ); } /** * A series' value in a tooltip, in tabular figures. * * @example * ```html * 186 * ``` */ @Directive({ selector: '[ndChartTooltipValue]', host: { 'data-slot': 'chart-tooltip-value', '[class]': 'classes()' }, }) export class ChartTooltipValue { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('font-mono font-medium text-foreground tabular-nums', this.class()), ); } /** * The legend: the series' swatches and names, under the chart. * * @example * ```html *
      …
    * ``` */ @Directive({ selector: '[ndChartLegend]', host: { 'data-slot': 'chart-legend', '[class]': 'classes()' }, }) export class ChartLegend { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex items-center justify-center gap-4', 'pt-3', this.class()), ); } /** * One series in the legend. * * @example * ```html *
  • …
  • * ``` */ @Directive({ selector: '[ndChartLegendItem]', host: { 'data-slot': 'chart-legend-item', '[class]': 'classes()' }, }) export class ChartLegendItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground', this.class(), ), ); } /** * A series' color swatch in the legend. * * @example * ```html * * ``` */ @Directive({ selector: '[ndChartLegendIndicator]', host: { 'data-slot': 'chart-legend-indicator', '[style.background-color]': 'color()', '[class]': 'classes()', }, }) export class ChartLegendIndicator { /** The series' color. */ readonly color = input('currentColor'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('h-2 w-2 shrink-0 rounded-[2px]', this.class()), ); } ``` ### src/app/lib/chart.ts ```ts /** A point in SVG user units. */ export interface ChartPoint { /** Horizontal position. */ readonly x: number; /** Vertical position. */ readonly y: number; } /** How a line or area joins its points. */ export type ChartCurve = 'linear' | 'monotone' | 'step'; /** A scale from numbers to positions, as from data to pixels. */ export interface LinearScale { /** * The position of a value. * * @param value - A value in the domain. * @returns Its position in the range. */ (value: number): number; /** The domain, after `nice`. */ readonly domain: readonly [number, number]; /** * Round values across the domain, for axis labels and grid lines. * * @param count - About how many; defaults to 5. * @returns The values. */ ticks(count?: number): number[]; } /** A scale from categories to bands, as for bars. */ export interface BandScale { /** * Where a category's band starts. * * @param key - A category in the domain. * @returns The band's start, or `NaN` for an unknown category. */ (key: string): number; /** The width of each band. */ readonly bandwidth: number; /** The distance from one band's start to the next one's. */ readonly step: number; } /** A scale from dates to positions. */ export interface TimeScale { /** * The position of a date. * * @param value - A date in the domain. * @returns Its position in the range. */ (value: Date): number; /** * Dates at round intervals across the domain: days, weeks, months or * years, whichever gives about `count`. * * @param count - About how many; defaults to 5. * @returns The dates. */ ticks(count?: number): Date[]; } /** One slice of a pie, donut or radial chart. */ export interface ChartArc { /** Where it starts, in radians clockwise from 12 o'clock. */ readonly startAngle: number; /** Where it ends, in radians. */ readonly endAngle: number; /** Its value. */ readonly value: number; /** Its index in the values. */ readonly index: number; } /** Space around a {@link BandScale}'s bands, in fractions of a step. */ export interface BandPadding { /** Space between bands; defaults to 0.2. */ readonly inner?: number; /** Space before the first band and after the last; defaults to 0.2. */ readonly outer?: number; } /** The angles a pie spans, for {@link pieArcs}. */ export interface PieOptions { /** Where the first slice starts, in radians clockwise from 12 o'clock. */ readonly startAngle?: number; /** Where the last slice ends; defaults to a full turn after 0. */ readonly endAngle?: number; /** The gap between slices, in radians; defaults to 0. */ readonly padAngle?: number; } /** * The smallest and largest of some numbers. * * @param values - The numbers. * @returns `[min, max]`, or `[0, 0]` for none. */ export function extent(values: readonly number[]): [number, number] { if (values.length === 0) { return [0, 0]; } return [Math.min(...values), Math.max(...values)]; } /** * A round step for about `count` ticks between two values: 1, 2 or 5 * times a power of ten. * * @param min - The lower value. * @param max - The upper value. * @param count - About how many ticks. * @returns The step. */ function tickStep(min: number, max: number, count: number): number { const raw = Math.abs(max - min) / Math.max(1, count); const power = 10 ** Math.floor(Math.log10(raw || 1)); const error = raw / power; const factor = error >= 7.5 ? 10 : error >= 3.5 ? 5 : error >= 1.5 ? 2 : 1; return factor * power; } /** * Round values from `min` to `max`, about `count` of them. * * @param min - The lower value. * @param max - The upper value. * @param count - About how many; defaults to 5. * @returns The values, ascending. */ export function niceTicks(min: number, max: number, count = 5): number[] { if (min === max) { return [min]; } const step = tickStep(min, max, count); const start = Math.ceil(min / step) * step; const ticks: number[] = []; for (let value = start; value <= max + step / 1e6; value += step) { // Rounds away floating point noise, such as 0.30000000000000004. ticks.push(Number(value.toPrecision(12))); } return ticks; } /** * A linear scale. With `nice`, the domain extends to round values, so the * axis starts and ends on a tick. * * @param domain - The values, `[min, max]`. * @param range - The positions, `[start, end]`; reversed works, as for a * y axis that grows upward. * @param nice - Whether to round the domain out; defaults to `true`. * @returns The scale. * * @example * ```ts * const y = scaleLinear([0, 305], [200, 0]); * y(150); // 100 * ``` */ export function scaleLinear( domain: readonly [number, number], range: readonly [number, number], nice = true, ): LinearScale { let [d0, d1] = domain; if (nice && d0 !== d1) { const step = tickStep(d0, d1, 5); d0 = Math.floor(d0 / step) * step; d1 = Math.ceil(d1 / step) * step; } const [r0, r1] = range; const span = d1 - d0 || 1; const scale = ((value: number) => r0 + ((value - d0) / span) * (r1 - r0)) as LinearScale; Object.assign(scale, { domain: [d0, d1] as const, ticks: (count = 5) => niceTicks(d0, d1, count), }); return scale; } /** * A band scale: the range split into one band per category, with padding * between bands and at the ends, in fractions of a step. * * @param domain - The categories, in order. * @param range - The positions, `[start, end]`. * @param padding - Space between bands and at the ends. * @returns The scale. * * @example * ```ts * const x = scaleBand(['Jan', 'Feb'], [0, 400]); * x('Feb'); // where February's bar starts * x.bandwidth; // the bar's width * ``` */ export function scaleBand( domain: readonly string[], range: readonly [number, number], padding: BandPadding = {}, ): BandScale { const inner = padding.inner ?? 0.2; const outer = padding.outer ?? 0.2; const [r0, r1] = range; const step = (r1 - r0) / Math.max(1, domain.length - inner + outer * 2); const index = new Map(domain.map((key, i) => [key, i])); const scale = ((key: string) => { const i = index.get(key); return i === undefined ? Number.NaN : r0 + step * (outer + i); }) as BandScale; Object.assign(scale, { bandwidth: step * (1 - inner), step }); return scale; } /** Time intervals for ticks, shortest first. */ const INTERVALS = [ { days: 1, months: 0 }, { days: 7, months: 0 }, { days: 0, months: 1 }, { days: 0, months: 3 }, { days: 0, months: 12 }, ] as const; /** * A time scale: linear on timestamps, with ticks on round days, weeks, * months or years. * * @param domain - The dates, `[first, last]`. * @param range - The positions, `[start, end]`. * @returns The scale. * * @example * ```ts * const x = scaleTime([new Date(2026, 0, 1), new Date(2026, 5, 30)], [0, 600]); * x.ticks(6); // the 1st of each month * ``` */ export function scaleTime( domain: readonly [Date, Date], range: readonly [number, number], ): TimeScale { const [d0, d1] = domain.map((date) => date.getTime()) as [number, number]; const linear = scaleLinear([d0, d1], range, false); const scale = ((value: Date) => linear(value.getTime())) as TimeScale; Object.assign(scale, { ticks(count = 5) { const days = (d1 - d0) / 86_400_000; const interval = INTERVALS.find( (option) => days / (option.days || option.months * 30.4) <= count, ) ?? INTERVALS[4]; const first = new Date(d0); let date = interval.months > 0 ? new Date(first.getFullYear(), first.getMonth(), 1) : new Date(first.getFullYear(), first.getMonth(), first.getDate()); const ticks: Date[] = []; while (date.getTime() <= d1) { if (date.getTime() >= d0) { ticks.push(date); } date = interval.months > 0 ? new Date(date.getFullYear(), date.getMonth() + interval.months, 1) : new Date( date.getFullYear(), date.getMonth(), date.getDate() + interval.days, ); } return ticks; }, }); return scale; } /** * Formats a number for an SVG path, short and without noise. * * @param value - The number. * @returns Its text. */ function n(value: number): string { return String(Math.round(value * 100) / 100); } /** * The slopes of a monotone cubic through points, which never overshoot * them (Fritsch–Carlson). * * @param points - The points, by ascending x. * @returns One tangent per point. */ function monotoneTangents(points: readonly ChartPoint[]): number[] { const count = points.length; const slopes: number[] = []; for (let i = 0; i < count - 1; i++) { const a = points[i] as ChartPoint; const b = points[i + 1] as ChartPoint; slopes.push((b.y - a.y) / (b.x - a.x || 1)); } const tangents = points.map((_, i) => { if (i === 0) return slopes[0] ?? 0; if (i === count - 1) return slopes[count - 2] ?? 0; const before = slopes[i - 1] as number; const after = slopes[i] as number; return before * after <= 0 ? 0 : (before + after) / 2; }); for (let i = 0; i < count - 1; i++) { const slope = slopes[i] as number; if (slope === 0) { tangents[i] = 0; tangents[i + 1] = 0; continue; } const alpha = (tangents[i] as number) / slope; const beta = (tangents[i + 1] as number) / slope; const length = alpha * alpha + beta * beta; if (length > 9) { const tau = 3 / Math.sqrt(length); tangents[i] = tau * alpha * slope; tangents[i + 1] = tau * beta * slope; } } return tangents; } /** * The path segments through points, after a first `M`. * * @param points - The points, by ascending x. * @param curve - How to join them. * @returns The segments, as SVG path commands. */ function segments(points: readonly ChartPoint[], curve: ChartCurve): string { let path = ''; if (curve === 'monotone' && points.length > 2) { const tangents = monotoneTangents(points); for (let i = 0; i < points.length - 1; i++) { const a = points[i] as ChartPoint; const b = points[i + 1] as ChartPoint; const dx = (b.x - a.x) / 3; path += `C${n(a.x + dx)},${n(a.y + dx * (tangents[i] as number))},${n(b.x - dx)},${n(b.y - dx * (tangents[i + 1] as number))},${n(b.x)},${n(b.y)}`; } return path; } for (let i = 1; i < points.length; i++) { const point = points[i] as ChartPoint; if (curve === 'step') { const previous = points[i - 1] as ChartPoint; const middle = (previous.x + point.x) / 2; path += `H${n(middle)}V${n(point.y)}H${n(point.x)}`; } else { path += `L${n(point.x)},${n(point.y)}`; } } return path; } /** * An SVG path through points, for a line chart. * * @param points - The points, by ascending x. * @param curve - How to join them; defaults to `linear`. * @returns The path's `d`, or an empty string for no points. * * @example * ```html * * ``` */ export function linePath( points: readonly ChartPoint[], curve: ChartCurve = 'linear', ): string { const [first] = points; return first ? `M${n(first.x)},${n(first.y)}${segments(points, curve)}` : ''; } /** * An SVG path filling between a line through points and a baseline, for * an area chart. With `lower` points, as for a stacked series, it fills * between the two lines. * * @param points - The top points, by ascending x. * @param baseline - The bottom's y, or the bottom points. * @param curve - How to join them; defaults to `linear`. * @returns The path's `d`, or an empty string for no points. */ export function areaPath( points: readonly ChartPoint[], baseline: number | readonly ChartPoint[], curve: ChartCurve = 'linear', ): string { const [first] = points; const last = points[points.length - 1]; if (!first || !last) { return ''; } const top = `M${n(first.x)},${n(first.y)}${segments(points, curve)}`; if (typeof baseline === 'number') { return `${top}L${n(last.x)},${n(baseline)}L${n(first.x)},${n(baseline)}Z`; } const lower = [...baseline].reverse(); const start = lower[0] as ChartPoint; return `${top}L${n(start.x)},${n(start.y)}${segments(lower, curve)}Z`; } /** * An SVG path for a slice of a ring, centered on 0,0: a pie slice when * `innerRadius` is 0, a donut segment or a radial bar otherwise. * * @param arc - The slice's angles, in radians clockwise from 12 o'clock. * @param innerRadius - The hole's radius; 0 for a pie. * @param outerRadius - The slice's radius. * @returns The path's `d`. * * @example * ```html * * ``` */ export function arcPath( arc: Pick, innerRadius: number, outerRadius: number, ): string { // A full circle as one arc has equal ends, which SVG draws as nothing. const end = Math.min(arc.endAngle, arc.startAngle + Math.PI * 2 - 1e-6); const point = (radius: number, angle: number) => `${n(radius * Math.sin(angle))},${n(-radius * Math.cos(angle))}`; const large = end - arc.startAngle > Math.PI ? 1 : 0; const outer = `M${point(outerRadius, arc.startAngle)}A${n(outerRadius)},${n(outerRadius)},0,${large},1,${point(outerRadius, end)}`; if (innerRadius <= 0) { return `${outer}L0,0Z`; } return `${outer}L${point(innerRadius, end)}A${n(innerRadius)},${n(innerRadius)},0,${large},0,${point(innerRadius, arc.startAngle)}Z`; } /** * The angles of a pie's slices, in order, each proportional to its value. * * @param values - The values; negatives count as 0. * @param options - The angles to span and the gap between slices. * @returns One arc per value. */ export function pieArcs( values: readonly number[], options: PieOptions = {}, ): ChartArc[] { const start = options.startAngle ?? 0; const span = (options.endAngle ?? Math.PI * 2) - start; const pad = options.padAngle ?? 0; const total = values.reduce((sum, value) => sum + Math.max(0, value), 0); let angle = start; return values.map((value, index) => { const share = total > 0 ? (Math.max(0, value) / total) * span : 0; const arc = { startAngle: angle + pad / 2, endAngle: Math.max(angle + pad / 2, angle + share - pad / 2), value, index, }; angle += share; return arc; }); } /** * Stacks series on top of each other, as for stacked bars or areas. * * @param series - Each series' values, in the same order. * @returns For each series, each value's `[bottom, top]`. * * @example * ```ts * stack([[1, 2], [3, 4]]); // [[[0, 1], [0, 2]], [[1, 4], [2, 6]]] * ``` */ export function stack( series: readonly (readonly number[])[], ): [number, number][][] { const totals: number[] = []; return series.map((values) => values.map((value, index) => { const bottom = totals[index] ?? 0; totals[index] = bottom + value; return [bottom, bottom + value]; }), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { ChartAxis, ChartContainer, ChartCursor, ChartGrid, ChartLabel, ChartLegend, ChartLegendIndicator, ChartLegendItem, ChartPlot, ChartTooltip, ChartTooltipBody, ChartTooltipIndicator, ChartTooltipItem, ChartTooltipLabel, ChartTooltipList, ChartTooltipName, ChartTooltipValue, scaleBand, scaleLinear, type ChartConfig, } from '@/ui/chart/chart'; ``` ```html
    Visitors, January to June 2026
    @for (tick of y.ticks(4); track tick) { } @if (current(); as row) { } @for (row of data; track row.month) { }
    @for (row of data; track row.month; let i = $index) { {{ row.month.slice(0, 3) }} }
    @if (current(); as row) {
    {{ row.month }}
    @for (key of keys; track key) {
    {{ config[key]?.label }} {{ row[key] }}
    }
    }
      @for (key of keys; track key) {
    • {{ config[key]?.label }}
    • }

    Use the arrow keys to read each month.

    {{ summary() }}

    @for (row of data; track row.month) { }
    Visitors by month
    Month Desktop Mobile
    {{ row.month }} {{ row.desktop }} {{ row.mobile }}
    ``` ## Examples ### Chart bar Desktop and mobile visitors by month, as grouped bars you can read with the keyboard. ```ts import { Component, computed, signal } from '@angular/core'; import { ChartAxis, ChartContainer, ChartCursor, ChartGrid, ChartLabel, ChartLegend, ChartLegendIndicator, ChartLegendItem, ChartPlot, ChartTooltip, ChartTooltipBody, ChartTooltipIndicator, ChartTooltipItem, ChartTooltipLabel, ChartTooltipList, ChartTooltipName, ChartTooltipValue, scaleBand, scaleLinear, type ChartConfig, } from '@/ui/chart/chart'; /** The plot's size, in viewBox units. */ const WIDTH = 500; const HEIGHT = 225; /** * Desktop and mobile visitors by month, as grouped bars. The chart is one * tab stop: the arrow keys move between months, showing each one's * tooltip and announcing its values; a hidden table holds every value. */ @Component({ selector: 'example-chart-bar', imports: [ ChartAxis, ChartContainer, ChartCursor, ChartGrid, ChartLabel, ChartLegend, ChartLegendIndicator, ChartLegendItem, ChartPlot, ChartTooltip, ChartTooltipBody, ChartTooltipIndicator, ChartTooltipItem, ChartTooltipLabel, ChartTooltipList, ChartTooltipName, ChartTooltipValue, ], template: `
    Visitors, January to June 2026
    @for (tick of y.ticks(4); track tick) { } @if (current(); as row) { } @for (row of data; track row.month) { }
    @for (row of data; track row.month; let i = $index) { {{ row.month.slice(0, 3) }} }
    @if (current(); as row) {
    {{ row.month }}
    @for (key of keys; track key) {
    {{ config[key]?.label }} {{ row[key] }}
    }
    }
      @for (key of keys; track key) {
    • {{ config[key]?.label }}
    • }

    Use the arrow keys to read each month.

    {{ summary() }}

    @for (row of data; track row.month) { }
    Visitors by month
    Month Desktop Mobile
    {{ row.month }} {{ row.desktop }} {{ row.mobile }}
    `, }) export class ChartBarExample { protected readonly width = WIDTH; protected readonly height = HEIGHT; protected readonly keys = ['desktop', 'mobile'] as const; protected readonly config: ChartConfig = { desktop: { label: 'Desktop', color: 'var(--chart-1)' }, mobile: { label: 'Mobile', color: 'var(--chart-2)' }, }; protected readonly data = [ { month: 'January', desktop: 186, mobile: 80 }, { month: 'February', desktop: 305, mobile: 200 }, { month: 'March', desktop: 237, mobile: 120 }, { month: 'April', desktop: 73, mobile: 190 }, { month: 'May', desktop: 209, mobile: 130 }, { month: 'June', desktop: 214, mobile: 140 }, ]; protected readonly x = scaleBand( this.data.map((row) => row.month), [0, WIDTH], ); protected readonly y = scaleLinear( [0, Math.max(...this.data.map((row) => Math.max(row.desktop, row.mobile)))], [HEIGHT, 8], ); protected readonly bar = this.x.bandwidth / 2; protected readonly centers = this.data.map( (row) => this.x(row.month) + this.x.bandwidth / 2, ); protected readonly active = signal(null); protected readonly current = computed(() => { const index = this.active(); return index === null ? null : this.data[index]; }); protected readonly left = computed(() => { const index = this.active(); return index === null ? 0 : ((this.centers[index] ?? 0) / WIDTH) * 100; }); protected readonly summary = computed(() => { const row = this.current(); return row ? `${row.month}: desktop ${row.desktop}, mobile ${row.mobile}.` : ''; }); } ``` ### Chart line Desktop and mobile visitors by month, as two smooth lines. ```ts import { Component, computed, signal } from '@angular/core'; import { ChartAxis, type ChartConfig, ChartContainer, ChartCursor, ChartGrid, ChartLabel, ChartLegend, ChartLegendIndicator, ChartLegendItem, ChartPlot, ChartTooltip, ChartTooltipBody, ChartTooltipIndicator, ChartTooltipItem, ChartTooltipLabel, ChartTooltipList, ChartTooltipName, ChartTooltipValue, linePath, scaleLinear, } from '@/ui/chart/chart'; /** The plot's size, in viewBox units. */ const WIDTH = 500; const HEIGHT = 225; /** * Desktop and mobile visitors by month, as two smooth lines. The arrow * keys move between months, showing each one's values; a hidden table * holds every value. */ @Component({ selector: 'example-chart-line', imports: [ ChartAxis, ChartContainer, ChartCursor, ChartGrid, ChartLabel, ChartLegend, ChartLegendIndicator, ChartLegendItem, ChartPlot, ChartTooltip, ChartTooltipBody, ChartTooltipIndicator, ChartTooltipItem, ChartTooltipLabel, ChartTooltipList, ChartTooltipName, ChartTooltipValue, ], template: `
    Visitors, January to June 2026
    @for (tick of y.ticks(4); track tick) { } @if (active() !== null) { } @for (key of keys; track key) { @if (current(); as row) { } }
    @for ( row of data; track row.month; let i = $index, first = $first, last = $last ) { {{ row.month.slice(0, 3) }} }
    @if (current(); as row) {
    {{ row.month }}
    @for (key of keys; track key) {
    {{ config[key]?.label }} {{ row[key] }}
    }
    }
      @for (key of keys; track key) {
    • {{ config[key]?.label }}
    • }

    Use the arrow keys to read each month.

    {{ summary() }}

    @for (row of data; track row.month) { }
    Visitors by month
    Month Desktop Mobile
    {{ row.month }} {{ row.desktop }} {{ row.mobile }}
    `, }) export class ChartLineExample { protected readonly width = WIDTH; protected readonly height = HEIGHT; protected readonly keys = ['desktop', 'mobile'] as const; protected readonly config: ChartConfig = { desktop: { label: 'Desktop', color: 'var(--chart-1)' }, mobile: { label: 'Mobile', color: 'var(--chart-2)' }, }; protected readonly data = [ { month: 'January', desktop: 186, mobile: 80 }, { month: 'February', desktop: 305, mobile: 200 }, { month: 'March', desktop: 237, mobile: 120 }, { month: 'April', desktop: 73, mobile: 190 }, { month: 'May', desktop: 209, mobile: 130 }, { month: 'June', desktop: 214, mobile: 140 }, ]; protected readonly xs = this.data.map( (_, index) => (index / (this.data.length - 1)) * WIDTH, ); protected readonly active = signal(null); protected readonly current = computed(() => { const index = this.active(); return index === null ? null : this.data[index]; }); protected readonly left = computed(() => { const index = this.active(); return index === null ? 0 : ((this.xs[index] ?? 0) / WIDTH) * 100; }); protected readonly summary = computed(() => { const row = this.current(); return row ? `${row.month}: desktop ${row.desktop}, mobile ${row.mobile}.` : ''; }); protected readonly y = scaleLinear( [0, Math.max(...this.data.map((row) => Math.max(row.desktop, row.mobile)))], [HEIGHT, 8], ); protected readonly lines = computed(() => ({ desktop: linePath( this.data.map((row, index) => ({ x: this.xs[index] ?? 0, y: this.y(row.desktop), })), 'monotone', ), mobile: linePath( this.data.map((row, index) => ({ x: this.xs[index] ?? 0, y: this.y(row.mobile), })), 'monotone', ), })); } ``` ### Chart area Desktop and mobile visitors by month, as stacked areas. ```ts import { Component, computed, signal } from '@angular/core'; import { areaPath, ChartAxis, type ChartConfig, ChartContainer, ChartCursor, ChartGrid, ChartLabel, ChartLegend, ChartLegendIndicator, ChartLegendItem, ChartPlot, ChartTooltip, ChartTooltipBody, ChartTooltipIndicator, ChartTooltipItem, ChartTooltipLabel, ChartTooltipList, ChartTooltipName, ChartTooltipValue, scaleLinear, stack, } from '@/ui/chart/chart'; /** The plot's size, in viewBox units. */ const WIDTH = 500; const HEIGHT = 225; /** * Desktop and mobile visitors by month, as stacked areas: mobile sits on * top of desktop. The arrow keys move between months; a hidden table holds * every value. */ @Component({ selector: 'example-chart-area', imports: [ ChartAxis, ChartContainer, ChartCursor, ChartGrid, ChartLabel, ChartLegend, ChartLegendIndicator, ChartLegendItem, ChartPlot, ChartTooltip, ChartTooltipBody, ChartTooltipIndicator, ChartTooltipItem, ChartTooltipLabel, ChartTooltipList, ChartTooltipName, ChartTooltipValue, ], template: `
    Visitors, January to June 2026
    @for (tick of y.ticks(4); track tick) { } @for (key of keys; track key) { } @if (active() !== null) { }
    @for ( row of data; track row.month; let i = $index, first = $first, last = $last ) { {{ row.month.slice(0, 3) }} }
    @if (current(); as row) {
    {{ row.month }}
    @for (key of keys; track key) {
    {{ config[key]?.label }} {{ row[key] }}
    }
    }
      @for (key of keys; track key) {
    • {{ config[key]?.label }}
    • }

    Use the arrow keys to read each month.

    {{ summary() }}

    @for (row of data; track row.month) { }
    Visitors by month
    Month Desktop Mobile
    {{ row.month }} {{ row.desktop }} {{ row.mobile }}
    `, }) export class ChartAreaExample { protected readonly width = WIDTH; protected readonly height = HEIGHT; protected readonly keys = ['desktop', 'mobile'] as const; protected readonly config: ChartConfig = { desktop: { label: 'Desktop', color: 'var(--chart-1)' }, mobile: { label: 'Mobile', color: 'var(--chart-2)' }, }; protected readonly data = [ { month: 'January', desktop: 186, mobile: 80 }, { month: 'February', desktop: 305, mobile: 200 }, { month: 'March', desktop: 237, mobile: 120 }, { month: 'April', desktop: 73, mobile: 190 }, { month: 'May', desktop: 209, mobile: 130 }, { month: 'June', desktop: 214, mobile: 140 }, ]; protected readonly xs = this.data.map( (_, index) => (index / (this.data.length - 1)) * WIDTH, ); protected readonly active = signal(null); protected readonly current = computed(() => { const index = this.active(); return index === null ? null : this.data[index]; }); protected readonly left = computed(() => { const index = this.active(); return index === null ? 0 : ((this.xs[index] ?? 0) / WIDTH) * 100; }); protected readonly summary = computed(() => { const row = this.current(); return row ? `${row.month}: desktop ${row.desktop}, mobile ${row.mobile}.` : ''; }); /** Mobile stacked on desktop: each value's bottom and top. */ protected readonly stacked = stack([ this.data.map((row) => row.desktop), this.data.map((row) => row.mobile), ]); protected readonly y = scaleLinear( [0, Math.max(...(this.stacked[1] ?? []).map(([, top]) => top))], [HEIGHT, 8], ); protected readonly areas = computed(() => { const points = (series: number, edge: 0 | 1) => (this.stacked[series] ?? []).map((pair, index) => ({ x: this.xs[index] ?? 0, y: this.y(pair[edge]), })); return { desktop: areaPath(points(0, 1), this.y(0), 'monotone'), mobile: areaPath(points(1, 1), points(1, 0), 'monotone'), }; }); } ``` ### Chart pie Visitors by browser, as a donut with the total in its middle. ```ts import { Component, computed, signal } from '@angular/core'; import { arcPath, type ChartConfig, ChartContainer, ChartLabel, ChartLegend, ChartLegendIndicator, ChartLegendItem, ChartPlot, ChartTooltip, ChartTooltipBody, ChartTooltipIndicator, ChartTooltipItem, ChartTooltipList, ChartTooltipName, ChartTooltipValue, pieArcs, } from '@/ui/chart/chart'; /** * Visitors by browser, as a donut with the total in its middle. The * arrow keys move between browsers, enlarging each slice; a hidden table * holds every value. */ @Component({ selector: 'example-chart-pie', imports: [ ChartContainer, ChartLabel, ChartLegend, ChartLegendIndicator, ChartLegendItem, ChartPlot, ChartTooltip, ChartTooltipBody, ChartTooltipIndicator, ChartTooltipItem, ChartTooltipList, ChartTooltipName, ChartTooltipValue, ], template: `
    Visitors by browser, June 2026
    @for (slice of slices; track slice.index) { }
    {{ total }} Visitors
    @if (current(); as row) {
    {{ config[row.browser]?.label }} {{ row.visitors }}
    }
      @for (row of data; track row.browser) {
    • {{ config[row.browser]?.label }}
    • }

    Use the arrow keys to read each browser.

    {{ summary() }}

    @for (row of data; track row.browser) { }
    Visitors by browser
    Browser Visitors
    {{ config[row.browser]?.label }} {{ row.visitors }}
    `, }) export class ChartPieExample { protected readonly config: ChartConfig = { chrome: { label: 'Chrome', color: 'var(--chart-1)' }, safari: { label: 'Safari', color: 'var(--chart-2)' }, firefox: { label: 'Firefox', color: 'var(--chart-3)' }, edge: { label: 'Edge', color: 'var(--chart-4)' }, other: { label: 'Other', color: 'var(--chart-5)' }, }; protected readonly data = [ { browser: 'chrome', visitors: 275 }, { browser: 'safari', visitors: 200 }, { browser: 'firefox', visitors: 187 }, { browser: 'edge', visitors: 173 }, { browser: 'other', visitors: 90 }, ]; protected readonly indexes = this.data.map((_, index) => index); protected readonly active = signal(null); protected readonly current = computed(() => { const index = this.active(); return index === null ? null : this.data[index]; }); protected readonly summary = computed(() => { const row = this.current(); return row ? `${this.config[row.browser]?.label}: ${row.visitors} visitors.` : ''; }); protected readonly arcPath = arcPath; protected readonly slices = pieArcs( this.data.map((row) => row.visitors), { padAngle: 0.02 }, ); protected readonly total = this.data.reduce( (sum, row) => sum + row.visitors, 0, ); } ``` ### Chart radial Visitors by browser, as bars around rings. ```ts import { Component, computed, signal } from '@angular/core'; import { arcPath, type ChartConfig, ChartContainer, ChartLegend, ChartLegendIndicator, ChartLegendItem, ChartPlot, ChartTooltip, ChartTooltipBody, ChartTooltipIndicator, ChartTooltipItem, ChartTooltipList, ChartTooltipName, ChartTooltipValue, } from '@/ui/chart/chart'; /** * Visitors by browser, as bars around rings: each bar's length is its * share of the most visited browser's. The arrow keys move between * browsers; a hidden table holds every value. */ @Component({ selector: 'example-chart-radial', imports: [ ChartContainer, ChartLegend, ChartLegendIndicator, ChartLegendItem, ChartPlot, ChartTooltip, ChartTooltipBody, ChartTooltipIndicator, ChartTooltipItem, ChartTooltipList, ChartTooltipName, ChartTooltipValue, ], template: `
    Visitors by browser, June 2026
    @for (row of data; track row.browser; let i = $index) { } @if (current(); as row) {
    {{ config[row.browser]?.label }} {{ row.visitors }}
    }
      @for (row of data; track row.browser) {
    • {{ config[row.browser]?.label }}
    • }

    Use the arrow keys to read each browser.

    {{ summary() }}

    @for (row of data; track row.browser) { }
    Visitors by browser
    Browser Visitors
    {{ config[row.browser]?.label }} {{ row.visitors }}
    `, }) export class ChartRadialExample { protected readonly config: ChartConfig = { chrome: { label: 'Chrome', color: 'var(--chart-1)' }, safari: { label: 'Safari', color: 'var(--chart-2)' }, firefox: { label: 'Firefox', color: 'var(--chart-3)' }, edge: { label: 'Edge', color: 'var(--chart-4)' }, other: { label: 'Other', color: 'var(--chart-5)' }, }; protected readonly data = [ { browser: 'chrome', visitors: 275 }, { browser: 'safari', visitors: 200 }, { browser: 'firefox', visitors: 187 }, { browser: 'edge', visitors: 173 }, { browser: 'other', visitors: 90 }, ]; protected readonly indexes = this.data.map((_, index) => index); protected readonly active = signal(null); protected readonly current = computed(() => { const index = this.active(); return index === null ? null : this.data[index]; }); protected readonly summary = computed(() => { const row = this.current(); return row ? `${this.config[row.browser]?.label}: ${row.visitors} visitors.` : ''; }); protected readonly arcPath = arcPath; protected readonly PI = Math.PI; private readonly most = Math.max(...this.data.map((row) => row.visitors)); /** * A browser's ring, from the outside in. * * @param index - The browser's index. * @returns The ring's outer radius. */ protected ring(index: number): number { return 110 - index * 16; } /** * How far a value's bar goes around its ring. * * @param visitors - The value. * @returns The bar's end angle, in radians. */ protected angle(visitors: number): number { return (visitors / this.most) * Math.PI * 1.5; } } ``` ## Accessibility Chart has no keyboard interaction or focus handling of its own; it keeps the semantics of the element it's on. ## API reference ### ChartContainer Selector: `[ndChartContainer]` A chart's frame: it sets `--color-` for each series in `config`, so the SVG, tooltip and legend use `var(--color-desktop)`, and keeps a video aspect ratio. Put it on a `figure` with a `figcaption`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[config]` | `ChartConfig` | `{}` | Each series' label and color, by key. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `chart` | ### ChartPlot Selector: `svg[ndChartPlot]` The plot, on the chart's `svg`: one tab stop where the arrow keys, `Home` and `End` move between data points and the pointer picks the nearest one, setting `active`. Bind a tooltip and a polite live region to `active`, so the keyboard and screen readers reach each value. Give it `xs`, each point's horizontal position in `viewBox` units, and name it with `aria-label` or `aria-labelledby`. It spans the chart's width at its `viewBox`'s aspect ratio, so a position in `viewBox` units is the same percentage of the plot's box, where the HTML labels and the tooltip go. For a fixed height instead, set one with a class and `preserveAspectRatio="none"`, and give your lines and outlines `vector-effect="non-scaling-stroke"` so they keep their width, as the grid and cursor do. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[xs]` | `readonly number[]` | `[]` | Each data point's horizontal position, in `viewBox` units. | | `[trackPointer]` | `boolean` | `true` | Whether the pointer picks the point nearest it horizontally; turn it off for pies, whose slices set `active` on hover themselves. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[(active)]` | `number \| null` | `null` | The index of the active data point, or `null`. Two-way bindable. | | Data attribute | Values | | --- | --- | | `data-slot` | `chart-plot` | | `data-active` | | ### ChartGrid Selector: `[ndChartGrid]` The grid lines behind the data, on a `g`. They stay a pixel wide whatever the plot's size. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `chart-grid` | ### ChartAxis Selector: `[ndChartAxis]` The x axis' labels, in a band under the plot. They're HTML rather than SVG text, so they keep their size at every width instead of scaling with the plot. Put a `ChartLabel` in it for each tick, at the tick's position across the plot in percent. Screen readers skip it: the chart's table holds the same labels. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `chart-axis` | ### ChartLabel Selector: `[ndChartLabel]` A label at a point of the chart, such as an axis tick or a value, in HTML so it keeps its size at every width. It sits at its `left`, a percentage of the plot's width, centered on it by default; a label at the plot's edge aligns `left` or `right` instead, so it stays within the plot. In a `ChartAxis` it sits at the top of the band. Over the plot, put the plot and the label in a `relative` box and set its `top` too, centering it with `-translate-y-1/2`. Like the plot, it isn't mirrored in right-to-left layouts, so it's placed with `left` and aligns to physical sides, not `start` and `end`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[align]` | `'center' \| 'left' \| 'right'` | `'center'` | Which of its sides is at its `left`: `center` for its middle, `left` for its left edge (it extends right), `right` for its right edge. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `chart-label` | | `data-align` | | ### ChartCursor Selector: `[ndChartCursor]` The marker of the active data point: a line, which stays a pixel wide whatever the plot's size, or a band behind bars. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[kind]` | `'line' \| 'band'` | `'line'` | `line` for line and area charts, `band` for bars. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `chart-cursor` | | `data-kind` | | ### ChartTooltip Selector: `[ndChartTooltip]` The tooltip of the active data point, drawn over the chart. It's for sighted users; a polite live region tells screen readers the same. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `chart-tooltip` | ### ChartTooltipLabel Selector: `[ndChartTooltipLabel]` The tooltip's heading, such as the month. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `chart-tooltip-label` | ### ChartTooltipList Selector: `[ndChartTooltipList]` The list of a tooltip's series. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `chart-tooltip-list` | ### ChartTooltipItem Selector: `[ndChartTooltipItem]` One series in a tooltip: its indicator, name and value. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `chart-tooltip-item` | ### ChartTooltipIndicator Selector: `[ndChartTooltipIndicator]` A series' color swatch in a tooltip: a `dot`, a `line` or a `dashed` line. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[indicator]` | `'line' \| 'dot' \| 'dashed'` | `'dot'` | Its shape. | | `[color]` | `string` | `'currentColor'` | The series' color. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `chart-tooltip-indicator` | ### ChartTooltipBody Selector: `[ndChartTooltipBody]` The name and value of a series in a tooltip, spread across its width. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `chart-tooltip-body` | ### ChartTooltipName Selector: `[ndChartTooltipName]` A series' name in a tooltip. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `chart-tooltip-name` | ### ChartTooltipValue Selector: `[ndChartTooltipValue]` A series' value in a tooltip, in tabular figures. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `chart-tooltip-value` | ### ChartLegend Selector: `[ndChartLegend]` The legend: the series' swatches and names, under the chart. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `chart-legend` | ### ChartLegendItem Selector: `[ndChartLegendItem]` One series in the legend. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `chart-legend-item` | ### ChartLegendIndicator Selector: `[ndChartLegendIndicator]` A series' color swatch in the legend. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[color]` | `string` | `'currentColor'` | The series' color. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `chart-legend-indicator` | ## Agent notes ### Do - Put the chart in a `figure` with a `figcaption` that says what it shows, and name the plot after it. - Bind a polite live region to the active point, and include a table of the data, hidden or behind a toggle. - Use the theme's chart colors, `var(--chart-1)` to `var(--chart-5)`. - Put labels in `ChartLabel`s rather than SVG `text`, which shrinks on phones and grows on wide screens. - Align a label at the plot's edge with `align="left"` or `align="right"`, so it stays within the plot instead of hanging half outside it. ### Don't - Don't rely on color alone: label series in the legend and tooltip. - Don't animate values on load; it delays reading them. --- # Checkbox > A control that's checked, unchecked or mixed, for choosing an option on its own or several from a list. Version 1.0.0 · WAI-ARIA pattern: [Checkbox](https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/) · SSR · Zoneless · 2.4 kB gzip A checkbox turns one option on or off, on its own, such as accepting the terms, or as one of several in a list. It's a native button announced as a checkbox, so it works with a keyboard, screen readers and any forms library: Signal Forms, reactive or template-driven. Set `indeterminate` for a "select all" box when only some items are selected; activating it checks it. A label whose `for` is its `id` names it and toggles it too. ## Installation ```bash npx @ndop/cli add checkbox ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/checkbox/checkbox.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdCheckbox, NdCheckboxIndicator } from '@ndop/primitives/checkbox'; import { cn } from '@/lib/utils'; /** * A checkbox on a native `button` (`@ndop/primitives/checkbox`): checked, * unchecked or, with `indeterminate`, mixed. Bind it with `[(checked)]` or * any forms library, name it with a `label[ndLabel]` whose `for` is its * `id`, and put a `ndCheckboxIndicator` with a check icon inside. * * @example * ```html * * * ``` */ @Directive({ selector: 'button[ndCheckbox]', hostDirectives: [ { directive: NdCheckbox, inputs: [ 'checked', 'indeterminate', 'defaultChecked', 'disabled', 'required', 'invalid', 'id', ], outputs: ['checkedChange', 'indeterminateChange'], }, ], host: { 'data-slot': 'checkbox', '[class]': 'classes()' }, }) export class Checkbox { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary', 'data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground dark:data-[state=indeterminate]:bg-primary', this.class(), ), ); } /** * The checkmark of a {@link Checkbox}, shown while it's checked or mixed. * Put a check icon inside, or a minus icon for a mixed checkbox. * * @example * ```html * * ``` */ @Directive({ selector: '[ndCheckboxIndicator]', hostDirectives: [NdCheckboxIndicator], host: { 'data-slot': 'checkbox-indicator', '[class]': 'classes()' }, }) export class CheckboxIndicator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'grid place-content-center text-current transition-none [&>svg]:size-3.5', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Checkbox, CheckboxIndicator } from '@/ui/checkbox/checkbox'; import { Field, FieldContent, FieldDescription, FieldLabel, } from '@/ui/form-field/form-field'; import { Label } from '@/ui/label/label'; ``` ```html

    You can enable or disable notifications at any time.

    ``` ## Examples ### Checkbox demo A checkbox with its label, and one with a description beside it. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Checkbox, CheckboxIndicator } from '@/ui/checkbox/checkbox'; import { Field, FieldContent, FieldDescription, FieldLabel, } from '@/ui/form-field/form-field'; import { Label } from '@/ui/label/label'; /** A checkbox with its label, and one with a description beside it. */ @Component({ selector: 'example-checkbox-demo', imports: [ Checkbox, CheckboxIndicator, Field, FieldContent, FieldDescription, FieldLabel, Label, NdIcon, ], template: `

    You can enable or disable notifications at any time.

    `, }) export class CheckboxDemoExample {} ``` ### Checkbox disabled A disabled checkbox, checked and unchecked, with dimmed labels. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Checkbox, CheckboxIndicator } from '@/ui/checkbox/checkbox'; import { Label } from '@/ui/label/label'; /** A disabled checkbox, checked and unchecked, with dimmed labels. */ @Component({ selector: 'example-checkbox-disabled', imports: [Checkbox, CheckboxIndicator, Label, NdIcon], template: `
    `, }) export class CheckboxDisabledExample {} ``` ### Checkbox indeterminate A select-all checkbox that's mixed while only some of the list is checked. ```ts import { Component, computed, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Checkbox, CheckboxIndicator } from '@/ui/checkbox/checkbox'; import { Label } from '@/ui/label/label'; /** * A select-all checkbox: checked when every item is, mixed when some are, * and checking or clearing them all when activated. */ @Component({ selector: 'example-checkbox-indeterminate', imports: [Checkbox, CheckboxIndicator, Label, NdIcon], template: `
    Email me about
    @for (topic of topics(); track topic.id) {
    }
    `, }) export class CheckboxIndeterminateExample { protected readonly topics = signal([ { id: 'product', label: 'Product news', checked: true }, { id: 'events', label: 'Events near me', checked: false }, { id: 'tips', label: 'Tips and tutorials', checked: false }, ]); protected readonly allChecked = computed(() => this.topics().every((topic) => topic.checked), ); protected readonly someChecked = computed( () => !this.allChecked() && this.topics().some((topic) => topic.checked), ); /** * Checks or clears one topic. * * @param id - The topic. * @param checked - Its new state. */ protected set(id: string, checked: boolean): void { this.topics.update((topics) => topics.map((topic) => (topic.id === id ? { ...topic, checked } : topic)), ); } /** * Checks or clears every topic. * * @param checked - The new state. */ protected setAll(checked: boolean): void { this.topics.update((topics) => topics.map((topic) => ({ ...topic, checked })), ); } } ``` ### Checkbox form A required terms checkbox in a Signal Forms form, with its error once touched. ```ts import { Component, signal } from '@angular/core'; import { form, FormField, required } from '@angular/forms/signals'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; import { Checkbox, CheckboxIndicator } from '@/ui/checkbox/checkbox'; import { Field, FieldContent, FieldDescription, FieldError, FieldLabel, } from '@/ui/form-field/form-field'; /** * A terms checkbox that must be checked to continue, in a Signal Forms * form: its error shows once the box has been left unchecked, or the form * was submitted. */ @Component({ selector: 'example-checkbox-form', imports: [ Button, Checkbox, CheckboxIndicator, Field, FieldContent, FieldDescription, FieldError, FieldLabel, FormField, NdIcon, ], template: `

    Read them before you sign up.

    Agree to the terms to create your account.

    `, }) export class CheckboxFormExample { protected readonly model = signal({ terms: false }); protected readonly signup = form(this.model, (path) => { required(path.terms); }); /** * Marks the fields touched, so errors show, instead of reloading. * * @param event - The form's submit event. */ protected submit(event: Event): void { event.preventDefault(); this.signup().markAsTouched(); } } ``` ## Accessibility Implements the [Checkbox](https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Space` | Checks or unchecks it; a mixed checkbox becomes checked. | | `Enter` | Does nothing, as for a native checkbox (APG); the default is prevented. | ### Focus - Unchanged from a native button: activating it keeps focus on it. - Leaving it marks a bound field touched. ### Right-to-left Nothing mirrors. ## API reference ### Checkbox Selector: `button[ndCheckbox]` · Role: `checkbox` A checkbox on a native `button` (`@ndop/primitives/checkbox`): checked, unchecked or, with `indeterminate`, mixed. Bind it with `[(checked)]` or any forms library, name it with a `label[ndLabel]` whose `for` is its `id`, and put a `ndCheckboxIndicator` with a check icon inside. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[defaultChecked]` | `boolean` | `false` | Initial `checked` when it isn't bound. | | `[disabled]` | `boolean` | `false` | Disables it. A disabled bound field disables it too. | | `[required]` | `boolean` | `false` | Marks it required: `aria-required="true"`. | | `[invalid]` | `boolean` | `false` | Marks it invalid when no forms library is bound; with one, the field's state decides. | | `[id]` | `string \| undefined` | | Element id; also the key a `label[ndLabel]` uses to find it. | | `[(checked)]` | `boolean` | `false` | Whether it's checked. Two-way bindable with `[(checked)]`. | | `[(indeterminate)]` | `boolean` | `false` | Whether it's mixed: `aria-checked="mixed"` whatever `checked` is. Activating it clears this. Two-way bindable. | | Data attribute | Values | | --- | --- | | `data-slot` | `checkbox` | | `data-state` | | | `data-disabled` | | | `data-invalid` | | ### CheckboxIndicator Selector: `[ndCheckboxIndicator]` The checkmark of a `Checkbox`, shown while it's checked or mixed. Put a check icon inside, or a minus icon for a mixed checkbox. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `checkbox-indicator` | | `data-state` | | ## Agent notes ### Do - Name every checkbox with a `label`, even inside a table row. - Use `indeterminate` for a parent box whose children are partly checked. - Put related checkboxes in a fieldset with a legend. ### Don't - Don't use a checkbox for a setting that applies right away; use a Switch. - Don't use checkboxes for options that exclude each other; use a Radio Group. --- # Collapsible > Shows and hides a section of content. Version 1.0.0 · WAI-ARIA pattern: [Disclosure (Show/Hide)](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) · SSR · Zoneless · 1.8 kB gzip A collapsible shows and hides a section of content with a button, the WAI-ARIA disclosure pattern. The trigger says whether the content is shown, with `aria-expanded`, and hidden content is out of the tab order. It has no styles of its own: style the trigger as a Button and the content as you like, and use `data-state` to rotate a chevron. ## Installation ```bash npx @ndop/cli add collapsible ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/collapsible/collapsible.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdCollapsible, NdCollapsibleContent, NdCollapsibleTrigger, } from '@ndop/primitives/collapsible'; import { cn } from '@/lib/utils'; /** * A collapsible (`@ndop/primitives/collapsible`): a trigger that shows and * hides a section of content, which stays in the page and slides open and * closed. Style the trigger with other items, such as a ghost Button. * `open` is two-way bindable. * * @example * ```html *
    * *
    …
    *
    * ``` */ @Directive({ selector: '[ndCollapsible]', hostDirectives: [ { directive: NdCollapsible, inputs: ['open', 'defaultOpen', 'disabled'], outputs: ['openChange'], }, ], host: { 'data-slot': 'collapsible' }, }) export class Collapsible {} /** * The button that shows and hides its collapsible's content, with * `aria-expanded` and `aria-controls`. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndCollapsibleTrigger]', hostDirectives: [NdCollapsibleTrigger], host: { 'data-slot': 'collapsible-trigger' }, }) export class CollapsibleTrigger {} /** * The content a collapsible shows and hides, sliding open and closed; * hidden content leaves the accessibility tree and the tab order. It clips * its content only while it animates, so focus rings inside it show. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndCollapsibleContent]', hostDirectives: [NdCollapsibleContent], host: { 'data-slot': 'collapsible-content', '[class]': 'classes()' }, }) export class CollapsibleContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'data-animating:overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down', // tw-animate-css's collapsible keyframes read this height. '[--radix-collapsible-content-height:var(--nd-collapsible-content-height)]', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Button } from '@/ui/button/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/ui/collapsible/collapsible'; ``` ```html

    @peduarte starred 3 repositories

    @radix-ui/primitives
    @radix-ui/colors
    @stitches/react
    ``` ## Examples ### Collapsible demo A list of repositories that expands. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/ui/collapsible/collapsible'; /** A list of starred repositories that expands to show the rest. */ @Component({ selector: 'example-collapsible-demo', imports: [ Button, Collapsible, CollapsibleContent, CollapsibleTrigger, NdIcon, ], template: `

    @peduarte starred 3 repositories

    @radix-ui/primitives
    @radix-ui/colors
    @stitches/react
    `, }) export class CollapsibleDemoExample {} ``` ### Collapsible settings Advanced options shown on demand. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/ui/collapsible/collapsible'; import { Input } from '@/ui/input/input'; import { Label } from '@/ui/label/label'; /** * Advanced options a form shows on demand; the chevron turns with * `data-state`. */ @Component({ selector: 'example-collapsible-settings', imports: [ Button, Input, Label, Collapsible, CollapsibleContent, CollapsibleTrigger, NdIcon, ], template: `
    `, }) export class CollapsibleSettingsExample {} ``` ## Accessibility Implements the [Disclosure (Show/Hide)](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Enter` | On the trigger, shows or hides the content. | | `Space` | On the trigger, shows or hides the content. | ### Focus - Focus stays on the trigger when the content shows or hides. - Shown content joins the tab order after the trigger; hidden content leaves it. ### Right-to-left Nothing mirrors. ## API reference ### Collapsible Selector: `[ndCollapsible]` A collapsible (`@ndop/primitives/collapsible`): a trigger that shows and hides a section of content, which stays in the page and slides open and closed. Style the trigger with other items, such as a ghost Button. `open` is two-way bindable. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[defaultOpen]` | `boolean` | `false` | Initial open state when `open` is not bound. | | `[disabled]` | `boolean` | `false` | Stops the trigger from toggling the content. | | `[(open)]` | `boolean` | `false` | Whether the content is shown. Two-way bindable with `[(open)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `collapsible` | | `data-state` | | | `data-disabled` | | ### CollapsibleTrigger Selector: `button[ndCollapsibleTrigger]` The button that shows and hides its collapsible's content, with `aria-expanded` and `aria-controls`. | Data attribute | Values | | --- | --- | | `data-slot` | `collapsible-trigger` | | `data-state` | | | `data-disabled` | | ### CollapsibleContent Selector: `[ndCollapsibleContent]` The content a collapsible shows and hides, sliding open and closed; hidden content leaves the accessibility tree and the tab order. It clips its content only while it animates, so focus rings inside it show. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `collapsible-content` | | `data-state` | | | `data-animating` | | ## Agent notes ### Do - Put `ndCollapsibleTrigger` on a native `button`, often with `ndButton`. - Write a trigger label that says what it reveals, such as "Show 3 more repositories". - Start it open with `defaultOpen`, or bind `[(open)]` to control it. ### Don't - Don't use it for a set of headings that each show a section; use an Accordion. - Don't hide content that most people need. --- # Combobox > A text field that filters a list of options as the user types. Version 1.0.0 · WAI-ARIA pattern: [Editable combobox with list autocomplete](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/) · SSR · Zoneless · 8.0 kB gzip A combobox is a text field that filters a list of options below it as the user types, for picking from many options, such as a country or a framework. Focus stays in the field while the arrow keys move through the options and Enter picks one. With `multiple`, the chosen options show as chips beside the field, and Backspace removes the last one. Bind forms on its field. ## Installation ```bash npx @ndop/cli add combobox ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/combobox/combobox.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdCombobox, NdComboboxChip, NdComboboxChipRemove, NdComboboxChips, NdComboboxClear, NdComboboxContent, NdComboboxEmpty, NdComboboxGroup, NdComboboxInput, NdComboboxItem, NdComboboxItemIndicator, NdComboboxLabel, NdComboboxList, NdComboboxSeparator, NdComboboxTrigger, } from '@ndop/primitives/combobox'; import { buttonVariants } from '@/ui/button/button'; import { cn } from '@/lib/utils'; /** Classes of the small icon buttons beside the field and in chips. */ const iconButton = buttonVariants({ variant: 'ghost', size: 'icon-xs' }); /** * A combobox (`@ndop/primitives/combobox`): a text field that filters a * list of options below it as the user types. With `multiple`, chosen * options show as chips. Put it on an `ng-container`, and bind forms on * its field. * * @example * ```html * *
    * * *
    *
    *
    *
    No framework found.
    *
    Angular
    *
    *
    *
    * ``` */ @Directive({ selector: '[ndCombobox]', hostDirectives: [ { directive: NdCombobox, inputs: [ 'value', 'values', 'multiple', 'search', 'open', 'filter', 'autoHighlight', 'disabled', 'required', 'invalid', ], outputs: ['valueChange', 'valuesChange', 'searchChange', 'openChange'], }, ], }) export class Combobox {} /** * The bordered row around a single-value combobox's field and its buttons. * The trigger hides while the clear button shows. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndComboboxControl]', host: { 'data-slot': 'combobox-control', '[class]': 'classes()' }, }) export class ComboboxControl { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/combobox-control relative flex h-9 w-auto min-w-0 items-center gap-1 rounded-md border border-input bg-transparent pe-1 shadow-xs transition-[color,box-shadow] has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-destructive/20 dark:bg-input/30 dark:has-aria-invalid:ring-destructive/40', this.class(), ), ); } /** * The text field of a single-value combobox, inside its * {@link ComboboxControl}. * * @example * ```html * * ``` */ @Directive({ selector: 'input[ndComboboxInput]', hostDirectives: [NdComboboxInput], host: { 'data-slot': 'combobox-input', '[class]': 'classes()' }, }) export class ComboboxInput { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'h-full w-full min-w-0 flex-1 bg-transparent px-3 py-1 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', this.class(), ), ); } /** * The button that opens and closes the list with the pointer. Put the * chevron icon in it. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndComboboxTrigger]', hostDirectives: [NdComboboxTrigger], host: { 'data-slot': 'combobox-trigger', '[class]': 'classes()' }, }) export class ComboboxTrigger { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( iconButton, "[&_svg:not([class*='size-'])]:size-4", '[&>svg]:pointer-events-none [&>svg]:size-4 [&>svg]:text-muted-foreground', 'group-has-[[data-slot=combobox-clear]:not([hidden])]/combobox-control:hidden', this.class(), ), ); } /** * The button that empties the field and the value with the pointer. Put * the x icon in it. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndComboboxClear]', hostDirectives: [NdComboboxClear], host: { 'data-slot': 'combobox-clear', '[class]': 'classes()' }, }) export class ComboboxClear { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn(iconButton, '[&>svg]:pointer-events-none', this.class()), ); } /** * The popup, written after the field; it opens below the field or the * chips, as wide as them. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndComboboxContent]', hostDirectives: [ { directive: NdComboboxContent, inputs: ['side', 'align', 'sideOffset'] }, ], host: { 'data-slot': 'combobox-content', '[class]': 'classes()' }, }) export class ComboboxContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/combobox-content relative max-h-96 w-(--nd-combobox-anchor-width) overflow-hidden rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=start]:slide-in-from-end-2 data-[side=end]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95', 'isolate z-50', this.class(), ), ); } /** * The scrolling list of options. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndComboboxList]', hostDirectives: [NdComboboxList], host: { 'data-slot': 'combobox-list', '[class]': 'classes()' }, }) export class ComboboxList { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'max-h-[calc(--spacing(96)---spacing(9))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0', this.class(), ), ); } /** * One option. Put its {@link ComboboxItemIndicator} last. * * @example * ```html *
    Angular
    * ``` */ @Directive({ selector: '[ndComboboxItem]', hostDirectives: [ { directive: NdComboboxItem, inputs: ['value', 'disabled', 'label'] }, ], host: { 'data-slot': 'combobox-item', '[class]': 'classes()' }, }) export class ComboboxItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pe-8 ps-2 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", this.class(), ), ); } /** * The check of a chosen option, at its end. * * @example * ```html * * ``` */ @Directive({ selector: '[ndComboboxItemIndicator]', hostDirectives: [NdComboboxItemIndicator], host: { 'data-slot': 'combobox-item-indicator', '[class]': 'classes()' }, }) export class ComboboxItemIndicator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'pointer-events-none absolute end-2 flex size-4 items-center justify-center', '[&>svg]:pointer-events-none [&>svg]:size-4 pointer-coarse:[&>svg]:size-5', this.class(), ), ); } /** * Groups options, named by its {@link ComboboxLabel}. * * @example * ```html *
    Frontend
    …
    * ``` */ @Directive({ selector: '[ndComboboxGroup]', hostDirectives: [NdComboboxGroup], host: { 'data-slot': 'combobox-group' }, }) export class ComboboxGroup {} /** * The heading of a {@link ComboboxGroup}. * * @example * ```html *
    Frontend
    * ``` */ @Directive({ selector: '[ndComboboxLabel]', hostDirectives: [NdComboboxLabel], host: { 'data-slot': 'combobox-label', '[class]': 'classes()' }, }) export class ComboboxLabel { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'px-2 py-1.5 text-xs text-muted-foreground pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm', this.class(), ), ); } /** * What shows while no option matches the field's text. * * @example * ```html *
    No framework found.
    * ``` */ @Directive({ selector: '[ndComboboxEmpty]', hostDirectives: [NdComboboxEmpty], host: { 'data-slot': 'combobox-empty', '[class]': 'classes()' }, }) export class ComboboxEmpty { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex', this.class(), ), ); } /** * A line between groups of options. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndComboboxSeparator]', hostDirectives: [NdComboboxSeparator], host: { 'data-slot': 'combobox-separator', '[class]': 'classes()' }, }) export class ComboboxSeparator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('-mx-1 my-1 h-px bg-border', this.class()), ); } /** * The bordered box around the chips and the field of a combobox with * `multiple`. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndComboboxChips]', hostDirectives: [NdComboboxChips], host: { 'data-slot': 'combobox-chips', '[class]': 'classes()' }, }) export class ComboboxChips { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-[3px] has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1.5 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40', this.class(), ), ); } /** * One chosen value, as a chip. Put its {@link ComboboxChipRemove} last. * * @example * ```html * {{ value }} * ``` */ @Directive({ selector: '[ndComboboxChip]', hostDirectives: [{ directive: NdComboboxChip, inputs: ['value'] }], host: { 'data-slot': 'combobox-chip', '[class]': 'classes()' }, }) export class ComboboxChip { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pe-0', this.class(), ), ); } /** * The button that removes its chip's value with the pointer. Put the x * icon in it. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndComboboxChipRemove]', hostDirectives: [NdComboboxChipRemove], host: { 'data-slot': 'combobox-chip-remove', '[class]': 'classes()' }, }) export class ComboboxChipRemove { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( iconButton, '-ms-1 opacity-50 hover:opacity-100', '[&>svg]:pointer-events-none', this.class(), ), ); } /** * The text field among the chips of a combobox with `multiple`. * * @example * ```html * * ``` */ @Directive({ selector: 'input[ndComboboxChipsInput]', hostDirectives: [NdComboboxInput], host: { 'data-slot': 'combobox-chip-input', '[class]': 'classes()' }, }) export class ComboboxChipsInput { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('min-w-16 flex-1 bg-transparent outline-none', this.class()), ); } ``` ### src/app/ui/button/button.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdButton } from '@ndop/primitives/button'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * Class table for {@link Button}; also usable to style other elements like * a button. */ export const buttonVariants = variants({ base: "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-disabled:pointer-events-none data-disabled:opacity-50", variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', }, size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', icon: 'size-9', 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", 'icon-sm': 'size-8', 'icon-lg': 'size-10', }, }, defaults: { variant: 'default', size: 'default' }, }); /** Visual style of a {@link Button}. */ export type ButtonVariant = VariantOf; /** Size of a {@link Button}. */ export type ButtonSize = VariantOf; /** * Styled button for a native `button` or an `a`, with the * `@ndop/primitives/button` behavior (safe default type, disabled links). * * @example * ```html * * Docs * ``` */ @Directive({ selector: 'button[ndButton], a[ndButton]', hostDirectives: [{ directive: NdButton, inputs: ['disabled'] }], host: { 'data-slot': 'button', '[attr.data-variant]': 'variant()', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class Button { /** Visual style. */ readonly variant = input('default'); /** Height, padding and icon size. */ readonly size = input('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: this.variant(), size: this.size() }), this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Combobox, ComboboxContent, ComboboxControl, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList, ComboboxTrigger, } from '@/ui/combobox/combobox'; import { Label } from '@/ui/label/label'; ``` ```html
    No framework found.
    @for (option of frameworks; track option.value) {
    {{ option.label }}
    }
    ``` ## Examples ### Combobox demo A framework picker that filters as you type. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Combobox, ComboboxContent, ComboboxControl, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList, ComboboxTrigger, } from '@/ui/combobox/combobox'; import { Label } from '@/ui/label/label'; /** * A framework picker that filters as you type. Focus stays in the field; * the arrow keys move through the options and Enter picks one. */ @Component({ selector: 'example-combobox-demo', imports: [ Combobox, ComboboxContent, ComboboxControl, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList, ComboboxTrigger, Label, NdIcon, ], template: `
    No framework found.
    @for (option of frameworks; track option.value) {
    {{ option.label }}
    }
    `, }) export class ComboboxDemoExample { protected readonly framework = signal(null); protected readonly frameworks = [ { value: 'angular', label: 'Angular' }, { value: 'analog', label: 'Analog' }, { value: 'astro', label: 'Astro' }, { value: 'nuxt', label: 'Nuxt' }, { value: 'remix', label: 'Remix' }, { value: 'sveltekit', label: 'SvelteKit' }, ]; } ``` ### Combobox clear A combobox with a button that clears it. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Combobox, ComboboxClear, ComboboxContent, ComboboxControl, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList, ComboboxTrigger, } from '@/ui/combobox/combobox'; /** * A combobox with a button that clears it, shown in place of the chevron * once there is something to clear. Escape clears it from the keyboard. */ @Component({ selector: 'example-combobox-clear', imports: [ Combobox, ComboboxClear, ComboboxContent, ComboboxControl, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList, ComboboxTrigger, NdIcon, ], template: `
    No country found.
    @for (name of countries; track name) {
    {{ name }}
    }
    `, }) export class ComboboxClearExample { protected readonly country = signal('Cameroon'); protected readonly countries = [ 'Brazil', 'Cameroon', 'Canada', 'France', 'Japan', 'Nigeria', ]; } ``` ### Combobox multiple Several frameworks, chosen as chips. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Combobox, ComboboxChip, ComboboxChipRemove, ComboboxChips, ComboboxChipsInput, ComboboxContent, ComboboxEmpty, ComboboxItem, ComboboxItemIndicator, ComboboxList, } from '@/ui/combobox/combobox'; /** * Several frameworks, chosen as chips. Picking an option toggles it and * keeps the list open; Backspace in the empty field removes the last chip. */ @Component({ selector: 'example-combobox-multiple', imports: [ Combobox, ComboboxChip, ComboboxChipRemove, ComboboxChips, ComboboxChipsInput, ComboboxContent, ComboboxEmpty, ComboboxItem, ComboboxItemIndicator, ComboboxList, NdIcon, ], template: `
    @for (value of chosen(); track value) { {{ labelOf(value) }} }
    No framework found.
    @for (option of frameworks; track option.value) {
    {{ option.label }}
    }
    `, }) export class ComboboxMultipleExample { protected readonly chosen = signal(['angular']); protected readonly frameworks = [ { value: 'angular', label: 'Angular' }, { value: 'analog', label: 'Analog' }, { value: 'astro', label: 'Astro' }, { value: 'nuxt', label: 'Nuxt' }, { value: 'remix', label: 'Remix' }, { value: 'sveltekit', label: 'SvelteKit' }, ]; /** * The label of a chosen framework, for its chip. * * @param value - The framework's value. * @returns Its label. */ protected labelOf(value: string): string { return ( this.frameworks.find((option) => option.value === value)?.label ?? value ); } } ``` ### Combobox groups Options in labelled groups, with separators. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Combobox, ComboboxContent, ComboboxControl, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxLabel, ComboboxList, ComboboxSeparator, ComboboxTrigger, } from '@/ui/combobox/combobox'; /** * Options in labelled groups, with separators. A group hides while none * of its options matches. */ @Component({ selector: 'example-combobox-groups', imports: [ Combobox, ComboboxContent, ComboboxControl, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxLabel, ComboboxList, ComboboxSeparator, ComboboxTrigger, NdIcon, ], template: `
    No food found.
    @for (group of groups; track group.name; let last = $last) {
    {{ group.name }}
    @for (food of group.foods; track food) {
    {{ food }}
    }
    @if (!last) {
    } }
    `, }) export class ComboboxGroupsExample { protected readonly groups = [ { name: 'Fruits', foods: ['Apple', 'Banana', 'Mango'] }, { name: 'Vegetables', foods: ['Carrot', 'Okra', 'Spinach'] }, { name: 'Grains', foods: ['Millet', 'Rice', 'Sorghum'] }, ]; } ``` ### Combobox disabled A disabled combobox, and one with a disabled option. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Combobox, ComboboxContent, ComboboxControl, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, ComboboxTrigger, } from '@/ui/combobox/combobox'; /** * A disabled combobox, and one with a disabled option, which the arrow * keys skip. */ @Component({ selector: 'example-combobox-disabled', imports: [ Combobox, ComboboxContent, ComboboxControl, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, ComboboxTrigger, NdIcon, ], template: `
    No team found.
    Design
    No region found.
    Africa
    Europe
    Antarctica (unavailable)
    `, }) export class ComboboxDisabledExample {} ``` ## Accessibility Implements the [Editable combobox with list autocomplete](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `a` | Types into the field, opening the popup and filtering its options. | | `ArrowDown` | Opens the popup and highlights the chosen option or the first; while open, highlights the next option. | | `ArrowUp` | Opens the popup and highlights the last option; while open, highlights the previous option. | | `Alt+ArrowDown` | Opens the popup without highlighting an option. | | `Enter` | Chooses the highlighted option. Without `multiple` the popup closes; with it, the option toggles and it stays open. | | `Escape` | Closes the popup; if it's closed, clears the field and, without `multiple`, the value. | | `Backspace` | With `multiple`, in an empty field, removes the last chosen value. | | `Home` or `End` | Move the caret in the field, as in any text field. | | `Tab` | Closes the popup and moves focus on. | ### Focus - Focus stays in the field; `aria-activedescendant` points at the highlighted option, which scrolls into view. - Clicking an option, the trigger, the clear button or a chip's remove button keeps focus in the field. - Focus leaving the combobox closes the popup and marks a bound field touched. ### Right-to-left `start` and `end` alignments are logical and swap in right-to-left layouts. The listbox is vertical, so its arrow keys don't change. ## API reference ### Combobox Selector: `[ndCombobox]` A combobox (`@ndop/primitives/combobox`): a text field that filters a list of options below it as the user types. With `multiple`, chosen options show as chips. Put it on an `ng-container`, and bind forms on its field. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[multiple]` | `boolean` | `false` | Lets several options be chosen, shown as chips. | | `[filter]` | `NdFilter \| null` | `ndContainsFilter` | Tests whether an option matches what was typed; `null` shows every option, for lists filtered elsewhere. | | `[autoHighlight]` | `boolean` | `false` | Highlights the first shown option as the user types. | | `[disabled]` | `boolean` | `false` | Disables the field. A disabled bound field does too. | | `[required]` | `boolean` | `false` | Marks the combobox required: `aria-required="true"` on the field. | | `[invalid]` | `boolean` | `false` | Marks the combobox invalid when no forms library is bound; with one, the field's state decides. | | `[(value)]` | `string \| null` | `null` | The chosen option's value, or `null`, without `multiple`. Two-way bindable with `[(value)]`. | | `[(values)]` | `readonly string[]` | `[]` | The chosen options' values, with `multiple`. Two-way bindable with `[(values)]`. | | `[(search)]` | `string` | `''` | The field's text. Two-way bindable with `[(search)]`. | | `[(open)]` | `boolean` | `false` | Whether the popup is open. Two-way bindable with `[(open)]`. | ### ComboboxControl Selector: `[ndComboboxControl]` The bordered row around a single-value combobox's field and its buttons. The trigger hides while the clear button shows. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `combobox-control` | ### ComboboxInput Selector: `input[ndComboboxInput]` · Role: `combobox` The text field of a single-value combobox, inside its `ComboboxControl`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `combobox-input` | | `data-state` | | | `data-disabled` | | | `data-invalid` | | ### ComboboxTrigger Selector: `button[ndComboboxTrigger]` The button that opens and closes the list with the pointer. Put the chevron icon in it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `combobox-trigger` | | `data-state` | | ### ComboboxClear Selector: `button[ndComboboxClear]` The button that empties the field and the value with the pointer. Put the x icon in it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `combobox-clear` | ### ComboboxContent Selector: `[ndComboboxContent]` The popup, written after the field; it opens below the field or the chips, as wide as them. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[side]` | `'bottom' \| 'top' \| 'start' \| 'end'` | `'bottom'` | Side of the anchor it opens on, before flipping. | | `[align]` | `'start' \| 'end' \| 'center'` | `'start'` | Alignment along that side. | | `[sideOffset]` | `number` | `6` | Gap between the anchor and the popup, in pixels. | | Data attribute | Values | | --- | --- | | `data-slot` | `combobox-content` | | `data-state` | | | `data-side` | | | `data-empty` | | ### ComboboxList Selector: `[ndComboboxList]` · Role: `listbox` The scrolling list of options. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `combobox-list` | | `data-empty` | | ### ComboboxItem Selector: `[ndComboboxItem]` · Role: `option` One option. Put its `ComboboxItemIndicator` last. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[value]` (required) | `string` | | The value choosing it gives the combobox. | | `[disabled]` | `boolean` | `false` | Makes it unchoosable; the arrow keys skip it. | | `[label]` | `string \| null` | `null` | Its text, when it differs from what it renders. | | Data attribute | Values | | --- | --- | | `data-slot` | `combobox-item` | | `data-highlighted` | | | `data-selected` | | | `data-disabled` | | ### ComboboxItemIndicator Selector: `[ndComboboxItemIndicator]` The check of a chosen option, at its end. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `combobox-item-indicator` | ### ComboboxGroup Selector: `[ndComboboxGroup]` · Role: `group` Groups options, named by its `ComboboxLabel`. | Data attribute | Values | | --- | --- | | `data-slot` | `combobox-group` | ### ComboboxLabel Selector: `[ndComboboxLabel]` The heading of a `ComboboxGroup`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `combobox-label` | ### ComboboxEmpty Selector: `[ndComboboxEmpty]` What shows while no option matches the field's text. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `combobox-empty` | ### ComboboxSeparator Selector: `[ndComboboxSeparator]` A line between groups of options. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `combobox-separator` | ### ComboboxChips Selector: `[ndComboboxChips]` The bordered box around the chips and the field of a combobox with `multiple`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `combobox-chips` | ### ComboboxChip Selector: `[ndComboboxChip]` One chosen value, as a chip. Put its `ComboboxChipRemove` last. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[value]` (required) | `string` | | The chosen value it shows. | | Data attribute | Values | | --- | --- | | `data-slot` | `combobox-chip` | | `data-disabled` | | ### ComboboxChipRemove Selector: `button[ndComboboxChipRemove]` The button that removes its chip's value with the pointer. Put the x icon in it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `combobox-chip-remove` | ### ComboboxChipsInput Selector: `input[ndComboboxChipsInput]` · Role: `combobox` The text field among the chips of a combobox with `multiple`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `combobox-chip-input` | | `data-state` | | | `data-disabled` | | | `data-invalid` | | ## Agent notes ### Do - Name the field with a label, as any form field. - Show an empty state for when nothing matches. - Pass `[filter]="null"` and filter yourself when options come from a server. ### Don't - Don't use it for a handful of options; use a Select or a Radio Group. - Don't use it to run commands; use Command. --- # Command > A search field over a list of commands, filtered as the user types. Version 1.0.0 · WAI-ARIA pattern: [Combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) · SSR · Zoneless · 3.7 kB gzip A command menu is a search field over a list of commands, filtered as the user types, such as a ⌘K palette. Focus stays in the field; the arrow keys move the highlight and Enter runs the highlighted command. Commands can be grouped, have keywords that match them too, and show their keyboard shortcuts. Put it in a Dialog to open it as a palette. ## Installation ```bash npx @ndop/cli add command ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/command/command.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdCommand, NdCommandEmpty, NdCommandGroup, NdCommandGroupHeading, NdCommandInput, NdCommandItem, NdCommandList, NdCommandSeparator, } from '@ndop/primitives/command'; import { cn } from '@/lib/utils'; /** * A command menu (`@ndop/primitives/command`): a search field over a list * of commands, filtered as the user types. Put it in a Dialog for a ⌘K * palette. * * @example * ```html *
    *
    * * *
    *
    *
    No results found.
    *
    Calendar
    *
    *
    * ``` */ @Directive({ selector: '[ndCommand]', hostDirectives: [ { directive: NdCommand, inputs: ['label', 'search', 'filter', 'shouldFilter', 'loop'], outputs: ['searchChange'], }, ], host: { 'data-slot': 'command', '[class]': 'classes()' }, }) export class Command { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground', this.class(), ), ); } /** * The row around the search field and its icon. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndCommandInputWrapper]', host: { 'data-slot': 'command-input-wrapper', '[class]': 'classes()' }, }) export class CommandInputWrapper { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex h-9 items-center gap-2 border-b px-3', '[&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:opacity-50', this.class(), ), ); } /** * The search field; focus stays in it while the arrow keys move through * the commands. * * @example * ```html * * ``` */ @Directive({ selector: 'input[ndCommandInput]', hostDirectives: [NdCommandInput], host: { 'data-slot': 'command-input', '[class]': 'classes()' }, }) export class CommandInput { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', this.class(), ), ); } /** * The scrolling list of commands. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndCommandList]', hostDirectives: [NdCommandList], host: { 'data-slot': 'command-list', '[class]': 'classes()' }, }) export class CommandList { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', this.class(), ), ); } /** * What shows while no command matches. * * @example * ```html *
    No results found.
    * ``` */ @Directive({ selector: '[ndCommandEmpty]', hostDirectives: [NdCommandEmpty], host: { 'data-slot': 'command-empty', '[class]': 'classes()' }, }) export class CommandEmpty { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('py-6 text-center text-sm', this.class()), ); } /** * A group of commands, named by its {@link CommandGroupHeading}. * * @example * ```html *
    *
    Suggestions
    * … *
    * ``` */ @Directive({ selector: '[ndCommandGroup]', hostDirectives: [NdCommandGroup], host: { 'data-slot': 'command-group', '[class]': 'classes()' }, }) export class CommandGroup { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('overflow-hidden p-1 text-foreground', this.class()), ); } /** * The heading of a {@link CommandGroup}. * * @example * ```html *
    Suggestions
    * ``` */ @Directive({ selector: '[ndCommandGroupHeading]', hostDirectives: [NdCommandGroupHeading], host: { 'data-slot': 'command-group-heading', '[class]': 'classes()' }, }) export class CommandGroupHeading { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', this.class()), ); } /** * A line between groups, hidden while searching. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndCommandSeparator]', hostDirectives: [NdCommandSeparator], host: { 'data-slot': 'command-separator', '[class]': 'classes()' }, }) export class CommandSeparator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('-mx-1 h-px bg-border', this.class()), ); } /** * One command; `triggered` emits when it runs. * * @example * ```html *
    Search emoji
    * ``` */ @Directive({ selector: '[ndCommandItem]', hostDirectives: [ { directive: NdCommandItem, inputs: ['value', 'keywords', 'disabled'], outputs: ['triggered'], }, ], host: { 'data-slot': 'command-item', '[class]': 'classes()' }, }) export class CommandItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", this.class(), ), ); } /** * A command's keyboard shortcut, shown at its end. It's text for sighted * users; the command doesn't bind it. * * @example * ```html * ⌘P * ``` */ @Directive({ selector: '[ndCommandShortcut]', host: { 'data-slot': 'command-shortcut', '[class]': 'classes()' }, }) export class CommandShortcut { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('ms-auto text-xs tracking-widest text-muted-foreground', this.class()), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Command, CommandEmpty, CommandGroup, CommandGroupHeading, CommandInput, CommandInputWrapper, CommandItem, CommandList, CommandSeparator, CommandShortcut, } from '@/ui/command/command'; ``` ```html
    No results found.
    Suggestions
    Calendar
    Search emoji
    Calculator
    Settings
    Profile ⌘P
    Billing ⌘B
    Settings ⌘S

    {{ ran() }}

    ``` ## Examples ### Command demo An inline command list with groups, shortcuts and a disabled command. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Command, CommandEmpty, CommandGroup, CommandGroupHeading, CommandInput, CommandInputWrapper, CommandItem, CommandList, CommandSeparator, CommandShortcut, } from '@/ui/command/command'; /** * An inline command list with groups, shortcuts and a disabled command. * Focus stays in the search field; the arrow keys move the highlight and * Enter runs it. */ @Component({ selector: 'example-command-demo', imports: [ Command, CommandEmpty, CommandGroup, CommandGroupHeading, CommandInput, CommandInputWrapper, CommandItem, CommandList, CommandSeparator, CommandShortcut, NdIcon, ], template: `
    No results found.
    Suggestions
    Calendar
    Search emoji
    Calculator
    Settings
    Profile ⌘P
    Billing ⌘B
    Settings ⌘S

    {{ ran() }}

    `, }) export class CommandDemoExample { protected readonly ran = signal(''); /** * Reports the command that ran. * * @param name - The command's name. */ protected run(name: string): void { this.ran.set(`Ran ${name}.`); } } ``` ### Command dialog A command palette in a dialog, opened with Ctrl+K or Cmd+K. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; import { Command, CommandEmpty, CommandGroup, CommandGroupHeading, CommandInput, CommandInputWrapper, CommandItem, CommandList, CommandShortcut, } from '@/ui/command/command'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, } from '@/ui/dialog/dialog'; /** * A command palette in a dialog, opened with its button or with Ctrl+K * (⌘K on a Mac). Running a command closes it. */ @Component({ selector: 'example-command-dialog', imports: [ Button, Command, CommandEmpty, CommandGroup, CommandGroupHeading, CommandInput, CommandInputWrapper, CommandItem, CommandList, CommandShortcut, Dialog, DialogContent, DialogDescription, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, NdIcon, ], host: { '(document:keydown)': 'shortcut($event)' }, template: `

    Command palette

    Search for a command to run.

    No results found.
    Suggestions
    Calendar
    Search emoji
    Settings
    Profile ⌘P
    Settings ⌘S

    {{ ran() }}

    `, }) export class CommandDialogExample { protected readonly open = signal(false); protected readonly ran = signal(''); /** * Opens or closes the palette for Ctrl+K or ⌘K. * * @param event - A keydown anywhere in the document. */ protected shortcut(event: KeyboardEvent): void { if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); this.open.update((open) => !open); } } /** * Runs a command and closes the palette. * * @param name - The command's name. */ protected run(name: string): void { this.ran.set(`Ran ${name}.`); this.open.set(false); } } ``` ## Accessibility Implements the [Combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `ArrowDown` or `ArrowUp` | Highlights the next or previous shown, enabled item. | | `Home` or `End` | Moves the caret in the field, as in any text field. | | `Enter` | Runs the highlighted item. | | `a` | Types into the field, filtering the list. | ### Focus - Focus stays in the field; `aria-activedescendant` points at the highlighted item, which scrolls into view. - Clicking an item runs it. ### Right-to-left Nothing mirrors: the list is vertical, and the field follows the text direction. ## API reference ### Command Selector: `[ndCommand]` A command menu (`@ndop/primitives/command`): a search field over a list of commands, filtered as the user types. Put it in a Dialog for a ⌘K palette. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[label]` | `string \| null` | `null` | Names the field and the list for assistive technology. | | `[filter]` | `NdFilter` | `ndContainsFilter` | Tests whether an item matches what was typed. | | `[shouldFilter]` | `boolean` | `true` | Hides the items that don't match; `false` for lists filtered elsewhere. | | `[loop]` | `boolean` | `false` | Makes the arrow keys wrap at the ends. | | `[(search)]` | `string` | `''` | The field's text. Two-way bindable with `[(search)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `command` | ### CommandInputWrapper Selector: `[ndCommandInputWrapper]` The row around the search field and its icon. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `command-input-wrapper` | ### CommandInput Selector: `input[ndCommandInput]` · Role: `combobox` The search field; focus stays in it while the arrow keys move through the commands. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `command-input` | ### CommandList Selector: `[ndCommandList]` · Role: `listbox` The scrolling list of commands. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `command-list` | ### CommandEmpty Selector: `[ndCommandEmpty]` What shows while no command matches. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `command-empty` | ### CommandGroup Selector: `[ndCommandGroup]` · Role: `group` A group of commands, named by its `CommandGroupHeading`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `command-group` | ### CommandGroupHeading Selector: `[ndCommandGroupHeading]` The heading of a `CommandGroup`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `command-group-heading` | ### CommandSeparator Selector: `[ndCommandSeparator]` A line between groups, hidden while searching. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `command-separator` | ### CommandItem Selector: `[ndCommandItem]` · Role: `option` One command; `triggered` emits when it runs. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[value]` | `string \| null` | `null` | Identifies it; defaults to its text. | | `[keywords]` | `string` | `''` | Other words the search matches it by, separated by spaces. | | `[disabled]` | `boolean` | `false` | Makes it inert; the arrow keys skip it. | | Data attribute | Values | | --- | --- | | `data-slot` | `command-item` | | `data-selected` | | | `data-disabled` | | ### CommandShortcut Selector: `[ndCommandShortcut]` A command's keyboard shortcut, shown at its end. It's text for sighted users; the command doesn't bind it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `command-shortcut` | ## Agent notes ### Do - Name it with `label`. - Add `keywords` for the words people may search a command by. - Group commands and show an empty state. ### Don't - Don't use it for picking a form value; use a Combobox. - Don't show shortcuts the app doesn't bind. --- # Context Menu > A menu of actions that a right click or long press on an area opens, at the pointer. Version 1.0.0 · WAI-ARIA pattern: [Menu](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) · SSR · Zoneless · 3.0 kB gzip A context menu lists actions for an area, opened at the pointer with a right click or a long press, or at the area's corner with Shift + F10 or the keyboard's menu key. Inside, it works like any menu: arrow keys, typing, Enter and Escape, which returns focus to the area. Items can be checkboxes, radios or submenus. ## Installation ```bash npx @ndop/cli add context-menu ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/context-menu/context-menu.ts ```ts import { booleanAttribute, computed, Directive, input } from '@angular/core'; import { NdContextMenuTrigger } from '@ndop/primitives/context-menu'; import { NdMenuContent, NdMenuGroup, NdMenuItem, NdMenuItemCheckbox, NdMenuItemIndicator, NdMenuItemRadio, NdMenuLabel, NdMenuRadioGroup, NdMenuSeparator, NdMenuSubTrigger, } from '@ndop/primitives/menu'; import { cn } from '@/lib/utils'; /** * An area with a context menu (`@ndop/primitives/context-menu`): its value * is the menu's `ng-template`, opened at the pointer on a right click or * long press, or at its corner on Shift + F10. Make it focusable and named. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndContextMenuTrigger]', hostDirectives: [ { directive: NdContextMenuTrigger, inputs: ['ndContextMenuTrigger', 'disabled'], }, ], host: { 'data-slot': 'context-menu-trigger' }, }) export class ContextMenuTrigger {} /** * A context menu's list (`@ndop/primitives/menu`), in the `ng-template` a * `[ndContextMenuTrigger]` area opens at the pointer on a right click or * long press, or at its corner on Shift + F10. * * @example * ```html *
    Back
    * ``` */ @Directive({ selector: '[ndContextMenuContent]', hostDirectives: [{ directive: NdMenuContent, outputs: ['closed'] }], host: { 'data-slot': 'context-menu-content', '[class]': 'classes()' }, }) export class ContextMenuContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'z-50 max-h-[80vh] min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=start]:slide-in-from-end-2 data-[side=end]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', this.class(), ), ); } /** * A submenu, opened beside its sub-trigger: at the end side, so on the left * in right-to-left layouts. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndContextMenuSubContent]', hostDirectives: [{ directive: NdMenuContent, outputs: ['closed'] }], host: { 'data-slot': 'context-menu-sub-content', '[class]': 'classes()' }, }) export class ContextMenuSubContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=start]:slide-in-from-end-2 data-[side=end]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', this.class(), ), ); } /** * Groups related items (`role="group"`). Name it with `aria-labelledby` * pointing at a label. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndContextMenuGroup]', hostDirectives: [NdMenuGroup], host: { 'data-slot': 'context-menu-group' }, }) export class ContextMenuGroup {} /** * One action (`role="menuitem"`): activating it emits `triggered` and * closes the menu. Disabled items stay focusable and do nothing. Use a * `div`: on a `button`, Space would close the menu. * * @example * ```html *
    Edit
    * ``` */ @Directive({ selector: '[ndContextMenuItem]', hostDirectives: [ { directive: NdMenuItem, inputs: ['disabled'], outputs: ['triggered'] }, ], host: { 'data-slot': 'context-menu-item', '[attr.data-inset]': 'inset() ? "" : null', '[attr.data-variant]': 'variant()', '[class]': 'classes()', }, }) export class ContextMenuItem { /** Indents it to line up with items that have an indicator. */ readonly inset = input(false, { transform: booleanAttribute }); /** `destructive` colors a dangerous action, such as Delete. */ readonly variant = input<'default' | 'destructive'>('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:ps-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", this.class(), ), ); } /** * An item that is on or off (`role="menuitemcheckbox"`), bound with * `[(checked)]`. Put an indicator with a check icon in it. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndContextMenuCheckboxItem]', hostDirectives: [ { directive: NdMenuItemCheckbox, inputs: ['checked', 'disabled'], outputs: ['checkedChange', 'triggered'], }, ], host: { 'data-slot': 'context-menu-checkbox-item', '[class]': 'classes()' }, }) export class ContextMenuCheckboxItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pe-2 ps-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", this.class(), ), ); } /** * Groups radio items (`role="group"`) and holds the checked value, bound * with `[(value)]`. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndContextMenuRadioGroup]', hostDirectives: [ { directive: NdMenuRadioGroup, inputs: ['value'], outputs: ['valueChange'], }, ], host: { 'data-slot': 'context-menu-radio-group' }, }) export class ContextMenuRadioGroup {} /** * One choice of a radio group (`role="menuitemradio"`). Put an indicator * with a small filled circle in it. * * @example * ```html *
    Top
    * ``` */ @Directive({ selector: '[ndContextMenuRadioItem]', hostDirectives: [ { directive: NdMenuItemRadio, inputs: ['value', 'disabled'], outputs: ['triggered'], }, ], host: { 'data-slot': 'context-menu-radio-item', '[class]': 'classes()' }, }) export class ContextMenuRadioItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pe-2 ps-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", this.class(), ), ); } /** * The check or dot at the start of a checkbox or radio item, shown while it * is checked. * * @example * ```html * * ``` */ @Directive({ selector: '[ndContextMenuItemIndicator]', hostDirectives: [NdMenuItemIndicator], host: { 'data-slot': 'context-menu-item-indicator', '[class]': 'classes()' }, }) export class ContextMenuItemIndicator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'pointer-events-none absolute start-2 flex size-3.5 items-center justify-center', this.class(), ), ); } /** * A heading for a group of items; point the group's `aria-labelledby` at * it. * * @example * ```html *
    My account
    * ``` */ @Directive({ selector: '[ndContextMenuLabel]', hostDirectives: [NdMenuLabel], host: { 'data-slot': 'context-menu-label', '[attr.data-inset]': 'inset() ? "" : null', '[class]': 'classes()', }, }) export class ContextMenuLabel { /** Indents it to line up with items that have an indicator. */ readonly inset = input(false, { transform: booleanAttribute }); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'px-2 py-1.5 text-sm font-medium text-foreground data-[inset]:ps-8', this.class(), ), ); } /** * A line between groups of items (`role="separator"`). * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndContextMenuSeparator]', hostDirectives: [NdMenuSeparator], host: { 'data-slot': 'context-menu-separator', '[class]': 'classes()' }, }) export class ContextMenuSeparator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('-mx-1 my-1 h-px bg-border', this.class()), ); } /** * The keyboard shortcut of an item, at its end, as a reminder; the menu * doesn't handle it. * * @example * ```html * ⌘S * ``` */ @Directive({ selector: '[ndContextMenuShortcut]', host: { 'data-slot': 'context-menu-shortcut', '[class]': 'classes()' }, }) export class ContextMenuShortcut { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('ms-auto text-xs tracking-widest text-muted-foreground', this.class()), ); } /** * An item that opens the submenu in its template. Put a chevron icon at its * end with `ms-auto`, turned around in right-to-left layouts. * * @example * ```html *
    More tools
    * ``` */ @Directive({ selector: '[ndContextMenuSubTrigger]', hostDirectives: [ { directive: NdMenuSubTrigger, inputs: ['ndMenuSubTrigger: ndContextMenuSubTrigger', 'disabled'], }, ], host: { 'data-slot': 'context-menu-sub-trigger', '[attr.data-inset]': 'inset() ? "" : null', '[class]': 'classes()', }, }) export class ContextMenuSubTrigger { /** Indents it to line up with items that have an indicator. */ readonly inset = input(false, { transform: booleanAttribute }); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:ps-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { ContextMenuCheckboxItem, ContextMenuContent, ContextMenuItem, ContextMenuItemIndicator, ContextMenuLabel, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, } from '@/ui/context-menu/context-menu'; ``` ```html
    Right click here
    Back ⌘[
    Forward ⌘]
    Reload ⌘R
    More tools
    Show bookmarks
    People
    @for (name of people; track name) {
    {{ name }}
    }
    Save page…
    Developer tools
    Delete
    ``` ## Examples ### Context Menu demo Browser-style actions on an area, with shortcuts, a submenu, checkbox and radio items. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { ContextMenuCheckboxItem, ContextMenuContent, ContextMenuItem, ContextMenuItemIndicator, ContextMenuLabel, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, } from '@/ui/context-menu/context-menu'; /** * Browser-style actions on an area: shortcuts, a disabled item, a submenu, * a checkbox and a group of radios. The area is focusable and named, so * Shift + F10 opens the menu from the keyboard. */ @Component({ selector: 'example-context-menu-demo', imports: [ ContextMenuCheckboxItem, ContextMenuContent, ContextMenuItem, ContextMenuItemIndicator, ContextMenuLabel, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, NdIcon, ], template: `
    Right click here
    Back ⌘[
    Forward ⌘]
    Reload ⌘R
    More tools
    Show bookmarks
    People
    @for (name of people; track name) {
    {{ name }}
    }
    Save page…
    Developer tools
    Delete
    `, }) export class ContextMenuDemoExample { protected readonly bookmarks = signal(true); protected readonly people = ['Pedro Duarte', 'Colm Tuite']; protected readonly person = signal('Pedro Duarte'); } ``` ### Context Menu checkboxes Checkbox items on a right click, for showing and hiding layers. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { ContextMenuCheckboxItem, ContextMenuContent, ContextMenuItemIndicator, ContextMenuTrigger, } from '@/ui/context-menu/context-menu'; /** * Checkbox items on a right click, for showing and hiding a map's * layers, which the area reflects. */ @Component({ selector: 'example-context-menu-checkboxes', imports: [ ContextMenuCheckboxItem, ContextMenuContent, ContextMenuItemIndicator, ContextMenuTrigger, NdIcon, ], template: `
    Map {{ traffic() ? 'Traffic' : '' }} {{ transit() ? 'Transit' : '' }}
    Traffic
    Transit
    `, }) export class ContextMenuCheckboxesExample { protected readonly traffic = signal(true); protected readonly transit = signal(false); } ``` ## Accessibility Implements the [Menu](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Shift + F10` | On the focused area, opens the menu and focuses its first item. | | `ArrowDown` or `ArrowUp` | Moves focus to the next or previous item, wrapping. | | `Home` or `End` | Moves focus to the first or last item. | | `Enter` | Activates the focused item and closes the menu. | | `Space` | Activates the focused item; checkbox and radio items keep the menu open. | | `ArrowRight` | On a sub-trigger, opens its menu and focuses the first item. | | `ArrowLeft` | In a submenu, closes it and focuses its sub-trigger. | | `Escape` | Closes the current menu and returns focus to what opened it. | | `Tab` | Closes every open menu and moves focus on from the trigger. | | `a` | Typeahead: moves focus to the next item whose label starts with the typed letters. | ### Focus - Opening focuses the menu; closing returns focus to the area. - Opened from the keyboard, a menu focuses its first item (its last with `ArrowUp` on a Dropdown Menu trigger); opened with the pointer, the menu itself. - Closing returns focus to the trigger or sub-trigger. ### Right-to-left Submenus follow Menu's RTL behavior. `ArrowLeft` opens submenus and `ArrowRight` closes them, and submenus open on the left. Direction comes from the app's `Directionality`: the document's `dir`, or a CDK `[dir]` directive. ## API reference ### ContextMenuTrigger Selector: `[ndContextMenuTrigger]` An area with a context menu (`@ndop/primitives/context-menu`): its value is the menu's `ng-template`, opened at the pointer on a right click or long press, or at its corner on Shift + F10. Make it focusable and named. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[ndContextMenuTrigger]` | `TemplateRef \| null` | `null` | The `ng-template` of the menu it opens. | | `[disabled]` | `boolean` | `false` | Leaves the browser's own context menu. | | Data attribute | Values | | --- | --- | | `data-slot` | `context-menu-trigger` | | `data-state` | | | `data-disabled` | | ### ContextMenuContent Selector: `[ndContextMenuContent]` · Role: `menu` A context menu's list (`@ndop/primitives/menu`), in the `ng-template` a `[ndContextMenuTrigger]` area opens at the pointer on a right click or long press, or at its corner on Shift + F10. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `context-menu-content` | | `data-state` | | | `data-side` | | ### ContextMenuSubContent Selector: `[ndContextMenuSubContent]` · Role: `menu` A submenu, opened beside its sub-trigger: at the end side, so on the left in right-to-left layouts. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `context-menu-sub-content` | | `data-state` | | | `data-side` | | ### ContextMenuGroup Selector: `[ndContextMenuGroup]` · Role: `group` Groups related items (`role="group"`). Name it with `aria-labelledby` pointing at a label. | Data attribute | Values | | --- | --- | | `data-slot` | `context-menu-group` | ### ContextMenuItem Selector: `[ndContextMenuItem]` · Role: `menuitem` One action (`role="menuitem"`): activating it emits `triggered` and closes the menu. Disabled items stay focusable and do nothing. Use a `div`: on a `button`, Space would close the menu. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[inset]` | `boolean` | `false` | Indents it to line up with items that have an indicator. | | `[variant]` | `'default' \| 'destructive'` | `'default'` | `destructive` colors a dangerous action, such as Delete. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[disabled]` | `boolean` | `false` | Makes it inert but focusable, with `aria-disabled`. | | Data attribute | Values | | --- | --- | | `data-slot` | `context-menu-item` | | `data-inset` | | | `data-variant` | | | `data-disabled` | | ### ContextMenuCheckboxItem Selector: `[ndContextMenuCheckboxItem]` · Role: `menuitemcheckbox` An item that is on or off (`role="menuitemcheckbox"`), bound with `[(checked)]`. Put an indicator with a check icon in it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[disabled]` | `boolean` | `false` | Makes it inert but focusable, with `aria-disabled`. | | `[(checked)]` | `boolean` | `false` | Whether it's on. Two-way bindable with `[(checked)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `context-menu-checkbox-item` | | `data-state` | | | `data-disabled` | | ### ContextMenuRadioGroup Selector: `[ndContextMenuRadioGroup]` · Role: `group` Groups radio items (`role="group"`) and holds the checked value, bound with `[(value)]`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[(value)]` | `string \| null` | `null` | The checked radio item's value, or `null`. Two-way bindable with `[(value)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `context-menu-radio-group` | ### ContextMenuRadioItem Selector: `[ndContextMenuRadioItem]` · Role: `menuitemradio` One choice of a radio group (`role="menuitemradio"`). Put an indicator with a small filled circle in it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[value]` (required) | `string` | | The value its group takes when it's checked. | | `[disabled]` | `boolean` | `false` | Makes it inert but focusable, with `aria-disabled`. | | Data attribute | Values | | --- | --- | | `data-slot` | `context-menu-radio-item` | | `data-state` | | | `data-disabled` | | ### ContextMenuItemIndicator Selector: `[ndContextMenuItemIndicator]` The check or dot at the start of a checkbox or radio item, shown while it is checked. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `context-menu-item-indicator` | | `data-state` | | ### ContextMenuLabel Selector: `[ndContextMenuLabel]` A heading for a group of items; point the group's `aria-labelledby` at it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[inset]` | `boolean` | `false` | Indents it to line up with items that have an indicator. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `context-menu-label` | | `data-inset` | | ### ContextMenuSeparator Selector: `[ndContextMenuSeparator]` · Role: `separator` A line between groups of items (`role="separator"`). | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `context-menu-separator` | ### ContextMenuShortcut Selector: `[ndContextMenuShortcut]` The keyboard shortcut of an item, at its end, as a reminder; the menu doesn't handle it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `context-menu-shortcut` | ### ContextMenuSubTrigger Selector: `[ndContextMenuSubTrigger]` · Role: `menuitem` An item that opens the submenu in its template. Put a chevron icon at its end with `ms-auto`, turned around in right-to-left layouts. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[inset]` | `boolean` | `false` | Indents it to line up with items that have an indicator. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[ndContextMenuSubTrigger]` | `TemplateRef \| null` | `null` | The `ng-template` of the submenu. | | `[disabled]` | `boolean` | `false` | Makes it inert but focusable, with `aria-disabled`. | | Data attribute | Values | | --- | --- | | `data-slot` | `context-menu-sub-trigger` | | `data-inset` | | | `data-state` | | | `data-disabled` | | ## Agent notes ### Do - Make the area focusable and give it a name, so keyboard users can open the menu. - Offer the same actions somewhere visible too, such as a toolbar or a Dropdown Menu. ### Don't - Don't hide actions people need only in a context menu. - Don't replace the browser's menu on text people may want to copy. --- # Data Table > A table of rows to sort, search, page through and select, on signals. Version 1.0.0 · SSR · Zoneless · 1.2 kB gzip A data table shows rows you can sort, search, page through and select, and whose columns you can hide. Its state is `createDataTable`, a few signals in your component: it renders nothing, so you mark up a native `table` with the Table item's parts, a Checkbox for selection, an Input for search and a Dropdown Menu for columns. It's all copy-in code with no dependency, renders on the server and needs no zone. ## Installation ```bash npx @ndop/cli add data-table ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/data-table/data-table.ts ```ts import { computed, Directive, input } from '@angular/core'; import { type DataTable } from '@/lib/data-table'; import { buttonVariants } from '@/ui/button/button'; import { cn } from '@/lib/utils'; export { compareValues, createDataTable, type DataTable, type DataTableColumn, type DataTableDirection, type DataTableOptions, type DataTableRow, type DataTableSort, } from '@/lib/data-table'; /** * A column header's sort button: a click sorts by its column, ascending, * then descending, then not at all. Bind the header cell's `aria-sort` to * `table.ariaSort(column)`, so screen readers hear the order; the button * has `data-sort` for its icon. * * @example * ```html * * * * ``` */ @Directive({ selector: 'button[ndDataTableSortButton]', host: { type: 'button', 'data-slot': 'data-table-sort-button', '[attr.data-sort]': 'table().ariaSort(column())', '[class]': 'classes()', '(click)': 'table().toggleSort(column())', }, }) export class DataTableSortButton { /** The table it sorts, from `createDataTable`. */ readonly table = input.required, 'ariaSort' | 'toggleSort'>>(); /** The id of the column it sorts by. */ readonly column = input.required(); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: 'ghost', size: 'sm' }), '-ms-3 h-8 data-[sort=none]:[&>svg]:opacity-50', this.class(), ), ); } ``` ### src/app/lib/data-table.ts ```ts import { computed, signal, type Signal } from '@angular/core'; /** One column of a {@link DataTable}. */ export interface DataTableColumn { /** A unique id, used for sorting, visibility and `aria-sort`. */ readonly id: string; /** The header's text, also shown where columns are listed. */ readonly header: string; /** The cell's value, for sorting and filtering; defaults to `row[id]`. */ readonly value?: (row: T) => unknown; /** Whether the header can sort by it. */ readonly sortable?: boolean; /** Whether it can be hidden; defaults to `true`. */ readonly hideable?: boolean; /** Orders two values; defaults to numbers, dates, then text. */ readonly compare?: (a: unknown, b: unknown) => number; } /** Which way a column sorts. */ export type DataTableDirection = 'ascending' | 'descending'; /** The column the rows are sorted by. */ export interface DataTableSort { /** The column's id. */ readonly id: string; /** Which way it sorts. */ readonly direction: DataTableDirection; } /** A row shown on the current page, with its id. */ export interface DataTableRow { /** The row's id, for selection and `track`. */ readonly id: string; /** The row's data. */ readonly data: T; } /** What {@link createDataTable} needs. */ export interface DataTableOptions { /** The rows, such as a signal or an input. */ readonly data: () => readonly T[]; /** The columns, in order. */ readonly columns: readonly DataTableColumn[]; /** A row's id; defaults to its index in `data`. */ readonly rowId?: (row: T, index: number) => string; /** Rows per page; defaults to 10. */ readonly pageSize?: number; /** * Whether a row matches the search; defaults to any column's value * containing it, ignoring case. */ readonly filter?: (row: T, query: string) => boolean; } /** * A data table's state, on signals: sorting, searching, pages, selected * rows and hidden columns, with the rows to show. It renders nothing: * mark up a `table` with the Table item's parts and loop over `rows()` * and `visibleColumns()`. */ export interface DataTable { /** The columns, in order. */ readonly columns: readonly DataTableColumn[]; /** The search text. */ readonly query: Signal; /** The sort, or `null` for the data's order. */ readonly sort: Signal; /** The current page, from 0. */ readonly pageIndex: Signal; /** Rows per page. */ readonly pageSize: Signal; /** How many pages the matching rows fill, at least 1. */ readonly pageCount: Signal; /** The rows matching the search, sorted, on every page. */ readonly matchingRows: Signal[]>; /** The rows to show: the current page of {@link DataTable.matchingRows}. */ readonly rows: Signal[]>; /** The columns not hidden. */ readonly visibleColumns: Signal[]>; /** The selected rows' ids. */ readonly selection: Signal>; /** Whether every row on the page is selected. */ readonly pageSelected: Signal; /** Whether some, but not all, rows on the page are selected. */ readonly pagePartlySelected: Signal; /** Whether there is a previous page. */ readonly canPrevious: Signal; /** Whether there is a next page. */ readonly canNext: Signal; /** * Sets the search text and goes back to the first page. * * @param query - What to search for. */ search(query: string): void; /** * Sorts by a column: ascending, then descending, then not at all. * * @param id - The column's id. */ toggleSort(id: string): void; /** * The `aria-sort` value for a column's header. * * @param id - The column's id. * @returns `ascending`, `descending`, or `none` for a sortable column * that isn't sorted, `null` otherwise. */ ariaSort(id: string): DataTableDirection | 'none' | null; /** * Shows a page. * * @param index - The page, from 0; kept within the pages there are. */ goTo(index: number): void; /** Shows the previous page, if any. */ previous(): void; /** Shows the next page, if any. */ next(): void; /** * Sets how many rows a page shows, and goes back to the first page. * * @param size - Rows per page. */ setPageSize(size: number): void; /** * Whether a row is selected. * * @param id - The row's id. * @returns `true` when it is. */ isSelected(id: string): boolean; /** * Selects or unselects a row. * * @param id - The row's id. * @param selected - Whether to select it; defaults to the opposite. */ toggleRow(id: string, selected?: boolean): void; /** * Selects or unselects every row on the page. * * @param selected - Whether to select them; defaults to the opposite of * {@link DataTable.pageSelected}. */ togglePage(selected?: boolean): void; /** * Whether a column is shown. * * @param id - The column's id. * @returns `true` unless it's hidden. */ isVisible(id: string): boolean; /** * Shows or hides a column, if it's hideable. * * @param id - The column's id. * @param visible - Whether to show it; defaults to the opposite. */ toggleColumn(id: string, visible?: boolean): void; } /** * The default order for two values: numbers and dates numerically, the * rest as text in the user's locale, with empty values last. * * @param a - A value. * @param b - Another value. * @returns Negative, zero or positive, as for `Array.sort`. */ export function compareValues(a: unknown, b: unknown): number { if (a === b) return 0; if (a === null || a === undefined || a === '') return 1; if (b === null || b === undefined || b === '') return -1; if (typeof a === 'number' && typeof b === 'number') return a - b; if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime(); return String(a).localeCompare(String(b), undefined, { numeric: true }); } /** * Creates a data table's state, usually in a component's field * initializer. It's all signals, so it renders on the server and needs no * zone. * * @param options - The rows, columns and defaults. * @returns The table's state. * * @example * ```ts * readonly table = createDataTable({ * data: this.payments, * columns: [ * { id: 'email', header: 'Email', sortable: true }, * { id: 'amount', header: 'Amount', sortable: true }, * ], * rowId: (payment) => payment.id, * }); * ``` */ export function createDataTable(options: DataTableOptions): DataTable { const { columns } = options; const valueOf = (column: DataTableColumn, row: T): unknown => column.value ? column.value(row) : (row as Record)[column.id]; const filter = options.filter ?? ((row: T, query: string) => { const needle = query.toLowerCase(); return columns.some((column) => String(valueOf(column, row) ?? '') .toLowerCase() .includes(needle), ); }); const query = signal(''); const sort = signal(null); const pageIndex = signal(0); const pageSize = signal(options.pageSize ?? 10); const selection = signal>(new Set()); const hidden = signal>(new Set()); const allRows = computed(() => options.data().map((data, index) => ({ id: options.rowId ? options.rowId(data, index) : String(index), data, })), ); const matchingRows = computed(() => { const text = query().trim(); const matching = text ? allRows().filter((row) => filter(row.data, text)) : allRows(); const current = sort(); const column = current && columns.find((other) => other.id === current.id); if (!current || !column) { return matching; } const compare = column.compare ?? compareValues; const sign = current.direction === 'ascending' ? 1 : -1; return [...matching].sort( (a, b) => sign * compare(valueOf(column, a.data), valueOf(column, b.data)), ); }); const pageCount = computed(() => Math.max(1, Math.ceil(matchingRows().length / pageSize())), ); // The page stays within the pages there are as rows come and go. const page = computed(() => Math.min(pageIndex(), pageCount() - 1)); const rows = computed(() => matchingRows().slice(page() * pageSize(), (page() + 1) * pageSize()), ); const pageSelected = computed( () => rows().length > 0 && rows().every((row) => selection().has(row.id)), ); const pagePartlySelected = computed( () => !pageSelected() && rows().some((row) => selection().has(row.id)), ); const goTo = (index: number) => pageIndex.set(Math.max(0, Math.min(index, pageCount() - 1))); return { columns, query: query.asReadonly(), sort: sort.asReadonly(), pageIndex: page, pageSize: pageSize.asReadonly(), pageCount, matchingRows, rows, visibleColumns: computed(() => columns.filter((column) => !hidden().has(column.id)), ), selection: selection.asReadonly(), pageSelected, pagePartlySelected, canPrevious: computed(() => page() > 0), canNext: computed(() => page() < pageCount() - 1), search(text) { query.set(text); pageIndex.set(0); }, toggleSort(id) { const current = sort(); if (current?.id !== id) { sort.set({ id, direction: 'ascending' }); } else if (current.direction === 'ascending') { sort.set({ id, direction: 'descending' }); } else { sort.set(null); } }, ariaSort(id) { const column = columns.find((other) => other.id === id); if (!column?.sortable) { return null; } const current = sort(); return current?.id === id ? current.direction : 'none'; }, goTo, previous: () => goTo(page() - 1), next: () => goTo(page() + 1), setPageSize(size) { pageSize.set(Math.max(1, size)); pageIndex.set(0); }, isSelected: (id) => selection().has(id), toggleRow(id, selected) { selection.update((current) => { const next = new Set(current); if (selected ?? !current.has(id)) { next.add(id); } else { next.delete(id); } return next; }); }, togglePage(selected) { const select = selected ?? !pageSelected(); selection.update((current) => { const next = new Set(current); for (const row of rows()) { if (select) { next.add(row.id); } else { next.delete(row.id); } } return next; }); }, isVisible: (id) => !hidden().has(id), toggleColumn(id, visible) { const column = columns.find((other) => other.id === id); if (column?.hideable === false) { return; } hidden.update((current) => { const next = new Set(current); if (visible ?? current.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }, }; } ``` ### src/app/ui/button/button.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdButton } from '@ndop/primitives/button'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * Class table for {@link Button}; also usable to style other elements like * a button. */ export const buttonVariants = variants({ base: "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-disabled:pointer-events-none data-disabled:opacity-50", variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', }, size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', icon: 'size-9', 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", 'icon-sm': 'size-8', 'icon-lg': 'size-10', }, }, defaults: { variant: 'default', size: 'default' }, }); /** Visual style of a {@link Button}. */ export type ButtonVariant = VariantOf; /** Size of a {@link Button}. */ export type ButtonSize = VariantOf; /** * Styled button for a native `button` or an `a`, with the * `@ndop/primitives/button` behavior (safe default type, disabled links). * * @example * ```html * * Docs * ``` */ @Directive({ selector: 'button[ndButton], a[ndButton]', hostDirectives: [{ directive: NdButton, inputs: ['disabled'] }], host: { 'data-slot': 'button', '[attr.data-variant]': 'variant()', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class Button { /** Visual style. */ readonly variant = input('default'); /** Height, padding and icon size. */ readonly size = input('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: this.variant(), size: this.size() }), this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Button } from '@/ui/button/button'; import { Checkbox, CheckboxIndicator } from '@/ui/checkbox/checkbox'; import { createDataTable, DataTableSortButton, } from '@/ui/data-table/data-table'; import { DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemIndicator, DropdownMenuTrigger, } from '@/ui/dropdown-menu/dropdown-menu'; import { Input } from '@/ui/input/input'; import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow, } from '@/ui/table/table'; ``` ```html
    @for (column of table.columns; track column.id) { @if (column.hideable !== false) {
    {{ column.header }}
    } }
    @for (column of table.visibleColumns(); track column.id) { } @for (row of table.rows(); track row.id) { @for (column of table.visibleColumns(); track column.id) { } } @empty { }
    @if (column.sortable) { } @else { {{ column.header }} } Actions
    @switch (column.id) { @case ('amount') { {{ row.data.amount | currency: 'USD' }} } @case ('status') { {{ row.data.status }} } @default { {{ row.data.email }} } }
    Copy payment ID
    View customer
    No results.

    {{ table.selection().size }} of {{ table.matchingRows().length }} rows selected.

    Page {{ table.pageIndex() + 1 }} of {{ table.pageCount() }}
    ``` ## Examples ### Data Table demo Payments to sort, search, select and page through, with columns to hide. ```ts import { CurrencyPipe } from '@angular/common'; import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; import { Checkbox, CheckboxIndicator } from '@/ui/checkbox/checkbox'; import { createDataTable, DataTableSortButton, } from '@/ui/data-table/data-table'; import { DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemIndicator, DropdownMenuTrigger, } from '@/ui/dropdown-menu/dropdown-menu'; import { Input } from '@/ui/input/input'; import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow, } from '@/ui/table/table'; interface Payment { id: string; amount: number; status: 'pending' | 'processing' | 'success' | 'failed'; email: string; } /** * Payments to sort by email or amount, search, select and page through, * with columns to hide and a menu of actions per row. The state is * `createDataTable`; the markup is the Table item's. */ @Component({ selector: 'example-data-table-demo', imports: [ Button, Checkbox, CheckboxIndicator, CurrencyPipe, DataTableSortButton, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuItemIndicator, DropdownMenuTrigger, Input, NdIcon, Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow, ], template: `
    @for (column of table.columns; track column.id) { @if (column.hideable !== false) {
    {{ column.header }}
    } }
    @for (column of table.visibleColumns(); track column.id) { } @for (row of table.rows(); track row.id) { @for (column of table.visibleColumns(); track column.id) { } } @empty { }
    @if (column.sortable) { } @else { {{ column.header }} } Actions
    @switch (column.id) { @case ('amount') { {{ row.data.amount | currency: 'USD' }} } @case ('status') { {{ row.data.status }} } @default { {{ row.data.email }} } }
    Copy payment ID
    View customer
    No results.

    {{ table.selection().size }} of {{ table.matchingRows().length }} rows selected.

    Page {{ table.pageIndex() + 1 }} of {{ table.pageCount() }}
    `, }) export class DataTableDemoExample { protected readonly payments = signal([ { id: 'm5gr84i9', amount: 316, status: 'success', email: 'ken99@example.com', }, { id: '3u1reuv4', amount: 242, status: 'success', email: 'abe45@example.com', }, { id: 'derv1ws0', amount: 837, status: 'processing', email: 'monserrat44@example.com', }, { id: '5kma53ae', amount: 874, status: 'success', email: 'silas22@example.com', }, { id: 'bhqecj4p', amount: 721, status: 'failed', email: 'carmella@example.com', }, { id: 'p0r8sd1k', amount: 129, status: 'pending', email: 'nadia@example.com', }, { id: 'x7v2kq9m', amount: 560, status: 'success', email: 'tomas@example.com', }, ]); protected readonly table = createDataTable({ data: this.payments, columns: [ { id: 'status', header: 'Status' }, { id: 'email', header: 'Email', sortable: true }, { id: 'amount', header: 'Amount', sortable: true }, ], rowId: (payment) => payment.id, pageSize: 5, }); /** * Copies a payment's id to the clipboard. * * @param id - The payment's id. */ protected copy(id: string): void { void navigator.clipboard?.writeText(id); } } ``` ## Accessibility Data Table has no keyboard interaction or focus handling of its own; it keeps the semantics of the element it's on. ## API reference ### DataTableSortButton Selector: `button[ndDataTableSortButton]` A column header's sort button: a click sorts by its column, ascending, then descending, then not at all. Bind the header cell's `aria-sort` to `table.ariaSort(column)`, so screen readers hear the order; the button has `data-sort` for its icon. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[table]` (required) | `Pick, "ariaSort" \| "toggleSort">` | | The table it sorts, from `createDataTable`. | | `[column]` (required) | `string` | | The id of the column it sorts by. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `data-table-sort-button` | | `data-sort` | | ## Agent notes ### Do - Bind each sortable header's `aria-sort` to `table.ariaSort(id)`, and sort with a `DataTableSortButton` in it. - Give rows a stable id with `rowId`, so selection survives sorting. - Say how many rows are selected and which page is shown, in text. ### Don't - Don't use it for a few static rows; use Table. - Don't make whole rows clickable without a link or button in them. --- # Date Picker > A button that opens a calendar in a popover to pick a date or a range. Version 1.0.0 · SSR · Zoneless · 1.1 kB gzip A date picker is a button that opens a calendar in a popover, to pick a date or a range of days. It composes Popover and Calendar: opening focuses the chosen day, the arrow keys move between days, and choosing one can close the popover and return focus to the button, which shows the date formatted for the locale. ## Installation ```bash npx @ndop/cli add date-picker ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/date-picker/date-picker.ts ```ts import { booleanAttribute, computed, Directive, input } from '@angular/core'; import { buttonVariants } from '@/ui/button/button'; import { cn } from '@/lib/utils'; /** * The button of a date picker: an outline button that shows the chosen * date, or a muted placeholder while `empty`. Put it on the trigger of a * Popover whose content holds a Calendar; opening focuses the chosen day. * * @example * ```html * * * *
    …
    *
    *
    * ``` */ @Directive({ selector: 'button[ndDatePickerTrigger]', host: { type: 'button', 'data-slot': 'date-picker-trigger', '[attr.data-empty]': 'empty()', '[class]': 'classes()', }, }) export class DatePickerTrigger { /** Whether no date is chosen yet, which mutes the placeholder text. */ readonly empty = input(false, { transform: booleanAttribute }); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: 'outline' }), 'w-[240px] justify-start text-start font-normal data-[empty=true]:text-muted-foreground', this.class(), ), ); } ``` ### src/app/ui/button/button.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdButton } from '@ndop/primitives/button'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * Class table for {@link Button}; also usable to style other elements like * a button. */ export const buttonVariants = variants({ base: "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-disabled:pointer-events-none data-disabled:opacity-50", variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', }, size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', icon: 'size-9', 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", 'icon-sm': 'size-8', 'icon-lg': 'size-10', }, }, defaults: { variant: 'default', size: 'default' }, }); /** Visual style of a {@link Button}. */ export type ButtonVariant = VariantOf; /** Size of a {@link Button}. */ export type ButtonSize = VariantOf; /** * Styled button for a native `button` or an `a`, with the * `@ndop/primitives/button` behavior (safe default type, disabled links). * * @example * ```html * * Docs * ``` */ @Directive({ selector: 'button[ndButton], a[ndButton]', hostDirectives: [{ directive: NdButton, inputs: ['disabled'] }], host: { 'data-slot': 'button', '[attr.data-variant]': 'variant()', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class Button { /** Visual style. */ readonly variant = input('default'); /** Height, padding and icon size. */ readonly size = input('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: this.variant(), size: this.size() }), this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Calendar, CalendarCaption, CalendarCell, CalendarDay, CalendarGrid, CalendarHeading, CalendarMonth, CalendarMonths, CalendarNav, CalendarNext, CalendarPrevious, CalendarWeek, CalendarWeekday, CalendarWeekdays, } from '@/ui/calendar/calendar'; import { DatePickerTrigger } from '@/ui/date-picker/date-picker'; import { Popover, PopoverContent, PopoverPortal, PopoverTrigger, } from '@/ui/popover/popover'; ``` ```html
    @for (month of calendar.months(); track month.key) {
    {{ month.label }}
    @for ( weekday of calendar.weekdays(); track weekday.long ) { } @for (week of month.weeks; track week.key) { @for (day of week.days; track day.key) { } }
    {{ weekday.short }}
    }
    ``` ## Examples ### Date Picker demo A button that opens a calendar to pick a date, and closes once one is picked. ```ts import { DatePipe } from '@angular/common'; import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Calendar, CalendarCaption, CalendarCell, CalendarDay, CalendarGrid, CalendarHeading, CalendarMonth, CalendarMonths, CalendarNav, CalendarNext, CalendarPrevious, CalendarWeek, CalendarWeekday, CalendarWeekdays, } from '@/ui/calendar/calendar'; import { DatePickerTrigger } from '@/ui/date-picker/date-picker'; import { Popover, PopoverContent, PopoverPortal, PopoverTrigger, } from '@/ui/popover/popover'; /** * A button that opens a calendar to pick a date. Opening focuses the chosen * day; picking one closes the popover and returns focus to the button. */ @Component({ selector: 'example-date-picker-demo', imports: [ Calendar, CalendarCaption, CalendarCell, CalendarDay, CalendarGrid, CalendarHeading, CalendarMonth, CalendarMonths, CalendarNav, CalendarNext, CalendarPrevious, CalendarWeek, CalendarWeekday, CalendarWeekdays, DatePickerTrigger, DatePipe, NdIcon, Popover, PopoverContent, PopoverPortal, PopoverTrigger, ], template: `
    @for (month of calendar.months(); track month.key) {
    {{ month.label }}
    @for ( weekday of calendar.weekdays(); track weekday.long ) { } @for (week of month.weeks; track week.key) { @for (day of week.days; track day.key) { } }
    {{ weekday.short }}
    }
    `, }) export class DatePickerDemoExample { protected readonly open = signal(false); protected readonly date = signal(null); } ``` ### Date Picker range A button that opens two months to pick a range of days. ```ts import { DatePipe } from '@angular/common'; import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Calendar, CalendarCaption, CalendarCell, CalendarDay, CalendarGrid, CalendarHeading, CalendarMonth, CalendarMonths, CalendarNav, CalendarNext, CalendarPrevious, CalendarWeek, CalendarWeekday, CalendarWeekdays, type DateRange, } from '@/ui/calendar/calendar'; import { DatePickerTrigger } from '@/ui/date-picker/date-picker'; import { Popover, PopoverContent, PopoverPortal, PopoverTrigger, } from '@/ui/popover/popover'; /** * A button that opens two months to pick a range of days; it shows both * ends once the range is complete. */ @Component({ selector: 'example-date-picker-range', imports: [ Calendar, CalendarCaption, CalendarCell, CalendarDay, CalendarGrid, CalendarHeading, CalendarMonth, CalendarMonths, CalendarNav, CalendarNext, CalendarPrevious, CalendarWeek, CalendarWeekday, CalendarWeekdays, DatePickerTrigger, DatePipe, NdIcon, Popover, PopoverContent, PopoverPortal, PopoverTrigger, ], template: `
    @for (month of calendar.months(); track month.key) {
    {{ month.label }}
    @for ( weekday of calendar.weekdays(); track weekday.long ) { } @for (week of month.weeks; track week.key) { @for (day of week.days; track day.key) { } }
    {{ weekday.short }}
    }
    `, }) export class DatePickerRangeExample { protected readonly open = signal(false); protected readonly range = signal({ from: null, to: null }); } ``` ## Accessibility Date Picker has no keyboard interaction or focus handling of its own; it keeps the semantics of the element it's on. ## API reference ### DatePickerTrigger Selector: `button[ndDatePickerTrigger]` The button of a date picker: an outline button that shows the chosen date, or a muted placeholder while `empty`. Put it on the trigger of a Popover whose content holds a Calendar; opening focuses the chosen day. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[empty]` | `boolean` | `false` | Whether no date is chosen yet, which mutes the placeholder text. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `date-picker-trigger` | | `data-empty` | | ## Agent notes ### Do - Show the chosen date on the button, formatted with Angular's `DatePipe`, and a placeholder until then. - Name the popover's content with `aria-label`, such as "Choose a date". - Use `min`, `max` and `disabledDates` for dates that can't be chosen. ### Don't - Don't use it for dates people know by heart, such as a birth date; separate fields for day, month and year are quicker to type. - Don't hide the calendar's keyboard: keep it in a popover, not on hover. --- # Dialog > A window overlaid on the primary window, rendering the content underneath inert. Version 1.0.0 · WAI-ARIA pattern: [Dialog (Modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) · SSR · Zoneless · 3.2 kB gzip A dialog interrupts the page to ask for a decision or information, and blocks the rest of the page until it closes. It follows the WAI-ARIA modal dialog pattern: focus moves into the dialog and stays there, Escape and the close button close it, and focus returns to the trigger. Its content renders in an `ng-template` portal, so nothing is in the page until it opens. ## Installation ```bash npx @ndop/cli add dialog ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/dialog/dialog.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdDialog, NdDialogClose, NdDialogContent, NdDialogDescription, NdDialogOverlay, NdDialogPortal, NdDialogTitle, NdDialogTrigger, } from '@ndop/primitives/dialog'; import { cn } from '@/lib/utils'; /** * A modal dialog (`@ndop/primitives/dialog`): it renders its portal's * content over the page while open, traps focus inside it and returns it on * close, and closes on Escape or a backdrop click. Put it on an * `ng-container`; `open` is two-way bindable. * * @example * ```html * … * ``` */ @Directive({ selector: '[ndDialog]', hostDirectives: [ { directive: NdDialog, inputs: ['open', 'defaultOpen', 'closeOnEscape', 'closeOnOverlayClick'], outputs: ['openChange'], }, ], }) export class Dialog {} /** * The button that opens its dialog, with `aria-haspopup`, `aria-expanded` * and `aria-controls`. * * @example * ```html * * ``` */ @Directive({ selector: '[ndDialogTrigger]', hostDirectives: [NdDialogTrigger], host: { 'data-slot': 'dialog-trigger' }, }) export class DialogTrigger {} /** * Marks the `ng-template` its dialog renders while open. * * @example * ```html * … * ``` */ @Directive({ selector: 'ng-template[ndDialogPortal]', hostDirectives: [NdDialogPortal], }) export class DialogPortal {} /** * A button that closes its dialog. * * @example * ```html * * ``` */ @Directive({ selector: '[ndDialogClose]', hostDirectives: [NdDialogClose], host: { 'data-slot': 'dialog-close' }, }) export class DialogClose {} /** * Backdrop behind the dialog; a click on it closes the dialog. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndDialogOverlay]', hostDirectives: [NdDialogOverlay], host: { 'data-slot': 'dialog-overlay', '[class]': 'classes()' }, }) export class DialogOverlay { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0', this.class(), ), ); } /** * The dialog box, centered on screen. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndDialogContent]', hostDirectives: [NdDialogContent], host: { 'data-slot': 'dialog-content', '[class]': 'classes()' }, }) export class DialogContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg', this.class(), ), ); } /** * The icon button in the content's corner that closes the dialog. Put an * `` and a `sr-only` label inside it. * * @example * ```html * Close * * ``` */ @Directive({ selector: 'button[ndDialogCloseButton]', hostDirectives: [NdDialogClose], host: { type: 'button', 'data-slot': 'dialog-close', '[class]': 'classes()', }, }) export class DialogCloseButton { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "absolute top-4 end-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", this.class(), ), ); } /** * Layout for the title and description. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndDialogHeader]', host: { 'data-slot': 'dialog-header', '[class]': 'classes()' }, }) export class DialogHeader { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex flex-col gap-2 text-center sm:text-start', this.class()), ); } /** * Layout for the action buttons. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndDialogFooter]', host: { 'data-slot': 'dialog-footer', '[class]': 'classes()' }, }) export class DialogFooter { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', this.class()), ); } /** * The dialog's title; it names the dialog. * * @example * ```html *

    Edit profile

    * ``` */ @Directive({ selector: '[ndDialogTitle]', hostDirectives: [NdDialogTitle], host: { 'data-slot': 'dialog-title', '[class]': 'classes()' }, }) export class DialogTitle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('text-lg leading-none font-semibold', this.class()), ); } /** * The dialog's description; it describes the dialog. * * @example * ```html *

    Changes are saved when you close.

    * ``` */ @Directive({ selector: '[ndDialogDescription]', hostDirectives: [NdDialogDescription], host: { 'data-slot': 'dialog-description', '[class]': 'classes()' }, }) export class DialogDescription { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('text-sm text-muted-foreground', this.class()), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Button } from '@/ui/button/button'; import { DialogCloseButton, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogTitle, Dialog, DialogClose, DialogPortal, DialogTrigger, } from '@/ui/dialog/dialog'; import { Input } from '@/ui/input/input'; import { Label } from '@/ui/label/label'; ``` ```html

    Edit profile

    Make changes to your profile here. Click save when you're done.

    ``` ## Examples ### Dialog demo An edit-profile dialog with a Signal Forms form. Saving validates the form, closes the dialog when it passes, and keeps it open otherwise. ```ts import { Component, signal } from '@angular/core'; import { form, FormField, required, submit } from '@angular/forms/signals'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; import { DialogCloseButton, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogTitle, Dialog, DialogClose, DialogPortal, DialogTrigger, } from '@/ui/dialog/dialog'; import { Input } from '@/ui/input/input'; import { Label } from '@/ui/label/label'; interface Profile { name: string; username: string; } /** * An edit-profile dialog with a Signal Forms form. Saving validates the * form, closes the dialog when it passes, and keeps it open otherwise. */ @Component({ selector: 'example-dialog-demo', imports: [ Dialog, DialogTrigger, DialogPortal, DialogClose, DialogOverlay, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, DialogCloseButton, NdIcon, Button, Input, Label, FormField, ], template: `

    Edit profile

    Make changes to your profile here. Click save when you're done.

    `, }) export class DialogDemoExample { protected readonly open = signal(false); private readonly model = signal({ name: 'Ada Lovelace', username: '@ada', }); protected readonly profile = form(this.model, (path) => { required(path.name); required(path.username); }); /** * Validates and saves the profile, then closes the dialog. * * @param event - The form's submit event. */ protected async save(event: Event): Promise { event.preventDefault(); await submit(this.profile, async () => { this.open.set(false); return undefined; }); } } ``` ### Dialog close button A share dialog with a read-only link and a footer close button. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; import { DialogCloseButton, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogTitle, Dialog, DialogClose, DialogPortal, DialogTrigger, } from '@/ui/dialog/dialog'; import { Input } from '@/ui/input/input'; import { Label } from '@/ui/label/label'; /** A share dialog with a read-only link and a footer close button. */ @Component({ selector: 'example-dialog-close-button', imports: [ Dialog, DialogTrigger, DialogPortal, DialogClose, DialogOverlay, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, DialogCloseButton, NdIcon, Button, Input, Label, ], template: `

    Share link

    Anyone who has this link will be able to view this.

    `, }) export class DialogCloseButtonExample {} ``` ### Dialog unsaved changes A dialog that only closes through its buttons, so a click outside doesn't lose the user's changes. ```ts import { Component } from '@angular/core'; import { Button } from '@/ui/button/button'; import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogTitle, Dialog, DialogClose, DialogPortal, DialogTrigger, } from '@/ui/dialog/dialog'; import { Label } from '@/ui/label/label'; import { Textarea } from '@/ui/textarea/textarea'; /** * A dialog that a click outside doesn't close, so the user doesn't lose * what they typed by accident. Escape and the buttons still close it. */ @Component({ selector: 'example-dialog-unsaved-changes', imports: [ Button, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogTitle, Label, Dialog, DialogClose, DialogPortal, DialogTrigger, Textarea, ], template: `

    New note

    The note is kept until you save or discard it.

    `, }) export class DialogUnsavedChangesExample {} ``` ## Accessibility Implements the [Dialog (Modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Tab` | Moves focus to the next focusable element inside the dialog; from the last one, wraps to the first. | | `Shift + Tab` | Moves focus to the previous focusable element inside the dialog; from the first one, wraps to the last. | | `Escape` | Closes the dialog, unless `closeOnEscape` is `false`. | ### Focus - On open: focus moves to the first focusable element in the content, or to the content element itself when it has none. - While open: focus is trapped in the content. - On close: focus returns to the element that had it before opening, usually the trigger. ### Right-to-left Nothing mirrors: the dialog is centered, and Tab order follows the DOM. ## API reference ### Dialog Selector: `[ndDialog]` A modal dialog (`@ndop/primitives/dialog`): it renders its portal's content over the page while open, traps focus inside it and returns it on close, and closes on Escape or a backdrop click. Put it on an `ng-container`; `open` is two-way bindable. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[defaultOpen]` | `boolean` | `false` | Initial open state when `open` is not bound. | | `[closeOnEscape]` | `boolean` | `true` | Whether Escape closes the dialog. | | `[closeOnOverlayClick]` | `boolean` | `true` | Whether a click on `[ndDialogOverlay]` closes the dialog. | | `[(open)]` | `boolean` | `false` | Whether the dialog is open. Two-way bindable with `[(open)]`. | ### DialogTrigger Selector: `[ndDialogTrigger]` The button that opens its dialog, with `aria-haspopup`, `aria-expanded` and `aria-controls`. | Data attribute | Values | | --- | --- | | `data-slot` | `dialog-trigger` | | `data-state` | | ### DialogPortal Selector: `ng-template[ndDialogPortal]` Marks the `ng-template` its dialog renders while open. ### DialogClose Selector: `[ndDialogClose]` A button that closes its dialog. | Data attribute | Values | | --- | --- | | `data-slot` | `dialog-close` | ### DialogOverlay Selector: `[ndDialogOverlay]` Backdrop behind the dialog; a click on it closes the dialog. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `dialog-overlay` | | `data-state` | | ### DialogContent Selector: `[ndDialogContent]` · Role: `dialog` The dialog box, centered on screen. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `dialog-content` | | `data-state` | | ### DialogCloseButton Selector: `button[ndDialogCloseButton]` The icon button in the content's corner that closes the dialog. Put an `` and a `sr-only` label inside it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `dialog-close` | ### DialogHeader Selector: `[ndDialogHeader]` Layout for the title and description. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `dialog-header` | ### DialogFooter Selector: `[ndDialogFooter]` Layout for the action buttons. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `dialog-footer` | ### DialogTitle Selector: `[ndDialogTitle]` The dialog's title; it names the dialog. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `dialog-title` | ### DialogDescription Selector: `[ndDialogDescription]` The dialog's description; it describes the dialog. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `dialog-description` | ## Agent notes ### Do - Give every dialog an `ndDialogTitle`; it names the dialog for screen readers. - Add an `ndDialogDescription` when the title alone doesn't explain what is being asked. - Keep one task per dialog, with the main action in the footer. - Bind `[(open)]` when the dialog's state matters to the page, for example to close it after a successful save. ### Don't - Don't open a dialog from another dialog; replace its content or use a page instead. - Don't put long or scrolling forms in a dialog; send people to a page. - Don't use a dialog for messages that don't need an answer; show them inline. --- # Drawer > A panel that slides up from the bottom of the screen and can be dragged away, suited to phones. Version 1.0.0 · WAI-ARIA pattern: [Dialog (Modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) · SSR · Zoneless · 4.1 kB gzip A drawer is a panel that slides up from the bottom of the screen, the way phone apps show options, and can be dragged back down to close. It's a modal dialog underneath, so it's labelled by its title, traps focus while open, and closes with Escape, its backdrop or a close button too. Dragging past a quarter of its height, or flicking it, closes it; a shorter drag springs back. Reduced-motion users get no animation. ## Installation ```bash npx @ndop/cli add drawer ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/drawer/drawer.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdDialog } from '@ndop/primitives/dialog'; import { NdDrawerClose, NdDrawerContent, NdDrawerDescription, NdDrawerHandle, NdDrawerOverlay, NdDrawerPortal, NdDrawerTitle, NdDrawerTrigger, } from '@ndop/primitives/drawer'; import { cn } from '@/lib/utils'; /** * A drawer (`@ndop/primitives/drawer`): a modal panel that slides in from * an edge and can be dragged back out. Put it on an `ng-container`; `open` * is two-way bindable, and the content's `direction` sets the edge. * * @example * ```html * … * ``` */ @Directive({ selector: '[ndDrawer]', hostDirectives: [ { directive: NdDialog, inputs: ['open', 'defaultOpen', 'closeOnEscape', 'closeOnOverlayClick'], outputs: ['openChange'], }, ], }) export class Drawer {} /** * The button that opens its drawer. * * @example * ```html * * ``` */ @Directive({ selector: '[ndDrawerTrigger]', hostDirectives: [NdDrawerTrigger], host: { 'data-slot': 'drawer-trigger' }, }) export class DrawerTrigger {} /** * Marks the `ng-template` its drawer renders while open. * * @example * ```html * … * ``` */ @Directive({ selector: 'ng-template[ndDrawerPortal]', hostDirectives: [NdDrawerPortal], }) export class DrawerPortal {} /** * A button that closes its drawer. * * @example * ```html * * ``` */ @Directive({ selector: '[ndDrawerClose]', hostDirectives: [NdDrawerClose], host: { 'data-slot': 'drawer-close' }, }) export class DrawerClose {} /** * The dimmed backdrop behind a drawer; clicking it closes the drawer. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndDrawerOverlay]', hostDirectives: [NdDrawerOverlay], host: { 'data-slot': 'drawer-overlay', '[class]': 'classes()' }, }) export class DrawerOverlay { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0', this.class(), ), ); } /** * The drawer panel (`@ndop/primitives/drawer`): a modal dialog at the edge * its `direction` names (`bottom` by default, `top`, `start` or `end`), * which follows a drag toward that edge and closes past a quarter of its * size. Motion stops for reduced-motion * users; the drag still works. * * @example * ```html *
    *
    *

    Move goal

    *
    * ``` */ @Directive({ selector: '[ndDrawerContent]', hostDirectives: [{ directive: NdDrawerContent, inputs: ['direction'] }], host: { 'data-slot': 'drawer-content', '[class]': 'classes()' }, }) export class DrawerContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/drawer-content fixed z-50 flex h-auto flex-col bg-background', 'data-[direction=top]:inset-x-0 data-[direction=top]:top-0 data-[direction=top]:mb-24 data-[direction=top]:max-h-[80vh] data-[direction=top]:rounded-b-lg data-[direction=top]:border-b', 'data-[direction=bottom]:inset-x-0 data-[direction=bottom]:bottom-0 data-[direction=bottom]:mt-24 data-[direction=bottom]:max-h-[80vh] data-[direction=bottom]:rounded-t-lg data-[direction=bottom]:border-t', 'data-[direction=end]:inset-y-0 data-[direction=end]:end-0 data-[direction=end]:w-3/4 data-[direction=end]:border-s data-[direction=end]:sm:max-w-sm', 'data-[direction=start]:inset-y-0 data-[direction=start]:start-0 data-[direction=start]:w-3/4 data-[direction=start]:border-e data-[direction=start]:sm:max-w-sm', // Follows the drag, and springs back when released short of closing. 'transition-[translate] duration-300 ease-out data-dragging:transition-none motion-reduce:transition-none', 'data-[direction=bottom]:translate-y-(--nd-drawer-drag) data-[direction=top]:-translate-y-(--nd-drawer-drag)', 'data-[direction=end]:translate-x-(--nd-drawer-drag) rtl:data-[direction=end]:-translate-x-(--nd-drawer-drag)', 'data-[direction=start]:-translate-x-(--nd-drawer-drag) rtl:data-[direction=start]:translate-x-(--nd-drawer-drag)', 'data-[state=open]:animate-in data-[state=open]:duration-500 motion-reduce:animate-none data-[direction=bottom]:slide-in-from-bottom data-[direction=top]:slide-in-from-top data-[direction=end]:slide-in-from-end data-[direction=start]:slide-in-from-start', this.class(), ), ); } /** * The grip at the top of a bottom drawer, showing it can be dragged; * hidden from assistive technology, and on other edges. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndDrawerHandle]', hostDirectives: [NdDrawerHandle], host: { 'data-slot': 'drawer-handle', '[class]': 'classes()' }, }) export class DrawerHandle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-muted group-data-[direction=bottom]/drawer-content:block', this.class(), ), ); } /** * Lays out a drawer's title and description, centered on top and bottom * drawers on small screens. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndDrawerHeader]', host: { 'data-slot': 'drawer-header', '[class]': 'classes()' }, }) export class DrawerHeader { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex flex-col gap-0.5 p-4 group-data-[direction=bottom]/drawer-content:text-center group-data-[direction=top]/drawer-content:text-center md:gap-1.5 md:text-start', this.class(), ), ); } /** * Lays out a drawer's buttons at its bottom. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndDrawerFooter]', host: { 'data-slot': 'drawer-footer', '[class]': 'classes()' }, }) export class DrawerFooter { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('mt-auto flex flex-col gap-2 p-4', this.class()), ); } /** * A drawer's title, which labels it. * * @example * ```html *

    Move goal

    * ``` */ @Directive({ selector: '[ndDrawerTitle]', hostDirectives: [NdDrawerTitle], host: { 'data-slot': 'drawer-title', '[class]': 'classes()' }, }) export class DrawerTitle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('font-semibold text-foreground', this.class()), ); } /** * A drawer's description. * * @example * ```html *

    Set your daily activity goal.

    * ``` */ @Directive({ selector: '[ndDrawerDescription]', hostDirectives: [NdDrawerDescription], host: { 'data-slot': 'drawer-description', '[class]': 'classes()' }, }) export class DrawerDescription { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('text-sm text-muted-foreground', this.class()), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Button } from '@/ui/button/button'; import { DrawerContent, DrawerDescription, DrawerFooter, DrawerHandle, DrawerHeader, DrawerOverlay, DrawerTitle, Drawer, DrawerClose, DrawerPortal, DrawerTrigger, } from '@/ui/drawer/drawer'; ``` ```html

    Move goal

    Set your daily activity goal.

    {{ goal() }} Calories a day

    ``` ## Examples ### Drawer demo A daily goal set with buttons in a bottom drawer, with a handle to drag it away. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; import { DrawerContent, DrawerDescription, DrawerFooter, DrawerHandle, DrawerHeader, DrawerOverlay, DrawerTitle, Drawer, DrawerClose, DrawerPortal, DrawerTrigger, } from '@/ui/drawer/drawer'; /** * A daily goal set with buttons in a bottom drawer, with a handle to drag * it away. The goal is announced as it changes. */ @Component({ selector: 'example-drawer-demo', imports: [ Button, DrawerContent, DrawerDescription, DrawerFooter, DrawerHandle, DrawerHeader, DrawerOverlay, DrawerTitle, Drawer, DrawerClose, DrawerPortal, DrawerTrigger, NdIcon, ], template: `

    Move goal

    Set your daily activity goal.

    {{ goal() }} Calories a day

    `, }) export class DrawerDemoExample { protected readonly goal = signal(350); /** * Moves the goal by a step, between 200 and 400. * * @param step - How much to add. */ protected change(step: number): void { this.goal.update((goal) => Math.min(400, Math.max(200, goal + step))); } } ``` ### Drawer directions Drawers from the end side and the top. ```ts import { Component } from '@angular/core'; import { Button } from '@/ui/button/button'; import { DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerOverlay, DrawerTitle, Drawer, DrawerClose, DrawerPortal, DrawerTrigger, } from '@/ui/drawer/drawer'; /** * Drawers from the end side and the top, each dragged back toward its * own edge. */ @Component({ selector: 'example-drawer-directions', imports: [ Button, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerOverlay, DrawerTitle, Drawer, DrawerClose, DrawerPortal, DrawerTrigger, ], template: `
    @for (direction of directions; track direction) {

    {{ direction }} drawer

    Drag it toward its edge to close it.

    }
    `, }) export class DrawerDirectionsExample { protected readonly directions = ['end', 'top'] as const; } ``` ## Accessibility Implements the [Dialog (Modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Tab` | Moves focus to the next focusable element inside the dialog; from the last one, wraps to the first. | | `Shift + Tab` | Moves focus to the previous focusable element inside the dialog; from the first one, wraps to the last. | | `Escape` | Closes the dialog, unless `closeOnEscape` is `false`. | | `Tab` | Moves focus to the next focusable element inside; wraps from last to first. | | `Shift + Tab` | Moves focus to the previous focusable element inside; wraps from first to last. | | `Escape` | Closes the drawer, unless `closeOnEscape` is `false`. | ### Focus - On open: focus moves to the first focusable element in the content, or to the content element itself when it has none. - While open: focus is trapped in the content. - On close: focus returns to the element that had it before opening, usually the trigger. ### Right-to-left Nothing mirrors: the dialog is centered, and Tab order follows the DOM. `start` and `end` drawers come from the right and left in right-to-left layouts, and are dragged toward those edges. Direction comes from the nearest `dir` attribute. ## API reference ### Drawer Selector: `[ndDrawer]` A drawer (`@ndop/primitives/drawer`): a modal panel that slides in from an edge and can be dragged back out. Put it on an `ng-container`; `open` is two-way bindable, and the content's `direction` sets the edge. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[defaultOpen]` | `boolean` | `false` | Initial open state when `open` is not bound. | | `[closeOnEscape]` | `boolean` | `true` | Whether Escape closes the dialog. | | `[closeOnOverlayClick]` | `boolean` | `true` | Whether a click on `[ndDialogOverlay]` closes the dialog. | | `[(open)]` | `boolean` | `false` | Whether the dialog is open. Two-way bindable with `[(open)]`. | ### DrawerTrigger Selector: `[ndDrawerTrigger]` The button that opens its drawer. | Data attribute | Values | | --- | --- | | `data-slot` | `drawer-trigger` | | `data-state` | | ### DrawerPortal Selector: `ng-template[ndDrawerPortal]` Marks the `ng-template` its drawer renders while open. ### DrawerClose Selector: `[ndDrawerClose]` A button that closes its drawer. | Data attribute | Values | | --- | --- | | `data-slot` | `drawer-close` | ### DrawerOverlay Selector: `[ndDrawerOverlay]` The dimmed backdrop behind a drawer; clicking it closes the drawer. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `drawer-overlay` | | `data-state` | | ### DrawerContent Selector: `[ndDrawerContent]` · Role: `dialog` The drawer panel (`@ndop/primitives/drawer`): a modal dialog at the edge its `direction` names (`bottom` by default, `top`, `start` or `end`), which follows a drag toward that edge and closes past a quarter of its size. Motion stops for reduced-motion users; the drag still works. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[direction]` | `'bottom' \| 'top' \| 'start' \| 'end'` | `'bottom'` | The edge it slides in from and is dragged back to. | | Data attribute | Values | | --- | --- | | `data-slot` | `drawer-content` | | `data-state` | | ### DrawerHandle Selector: `[ndDrawerHandle]` The grip at the top of a bottom drawer, showing it can be dragged; hidden from assistive technology, and on other edges. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `drawer-handle` | ### DrawerHeader Selector: `[ndDrawerHeader]` Lays out a drawer's title and description, centered on top and bottom drawers on small screens. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `drawer-header` | ### DrawerFooter Selector: `[ndDrawerFooter]` Lays out a drawer's buttons at its bottom. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `drawer-footer` | ### DrawerTitle Selector: `[ndDrawerTitle]` A drawer's title, which labels it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `drawer-title` | ### DrawerDescription Selector: `[ndDrawerDescription]` A drawer's description. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `drawer-description` | ## Agent notes ### Do - Use it on small screens, and a Dialog or Sheet on large ones. - Keep a close or done button in it: dragging is a shortcut, not the only way out. - Show the handle on bottom drawers, so people know they can drag. ### Don't - Don't put content that scrolls sideways or sliders in it without `data-no-drag`, or dragging them would move the drawer. - Don't use it for content people read for long; use a page. --- # Dropdown Menu > A menu of actions or options that a button opens, such as a row's actions in a table. Version 1.0.0 · WAI-ARIA pattern: [Menu Button](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/) · SSR · Zoneless · 3.5 kB gzip A dropdown menu lists actions or options under a button, such as a row's actions in a table or an account menu. It's a menu button: Enter, Space or the arrow keys open it on the first or last item, the arrow keys and typing move between items, and Escape closes it and returns to the button. Items can be checkboxes, radios or open submenus, and can be disabled while staying reachable. ## Installation ```bash npx @ndop/cli add dropdown-menu ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/dropdown-menu/dropdown-menu.ts ```ts import { booleanAttribute, computed, Directive, input } from '@angular/core'; import { NdDropdownMenuTrigger } from '@ndop/primitives/dropdown-menu'; import { NdMenuContent, NdMenuGroup, NdMenuItem, NdMenuItemCheckbox, NdMenuItemIndicator, NdMenuItemRadio, NdMenuLabel, NdMenuRadioGroup, NdMenuSeparator, NdMenuSubTrigger, } from '@ndop/primitives/menu'; import { cn } from '@/lib/utils'; /** * The button that opens a dropdown menu (`@ndop/primitives/dropdown-menu`): * its value is the menu's `ng-template`. Enter, Space and the arrow keys * open it on an item; closing returns focus here. * * @example * ```html * * ``` */ @Directive({ selector: '[ndDropdownMenuTrigger]', hostDirectives: [ { directive: NdDropdownMenuTrigger, inputs: [ 'ndDropdownMenuTrigger', 'open', 'defaultOpen', 'side', 'align', 'sideOffset', ], outputs: ['openChange'], }, ], host: { 'data-slot': 'dropdown-menu-trigger' }, }) export class DropdownMenuTrigger {} /** * A dropdown menu's list (`@ndop/primitives/menu`), in the `ng-template` a * `[ndDropdownMenuTrigger]` button opens. Arrow keys, Home, End and typing * move between items; Escape closes it and returns focus to the button. * * @example * ```html *
    Profile
    * ``` */ @Directive({ selector: '[ndDropdownMenuContent]', hostDirectives: [{ directive: NdMenuContent, outputs: ['closed'] }], host: { 'data-slot': 'dropdown-menu-content', '[class]': 'classes()' }, }) export class DropdownMenuContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'z-50 max-h-[80vh] min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=start]:slide-in-from-end-2 data-[side=end]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', this.class(), ), ); } /** * A submenu, opened beside its sub-trigger: at the end side, so on the left * in right-to-left layouts. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndDropdownMenuSubContent]', hostDirectives: [{ directive: NdMenuContent, outputs: ['closed'] }], host: { 'data-slot': 'dropdown-menu-sub-content', '[class]': 'classes()' }, }) export class DropdownMenuSubContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=start]:slide-in-from-end-2 data-[side=end]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', this.class(), ), ); } /** * Groups related items (`role="group"`). Name it with `aria-labelledby` * pointing at a label. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndDropdownMenuGroup]', hostDirectives: [NdMenuGroup], host: { 'data-slot': 'dropdown-menu-group' }, }) export class DropdownMenuGroup {} /** * One action (`role="menuitem"`): activating it emits `triggered` and * closes the menu. Disabled items stay focusable and do nothing. Use a * `div`: on a `button`, Space would close the menu. * * @example * ```html *
    Edit
    * ``` */ @Directive({ selector: '[ndDropdownMenuItem]', hostDirectives: [ { directive: NdMenuItem, inputs: ['disabled'], outputs: ['triggered'] }, ], host: { 'data-slot': 'dropdown-menu-item', '[attr.data-inset]': 'inset() ? "" : null', '[attr.data-variant]': 'variant()', '[class]': 'classes()', }, }) export class DropdownMenuItem { /** Indents it to line up with items that have an indicator. */ readonly inset = input(false, { transform: booleanAttribute }); /** `destructive` colors a dangerous action, such as Delete. */ readonly variant = input<'default' | 'destructive'>('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:ps-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", this.class(), ), ); } /** * An item that is on or off (`role="menuitemcheckbox"`), bound with * `[(checked)]`. Put an indicator with a check icon in it. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndDropdownMenuCheckboxItem]', hostDirectives: [ { directive: NdMenuItemCheckbox, inputs: ['checked', 'disabled'], outputs: ['checkedChange', 'triggered'], }, ], host: { 'data-slot': 'dropdown-menu-checkbox-item', '[class]': 'classes()' }, }) export class DropdownMenuCheckboxItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pe-2 ps-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", this.class(), ), ); } /** * Groups radio items (`role="group"`) and holds the checked value, bound * with `[(value)]`. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndDropdownMenuRadioGroup]', hostDirectives: [ { directive: NdMenuRadioGroup, inputs: ['value'], outputs: ['valueChange'], }, ], host: { 'data-slot': 'dropdown-menu-radio-group' }, }) export class DropdownMenuRadioGroup {} /** * One choice of a radio group (`role="menuitemradio"`). Put an indicator * with a small filled circle in it. * * @example * ```html *
    Top
    * ``` */ @Directive({ selector: '[ndDropdownMenuRadioItem]', hostDirectives: [ { directive: NdMenuItemRadio, inputs: ['value', 'disabled'], outputs: ['triggered'], }, ], host: { 'data-slot': 'dropdown-menu-radio-item', '[class]': 'classes()' }, }) export class DropdownMenuRadioItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pe-2 ps-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", this.class(), ), ); } /** * The check or dot at the start of a checkbox or radio item, shown while it * is checked. * * @example * ```html * * ``` */ @Directive({ selector: '[ndDropdownMenuItemIndicator]', hostDirectives: [NdMenuItemIndicator], host: { 'data-slot': 'dropdown-menu-item-indicator', '[class]': 'classes()' }, }) export class DropdownMenuItemIndicator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'pointer-events-none absolute start-2 flex size-3.5 items-center justify-center', this.class(), ), ); } /** * A heading for a group of items; point the group's `aria-labelledby` at * it. * * @example * ```html *
    My account
    * ``` */ @Directive({ selector: '[ndDropdownMenuLabel]', hostDirectives: [NdMenuLabel], host: { 'data-slot': 'dropdown-menu-label', '[attr.data-inset]': 'inset() ? "" : null', '[class]': 'classes()', }, }) export class DropdownMenuLabel { /** Indents it to line up with items that have an indicator. */ readonly inset = input(false, { transform: booleanAttribute }); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('px-2 py-1.5 text-sm font-medium data-[inset]:ps-8', this.class()), ); } /** * A line between groups of items (`role="separator"`). * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndDropdownMenuSeparator]', hostDirectives: [NdMenuSeparator], host: { 'data-slot': 'dropdown-menu-separator', '[class]': 'classes()' }, }) export class DropdownMenuSeparator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('-mx-1 my-1 h-px bg-border', this.class()), ); } /** * The keyboard shortcut of an item, at its end, as a reminder; the menu * doesn't handle it. * * @example * ```html * ⌘S * ``` */ @Directive({ selector: '[ndDropdownMenuShortcut]', host: { 'data-slot': 'dropdown-menu-shortcut', '[class]': 'classes()' }, }) export class DropdownMenuShortcut { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('ms-auto text-xs tracking-widest text-muted-foreground', this.class()), ); } /** * An item that opens the submenu in its template. Put a chevron icon at its * end with `ms-auto`, turned around in right-to-left layouts. * * @example * ```html *
    More tools
    * ``` */ @Directive({ selector: '[ndDropdownMenuSubTrigger]', hostDirectives: [ { directive: NdMenuSubTrigger, inputs: ['ndMenuSubTrigger: ndDropdownMenuSubTrigger', 'disabled'], }, ], host: { 'data-slot': 'dropdown-menu-sub-trigger', '[attr.data-inset]': 'inset() ? "" : null', '[class]': 'classes()', }, }) export class DropdownMenuSubTrigger { /** Indents it to line up with items that have an indicator. */ readonly inset = input(false, { transform: booleanAttribute }); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:ps-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Button } from '@/ui/button/button'; import { DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/ui/dropdown-menu/dropdown-menu'; ``` ```html

    {{ chosen() }}

    Profile ⇧⌘P
    Billing ⌘B
    Settings ⌘S
    Team
    Invite users
    API (coming soon)
    Log out ⇧⌘Q
    Email
    Message
    ``` ## Examples ### Dropdown Menu demo An account menu with groups, shortcuts, a submenu, a disabled item and a destructive action. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; import { DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/ui/dropdown-menu/dropdown-menu'; /** * An account menu: labelled groups, shortcuts, a submenu, a disabled item * and a destructive action, reporting what was chosen. */ @Component({ selector: 'example-dropdown-menu-demo', imports: [ Button, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, NdIcon, ], template: `

    {{ chosen() }}

    Profile ⇧⌘P
    Billing ⌘B
    Settings ⌘S
    Team
    Invite users
    API (coming soon)
    Log out ⇧⌘Q
    Email
    Message
    `, }) export class DropdownMenuDemoExample { protected readonly chosen = signal(''); /** * Reports what was chosen. * * @param action - The item's name. */ protected choose(action: string): void { this.chosen.set(`Chose ${action}.`); } } ``` ### Dropdown Menu checkboxes Checkbox items that show or hide panels, keeping the menu open on Space. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; import { DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItemIndicator, DropdownMenuLabel, DropdownMenuTrigger, } from '@/ui/dropdown-menu/dropdown-menu'; /** * Checkbox items that show or hide panels. Space toggles one and keeps the * menu open; Enter or a click toggles it and closes the menu. */ @Component({ selector: 'example-dropdown-menu-checkboxes', imports: [ Button, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItemIndicator, DropdownMenuLabel, DropdownMenuTrigger, NdIcon, ], template: `
    Status bar
    Activity bar
    Panel
    `, }) export class DropdownMenuCheckboxesExample { protected readonly statusBar = signal(true); protected readonly activityBar = signal(false); protected readonly panel = signal(false); } ``` ### Dropdown Menu radio group Radio items that choose one panel position. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; import { DropdownMenuContent, DropdownMenuItemIndicator, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/ui/dropdown-menu/dropdown-menu'; /** Radio items that choose one panel position. */ @Component({ selector: 'example-dropdown-menu-radio-group', imports: [ Button, DropdownMenuContent, DropdownMenuItemIndicator, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, NdIcon, ], template: `
    @for (option of options; track option) {
    {{ option }}
    }
    `, }) export class DropdownMenuRadioGroupExample { protected readonly options = ['top', 'bottom', 'end']; protected readonly position = signal('bottom'); } ``` ## Accessibility Implements the [Menu Button](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Enter` or `Space` | Opens the menu and focuses its first item. | | `ArrowDown` | Opens the menu and focuses its first item. | | `ArrowUp` | Opens the menu and focuses its last item. | | `ArrowDown` or `ArrowUp` | Moves focus to the next or previous item, wrapping. | | `Home` or `End` | Moves focus to the first or last item. | | `Enter` | Activates the focused item and closes the menu. | | `Space` | Activates the focused item; checkbox and radio items keep the menu open. | | `ArrowRight` | On a sub-trigger, opens its menu and focuses the first item. | | `ArrowLeft` | In a submenu, closes it and focuses its sub-trigger. | | `Escape` | Closes the current menu and returns focus to what opened it. | | `Tab` | Closes every open menu and moves focus on from the trigger. | | `a` | Typeahead: moves focus to the next item whose label starts with the typed letters. | ### Focus - Closing the menu returns focus to the trigger. - Opened from the keyboard, a menu focuses its first item (its last with `ArrowUp` on a Dropdown Menu trigger); opened with the pointer, the menu itself. - Closing returns focus to the trigger or sub-trigger. ### Right-to-left `start` and `end` sides are logical and swap in right-to-left layouts. `ArrowLeft` opens submenus and `ArrowRight` closes them, and submenus open on the left. Direction comes from the app's `Directionality`: the document's `dir`, or a CDK `[dir]` directive. ## API reference ### DropdownMenuTrigger Selector: `[ndDropdownMenuTrigger]` The button that opens a dropdown menu (`@ndop/primitives/dropdown-menu`): its value is the menu's `ng-template`. Enter, Space and the arrow keys open it on an item; closing returns focus here. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[ndDropdownMenuTrigger]` | `TemplateRef \| null` | `null` | The `ng-template` of the menu it opens. | | `[defaultOpen]` | `boolean` | `false` | Initial open state when `open` isn't bound, applied after hydration. | | `[side]` | `'bottom' \| 'top' \| 'start' \| 'end'` | `'bottom'` | Side of the trigger the menu opens on, before flipping to fit. | | `[align]` | `'start' \| 'end' \| 'center'` | `'start'` | Alignment of the menu along that side. | | `[sideOffset]` | `number` | `4` | Gap between the trigger and the menu, in pixels. | | `[(open)]` | `boolean` | `false` | Whether the menu is open. Two-way bindable with `[(open)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `dropdown-menu-trigger` | | `data-state` | | ### DropdownMenuContent Selector: `[ndDropdownMenuContent]` · Role: `menu` A dropdown menu's list (`@ndop/primitives/menu`), in the `ng-template` a `[ndDropdownMenuTrigger]` button opens. Arrow keys, Home, End and typing move between items; Escape closes it and returns focus to the button. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `dropdown-menu-content` | | `data-state` | | | `data-side` | | ### DropdownMenuSubContent Selector: `[ndDropdownMenuSubContent]` · Role: `menu` A submenu, opened beside its sub-trigger: at the end side, so on the left in right-to-left layouts. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `dropdown-menu-sub-content` | | `data-state` | | | `data-side` | | ### DropdownMenuGroup Selector: `[ndDropdownMenuGroup]` · Role: `group` Groups related items (`role="group"`). Name it with `aria-labelledby` pointing at a label. | Data attribute | Values | | --- | --- | | `data-slot` | `dropdown-menu-group` | ### DropdownMenuItem Selector: `[ndDropdownMenuItem]` · Role: `menuitem` One action (`role="menuitem"`): activating it emits `triggered` and closes the menu. Disabled items stay focusable and do nothing. Use a `div`: on a `button`, Space would close the menu. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[inset]` | `boolean` | `false` | Indents it to line up with items that have an indicator. | | `[variant]` | `'default' \| 'destructive'` | `'default'` | `destructive` colors a dangerous action, such as Delete. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[disabled]` | `boolean` | `false` | Makes it inert but focusable, with `aria-disabled`. | | Data attribute | Values | | --- | --- | | `data-slot` | `dropdown-menu-item` | | `data-inset` | | | `data-variant` | | | `data-disabled` | | ### DropdownMenuCheckboxItem Selector: `[ndDropdownMenuCheckboxItem]` · Role: `menuitemcheckbox` An item that is on or off (`role="menuitemcheckbox"`), bound with `[(checked)]`. Put an indicator with a check icon in it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[disabled]` | `boolean` | `false` | Makes it inert but focusable, with `aria-disabled`. | | `[(checked)]` | `boolean` | `false` | Whether it's on. Two-way bindable with `[(checked)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `dropdown-menu-checkbox-item` | | `data-state` | | | `data-disabled` | | ### DropdownMenuRadioGroup Selector: `[ndDropdownMenuRadioGroup]` · Role: `group` Groups radio items (`role="group"`) and holds the checked value, bound with `[(value)]`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[(value)]` | `string \| null` | `null` | The checked radio item's value, or `null`. Two-way bindable with `[(value)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `dropdown-menu-radio-group` | ### DropdownMenuRadioItem Selector: `[ndDropdownMenuRadioItem]` · Role: `menuitemradio` One choice of a radio group (`role="menuitemradio"`). Put an indicator with a small filled circle in it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[value]` (required) | `string` | | The value its group takes when it's checked. | | `[disabled]` | `boolean` | `false` | Makes it inert but focusable, with `aria-disabled`. | | Data attribute | Values | | --- | --- | | `data-slot` | `dropdown-menu-radio-item` | | `data-state` | | | `data-disabled` | | ### DropdownMenuItemIndicator Selector: `[ndDropdownMenuItemIndicator]` The check or dot at the start of a checkbox or radio item, shown while it is checked. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `dropdown-menu-item-indicator` | | `data-state` | | ### DropdownMenuLabel Selector: `[ndDropdownMenuLabel]` A heading for a group of items; point the group's `aria-labelledby` at it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[inset]` | `boolean` | `false` | Indents it to line up with items that have an indicator. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `dropdown-menu-label` | | `data-inset` | | ### DropdownMenuSeparator Selector: `[ndDropdownMenuSeparator]` · Role: `separator` A line between groups of items (`role="separator"`). | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `dropdown-menu-separator` | ### DropdownMenuShortcut Selector: `[ndDropdownMenuShortcut]` The keyboard shortcut of an item, at its end, as a reminder; the menu doesn't handle it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `dropdown-menu-shortcut` | ### DropdownMenuSubTrigger Selector: `[ndDropdownMenuSubTrigger]` · Role: `menuitem` An item that opens the submenu in its template. Put a chevron icon at its end with `ms-auto`, turned around in right-to-left layouts. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[inset]` | `boolean` | `false` | Indents it to line up with items that have an indicator. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[ndDropdownMenuSubTrigger]` | `TemplateRef \| null` | `null` | The `ng-template` of the submenu. | | `[disabled]` | `boolean` | `false` | Makes it inert but focusable, with `aria-disabled`. | | Data attribute | Values | | --- | --- | | `data-slot` | `dropdown-menu-sub-trigger` | | `data-inset` | | | `data-state` | | | `data-disabled` | | ## Agent notes ### Do - Name the button after what the menu is for, such as "Actions" or an icon with an `aria-label`. - Group related items, with labels and separators. - Mark dangerous actions with `variant="destructive"`. ### Don't - Don't put links to pages in it; use Navigation Menu. - Don't use it for choosing one value in a form; use a Select. --- # Empty State > Shows that there is nothing here yet, and what to do about it. Version 1.0.0 · SSR · Zoneless · 0.8 kB gzip An empty state fills the space where content would be when there's none yet: no projects, no results, no messages. It says what's missing and what to do about it, with an optional icon or image and actions. ## Installation ```bash npx @ndop/cli add empty ``` Or by hand: install `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/empty/empty.ts ```ts import { computed, Directive, input } from '@angular/core'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * An empty state: shown where content would be, when there's none yet, * with what to do about it. * * @example * ```html *
    *
    *
    *

    No messages

    *

    New messages show up here.

    *
    *
    *
    * ``` */ @Directive({ selector: '[ndEmpty]', host: { 'data-slot': 'empty', '[class]': 'classes()' }, }) export class Empty { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( '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', this.class(), ), ); } /** * An empty state's header: its media, title and description. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndEmptyHeader]', host: { 'data-slot': 'empty-header', '[class]': 'classes()' }, }) export class EmptyHeader { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex max-w-sm flex-col items-center gap-2 text-center', this.class()), ); } /** * Class table for {@link EmptyMedia}. */ export const emptyMediaVariants = variants({ base: 'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0', variants: { variant: { default: 'bg-transparent', icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6", }, }, defaults: { variant: 'default' }, }); /** Visual style of an {@link EmptyMedia}. */ export type EmptyMediaVariant = VariantOf; /** * An image or icon above an empty state's title; `variant="icon"` puts an * icon on a muted tile. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndEmptyMedia]', host: { 'data-slot': 'empty-icon', '[attr.data-variant]': 'variant()', '[class]': 'classes()', }, }) export class EmptyMedia { /** Visual style. */ readonly variant = input('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn(emptyMediaVariants({ variant: this.variant() }), this.class()), ); } /** * An empty state's title. Put it on the heading element that fits the * page's outline. * * @example * ```html *

    No messages

    * ``` */ @Directive({ selector: '[ndEmptyTitle]', host: { 'data-slot': 'empty-title', '[class]': 'classes()' }, }) export class EmptyTitle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('text-lg font-medium tracking-tight', this.class()), ); } /** * What an empty state means, and what to do; links in it are underlined. * * @example * ```html *

    New messages show up here.

    * ``` */ @Directive({ selector: '[ndEmptyDescription]', host: { 'data-slot': 'empty-description', '[class]': 'classes()' }, }) export class EmptyDescription { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary', this.class(), ), ); } /** * An empty state's actions, under its header. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndEmptyContent]', host: { 'data-slot': 'empty-content', '[class]': 'classes()' }, }) export class EmptyContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Button } from '@/ui/button/button'; import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '@/ui/empty/empty'; ``` ```html

    No projects yet

    You haven't created a project yet. Start by creating your first one.

    Read about projects
    ``` ## Examples ### Empty State demo No projects yet, with actions to create one. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '@/ui/empty/empty'; /** No projects yet, with actions to create one. */ @Component({ selector: 'example-empty-demo', imports: [ Button, Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, NdIcon, ], template: `

    No projects yet

    You haven't created a project yet. Start by creating your first one.

    Read about projects
    `, }) export class EmptyDemoExample {} ``` ### Empty State search No search results, with a way to clear the search. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '@/ui/empty/empty'; /** * No search results, which says what was searched and offers to clear * the search. */ @Component({ selector: 'example-empty-search', imports: [ Button, Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, NdIcon, ], template: `

    No results

    Nothing matches “{{ query() }}”. Check the spelling or try other words.

    `, }) export class EmptySearchExample { protected readonly query = signal('quarterly report 2019'); } ``` ### Empty State avatar An empty state with an avatar as its media. ```ts import { Component } from '@angular/core'; import { Avatar, AvatarFallback } from '@/ui/avatar/avatar'; import { Button } from '@/ui/button/button'; import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '@/ui/empty/empty'; /** An empty state with an avatar as its media, in the default style. */ @Component({ selector: 'example-empty-avatar', imports: [ Avatar, AvatarFallback, Button, Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, ], template: `
    LR

    Lena is away

    She'll reply to messages when she's back on Monday.

    `, }) export class EmptyAvatarExample {} ``` ## Accessibility Empty State has no keyboard interaction or focus handling of its own; it keeps the semantics of the element it's on. ## API reference ### Empty Selector: `[ndEmpty]` An empty state: shown where content would be, when there's none yet, with what to do about it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `empty` | ### EmptyHeader Selector: `[ndEmptyHeader]` An empty state's header: its media, title and description. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `empty-header` | ### EmptyMedia Selector: `[ndEmptyMedia]` An image or icon above an empty state's title; `variant="icon"` puts an icon on a muted tile. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[variant]` | `'default' \| 'icon'` | `'default'` | Visual style. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `empty-icon` | | `data-variant` | | ### EmptyTitle Selector: `[ndEmptyTitle]` An empty state's title. Put it on the heading element that fits the page's outline. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `empty-title` | ### EmptyDescription Selector: `[ndEmptyDescription]` What an empty state means, and what to do; links in it are underlined. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `empty-description` | ### EmptyContent Selector: `[ndEmptyContent]` An empty state's actions, under its header. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `empty-content` | ## Agent notes ### Do - Say what would be here, and how to get some: "No projects yet. Create one to get started." - Give it one main action, such as a button that creates the first item. - Tell search and filter results apart from a truly empty list, and offer to clear the filters. ### Don't - Don't leave a blank area or a table with only headers instead. - Don't use it for errors; use an alert, which says what went wrong. --- # Form Field > Lays out a form control with its label, description and error, and links them for assistive technology. Version 1.0.0 · WAI-ARIA pattern: [`aria-describedby`](https://www.w3.org/TR/wai-aria-1.2/#aria-describedby) · SSR · Zoneless · 2.9 kB gzip A field lays out one form control with its label, description and error, and links them for screen readers: the control's description lists the help text and, once the control is invalid and has been touched, the error, which then appears and is announced. The field turns destructive with it. Fields stack the label above the control, sit side by side for checkboxes and switches, or switch between the two by width; field sets and groups space related fields. ## Installation ```bash npx @ndop/cli add form-field ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/form-field/form-field.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdField, NdFieldDescription, NdFieldError, } from '@ndop/primitives/form-field'; import { NdLabel } from '@ndop/primitives/label'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * A set of related fields, on a native `fieldset`, titled by a * {@link FieldLegend}. * * @example * ```html *
    * Address *
    …
    *
    * ``` */ @Directive({ selector: 'fieldset[ndFieldSet]', host: { 'data-slot': 'field-set', '[class]': 'classes()' }, }) export class FieldSet { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex flex-col gap-6', 'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3', this.class(), ), ); } /** * The title of a {@link FieldSet}, on a native `legend`: `legend` size by * default, or `label` size for a set that reads as one field. * * @example * ```html * Notifications * ``` */ @Directive({ selector: 'legend[ndFieldLegend]', host: { 'data-slot': 'field-legend', '[attr.data-variant]': 'variant()', '[class]': 'classes()', }, }) export class FieldLegend { /** Text size: `legend` or `label`. */ readonly variant = input<'legend' | 'label'>('legend'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'mb-3 font-medium', 'data-[variant=legend]:text-base', 'data-[variant=label]:text-sm', this.class(), ), ); } /** * Stacks fields with even spacing; responsive fields inside switch to a * row when the group is wide enough. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndFieldGroup]', host: { 'data-slot': 'field-group', '[class]': 'classes()' }, }) export class FieldGroup { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4', this.class(), ), ); } /** Class table for {@link Field}. */ export const fieldVariants = variants({ base: 'group/field flex w-full gap-3 data-invalid:text-destructive', variants: { orientation: { vertical: 'flex-col [&>*]:w-full [&>.sr-only]:w-auto', horizontal: cn( 'flex-row items-center', '[&>[data-slot=field-label]]:flex-auto', 'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', ), responsive: cn( 'flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto', '@md/field-group:[&>[data-slot=field-label]]:flex-auto', '@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', ), }, }, defaults: { orientation: 'vertical' }, }); /** Layout of a {@link Field}. */ export type FieldOrientation = VariantOf; /** * One form field (`@ndop/primitives/form-field`): a control with its * label, description and error. Ndop controls inside it list the * description and, once invalid, the error in their `aria-describedby`; * the error shows only then. The field turns destructive while its control * is invalid. * * @example * ```html *
    * * *

    We'll never share your email.

    *
    Enter a valid email address.
    *
    * ``` */ @Directive({ selector: '[ndField]', hostDirectives: [{ directive: NdField, inputs: ['invalid'] }], host: { 'data-slot': 'field', '[attr.data-orientation]': 'orientation()', '[class]': 'classes()', }, }) export class Field { /** * `vertical` stacks the label above the control; `horizontal` puts them * side by side, as for a checkbox; `responsive` switches from one to the * other when its `ndFieldGroup` is wide enough. */ readonly orientation = input('vertical'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn(fieldVariants({ orientation: this.orientation() }), this.class()), ); } /** * Groups a field's label and description beside its control, as for a * checkbox with help text. * * @example * ```html *
    * *

    One email a month.

    *
    * ``` */ @Directive({ selector: '[ndFieldContent]', host: { 'data-slot': 'field-content', '[class]': 'classes()' }, }) export class FieldContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/field-content flex flex-1 flex-col gap-1.5 leading-snug', this.class(), ), ); } /** * A field's label, on a native `label` (`@ndop/primitives/label`). Wrapping * a whole {@link Field} in it makes a selectable card, highlighted while * the checkbox or radio inside is checked; its descriptions darken then, * to keep their contrast on the tint. * * @example * ```html * * ``` */ @Directive({ selector: 'label[ndFieldLabel]', hostDirectives: [{ directive: NdLabel, inputs: ['for'] }], host: { 'data-slot': 'field-label', '[class]': 'classes()' }, }) export class FieldLabel { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50', 'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-disabled/field:opacity-50', 'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4', 'has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10', 'has-data-[state=checked]:[&_[data-slot=field-description]]:text-foreground/80', this.class(), ), ); } /** * A field's title when it isn't a label, such as the heading of a * selectable card. * * @example * ```html *
    Pro plan
    * ``` */ @Directive({ selector: '[ndFieldTitle]', host: { 'data-slot': 'field-label', '[class]': 'classes()' }, }) export class FieldTitle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-disabled/field:opacity-50', this.class(), ), ); } /** * Help text for a {@link Field}'s control, which lists it in * `aria-describedby`. * * @example * ```html *

    We'll never share your email.

    * ``` */ @Directive({ selector: '[ndFieldDescription]', hostDirectives: [NdFieldDescription], host: { 'data-slot': 'field-description', '[class]': 'classes()' }, }) export class FieldDescription { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance', 'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5', '[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary', this.class(), ), ); } /** * A line between fields, with optional text on it, such as "Or". * * @example * ```html *
    * Or continue with *
    * ``` */ @Directive({ selector: '[ndFieldSeparator]', host: { 'data-slot': 'field-separator', '[class]': 'classes()' }, }) export class FieldSeparator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2', 'before:absolute before:inset-x-0 before:top-1/2 before:h-px before:bg-border', this.class(), ), ); } /** * The text on a {@link FieldSeparator}'s line. * * @example * ```html * Or continue with * ``` */ @Directive({ selector: '[ndFieldSeparatorContent]', host: { 'data-slot': 'field-separator-content', '[class]': 'classes()' }, }) export class FieldSeparatorContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative mx-auto block w-fit bg-background px-2 text-muted-foreground', this.class(), ), ); } /** * A {@link Field}'s error, shown and announced once its control is invalid * and touched. Put the messages inside: one as text, several as a list. * * @example * ```html *
    * @for (error of form.email().errors(); track error.kind) { *

    {{ error.message }}

    * } *
    * ``` */ @Directive({ selector: '[ndFieldError]', hostDirectives: [NdFieldError], host: { 'data-slot': 'field-error', '[class]': 'classes()' }, }) export class FieldError { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('text-sm font-normal text-destructive', this.class()), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Button } from '@/ui/button/button'; import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldSeparator, FieldSeparatorContent, } from '@/ui/form-field/form-field'; import { Input } from '@/ui/input/input'; ``` ```html

    We'll never share your email.

    @for (error of signup.email().errors(); track error.kind) {

    {{ error.message }}

    }

    At least 12 characters.

    @for (error of signup.password().errors(); track error.kind) {

    {{ error.message }}

    }
    Or
    ``` ## Examples ### Form Field demo A sign-up form whose fields describe themselves and show errors once touched. ```ts import { Component, signal } from '@angular/core'; import { email, form, FormField, minLength, required, } from '@angular/forms/signals'; import { Button } from '@/ui/button/button'; import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldSeparator, FieldSeparatorContent, } from '@/ui/form-field/form-field'; import { Input } from '@/ui/input/input'; /** * A sign-up form in Signal Forms. Each field's input is described by its * help text and, once it's invalid and has been left, by its errors, which * appear and are announced then. */ @Component({ selector: 'example-form-field-demo', imports: [ Button, Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldSeparator, FieldSeparatorContent, FormField, Input, ], template: `

    We'll never share your email.

    @for (error of signup.email().errors(); track error.kind) {

    {{ error.message }}

    }

    At least 12 characters.

    @for (error of signup.password().errors(); track error.kind) {

    {{ error.message }}

    }
    Or
    `, }) export class FormFieldDemoExample { protected readonly model = signal({ email: '', password: '' }); protected readonly signup = form(this.model, (path) => { required(path.email, { message: 'Enter your email.' }); email(path.email, { message: 'Enter an email like ada@example.com.' }); required(path.password, { message: 'Choose a password.' }); minLength(path.password, 12, { message: 'Use at least 12 characters.', }); }); /** * Marks the fields touched, so errors show, instead of reloading. * * @param event - The form's submit event. */ protected submit(event: Event): void { event.preventDefault(); this.signup().markAsTouched(); } } ``` ### Form Field fieldset An address in a field set with a legend, its fields side by side when there's room. ```ts import { Component } from '@angular/core'; import { Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet, } from '@/ui/form-field/form-field'; import { Input } from '@/ui/input/input'; /** * An address in a field set with a legend. The responsive fields put * their label beside the input when the group is wide enough. */ @Component({ selector: 'example-form-field-fieldset', imports: [ Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet, Input, ], template: `
    Shipping address

    Where should we send your order?

    `, }) export class FormFieldFieldsetExample {} ``` ### Form Field horizontal Checkbox and switch fields with their labels and descriptions beside the control. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Checkbox, CheckboxIndicator } from '@/ui/checkbox/checkbox'; import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, } from '@/ui/form-field/form-field'; import { Switch, SwitchThumb } from '@/ui/switch/switch'; /** * Checkbox and switch fields with their labels and descriptions beside * the control, which each control's description lists. */ @Component({ selector: 'example-form-field-horizontal', imports: [ Checkbox, CheckboxIndicator, Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, NdIcon, Switch, SwitchThumb, ], template: `

    Your settings follow you to every device you sign in on.

    Ask for a code from your phone when you sign in.

    `, }) export class FormFieldHorizontalExample {} ``` ### Form Field choice card Options as cards: each whole field is the label of its radio. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Checkbox, CheckboxIndicator } from '@/ui/checkbox/checkbox'; import { Field, FieldContent, FieldDescription, FieldLabel, FieldLegend, FieldSet, FieldTitle, } from '@/ui/form-field/form-field'; /** * Add-ons as cards: each whole field is the label of its checkbox, and a * checked card is highlighted. */ @Component({ selector: 'example-form-field-choice-card', imports: [ Checkbox, CheckboxIndicator, Field, FieldContent, FieldDescription, FieldLabel, FieldLegend, FieldSet, FieldTitle, NdIcon, ], template: `
    Add-ons @for (addOn of addOns; track addOn.id) { }
    `, }) export class FormFieldChoiceCardExample { protected readonly addOns = [ { id: 'backups', title: 'Daily backups', description: 'Restore any day from the last 30.', checked: true, }, { id: 'domain', title: 'Custom domain', description: 'Serve your site from your own address.', checked: false, }, ]; } ``` ## Accessibility Implements the [`aria-describedby`](https://www.w3.org/TR/wai-aria-1.2/#aria-describedby) pattern of the WAI-ARIA Authoring Practices. ### Focus - A field never moves focus. An error appearing doesn't take focus; it's announced. - Clicking the label moves focus to the associated control (native behavior, unchanged). ### Right-to-left Nothing mirrors. Nothing mirrors. ## API reference ### FieldSet Selector: `fieldset[ndFieldSet]` A set of related fields, on a native `fieldset`, titled by a `FieldLegend`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `field-set` | ### FieldLegend Selector: `legend[ndFieldLegend]` The title of a `FieldSet`, on a native `legend`: `legend` size by default, or `label` size for a set that reads as one field. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[variant]` | `'legend' \| 'label'` | `'legend'` | Text size: `legend` or `label`. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `field-legend` | | `data-variant` | | ### FieldGroup Selector: `[ndFieldGroup]` Stacks fields with even spacing; responsive fields inside switch to a row when the group is wide enough. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `field-group` | ### Field Selector: `[ndField]` · Role: `group` One form field (`@ndop/primitives/form-field`): a control with its label, description and error. Ndop controls inside it list the description and, once invalid, the error in their `aria-describedby`; the error shows only then. The field turns destructive while its control is invalid. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[orientation]` | `'horizontal' \| 'vertical' \| 'responsive'` | `'vertical'` | `vertical` stacks the label above the control; `horizontal` puts them side by side, as for a checkbox; `responsive` switches from one to the other when its `ndFieldGroup` is wide enough. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[invalid]` | `boolean` | `false` | Marks the field invalid by hand, for errors no control reports. | | Data attribute | Values | | --- | --- | | `data-slot` | `field` | | `data-orientation` | | | `data-invalid` | | | `data-disabled` | | ### FieldContent Selector: `[ndFieldContent]` Groups a field's label and description beside its control, as for a checkbox with help text. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `field-content` | ### FieldLabel Selector: `label[ndFieldLabel]` A field's label, on a native `label` (`@ndop/primitives/label`). Wrapping a whole `Field` in it makes a selectable card, highlighted while the checkbox or radio inside is checked; its descriptions darken then, to keep their contrast on the tint. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[for]` | `string \| undefined` | | Id of the labelled control. | | Data attribute | Values | | --- | --- | | `data-slot` | `field-label` | | `data-disabled` | | | `data-invalid` | | ### FieldTitle Selector: `[ndFieldTitle]` A field's title when it isn't a label, such as the heading of a selectable card. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `field-label` | ### FieldDescription Selector: `[ndFieldDescription]` Help text for a `Field`'s control, which lists it in `aria-describedby`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `field-description` | ### FieldSeparator Selector: `[ndFieldSeparator]` A line between fields, with optional text on it, such as "Or". | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `field-separator` | ### FieldSeparatorContent Selector: `[ndFieldSeparatorContent]` The text on a `FieldSeparator`'s line. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `field-separator-content` | ### FieldError Selector: `[ndFieldError]` · Role: `alert` A `Field`'s error, shown and announced once its control is invalid and touched. Put the messages inside: one as text, several as a list. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `field-error` | ## Agent notes ### Do - Put each control in its own field, with a label. - Write errors that say how to fix the problem, such as "Enter an email address like ada@example.com". - Group related fields in a field set with a legend, such as an address. ### Don't - Don't show errors before people have left the field. - Don't put more than one text control in a field; give each its own. --- # Hover Card > A preview of what's behind a link, shown to pointer and keyboard users while they hover or focus it. Version 1.0.0 · WAI-ARIA pattern: [Tooltip](https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/) · SSR · Zoneless · 3.0 kB gzip A hover card previews what's behind a link, such as a person's profile behind their username, for sighted users: it opens after a short delay while the link is hovered or focused with the keyboard, and stays open while the pointer is over it. Screen readers don't announce it, so the link must make sense without it. ## Installation ```bash npx @ndop/cli add hover-card ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/hover-card/hover-card.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdTooltip } from '@ndop/primitives/tooltip'; import { NdHoverCardContent, NdHoverCardPortal, NdHoverCardTrigger, } from '@ndop/primitives/hover-card'; import { cn } from '@/lib/utils'; /** * A hover card (`@ndop/primitives/hover-card`): a preview of what's behind * a link, for sighted users, after a short delay on hover or keyboard * focus. Put it on an `ng-container`. * * @example * ```html * … * ``` */ @Directive({ selector: '[ndHoverCard]', hostDirectives: [ { directive: NdTooltip, inputs: ['open', 'defaultOpen', 'openDelay', 'closeDelay'], outputs: ['openChange'], }, ], }) export class HoverCard {} /** * The link its card previews. * * @example * ```html * @ada * ``` */ @Directive({ selector: '[ndHoverCardTrigger]', hostDirectives: [NdHoverCardTrigger], host: { 'data-slot': 'hover-card-trigger' }, }) export class HoverCardTrigger {} /** * Marks the `ng-template` its card renders while open. * * @example * ```html * … * ``` */ @Directive({ selector: 'ng-template[ndHoverCardPortal]', hostDirectives: [NdHoverCardPortal], }) export class HoverCardPortal {} /** * A hover card (`@ndop/primitives/hover-card`): a preview of what's behind * a link, shown after a short delay while the link is hovered or focused * with the keyboard, and kept open while the pointer is over it. It's for * sighted users only, so the link must make sense without it. * * @example * ```html * * @nextjs * *
    …
    *
    *
    * ``` */ @Directive({ selector: '[ndHoverCardContent]', hostDirectives: [ { directive: NdHoverCardContent, inputs: ['side', 'align', 'sideOffset'], }, ], host: { 'data-slot': 'hover-card-content', '[class]': 'classes()' }, }) export class HoverCardContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=start]:slide-in-from-end-2 data-[side=end]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Avatar, AvatarFallback } from '@/ui/avatar/avatar'; import { Button } from '@/ui/button/button'; import { HoverCardContent, HoverCard, HoverCardPortal, HoverCardTrigger, } from '@/ui/hover-card/hover-card'; ``` ```html @angular
    NG

    @angular

    The web development framework for building modern apps.

    Joined September 2016

    ``` ## Examples ### Hover Card demo A profile preview behind a username link. ```ts import { Component } from '@angular/core'; import { Avatar, AvatarFallback } from '@/ui/avatar/avatar'; import { Button } from '@/ui/button/button'; import { HoverCardContent, HoverCard, HoverCardPortal, HoverCardTrigger, } from '@/ui/hover-card/hover-card'; /** * A profile preview behind a username link. The link says where it goes * on its own; the card adds a glance for sighted users. */ @Component({ selector: 'example-hover-card-demo', imports: [ Avatar, AvatarFallback, Button, HoverCardContent, HoverCard, HoverCardPortal, HoverCardTrigger, ], template: ` @angular
    NG

    @angular

    The web development framework for building modern apps.

    Joined September 2016

    `, }) export class HoverCardDemoExample {} ``` ### Hover Card sides Cards on each side of their links. ```ts import { Component } from '@angular/core'; import { Button } from '@/ui/button/button'; import { HoverCardContent, HoverCard, HoverCardPortal, HoverCardTrigger, } from '@/ui/hover-card/hover-card'; /** * Cards on each side of their links; they flip when there's no room. * `start` and `end` swap in right-to-left layouts. */ @Component({ selector: 'example-hover-card-sides', imports: [ Button, HoverCardContent, HoverCard, HoverCardPortal, HoverCardTrigger, ], template: `
    @for (side of sides; track side) { {{ side }} side

    This card opens on the {{ side }} side.

    }
    `, }) export class HoverCardSidesExample { protected readonly sides = ['top', 'end', 'bottom', 'start'] as const; } ``` ## Accessibility Implements the [Tooltip](https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Tab` | Focusing the trigger shows the tooltip; moving focus away hides it. | | `Escape` | Hides the tooltip without moving focus. | | `Tab` | Focusing the trigger opens the card after the delay; leaving it closes it. | | `Escape` | Closes the card without moving focus. | ### Focus - The tooltip never takes focus; focus stays on the trigger. - The card never takes focus; focus stays on the trigger. ### Right-to-left As in Popover's `spec.md`: `start` and `end` sides and alignments swap in RTL. `start` and `end` sides are logical and swap in right-to-left layouts. ## API reference ### HoverCard Selector: `[ndHoverCard]` A hover card (`@ndop/primitives/hover-card`): a preview of what's behind a link, for sighted users, after a short delay on hover or keyboard focus. Put it on an `ng-container`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[defaultOpen]` | `boolean` | `false` | Initial open state when `open` is not bound. | | `[openDelay]` | `number` | `700` | Delay before opening on hover, in milliseconds. | | `[closeDelay]` | `number` | `300` | Delay before closing once the pointer has left, in milliseconds. | | `[(open)]` | `boolean` | `false` | Whether the tooltip is open. Two-way bindable with `[(open)]`. | ### HoverCardTrigger Selector: `[ndHoverCardTrigger]` The link its card previews. | Data attribute | Values | | --- | --- | | `data-slot` | `hover-card-trigger` | | `data-state` | | ### HoverCardPortal Selector: `ng-template[ndHoverCardPortal]` Marks the `ng-template` its card renders while open. ### HoverCardContent Selector: `[ndHoverCardContent]` A hover card (`@ndop/primitives/hover-card`): a preview of what's behind a link, shown after a short delay while the link is hovered or focused with the keyboard, and kept open while the pointer is over it. It's for sighted users only, so the link must make sense without it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[side]` | `'bottom' \| 'top' \| 'start' \| 'end'` | `'bottom'` | Side of the trigger it sits on, before flipping. | | `[align]` | `'start' \| 'end' \| 'center'` | `'center'` | Alignment along that side. | | `[sideOffset]` | `number` | `4` | Gap between the trigger and the card, in pixels. | | Data attribute | Values | | --- | --- | | `data-slot` | `hover-card-content` | | `data-state` | | | `data-side` | | | `data-align` | | ## Agent notes ### Do - Use it for a preview of something people can also open. - Keep it short: a name, a line of text, a date. ### Don't - Don't put anything only in the card: what it says must be on the page it previews. - Don't put buttons or forms in it; use a Popover. --- # Input > Displays a form input field. Version 1.0.0 · SSR · Zoneless · 1.8 kB gzip A text input collects a single line of text. `ndInput` styles a native `input` and adds no value accessor, so forms bind it directly: Signal Forms with `[formField]`, reactive forms with `formControlName`, or plain attributes. It reads the bound field's state and shows the input as invalid once the field is invalid and touched, with `aria-invalid` and `data-invalid`. ## Installation ```bash npx @ndop/cli add input ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/input/input.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdInput } from '@ndop/primitives/input'; import { cn } from '@/lib/utils'; /** * Styled text input. Forms bind the native input directly; the * `@ndop/primitives/input` behavior reflects their state as * `aria-invalid` once the field is touched. * * @example * ```html * * ``` */ @Directive({ selector: 'input[ndInput]', hostDirectives: [{ directive: NdInput, inputs: ['id', 'invalid'] }], host: { 'data-slot': 'input', '[class]': 'classes()' }, }) export class Input { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30', 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50', 'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Input } from '@/ui/input/input'; ``` ```html ``` ## Examples ### Input demo A plain email input. ```ts import { Component } from '@angular/core'; import { Input } from '@/ui/input/input'; /** A plain email input. */ @Component({ selector: 'example-input-demo', imports: [Input], template: ` `, }) export class InputDemoExample {} ``` ### Input signal forms An input bound with Signal Forms. It turns invalid once the field is touched and fails validation, and the label follows it. ```ts import { Component, signal } from '@angular/core'; import { email, form, FormField, required } from '@angular/forms/signals'; import { Input } from '@/ui/input/input'; import { Label } from '@/ui/label/label'; /** * An input bound with Signal Forms. It turns invalid once the field is * touched and fails validation, and the label follows it. */ @Component({ selector: 'example-input-signal-forms', imports: [Input, Label, FormField], template: `

    @if (account.email().touched() && account.email().invalid()) { Enter a valid email address. } @else { We'll send the invite here. }

    `, }) export class InputSignalFormsExample { private readonly model = signal({ email: '' }); protected readonly account = form(this.model, (path) => { required(path.email); email(path.email); }); } ``` ### Input with button An input and a button on one row, as in a newsletter sign-up. ```ts import { Component } from '@angular/core'; import { Button } from '@/ui/button/button'; import { Input } from '@/ui/input/input'; /** An input and a button on one row, as in a newsletter sign-up. */ @Component({ selector: 'example-input-with-button', imports: [Input, Button], template: `
    `, }) export class InputWithButtonExample {} ``` ### Input disabled A disabled input with its label. ```ts import { Component } from '@angular/core'; import { Input } from '@/ui/input/input'; import { Label } from '@/ui/label/label'; /** A disabled input; its label dims with it. */ @Component({ selector: 'example-input-disabled', imports: [Input, Label], template: `
    `, }) export class InputDisabledExample {} ``` ## Accessibility ### Focus - Unchanged from the native input. Focusing it and leaving it marks the bound field touched, through the forms library. ### Right-to-left Text direction follows the document or the element's `dir`; Ndop changes nothing. ## API reference ### Input Selector: `input[ndInput]` · Role: `native` Styled text input. Forms bind the native input directly; the `@ndop/primitives/input` behavior reflects their state as `aria-invalid` once the field is touched. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[id]` | `string \| undefined` | | Element id; also the key a `label[ndLabel]` uses to find this input. | | `[invalid]` | `boolean` | `false` | Marks the input invalid when no forms library is bound; with one, the field's state decides. | | Data attribute | Values | | --- | --- | | `data-slot` | `input` | | `data-disabled` | | | `data-invalid` | | ## Agent notes ### Do - Pair every input with an `ndLabel` whose `for` matches the input's `id`. - Bind it with Signal Forms (`[formField]`) or reactive forms, and let the field's validity drive the invalid state. - Set the right `type` (`email`, `password`, `search`, `tel`) so devices show the matching keyboard and browsers can autofill. ### Don't - Don't set `invalid` while a forms library is bound; the field's state decides. - Don't rely on color alone for errors; describe them in text linked with `aria-describedby`. --- # Input OTP > Enters a one-time code, digit by digit, in one field that autofill and screen readers understand. Version 1.0.0 · SSR · Zoneless · 3.2 kB gzip An OTP input takes a one-time code, such as a verification code sent by text message, drawing each character in its own slot. It's one real input underneath, so screen readers read one field, the phone's keyboard offers the code from a message (`autocomplete="one-time-code"`), and pasting fills every slot at once. It keeps digits only by default; set `allow` for letters. Codes read left to right in every language, so its slots don't mirror. ## Installation ```bash npx @ndop/cli add input-otp ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/input-otp/input-otp.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdInputOtp, NdInputOtpGroup, NdInputOtpInput, NdInputOtpSeparator, NdInputOtpSlot, } from '@ndop/primitives/input-otp'; import { cn } from '@/lib/utils'; /** * A one-time code input (`@ndop/primitives/input-otp`): one native * `input[ndInputOtpInput]` holds the code, invisible over the slots that * draw it. Set `length`, bind it with `[(value)]` or any forms library on * this element, and name the input with a label or `aria-label`. * * @example * ```html *
    * *
    * @for (index of [0, 1, 2, 3, 4, 5]; track index) { *
    * } *
    *
    * ``` */ @Directive({ selector: '[ndInputOtp]', hostDirectives: [ { directive: NdInputOtp, inputs: [ 'value', 'defaultValue', 'length', 'allow', 'inputMode', 'disabled', 'invalid', ], outputs: ['valueChange', 'completed'], }, ], host: { 'data-slot': 'input-otp', '[class]': 'classes()' }, }) export class InputOtp { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex items-center gap-2 has-disabled:opacity-50', 'relative w-fit', this.class(), ), ); } /** * The native input of an {@link InputOtp}: transparent and stretched over * the slots, so clicking them focuses it. * * @example * ```html * * ``` */ @Directive({ selector: 'input[ndInputOtpInput]', hostDirectives: [{ directive: NdInputOtpInput, inputs: ['id'] }], host: { 'data-slot': 'input-otp-input', '[class]': 'classes()' }, }) export class InputOtpInput { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'absolute inset-0 z-20 size-full bg-transparent text-transparent caret-transparent outline-none selection:bg-transparent disabled:cursor-not-allowed', this.class(), ), ); } /** * A row of {@link InputOtpSlot}s that join into one box. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndInputOtpGroup]', hostDirectives: [NdInputOtpGroup], host: { 'data-slot': 'input-otp-group', '[class]': 'classes()' }, }) export class InputOtpGroup { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex items-center', this.class()), ); } /** * One character of an {@link InputOtp}, ringed while it's where the next * one goes, with a blinking caret while that slot is empty. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndInputOtpSlot]', hostDirectives: [{ directive: NdInputOtpSlot, inputs: ['index'] }], host: { 'data-slot': 'input-otp-slot', '[class]': 'classes()' }, }) export class InputOtpSlot { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative flex h-9 w-9 items-center justify-center border-y border-e border-input text-sm shadow-xs transition-all outline-none first:rounded-s-md first:border-s last:rounded-e-md data-invalid:border-destructive data-active:z-10 data-active:border-ring data-active:ring-[3px] data-active:ring-ring/50 data-active:data-invalid:border-destructive data-active:data-invalid:ring-destructive/20 dark:bg-input/30 dark:data-active:data-invalid:ring-destructive/40', 'data-caret:after:pointer-events-none data-caret:after:absolute data-caret:after:h-4 data-caret:after:w-px data-caret:after:animate-caret-blink data-caret:after:bg-foreground data-caret:after:duration-1000', this.class(), ), ); } /** * A separator between groups of slots. Put a minus icon inside. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndInputOtpSeparator]', hostDirectives: [NdInputOtpSeparator], host: { 'data-slot': 'input-otp-separator' }, }) export class InputOtpSeparator {} ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { InputOtp, InputOtpGroup, InputOtpInput, InputOtpSeparator, InputOtpSlot, } from '@/ui/input-otp/input-otp'; ``` ```html
    @for (index of [0, 1, 2]; track index) {
    }
    @for (index of [3, 4, 5]; track index) {
    }
    ``` ## Examples ### Input OTP demo A six-digit code in two groups of three. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { InputOtp, InputOtpGroup, InputOtpInput, InputOtpSeparator, InputOtpSlot, } from '@/ui/input-otp/input-otp'; /** A six-digit code in two groups of three. */ @Component({ selector: 'example-input-otp-demo', imports: [ InputOtp, InputOtpGroup, InputOtpInput, InputOtpSeparator, InputOtpSlot, NdIcon, ], template: `
    @for (index of [0, 1, 2]; track index) {
    }
    @for (index of [3, 4, 5]; track index) {
    }
    `, }) export class InputOtpDemoExample {} ``` ### Input OTP pattern A code of letters and digits. ```ts import { Component } from '@angular/core'; import { InputOtp, InputOtpGroup, InputOtpInput, InputOtpSlot, } from '@/ui/input-otp/input-otp'; import { Label } from '@/ui/label/label'; /** * A recovery code of capital letters and digits: `allow` keeps them, and * the text keyboard opens on phones. */ @Component({ selector: 'example-input-otp-pattern', imports: [InputOtp, InputOtpGroup, InputOtpInput, InputOtpSlot, Label], template: `
    @for (index of [0, 1, 2, 3, 4, 5]; track index) {
    }
    `, }) export class InputOtpPatternExample {} ``` ### Input OTP verify A verification step that submits the code as soon as it's complete. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; import { Field, FieldDescription, FieldError, FieldLabel, } from '@/ui/form-field/form-field'; import { InputOtp, InputOtpGroup, InputOtpInput, InputOtpSeparator, InputOtpSlot, } from '@/ui/input-otp/input-otp'; /** * A verification step that checks the code as soon as it's complete, * without a submit button: a wrong code turns the field invalid and says * so. Here, 123456 is the right code. */ @Component({ selector: 'example-input-otp-verify', imports: [ Button, Field, FieldDescription, FieldError, FieldLabel, InputOtp, InputOtpGroup, InputOtpInput, InputOtpSeparator, InputOtpSlot, NdIcon, ], template: `

    We sent a code to ada@example.com.

    @for (index of [0, 1, 2]; track index) {
    }
    @for (index of [3, 4, 5]; track index) {
    }

    That code isn't right. Check it, or send a new one.

    @if (status() === 'verified') { Verified. }

    `, }) export class InputOtpVerifyExample { protected readonly code = signal(''); protected readonly status = signal<'pending' | 'wrong' | 'verified'>( 'pending', ); /** * Checks a complete code. * * @param code - The code. */ protected verify(code: string): void { this.status.set(code === '123456' ? 'verified' : 'wrong'); } /** Clears the code, as after sending a new one. */ protected resend(): void { this.code.set(''); this.status.set('pending'); } } ``` ### Input OTP disabled A disabled code input. ```ts import { Component } from '@angular/core'; import { InputOtp, InputOtpGroup, InputOtpInput, InputOtpSlot, } from '@/ui/input-otp/input-otp'; /** A disabled code input, such as while a code is being checked. */ @Component({ selector: 'example-input-otp-disabled', imports: [InputOtp, InputOtpGroup, InputOtpInput, InputOtpSlot], template: `
    @for (index of [0, 1, 2, 3]; track index) {
    }
    `, }) export class InputOtpDisabledExample {} ``` ## Accessibility ### Keyboard | Key | Action | | --- | --- | | `Backspace` | Deletes the last character. | ### Focus - Clicking any slot focuses the input, with the caret at the end of the code. - Focus stays on the input as the code fills; the `completed` output lets the app move on. - Leaving the input marks a bound field touched. ### Right-to-left The root has `dir="ltr"`: a code's characters read left to right in every language, as phone numbers do, so the slots don't mirror. ## API reference ### InputOtp Selector: `[ndInputOtp]` A one-time code input (`@ndop/primitives/input-otp`): one native `input[ndInputOtpInput]` holds the code, invisible over the slots that draw it. Set `length`, bind it with `[(value)]` or any forms library on this element, and name the input with a label or `aria-label`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[defaultValue]` | `string` | `''` | Initial `value` when it isn't bound. | | `[length]` (required) | `number` | | The code's length: how many slots it fills. | | `[allow]` | `RegExp` | `/\d/` | A regular expression one character must match to be kept, or its source. Digits by default. | | `[inputMode]` | `'numeric' \| 'text'` | `'numeric'` | The input's `inputmode`, which picks the on-screen keyboard. | | `[disabled]` | `boolean` | `false` | Disables the input. A disabled bound field does too. | | `[invalid]` | `boolean` | `false` | Marks the code invalid when no forms library is bound; with one, the field's state decides. | | `[(value)]` | `string` | `''` | The code. Two-way bindable with `[(value)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `input-otp` | | `data-complete` | | | `data-disabled` | | | `data-invalid` | | ### InputOtpInput Selector: `input[ndInputOtpInput]` · Role: `native` The native input of an `InputOtp`: transparent and stretched over the slots, so clicking them focuses it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[id]` | `string \| undefined` | | Element id; also the key a `label[ndLabel]` uses to find it. | | Data attribute | Values | | --- | --- | | `data-slot` | `input-otp-input` | | `data-invalid` | | ### InputOtpGroup Selector: `[ndInputOtpGroup]` A row of `InputOtpSlot`s that join into one box. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `input-otp-group` | ### InputOtpSlot Selector: `[ndInputOtpSlot]` One character of an `InputOtp`, ringed while it's where the next one goes, with a blinking caret while that slot is empty. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[index]` (required) | `number` | | The character's position in the code, from 0. | | Data attribute | Values | | --- | --- | | `data-slot` | `input-otp-slot` | | `data-active` | | | `data-filled` | | | `data-caret` | | | `data-invalid` | | ### InputOtpSeparator Selector: `[ndInputOtpSeparator]` · Role: `separator` A separator between groups of slots. Put a minus icon inside. | Data attribute | Values | | --- | --- | | `data-slot` | `input-otp-separator` | ## Agent notes ### Do - Name it with a label, such as "Verification code". - Submit the code from the `completed` output, so people don't have to press a button. - Tell people where the code was sent, and how to get a new one. ### Don't - Don't use it for passwords or PINs people choose; use an Input. - Don't split the code into several inputs. --- # Label > Renders an accessible label associated with controls. Version 1.0.0 · SSR · Zoneless · 1.3 kB gzip A label names a form control. `ndLabel` styles a native `label`, which names the control its `for` attribute points at and moves focus to it when clicked. When the control is an `input[ndInput]`, the label also mirrors its state with `data-invalid` and `data-disabled`, so the two style together. ## Installation ```bash npx @ndop/cli add label ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/label/label.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdLabel } from '@ndop/primitives/label'; import { cn } from '@/lib/utils'; /** * Styled label. Names its control natively through `for`, and mirrors an * `input[ndInput]`'s disabled and invalid state (`@ndop/primitives/label`). * * @example * ```html * * ``` */ @Directive({ selector: 'label[ndLabel]', hostDirectives: [{ directive: NdLabel, inputs: ['for'] }], host: { 'data-slot': 'label', '[class]': 'classes()' }, }) export class Label { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Input } from '@/ui/input/input'; import { Label } from '@/ui/label/label'; ``` ```html
    ``` ## Examples ### Label demo A label naming its input. ```ts import { Component } from '@angular/core'; import { Input } from '@/ui/input/input'; import { Label } from '@/ui/label/label'; /** A label naming its input. */ @Component({ selector: 'example-label-demo', imports: [Label, Input], template: `
    `, }) export class LabelDemoExample {} ``` ### Label disabled A label that dims with its disabled input. ```ts import { Component } from '@angular/core'; import { Input } from '@/ui/input/input'; import { Label } from '@/ui/label/label'; /** A label that dims with its disabled input. */ @Component({ selector: 'example-label-disabled', imports: [Label, Input], template: `
    `, }) export class LabelDisabledExample {} ``` ## Accessibility ### Focus - Clicking the label moves focus to the associated control (native behavior, unchanged). ### Right-to-left Nothing mirrors. ## API reference ### Label Selector: `label[ndLabel]` Styled label. Names its control natively through `for`, and mirrors an `input[ndInput]`'s disabled and invalid state (`@ndop/primitives/label`). | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[for]` | `string \| undefined` | | Id of the labelled control. | | Data attribute | Values | | --- | --- | | `data-slot` | `label` | | `data-disabled` | | | `data-invalid` | | ## Agent notes ### Do - Give every input a visible label, and point its `for` at the input's `id`. - Keep labels short: name the value, and put instructions in a description linked with `aria-describedby`. ### Don't - Don't use placeholder text instead of a label; it disappears as soon as someone types. - Don't style a `div` or `span` as a label; only a native `label` names its control. --- # Menubar > A bar of menus, such as a desktop app's File and Edit menus. Version 1.0.0 · WAI-ARIA pattern: [Menubar](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) · SSR · Zoneless · 3.4 kB gzip A menubar is a row of menus, like a desktop app's File, Edit and View menus. It's one tab stop: the arrow keys move between its menus, and open the next one while a menu is open. Its menus have the same items as Dropdown Menu: checkboxes, radios, submenus, labels, separators and shortcuts. ## Installation ```bash npx @ndop/cli add menubar ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/menubar/menubar.ts ```ts import { booleanAttribute, computed, Directive, input } from '@angular/core'; import { NdMenuContent, NdMenuGroup, NdMenuItem, NdMenuItemCheckbox, NdMenuItemIndicator, NdMenuItemRadio, NdMenuLabel, NdMenuRadioGroup, NdMenuSeparator, NdMenuSubTrigger, } from '@ndop/primitives/menu'; import { NdMenubar, NdMenubarTrigger } from '@ndop/primitives/menubar'; import { cn } from '@/lib/utils'; /** * A menubar (`@ndop/primitives/menubar`): a bar of menus, such as a desktop * app's File and Edit menus. It's one tab stop; the arrow keys move between * its triggers, and between their menus while one is open. Name it with * `aria-label`. * * @example * ```html *
    *
    File
    *
    * ``` */ @Directive({ selector: '[ndMenubar]', hostDirectives: [NdMenubar], host: { 'data-slot': 'menubar', '[class]': 'classes()' }, }) export class Menubar { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex h-9 items-center gap-1 rounded-md border bg-background p-1 shadow-xs', this.class(), ), ); } /** * One menu of a menubar: its value is the menu's `ng-template`, which opens * below it. Use a `div`. * * @example * ```html *
    File
    * ``` */ @Directive({ selector: '[ndMenubarTrigger]', hostDirectives: [ { directive: NdMenubarTrigger, inputs: ['ndMenubarTrigger', 'side', 'align', 'sideOffset'], }, ], host: { 'data-slot': 'menubar-trigger', '[class]': 'classes()' }, }) export class MenubarTrigger { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground', this.class(), ), ); } /** * A menubar menu's list (`@ndop/primitives/menu`), in the `ng-template` a * `[ndMenubarTrigger]` button opens. Arrow keys, Home, End and typing * move between items; Escape closes it and returns focus to its trigger. * * @example * ```html *
    Profile
    * ``` */ @Directive({ selector: '[ndMenubarContent]', hostDirectives: [{ directive: NdMenuContent, outputs: ['closed'] }], host: { 'data-slot': 'menubar-content', '[class]': 'classes()' }, }) export class MenubarContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=start]:slide-in-from-end-2 data-[side=end]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', this.class(), ), ); } /** * A submenu, opened beside its sub-trigger: at the end side, so on the left * in right-to-left layouts. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndMenubarSubContent]', hostDirectives: [{ directive: NdMenuContent, outputs: ['closed'] }], host: { 'data-slot': 'menubar-sub-content', '[class]': 'classes()' }, }) export class MenubarSubContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=start]:slide-in-from-end-2 data-[side=end]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', this.class(), ), ); } /** * Groups related items (`role="group"`). Name it with `aria-labelledby` * pointing at a label. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndMenubarGroup]', hostDirectives: [NdMenuGroup], host: { 'data-slot': 'menubar-group' }, }) export class MenubarGroup {} /** * One action (`role="menuitem"`): activating it emits `triggered` and * closes the menu. Disabled items stay focusable and do nothing. Use a * `div`: on a `button`, Space would close the menu. * * @example * ```html *
    Edit
    * ``` */ @Directive({ selector: '[ndMenubarItem]', hostDirectives: [ { directive: NdMenuItem, inputs: ['disabled'], outputs: ['triggered'] }, ], host: { 'data-slot': 'menubar-item', '[attr.data-inset]': 'inset() ? "" : null', '[attr.data-variant]': 'variant()', '[class]': 'classes()', }, }) export class MenubarItem { /** Indents it to line up with items that have an indicator. */ readonly inset = input(false, { transform: booleanAttribute }); /** `destructive` colors a dangerous action, such as Delete. */ readonly variant = input<'default' | 'destructive'>('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:ps-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", this.class(), ), ); } /** * An item that is on or off (`role="menuitemcheckbox"`), bound with * `[(checked)]`. Put an indicator with a check icon in it. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndMenubarCheckboxItem]', hostDirectives: [ { directive: NdMenuItemCheckbox, inputs: ['checked', 'disabled'], outputs: ['checkedChange', 'triggered'], }, ], host: { 'data-slot': 'menubar-checkbox-item', '[class]': 'classes()' }, }) export class MenubarCheckboxItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pe-2 ps-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", this.class(), ), ); } /** * Groups radio items (`role="group"`) and holds the checked value, bound * with `[(value)]`. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndMenubarRadioGroup]', hostDirectives: [ { directive: NdMenuRadioGroup, inputs: ['value'], outputs: ['valueChange'], }, ], host: { 'data-slot': 'menubar-radio-group' }, }) export class MenubarRadioGroup {} /** * One choice of a radio group (`role="menuitemradio"`). Put an indicator * with a small filled circle in it. * * @example * ```html *
    Top
    * ``` */ @Directive({ selector: '[ndMenubarRadioItem]', hostDirectives: [ { directive: NdMenuItemRadio, inputs: ['value', 'disabled'], outputs: ['triggered'], }, ], host: { 'data-slot': 'menubar-radio-item', '[class]': 'classes()' }, }) export class MenubarRadioItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pe-2 ps-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", this.class(), ), ); } /** * The check or dot at the start of a checkbox or radio item, shown while it * is checked. * * @example * ```html * * ``` */ @Directive({ selector: '[ndMenubarItemIndicator]', hostDirectives: [NdMenuItemIndicator], host: { 'data-slot': 'menubar-item-indicator', '[class]': 'classes()' }, }) export class MenubarItemIndicator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'pointer-events-none absolute start-2 flex size-3.5 items-center justify-center', this.class(), ), ); } /** * A heading for a group of items; point the group's `aria-labelledby` at * it. * * @example * ```html *
    My account
    * ``` */ @Directive({ selector: '[ndMenubarLabel]', hostDirectives: [NdMenuLabel], host: { 'data-slot': 'menubar-label', '[attr.data-inset]': 'inset() ? "" : null', '[class]': 'classes()', }, }) export class MenubarLabel { /** Indents it to line up with items that have an indicator. */ readonly inset = input(false, { transform: booleanAttribute }); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('px-2 py-1.5 text-sm font-medium data-[inset]:ps-8', this.class()), ); } /** * A line between groups of items (`role="separator"`). * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndMenubarSeparator]', hostDirectives: [NdMenuSeparator], host: { 'data-slot': 'menubar-separator', '[class]': 'classes()' }, }) export class MenubarSeparator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('-mx-1 my-1 h-px bg-border', this.class()), ); } /** * The keyboard shortcut of an item, at its end, as a reminder; the menu * doesn't handle it. * * @example * ```html * ⌘S * ``` */ @Directive({ selector: '[ndMenubarShortcut]', host: { 'data-slot': 'menubar-shortcut', '[class]': 'classes()' }, }) export class MenubarShortcut { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('ms-auto text-xs tracking-widest text-muted-foreground', this.class()), ); } /** * An item that opens the submenu in its template. Put a chevron icon at its * end with `ms-auto`, turned around in right-to-left layouts. * * @example * ```html *
    More tools
    * ``` */ @Directive({ selector: '[ndMenubarSubTrigger]', hostDirectives: [ { directive: NdMenuSubTrigger, inputs: ['ndMenuSubTrigger: ndMenubarSubTrigger', 'disabled'], }, ], host: { 'data-slot': 'menubar-sub-trigger', '[attr.data-inset]': 'inset() ? "" : null', '[class]': 'classes()', }, }) export class MenubarSubTrigger { /** Indents it to line up with items that have an indicator. */ readonly inset = input(false, { transform: booleanAttribute }); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:ps-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Menubar, MenubarCheckboxItem, MenubarContent, MenubarGroup, MenubarItem, MenubarItemIndicator, MenubarRadioGroup, MenubarRadioItem, MenubarSeparator, MenubarShortcut, MenubarSubContent, MenubarSubTrigger, MenubarTrigger, } from '@/ui/menubar/menubar'; ``` ```html
    File
    Edit
    View
    Profiles

    {{ chosen() }}

    New tab ⌘T
    New window ⌘N
    New incognito window
    Share
    Print… ⌘P
    Email link
    Messages
    Notes
    Undo ⌘Z
    Redo ⇧⌘Z
    Cut
    Copy
    Paste
    Always show bookmarks bar
    Always show full URLs
    Reload ⌘R
    Force reload ⇧⌘R
    Toggle full screen
    @for (name of names; track name) {
    {{ name }}
    }
    Edit…
    Add profile…
    ``` ## Examples ### Menubar demo An editor's File, Edit and View menus, with shortcuts, checkboxes, radios and a submenu. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Menubar, MenubarCheckboxItem, MenubarContent, MenubarGroup, MenubarItem, MenubarItemIndicator, MenubarRadioGroup, MenubarRadioItem, MenubarSeparator, MenubarShortcut, MenubarSubContent, MenubarSubTrigger, MenubarTrigger, } from '@/ui/menubar/menubar'; /** * An editor's File, Edit, View and Profiles menus, with shortcuts, a * disabled item, a submenu, checkbox items and a radio group. The menubar * is one tab stop; the arrow keys move between its menus. */ @Component({ selector: 'example-menubar-demo', imports: [ Menubar, MenubarCheckboxItem, MenubarContent, MenubarGroup, MenubarItem, MenubarItemIndicator, MenubarRadioGroup, MenubarRadioItem, MenubarSeparator, MenubarShortcut, MenubarSubContent, MenubarSubTrigger, MenubarTrigger, NdIcon, ], template: `
    File
    Edit
    View
    Profiles

    {{ chosen() }}

    New tab ⌘T
    New window ⌘N
    New incognito window
    Share
    Print… ⌘P
    Email link
    Messages
    Notes
    Undo ⌘Z
    Redo ⇧⌘Z
    Cut
    Copy
    Paste
    Always show bookmarks bar
    Always show full URLs
    Reload ⌘R
    Force reload ⇧⌘R
    Toggle full screen
    @for (name of names; track name) {
    {{ name }}
    }
    Edit…
    Add profile…
    `, }) export class MenubarDemoExample { protected readonly chosen = signal(''); protected readonly bookmarks = signal(false); protected readonly fullUrls = signal(true); protected readonly profile = signal('Benoit'); protected readonly names = ['Andy', 'Benoit', 'Luis']; /** * Reports what was chosen. * * @param action - The item's name. */ protected choose(action: string): void { this.chosen.set(`Chose ${action}.`); } } ``` ## Accessibility Implements the [Menubar](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Tab` | Moves focus into the menubar, to its first trigger; from a trigger, out of the menubar. | | `ArrowRight` or `ArrowLeft` | Moves focus to the next or previous trigger, wrapping; if a menu is open, opens that trigger's menu instead. | | `ArrowDown` | On a trigger, opens its menu and focuses its first item. | | `Enter` or `Space` | On a trigger, opens its menu and focuses its first item. | | `Escape` | Closes the open menu and returns focus to its trigger. | | `ArrowDown` or `ArrowUp` | Moves focus to the next or previous item, wrapping. | | `Home` or `End` | Moves focus to the first or last item. | | `Enter` | Activates the focused item and closes the menu. | | `Space` | Activates the focused item; checkbox and radio items keep the menu open. | | `ArrowRight` | On a sub-trigger, opens its menu and focuses the first item. | | `ArrowLeft` | In a submenu, closes it and focuses its sub-trigger. | | `Escape` | Closes the current menu and returns focus to what opened it. | | `Tab` | Closes every open menu and moves focus on from the trigger. | | `a` | Typeahead: moves focus to the next item whose label starts with the typed letters. | ### Focus - The menubar is one tab stop, whose focus goes to its first trigger. - Closing a menu returns focus to its trigger. - While one menu is open, hovering another trigger opens its menu. - Opened from the keyboard, a menu focuses its first item (its last with `ArrowUp` on a Dropdown Menu trigger); opened with the pointer, the menu itself. - Closing returns focus to the trigger or sub-trigger. ### Right-to-left In right-to-left layouts, the bar runs from right to left: `ArrowLeft` moves to the next trigger and `ArrowRight` to the previous one, and menus align to the trigger's right edge. Direction comes from the app's `Directionality`: the document's `dir`, or a CDK `[dir]` directive. `ArrowLeft` opens submenus and `ArrowRight` closes them, and submenus open on the left. Direction comes from the app's `Directionality`: the document's `dir`, or a CDK `[dir]` directive. ## API reference ### Menubar Selector: `[ndMenubar]` · Role: `menubar` A menubar (`@ndop/primitives/menubar`): a bar of menus, such as a desktop app's File and Edit menus. It's one tab stop; the arrow keys move between its triggers, and between their menus while one is open. Name it with `aria-label`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `menubar` | ### MenubarTrigger Selector: `[ndMenubarTrigger]` · Role: `menuitem` One menu of a menubar: its value is the menu's `ng-template`, which opens below it. Use a `div`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[ndMenubarTrigger]` | `TemplateRef \| null` | `null` | The `ng-template` of the menu it opens. | | `[side]` | `'bottom' \| 'top' \| 'start' \| 'end'` | `'bottom'` | Side of the trigger the menu opens on, before flipping to fit. | | `[align]` | `'start' \| 'end' \| 'center'` | `'start'` | Alignment of the menu along that side. | | `[sideOffset]` | `number` | `8` | Gap between the trigger and the menu, in pixels. | | Data attribute | Values | | --- | --- | | `data-slot` | `menubar-trigger` | | `data-state` | | ### MenubarContent Selector: `[ndMenubarContent]` · Role: `menu` A menubar menu's list (`@ndop/primitives/menu`), in the `ng-template` a `[ndMenubarTrigger]` button opens. Arrow keys, Home, End and typing move between items; Escape closes it and returns focus to its trigger. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `menubar-content` | | `data-state` | | | `data-side` | | ### MenubarSubContent Selector: `[ndMenubarSubContent]` · Role: `menu` A submenu, opened beside its sub-trigger: at the end side, so on the left in right-to-left layouts. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `menubar-sub-content` | | `data-state` | | | `data-side` | | ### MenubarGroup Selector: `[ndMenubarGroup]` · Role: `group` Groups related items (`role="group"`). Name it with `aria-labelledby` pointing at a label. | Data attribute | Values | | --- | --- | | `data-slot` | `menubar-group` | ### MenubarItem Selector: `[ndMenubarItem]` · Role: `menuitem` One action (`role="menuitem"`): activating it emits `triggered` and closes the menu. Disabled items stay focusable and do nothing. Use a `div`: on a `button`, Space would close the menu. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[inset]` | `boolean` | `false` | Indents it to line up with items that have an indicator. | | `[variant]` | `'default' \| 'destructive'` | `'default'` | `destructive` colors a dangerous action, such as Delete. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[disabled]` | `boolean` | `false` | Makes it inert but focusable, with `aria-disabled`. | | Data attribute | Values | | --- | --- | | `data-slot` | `menubar-item` | | `data-inset` | | | `data-variant` | | | `data-disabled` | | ### MenubarCheckboxItem Selector: `[ndMenubarCheckboxItem]` · Role: `menuitemcheckbox` An item that is on or off (`role="menuitemcheckbox"`), bound with `[(checked)]`. Put an indicator with a check icon in it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[disabled]` | `boolean` | `false` | Makes it inert but focusable, with `aria-disabled`. | | `[(checked)]` | `boolean` | `false` | Whether it's on. Two-way bindable with `[(checked)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `menubar-checkbox-item` | | `data-state` | | | `data-disabled` | | ### MenubarRadioGroup Selector: `[ndMenubarRadioGroup]` · Role: `group` Groups radio items (`role="group"`) and holds the checked value, bound with `[(value)]`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[(value)]` | `string \| null` | `null` | The checked radio item's value, or `null`. Two-way bindable with `[(value)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `menubar-radio-group` | ### MenubarRadioItem Selector: `[ndMenubarRadioItem]` · Role: `menuitemradio` One choice of a radio group (`role="menuitemradio"`). Put an indicator with a small filled circle in it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[value]` (required) | `string` | | The value its group takes when it's checked. | | `[disabled]` | `boolean` | `false` | Makes it inert but focusable, with `aria-disabled`. | | Data attribute | Values | | --- | --- | | `data-slot` | `menubar-radio-item` | | `data-state` | | | `data-disabled` | | ### MenubarItemIndicator Selector: `[ndMenubarItemIndicator]` The check or dot at the start of a checkbox or radio item, shown while it is checked. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `menubar-item-indicator` | | `data-state` | | ### MenubarLabel Selector: `[ndMenubarLabel]` A heading for a group of items; point the group's `aria-labelledby` at it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[inset]` | `boolean` | `false` | Indents it to line up with items that have an indicator. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `menubar-label` | | `data-inset` | | ### MenubarSeparator Selector: `[ndMenubarSeparator]` · Role: `separator` A line between groups of items (`role="separator"`). | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `menubar-separator` | ### MenubarShortcut Selector: `[ndMenubarShortcut]` The keyboard shortcut of an item, at its end, as a reminder; the menu doesn't handle it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `menubar-shortcut` | ### MenubarSubTrigger Selector: `[ndMenubarSubTrigger]` · Role: `menuitem` An item that opens the submenu in its template. Put a chevron icon at its end with `ms-auto`, turned around in right-to-left layouts. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[inset]` | `boolean` | `false` | Indents it to line up with items that have an indicator. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[ndMenubarSubTrigger]` | `TemplateRef \| null` | `null` | The `ng-template` of the submenu. | | `[disabled]` | `boolean` | `false` | Makes it inert but focusable, with `aria-disabled`. | | Data attribute | Values | | --- | --- | | `data-slot` | `menubar-sub-trigger` | | `data-inset` | | | `data-state` | | | `data-disabled` | | ## Agent notes ### Do - Use it in app-like screens, such as an editor's toolbar. - Name it with `aria-label`. - Show keyboard shortcuts the app really binds. ### Don't - Don't use it for site navigation; use Navigation Menu. - Don't use it for one menu; use Dropdown Menu. --- # Navigation Menu > A site's navigation: links and buttons that show panels of links. Version 1.0.0 · WAI-ARIA pattern: [Disclosure navigation](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/) · SSR · Zoneless · 3.5 kB gzip A navigation menu is a site's main navigation: links, and buttons that show panels of more links below them. It uses no menu roles, as screen readers expect for links, and each panel stays right after its button in the page, so Tab moves into its links and they're in the server-rendered HTML. A panel opens on click, Enter or Space, or when the pointer rests on its button, and Escape closes it. ## Installation ```bash npx @ndop/cli add navigation-menu ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/navigation-menu/navigation-menu.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdNavigationMenu, NdNavigationMenuContent, NdNavigationMenuItem, NdNavigationMenuLink, NdNavigationMenuList, NdNavigationMenuTrigger, } from '@ndop/primitives/navigation-menu'; import { cn } from '@/lib/utils'; /** * Classes of a navigation menu's buttons; give them to a top-level link so * it looks like one. */ export const navigationMenuTriggerStyle = 'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-[color,box-shadow] outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent/50 data-[state=open]:text-accent-foreground data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent'; /** * A site's navigation (`@ndop/primitives/navigation-menu`): links, and * buttons that show panels of links below them. Each panel stays after its * button in the page, so its links render on the server. Put it on a `nav` * named with `aria-label`. * * @example * ```html * * ``` */ @Directive({ selector: '[ndNavigationMenu]', hostDirectives: [ { directive: NdNavigationMenu, inputs: ['value', 'openDelay', 'closeDelay'], outputs: ['valueChange'], }, ], host: { 'data-slot': 'navigation-menu', // Panels sit under their own items, as shadcn's `viewport={false}`. 'data-viewport': 'false', '[class]': 'classes()', }, }) export class NavigationMenu { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center', this.class(), ), ); } /** * The list of top-level items, on a `ul`. * * @example * ```html *
      …
    * ``` */ @Directive({ selector: '[ndNavigationMenuList]', hostDirectives: [NdNavigationMenuList], host: { 'data-slot': 'navigation-menu-list', '[class]': 'classes()' }, }) export class NavigationMenuList { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group flex flex-1 list-none items-center justify-center gap-1', this.class(), ), ); } /** * One top-level item, on an `li`: a link, or a button with its panel. * * @example * ```html *
  • …
  • * ``` */ @Directive({ selector: '[ndNavigationMenuItem]', hostDirectives: [{ directive: NdNavigationMenuItem, inputs: ['value'] }], host: { 'data-slot': 'navigation-menu-item', '[class]': 'classes()' }, }) export class NavigationMenuItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('relative', this.class())); } /** * The button that shows its item's panel. Put the chevron icon in it, * last; it turns over while the panel is open. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndNavigationMenuTrigger]', hostDirectives: [ { directive: NdNavigationMenuTrigger, inputs: ['disabled'] }, ], host: { 'data-slot': 'navigation-menu-trigger', '[class]': 'classes()' }, }) export class NavigationMenuTrigger { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( navigationMenuTriggerStyle, 'group', '[&>svg]:relative [&>svg]:top-[1px] [&>svg]:ms-1 [&>svg]:size-3 [&>svg]:transition [&>svg]:duration-300 group-data-[state=open]:[&>svg]:rotate-180', this.class(), ), ); } /** * Its item's panel of links, shown below the item from the `md` * breakpoint, and in the flow of the page below it. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndNavigationMenuContent]', hostDirectives: [NdNavigationMenuContent], host: { 'data-slot': 'navigation-menu-content', '[class]': 'classes()' }, }) export class NavigationMenuContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'top-0 start-0 w-full p-2 pe-2.5 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out md:absolute md:w-auto', 'group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95', 'z-50', this.class(), ), ); } /** * A link, at the top level or in a panel; `active` marks the current page. * At the top level, give it {@link navigationMenuTriggerStyle} to match the * buttons. * * @example * ```html * Docs * ``` */ @Directive({ selector: 'a[ndNavigationMenuLink]', hostDirectives: [{ directive: NdNavigationMenuLink, inputs: ['active'] }], host: { 'data-slot': 'navigation-menu-link', '[class]': 'classes()' }, }) export class NavigationMenuLink { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground data-[active=true]:hover:bg-accent data-[active=true]:focus:bg-accent [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, navigationMenuTriggerStyle, } from '@/ui/navigation-menu/navigation-menu'; ``` ```html ``` ## Examples ### Navigation Menu demo A site's navigation with two panels of links and a top-level link. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, navigationMenuTriggerStyle, } from '@/ui/navigation-menu/navigation-menu'; /** * A site's navigation: two buttons with panels of links, and a top-level * link to the current page. Each panel follows its button in the page, so * Tab moves into its links. */ @Component({ selector: 'example-navigation-menu-demo', imports: [ NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, NdIcon, ], template: ` `, }) export class NavigationMenuDemoExample { protected readonly triggerStyle = navigationMenuTriggerStyle; protected readonly guides = [ { href: '#introduction', title: 'Introduction', text: 'Accessible Angular components you copy into your app.', }, { href: '#installation', title: 'Installation', text: 'Set up Ndop in a new or existing Angular app.', }, { href: '#theming', title: 'Theming', text: 'Colors, radius and fonts, as CSS variables.', }, { href: '#forms', title: 'Forms', text: 'Bind Signal Forms, reactive or template-driven forms.', }, ]; protected readonly components = [ { href: '#dialog', title: 'Dialog' }, { href: '#select', title: 'Select' }, { href: '#tabs', title: 'Tabs' }, ]; } ``` ## Accessibility Implements the [Disclosure navigation](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Tab` | Moves focus through the buttons, top-level links and the open panel's links, in document order. | | `Enter` or `Space` | On a button, shows or hides its panel. | | `ArrowDown` | On a button, opens its panel and focuses its first link. | | `ArrowRight` or `ArrowLeft` | On a top-level button or link, moves focus to the next or previous one, wrapping. | | `Home` or `End` | On a top-level button or link, moves focus to the first or last one. | | `Escape` | Closes the open panel and returns focus to its button. | ### Focus - Opening a panel with `Enter`, `Space` or a click keeps focus on the button. - Focus leaving the navigation closes the open panel. ### Right-to-left In right-to-left layouts the top-level items run from right to left, so `ArrowLeft` moves to the next one and `ArrowRight` to the previous one. Direction comes from the nearest `dir` attribute. ## API reference ### NavigationMenu Selector: `[ndNavigationMenu]` A site's navigation (`@ndop/primitives/navigation-menu`): links, and buttons that show panels of links below them. Each panel stays after its button in the page, so its links render on the server. Put it on a `nav` named with `aria-label`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[openDelay]` | `number` | `200` | How long the pointer rests on a button before its panel opens, in ms. | | `[closeDelay]` | `number` | `300` | How long a panel stays after the pointer leaves its item, in ms. | | `[(value)]` | `string \| null` | `null` | The open item's value, or `null` when every panel is closed. Two-way bindable with `[(value)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `navigation-menu` | | `data-viewport` | `false` | | `data-state` | | ### NavigationMenuList Selector: `[ndNavigationMenuList]` The list of top-level items, on a `ul`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `navigation-menu-list` | ### NavigationMenuItem Selector: `[ndNavigationMenuItem]` One top-level item, on an `li`: a link, or a button with its panel. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[value]` | `string` | `injectId('nd-navigation-menu-item')` | Its value, which the root's `value` holds while it's open. | | Data attribute | Values | | --- | --- | | `data-slot` | `navigation-menu-item` | | `data-state` | | ### NavigationMenuTrigger Selector: `button[ndNavigationMenuTrigger]` The button that shows its item's panel. Put the chevron icon in it, last; it turns over while the panel is open. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[disabled]` | `boolean` | `false` | Disables it. | | Data attribute | Values | | --- | --- | | `data-slot` | `navigation-menu-trigger` | | `data-state` | | | `data-disabled` | | ### NavigationMenuContent Selector: `[ndNavigationMenuContent]` Its item's panel of links, shown below the item from the `md` breakpoint, and in the flow of the page below it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `navigation-menu-content` | | `data-state` | | ### NavigationMenuLink Selector: `a[ndNavigationMenuLink]` A link, at the top level or in a panel; `active` marks the current page. At the top level, give it `navigationMenuTriggerStyle` to match the buttons. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[active]` | `boolean` | `false` | Marks the link to the current page. | | Data attribute | Values | | --- | --- | | `data-slot` | `navigation-menu-link` | | `data-active` | | ## Agent notes ### Do - Put it on a `nav` with an `aria-label`. - Mark the link to the current page with `active`. - Give top-level links `navigationMenuTriggerStyle` so they match the buttons. ### Don't - Don't put actions or form fields in a panel; use Dropdown Menu or Popover. - Don't hide the only link to an important page in a panel. --- # Pagination > Navigates between pages of results. Version 1.0.0 · SSR · Zoneless · 1.4 kB gzip Pagination moves between pages of results. It's a `nav` landmark named "Pagination" with a list of links, styled as buttons: ghost for other pages, outlined for the current one, which is marked `aria-current="page"`. The previous and next links are named for screen readers, and their chevrons turn around in right-to-left layouts. ## Installation ```bash npx @ndop/cli add pagination ``` Or by hand: install `@ndop/core`, `@ndop/primitives`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/pagination/pagination.ts ```ts import { booleanAttribute, computed, Directive, input } from '@angular/core'; import { cn } from '@/lib/utils'; import { buttonVariants, type ButtonSize } from '@/ui/button/button'; /** * Page navigation for a list of results, as a `nav` landmark. * * @example * ```html * * ``` */ @Directive({ selector: 'nav[ndPagination]', host: { 'data-slot': 'pagination', '[attr.aria-label]': 'label()', '[class]': 'classes()', }, }) export class Pagination { /** Name of the landmark. */ readonly label = input('Pagination'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('mx-auto flex w-full justify-center', this.class()), ); } /** * The list of a pagination's links. * * @example * ```html *
      …
    * ``` */ @Directive({ selector: 'ul[ndPaginationContent]', host: { 'data-slot': 'pagination-content', '[class]': 'classes()' }, }) export class PaginationContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex flex-row items-center gap-1', this.class()), ); } /** * One entry of a pagination: a link, or an ellipsis. * * @example * ```html *
  • …
  • * ``` */ @Directive({ selector: 'li[ndPaginationItem]', host: { 'data-slot': 'pagination-item' }, }) export class PaginationItem {} /** * A link to one page, styled as a ghost button, or an outline one for the * current page, which it marks `aria-current="page"`. On a `button`, it * calls your handler instead of navigating. * * @example * ```html * 2 * ``` */ @Directive({ selector: 'a[ndPaginationLink], button[ndPaginationLink]', host: { 'data-slot': 'pagination-link', '[attr.aria-current]': 'active() ? "page" : null', '[attr.data-active]': 'active()', '[class]': 'classes()', }, }) export class PaginationLink { /** Whether it's the current page. */ readonly active = input(false, { transform: booleanAttribute }); /** Button size. */ readonly size = input('icon'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: this.active() ? 'outline' : 'ghost', size: this.size(), }), this.class(), ), ); } /** * The link to the previous page. Put a `chevron-left` icon and the word * "Previous" in it; the icon turns around in right-to-left layouts. * * @example * ```html * Previous * ``` */ @Directive({ selector: 'a[ndPaginationPrevious], button[ndPaginationPrevious]', host: { 'data-slot': 'pagination-link', 'aria-label': 'Go to previous page', '[class]': 'classes()', }, }) export class PaginationPrevious { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: 'ghost', size: 'default' }), 'gap-1 px-2.5 sm:ps-2.5', 'rtl:[&>svg]:rotate-180', this.class(), ), ); } /** * The link to the next page. Put the word "Next" and a `chevron-right` * icon in it; the icon turns around in right-to-left layouts. * * @example * ```html * Next * ``` */ @Directive({ selector: 'a[ndPaginationNext], button[ndPaginationNext]', host: { 'data-slot': 'pagination-link', 'aria-label': 'Go to next page', '[class]': 'classes()', }, }) export class PaginationNext { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: 'ghost', size: 'default' }), 'gap-1 px-2.5 sm:pe-2.5', 'rtl:[&>svg]:rotate-180', this.class(), ), ); } /** * Stands for pages left out of a long range; hidden from assistive * technology. Put an `ellipsis` icon in it. * * @example * ```html * * ``` */ @Directive({ selector: '[ndPaginationEllipsis]', host: { 'data-slot': 'pagination-ellipsis', 'aria-hidden': 'true', '[class]': 'classes()', }, }) export class PaginationEllipsis { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex size-9 items-center justify-center', '[&>svg]:size-4', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ### src/app/ui/button/button.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdButton } from '@ndop/primitives/button'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * Class table for {@link Button}; also usable to style other elements like * a button. */ export const buttonVariants = variants({ base: "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-disabled:pointer-events-none data-disabled:opacity-50", variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', }, size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', icon: 'size-9', 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", 'icon-sm': 'size-8', 'icon-lg': 'size-10', }, }, defaults: { variant: 'default', size: 'default' }, }); /** Visual style of a {@link Button}. */ export type ButtonVariant = VariantOf; /** Size of a {@link Button}. */ export type ButtonSize = VariantOf; /** * Styled button for a native `button` or an `a`, with the * `@ndop/primitives/button` behavior (safe default type, disabled links). * * @example * ```html * * Docs * ``` */ @Directive({ selector: 'button[ndButton], a[ndButton]', hostDirectives: [{ directive: NdButton, inputs: ['disabled'] }], host: { 'data-slot': 'button', '[attr.data-variant]': 'variant()', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class Button { /** Visual style. */ readonly variant = input('default'); /** Height, padding and icon size. */ readonly size = input('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: this.variant(), size: this.size() }), this.class(), ), ); } ``` ## Usage ```ts import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from '@/ui/pagination/pagination'; ``` ```html ``` ## Examples ### Pagination demo Links to pages, with the current one marked. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from '@/ui/pagination/pagination'; /** Links to pages, with the current one marked. */ @Component({ selector: 'example-pagination-demo', imports: [ NdIcon, Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, ], template: ` `, }) export class PaginationDemoExample {} ``` ### Pagination state Pages kept in component state, with buttons. ```ts import { Component, computed, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from '@/ui/pagination/pagination'; /** * Pages kept in component state: buttons instead of links, with the * current page marked and the arrows disabled at either end. */ @Component({ selector: 'example-pagination-state', imports: [ NdIcon, Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, ], template: `

    Showing results {{ (page() - 1) * 10 + 1 }} to {{ page() * 10 }} of 50

    `, }) export class PaginationStateExample { protected readonly count = 5; protected readonly page = signal(1); protected readonly pages = computed(() => Array.from({ length: this.count }, (_, index) => index + 1), ); } ``` ## Accessibility Pagination has no keyboard interaction or focus handling of its own; it keeps the semantics of the element it's on. ## API reference ### Pagination Selector: `nav[ndPagination]` Page navigation for a list of results, as a `nav` landmark. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[label]` | `string` | `'Pagination'` | Name of the landmark. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `pagination` | ### PaginationContent Selector: `ul[ndPaginationContent]` The list of a pagination's links. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `pagination-content` | ### PaginationItem Selector: `li[ndPaginationItem]` One entry of a pagination: a link, or an ellipsis. | Data attribute | Values | | --- | --- | | `data-slot` | `pagination-item` | ### PaginationLink Selector: `a[ndPaginationLink], button[ndPaginationLink]` A link to one page, styled as a ghost button, or an outline one for the current page, which it marks `aria-current="page"`. On a `button`, it calls your handler instead of navigating. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[active]` | `boolean` | `false` | Whether it's the current page. | | `[size]` | `'default' \| 'xs' \| 'sm' \| 'lg' \| 'icon' \| 'icon-xs' \| 'icon-sm' \| 'icon-lg'` | `'icon'` | Button size. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `pagination-link` | | `data-active` | | ### PaginationPrevious Selector: `a[ndPaginationPrevious], button[ndPaginationPrevious]` The link to the previous page. Put a `chevron-left` icon and the word "Previous" in it; the icon turns around in right-to-left layouts. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `pagination-link` | ### PaginationNext Selector: `a[ndPaginationNext], button[ndPaginationNext]` The link to the next page. Put the word "Next" and a `chevron-right` icon in it; the icon turns around in right-to-left layouts. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `pagination-link` | ### PaginationEllipsis Selector: `[ndPaginationEllipsis]` Stands for pages left out of a long range; hidden from assistive technology. Put an `ellipsis` icon in it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `pagination-ellipsis` | ## Agent notes ### Do - Use links with an `href` (or `routerLink`) when each page has a URL, so pages can be bookmarked and opened in a new tab. - Use `button[ndPaginationLink]` when the page lives only in component state, and keep `active` in sync. - Show the first, last and nearby pages, with `ndPaginationEllipsis` for the gaps. ### Don't - Don't paginate a short list that fits on one page. - Don't leave out the previous and next links on long ranges. --- # Popover > Displays rich content in a portal, triggered by a button. Version 1.0.0 · WAI-ARIA pattern: [dialog role](https://www.w3.org/TR/wai-aria-1.2/#dialog) · SSR · Zoneless · 3.7 kB gzip A popover shows rich, interactive content next to the element that opens it, such as a small form or a list of filters. It is non-modal: the rest of the page stays usable, and clicking outside or pressing Escape closes it, returning focus to the trigger. `side` and `align` place it, using logical `start` and `end` so it mirrors in right-to-left layouts. ## Installation ```bash npx @ndop/cli add popover ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/popover/popover.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdPopover, NdPopoverClose, NdPopoverContent, NdPopoverPortal, NdPopoverTrigger, } from '@ndop/primitives/popover'; import { cn } from '@/lib/utils'; /** * A popover (`@ndop/primitives/popover`): rich content anchored to its * trigger, opened and closed with it. Put it on an `ng-container`; `open` * is two-way bindable. * * @example * ```html * … * ``` */ @Directive({ selector: '[ndPopover]', hostDirectives: [ { directive: NdPopover, inputs: ['open', 'defaultOpen'], outputs: ['openChange'], }, ], }) export class Popover {} /** * The button that opens and closes its popover. * * @example * ```html * * ``` */ @Directive({ selector: '[ndPopoverTrigger]', hostDirectives: [NdPopoverTrigger], host: { 'data-slot': 'popover-trigger' }, }) export class PopoverTrigger {} /** * Marks the `ng-template` its popover renders while open. * * @example * ```html * … * ``` */ @Directive({ selector: 'ng-template[ndPopoverPortal]', hostDirectives: [NdPopoverPortal], }) export class PopoverPortal {} /** * A button that closes its popover. * * @example * ```html * * ``` */ @Directive({ selector: '[ndPopoverClose]', hostDirectives: [NdPopoverClose], host: { 'data-slot': 'popover-close' }, }) export class PopoverClose {} /** * The popover panel, placed next to its trigger. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndPopoverContent]', hostDirectives: [ { directive: NdPopoverContent, inputs: ['side', 'align', 'sideOffset'] }, ], host: { 'data-slot': 'popover-content', '[class]': 'classes()' }, }) export class PopoverContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=start]:slide-in-from-end-2 data-[side=end]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', this.class(), ), ); } /** * Layout for the popover's title and description. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndPopoverHeader]', host: { 'data-slot': 'popover-header', '[class]': 'classes()' }, }) export class PopoverHeader { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex flex-col gap-1 text-sm', this.class()), ); } /** * The popover's title. Point the content's `aria-labelledby` at it. * * @example * ```html *

    Filters

    * ``` */ @Directive({ selector: '[ndPopoverTitle]', host: { 'data-slot': 'popover-title', '[class]': 'classes()' }, }) export class PopoverTitle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('font-medium', this.class())); } /** * Supporting text under the popover's title. * * @example * ```html *

    Narrow the results.

    * ``` */ @Directive({ selector: '[ndPopoverDescription]', host: { 'data-slot': 'popover-description', '[class]': 'classes()' }, }) export class PopoverDescription { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('text-muted-foreground', this.class()), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Button } from '@/ui/button/button'; import { Input } from '@/ui/input/input'; import { Label } from '@/ui/label/label'; import { Popover, PopoverPortal, PopoverTrigger, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, } from '@/ui/popover/popover'; ``` ```html

    Dimensions

    Set the dimensions for the layer.

    @for (field of fields; track field.id) {
    }
    ``` ## Examples ### Popover demo A popover holding a small dimensions form. ```ts import { Component } from '@angular/core'; import { Button } from '@/ui/button/button'; import { Input } from '@/ui/input/input'; import { Label } from '@/ui/label/label'; import { Popover, PopoverPortal, PopoverTrigger, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, } from '@/ui/popover/popover'; /** A popover holding a small dimensions form. */ @Component({ selector: 'example-popover-demo', imports: [ Popover, PopoverTrigger, PopoverPortal, PopoverContent, PopoverHeader, PopoverTitle, PopoverDescription, Button, Input, Label, ], template: `

    Dimensions

    Set the dimensions for the layer.

    @for (field of fields; track field.id) {
    }
    `, }) export class PopoverDemoExample { protected readonly fields = [ { id: 'popover-demo-width', label: 'Width', value: '100%' }, { id: 'popover-demo-max-width', label: 'Max. width', value: '300px' }, { id: 'popover-demo-height', label: 'Height', value: '25px' }, { id: 'popover-demo-max-height', label: 'Max. height', value: 'none' }, ]; } ``` ### Popover placement Popovers on each logical side, with a close button inside. `start` and `end` follow the text direction. ```ts import { Component } from '@angular/core'; import { Button } from '@/ui/button/button'; import { Popover, PopoverClose, PopoverPortal, PopoverTrigger, PopoverContent, } from '@/ui/popover/popover'; /** * Popovers on each logical side, with a close button inside. `start` and * `end` follow the text direction. */ @Component({ selector: 'example-popover-placement', imports: [ Popover, PopoverTrigger, PopoverPortal, PopoverClose, PopoverContent, Button, ], template: `
    @for (placement of placements; track placement.side) {

    Placed on the {{ placement.side }} side.

    }
    `, }) export class PopoverPlacementExample { protected readonly placements = [ { side: 'top', align: 'center' }, { side: 'end', align: 'start' }, { side: 'bottom', align: 'center' }, { side: 'start', align: 'start' }, ] as const; } ``` ## Accessibility Implements the [dialog role](https://www.w3.org/TR/wai-aria-1.2/#dialog) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Escape` | Closes the popover and returns focus to the trigger. | | `Tab` | Moves focus through the content; past the last element, the popover closes and focus moves to what follows the trigger. | | `Shift + Tab` | Moves focus backwards through the content; before the first element, the popover closes and focus returns to the trigger. | ### Focus - On open: focus moves to the first focusable element in the content, or to the content element. - Focus is not trapped. - On close with Escape or PopoverClose: focus returns to the trigger. - On close because focus or a pointer left: focus stays where the user put it. ### Right-to-left `side="start"` and `side="end"` place the content on the inline start and end of the trigger, which swap in RTL; `align="start"` and `align="end"` swap likewise. ## API reference ### Popover Selector: `[ndPopover]` A popover (`@ndop/primitives/popover`): rich content anchored to its trigger, opened and closed with it. Put it on an `ng-container`; `open` is two-way bindable. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[defaultOpen]` | `boolean` | `false` | Initial open state when `open` is not bound. | | `[(open)]` | `boolean` | `false` | Whether the popover is open. Two-way bindable with `[(open)]`. | ### PopoverTrigger Selector: `[ndPopoverTrigger]` The button that opens and closes its popover. | Data attribute | Values | | --- | --- | | `data-slot` | `popover-trigger` | | `data-state` | | ### PopoverPortal Selector: `ng-template[ndPopoverPortal]` Marks the `ng-template` its popover renders while open. ### PopoverClose Selector: `[ndPopoverClose]` A button that closes its popover. | Data attribute | Values | | --- | --- | | `data-slot` | `popover-close` | ### PopoverContent Selector: `[ndPopoverContent]` · Role: `dialog` The popover panel, placed next to its trigger. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[side]` | `'bottom' \| 'top' \| 'start' \| 'end'` | `'bottom'` | Preferred side of the trigger; `start` and `end` swap in RTL. | | `[align]` | `'start' \| 'end' \| 'center'` | `'center'` | Preferred alignment along that side. | | `[sideOffset]` | `number` | `4` | Gap from the trigger, in pixels. | | Data attribute | Values | | --- | --- | | `data-slot` | `popover-content` | | `data-state` | | | `data-side` | | | `data-align` | | ### PopoverHeader Selector: `[ndPopoverHeader]` Layout for the popover's title and description. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `popover-header` | ### PopoverTitle Selector: `[ndPopoverTitle]` The popover's title. Point the content's `aria-labelledby` at it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `popover-title` | ### PopoverDescription Selector: `[ndPopoverDescription]` Supporting text under the popover's title. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `popover-description` | ## Agent notes ### Do - Name the content with `aria-labelledby` pointing at a heading inside it, or with `aria-label`. - Keep the content short and related to its trigger. - Use `start` and `end` rather than left and right, so the placement follows the text direction. ### Don't - Don't use a popover for a decision that must block the page; use a dialog. - Don't put a popover's only copy of important information behind it; keep it reachable on the page too. --- # Progress > Displays how far a task has got. Version 1.0.0 · WAI-ARIA pattern: [progressbar role](https://www.w3.org/TR/wai-aria-1.2/#progressbar) · SSR · Zoneless · 1.0 kB gzip A progress bar shows how far a task has got: an upload, an installation, the steps of a form. It's announced as a `progressbar` with its value and percentage, and fills from the start side, so from the right in right-to-left layouts. Leave `value` empty while you can't say how far it has got, and it pulses instead. ## Installation ```bash npx @ndop/cli add progress ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/progress/progress.ts ```ts import { computed, Directive, inject, input } from '@angular/core'; import { ND_PROGRESS, NdProgress, NdProgressIndicator, } from '@ndop/primitives/progress'; import { cn } from '@/lib/utils'; /** * A progress bar (`@ndop/primitives/progress`): how far a task has got, * from 0 to `max`, or indeterminate while `value` is `null`. Give it a * name with `aria-label` or `aria-labelledby`, and put a * `ndProgressIndicator` inside. * * @example * ```html *
    *
    *
    * ``` */ @Directive({ selector: '[ndProgress]', hostDirectives: [{ directive: NdProgress, inputs: ['value', 'max'] }], host: { 'data-slot': 'progress', '[class]': 'classes()' }, }) export class Progress { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative h-2 w-full overflow-hidden rounded-full bg-primary/20', this.class(), ), ); } /** * The filled part of a progress bar. It fills from the start side, so * from the right in right-to-left layouts, and pulses across the whole * bar while indeterminate. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndProgressIndicator]', hostDirectives: [NdProgressIndicator], host: { 'data-slot': 'progress-indicator', '[style.width.%]': 'progress.percent()', '[class]': 'classes()', }, }) export class ProgressIndicator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly progress = inject(ND_PROGRESS); protected readonly classes = computed(() => cn( 'h-full flex-1 bg-primary transition-all', 'data-[state=indeterminate]:w-full data-[state=indeterminate]:animate-pulse', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Progress, ProgressIndicator } from '@/ui/progress/progress'; ``` ```html
    Setting up your workspace
    ``` ## Examples ### Progress demo A labelled progress bar. ```ts import { Component } from '@angular/core'; import { Progress, ProgressIndicator } from '@/ui/progress/progress'; /** A progress bar named by a visible label. */ @Component({ selector: 'example-progress-demo', imports: [Progress, ProgressIndicator], template: `
    Setting up your workspace
    `, }) export class ProgressDemoExample {} ``` ### Progress upload Files uploading, each with its progress and percentage. ```ts import { Component } from '@angular/core'; import { Progress, ProgressIndicator } from '@/ui/progress/progress'; /** * Files uploading, each with its own bar and percentage; the last one * waits to start, so its bar is indeterminate. */ @Component({ selector: 'example-progress-upload', imports: [Progress, ProgressIndicator], template: `
      @for (file of files; track file.name) {
    • {{ file.name }} {{ file.value === null ? 'Waiting' : file.value + '%' }}
    • }
    `, }) export class ProgressUploadExample { protected readonly files = [ { name: 'report.pdf', value: 100 }, { name: 'photos.zip', value: 45 }, { name: 'video.mp4', value: null }, ]; } ``` ## Accessibility Implements the [progressbar role](https://www.w3.org/TR/wai-aria-1.2/#progressbar) pattern of the WAI-ARIA Authoring Practices. ### Focus - The progress bar never receives focus and never changes focus. ### Right-to-left The indicator fills from the start side: from the right in right-to-left layouts. ## API reference ### Progress Selector: `[ndProgress]` · Role: `progressbar` A progress bar (`@ndop/primitives/progress`): how far a task has got, from 0 to `max`, or indeterminate while `value` is `null`. Give it a name with `aria-label` or `aria-labelledby`, and put a `ndProgressIndicator` inside. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[value]` | `number \| null` | `null` | How far the task has got, from 0 to `max`; `null` while unknown. | | `[max]` | `number` | `100` | The value of a finished task. | | Data attribute | Values | | --- | --- | | `data-slot` | `progress` | | `data-state` | | | `data-value` | | | `data-max` | | ### ProgressIndicator Selector: `[ndProgressIndicator]` The filled part of a progress bar. It fills from the start side, so from the right in right-to-left layouts, and pulses across the whole bar while indeterminate. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `progress-indicator` | | `data-state` | | ## Agent notes ### Do - Name it with `aria-label` or `aria-labelledby`, such as "Upload". - Show the percentage or what's left in text next to it, too. - Use a Spinner instead when there's no measure of progress at all. ### Don't - Don't use it to show a static quantity, such as storage used; that's a meter. - Don't move it backwards. --- # Radio Group > A set of options of which one at most is chosen, navigated with the arrow keys. Version 1.0.0 · WAI-ARIA pattern: [Radio Group](https://www.w3.org/WAI/ARIA/apg/patterns/radio/) · SSR · Zoneless · 3.1 kB gzip A radio group offers a few options of which one is chosen, such as a plan or a delivery speed. It's announced as a radio group: Tab moves into it, to the chosen option, and the arrow keys move between options and choose them, mirrored in right-to-left layouts. Its value is the chosen option's `value`, bound with `[(value)]` or any forms library. ## Installation ```bash npx @ndop/cli add radio-group ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/radio-group/radio-group.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdRadioGroup, NdRadioGroupIndicator, NdRadioGroupItem, } from '@ndop/primitives/radio-group'; import { cn } from '@/lib/utils'; /** * A radio group (`@ndop/primitives/radio-group`): options of which one at * most is checked, with the arrow keys moving between them. Bind it with * `[(value)]` or any forms library, and name it with `aria-label` or * `aria-labelledby`. * * @example * ```html *
    *
    * * *
    *
    * ``` */ @Directive({ selector: '[ndRadioGroup]', hostDirectives: [ { directive: NdRadioGroup, inputs: [ 'value', 'defaultValue', 'orientation', 'disabled', 'required', 'invalid', ], outputs: ['valueChange'], }, ], host: { 'data-slot': 'radio-group', '[class]': 'classes()' }, }) export class RadioGroup { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('group/radio-group grid gap-3', this.class()), ); } /** * One option of a {@link RadioGroup}, on a native `button`, with a * `ndRadioGroupIndicator` inside. Name it with a `label[ndLabel]` whose * `for` is its `id`. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndRadioGroupItem]', hostDirectives: [ { directive: NdRadioGroupItem, inputs: ['value', 'disabled'] }, ], host: { 'data-slot': 'radio-group-item', '[class]': 'classes()' }, }) export class RadioGroupItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40', 'group-aria-invalid/radio-group:border-destructive', this.class(), ), ); } /** * The dot of a {@link RadioGroupItem}, shown while it's checked. Put a * circle icon inside. * * @example * ```html * * ``` */ @Directive({ selector: '[ndRadioGroupIndicator]', hostDirectives: [NdRadioGroupIndicator], host: { 'data-slot': 'radio-group-indicator', '[class]': 'classes()' }, }) export class RadioGroupIndicator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative flex items-center justify-center [&>svg]:size-2 [&>svg]:fill-primary', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Label } from '@/ui/label/label'; import { RadioGroup, RadioGroupIndicator, RadioGroupItem, } from '@/ui/radio-group/radio-group'; ``` ```html
    @for (option of options; track option.value) {
    }
    ``` ## Examples ### Radio Group demo Three options with labels, one chosen by default. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Label } from '@/ui/label/label'; import { RadioGroup, RadioGroupIndicator, RadioGroupItem, } from '@/ui/radio-group/radio-group'; /** Three options with labels, one chosen by default. */ @Component({ selector: 'example-radio-group-demo', imports: [Label, NdIcon, RadioGroup, RadioGroupIndicator, RadioGroupItem], template: `
    @for (option of options; track option.value) {
    }
    `, }) export class RadioGroupDemoExample { protected readonly options = [ { value: 'default', label: 'Default' }, { value: 'comfortable', label: 'Comfortable' }, { value: 'compact', label: 'Compact' }, ]; } ``` ### Radio Group cards Plans as selectable cards, each with a description. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle, } from '@/ui/form-field/form-field'; import { RadioGroup, RadioGroupIndicator, RadioGroupItem, } from '@/ui/radio-group/radio-group'; /** * Plans as selectable cards: each card is the label of its radio, so * clicking anywhere on it chooses the plan, and the chosen card is * highlighted. */ @Component({ selector: 'example-radio-group-cards', imports: [ Field, FieldContent, FieldDescription, FieldLabel, FieldTitle, NdIcon, RadioGroup, RadioGroupIndicator, RadioGroupItem, ], template: `
    @for (option of plans; track option.value) { }
    `, }) export class RadioGroupCardsExample { protected readonly plans = [ { value: 'starter', title: 'Starter', description: 'For side projects: one seat, 5 GB of storage.', }, { value: 'pro', title: 'Pro', description: 'For small teams: ten seats, 100 GB of storage.', }, { value: 'business', title: 'Business', description: 'Unlimited seats, single sign-on and audit logs.', }, ]; protected readonly plan = signal('pro'); } ``` ### Radio Group disabled A group with one option that can't be chosen. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Label } from '@/ui/label/label'; import { RadioGroup, RadioGroupIndicator, RadioGroupItem, } from '@/ui/radio-group/radio-group'; /** * A group with one option that can't be chosen; the arrow keys skip it. */ @Component({ selector: 'example-radio-group-disabled', imports: [Label, NdIcon, RadioGroup, RadioGroupIndicator, RadioGroupItem], template: `
    @for (option of options; track option.value) {
    }
    `, }) export class RadioGroupDisabledExample { protected readonly options = [ { value: 'standard', label: 'Standard, 3–5 days', disabled: false }, { value: 'express', label: 'Express, next day', disabled: false }, { value: 'same-day', label: 'Same day (unavailable)', disabled: true }, ]; } ``` ### Radio Group form A required choice in a Signal Forms form, with its error once touched. ```ts import { Component, signal } from '@angular/core'; import { form, FormField, required } from '@angular/forms/signals'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; import { Field, FieldError, FieldLegend, FieldSet, } from '@/ui/form-field/form-field'; import { Label } from '@/ui/label/label'; import { RadioGroup, RadioGroupIndicator, RadioGroupItem, } from '@/ui/radio-group/radio-group'; /** * A required choice in a Signal Forms form. Submitting without one shows * the error and announces it. */ @Component({ selector: 'example-radio-group-form', imports: [ Button, Field, FieldError, FieldLegend, FieldSet, FormField, Label, NdIcon, RadioGroup, RadioGroupIndicator, RadioGroupItem, ], template: `
    Notify me about
    @for (option of options; track option.value) {
    }

    Choose what to be notified about.

    `, }) export class RadioGroupFormExample { protected readonly options = [ { value: 'all', label: 'All new messages' }, { value: 'mentions', label: 'Direct messages and mentions' }, { value: 'none', label: 'Nothing' }, ]; protected readonly model = signal<{ notify: string | null }>({ notify: null, }); protected readonly preferences = form(this.model, (path) => { required(path.notify); }); /** * Marks the fields touched, so errors show, instead of reloading. * * @param event - The form's submit event. */ protected submit(event: Event): void { event.preventDefault(); this.preferences().markAsTouched(); } } ``` ## Accessibility Implements the [Radio Group](https://www.w3.org/WAI/ARIA/apg/patterns/radio/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Tab` | Moves focus into the group, to the checked item or, if none, the first; then out of it. | | `Space` | Checks the focused item, if it isn't checked. | | `Enter` | Does nothing, as for native radio buttons; the default is prevented. | | `ArrowDown` or `ArrowRight` | Moves focus to the next enabled item and checks it; from the last, wraps to the first. | | `ArrowUp` or `ArrowLeft` | Moves focus to the previous enabled item and checks it; from the first, wraps to the last. | ### Focus - Arrow keys move focus and the selection together; disabled items are skipped. - Clicking an item focuses and checks it. - Focus leaving the group marks a bound field touched. ### Right-to-left In right-to-left layouts, `ArrowLeft` moves to the next item and `ArrowRight` to the previous one. Direction comes from the nearest `dir` attribute, on the item or an ancestor. ## API reference ### RadioGroup Selector: `[ndRadioGroup]` · Role: `radiogroup` A radio group (`@ndop/primitives/radio-group`): options of which one at most is checked, with the arrow keys moving between them. Bind it with `[(value)]` or any forms library, and name it with `aria-label` or `aria-labelledby`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[defaultValue]` | `string \| null` | `null` | Initial `value` when it isn't bound. | | `[orientation]` | `'horizontal' \| 'vertical'` | `'vertical'` | The items' layout, for `aria-orientation` and `data-orientation`. All four arrow keys work either way. | | `[disabled]` | `boolean` | `false` | Disables every item. A disabled bound field does too. | | `[required]` | `boolean` | `false` | Marks the group required: `aria-required="true"`. | | `[invalid]` | `boolean` | `false` | Marks the group invalid when no forms library is bound; with one, the field's state decides. | | `[(value)]` | `string \| null` | `null` | The checked item's value, or `null` when none is checked. Two-way bindable with `[(value)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `radio-group` | | `data-orientation` | | | `data-disabled` | | | `data-invalid` | | ### RadioGroupItem Selector: `button[ndRadioGroupItem]` · Role: `radio` One option of a `RadioGroup`, on a native `button`, with a `ndRadioGroupIndicator` inside. Name it with a `label[ndLabel]` whose `for` is its `id`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[value]` (required) | `string` | | The value the group takes when this item is checked. | | `[disabled]` | `boolean` | `false` | Disables this item; arrow keys skip it. | | Data attribute | Values | | --- | --- | | `data-slot` | `radio-group-item` | | `data-state` | | | `data-disabled` | | | `data-orientation` | | ### RadioGroupIndicator Selector: `[ndRadioGroupIndicator]` The dot of a `RadioGroupItem`, shown while it's checked. Put a circle icon inside. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `radio-group-indicator` | | `data-state` | | ## Agent notes ### Do - Name the group with `aria-labelledby` pointing at its heading or legend, and each option with a label. - Start with an option chosen when there's a sensible default. - Keep the options short, and few. ### Don't - Don't use a radio group for more than about seven options; use a Select. - Don't use it for options that can be combined; use checkboxes. --- # Resizable > Panels that share space, resized by dragging or with the keyboard. Version 1.0.0 · WAI-ARIA pattern: [Window Splitter](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/) · SSR · Zoneless · 2.8 kB gzip Resizable panels share a row or column, with a handle between each pair that a pointer drags or the arrow keys move: a window splitter, such as a sidebar next to an editor. Each panel can have a default, minimum and maximum size, in percent of the group, and `[(sizes)]` reads or restores the layout. ## Installation ```bash npx @ndop/cli add resizable ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/resizable/resizable.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdResizableHandle, NdResizablePanel, NdResizablePanelGroup, } from '@ndop/primitives/resizable'; import { cn } from '@/lib/utils'; /** * A group of resizable panels (`@ndop/primitives/resizable`), side by side * or stacked, with a handle between each pair. * * @example * ```html *
    *
    Sidebar
    *
    *
    Content
    *
    * ``` */ @Directive({ selector: '[ndResizablePanelGroup]', hostDirectives: [ { directive: NdResizablePanelGroup, inputs: ['orientation', 'sizes', 'keyboardStep'], outputs: ['sizesChange'], }, ], host: { 'data-slot': 'resizable-panel-group', '[class]': 'classes()' }, }) export class ResizablePanelGroup { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex h-full w-full data-[orientation=vertical]:flex-col', this.class()), ); } /** * One panel; its size is in percent of the group. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndResizablePanel]', hostDirectives: [ { directive: NdResizablePanel, inputs: ['defaultSize', 'minSize', 'maxSize'], }, ], host: { 'data-slot': 'resizable-panel' }, }) export class ResizablePanel {} /** * The line between two panels, which arrow keys and dragging move. Put a * {@link ResizableHandleGrip} in it to show it can be dragged. Name it * with `aria-label`. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndResizableHandle]', hostDirectives: [{ directive: NdResizableHandle, inputs: ['disabled'] }], host: { 'data-slot': 'resizable-handle', '[class]': 'classes()' }, }) export class ResizableHandle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:start-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90', this.class(), ), ); } /** * A grip in a handle, showing it can be dragged. It's decorative. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndResizableHandleGrip]', host: { 'data-slot': 'resizable-handle-grip', 'aria-hidden': 'true', '[class]': 'classes()', }, }) export class ResizableHandleGrip { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'z-10 flex h-4 w-3 items-center justify-center rounded-xs border bg-border', '[&>svg]:size-2.5', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from '@/ui/resizable/resizable'; ``` ```html
    One
    Two
    Three
    ``` ## Examples ### Resizable demo A sidebar beside two stacked panes, each resizable. ```ts import { Component } from '@angular/core'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from '@/ui/resizable/resizable'; /** * A sidebar beside two stacked panes. Each handle is a separator that the * arrow keys or a pointer move. */ @Component({ selector: 'example-resizable-demo', imports: [ResizableHandle, ResizablePanel, ResizablePanelGroup], template: `
    One
    Two
    Three
    `, }) export class ResizableDemoExample {} ``` ### Resizable handle A handle with a grip, showing it can be dragged. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { ResizableHandle, ResizableHandleGrip, ResizablePanel, ResizablePanelGroup, } from '@/ui/resizable/resizable'; /** A handle with a grip, showing it can be dragged. */ @Component({ selector: 'example-resizable-handle', imports: [ NdIcon, ResizableHandle, ResizableHandleGrip, ResizablePanel, ResizablePanelGroup, ], template: `
    Sidebar
    Content
    `, }) export class ResizableHandleExample {} ``` ### Resizable vertical Two panes stacked, resized up and down. ```ts import { Component, signal } from '@angular/core'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from '@/ui/resizable/resizable'; /** * Two panes stacked, resized up and down; the sizes are bound, to save * and restore the layout. */ @Component({ selector: 'example-resizable-vertical', imports: [ResizableHandle, ResizablePanel, ResizablePanelGroup], template: `
    Header
    Content
    `, }) export class ResizableVerticalExample { protected readonly sizes = signal([]); } ``` ## Accessibility Implements the [Window Splitter](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Tab` | Moves focus to the next handle, in document order. | | `ArrowLeft` or `ArrowRight` | In a horizontal group, moves the focused handle toward that side by `keyboardStep`. | | `ArrowUp` or `ArrowDown` | In a vertical group, moves the focused handle up or down by `keyboardStep`. | | `Home` | Shrinks the panel before the handle to its `minSize`, as far as the panel after it allows. | | `End` | Grows the panel before the handle to its `maxSize`, as far as the panel after it allows. | ### Focus - Handles are in the tab order unless disabled; panels aren't. - Dragging a handle with the pointer doesn't move focus. ### Right-to-left In a horizontal group in a right-to-left layout, the panels run from right to left, so `ArrowLeft` grows the panel before the handle and `ArrowRight` shrinks it, and dragging follows the pointer. Direction comes from the nearest `dir` attribute. ## API reference ### ResizablePanelGroup Selector: `[ndResizablePanelGroup]` A group of resizable panels (`@ndop/primitives/resizable`), side by side or stacked, with a handle between each pair. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[orientation]` | `'horizontal' \| 'vertical'` | `'horizontal'` | Whether the panels sit side by side or stacked. | | `[keyboardStep]` | `number` | `10` | How far an arrow key moves a handle, in percent. | | `[(sizes)]` | `readonly number[]` | `[]` | The panels' sizes in percent, in document order. Two-way bindable with `[(sizes)]`; when unset, each panel's `defaultSize` applies. | | Data attribute | Values | | --- | --- | | `data-slot` | `resizable-panel-group` | | `data-orientation` | | ### ResizablePanel Selector: `[ndResizablePanel]` One panel; its size is in percent of the group. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[defaultSize]` | `number \| undefined` | `undefined` | Initial size in percent; without one, it shares the space left. | | `[minSize]` | `number` | `0` | Smallest size in percent. | | `[maxSize]` | `number` | `100` | Largest size in percent. | | Data attribute | Values | | --- | --- | | `data-slot` | `resizable-panel` | | `data-size` | | ### ResizableHandle Selector: `[ndResizableHandle]` · Role: `separator` The line between two panels, which arrow keys and dragging move. Put a `ResizableHandleGrip` in it to show it can be dragged. Name it with `aria-label`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[disabled]` | `boolean` | `false` | Stops it moving and takes it out of the tab order. | | Data attribute | Values | | --- | --- | | `data-slot` | `resizable-handle` | | `data-dragging` | | | `data-disabled` | | ### ResizableHandleGrip Selector: `[ndResizableHandleGrip]` A grip in a handle, showing it can be dragged. It's decorative. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `resizable-handle-grip` | ## Agent notes ### Do - Name each handle with `aria-label`, such as "Resize sidebar". - Set a `minSize` that keeps each panel usable. - Show a grip with `ResizableHandleGrip` when the handle is hard to see. ### Don't - Don't nest so many groups that the handles are hard to tell apart. - Don't put the only way to reach content in a panel that can shrink to nothing. --- # Scroll Area > A scrollable region with styled scrollbars. Version 1.0.0 · SSR · Zoneless · 0.6 kB gzip Scroll Area is a region that scrolls inside a fixed size, with thin scrollbars in the theme's border color. It uses the browser's own scrolling, so touch, the mouse wheel and the keyboard all work, and it can take focus so keyboard users can scroll it with the arrow keys. Choose `vertical`, `horizontal` or `both`. ## Installation ```bash npx @ndop/cli add scroll-area ``` Or by hand: install `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/scroll-area/scroll-area.ts ```ts import { computed, Directive, input } from '@angular/core'; import { cn } from '@/lib/utils'; /** Which way a {@link ScrollArea} scrolls. */ export type ScrollAreaOrientation = 'vertical' | 'horizontal' | 'both'; /** * A scrollable region with thin, themed scrollbars, using the browser's * own scrolling (ADR-0021): touch, wheel and keyboard scrolling all work, * and it can take focus, so keyboard users can scroll it. Give it a size * and, when it holds more than a list, a name with `aria-label`. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndScrollArea]', host: { 'data-slot': 'scroll-area', tabindex: '0', '[attr.data-orientation]': 'orientation()', '[class]': 'classes()', }, }) export class ScrollArea { /** Which way it scrolls. */ readonly orientation = input('vertical'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative', 'size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1', '[scrollbar-color:var(--border)_transparent] [scrollbar-width:thin]', this.orientation() === 'horizontal' ? 'overflow-x-auto overflow-y-hidden' : this.orientation() === 'both' ? 'overflow-auto' : 'overflow-x-hidden overflow-y-auto', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { ScrollArea } from '@/ui/scroll-area/scroll-area'; import { Separator } from '@/ui/separator/separator'; ``` ```html

    Tags

    @for (tag of tags; track tag; let last = $last) {
    {{ tag }}
    @if (!last) {
    } }
    ``` ## Examples ### Scroll Area demo A list of tags that scrolls vertically. ```ts import { Component } from '@angular/core'; import { ScrollArea } from '@/ui/scroll-area/scroll-area'; import { Separator } from '@/ui/separator/separator'; /** A list of tags that scrolls vertically in a fixed height. */ @Component({ selector: 'example-scroll-area-demo', imports: [ScrollArea, Separator], template: `

    Tags

    @for (tag of tags; track tag; let last = $last) {
    {{ tag }}
    @if (!last) {
    } }
    `, }) export class ScrollAreaDemoExample { protected readonly tags = Array.from( { length: 30 }, (_, index) => `v1.2.0-beta.${30 - index}`, ); } ``` ### Scroll Area horizontal A row of artworks that scrolls sideways. ```ts import { Component } from '@angular/core'; import { AspectRatio } from '@/ui/aspect-ratio/aspect-ratio'; import { ScrollArea } from '@/ui/scroll-area/scroll-area'; /** A row of artworks that scrolls sideways. */ @Component({ selector: 'example-scroll-area-horizontal', imports: [AspectRatio, ScrollArea], template: `
      @for (artwork of artworks; track artwork.title) {
    • By {{ artwork.artist }}
    • }
    `, }) export class ScrollAreaHorizontalExample { protected readonly artworks = [ { title: 'Ripple', artist: 'Ornella Binni' }, { title: 'Stillness', artist: 'Tom Byrom' }, { title: 'Drift', artist: 'Vladimir Malyavko' }, { title: 'Haze', artist: 'Ahmed Saeed' }, ]; } ``` ## Accessibility Scroll Area has no keyboard interaction or focus handling of its own; it keeps the semantics of the element it's on. ## API reference ### ScrollArea Selector: `[ndScrollArea]` A scrollable region with thin, themed scrollbars, using the browser's own scrolling (ADR-0021): touch, wheel and keyboard scrolling all work, and it can take focus, so keyboard users can scroll it. Give it a size and, when it holds more than a list, a name with `aria-label`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[orientation]` | `'horizontal' \| 'vertical' \| 'both'` | `'vertical'` | Which way it scrolls. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `scroll-area` | | `data-orientation` | | ## Agent notes ### Do - Give it a height (or width, when horizontal), such as `class="h-72"`. - Name it with `aria-label` when it holds a section of the page rather than a single list. - Keep focusable controls inside it reachable; scrolling follows focus. ### Don't - Don't nest scroll areas in the same direction. - Don't use it to hide content that should be visible; let the page scroll. --- # Select > A button that shows a value and opens a list of options to pick from. Version 1.0.0 · WAI-ARIA pattern: [Select-only combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/) · SSR · Zoneless · 6.0 kB gzip A select shows the chosen value on a button that opens a list of options below it. Typing on the closed button picks the next option starting with those letters, as on a native `select`; open, the arrow keys and typing move through the options and Enter picks one. The options are written in place, so the button shows the chosen option's text on the server. Bind forms on its trigger. ## Installation ```bash npx @ndop/cli add select ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/select/select.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdSelect, NdSelectContent, NdSelectGroup, NdSelectItem, NdSelectItemIndicator, NdSelectItemText, NdSelectLabel, NdSelectSeparator, NdSelectTrigger, NdSelectValue, } from '@ndop/primitives/select'; import { cn } from '@/lib/utils'; /** * A select (`@ndop/primitives/select`): a button that shows its value and * opens a list of options below it. Put it on an `ng-container`, and bind * forms on its trigger. * * @example * ```html * * *
    *
    * * Apple *
    *
    *
    * ``` */ @Directive({ selector: '[ndSelect]', hostDirectives: [ { directive: NdSelect, inputs: [ 'value', 'defaultValue', 'open', 'defaultOpen', 'disabled', 'required', 'invalid', ], outputs: ['valueChange', 'openChange'], }, ], }) export class Select {} /** * The button that shows the value and opens the options. `size` is * `default` or `sm`. Put the chevron icon in it, last. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndSelectTrigger]', hostDirectives: [NdSelectTrigger], host: { 'data-slot': 'select-trigger', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class SelectTrigger { /** Height: `default` or `sm`. */ readonly size = input<'default' | 'sm'>('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", this.class(), ), ); } /** * The selected option's text, or `placeholder` while none is selected. * * @example * ```html * * ``` */ @Directive({ selector: '[ndSelectValue]', hostDirectives: [{ directive: NdSelectValue, inputs: ['placeholder'] }], host: { 'data-slot': 'select-value' }, }) export class SelectValue {} /** * The list of options, written after the trigger; it opens below the * trigger, at least as wide. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndSelectContent]', hostDirectives: [ { directive: NdSelectContent, inputs: ['side', 'align', 'sideOffset'] }, ], host: { 'data-slot': 'select-content', '[class]': 'classes()' }, }) export class SelectContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative z-50 max-h-[min(24rem,80vh)] min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=start]:slide-in-from-end-2 data-[side=end]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', 'p-1 scroll-my-1', this.class(), ), ); } /** * A heading for a group of options; point the group's `aria-labelledby` * at it. * * @example * ```html *
    Fruits
    * ``` */ @Directive({ selector: '[ndSelectLabel]', hostDirectives: [NdSelectLabel], host: { 'data-slot': 'select-label', '[class]': 'classes()' }, }) export class SelectLabel { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('px-2 py-1.5 text-xs text-muted-foreground', this.class()), ); } /** * Groups options; name it with `aria-labelledby`. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndSelectGroup]', hostDirectives: [NdSelectGroup], host: { 'data-slot': 'select-group' }, }) export class SelectGroup {} /** * One option. Put its {@link SelectItemIndicator} first and its * {@link SelectItemText} after. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndSelectItem]', hostDirectives: [ { directive: NdSelectItem, inputs: ['value', 'disabled', 'label'] }, ], host: { 'data-slot': 'select-item', '[class]': 'classes()' }, }) export class SelectItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pe-8 ps-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", this.class(), ), ); } /** * The check of the selected option, at its end. * * @example * ```html * * ``` */ @Directive({ selector: '[ndSelectItemIndicator]', hostDirectives: [NdSelectItemIndicator], host: { 'data-slot': 'select-item-indicator', '[class]': 'classes()' }, }) export class SelectItemIndicator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'absolute end-2 flex size-3.5 items-center justify-center', this.class(), ), ); } /** * The text of an option, which the trigger shows while it's selected. * * @example * ```html * Apple * ``` */ @Directive({ selector: '[ndSelectItemText]', hostDirectives: [NdSelectItemText], host: { 'data-slot': 'select-item-text' }, }) export class SelectItemText {} /** * A line between groups of options. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndSelectSeparator]', hostDirectives: [NdSelectSeparator], host: { 'data-slot': 'select-separator', '[class]': 'classes()' }, }) export class SelectSeparator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('pointer-events-none -mx-1 my-1 h-px bg-border', this.class()), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Label } from '@/ui/label/label'; import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue, } from '@/ui/select/select'; ``` ```html
    @for (option of fruits; track option.value) {
    {{ option.label }}
    }
    ``` ## Examples ### Select demo A fruit picker with a placeholder. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Label } from '@/ui/label/label'; import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue, } from '@/ui/select/select'; /** * A fruit picker with a placeholder. Typing a letter on the closed select * picks the next fruit starting with it. */ @Component({ selector: 'example-select-demo', imports: [ Label, NdIcon, Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue, ], template: `
    @for (option of fruits; track option.value) {
    {{ option.label }}
    }
    `, }) export class SelectDemoExample { protected readonly fruit = signal(null); protected readonly fruits = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, { value: 'blueberry', label: 'Blueberry' }, { value: 'grapes', label: 'Grapes' }, { value: 'pineapple', label: 'Pineapple' }, ]; } ``` ### Select groups A long list of options in labelled groups. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Select, SelectContent, SelectGroup, SelectItem, SelectItemIndicator, SelectItemText, SelectLabel, SelectSeparator, SelectTrigger, SelectValue, } from '@/ui/select/select'; /** A long list of time zones in labelled groups, which scrolls. */ @Component({ selector: 'example-select-groups', imports: [ NdIcon, Select, SelectContent, SelectGroup, SelectItem, SelectItemIndicator, SelectItemText, SelectLabel, SelectSeparator, SelectTrigger, SelectValue, ], template: `
    @for (region of regions; track region.name; let last = $last) {
    {{ region.name }}
    @for (zone of region.zones; track zone.value) {
    {{ zone.label }}
    }
    @if (!last) {
    } }
    `, }) export class SelectGroupsExample { protected readonly regions = [ { name: 'North America', zones: [ { value: 'est', label: 'Eastern Standard Time (EST)' }, { value: 'cst', label: 'Central Standard Time (CST)' }, { value: 'mst', label: 'Mountain Standard Time (MST)' }, { value: 'pst', label: 'Pacific Standard Time (PST)' }, ], }, { name: 'Europe and Africa', zones: [ { value: 'gmt', label: 'Greenwich Mean Time (GMT)' }, { value: 'cet', label: 'Central European Time (CET)' }, { value: 'wat', label: 'West Africa Time (WAT)' }, { value: 'eat', label: 'East Africa Time (EAT)' }, ], }, { name: 'Asia', zones: [ { value: 'ist', label: 'India Standard Time (IST)' }, { value: 'cst-china', label: 'China Standard Time (CST)' }, { value: 'jst', label: 'Japan Standard Time (JST)' }, ], }, ]; } ``` ### Select sizes The default and small triggers. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue, } from '@/ui/select/select'; /** The default and small triggers. */ @Component({ selector: 'example-select-sizes', imports: [ NdIcon, Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue, ], template: `
    @for (size of sizes; track size) {
    @for (range of ranges; track range.value) {
    {{ range.label }}
    }
    }
    `, }) export class SelectSizesExample { protected readonly sizes = ['default', 'sm'] as const; protected readonly ranges = [ { value: 'day', label: 'Today' }, { value: 'week', label: 'This week' }, { value: 'month', label: 'This month' }, ]; } ``` ### Select disabled A disabled select, and one with a disabled option. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue, } from '@/ui/select/select'; /** * A disabled select, and one with a disabled option, which the arrow keys * skip. */ @Component({ selector: 'example-select-disabled', imports: [ NdIcon, Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue, ], template: `
    Free
    1 seat
    5 seats
    Unlimited (Enterprise)
    `, }) export class SelectDisabledExample {} ``` ## Accessibility Implements the [Select-only combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Enter` or `Space` | On the trigger, opens the listbox; on an option, selects it, closes and focuses the trigger. | | `ArrowDown` or `ArrowUp` | On the trigger, opens the listbox; in it, moves to the next or previous enabled option. | | `Home` or `End` | In the listbox, moves to the first or last enabled option. | | `a` | Typeahead: on the closed trigger, selects the next option starting with the typed letters; in the listbox, moves to it. | | `Escape` | Closes the listbox and focuses the trigger. | | `Tab` | Closes the listbox and moves focus on from the trigger. | ### Focus - Opening focuses the selected option, or the first enabled one. - Selecting an option, `Escape` and `Tab` close the listbox; the first two return focus to the trigger. A click outside closes it where focus is. - Focus leaving the trigger and listbox marks a bound field touched. ### Right-to-left `start` and `end` alignments are logical and swap in right-to-left layouts. The listbox is vertical, so its arrow keys don't change. ## API reference ### Select Selector: `[ndSelect]` A select (`@ndop/primitives/select`): a button that shows its value and opens a list of options below it. Put it on an `ng-container`, and bind forms on its trigger. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[defaultValue]` | `string \| null` | `null` | Initial `value` when it isn't bound. | | `[defaultOpen]` | `boolean` | `false` | Initial open state when `open` isn't bound, applied after hydration. | | `[disabled]` | `boolean` | `false` | Disables the trigger. A disabled bound field does too. | | `[required]` | `boolean` | `false` | Marks the select required: `aria-required="true"` on the trigger. | | `[invalid]` | `boolean` | `false` | Marks the select invalid when no forms library is bound; with one, the field's state decides. | | `[(value)]` | `string \| null` | `null` | The selected option's value, or `null` when none is. Two-way bindable with `[(value)]`. | | `[(open)]` | `boolean` | `false` | Whether the listbox is open. Two-way bindable with `[(open)]`. | ### SelectTrigger Selector: `button[ndSelectTrigger]` · Role: `combobox` The button that shows the value and opens the options. `size` is `default` or `sm`. Put the chevron icon in it, last. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[size]` | `'default' \| 'sm'` | `'default'` | Height: `default` or `sm`. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `select-trigger` | | `data-size` | | | `data-state` | | | `data-placeholder` | | | `data-disabled` | | ### SelectValue Selector: `[ndSelectValue]` The selected option's text, or `placeholder` while none is selected. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[placeholder]` | `string` | `''` | What it shows while no option is selected. | | Data attribute | Values | | --- | --- | | `data-slot` | `select-value` | | `data-placeholder` | | ### SelectContent Selector: `[ndSelectContent]` · Role: `listbox` The list of options, written after the trigger; it opens below the trigger, at least as wide. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[side]` | `'bottom' \| 'top' \| 'start' \| 'end'` | `'bottom'` | Side of the trigger it opens on, before flipping. | | `[align]` | `'start' \| 'end' \| 'center'` | `'start'` | Alignment along that side. | | `[sideOffset]` | `number` | `4` | Gap between the trigger and the listbox, in pixels. | | Data attribute | Values | | --- | --- | | `data-slot` | `select-content` | | `data-state` | | | `data-side` | | ### SelectLabel Selector: `[ndSelectLabel]` A heading for a group of options; point the group's `aria-labelledby` at it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `select-label` | ### SelectGroup Selector: `[ndSelectGroup]` · Role: `group` Groups options; name it with `aria-labelledby`. | Data attribute | Values | | --- | --- | | `data-slot` | `select-group` | ### SelectItem Selector: `[ndSelectItem]` · Role: `option` One option. Put its `SelectItemIndicator` first and its `SelectItemText` after. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[value]` (required) | `string` | | The value the select takes when it's chosen. | | `[disabled]` | `boolean` | `false` | Makes it unselectable; arrow keys skip it. | | `[label]` | `string \| null` | `null` | Its text, when it differs from what it renders. | | Data attribute | Values | | --- | --- | | `data-slot` | `select-item` | | `data-state` | | | `data-highlighted` | | | `data-disabled` | | ### SelectItemIndicator Selector: `[ndSelectItemIndicator]` The check of the selected option, at its end. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `select-item-indicator` | | `data-state` | | ### SelectItemText Selector: `[ndSelectItemText]` The text of an option, which the trigger shows while it's selected. | Data attribute | Values | | --- | --- | | `data-slot` | `select-item-text` | ### SelectSeparator Selector: `[ndSelectSeparator]` A line between groups of options. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `select-separator` | ## Agent notes ### Do - Name the trigger with a label, as any form field. - Group long lists, with labels. - Use `size="sm"` in dense layouts. ### Don't - Don't use it for more than a few dozen options, or when people know what they want to type; use a Combobox. - Don't use it for actions; use Dropdown Menu. --- # Separator > Visually or semantically separates content. Version 1.0.0 · WAI-ARIA pattern: [separator role](https://www.w3.org/TR/wai-aria-1.2/#separator) · SSR · Zoneless · 0.6 kB gzip A separator divides content, horizontally or vertically. By default it is semantic, with `role="separator"` and `aria-orientation`, so assistive technology announces the division. Set `decorative` when the separation is only visual, for example between sections that headings already divide; it then gets `role="none"`. ## Installation ```bash npx @ndop/cli add separator ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/separator/separator.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdSeparator } from '@ndop/primitives/separator'; import { cn } from '@/lib/utils'; /** * Styled separator line, horizontal or vertical * (`@ndop/primitives/separator`). * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndSeparator]', hostDirectives: [ { directive: NdSeparator, inputs: ['orientation', 'decorative'] }, ], host: { 'data-slot': 'separator', '[class]': 'classes()' }, }) export class Separator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Separator } from '@/ui/separator/separator'; ``` ```html
    Ndop UI

    Accessible Angular components you own.

    Blog
    Docs
    Source
    ``` ## Examples ### Separator demo Horizontal and vertical separators between blocks of content. ```ts import { Component } from '@angular/core'; import { Separator } from '@/ui/separator/separator'; /** Horizontal and vertical separators between blocks of content. */ @Component({ selector: 'example-separator-demo', imports: [Separator], template: `
    Ndop UI

    Accessible Angular components you own.

    Blog
    Docs
    Source
    `, }) export class SeparatorDemoExample {} ``` ### Separator decorative A decorative separator, hidden from assistive technology because the headings already divide the content. ```ts import { Component } from '@angular/core'; import { Separator } from '@/ui/separator/separator'; /** * A decorative separator, hidden from assistive technology because the * headings already divide the content. */ @Component({ selector: 'example-separator-decorative', imports: [Separator], template: `

    Account

    Name, email and password.

    Notifications

    Email and push preferences.

    `, }) export class SeparatorDecorativeExample {} ``` ## Accessibility Implements the [separator role](https://www.w3.org/TR/wai-aria-1.2/#separator) pattern of the WAI-ARIA Authoring Practices. ### Focus - The separator never receives focus and never changes focus. ### Right-to-left Nothing mirrors: orientation is the same in both directions. ## API reference ### Separator Selector: `[ndSeparator]` · Role: `separator or none` Styled separator line, horizontal or vertical (`@ndop/primitives/separator`). | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[orientation]` | `'horizontal' \| 'vertical'` | `'horizontal'` | Layout direction of the separator. | | `[decorative]` | `boolean` | `false` | Whether the separator is purely visual. Decorative separators are hidden from assistive technology. | | Data attribute | Values | | --- | --- | | `data-slot` | `separator` | | `data-orientation` | | ## Agent notes ### Do - Use `orientation="vertical"` between items in a row, inside a container with a height. - Set `decorative` when headings or landmarks already separate the content. ### Don't - Don't use a separator to add spacing; use margin or gap. - Don't stack separators next to borders that already divide the same content. --- # Sheet > A dialog that slides in from an edge of the screen, for secondary content such as settings or filters. Version 1.0.0 · WAI-ARIA pattern: [Dialog (Modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) · SSR · Zoneless · 3.3 kB gzip A sheet is a dialog that slides in from an edge of the screen, for content that goes with the page without replacing it, such as settings, filters or a cart. It's a modal dialog: focus is trapped inside while it's open and returns when it closes, and Escape or the backdrop closes it. It comes from the end side by default; `start` and `end` are logical, so they swap in right-to-left layouts. ## Installation ```bash npx @ndop/cli add sheet ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/sheet/sheet.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdDialog, NdDialogClose, NdDialogContent, NdDialogDescription, NdDialogOverlay, NdDialogPortal, NdDialogTitle, NdDialogTrigger, } from '@ndop/primitives/dialog'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * A sheet: a modal dialog (`@ndop/primitives/dialog`) that slides in from * an edge, for secondary content such as settings or filters. Put it on * an `ng-container`; `open`, `defaultOpen`, `closeOnEscape` and * `closeOnOverlayClick` are Dialog's. * * @example * ```html * * * *
    *
    *

    Edit profile

    *
    *
    *
    * ``` */ @Directive({ selector: '[ndSheet]', exportAs: 'ndSheet', hostDirectives: [ { directive: NdDialog, inputs: ['open', 'defaultOpen', 'closeOnEscape', 'closeOnOverlayClick'], outputs: ['openChange'], }, ], }) export class Sheet {} /** * The button that opens a sheet. * * @example * ```html * * ``` */ @Directive({ selector: '[ndSheetTrigger]', hostDirectives: [NdDialogTrigger], host: { 'data-slot': 'sheet-trigger' }, }) export class SheetTrigger {} /** * Marks the `ng-template` a sheet renders while open. * * @example * ```html * … * ``` */ @Directive({ selector: 'ng-template[ndSheetPortal]', hostDirectives: [NdDialogPortal], }) export class SheetPortal {} /** * A button that closes its sheet. * * @example * ```html * * ``` */ @Directive({ selector: '[ndSheetClose]', hostDirectives: [NdDialogClose], host: { 'data-slot': 'sheet-close' }, }) export class SheetClose {} /** * The dimmed backdrop behind a sheet; clicking it closes the sheet. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndSheetOverlay]', hostDirectives: [NdDialogOverlay], host: { 'data-slot': 'sheet-overlay', '[class]': 'classes()' }, }) export class SheetOverlay { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0', this.class(), ), ); } /** Class table for {@link SheetContent}. */ export const sheetVariants = variants({ base: 'fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500', variants: { side: { end: 'inset-y-0 end-0 h-full w-3/4 border-s data-[state=closed]:slide-out-to-end data-[state=open]:slide-in-from-end sm:max-w-sm', start: 'inset-y-0 start-0 h-full w-3/4 border-e data-[state=closed]:slide-out-to-start data-[state=open]:slide-in-from-start sm:max-w-sm', top: 'inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', bottom: 'inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', }, }, defaults: { side: 'end' }, }); /** The edge a {@link SheetContent} slides in from. */ export type SheetSide = VariantOf; /** * The sheet panel: a modal dialog labelled by its title, sliding in from * its `side`. `start` and `end` are logical, so they swap in * right-to-left layouts. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndSheetContent]', hostDirectives: [NdDialogContent], host: { 'data-slot': 'sheet-content', '[attr.data-side]': 'side()', '[class]': 'classes()', }, }) export class SheetContent { /** The edge it slides in from. */ readonly side = input('end'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn(sheetVariants({ side: this.side() }), this.class()), ); } /** * The close button in a sheet's corner; put an icon and a visually hidden * name in it. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndSheetCloseButton]', hostDirectives: [NdDialogClose], host: { type: 'button', 'data-slot': 'sheet-close', '[class]': 'classes()', }, }) export class SheetCloseButton { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'absolute top-4 end-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary [&_svg]:size-4', this.class(), ), ); } /** * Lays out a sheet's title and description. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndSheetHeader]', host: { 'data-slot': 'sheet-header', '[class]': 'classes()' }, }) export class SheetHeader { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex flex-col gap-1.5 p-4', this.class()), ); } /** * Lays out a sheet's buttons at its bottom. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndSheetFooter]', host: { 'data-slot': 'sheet-footer', '[class]': 'classes()' }, }) export class SheetFooter { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('mt-auto flex flex-col gap-2 p-4', this.class()), ); } /** * A sheet's title, which labels it. * * @example * ```html *

    Edit profile

    * ``` */ @Directive({ selector: '[ndSheetTitle]', hostDirectives: [NdDialogTitle], host: { 'data-slot': 'sheet-title', '[class]': 'classes()' }, }) export class SheetTitle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('font-semibold text-foreground', this.class()), ); } /** * A sheet's description. * * @example * ```html *

    Make changes to your profile here.

    * ``` */ @Directive({ selector: '[ndSheetDescription]', hostDirectives: [NdDialogDescription], host: { 'data-slot': 'sheet-description', '[class]': 'classes()' }, }) export class SheetDescription { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('text-sm text-muted-foreground', this.class()), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Button } from '@/ui/button/button'; import { Input } from '@/ui/input/input'; import { Label } from '@/ui/label/label'; import { Sheet, SheetClose, SheetCloseButton, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, } from '@/ui/sheet/sheet'; ``` ```html

    Edit profile

    Make changes to your profile here. Save when you're done.

    ``` ## Examples ### Sheet demo A profile form in a sheet from the end side, saved from its footer. ```ts import { Component, signal } from '@angular/core'; import { form, FormField, required } from '@angular/forms/signals'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; import { Input } from '@/ui/input/input'; import { Label } from '@/ui/label/label'; import { Sheet, SheetClose, SheetCloseButton, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, } from '@/ui/sheet/sheet'; /** * A profile form in a sheet from the end side, bound to Signal Forms and * saved from its footer, which closes the sheet. */ @Component({ selector: 'example-sheet-demo', imports: [ Button, FormField, Input, Label, NdIcon, Sheet, SheetClose, SheetCloseButton, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, ], template: `

    Edit profile

    Make changes to your profile here. Save when you're done.

    `, }) export class SheetDemoExample { protected readonly open = signal(false); protected readonly model = signal({ name: 'Ada Lovelace', username: 'ada' }); protected readonly profile = form(this.model, (path) => { required(path.name); required(path.username); }); /** * Closes the sheet once the profile is valid, instead of reloading. * * @param event - The form's submit event. */ protected save(event: Event): void { event.preventDefault(); this.profile().markAsTouched(); if (this.profile().valid()) { this.open.set(false); } } } ``` ### Sheet sides A sheet from each edge: top, end, bottom and start. ```ts import { Component } from '@angular/core'; import { Button } from '@/ui/button/button'; import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, type SheetSide, } from '@/ui/sheet/sheet'; /** * A sheet from each edge. `start` and `end` are logical, so they swap in * right-to-left layouts. */ @Component({ selector: 'example-sheet-sides', imports: [ Button, Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, ], template: `
    @for (side of sides; track side) {

    {{ side }} sheet

    It slides in from the {{ side }}.

    }
    `, }) export class SheetSidesExample { protected readonly sides: readonly SheetSide[] = [ 'top', 'end', 'bottom', 'start', ]; } ``` ## Accessibility Implements the [Dialog (Modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Tab` | Moves focus to the next focusable element inside the dialog; from the last one, wraps to the first. | | `Shift + Tab` | Moves focus to the previous focusable element inside the dialog; from the first one, wraps to the last. | | `Escape` | Closes the dialog, unless `closeOnEscape` is `false`. | ### Focus - On open: focus moves to the first focusable element in the content, or to the content element itself when it has none. - While open: focus is trapped in the content. - On close: focus returns to the element that had it before opening, usually the trigger. ### Right-to-left Nothing mirrors: the dialog is centered, and Tab order follows the DOM. ## API reference ### Sheet Selector: `[ndSheet]` A sheet: a modal dialog (`@ndop/primitives/dialog`) that slides in from an edge, for secondary content such as settings or filters. Put it on an `ng-container`; `open`, `defaultOpen`, `closeOnEscape` and `closeOnOverlayClick` are Dialog's. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[defaultOpen]` | `boolean` | `false` | Initial open state when `open` is not bound. | | `[closeOnEscape]` | `boolean` | `true` | Whether Escape closes the dialog. | | `[closeOnOverlayClick]` | `boolean` | `true` | Whether a click on `[ndDialogOverlay]` closes the dialog. | | `[(open)]` | `boolean` | `false` | Whether the dialog is open. Two-way bindable with `[(open)]`. | ### SheetTrigger Selector: `[ndSheetTrigger]` The button that opens a sheet. | Data attribute | Values | | --- | --- | | `data-slot` | `sheet-trigger` | | `data-state` | | ### SheetPortal Selector: `ng-template[ndSheetPortal]` Marks the `ng-template` a sheet renders while open. ### SheetClose Selector: `[ndSheetClose]` A button that closes its sheet. | Data attribute | Values | | --- | --- | | `data-slot` | `sheet-close` | ### SheetOverlay Selector: `[ndSheetOverlay]` The dimmed backdrop behind a sheet; clicking it closes the sheet. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sheet-overlay` | | `data-state` | | ### SheetContent Selector: `[ndSheetContent]` · Role: `dialog` The sheet panel: a modal dialog labelled by its title, sliding in from its `side`. `start` and `end` are logical, so they swap in right-to-left layouts. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[side]` | `'bottom' \| 'top' \| 'start' \| 'end'` | `'end'` | The edge it slides in from. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sheet-content` | | `data-side` | | | `data-state` | | ### SheetCloseButton Selector: `button[ndSheetCloseButton]` The close button in a sheet's corner; put an icon and a visually hidden name in it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sheet-close` | ### SheetHeader Selector: `[ndSheetHeader]` Lays out a sheet's title and description. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sheet-header` | ### SheetFooter Selector: `[ndSheetFooter]` Lays out a sheet's buttons at its bottom. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sheet-footer` | ### SheetTitle Selector: `[ndSheetTitle]` A sheet's title, which labels it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sheet-title` | ### SheetDescription Selector: `[ndSheetDescription]` A sheet's description. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sheet-description` | ## Agent notes ### Do - Give it a title, which names it. - Put a close button in it, with a visible or visually hidden name. - Use the end side for secondary panels and the bottom on small screens. ### Don't - Don't use it for your main navigation on large screens; use a Sidebar. - Don't stack sheets on top of each other. --- # Sidebar > A collapsible side panel for an app's navigation, which becomes a dialog on small screens. Version 1.0.0 · WAI-ARIA pattern: [Dialog (modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) · SSR · Zoneless · 6.9 kB gzip A sidebar holds an app's main navigation beside its content. On desktop it collapses off-canvas or down to its icons, with its trigger, a rail along its edge, or `Ctrl+B` (`⌘B` on a Mac); collapsed off-canvas, it's inert, so its links leave the tab order. Below the `md` breakpoint it opens as a modal dialog with a focus trap, closed by `Escape`, a backdrop click or following a link. ## Installation ```bash npx @ndop/cli add sidebar ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/sidebar/sidebar.ts ```ts import { booleanAttribute, computed, Directive, inject, input, } from '@angular/core'; import { NdInput } from '@ndop/primitives/input'; import { NdSeparator } from '@ndop/primitives/separator'; import { NdSidebar, NdSidebarContainer, NdSidebarGap, NdSidebarMenuButton, NdSidebarProvider, NdSidebarRail, NdSidebarTrigger, } from '@ndop/primitives/sidebar'; import { buttonVariants } from '@/ui/button/button'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** How a {@link Sidebar} looks: flush with the page, floating or inset. */ export type SidebarVariant = 'sidebar' | 'floating' | 'inset'; /** * The page around a sidebar (`@ndop/primitives/sidebar`): it holds the * sidebar's state, sets its widths, and lays the sidebar beside the * page's main content. `Ctrl+B` (`⌘B` on a Mac) toggles the sidebar. * * @example * ```html *
    *
    …
    *
    …
    *
    * ``` */ @Directive({ selector: '[ndSidebarProvider]', exportAs: 'ndSidebarProvider', hostDirectives: [ { directive: NdSidebarProvider, inputs: ['open', 'defaultOpen', 'openMobile', 'mobileQuery', 'shortcut'], outputs: ['openChange', 'openMobileChange'], }, ], host: { 'data-slot': 'sidebar-wrapper', '[style.--sidebar-width]': '"16rem"', '[style.--sidebar-width-icon]': '"3rem"', '[style.--sidebar-width-mobile]': '"18rem"', '[class]': 'classes()', }, }) export class SidebarProvider { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar', this.class(), ), ); } /** * The sidebar: on desktop, it collapses off-canvas, to its icons, or not * at all; below the `md` breakpoint, it opens as a dialog. Put a * {@link SidebarGap} and a {@link SidebarContainer} in it, unless * `collapsible` is `none`: then put the content in it directly. * * @example * ```html *
    *
    *
    *
    …
    *
    *
    * ``` */ @Directive({ selector: '[ndSidebar]', hostDirectives: [{ directive: NdSidebar, inputs: ['side', 'collapsible'] }], host: { 'data-slot': 'sidebar', '[attr.data-variant]': 'variant()', '[class]': 'classes()', }, }) export class Sidebar { /** How it looks: flush with the page, floating or inset. */ readonly variant = input('sidebar'); /** Extra classes, merged last so they win. */ readonly class = input(''); /** The primitive, whose side and collapsing the parts read. */ readonly sidebar = inject(NdSidebar); protected readonly classes = computed(() => cn( this.sidebar.collapsible() === 'none' ? 'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground' : 'group peer hidden text-sidebar-foreground md:block', this.class(), ), ); } /** * Keeps the sidebar's room in the page beside it, widening and narrowing * as the sidebar does. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndSidebarGap]', hostDirectives: [NdSidebarGap], host: { 'data-slot': 'sidebar-gap', '[class]': 'classes()' }, }) export class SidebarGap { /** Extra classes, merged last so they win. */ readonly class = input(''); private readonly sidebar = inject(Sidebar); protected readonly classes = computed(() => { const variant = this.sidebar.variant(); return cn( 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear', 'group-data-[collapsible=offcanvas]:w-0', 'group-data-[side=end]:rotate-180', variant === 'floating' || variant === 'inset' ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]' : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)', this.class(), ); }); } /** * The fixed panel holding the sidebar's content: off-canvas or narrowed * while collapsed, and the dialog panel on mobile. Name it with * `aria-label`. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndSidebarContainer]', hostDirectives: [ { directive: NdSidebarContainer, inputs: ['backdropClass'] }, ], host: { 'data-slot': 'sidebar-container', '[class]': 'classes()' }, }) export class SidebarContainer { /** Extra classes, merged last so they win. */ readonly class = input(''); private readonly sidebar = inject(Sidebar); protected readonly classes = computed(() => { const variant = this.sidebar.variant(); return cn( 'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex', this.sidebar.sidebar.side() === 'start' ? 'start-0 group-data-[collapsible=offcanvas]:start-[calc(var(--sidebar-width)*-1)]' : 'end-0 group-data-[collapsible=offcanvas]:end-[calc(var(--sidebar-width)*-1)]', variant === 'floating' || variant === 'inset' ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]' : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=start]:border-e group-data-[side=end]:border-s', // On mobile it's the dialog's panel, outside the sidebar's group. 'data-mobile:relative data-mobile:flex data-mobile:h-full data-mobile:w-(--sidebar-width-mobile) data-mobile:border-0 data-mobile:bg-sidebar data-mobile:p-0 data-mobile:text-sidebar-foreground', this.class(), ); }); } /** * The sidebar's surface, inside its container: a column for its header, * content and footer. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndSidebarInner]', host: { 'data-slot': 'sidebar-inner', 'data-sidebar': 'sidebar', '[class]': 'classes()', }, }) export class SidebarInner { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm', this.class(), ), ); } /** * The button that opens and closes the sidebar. Name it with `sr-only` * text, and put the panel icon in it. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndSidebarTrigger]', hostDirectives: [NdSidebarTrigger], host: { 'data-slot': 'sidebar-trigger', 'data-sidebar': 'trigger', '[class]': 'classes()', }, }) export class SidebarTrigger { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: 'ghost', size: 'icon' }), 'size-7', this.class(), ), ); } /** * A thin strip along the sidebar's inner edge that toggles it with the * pointer, showing a line on hover. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndSidebarRail]', hostDirectives: [NdSidebarRail], host: { 'data-slot': 'sidebar-rail', 'data-sidebar': 'rail', '[class]': 'classes()', }, }) export class SidebarRail { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=start]:-end-4 group-data-[side=end]:start-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex', 'in-data-[side=start]:cursor-w-resize in-data-[side=end]:cursor-e-resize', '[[data-side=start][data-state=collapsed]_&]:cursor-e-resize [[data-side=end][data-state=collapsed]_&]:cursor-w-resize', 'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:start-full hover:group-data-[collapsible=offcanvas]:bg-sidebar', '[[data-side=start][data-collapsible=offcanvas]_&]:-end-2', '[[data-side=end][data-collapsible=offcanvas]_&]:-start-2', 'data-mobile:hidden', this.class(), ), ); } /** * The page's main content beside the sidebar; with the `inset` variant, * it's a rounded card. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndSidebarInset]', host: { 'data-slot': 'sidebar-inset', '[class]': 'classes()' }, }) export class SidebarInset { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative flex w-full flex-1 flex-col bg-background', 'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-2', this.class(), ), ); } /** * A search field in the sidebar, styled like an Input. * * @example * ```html * * ``` */ @Directive({ selector: 'input[ndSidebarInput]', hostDirectives: [{ directive: NdInput, inputs: ['id', 'invalid'] }], host: { 'data-slot': 'sidebar-input', 'data-sidebar': 'input', '[class]': 'classes()', }, }) export class SidebarInput { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm dark:bg-input/30', 'h-8 w-full bg-background shadow-none', this.class(), ), ); } /** * The top of the sidebar, such as a team switcher. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndSidebarHeader]', host: { 'data-slot': 'sidebar-header', 'data-sidebar': 'header', '[class]': 'classes()', }, }) export class SidebarHeader { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex flex-col gap-2 p-2', this.class()), ); } /** * The bottom of the sidebar, such as the user's account. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndSidebarFooter]', host: { 'data-slot': 'sidebar-footer', 'data-sidebar': 'footer', '[class]': 'classes()', }, }) export class SidebarFooter { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex flex-col gap-2 p-2', this.class()), ); } /** * A line between parts of the sidebar. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndSidebarSeparator]', hostDirectives: [ { directive: NdSeparator, inputs: ['orientation', 'decorative'] }, ], host: { 'data-slot': 'sidebar-separator', 'data-sidebar': 'separator', '[class]': 'classes()', }, }) export class SidebarSeparator { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full', 'mx-2 w-auto bg-sidebar-border', this.class(), ), ); } /** * The scrolling middle of the sidebar, holding its groups. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndSidebarContent]', host: { 'data-slot': 'sidebar-content', 'data-sidebar': 'content', '[class]': 'classes()', }, }) export class SidebarContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', this.class(), ), ); } /** * A group of the sidebar's menu, with a label. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndSidebarGroup]', host: { 'data-slot': 'sidebar-group', 'data-sidebar': 'group', '[class]': 'classes()', }, }) export class SidebarGroup { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('relative flex w-full min-w-0 flex-col p-2', this.class()), ); } /** * A group's label, which fades out while the sidebar shows only icons. * * @example * ```html *
    Platform
    * ``` */ @Directive({ selector: '[ndSidebarGroupLabel]', host: { 'data-slot': 'sidebar-group-label', 'data-sidebar': 'group-label', '[class]': 'classes()', }, }) export class SidebarGroupLabel { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0', this.class(), ), ); } /** * A button at the end of a group's label, such as "Add project". Name it * with `sr-only` text. * * @example * ```html * * ``` */ @Directive({ selector: '[ndSidebarGroupAction]', host: { 'data-slot': 'sidebar-group-action', 'data-sidebar': 'group-action', '[class]': 'classes()', }, }) export class SidebarGroupAction { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'absolute top-3.5 end-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', 'after:absolute after:-inset-2 md:after:hidden', 'group-data-[collapsible=icon]:hidden', this.class(), ), ); } /** * The content of a group, usually a {@link SidebarMenu}. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndSidebarGroupContent]', host: { 'data-slot': 'sidebar-group-content', 'data-sidebar': 'group-content', '[class]': 'classes()', }, }) export class SidebarGroupContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('w-full text-sm', this.class()), ); } /** * A list of menu items, on a `ul`. * * @example * ```html *
      …
    * ``` */ @Directive({ selector: '[ndSidebarMenu]', host: { 'data-slot': 'sidebar-menu', 'data-sidebar': 'menu', '[class]': 'classes()', }, }) export class SidebarMenu { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex w-full min-w-0 flex-col gap-1', this.class()), ); } /** * One menu item, on an `li`. * * @example * ```html *
  • …
  • * ``` */ @Directive({ selector: '[ndSidebarMenuItem]', host: { 'data-slot': 'sidebar-menu-item', 'data-sidebar': 'menu-item', '[class]': 'classes()', }, }) export class SidebarMenuItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('group/menu-item relative', this.class()), ); } /** Class table for {@link SidebarMenuButton}. */ export const sidebarMenuButtonVariants = variants({ base: 'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pe-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! 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 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', variants: { variant: { default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', outline: 'bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]', }, size: { default: 'h-8 text-sm', sm: 'h-7 text-xs', lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!', }, }, defaults: { variant: 'default', size: 'default' }, }); /** Visual style of a {@link SidebarMenuButton}. */ export type SidebarMenuButtonVariant = VariantOf< typeof sidebarMenuButtonVariants, 'variant' >; /** Size of a {@link SidebarMenuButton}. */ export type SidebarMenuButtonSize = VariantOf< typeof sidebarMenuButtonVariants, 'size' >; /** * A menu item's link or button: put an icon first and the label after; * while the sidebar shows only icons, the label is clipped but still * names it. `active` marks the current page. * * @example * ```html * * Inbox * * ``` */ @Directive({ selector: '[ndSidebarMenuButton]', hostDirectives: [{ directive: NdSidebarMenuButton, inputs: ['active'] }], host: { 'data-slot': 'sidebar-menu-button', 'data-sidebar': 'menu-button', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class SidebarMenuButton { /** Visual style. */ readonly variant = input('default'); /** Height. */ readonly size = input('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( sidebarMenuButtonVariants({ variant: this.variant(), size: this.size() }), this.class(), ), ); } /** * A button at the end of a menu item, such as its own menu. Name it with * `sr-only` text; `showOnHover` shows it only while the item is hovered or * focused, on wide screens. * * @example * ```html * * ``` */ @Directive({ selector: '[ndSidebarMenuAction]', host: { 'data-slot': 'sidebar-menu-action', 'data-sidebar': 'menu-action', '[class]': 'classes()', }, }) export class SidebarMenuAction { /** Shows it only while its item is hovered or focused, on wide screens. */ readonly showOnHover = input(false, { transform: booleanAttribute }); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'absolute top-1.5 end-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', 'after:absolute after:-inset-2 md:after:hidden', 'peer-data-[size=sm]/menu-button:top-1', 'peer-data-[size=default]/menu-button:top-1.5', 'peer-data-[size=lg]/menu-button:top-2.5', 'group-data-[collapsible=icon]:hidden', this.showOnHover() && 'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground data-[state=open]:opacity-100 md:opacity-0', this.class(), ), ); } /** * A count at the end of a menu item, such as unread messages. * * @example * ```html *
    24
    * ``` */ @Directive({ selector: '[ndSidebarMenuBadge]', host: { 'data-slot': 'sidebar-menu-badge', 'data-sidebar': 'menu-badge', '[class]': 'classes()', }, }) export class SidebarMenuBadge { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'pointer-events-none absolute end-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none', 'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground', 'peer-data-[size=sm]/menu-button:top-1', 'peer-data-[size=default]/menu-button:top-1.5', 'peer-data-[size=lg]/menu-button:top-2.5', 'group-data-[collapsible=icon]:hidden', this.class(), ), ); } /** * A placeholder menu item while the menu loads, with a text bar of a * random width. Put Skeletons in it. * * @example * ```html *
    *
    *
    * ``` */ @Directive({ selector: '[ndSidebarMenuSkeleton]', host: { 'data-slot': 'sidebar-menu-skeleton', 'data-sidebar': 'menu-skeleton', 'aria-hidden': 'true', '[style.--skeleton-width]': 'width', '[class]': 'classes()', }, }) export class SidebarMenuSkeleton { /** Extra classes, merged last so they win. */ readonly class = input(''); /** A width between 50% and 90%, so placeholders don't line up. */ protected readonly width = `${Math.floor(Math.random() * 40) + 50}%`; protected readonly classes = computed(() => cn('flex h-8 items-center gap-2 rounded-md px-2', this.class()), ); } /** * A nested list of a menu item, hidden while the sidebar shows only * icons. * * @example * ```html *
      …
    * ``` */ @Directive({ selector: '[ndSidebarMenuSub]', host: { 'data-slot': 'sidebar-menu-sub', 'data-sidebar': 'menu-sub', '[class]': 'classes()', }, }) export class SidebarMenuSub { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-s border-sidebar-border px-2.5 py-0.5', 'group-data-[collapsible=icon]:hidden', this.class(), ), ); } /** * One item of a nested list, on an `li`. * * @example * ```html *
  • …
  • * ``` */ @Directive({ selector: '[ndSidebarMenuSubItem]', host: { 'data-slot': 'sidebar-menu-sub-item', 'data-sidebar': 'menu-sub-item', '[class]': 'classes()', }, }) export class SidebarMenuSubItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('group/menu-sub-item relative', this.class()), ); } /** * A nested item's link: `size` is `sm` or `md`; `active` marks the * current page. * * @example * ```html * Team * ``` */ @Directive({ selector: '[ndSidebarMenuSubButton]', hostDirectives: [{ directive: NdSidebarMenuButton, inputs: ['active'] }], host: { 'data-slot': 'sidebar-menu-sub-button', 'data-sidebar': 'menu-sub-button', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class SidebarMenuSubButton { /** Text size: `sm` or `md`. */ readonly size = input<'sm' | 'md'>('md'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden 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 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground', 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground', this.size() === 'sm' && 'text-xs', this.size() === 'md' && 'text-sm', 'group-data-[collapsible=icon]:hidden', this.class(), ), ); } ``` ### src/app/ui/button/button.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdButton } from '@ndop/primitives/button'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * Class table for {@link Button}; also usable to style other elements like * a button. */ export const buttonVariants = variants({ base: "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-disabled:pointer-events-none data-disabled:opacity-50", variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', }, size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', icon: 'size-9', 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", 'icon-sm': 'size-8', 'icon-lg': 'size-10', }, }, defaults: { variant: 'default', size: 'default' }, }); /** Visual style of a {@link Button}. */ export type ButtonVariant = VariantOf; /** Size of a {@link Button}. */ export type ButtonSize = VariantOf; /** * Styled button for a native `button` or an `a`, with the * `@ndop/primitives/button` behavior (safe default type, disabled links). * * @example * ```html * * Docs * ``` */ @Directive({ selector: 'button[ndButton], a[ndButton]', hostDirectives: [{ directive: NdButton, inputs: ['disabled'] }], host: { 'data-slot': 'button', '[attr.data-variant]': 'variant()', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class Button { /** Visual style. */ readonly variant = input('default'); /** Height, padding and icon size. */ readonly size = input('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: this.variant(), size: this.size() }), this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Sidebar, SidebarContainer, SidebarContent, SidebarFooter, SidebarGap, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInner, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarRail, SidebarTrigger, } from '@/ui/sidebar/sidebar'; ``` ```html
    Acme Inc
    Home
    Your workspace.
    ``` ## Examples ### Sidebar demo An app shell with a sidebar of grouped links that collapses to icons. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Sidebar, SidebarContainer, SidebarContent, SidebarFooter, SidebarGap, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInner, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarRail, SidebarTrigger, } from '@/ui/sidebar/sidebar'; /** * An app shell: a sidebar of grouped links that collapses to its icons * with the trigger, the rail or Ctrl+B (⌘B), and opens as a dialog on * small screens. In an app, drop the classes that keep it in this box, * and put `ndSidebarInset` on the page's `main`. */ @Component({ selector: 'example-sidebar-demo', imports: [ NdIcon, Sidebar, SidebarContainer, SidebarContent, SidebarFooter, SidebarGap, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInner, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarRail, SidebarTrigger, ], template: `
    Acme Inc
    Home
    Your workspace.
    `, }) export class SidebarDemoExample { protected readonly links = [ { href: '#home', label: 'Home', icon: 'house' }, { href: '#inbox', label: 'Inbox', icon: 'inbox' }, { href: '#calendar', label: 'Calendar', icon: 'calendar' }, { href: '#search', label: 'Search', icon: 'search' }, ]; } ``` ### Sidebar floating A floating sidebar that slides off-canvas, with a badge and nested links. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Sidebar, SidebarContainer, SidebarContent, SidebarGap, SidebarGroup, SidebarGroupContent, SidebarInner, SidebarInset, SidebarMenu, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarTrigger, } from '@/ui/sidebar/sidebar'; /** * A floating sidebar that slides off-canvas, with an unread count and * nested links. Collapsed, it's inert, so its links leave the tab order. */ @Component({ selector: 'example-sidebar-floating', imports: [ NdIcon, Sidebar, SidebarContainer, SidebarContent, SidebarGap, SidebarGroup, SidebarGroupContent, SidebarInner, SidebarInset, SidebarMenu, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarTrigger, ], template: ` `, }) export class SidebarFloatingExample {} ``` ## Accessibility Implements the [Dialog (modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Ctrl+b` | Toggles the sidebar (`⌘B` on a Mac), unless `shortcut` is `null`. | | `Enter` | On the trigger, toggles the sidebar. | | `Escape` | Closes the mobile sidebar and returns focus. | | `Tab` | Moves through the sidebar's content; on mobile, it stays inside. | ### Focus - Opening the mobile sidebar focuses its first focusable element; closing it returns focus to the trigger, or wherever focus was before. - Collapsing the desktop sidebar off-canvas while focus is inside moves focus to the trigger that collapsed it. - Unchanged from the native input. Focusing it and leaving it marks the bound field touched, through the forms library. - The separator never receives focus and never changes focus. ### Right-to-left `start` and `end` are logical: the `start` sidebar is on the right in right-to-left layouts, on desktop and on mobile. Text direction follows the document or the element's `dir`; Ndop changes nothing. Nothing mirrors: orientation is the same in both directions. ## API reference ### SidebarProvider Selector: `[ndSidebarProvider]` The page around a sidebar (`@ndop/primitives/sidebar`): it holds the sidebar's state, sets its widths, and lays the sidebar beside the page's main content. `Ctrl+B` (`⌘B` on a Mac) toggles the sidebar. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[defaultOpen]` | `boolean` | `true` | Whether the desktop sidebar starts expanded, when `open` isn't bound. | | `[mobileQuery]` | `string` | `'(max-width: 767.98px)'` | The CSS media query below which the sidebar is a mobile dialog. | | `[shortcut]` | `string \| null` | `'b'` | The letter that toggles the sidebar with `Ctrl` or `⌘`, or `null`. | | `[(open)]` | `boolean` | `true` | Whether the desktop sidebar is expanded. Two-way bindable. | | `[(openMobile)]` | `boolean` | `false` | Whether the mobile sidebar is open. Two-way bindable. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-wrapper` | | `data-state` | | | `data-mobile` | | ### Sidebar Selector: `[ndSidebar]` The sidebar: on desktop, it collapses off-canvas, to its icons, or not at all; below the `md` breakpoint, it opens as a dialog. Put a `SidebarGap` and a `SidebarContainer` in it, unless `collapsible` is `none`: then put the content in it directly. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[variant]` | `'sidebar' \| 'floating' \| 'inset'` | `'sidebar'` | How it looks: flush with the page, floating or inset. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[side]` | `'start' \| 'end'` | `'start'` | The side it sits on: `start` or `end`. | | `[collapsible]` | `'icon' \| 'none' \| 'offcanvas'` | `'offcanvas'` | How it collapses on desktop. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar` | | `data-variant` | | | `data-state` | | | `data-collapsible` | | | `data-side` | | | `data-mobile` | | ### SidebarGap Selector: `[ndSidebarGap]` Keeps the sidebar's room in the page beside it, widening and narrowing as the sidebar does. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-gap` | ### SidebarContainer Selector: `[ndSidebarContainer]` · Role: `region, dialog on mobile` The fixed panel holding the sidebar's content: off-canvas or narrowed while collapsed, and the dialog panel on mobile. Name it with `aria-label`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[backdropClass]` | `string` | `'cdk-overlay-dark-backdrop'` | Classes of the mobile overlay's backdrop. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-container` | | `data-mobile` | | | `data-state` | | ### SidebarInner Selector: `[ndSidebarInner]` The sidebar's surface, inside its container: a column for its header, content and footer. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-inner` | | `data-sidebar` | `sidebar` | ### SidebarTrigger Selector: `button[ndSidebarTrigger]` The button that opens and closes the sidebar. Name it with `sr-only` text, and put the panel icon in it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-trigger` | | `data-sidebar` | `trigger` | | `data-state` | | ### SidebarRail Selector: `button[ndSidebarRail]` A thin strip along the sidebar's inner edge that toggles it with the pointer, showing a line on hover. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-rail` | | `data-sidebar` | `rail` | ### SidebarInset Selector: `[ndSidebarInset]` The page's main content beside the sidebar; with the `inset` variant, it's a rounded card. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-inset` | ### SidebarInput Selector: `input[ndSidebarInput]` · Role: `native` A search field in the sidebar, styled like an Input. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[id]` | `string \| undefined` | | Element id; also the key a `label[ndLabel]` uses to find this input. | | `[invalid]` | `boolean` | `false` | Marks the input invalid when no forms library is bound; with one, the field's state decides. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-input` | | `data-sidebar` | `input` | | `data-disabled` | | | `data-invalid` | | ### SidebarHeader Selector: `[ndSidebarHeader]` The top of the sidebar, such as a team switcher. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-header` | | `data-sidebar` | `header` | ### SidebarFooter Selector: `[ndSidebarFooter]` The bottom of the sidebar, such as the user's account. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-footer` | | `data-sidebar` | `footer` | ### SidebarSeparator Selector: `[ndSidebarSeparator]` · Role: `separator or none` A line between parts of the sidebar. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[orientation]` | `'horizontal' \| 'vertical'` | `'horizontal'` | Layout direction of the separator. | | `[decorative]` | `boolean` | `false` | Whether the separator is purely visual. Decorative separators are hidden from assistive technology. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-separator` | | `data-sidebar` | `separator` | | `data-orientation` | | ### SidebarContent Selector: `[ndSidebarContent]` The scrolling middle of the sidebar, holding its groups. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-content` | | `data-sidebar` | `content` | ### SidebarGroup Selector: `[ndSidebarGroup]` A group of the sidebar's menu, with a label. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-group` | | `data-sidebar` | `group` | ### SidebarGroupLabel Selector: `[ndSidebarGroupLabel]` A group's label, which fades out while the sidebar shows only icons. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-group-label` | | `data-sidebar` | `group-label` | ### SidebarGroupAction Selector: `[ndSidebarGroupAction]` A button at the end of a group's label, such as "Add project". Name it with `sr-only` text. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-group-action` | | `data-sidebar` | `group-action` | ### SidebarGroupContent Selector: `[ndSidebarGroupContent]` The content of a group, usually a `SidebarMenu`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-group-content` | | `data-sidebar` | `group-content` | ### SidebarMenu Selector: `[ndSidebarMenu]` A list of menu items, on a `ul`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-menu` | | `data-sidebar` | `menu` | ### SidebarMenuItem Selector: `[ndSidebarMenuItem]` One menu item, on an `li`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-menu-item` | | `data-sidebar` | `menu-item` | ### SidebarMenuButton Selector: `[ndSidebarMenuButton]` A menu item's link or button: put an icon first and the label after; while the sidebar shows only icons, the label is clipped but still names it. `active` marks the current page. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[variant]` | `'default' \| 'outline'` | `'default'` | Visual style. | | `[size]` | `'default' \| 'sm' \| 'lg'` | `'default'` | Height. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[active]` | `boolean` | `false` | Marks the link to the current page. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-menu-button` | | `data-sidebar` | `menu-button` | | `data-size` | | | `data-active` | | ### SidebarMenuAction Selector: `[ndSidebarMenuAction]` A button at the end of a menu item, such as its own menu. Name it with `sr-only` text; `showOnHover` shows it only while the item is hovered or focused, on wide screens. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[showOnHover]` | `boolean` | `false` | Shows it only while its item is hovered or focused, on wide screens. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-menu-action` | | `data-sidebar` | `menu-action` | ### SidebarMenuBadge Selector: `[ndSidebarMenuBadge]` A count at the end of a menu item, such as unread messages. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-menu-badge` | | `data-sidebar` | `menu-badge` | ### SidebarMenuSkeleton Selector: `[ndSidebarMenuSkeleton]` A placeholder menu item while the menu loads, with a text bar of a random width. Put Skeletons in it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-menu-skeleton` | | `data-sidebar` | `menu-skeleton` | ### SidebarMenuSub Selector: `[ndSidebarMenuSub]` A nested list of a menu item, hidden while the sidebar shows only icons. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-menu-sub` | | `data-sidebar` | `menu-sub` | ### SidebarMenuSubItem Selector: `[ndSidebarMenuSubItem]` One item of a nested list, on an `li`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-menu-sub-item` | | `data-sidebar` | `menu-sub-item` | ### SidebarMenuSubButton Selector: `[ndSidebarMenuSubButton]` A nested item's link: `size` is `sm` or `md`; `active` marks the current page. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[size]` | `'sm' \| 'md'` | `'md'` | Text size: `sm` or `md`. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[active]` | `boolean` | `false` | Marks the link to the current page. | | Data attribute | Values | | --- | --- | | `data-slot` | `sidebar-menu-sub-button` | | `data-sidebar` | `menu-sub-button` | | `data-size` | | | `data-active` | | ## Agent notes ### Do - Put a `nav` with an `aria-label` in it, and name its container too. - Mark the current page's link with `active`. - Give each menu link an icon and a text label: collapsed to icons, the label is clipped but still names the link. ### Don't - Don't put the page's only way to an action in a collapsed sidebar. - Don't nest menus more than one level; use separate groups. --- # Skeleton > Shows a placeholder while content loads. Version 1.0.0 · SSR · Zoneless · 0.4 kB gzip A skeleton is a placeholder in the shape of content that's still loading, so the page doesn't jump when it arrives. Size it with classes. It's hidden from assistive technology; mark the region that's loading with `aria-busy="true"` so screen readers wait for the real content. ## Installation ```bash npx @ndop/cli add skeleton ``` Or by hand: install `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/skeleton/skeleton.ts ```ts import { computed, Directive, input } from '@angular/core'; import { cn } from '@/lib/utils'; /** * A pulsing placeholder in the shape of content that's loading. It's * hidden from assistive technology; mark the region that's loading with * `aria-busy="true"` instead. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndSkeleton]', host: { 'data-slot': 'skeleton', 'aria-hidden': 'true', '[class]': 'classes()', }, }) export class Skeleton { /** Extra classes, merged last so they win: usually a size and shape. */ readonly class = input(''); protected readonly classes = computed(() => cn('animate-pulse rounded-md bg-accent', this.class()), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Skeleton } from '@/ui/skeleton/skeleton'; ``` ```html
    Loading profile
    ``` ## Examples ### Skeleton demo Placeholders for a profile while it loads. ```ts import { Component } from '@angular/core'; import { Skeleton } from '@/ui/skeleton/skeleton'; /** Placeholders for a profile while it loads. */ @Component({ selector: 'example-skeleton-demo', imports: [Skeleton], template: `
    Loading profile
    `, }) export class SkeletonDemoExample {} ``` ### Skeleton card A card that shows skeletons while its content loads. ```ts import { Component, signal } from '@angular/core'; import { Button } from '@/ui/button/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/ui/card/card'; import { Skeleton } from '@/ui/skeleton/skeleton'; /** * A card that shows skeletons while its content loads, and marks itself * busy so screen readers wait for the real content. */ @Component({ selector: 'example-skeleton-card', imports: [ Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Skeleton, ], template: `

    Weekly report

    @if (loading()) {
    } @else {

    October 2 to October 8

    }
    @if (loading()) {
    } @else {

    1,284 visits

    Up 12% on last week.

    }
    `, }) export class SkeletonCardExample { protected readonly loading = signal(true); } ``` ## Accessibility Skeleton has no keyboard interaction or focus handling of its own; it keeps the semantics of the element it's on. ## API reference ### Skeleton Selector: `[ndSkeleton]` A pulsing placeholder in the shape of content that's loading. It's hidden from assistive technology; mark the region that's loading with `aria-busy="true"` instead. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win: usually a size and shape. | | Data attribute | Values | | --- | --- | | `data-slot` | `skeleton` | ## Agent notes ### Do - Match the size and shape of what's coming: lines of text, an avatar, an image. - Set `aria-busy="true"` on the container while it loads, and remove it when the content is in. - Use `@defer` or a `resource()`'s loading state to swap skeletons for content. ### Don't - Don't show skeletons for content that loads in a blink; it flickers. - Don't put text or controls inside a skeleton. --- # Slider > Picks a number, or a range with two thumbs, by dragging along a track or with the arrow keys. Version 1.0.0 · WAI-ARIA pattern: [Slider](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) · SSR · Zoneless · 3.6 kB gzip A slider picks a number from a range by dragging a thumb along a track, or a range with two thumbs, such as a price filter. Each thumb is a `slider` with its own value, which the arrow keys change by a step and Page Up and Page Down by ten; range thumbs can't pass each other. Horizontal sliders run from the start side, so they mirror in right-to-left layouts, and vertical ones run bottom to top. ## Installation ```bash npx @ndop/cli add slider ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/slider/slider.ts ```ts import { computed, Directive, inject, input } from '@angular/core'; import { ND_SLIDER, NdSlider, NdSliderRange, NdSliderThumb, NdSliderTrack, } from '@ndop/primitives/slider'; import { cn } from '@/lib/utils'; /** * A slider (`@ndop/primitives/slider`): pick a number, or a range with two * thumbs, by dragging or with the arrow keys. The root sets `min`, `max` * and `step`; each `ndSliderThumb` holds one value, so bind each thumb * with `[(value)]` or a forms library, and name it with `aria-label`. * * @example * ```html *
    *
    * *
    * ``` */ @Directive({ selector: '[ndSlider]', hostDirectives: [ { directive: NdSlider, inputs: ['min', 'max', 'step', 'orientation', 'disabled'], }, ], host: { 'data-slot': 'slider', '[class]': 'classes()' }, }) export class Slider { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col', this.class(), ), ); } /** * The track of a {@link Slider}, with a `ndSliderRange` inside. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndSliderTrack]', hostDirectives: [NdSliderTrack], host: { 'data-slot': 'slider-track', '[class]': 'classes()' }, }) export class SliderTrack { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5', this.class(), ), ); } /** * The filled part of a {@link Slider}'s track: from the start to a single * thumb, or between two. * * @example * ```html *
    * ``` */ @Directive({ selector: '[ndSliderRange]', hostDirectives: [NdSliderRange], host: { 'data-slot': 'slider-range', '[style.inset-inline-start.%]': 'horizontal() ? range().start : null', '[style.inset-inline-end.%]': 'horizontal() ? 100 - range().end : null', '[style.bottom.%]': 'horizontal() ? null : range().start', '[style.top.%]': 'horizontal() ? null : 100 - range().end', '[class]': 'classes()', }, }) export class SliderRange { /** Extra classes, merged last so they win. */ readonly class = input(''); private readonly slider = inject(ND_SLIDER); protected readonly range = this.slider.range; protected readonly horizontal = computed( () => this.slider.orientation() === 'horizontal', ); protected readonly classes = computed(() => cn( 'absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full', this.class(), ), ); } /** * A thumb of a {@link Slider}: one value, bound with `[(value)]` or a forms * library. Name it with `aria-label`; add a second one for a range. It * stays inside the track at both ends. * * @example * ```html * * ``` */ @Directive({ selector: '[ndSliderThumb]', hostDirectives: [ { directive: NdSliderThumb, inputs: ['value', 'defaultValue', 'valueText', 'invalid'], outputs: ['valueChange'], }, ], host: { 'data-slot': 'slider-thumb', // The thumb's edge moves from the track's start to its end, so it // never overhangs: positioned at the value's percentage, then pulled // back by the same percentage of its own size. '[style.inset-inline-start.%]': 'horizontal() ? thumb.percent() : null', '[style.bottom.%]': 'horizontal() ? null : thumb.percent()', '[style.--slider-thumb-offset]': 'thumb.percent() + "%"', '[class]': 'classes()', }, }) export class SliderThumb { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly thumb = inject(NdSliderThumb); protected readonly horizontal = computed( () => this.thumb.slider.orientation() === 'horizontal', ); protected readonly classes = computed(() => cn( 'block size-4 shrink-0 rounded-full border border-primary bg-background shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50', 'absolute data-[orientation=horizontal]:-translate-x-(--slider-thumb-offset) rtl:data-[orientation=horizontal]:translate-x-(--slider-thumb-offset) data-[orientation=vertical]:translate-y-(--slider-thumb-offset) aria-disabled:pointer-events-none aria-invalid:border-destructive', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Slider, SliderRange, SliderThumb, SliderTrack, } from '@/ui/slider/slider'; ``` ```html
    ``` ## Examples ### Slider demo A single value from 0 to 100. ```ts import { Component } from '@angular/core'; import { Slider, SliderRange, SliderThumb, SliderTrack, } from '@/ui/slider/slider'; /** A single value from 0 to 100. */ @Component({ selector: 'example-slider-demo', imports: [Slider, SliderRange, SliderThumb, SliderTrack], template: `
    `, }) export class SliderDemoExample {} ``` ### Slider range A price range with two thumbs, and the values shown beside it. ```ts import { Component, signal } from '@angular/core'; import { Slider, SliderRange, SliderThumb, SliderTrack, } from '@/ui/slider/slider'; /** * A price filter with two thumbs that can't pass each other, and the * chosen range shown beside it. */ @Component({ selector: 'example-slider-range', imports: [Slider, SliderRange, SliderThumb, SliderTrack], template: `
    Price {{ '$' + min() }} – {{ '$' + max() }}
    `, }) export class SliderRangeExample { protected readonly min = signal(100); protected readonly max = signal(350); } ``` ### Slider steps Steps of ten, with the value said in words for screen readers. ```ts import { Component, computed, signal } from '@angular/core'; import { Slider, SliderRange, SliderThumb, SliderTrack, } from '@/ui/slider/slider'; /** Steps of ten, with the value said in words for screen readers. */ @Component({ selector: 'example-slider-steps', imports: [Slider, SliderRange, SliderThumb, SliderTrack], template: `

    Quality: {{ label() }}

    `, }) export class SliderStepsExample { protected readonly quality = signal(60); protected readonly label = computed(() => { const quality = this.quality(); const trade = quality < 40 ? 'smaller files' : quality < 80 ? 'balanced' : 'best quality'; return `${quality}%, ${trade}`; }); } ``` ### Slider vertical A vertical slider, running bottom to top. ```ts import { Component } from '@angular/core'; import { Slider, SliderRange, SliderThumb, SliderTrack, } from '@/ui/slider/slider'; /** A vertical slider, running bottom to top. */ @Component({ selector: 'example-slider-vertical', imports: [Slider, SliderRange, SliderThumb, SliderTrack], template: `
    `, }) export class SliderVerticalExample {} ``` ### Slider disabled A disabled slider. ```ts import { Component } from '@angular/core'; import { Slider, SliderRange, SliderThumb, SliderTrack, } from '@/ui/slider/slider'; /** A disabled slider. */ @Component({ selector: 'example-slider-disabled', imports: [Slider, SliderRange, SliderThumb, SliderTrack], template: `
    `, }) export class SliderDisabledExample {} ``` ## Accessibility Implements the [Slider](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `ArrowRight` or `ArrowUp` | Increases the focused thumb's value by one step. | | `ArrowLeft` or `ArrowDown` | Decreases the focused thumb's value by one step. | | `PageUp` | Increases the value by ten steps. | | `PageDown` | Decreases the value by ten steps. | | `Home` | Sets the value to its minimum: the slider's, or the previous thumb's. | | `End` | Sets the value to its maximum: the slider's, or the next thumb's. | ### Focus - Keys and dragging keep focus on the thumb; pressing the slider's track focuses the thumb it moves. - Leaving a thumb marks its bound field touched. ### Right-to-left Horizontal sliders run from the start side, so the minimum is on the right in right-to-left layouts, and `ArrowLeft` increases the value while `ArrowRight` decreases it. Vertical sliders run bottom to top in either direction. Direction comes from the nearest `dir` attribute, on the slider or an ancestor. ## API reference ### Slider Selector: `[ndSlider]` A slider (`@ndop/primitives/slider`): pick a number, or a range with two thumbs, by dragging or with the arrow keys. The root sets `min`, `max` and `step`; each `ndSliderThumb` holds one value, so bind each thumb with `[(value)]` or a forms library, and name it with `aria-label`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[min]` | `number` | `0` | The lowest value. | | `[max]` | `number` | `100` | The highest value. | | `[step]` | `number` | `1` | The distance between allowed values, from `min`. | | `[orientation]` | `'horizontal' \| 'vertical'` | `'horizontal'` | Horizontal (from the start side) or vertical (from the bottom). | | `[disabled]` | `boolean` | `false` | Disables every thumb. | | Data attribute | Values | | --- | --- | | `data-slot` | `slider` | | `data-orientation` | | | `data-disabled` | | ### SliderTrack Selector: `[ndSliderTrack]` The track of a `Slider`, with a `ndSliderRange` inside. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `slider-track` | | `data-orientation` | | | `data-disabled` | | ### SliderRange Selector: `[ndSliderRange]` The filled part of a `Slider`'s track: from the start to a single thumb, or between two. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `slider-range` | | `data-orientation` | | ### SliderThumb Selector: `[ndSliderThumb]` · Role: `slider` A thumb of a `Slider`: one value, bound with `[(value)]` or a forms library. Name it with `aria-label`; add a second one for a range. It stays inside the track at both ends. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[defaultValue]` | `number \| undefined` | `undefined` | Initial `value` when it isn't bound. | | `[valueText]` | `string \| undefined` | | Text for `aria-valuetext`, when the number alone doesn't say it. | | `[invalid]` | `boolean` | `false` | Marks the thumb invalid when no forms library is bound; with one, the field's state decides. | | `[(value)]` | `number` | `0` | This thumb's value. Two-way bindable with `[(value)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `slider-thumb` | | `data-orientation` | | | `data-disabled` | | | `data-invalid` | | ## Agent notes ### Do - Name every thumb, such as "Minimum price" and "Maximum price". - Show the value next to the slider, and set `valueText` when a number alone doesn't say it, such as "$40". ### Don't - Don't use a slider when an exact value matters; use an Input of type number. - Don't use a slider for a few distinct options; use a Radio Group. --- # Spinner > Shows that something is loading. Version 1.0.0 · SSR · Zoneless · 0.4 kB gzip A spinner shows that something is loading when you can't say how far it has got. `ndSpinner` spins what's inside it, usually the `loader-circle` icon, and announces itself as a status named "Loading", or what its `label` says. ## Installation ```bash npx @ndop/cli add spinner ``` Or by hand: install `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/spinner/spinner.ts ```ts import { computed, Directive, input } from '@angular/core'; import { cn } from '@/lib/utils'; /** * A spinning loading indicator, announced as a status. Put the loading * icon inside it: the icon stays hidden from assistive technology, and the * spinner carries the name. * * @example * ```html * * ``` */ @Directive({ selector: '[ndSpinner]', host: { 'data-slot': 'spinner', role: 'status', '[attr.aria-label]': 'label()', '[class]': 'classes()', }, }) export class Spinner { /** What is loading, read by screen readers. */ readonly label = input('Loading'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('size-4 animate-spin', 'inline-flex [&>svg]:size-full', this.class()), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Spinner } from '@/ui/spinner/spinner'; ``` ```html
    ``` ## Examples ### Spinner demo Spinners in three sizes. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Spinner } from '@/ui/spinner/spinner'; /** Spinners in three sizes. */ @Component({ selector: 'example-spinner-demo', imports: [NdIcon, Spinner], template: `
    `, }) export class SpinnerDemoExample {} ``` ### Spinner button A button that shows a spinner while it saves. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; import { Spinner } from '@/ui/spinner/spinner'; /** * A button that shows a spinner, and is disabled, while its action runs. */ @Component({ selector: 'example-spinner-button', imports: [Button, NdIcon, Spinner], template: ` `, }) export class SpinnerButtonExample { protected readonly saving = signal(false); /** Pretends to save for two seconds. */ protected save(): void { this.saving.set(true); setTimeout(() => this.saving.set(false), 2000); } } ``` ## Accessibility Spinner has no keyboard interaction or focus handling of its own; it keeps the semantics of the element it's on. ## API reference ### Spinner Selector: `[ndSpinner]` A spinning loading indicator, announced as a status. Put the loading icon inside it: the icon stays hidden from assistive technology, and the spinner carries the name. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[label]` | `string` | `'Loading'` | What is loading, read by screen readers. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `spinner` | ## Agent notes ### Do - Say what's loading with `label`, such as "Saving changes". - Put it in a disabled button while that button's action runs, next to the button's text. - Use Progress instead when you know how far the task has got. ### Don't - Don't show several spinners for one task. - Don't use it for anything that takes more than a few seconds without saying what's happening. --- # Switch > An on/off control that takes effect right away, such as a setting. Version 1.0.0 · WAI-ARIA pattern: [Switch](https://www.w3.org/WAI/ARIA/apg/patterns/switch/) · SSR · Zoneless · 2.4 kB gzip A switch turns a setting on or off, taking effect right away, like the switches in a phone's settings. It's a native button announced as a switch, with its state as on or off, and binds with `[(checked)]` or any forms library. The thumb moves to the end side when it's on, so it mirrors in right-to-left layouts. A small size suits dense lists. ## Installation ```bash npx @ndop/cli add switch ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/switch/switch.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdSwitch, NdSwitchThumb } from '@ndop/primitives/switch'; import { cn } from '@/lib/utils'; /** * A switch on a native `button` (`@ndop/primitives/switch`): an on/off * setting that applies at once. Bind it with `[(checked)]` or any forms * library, name it with a `label[ndLabel]`, and put a `ndSwitchThumb` * inside. * * @example * ```html * * * ``` */ @Directive({ selector: 'button[ndSwitch]', hostDirectives: [ { directive: NdSwitch, inputs: [ 'checked', 'defaultChecked', 'disabled', 'required', 'invalid', 'id', ], outputs: ['checkedChange'], }, ], host: { 'data-slot': 'switch', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class Switch { /** Size: `default`, or `sm` for dense layouts. */ readonly size = input<'default' | 'sm'>('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80', 'aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40', this.class(), ), ); } /** * The thumb of a {@link Switch}. It moves to the end side when on, so to * the left in right-to-left layouts. * * @example * ```html * * ``` */ @Directive({ selector: '[ndSwitchThumb]', hostDirectives: [NdSwitchThumb], host: { 'data-slot': 'switch-thumb', '[class]': 'classes()' }, }) export class SwitchThumb { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground', 'rtl:data-[state=checked]:-translate-x-[calc(100%-2px)]', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Label } from '@/ui/label/label'; import { Switch, SwitchThumb } from '@/ui/switch/switch'; ``` ```html
    ``` ## Examples ### Switch demo A switch that turns a setting on or off right away. ```ts import { Component } from '@angular/core'; import { Label } from '@/ui/label/label'; import { Switch, SwitchThumb } from '@/ui/switch/switch'; /** A switch that turns a setting on or off right away. */ @Component({ selector: 'example-switch-demo', imports: [Label, Switch, SwitchThumb], template: `
    `, }) export class SwitchDemoExample {} ``` ### Switch small The small size, for dense lists of settings. ```ts import { Component } from '@angular/core'; import { Label } from '@/ui/label/label'; import { Switch, SwitchThumb } from '@/ui/switch/switch'; /** The small size, for dense lists of settings. */ @Component({ selector: 'example-switch-small', imports: [Label, Switch, SwitchThumb], template: `
    `, }) export class SwitchSmallExample {} ``` ### Switch disabled Disabled switches, on and off. ```ts import { Component } from '@angular/core'; import { Label } from '@/ui/label/label'; import { Switch, SwitchThumb } from '@/ui/switch/switch'; /** Disabled switches, on and off. */ @Component({ selector: 'example-switch-disabled', imports: [Label, Switch, SwitchThumb], template: `
    `, }) export class SwitchDisabledExample {} ``` ### Switch form A settings card of switches bound to a Signal Forms model. ```ts import { Component, signal } from '@angular/core'; import { disabled, form, FormField } from '@angular/forms/signals'; import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet, } from '@/ui/form-field/form-field'; import { Switch, SwitchThumb } from '@/ui/switch/switch'; /** * Email settings as switches bound to a Signal Forms model, each saved as * soon as it changes; security emails can't be turned off. */ @Component({ selector: 'example-switch-form', imports: [ Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet, FormField, Switch, SwitchThumb, ], template: `
    Email notifications

    Receive emails about new products, features and more.

    Receive emails about your account security.

    `, }) export class SwitchFormExample { protected readonly model = signal({ marketing: false, security: true }); protected readonly settings = form(this.model, (path) => { disabled(path.security); }); } ``` ## Accessibility Implements the [Switch](https://www.w3.org/WAI/ARIA/apg/patterns/switch/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Space` | Turns it on or off. | | `Enter` | Turns it on or off. | ### Focus - Unchanged from a native button: activating it keeps focus on it. - Leaving it marks a bound field touched. ### Right-to-left The thumb's position is styling: the styled item moves it toward the end side, so it mirrors in right-to-left layouts. ## API reference ### Switch Selector: `button[ndSwitch]` · Role: `switch` A switch on a native `button` (`@ndop/primitives/switch`): an on/off setting that applies at once. Bind it with `[(checked)]` or any forms library, name it with a `label[ndLabel]`, and put a `ndSwitchThumb` inside. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[size]` | `'default' \| 'sm'` | `'default'` | Size: `default`, or `sm` for dense layouts. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[defaultChecked]` | `boolean` | `false` | Initial `checked` when it isn't bound. | | `[disabled]` | `boolean` | `false` | Disables it. A disabled bound field disables it too. | | `[required]` | `boolean` | `false` | Marks it required: `aria-required="true"`. | | `[invalid]` | `boolean` | `false` | Marks it invalid when no forms library is bound; with one, the field's state decides. | | `[id]` | `string \| undefined` | | Element id; also the key a `label[ndLabel]` uses to find it. | | `[(checked)]` | `boolean` | `false` | Whether it's on. Two-way bindable with `[(checked)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `switch` | | `data-size` | | | `data-state` | | | `data-disabled` | | | `data-invalid` | | ### SwitchThumb Selector: `[ndSwitchThumb]` The thumb of a `Switch`. It moves to the end side when on, so to the left in right-to-left layouts. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `switch-thumb` | | `data-state` | | ## Agent notes ### Do - Name it with a label that says what it controls, such as "Airplane mode". - Apply the change as soon as it's switched. ### Don't - Don't use a switch in a form that's only applied when submitted; use a Checkbox. - Don't change the label with the state, such as "On" and "Off". --- # Table > Displays tabular data with responsive styles. Version 1.0.0 · SSR · Zoneless · 0.8 kB gzip Table styles a native HTML table: its caption, header, body, footer, rows and cells. Because each part is the table's own element, screen readers announce rows, columns and headers as they should. Wrap it in `ndTableContainer` so a wide table scrolls sideways instead of the page. ## Installation ```bash npx @ndop/cli add table ``` Or by hand: install `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/table/table.ts ```ts import { computed, Directive, input } from '@angular/core'; import { cn } from '@/lib/utils'; /** * Scrolls a wide table sideways instead of the page. Wrap the * `table[ndTable]` in it. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndTableContainer]', host: { 'data-slot': 'table-container', '[class]': 'classes()' }, }) export class TableContainer { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('relative w-full overflow-x-auto', this.class()), ); } /** * A styled native table. Its parts style the table's own elements, so the * table keeps all of its semantics. * * @example * ```html * * * * *
    Recent invoices
    Invoice
    INV001
    * ``` */ @Directive({ selector: 'table[ndTable]', host: { 'data-slot': 'table', '[class]': 'classes()' }, }) export class Table { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('w-full caption-bottom text-sm', this.class()), ); } /** * A table's header rows. * * @example * ```html * … * ``` */ @Directive({ selector: 'thead[ndTableHeader]', host: { 'data-slot': 'table-header', '[class]': 'classes()' }, }) export class TableHeader { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('[&_tr]:border-b', this.class()), ); } /** * A table's body rows. * * @example * ```html * … * ``` */ @Directive({ selector: 'tbody[ndTableBody]', host: { 'data-slot': 'table-body', '[class]': 'classes()' }, }) export class TableBody { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('[&_tr:last-child]:border-0', this.class()), ); } /** * A table's footer rows, such as totals. * * @example * ```html * … * ``` */ @Directive({ selector: 'tfoot[ndTableFooter]', host: { 'data-slot': 'table-footer', '[class]': 'classes()' }, }) export class TableFooter { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', this.class()), ); } /** * A table row; `data-state="selected"` highlights it. * * @example * ```html * … * ``` */ @Directive({ selector: 'tr[ndTableRow]', host: { 'data-slot': 'table-row', '[class]': 'classes()' }, }) export class TableRow { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted', this.class(), ), ); } /** * A header cell. Add `scope="col"` or `scope="row"` as fits. * * @example * ```html * Status * ``` */ @Directive({ selector: 'th[ndTableHead]', host: { 'data-slot': 'table-head', '[class]': 'classes()' }, }) export class TableHead { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'h-10 px-2 text-start align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pe-0 [&>[role=checkbox]]:translate-y-[2px]', this.class(), ), ); } /** * A data cell. * * @example * ```html * INV001 * ``` */ @Directive({ selector: 'td[ndTableCell]', host: { 'data-slot': 'table-cell', '[class]': 'classes()' }, }) export class TableCell { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0 [&>[role=checkbox]]:translate-y-[2px]', this.class(), ), ); } /** * A table's caption, which names it; shown under the table. * * @example * ```html * Recent invoices * ``` */ @Directive({ selector: 'caption[ndTableCaption]', host: { 'data-slot': 'table-caption', '[class]': 'classes()' }, }) export class TableCaption { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('mt-4 text-sm text-muted-foreground', this.class()), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Table, TableBody, TableCaption, TableCell, TableContainer, TableFooter, TableHead, TableHeader, TableRow, } from '@/ui/table/table'; ``` ```html
    @for (invoice of invoices; track invoice.id) { }
    A list of your recent invoices.
    Invoice Status Method Amount
    {{ invoice.id }} {{ invoice.status }} {{ invoice.method }} {{ invoice.amount }}
    Total $1,750.00
    ``` ## Examples ### Table demo Invoices with a caption and a total. ```ts import { Component } from '@angular/core'; import { Table, TableBody, TableCaption, TableCell, TableContainer, TableFooter, TableHead, TableHeader, TableRow, } from '@/ui/table/table'; /** Recent invoices, named by a caption, with a total in the footer. */ @Component({ selector: 'example-table-demo', imports: [ Table, TableBody, TableCaption, TableCell, TableContainer, TableFooter, TableHead, TableHeader, TableRow, ], template: `
    @for (invoice of invoices; track invoice.id) { }
    A list of your recent invoices.
    Invoice Status Method Amount
    {{ invoice.id }} {{ invoice.status }} {{ invoice.method }} {{ invoice.amount }}
    Total $1,750.00
    `, }) export class TableDemoExample { protected readonly invoices = [ { id: 'INV001', status: 'Paid', method: 'Credit card', amount: '$250.00' }, { id: 'INV002', status: 'Pending', method: 'PayPal', amount: '$150.00' }, { id: 'INV003', status: 'Unpaid', method: 'Bank transfer', amount: '$350.00', }, { id: 'INV004', status: 'Paid', method: 'Credit card', amount: '$450.00' }, { id: 'INV005', status: 'Paid', method: 'PayPal', amount: '$550.00' }, ]; } ``` ### Table status Orders with a status and an action on each row. ```ts import { Component } from '@angular/core'; import { Badge, type BadgeVariant } from '@/ui/badge/badge'; import { Button } from '@/ui/button/button'; import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow, } from '@/ui/table/table'; /** One order's status. */ type Status = 'Delivered' | 'Shipped' | 'Cancelled'; /** * Orders with a status badge and an action on each row; each button's * name says which order it acts on. */ @Component({ selector: 'example-table-status', imports: [ Badge, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow, ], template: `
    @for (order of orders; track order.id) { }
    Order Customer Status Actions
    {{ order.id }} {{ order.customer }} {{ order.status }}
    `, }) export class TableStatusExample { protected readonly variants: Record = { Delivered: 'secondary', Shipped: 'outline', Cancelled: 'destructive', }; protected readonly orders: { id: string; customer: string; status: Status; }[] = [ { id: '#3210', customer: 'Olivia Martin', status: 'Delivered' }, { id: '#3209', customer: 'Jackson Lee', status: 'Shipped' }, { id: '#3204', customer: 'Isabella Nguyen', status: 'Cancelled' }, ]; } ``` ## Accessibility Table has no keyboard interaction or focus handling of its own; it keeps the semantics of the element it's on. ## API reference ### TableContainer Selector: `[ndTableContainer]` Scrolls a wide table sideways instead of the page. Wrap the `table[ndTable]` in it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `table-container` | ### Table Selector: `table[ndTable]` A styled native table. Its parts style the table's own elements, so the table keeps all of its semantics. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `table` | ### TableHeader Selector: `thead[ndTableHeader]` A table's header rows. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `table-header` | ### TableBody Selector: `tbody[ndTableBody]` A table's body rows. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `table-body` | ### TableFooter Selector: `tfoot[ndTableFooter]` A table's footer rows, such as totals. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `table-footer` | ### TableRow Selector: `tr[ndTableRow]` A table row; `data-state="selected"` highlights it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `table-row` | ### TableHead Selector: `th[ndTableHead]` A header cell. Add `scope="col"` or `scope="row"` as fits. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `table-head` | ### TableCell Selector: `td[ndTableCell]` A data cell. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `table-cell` | ### TableCaption Selector: `caption[ndTableCaption]` A table's caption, which names it; shown under the table. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `table-caption` | ## Agent notes ### Do - Name the table with `caption[ndTableCaption]`, or `aria-label` when a visible caption doesn't fit the design. - Give header cells a `scope`: `col` for column headers, `row` for row headers. - Use Data Table for sorting, filtering, selection and pagination. ### Don't - Don't use a table for layout; use flex or grid. - Don't put interactive controls in every cell without labels that say which row they act on. --- # Tabs > Layered sections of content, shown one at a time. Version 1.0.0 · WAI-ARIA pattern: [Tabs](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) · SSR · Zoneless · 3.1 kB gzip Tabs split related content into sections shown one at a time, such as the panes of a settings page. Only the selected tab is in the tab order; the arrow keys move between tabs and show each one's panel, and Tab moves on into the panel. `line` underlines the selected tab instead of raising it, and `orientation="vertical"` stacks the tabs beside their panels. ## Installation ```bash npx @ndop/cli add tabs ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/tabs/tabs.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdTabs, NdTabsContent, NdTabsList, NdTabsTrigger, } from '@ndop/primitives/tabs'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * Tabs (`@ndop/primitives/tabs`): layered sections of content, shown one * at a time. Its list of tabs and its panels go inside it. * * @example * ```html *
    *
    * * *
    *
    …
    *
    …
    *
    * ``` */ @Directive({ selector: '[ndTabs]', hostDirectives: [ { directive: NdTabs, inputs: ['value', 'defaultValue', 'orientation', 'activationMode'], outputs: ['valueChange'], }, ], host: { 'data-slot': 'tabs', '[class]': 'classes()' }, }) export class Tabs { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/tabs flex gap-2 data-[orientation=horizontal]:flex-col', this.class(), ), ); } /** Class table for {@link TabsList}. */ export const tabsListVariants = variants({ base: 'group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none', variants: { variant: { default: 'bg-muted', line: 'gap-1 bg-transparent', }, }, defaults: { variant: 'default' }, }); /** Visual style of a {@link TabsList}. */ export type TabsListVariant = VariantOf; /** * The row (or column) of tabs. `line` underlines the selected tab instead * of raising it. Name it with `aria-label`. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndTabsList]', hostDirectives: [NdTabsList], host: { 'data-slot': 'tabs-list', '[attr.data-variant]': 'variant()', '[class]': 'classes()', }, }) export class TabsList { /** Visual style. */ readonly variant = input('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn(tabsListVariants({ variant: this.variant() }), this.class()), ); } /** * One tab, on a `button`: it shows the panel with the same `value`. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndTabsTrigger]', hostDirectives: [{ directive: NdTabsTrigger, inputs: ['value', 'disabled'] }], host: { 'data-slot': 'tabs-trigger', '[class]': 'classes()' }, }) export class TabsTrigger { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", 'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent', 'data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground', 'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-end-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100', this.class(), ), ); } /** * The panel of the tab with the same `value`, hidden while that tab isn't * selected. It can take focus, so it shows a focus ring. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndTabsContent]', hostDirectives: [{ directive: NdTabsContent, inputs: ['value'] }], host: { 'data-slot': 'tabs-content', '[class]': 'classes()' }, }) export class TabsContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex-1 outline-none', 'rounded-md focus-visible:ring-[3px] focus-visible:ring-ring/50', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Button } from '@/ui/button/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/ui/card/card'; import { Input } from '@/ui/input/input'; import { Label } from '@/ui/label/label'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/ui/tabs/tabs'; ``` ```html

    Account

    Make changes to your account here. Click save when you're done.

    Password

    Change your password here. After saving, you'll be logged out.

    ``` ## Examples ### Tabs demo Account settings in two tabs, each a card with a form. ```ts import { Component } from '@angular/core'; import { Button } from '@/ui/button/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/ui/card/card'; import { Input } from '@/ui/input/input'; import { Label } from '@/ui/label/label'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/ui/tabs/tabs'; /** * Account settings in two tabs, each a card with a form. The arrow keys * move between the tabs; Tab moves on into the shown panel. */ @Component({ selector: 'example-tabs-demo', imports: [ Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Input, Label, Tabs, TabsContent, TabsList, TabsTrigger, ], template: `

    Account

    Make changes to your account here. Click save when you're done.

    Password

    Change your password here. After saving, you'll be logged out.

    `, }) export class TabsDemoExample {} ``` ### Tabs line Tabs underlined instead of raised, with the line variant. ```ts import { Component } from '@angular/core'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/ui/tabs/tabs'; /** Tabs underlined instead of raised, with the line variant. */ @Component({ selector: 'example-tabs-line', imports: [Tabs, TabsContent, TabsList, TabsTrigger], template: `
    Your project at a glance.
    Visitors and conversions over time.
    Reports are on the paid plan.
    `, }) export class TabsLineExample {} ``` ### Tabs vertical Tabs stacked beside their panels, selected with Enter or Space. ```ts import { Component } from '@angular/core'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/ui/tabs/tabs'; /** * Tabs stacked beside their panels. With manual activation, the arrow * keys only move focus; Enter or Space shows the focused tab's panel. */ @Component({ selector: 'example-tabs-vertical', imports: [Tabs, TabsContent, TabsList, TabsTrigger], template: `
    Language, time zone and start page.
    Email and push notifications.
    Who can see your profile and activity.
    `, }) export class TabsVerticalExample {} ``` ## Accessibility Implements the [Tabs](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Tab` | Moves focus into the tab list, to the selected tab; then to the active panel. | | `ArrowRight` or `ArrowLeft` | In a horizontal list, moves focus to the next or previous enabled tab, wrapping; selects it if automatic. | | `ArrowDown` or `ArrowUp` | In a vertical list, moves focus to the next or previous enabled tab, wrapping; selects it if automatic. | | `Home` | Moves focus to the first enabled tab; selects it if automatic. | | `End` | Moves focus to the last enabled tab; selects it if automatic. | | `Enter` or `Space` | Selects the focused tab. | ### Focus - The selected tab is the list's only tab stop; arrow keys move focus without leaving the list, and disabled tabs are skipped. - Clicking a tab focuses and selects it. ### Right-to-left In a horizontal list in a right-to-left layout, `ArrowLeft` moves to the next tab and `ArrowRight` to the previous one. Direction comes from the nearest `dir` attribute. ## API reference ### Tabs Selector: `[ndTabs]` Tabs (`@ndop/primitives/tabs`): layered sections of content, shown one at a time. Its list of tabs and its panels go inside it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[defaultValue]` | `string \| null` | `null` | Initial `value` when it isn't bound. | | `[orientation]` | `'horizontal' \| 'vertical'` | `'horizontal'` | The tab list's layout: which arrow keys move between tabs. | | `[activationMode]` | `'automatic' \| 'manual'` | `'automatic'` | Whether moving focus to a tab selects it. | | `[(value)]` | `string \| null` | `null` | The selected tab's value, or `null` before one is selected. Two-way bindable with `[(value)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `tabs` | | `data-orientation` | | ### TabsList Selector: `[ndTabsList]` · Role: `tablist` The row (or column) of tabs. `line` underlines the selected tab instead of raising it. Name it with `aria-label`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[variant]` | `'default' \| 'line'` | `'default'` | Visual style. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `tabs-list` | | `data-variant` | | | `data-orientation` | | ### TabsTrigger Selector: `button[ndTabsTrigger]` · Role: `tab` One tab, on a `button`: it shows the panel with the same `value`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[value]` (required) | `string` | | The value of the panel it shows. | | `[disabled]` | `boolean` | `false` | Disables it; arrow keys skip it. | | Data attribute | Values | | --- | --- | | `data-slot` | `tabs-trigger` | | `data-state` | | | `data-disabled` | | | `data-orientation` | | ### TabsContent Selector: `[ndTabsContent]` · Role: `tabpanel` The panel of the tab with the same `value`, hidden while that tab isn't selected. It can take focus, so it shows a focus ring. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[value]` (required) | `string` | | The value of the tab that shows it. | | Data attribute | Values | | --- | --- | | `data-slot` | `tabs-content` | | `data-state` | | | `data-orientation` | | ## Agent notes ### Do - Keep tab labels short, and the content of each panel independent. - Name the list with `aria-label` when nothing on the page names it. - Use `activationMode="manual"` when showing a panel is slow, so moving through the tabs doesn't load each one. ### Don't - Don't use tabs to step through a process in order; use a form with steps. - Don't use them for site navigation between pages; use links, or Navigation Menu. --- # Textarea > Displays a multi-line text field, or a component that looks like one. Version 1.0.0 · SSR · Zoneless · 1.8 kB gzip A textarea takes longer text than an input: a comment, a message, a bio. `ndTextarea` styles a native `textarea` and grows with its content. It reflects the bound field's invalid and disabled states like Input does, so forms bind the `textarea` directly. ## Installation ```bash npx @ndop/cli add textarea ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/textarea/textarea.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdInput } from '@ndop/primitives/input'; import { cn } from '@/lib/utils'; /** * Styled multi-line text field (`@ndop/primitives/input`). It grows with * its content (`field-sizing: content`) from a two-line minimum. Forms bind * the native `textarea`; invalid and disabled states show once the bound * field reports them, and a `label[ndLabel]` with a matching `for` follows * them too. * * @example * ```html * * * ``` */ @Directive({ selector: 'textarea[ndTextarea]', hostDirectives: [{ directive: NdInput, inputs: ['id', 'invalid'] }], host: { 'data-slot': 'textarea', '[class]': 'classes()' }, }) export class Textarea { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Label } from '@/ui/label/label'; import { Textarea } from '@/ui/textarea/textarea'; ``` ```html

    Your message goes to the support team.

    ``` ## Examples ### Textarea demo A textarea with a label and a description. ```ts import { Component } from '@angular/core'; import { Label } from '@/ui/label/label'; import { Textarea } from '@/ui/textarea/textarea'; /** A textarea with a label and a description. */ @Component({ selector: 'example-textarea-demo', imports: [Label, Textarea], template: `

    Your message goes to the support team.

    `, }) export class TextareaDemoExample {} ``` ### Textarea form A required message field in a Signal Forms form, with its error. ```ts import { Component, signal } from '@angular/core'; import { form, FormField, maxLength, required } from '@angular/forms/signals'; import { Button } from '@/ui/button/button'; import { Label } from '@/ui/label/label'; import { Textarea } from '@/ui/textarea/textarea'; /** * A required message field in a Signal Forms form. It turns invalid once * touched, says why under the field, and counts what's left. */ @Component({ selector: 'example-textarea-form', imports: [Button, FormField, Label, Textarea], template: `

    @if (profile.bio().touched() && profile.bio().invalid()) { Write a bio of up to 160 characters. } @else { {{ 160 - model().bio.length }} characters left. }

    `, }) export class TextareaFormExample { protected readonly model = signal({ bio: '' }); protected readonly profile = form(this.model, (path) => { required(path.bio); maxLength(path.bio, 160); }); /** * Marks the fields touched, so errors show, instead of reloading. * * @param event - The form's submit event. */ protected send(event: Event): void { event.preventDefault(); this.profile().markAsTouched(); } } ``` ### Textarea disabled A disabled textarea with its label. ```ts import { Component } from '@angular/core'; import { Label } from '@/ui/label/label'; import { Textarea } from '@/ui/textarea/textarea'; /** A disabled textarea; its label dims with it. */ @Component({ selector: 'example-textarea-disabled', imports: [Label, Textarea], template: `
    `, }) export class TextareaDisabledExample {} ``` ## Accessibility ### Focus - Unchanged from the native input. Focusing it and leaving it marks the bound field touched, through the forms library. ### Right-to-left Text direction follows the document or the element's `dir`; Ndop changes nothing. ## API reference ### Textarea Selector: `textarea[ndTextarea]` · Role: `native` Styled multi-line text field (`@ndop/primitives/input`). It grows with its content (`field-sizing: content`) from a two-line minimum. Forms bind the native `textarea`; invalid and disabled states show once the bound field reports them, and a `label[ndLabel]` with a matching `for` follows them too. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[id]` | `string \| undefined` | | Element id; also the key a `label[ndLabel]` uses to find this input. | | `[invalid]` | `boolean` | `false` | Marks the input invalid when no forms library is bound; with one, the field's state decides. | | Data attribute | Values | | --- | --- | | `data-slot` | `textarea` | | `data-disabled` | | | `data-invalid` | | ## Agent notes ### Do - Give it a visible `label[ndLabel]` with a matching `for` and `id`. - Bind the form field to the `textarea` itself, with `[formField]`, `formControlName` or `ngModel`. - Say the expected length or format in a description under it, linked with `aria-describedby`. ### Don't - Don't use it for a single line of text; use Input. - Don't rely on the placeholder as the label; it disappears as soon as the user types. --- # Toast > A brief message about an action, shown in a corner without interrupting, such as Saved or Message sent. Version 1.0.0 · WAI-ARIA pattern: [live region](https://www.w3.org/TR/wai-aria-1.2/#aria-live) · SSR · Zoneless · 3.6 kB gzip A toast is a brief message about something that just happened, such as "Saved" or "Message sent", in a corner of the screen. Toasts stack, newest in front, and spread out while hovered or focused; they're announced politely by screen readers, without moving focus, and go away after four seconds unless the pointer or focus is on them. F8 jumps to them. Show one with `inject(Toaster).show({ title: 'Saved' })`, and put the region in your app's shell once, with `toaster = inject(Toaster)`: ```html
      @for (toast of toaster.toasts(); track toast.id) {
    1. {{ toast.title }}

      @if (toast.description) {

      {{ toast.description }}

      }
      @if (toast.action; as action) { }
    2. }
    ``` ## Installation ```bash npx @ndop/cli add toast ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/toast/toast.ts ```ts import { computed, Directive, inject, input } from '@angular/core'; import { NdToast, NdToastAction, NdToastClose, NdToastDescription, NdToastRegion, NdToastTitle, NdToasterOutlet, } from '@ndop/primitives/toast'; import { cn } from '@/lib/utils'; export { NdToaster as Toaster, type NdToastActionOptions as ToastActionOptions, type NdToastOptions as ToastOptions, type NdToastRecord as ToastRecord, type NdToastType as ToastType, } from '@ndop/primitives/toast'; /** * Renders the toast region in its template above everything else, once the * app has hydrated. Put one in the app's shell. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: 'ng-template[ndToaster]', hostDirectives: [NdToasterOutlet], }) export class ToasterOutlet {} /** How many toasts show in the collapsed stack. */ const VISIBLE = 3; /** * The toast region (`@ndop/primitives/toast`), in the corner at the end * side, bottom: a polite live region and landmark that F8 focuses. Put it * in an `ng-template[ndToaster]` once, in the app's shell, with an `ol` of * the toasts in `NdToaster.toasts()`. Hovering it or focusing inside it * pauses the toasts and spreads the stack out. * * @example * ```html * *
    *
      * @for (toast of toaster.toasts(); track toast.id) { *
    1. …
    2. * } *
    *
    *
    * ``` */ @Directive({ selector: '[ndToastRegion]', hostDirectives: [NdToastRegion], host: { 'data-slot': 'toaster', '[class]': 'classes()' }, }) export class ToastRegion { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'toaster group fixed end-4 bottom-4 z-[1100] w-[calc(100%-2rem)] rounded-(--radius) outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 sm:w-[356px]', this.class(), ), ); } /** * The list of toasts: stacked on top of each other, newest in front, and * spread out while its region is hovered or focused. * * @example * ```html *
      …
    * ``` */ @Directive({ selector: 'ol[ndToastList]', host: { 'data-slot': 'toast-list', '[class]': 'classes()' }, }) export class ToastList { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'grid [grid-template-areas:"stack"] items-end group-data-expanded:flex group-data-expanded:flex-col-reverse group-data-expanded:gap-2', this.class(), ), ); } /** * One toast, styled like Sonner's: in the stack, older toasts sit * slightly behind and above the newest, and only three show until the * stack spreads out. It follows a swipe toward the end side, which * dismisses it past 50 pixels. * * @example * ```html *
  • * *
    *

    {{ toast.title }}

    *
    *
  • * ``` */ @Directive({ selector: '[ndToast]', hostDirectives: [{ directive: NdToast, inputs: ['ndToast'] }], host: { 'data-slot': 'toast', '[class]': 'classes()' }, }) export class Toast { /** Extra classes, merged last so they win. */ readonly class = input(''); private readonly toast = inject(NdToast); protected readonly classes = computed(() => cn( 'relative flex w-full items-center gap-3 rounded-(--radius) border border-border bg-popover p-4 text-sm text-popover-foreground shadow-lg [grid-area:stack] [&>svg]:size-4 [&>svg]:shrink-0', 'z-[calc(100-var(--nd-toast-index))] origin-bottom transition-[translate,scale,opacity] duration-300 ease-out motion-reduce:transition-none data-swiping:transition-none', 'translate-x-(--nd-toast-swipe) rtl:-translate-x-(--nd-toast-swipe) translate-y-[calc(var(--nd-toast-index)*-14px)] scale-[calc(1-var(--nd-toast-index)*0.05)]', 'group-data-expanded:translate-y-0 group-data-expanded:scale-100', this.toast.index() >= VISIBLE && 'pointer-events-none opacity-0 group-data-expanded:pointer-events-auto group-data-expanded:opacity-100', this.class(), ), ); } /** * Stacks a toast's title and description. * * @example * ```html *
    …
    * ``` */ @Directive({ selector: '[ndToastContent]', host: { 'data-slot': 'toast-content', '[class]': 'classes()' }, }) export class ToastContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('grid flex-1 gap-0.5', this.class()), ); } /** * A toast's message. * * @example * ```html *

    {{ toast.title }}

    * ``` */ @Directive({ selector: '[ndToastTitle]', hostDirectives: [NdToastTitle], host: { 'data-slot': 'toast-title', '[class]': 'classes()' }, }) export class ToastTitle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('font-medium', this.class())); } /** * A toast's detail, under its title. * * @example * ```html *

    {{ toast.description }}

    * ``` */ @Directive({ selector: '[ndToastDescription]', hostDirectives: [NdToastDescription], host: { 'data-slot': 'toast-description', '[class]': 'classes()' }, }) export class ToastDescription { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('text-muted-foreground', this.class()), ); } /** * The button of a toast's action, such as Undo: it runs the action, then * dismisses the toast. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndToastAction]', hostDirectives: [NdToastAction], host: { 'data-slot': 'toast-action', '[class]': 'classes()' }, }) export class ToastAction { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'ms-auto inline-flex h-6 shrink-0 items-center rounded-md bg-primary px-2 text-xs font-medium text-primary-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50', this.class(), ), ); } /** * The button that dismisses its toast, at its top start corner; name it, * such as `aria-label="Close"`, and put an icon in it. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndToastClose]', hostDirectives: [NdToastClose], host: { 'data-slot': 'toast-close', '[class]': 'classes()' }, }) export class ToastClose { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'absolute -top-2 -start-2 flex size-5 items-center justify-center rounded-full border border-border bg-popover text-popover-foreground opacity-0 transition-opacity outline-none group-data-expanded:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-3', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Button } from '@/ui/button/button'; import { Toaster } from '@/ui/toast/toast'; ``` ```html ``` ## Examples ### Toast demo A toast with a description and an Undo action. ```ts import { Component, inject } from '@angular/core'; import { Button } from '@/ui/button/button'; import { Toaster } from '@/ui/toast/toast'; /** * A toast with a description and an Undo action. Toasts show in the * toast region your app's shell renders once (see the Toast page). */ @Component({ selector: 'example-toast-demo', imports: [Button], template: ` `, }) export class ToastDemoExample { private readonly toaster = inject(Toaster); /** Shows the confirmation, with an Undo that says so. */ protected schedule(): void { this.toaster.show({ title: 'Event has been created', description: 'Sunday, December 3 at 9:00 AM', action: { label: 'Undo', onClick: () => this.toaster.show({ title: 'Event removed' }), }, }); } } ``` ### Toast types Success, info, warning, error and loading toasts, the loading one replaced when it finishes. ```ts import { Component, inject } from '@angular/core'; import { Button } from '@/ui/button/button'; import { Toaster, type ToastType } from '@/ui/toast/toast'; /** * Success, info, warning, error and loading toasts. The loading one is * replaced, under the same id, when the work finishes. */ @Component({ selector: 'example-toast-types', imports: [Button], template: `
    @for (type of types; track type.type) { }
    `, }) export class ToastTypesExample { private readonly toaster = inject(Toaster); protected readonly types: readonly { type: ToastType; label: string; title: string; }[] = [ { type: 'success', label: 'Success', title: 'Changes saved' }, { type: 'info', label: 'Info', title: 'A new version is available' }, { type: 'warning', label: 'Warning', title: 'Your storage is almost full' }, { type: 'error', label: 'Error', title: "Couldn't upload the file" }, ]; /** * Shows a toast of a type. * * @param type - What it's about. * @param title - Its message. */ protected show(type: ToastType, title: string): void { this.toaster.show({ type, title }); } /** Shows a loading toast, then replaces it once the upload is done. */ protected load(): void { const id = this.toaster.show({ type: 'loading', title: 'Uploading report.pdf…', duration: Infinity, }); setTimeout( () => this.toaster.show({ id, type: 'success', title: 'Report uploaded' }), 2000, ); } } ``` ## Accessibility Implements the [live region](https://www.w3.org/TR/wai-aria-1.2/#aria-live) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `F8` | Focuses the toast region from anywhere on the page. | | `Tab` | Moves through the toasts' buttons, then on to the page. | | `Escape` | With focus in a toast, dismisses it. | ### Focus - Toasts never take focus. F8 moves focus to the region; while it's there, nothing times out. - When a toast with focus inside is dismissed, focus moves to the region. ### Right-to-left Toasts swipe toward the end side, so to the left in right-to-left layouts. Direction comes from the nearest `dir` attribute. ## API reference ### ToasterOutlet Selector: `ng-template[ndToaster]` Renders the toast region in its template above everything else, once the app has hydrated. Put one in the app's shell. ### ToastRegion Selector: `[ndToastRegion]` · Role: `region` The toast region (`@ndop/primitives/toast`), in the corner at the end side, bottom: a polite live region and landmark that F8 focuses. Put it in an `ng-template[ndToaster]` once, in the app's shell, with an `ol` of the toasts in `NdToaster.toasts()`. Hovering it or focusing inside it pauses the toasts and spreads the stack out. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `toaster` | | `data-expanded` | | ### ToastList Selector: `ol[ndToastList]` The list of toasts: stacked on top of each other, newest in front, and spread out while its region is hovered or focused. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `toast-list` | ### Toast Selector: `[ndToast]` One toast, styled like Sonner's: in the stack, older toasts sit slightly behind and above the newest, and only three show until the stack spreads out. It follows a swipe toward the end side, which dismisses it past 50 pixels. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[ndToast]` (required) | `NdToastRecord` | | The toast's record. | | Data attribute | Values | | --- | --- | | `data-slot` | `toast` | | `data-type` | | | `data-index` | | | `data-front` | | | `data-swiping` | | ### ToastContent Selector: `[ndToastContent]` Stacks a toast's title and description. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `toast-content` | ### ToastTitle Selector: `[ndToastTitle]` A toast's message. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `toast-title` | ### ToastDescription Selector: `[ndToastDescription]` A toast's detail, under its title. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `toast-description` | ### ToastAction Selector: `button[ndToastAction]` The button of a toast's action, such as Undo: it runs the action, then dismisses the toast. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `toast-action` | ### ToastClose Selector: `button[ndToastClose]` The button that dismisses its toast, at its top start corner; name it, such as `aria-label="Close"`, and put an icon in it. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | Data attribute | Values | | --- | --- | | `data-slot` | `toast-close` | ## Agent notes ### Do - Keep it short, and say what happened: "Event created". - Offer Undo as its action for things that can be undone. - Use `duration: Infinity` for toasts people must act on. ### Don't - Don't use a toast as the only place an error is shown: it disappears. - Don't show several at once for one action. --- # Toggle > A button that stays pressed or released, such as Bold in a text editor. Version 1.0.0 · WAI-ARIA pattern: [Button](https://www.w3.org/WAI/ARIA/apg/patterns/button/) · SSR · Zoneless · 1.3 kB gzip A toggle is a button that stays pressed or released, such as Bold in a text editor. It's announced with its pressed state, comes in a default and an outline style and three sizes, and binds with `[(pressed)]`. For several related toggles, such as formatting or alignment, use a Toggle Group, which adds arrow-key navigation. ## Installation ```bash npx @ndop/cli add toggle ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/toggle/toggle.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdToggle } from '@ndop/primitives/toggle'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** Class table for {@link Toggle}, shared with Toggle Group's items. */ export const toggleVariants = variants({ base: "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", variants: { variant: { default: 'bg-transparent', outline: 'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground', }, size: { default: 'h-9 min-w-9 px-2', sm: 'h-8 min-w-8 px-1.5', lg: 'h-10 min-w-10 px-2.5', }, }, defaults: { variant: 'default', size: 'default' }, }); /** Visual style of a {@link Toggle}. */ export type ToggleVariant = VariantOf; /** Size of a {@link Toggle}. */ export type ToggleSize = VariantOf; /** * A toggle button (`@ndop/primitives/toggle`): pressed or released, * announced with `aria-pressed`. Bind it with `[(pressed)]`. Keep its name * the same in both states, such as `aria-label="Toggle bold"`. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndToggle]', hostDirectives: [ { directive: NdToggle, inputs: ['pressed', 'defaultPressed', 'disabled'], outputs: ['pressedChange'], }, ], host: { 'data-slot': 'toggle', '[attr.data-variant]': 'variant()', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class Toggle { /** Visual style. */ readonly variant = input('default'); /** Size. */ readonly size = input('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( toggleVariants({ variant: this.variant(), size: this.size() }), this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Toggle } from '@/ui/toggle/toggle'; ``` ```html ``` ## Examples ### Toggle demo An icon-only toggle, named for screen readers. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Toggle } from '@/ui/toggle/toggle'; /** An icon-only toggle, named for screen readers. */ @Component({ selector: 'example-toggle-demo', imports: [NdIcon, Toggle], template: ` `, }) export class ToggleDemoExample {} ``` ### Toggle outline The outline variant. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Toggle } from '@/ui/toggle/toggle'; /** The outline variant. */ @Component({ selector: 'example-toggle-outline', imports: [NdIcon, Toggle], template: ` `, }) export class ToggleOutlineExample {} ``` ### Toggle with text A toggle with an icon and a label. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Toggle } from '@/ui/toggle/toggle'; /** * A toggle with an icon and a label, whose icon fills while it's pressed. */ @Component({ selector: 'example-toggle-with-text', imports: [NdIcon, Toggle], template: ` `, }) export class ToggleWithTextExample { protected readonly saved = signal(false); } ``` ### Toggle sizes The small, default and large sizes. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Toggle } from '@/ui/toggle/toggle'; /** The small, default and large sizes. */ @Component({ selector: 'example-toggle-sizes', imports: [NdIcon, Toggle], template: `
    `, }) export class ToggleSizesExample {} ``` ### Toggle disabled A disabled toggle. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Toggle } from '@/ui/toggle/toggle'; /** A disabled toggle. */ @Component({ selector: 'example-toggle-disabled', imports: [NdIcon, Toggle], template: ` `, }) export class ToggleDisabledExample {} ``` ## Accessibility Implements the [Button](https://www.w3.org/WAI/ARIA/apg/patterns/button/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Space` | Presses or releases it. | | `Enter` | Presses or releases it. | ### Focus - Unchanged from a native button: activating it keeps focus on it. ### Right-to-left Nothing mirrors. ## API reference ### Toggle Selector: `button[ndToggle]` A toggle button (`@ndop/primitives/toggle`): pressed or released, announced with `aria-pressed`. Bind it with `[(pressed)]`. Keep its name the same in both states, such as `aria-label="Toggle bold"`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[variant]` | `'default' \| 'outline'` | `'default'` | Visual style. | | `[size]` | `'default' \| 'sm' \| 'lg'` | `'default'` | Size. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[defaultPressed]` | `boolean` | `false` | Initial `pressed` when it isn't bound. | | `[disabled]` | `boolean` | `false` | Disables it. A disabled bound field disables it too. | | `[(pressed)]` | `boolean` | `false` | Whether it's pressed. Two-way bindable with `[(pressed)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `toggle` | | `data-variant` | | | `data-size` | | | `data-state` | | | `data-disabled` | | ## Agent notes ### Do - Give an icon-only toggle an `aria-label`, such as "Toggle bold". - Keep the same name in both states. ### Don't - Don't use a toggle for an on/off setting; use a Switch. - Don't use it to trigger an action; use a Button. --- # Toggle Group > A set of toggle buttons of which one or several are pressed, such as text alignment or formatting. Version 1.0.0 · WAI-ARIA pattern: [toggle buttons](https://www.w3.org/WAI/ARIA/apg/patterns/button/) · SSR · Zoneless · 3.1 kB gzip A toggle group is a set of toggle buttons: one pressed at most, such as text alignment, or any number, such as bold, italic and underline. Only one of its buttons is in the tab order, and the arrow keys move between them. Its value is always an array of the pressed buttons' values. With `spacing` 0, the default, its buttons join into one bar; the outline variant draws a border around each. ## Installation ```bash npx @ndop/cli add toggle-group ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/toggle-group/toggle-group.ts ```ts import { computed, Directive, inject, input, numberAttribute, } from '@angular/core'; import { NdToggleGroup, NdToggleGroupItem, } from '@ndop/primitives/toggle-group'; import { toggleVariants, type ToggleSize, type ToggleVariant, } from '@/ui/toggle/toggle'; import { cn } from '@/lib/utils'; /** * A group of toggle buttons (`@ndop/primitives/toggle-group`): one pressed * at most (`type="single"`, the default) or any number * (`type="multiple"`). Arrow keys move between items. Its value is always * an array of the pressed items' values. Name it with `aria-label`. Its * `variant` and `size` apply to every item; with `spacing` 0 (the default) * the items join into one bar. * * @example * ```html *
    * * *
    * ``` */ @Directive({ selector: '[ndToggleGroup]', hostDirectives: [ { directive: NdToggleGroup, inputs: ['value', 'defaultValue', 'type', 'orientation', 'disabled'], outputs: ['valueChange'], }, ], host: { 'data-slot': 'toggle-group', '[attr.data-variant]': 'variant()', '[attr.data-size]': 'size()', '[attr.data-spacing]': 'spacing()', '[style.--gap]': 'spacing()', '[class]': 'classes()', }, }) export class ToggleGroup { /** Visual style of every item. */ readonly variant = input('default'); /** Size of every item. */ readonly size = input('default'); /** * Space between items, in Tailwind spacing units; 0 joins them into one * bar. */ readonly spacing = input(0, { transform: numberAttribute }); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs', 'data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch', this.class(), ), ); } /** * One toggle button of a {@link ToggleGroup}, styled like a Toggle with the * group's variant and size. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndToggleGroupItem]', hostDirectives: [ { directive: NdToggleGroupItem, inputs: ['value', 'disabled'] }, ], host: { 'data-slot': 'toggle-group-item', '[attr.data-variant]': 'resolvedVariant()', '[attr.data-size]': 'resolvedSize()', '[attr.data-spacing]': 'group.spacing()', '[class]': 'classes()', }, }) export class ToggleGroupItem { /** Visual style, when the group doesn't set one. */ readonly variant = input(); /** Size, when the group doesn't set one. */ readonly size = input(); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly group = inject(ToggleGroup); protected readonly resolvedVariant = computed( () => this.variant() ?? this.group.variant(), ); protected readonly resolvedSize = computed( () => this.size() ?? this.group.size(), ); protected readonly classes = computed(() => cn( toggleVariants({ variant: this.resolvedVariant(), size: this.resolvedSize(), }), 'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10', 'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-s-md data-[spacing=0]:last:rounded-e-md data-[spacing=0]:data-[variant=outline]:border-s-0 data-[spacing=0]:data-[variant=outline]:first:border-s', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ### src/app/ui/toggle/toggle.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdToggle } from '@ndop/primitives/toggle'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** Class table for {@link Toggle}, shared with Toggle Group's items. */ export const toggleVariants = variants({ base: "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", variants: { variant: { default: 'bg-transparent', outline: 'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground', }, size: { default: 'h-9 min-w-9 px-2', sm: 'h-8 min-w-8 px-1.5', lg: 'h-10 min-w-10 px-2.5', }, }, defaults: { variant: 'default', size: 'default' }, }); /** Visual style of a {@link Toggle}. */ export type ToggleVariant = VariantOf; /** Size of a {@link Toggle}. */ export type ToggleSize = VariantOf; /** * A toggle button (`@ndop/primitives/toggle`): pressed or released, * announced with `aria-pressed`. Bind it with `[(pressed)]`. Keep its name * the same in both states, such as `aria-label="Toggle bold"`. * * @example * ```html * * ``` */ @Directive({ selector: 'button[ndToggle]', hostDirectives: [ { directive: NdToggle, inputs: ['pressed', 'defaultPressed', 'disabled'], outputs: ['pressedChange'], }, ], host: { 'data-slot': 'toggle', '[attr.data-variant]': 'variant()', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class Toggle { /** Visual style. */ readonly variant = input('default'); /** Size. */ readonly size = input('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( toggleVariants({ variant: this.variant(), size: this.size() }), this.class(), ), ); } ``` ## Usage ```ts import { ToggleGroup, ToggleGroupItem } from '@/ui/toggle-group/toggle-group'; ``` ```html
    ``` ## Examples ### Toggle Group demo Text formatting where any number of options combine, in the outline style. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { ToggleGroup, ToggleGroupItem } from '@/ui/toggle-group/toggle-group'; /** Text formatting where any number of options combine, in the outline style. */ @Component({ selector: 'example-toggle-group-demo', imports: [NdIcon, ToggleGroup, ToggleGroupItem], template: `
    `, }) export class ToggleGroupDemoExample { protected readonly formatting = signal(['bold']); } ``` ### Toggle Group single Text alignment, where one option at most is pressed. ```ts import { Component, signal } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { ToggleGroup, ToggleGroupItem } from '@/ui/toggle-group/toggle-group'; /** Text alignment, where one option at most is pressed. */ @Component({ selector: 'example-toggle-group-single', imports: [NdIcon, ToggleGroup, ToggleGroupItem], template: `
    `, }) export class ToggleGroupSingleExample { protected readonly alignment = signal(['start']); } ``` ### Toggle Group sizes The small and large sizes. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { ToggleGroup, ToggleGroupItem } from '@/ui/toggle-group/toggle-group'; /** The small and large sizes. */ @Component({ selector: 'example-toggle-group-sizes', imports: [NdIcon, ToggleGroup, ToggleGroupItem], template: `
    @for (size of sizes; track size) {
    }
    `, }) export class ToggleGroupSizesExample { protected readonly sizes = ['sm', 'lg'] as const; } ``` ### Toggle Group spacing Separate buttons instead of one joined bar. ```ts import { Component } from '@angular/core'; import { ToggleGroup, ToggleGroupItem } from '@/ui/toggle-group/toggle-group'; /** Separate buttons instead of one joined bar. */ @Component({ selector: 'example-toggle-group-spacing', imports: [ToggleGroup, ToggleGroupItem], template: `
    `, }) export class ToggleGroupSpacingExample {} ``` ### Toggle Group vertical A vertical group, moved through with the up and down arrows. ```ts import { Component } from '@angular/core'; import { ToggleGroup, ToggleGroupItem } from '@/ui/toggle-group/toggle-group'; /** A vertical group, moved through with the up and down arrows. */ @Component({ selector: 'example-toggle-group-vertical', imports: [ToggleGroup, ToggleGroupItem], template: `
    `, }) export class ToggleGroupVerticalExample {} ``` ### Toggle Group disabled A disabled group. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { ToggleGroup, ToggleGroupItem } from '@/ui/toggle-group/toggle-group'; /** A disabled group. */ @Component({ selector: 'example-toggle-group-disabled', imports: [NdIcon, ToggleGroup, ToggleGroupItem], template: `
    `, }) export class ToggleGroupDisabledExample {} ``` ## Accessibility Implements the [toggle buttons](https://www.w3.org/WAI/ARIA/apg/patterns/button/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Tab` | Moves focus into the group, to its tab stop; then out of it. | | `Space` or `Enter` | Presses or releases the focused item. | | `ArrowRight` or `ArrowLeft` | Horizontal groups: moves focus to the next or previous enabled item, wrapping, without pressing it. | | `ArrowDown` or `ArrowUp` | Vertical groups: moves focus to the next or previous enabled item, wrapping, without pressing it. | | `Home` or `End` | Moves focus to the first or last enabled item. | ### Focus - Arrow keys, Home and End move focus only; pressing stays with Space and Enter. Disabled items are skipped. ### Right-to-left In right-to-left layouts, `ArrowLeft` moves to the next item and `ArrowRight` to the previous one. Direction comes from the nearest `dir` attribute, on the item or an ancestor. ## API reference ### ToggleGroup Selector: `[ndToggleGroup]` · Role: `group` A group of toggle buttons (`@ndop/primitives/toggle-group`): one pressed at most (`type="single"`, the default) or any number (`type="multiple"`). Arrow keys move between items. Its value is always an array of the pressed items' values. Name it with `aria-label`. Its `variant` and `size` apply to every item; with `spacing` 0 (the default) the items join into one bar. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[variant]` | `'default' \| 'outline'` | `'default'` | Visual style of every item. | | `[size]` | `'default' \| 'sm' \| 'lg'` | `'default'` | Size of every item. | | `[spacing]` | `number` | `0` | Space between items, in Tailwind spacing units; 0 joins them into one bar. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[defaultValue]` | `readonly string[]` | `[]` | Initial `value` when it isn't bound. | | `[type]` | `'single' \| 'multiple'` | `'single'` | `single`: pressing an item releases the others. `multiple`: items are pressed independently. | | `[orientation]` | `'horizontal' \| 'vertical'` | `'horizontal'` | Which arrow keys move focus: left and right, or up and down. | | `[disabled]` | `boolean` | `false` | Disables every item. A disabled bound field does too. | | `[(value)]` | `readonly string[]` | `[]` | The pressed items' values, in both modes. Two-way bindable with `[(value)]`. | | Data attribute | Values | | --- | --- | | `data-slot` | `toggle-group` | | `data-variant` | | | `data-size` | | | `data-spacing` | | | `data-orientation` | | | `data-disabled` | | ### ToggleGroupItem Selector: `button[ndToggleGroupItem]` One toggle button of a `ToggleGroup`, styled like a Toggle with the group's variant and size. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[variant]` | `'default' \| 'outline'` | | Visual style, when the group doesn't set one. | | `[size]` | `'default' \| 'sm' \| 'lg'` | | Size, when the group doesn't set one. | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[value]` (required) | `string` | | The value this item adds to the group's while pressed. | | `[disabled]` | `boolean` | `false` | Disables this item; arrow keys skip it. | | Data attribute | Values | | --- | --- | | `data-slot` | `toggle-group-item` | | `data-variant` | | | `data-size` | | | `data-spacing` | | | `data-state` | | | `data-disabled` | | | `data-orientation` | | ## Agent notes ### Do - Name the group with `aria-label`, such as "Text alignment", and every icon-only button too. - Use `type="multiple"` when the options combine. ### Don't - Don't use it for navigation between views of a page; use Tabs. - Don't use a single toggle group for unrelated options. --- # Tooltip > A popup that displays information related to an element when it receives keyboard focus or the mouse hovers over it. Version 1.0.0 · WAI-ARIA pattern: [Tooltip](https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/) · SSR · Zoneless · 3.1 kB gzip A tooltip shows a short text label for an element when it is hovered or focused, after a short delay. It describes the trigger through `aria-describedby`, stays open while the pointer is over it, and closes on Escape, as WCAG 1.4.13 requires. It never takes focus, so it can't hold links or buttons. ## Installation ```bash npx @ndop/cli add tooltip ``` Or by hand: install `@ndop/primitives`, `@ndop/core`, then copy these files. Their `@/ui` and `@/lib` imports follow the default `ndop.json` aliases. ### src/app/ui/tooltip/tooltip.ts ```ts import { computed, Directive, input } from '@angular/core'; import { NdTooltip, NdTooltipContent, NdTooltipPortal, NdTooltipTrigger, } from '@ndop/primitives/tooltip'; import { cn } from '@/lib/utils'; /** * A tooltip (`@ndop/primitives/tooltip`): a short description of its * trigger, shown after a delay on hover and at once on keyboard focus. Put * it on an `ng-container`. * * @example * ```html * … * ``` */ @Directive({ selector: '[ndTooltip]', hostDirectives: [ { directive: NdTooltip, inputs: ['open', 'defaultOpen', 'openDelay', 'closeDelay'], outputs: ['openChange'], }, ], }) export class Tooltip {} /** * The element its tooltip describes, which shows it on hover and focus. * * @example * ```html * * ``` */ @Directive({ selector: '[ndTooltipTrigger]', hostDirectives: [NdTooltipTrigger], host: { 'data-slot': 'tooltip-trigger' }, }) export class TooltipTrigger {} /** * Marks the `ng-template` its tooltip renders while open. * * @example * ```html * … * ``` */ @Directive({ selector: 'ng-template[ndTooltipPortal]', hostDirectives: [NdTooltipPortal], }) export class TooltipPortal {} /** * The tooltip bubble. * * @example * ```html *
    Delete permanently
    * ``` */ @Directive({ selector: '[ndTooltipContent]', hostDirectives: [ { directive: NdTooltipContent, inputs: ['side', 'align', 'sideOffset'] }, ], host: { 'data-slot': 'tooltip-content', '[class]': 'classes()' }, }) export class TooltipContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'z-50 w-fit animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=start]:slide-in-from-end-2 data-[side=end]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95', this.class(), ), ); } ``` ### src/app/lib/utils.ts ```ts /** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core'; ``` ## Usage ```ts import { Button } from '@/ui/button/button'; import { Tooltip, TooltipPortal, TooltipTrigger, TooltipContent, } from '@/ui/tooltip/tooltip'; ``` ```html
    Add to library
    ``` ## Examples ### Tooltip demo A tooltip describing a button, shown on hover and keyboard focus. ```ts import { Component } from '@angular/core'; import { Button } from '@/ui/button/button'; import { Tooltip, TooltipPortal, TooltipTrigger, TooltipContent, } from '@/ui/tooltip/tooltip'; /** A tooltip describing a button, shown on hover and keyboard focus. */ @Component({ selector: 'example-tooltip-demo', imports: [Tooltip, TooltipTrigger, TooltipPortal, TooltipContent, Button], template: `
    Add to library
    `, }) export class TooltipDemoExample {} ``` ### Tooltip icon button An icon-only button with its own accessible name and a tooltip on the end side for sighted users. ```ts import { Component } from '@angular/core'; import { NdIcon } from '@ndop/core'; import { Button } from '@/ui/button/button'; import { Tooltip, TooltipPortal, TooltipTrigger, TooltipContent, } from '@/ui/tooltip/tooltip'; /** * An icon-only button with its own accessible name and a tooltip on the * end side for sighted users. */ @Component({ selector: 'example-tooltip-icon-button', imports: [ Tooltip, TooltipTrigger, TooltipPortal, TooltipContent, Button, NdIcon, ], template: `
    Dismiss
    `, }) export class TooltipIconButtonExample {} ``` ### Tooltip sides Tooltips on each side of their triggers. ```ts import { Component } from '@angular/core'; import type { NdSide } from '@ndop/primitives/tooltip'; import { Button } from '@/ui/button/button'; import { Tooltip, TooltipPortal, TooltipTrigger, TooltipContent, } from '@/ui/tooltip/tooltip'; /** * Tooltips on each side of their triggers. `start` and `end` follow the * reading direction, so they swap in right-to-left layouts. */ @Component({ selector: 'example-tooltip-sides', imports: [Button, Tooltip, TooltipPortal, TooltipTrigger, TooltipContent], template: `
    @for (side of sides; track side) {
    On the {{ side }} side
    }
    `, }) export class TooltipSidesExample { protected readonly sides: NdSide[] = ['top', 'end', 'bottom', 'start']; } ``` ## Accessibility Implements the [Tooltip](https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/) pattern of the WAI-ARIA Authoring Practices. ### Keyboard | Key | Action | | --- | --- | | `Tab` | Focusing the trigger shows the tooltip; moving focus away hides it. | | `Escape` | Hides the tooltip without moving focus. | ### Focus - The tooltip never takes focus; focus stays on the trigger. ### Right-to-left As in Popover's `spec.md`: `start` and `end` sides and alignments swap in RTL. ## API reference ### Tooltip Selector: `[ndTooltip]` A tooltip (`@ndop/primitives/tooltip`): a short description of its trigger, shown after a delay on hover and at once on keyboard focus. Put it on an `ng-container`. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[defaultOpen]` | `boolean` | `false` | Initial open state when `open` is not bound. | | `[openDelay]` | `number` | `700` | Delay before opening on hover, in milliseconds. | | `[closeDelay]` | `number` | `300` | Delay before closing once the pointer has left, in milliseconds. | | `[(open)]` | `boolean` | `false` | Whether the tooltip is open. Two-way bindable with `[(open)]`. | ### TooltipTrigger Selector: `[ndTooltipTrigger]` The element its tooltip describes, which shows it on hover and focus. | Data attribute | Values | | --- | --- | | `data-slot` | `tooltip-trigger` | | `data-state` | | ### TooltipPortal Selector: `ng-template[ndTooltipPortal]` Marks the `ng-template` its tooltip renders while open. ### TooltipContent Selector: `[ndTooltipContent]` · Role: `tooltip` The tooltip bubble. | Binding | Type | Default | Description | | --- | --- | --- | --- | | `[class]` | `string` | `''` | Extra classes, merged last so they win. | | `[side]` | `'bottom' \| 'top' \| 'start' \| 'end'` | `'top'` | Preferred side of the trigger; `start` and `end` swap in RTL. Defaults to `top`, as in Radix and shadcn. | | `[align]` | `'start' \| 'end' \| 'center'` | `'center'` | Preferred alignment along that side. | | `[sideOffset]` | `number` | `4` | Gap from the trigger, in pixels. | | Data attribute | Values | | --- | --- | | `data-slot` | `tooltip-content` | | `data-state` | | | `data-side` | | | `data-align` | | ## Agent notes ### Do - Use tooltips for brief, supplementary text, such as the name of an icon-only button that also has an `aria-label`. - Keep the text to a few words. ### Don't - Don't put essential information only in a tooltip; touch devices can't hover. - Don't put interactive content in a tooltip; use a popover. - Don't add a tooltip to a disabled button; it can't receive focus. --- # Accordion primitive Headless Accordion: the behavior, keyboard support and ARIA of the WAI-ARIA Accordion pattern, with no styles. ```ts import { NdAccordion, NdAccordionContent, NdAccordionHeader, NdAccordionItem, NdAccordionTrigger, } from '@ndop/primitives/accordion'; ``` Implements the [Accordion](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Tab` | Moves focus through the triggers and any open content, in document order. | | `Enter` or `Space` | Opens or closes the focused trigger's item. | | `ArrowDown` | Moves focus to the next enabled trigger, wrapping to the first. | | `ArrowUp` | Moves focus to the previous enabled trigger, wrapping to the last. | | `Home` | Moves focus to the first enabled trigger. | | `End` | Moves focus to the last enabled trigger. | ## Focus - Every enabled trigger is in the tab order; opening or closing an item keeps focus on its trigger. - Disabled triggers are skipped by the arrow keys. ## Right-to-left Nothing mirrors: the arrow keys follow the vertical order of the triggers. ## API reference ### NdAccordion `[ndAccordion]`: Root of an accordion: the WAI-ARIA Accordion pattern, a stack of headed sections that each show or hide their content. It composes the CDK's `CdkAccordion` (ADR-0004, amendment 3) and holds the open items' values. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultValue` | input | `string \| readonly string[] \| null` | Initially open items when `value` isn't bound: one value or a list. | | `multiple` | input | `boolean` | Lets several items be open at once. | | `collapsible` | input | `boolean` | Lets the open item close when only one item may be open. | | `disabled` | input | `boolean` | Disables every item. | | `value` | model | `readonly string[]` | The open items' values. Two-way bindable with `[(value)]`. | ### NdAccordionContent `[ndAccordionContent]`: The content its item's trigger shows and hides: a region labelled by the trigger. It has the `hidden` attribute while closed. For the styled layer to animate it, it sets `--nd-accordion-content-height` to its height when it opens or closes, stays visible until a closing animation ends, and has `data-animating` while one runs; no animation plays on the first render. ### NdAccordionHeader `[ndAccordionHeader]`: The heading around an item's trigger. Put it on an `h2` to `h6` that fits the page's outline. ### NdAccordionItem `[ndAccordionItem]`: One section of an `NdAccordion`, composing `CdkAccordionItem`: a heading with its trigger, and the content the trigger shows. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `value` | input | `string` | The value `value` lists while it's open. | | `disabled` | input | `boolean` | Disables its trigger. | ### NdAccordionTrigger `button[ndAccordionTrigger]`: The button that opens and closes its item, on a native `button` inside its `NdAccordionHeader`. `ArrowDown`, `ArrowUp`, `Home` and `End` move between the accordion's triggers. --- # Alert Dialog primitive Headless Alert Dialog: the behavior, keyboard support and ARIA of the WAI-ARIA Alert and Message Dialogs pattern, with no styles. ```ts import { NdAlertDialog, NdAlertDialogAction, NdAlertDialogCancel, NdAlertDialogContent, NdAlertDialogDescription, NdAlertDialogOverlay, NdAlertDialogPortal, NdAlertDialogTitle, NdAlertDialogTrigger, } from '@ndop/primitives/alert-dialog'; ``` Implements the [Alert and Message Dialogs](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Tab` | Moves focus to the next focusable element inside; wraps from last to first. | | `Shift + Tab` | Moves focus to the previous focusable element inside; wraps from first to last. | | `Escape` | Closes the dialog, as Cancel does. | | `Tab` | Moves focus to the next focusable element inside the dialog; from the last one, wraps to the first. | | `Shift + Tab` | Moves focus to the previous focusable element inside the dialog; from the first one, wraps to the last. | | `Escape` | Closes the dialog, unless `closeOnEscape` is `false`. | ## Focus - On open: focus moves to AlertDialogCancel. - While open: focus is trapped in the content. - On close: focus returns to the element that had it before opening, usually the trigger. - On open: focus moves to the first focusable element in the content, or to the content element itself when it has none. - While open: focus is trapped in the content. - On close: focus returns to the element that had it before opening, usually the trigger. ## Right-to-left Nothing mirrors. Nothing mirrors: the dialog is centered, and Tab order follows the DOM. ## API reference ### NdAlertDialog `[ndAlertDialog]`: Root of an alert dialog: the WAI-ARIA Alert Dialog pattern, a modal dialog that asks the user to confirm or cancel. It's the Dialog primitive with a different content role and dismissal: clicking outside doesn't close it, Escape cancels it, and focus starts on Cancel. Put it on an `ng-container`. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultOpen` | input | `boolean` | Initial open state when `open` is not bound. | | `open` | model | `boolean` | Whether the dialog is open. Two-way bindable with `[(open)]`. | ### NdAlertDialogAction `button[ndAlertDialogAction]`: The button that confirms an `NdAlertDialog`: its own `(click)` handler runs, then the dialog closes. ### NdAlertDialogCancel `button[ndAlertDialogCancel]`: The button that cancels an `NdAlertDialog` and closes it. Focus starts here when the dialog opens, since cancelling is the safe choice. ### NdAlertDialogContent `[ndAlertDialogContent]`: The alert dialog box: `role="alertdialog"`, `aria-modal="true"`, labelled by its title and described by its description. Focus is trapped inside it while open. ### NdAlertDialogDescription `[ndAlertDialogDescription]`: The message of an `NdAlertDialog`, which describes it. ### NdAlertDialogOverlay `[ndAlertDialogOverlay]`: The backdrop behind an `NdAlertDialog`, hidden from assistive technology. Unlike a dialog's, clicking it does nothing: the user must choose. ### NdAlertDialogPortal `ng-template[ndAlertDialogPortal]`: Marks the `ng-template` an `NdAlertDialog` renders while open. ### NdAlertDialogTitle `[ndAlertDialogTitle]`: The title of an `NdAlertDialog`, which labels it. ### NdAlertDialogTrigger `[ndAlertDialogTrigger]`: The button that opens an `NdAlertDialog`, with `aria-haspopup="dialog"`, `aria-expanded` and `aria-controls`. --- # Avatar primitive Headless Avatar: the behavior, keyboard support and ARIA of the WAI-ARIA `img` element's pattern, with no styles. ```ts import { NdAvatar, NdAvatarFallback, NdAvatarImage, } from '@ndop/primitives/avatar'; ``` Implements the [`img` element's](https://www.w3.org/WAI/tutorials/images/) pattern of the WAI-ARIA Authoring Practices. ## Focus - The avatar never receives focus and never changes focus. ## Right-to-left Nothing mirrors. ## API reference ### NdAvatar `[ndAvatar]`: Root of an avatar: an image with a fallback, such as initials, shown until the image loads or when it fails. It tracks the image's status, which every part reflects as `data-status` for styling. ### NdAvatarFallback `[ndAvatarFallback]`: What an avatar shows until its image loads, or when it fails: initials or an icon. Hidden from assistive technology once the image has loaded, so the avatar is announced once. ### NdAvatarImage `img[ndAvatarImage]`: An avatar's image: a native `img`, whose `alt` names the avatar. Until it has loaded it's hidden from assistive technology, which reads the fallback instead. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `src` | input | `string \| null \| undefined` | The image's URL; without one, the fallback shows. | --- # Button primitive Headless Button: the behavior, keyboard support and ARIA of the WAI-ARIA Button pattern, with no styles. ```ts import { NdButton } from '@ndop/primitives/button'; ``` Implements the [Button](https://www.w3.org/WAI/ARIA/apg/patterns/button/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Enter` | Activates the button, or follows the link; does nothing while disabled. | | `Space` | Activates a `button`; does nothing while disabled. Links don't react to Space. | ## Focus - A disabled `button` and a disabled `a` are removed from the tab order. - Activation does not move focus. ## Right-to-left Nothing mirrors. ## API reference ### NdButton `button[ndButton], a[ndButton]`: Headless button for a native `button` or an `a` styled as a button. - On `button`: `disabled` sets the native attribute, and a missing static `type` becomes `type="button"` so it never submits a form by accident. - On `a`: anchors can't be natively disabled, so `disabled` sets `aria-disabled="true"`, removes it from the tab order and cancels clicks before any other listener sees them. - `data-disabled` is present while disabled, on both. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `disabled` | input | `boolean` | Whether the button or link is disabled. | --- # Calendar primitive Headless Calendar: the behavior, keyboard support and ARIA of the WAI-ARIA Date grid pattern, with no styles. ```ts import { NdCalendar, NdCalendarMonth, NdCalendarWeekday, NdCalendarCell, NdCalendarDay, NdCalendarGrid, NdCalendarHeading, NdCalendarNext, NdCalendarPrevious, } from '@ndop/primitives/calendar'; ``` Implements the [Date grid](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/datepicker-dialog/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Tab` | Moves focus to the day in the tab order, then out of the grid. | | `ArrowRight` or `ArrowLeft` | Moves focus to the next or previous day, changing month when needed. | | `ArrowDown` or `ArrowUp` | Moves focus to the same day of the next or previous week. | | `Home` or `End` | Moves focus to the first or last day of the week. | | `PageDown` or `PageUp` | Moves focus to the same day of the next or previous month. | | `Shift+PageDown` or `Shift+PageUp` | Moves focus to the same day of the next or previous year. | | `Enter` or `Space` | Chooses the focused day, unless it's disabled. | ## Focus - Focus never leaves the shown months on its own: moving past their edge shows the next or previous month and focuses the day there. - `min` and `max` stop the keys at their bounds. - Choosing a day keeps focus on it. ## Right-to-left In right-to-left layouts, the days run from right to left, so `ArrowLeft` moves to the next day and `ArrowRight` to the previous one. Direction comes from the nearest `dir` attribute. ## API reference ### NdCalendar `[ndCalendar]`: A calendar: month grids to choose a date, a range or several dates, on the WAI-ARIA date grid pattern with a roving tabindex. Its structure is the user's template, looping over `NdCalendar.months` and `NdCalendar.weekdays`; dates use `Date` and `Intl` only. It owns its value, so it's a `ControlValueAccessor` that Signal Forms (`[formField]`), reactive and template-driven forms bind (ADR-0004, amendments 1 and 4): a `Date` in `single` mode, an `NdDateRange` in `range` mode, a list of dates in `multiple` mode. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `mode` | input | `'single' \| 'range' \| 'multiple'` | How it chooses dates. | | `defaultMonth` | input | `Date \| null` | The first month shown when `month` isn't bound. | | `numberOfMonths` | input | `number` | How many months show side by side. | | `min` | input | `Date \| null` | The earliest day that can be chosen. | | `max` | input | `Date \| null` | The latest day that can be chosen. | | `disabledDates` | input | `((date: Date) => boolean) \| null` | Disables days it returns `true` for, such as weekends. | | `weekStartsOn` | input | `number \| null` | The first weekday, 0 for Sunday; defaults to the locale's. | | `locale` | input | `string` | The locale for names and labels; defaults to the app's `LOCALE_ID`. | | `showOutsideDays` | input | `boolean` | Shows the neighboring months' days that fill the first and last weeks. | | `value` | model | `Date \| null` | The chosen date in `single` mode. Two-way bindable with `[(value)]`. | | `range` | model | `NdDateRange` | The chosen days in `range` mode. Two-way bindable with `[(range)]`. | | `values` | model | `readonly Date[]` | The chosen dates in `multiple` mode. Two-way bindable with `[(values)]`. | | `month` | model | `Date \| null` | The first month shown. Two-way bindable with `[(month)]`. | ### NdCalendarMonth `[ndCalendarMonth]`: One shown month: wraps its heading and grid. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `month` | input | `NdCalendarMonthView` | The month, from `NdCalendar.months`. | ### NdCalendarWeekday `th[ndCalendarWeekday]`: A weekday's column header, with the full name in `abbr`. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `weekday` | input | `NdCalendarWeekdayView` | The weekday, from `NdCalendar.weekdays`. | ### NdCalendarCell `td[ndCalendarCell]`: A day's cell (`role="gridcell"`), on a native `td`, with `aria-selected` when the day is chosen. Its data attributes repeat its day's state, for styling. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `day` | input | `NdCalendarDayView` | The day, from a week of `NdCalendar.months`. | ### NdCalendarDay `button[ndCalendarDay]`: A day's button, labelled with the full date. Only one day is in the tab order; the date grid keys move focus. Hidden outside days render nothing clickable: the button gets `hidden`. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `day` | input | `NdCalendarDayView` | The day, from a week of `NdCalendar.months`. | ### NdCalendarGrid `table[ndCalendarGrid]`: Its month's grid of days (`role="grid"`), on a native `table`. ### NdCalendarHeading `[ndCalendarHeading]`: Its month's name and year: a polite live region, so changing months is announced, and the label of its grid. ### NdCalendarNext `button[ndCalendarNext]`: The button that shows the next month. Name it with `aria-label`. ### NdCalendarPrevious `button[ndCalendarPrevious]`: The button that shows the previous month. Name it with `aria-label`. --- # Checkbox primitive Headless Checkbox: the behavior, keyboard support and ARIA of the WAI-ARIA Checkbox pattern, with no styles. ```ts import { NdCheckbox, NdCheckboxIndicator } from '@ndop/primitives/checkbox'; ``` Implements the [Checkbox](https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Space` | Checks or unchecks it; a mixed checkbox becomes checked. | | `Enter` | Does nothing, as for a native checkbox (APG); the default is prevented. | ## Focus - Unchanged from a native button: activating it keeps focus on it. - Leaving it marks a bound field touched. ## Right-to-left Nothing mirrors. ## API reference ### NdCheckbox `button[ndCheckbox]`: Headless checkbox on a native `button`: the WAI-ARIA checkbox pattern, dual-state or, with `indeterminate`, mixed. It owns its value, so it's a `ControlValueAccessor` that Signal Forms (`[formField]`), reactive and template-driven forms bind (ADR-0004, amendments 1 and 4). Space toggles it; Enter does nothing, as for a native checkbox. A label whose `for` is its `id` toggles it too. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultChecked` | input | `boolean` | Initial `checked` when it isn't bound. | | `disabled` | input | `boolean` | Disables it. A disabled bound field disables it too. | | `required` | input | `boolean` | Marks it required: `aria-required="true"`. | | `invalid` | input | `boolean` | Marks it invalid when no forms library is bound; with one, the field's state decides. | | `id` | input | `string \| undefined` | Element id; also the key a `label[ndLabel]` uses to find it. | | `checked` | model | `boolean` | Whether it's checked. Two-way bindable with `[(checked)]`. | | `indeterminate` | model | `boolean` | Whether it's mixed: `aria-checked="mixed"` whatever `checked` is. Activating it clears this. Two-way bindable. | ### NdCheckboxIndicator `[ndCheckboxIndicator]`: The checkmark of a `NdCheckbox`. It has the `hidden` attribute while the checkbox is unchecked, so what's inside shows only while it's checked or mixed. --- # Collapsible primitive Headless Collapsible: the behavior, keyboard support and ARIA of the WAI-ARIA Disclosure (Show/Hide) pattern, with no styles. ```ts import { NdCollapsible, NdCollapsibleContent, NdCollapsibleTrigger, } from '@ndop/primitives/collapsible'; ``` Implements the [Disclosure (Show/Hide)](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Enter` | On the trigger, shows or hides the content. | | `Space` | On the trigger, shows or hides the content. | ## Focus - Focus stays on the trigger when the content shows or hides. - Shown content joins the tab order after the trigger; hidden content leaves it. ## Right-to-left Nothing mirrors. ## API reference ### NdCollapsible `[ndCollapsible]`: Root of a collapsible: a trigger that shows and hides a section of content, the WAI-ARIA disclosure pattern. It holds the open state, which renders on the server too, since the content stays in the page. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultOpen` | input | `boolean` | Initial open state when `open` is not bound. | | `disabled` | input | `boolean` | Stops the trigger from toggling the content. | | `open` | model | `boolean` | Whether the content is shown. Two-way bindable with `[(open)]`. | ### NdCollapsibleContent `[ndCollapsibleContent]`: The content a `NdCollapsible` shows and hides. While closed it has the `hidden` attribute, so it's out of the accessibility tree and the tab order. For the styled layer to animate it, it sets `--nd-collapsible-content-height` to its height when it opens or closes, stays visible until a closing animation ends, and has `data-animating` while one runs; no animation plays on the first render. ### NdCollapsibleTrigger `button[ndCollapsibleTrigger]`: The button that shows and hides a `NdCollapsible`'s content. Use a native `button`, so Enter and Space activate it. --- # Combobox primitive Headless Combobox: the behavior, keyboard support and ARIA of the WAI-ARIA Editable combobox with list autocomplete pattern, with no styles. ```ts import { NdCombobox, NdComboboxGroup, NdComboboxLabel, NdComboboxSeparator, NdComboboxChip, NdComboboxChipRemove, NdComboboxChips, NdComboboxClear, NdComboboxContent, NdComboboxEmpty, NdComboboxInput, NdComboboxItem, NdComboboxItemIndicator, NdComboboxList, NdComboboxTrigger, } from '@ndop/primitives/combobox'; ``` Implements the [Editable combobox with list autocomplete](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `a` | Types into the field, opening the popup and filtering its options. | | `ArrowDown` | Opens the popup and highlights the chosen option or the first; while open, highlights the next option. | | `ArrowUp` | Opens the popup and highlights the last option; while open, highlights the previous option. | | `Alt+ArrowDown` | Opens the popup without highlighting an option. | | `Enter` | Chooses the highlighted option. Without `multiple` the popup closes; with it, the option toggles and it stays open. | | `Escape` | Closes the popup; if it's closed, clears the field and, without `multiple`, the value. | | `Backspace` | With `multiple`, in an empty field, removes the last chosen value. | | `Home` or `End` | Move the caret in the field, as in any text field. | | `Tab` | Closes the popup and moves focus on. | ## Focus - Focus stays in the field; `aria-activedescendant` points at the highlighted option, which scrolls into view. - Clicking an option, the trigger, the clear button or a chip's remove button keeps focus in the field. - Focus leaving the combobox closes the popup and marks a bound field touched. ## Right-to-left `start` and `end` alignments are logical and swap in right-to-left layouts. The listbox is vertical, so its arrow keys don't change. ## API reference ### NdCombobox `[ndCombobox]`: Root of a combobox: the WAI-ARIA editable combobox pattern with list autocomplete, a text field that filters a popup listbox of options. Focus stays in the field while `aria-activedescendant` points at the highlighted option, on the CDK's `ActiveDescendantKeyManager`; the popup is written in place and opening moves it into a CDK overlay. With `multiple`, chosen options show as chips. Put it on an `ng-container`, and bind forms on the `NdComboboxInput`. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `multiple` | input | `boolean` | Lets several options be chosen, shown as chips. | | `filter` | input | `NdFilter \| null` | Tests whether an option matches what was typed; `null` shows every option, for lists filtered elsewhere. | | `autoHighlight` | input | `boolean` | Highlights the first shown option as the user types. | | `disabled` | input | `boolean` | Disables the field. A disabled bound field does too. | | `required` | input | `boolean` | Marks the combobox required: `aria-required="true"` on the field. | | `invalid` | input | `boolean` | Marks the combobox invalid when no forms library is bound; with one, the field's state decides. | | `value` | model | `string \| null` | The chosen option's value, or `null`, without `multiple`. Two-way bindable with `[(value)]`. | | `values` | model | `readonly string[]` | The chosen options' values, with `multiple`. Two-way bindable with `[(values)]`. | | `search` | model | `string` | The field's text. Two-way bindable with `[(search)]`. | | `open` | model | `boolean` | Whether the popup is open. Two-way bindable with `[(open)]`. | ### NdComboboxGroup `[ndComboboxGroup]`: Groups options (`role="group"`), named by its `NdComboboxLabel`. It's hidden while none of its options is shown. ### NdComboboxLabel `[ndComboboxLabel]`: The heading of a `NdComboboxGroup`, which names the group. ### NdComboboxSeparator `[ndComboboxSeparator]`: A decorative line between groups of options. ### NdComboboxChip `[ndComboboxChip]`: One chosen value, shown as a chip. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `value` | input | `string` | The chosen value it shows. | ### NdComboboxChipRemove `button[ndComboboxChipRemove]`: A button that removes its chip's value with the pointer, keeping focus in the field, as `Backspace` does from it. It's out of the tab order and hidden from assistive technology. ### NdComboboxChips `[ndComboboxChips]`: Holds the chips and the field of a combobox with `multiple`; the popup sits below it. Clicking its empty space focuses the field. ### NdComboboxClear `button[ndComboboxClear]`: A button that empties the field and the value with the pointer, keeping focus in the field, as `Escape` does from it. It's out of the tab order, hidden from assistive technology, and has the `hidden` attribute while there is nothing to clear. ### NdComboboxContent `[ndComboboxContent]`: The popup. Write it after the field: it has the `hidden` attribute while closed, and opening moves it into an overlay below the chips, or else the field. `data-empty` marks it while no option is shown. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `side` | input | `'start' \| 'end' \| 'bottom' \| 'top'` | Side of the anchor it opens on, before flipping. | | `align` | input | `'start' \| 'end' \| 'center'` | Alignment along that side. | | `sideOffset` | input | `number` | Gap between the anchor and the popup, in pixels. | ### NdComboboxEmpty `[ndComboboxEmpty]`: What shows while no option matches the field's text. It has the `hidden` attribute otherwise. ### NdComboboxInput `input[ndComboboxInput]`: The text field (`role="combobox"`), on a native `input`. Typing filters the popup's options; the arrow keys, `Enter` and `Escape` work from here while focus stays in it. The combobox owns its value, so this is a `ControlValueAccessor`: bind Signal Forms (`[formField]`), reactive or template-driven forms here (ADR-0004, amendments 1 and 4). ### NdComboboxItem `[ndComboboxItem]`: One option (`role="option"`). Clicking it, or `Enter` while it's highlighted, chooses it. It's hidden while the field's text doesn't match it. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `value` | input | `string` | The value choosing it gives the combobox. | | `disabled` | input | `boolean` | Makes it unchoosable; the arrow keys skip it. | | `label` | input | `string \| null` | Its text, when it differs from what it renders. | ### NdComboboxItemIndicator `[ndComboboxItemIndicator]`: The check of an option. It has the `hidden` attribute unless its option is chosen. ### NdComboboxList `[ndComboboxList]`: The listbox of options (`role="listbox"`), named after the field's labels unless it has its own `aria-label`. ### NdComboboxTrigger `button[ndComboboxTrigger]`: A button that opens and closes the popup with the pointer, keeping focus in the field. It's out of the tab order and hidden from assistive technology, which use `Alt+ArrowDown` from the field. --- # Command primitive Headless Command: the behavior, keyboard support and ARIA of the WAI-ARIA Combobox pattern, with no styles. ```ts import { NdCommand, NdCommandGroup, NdCommandEmpty, NdCommandGroupHeading, NdCommandInput, NdCommandItem, NdCommandList, NdCommandSeparator, } from '@ndop/primitives/command'; ``` Implements the [Combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `ArrowDown` or `ArrowUp` | Highlights the next or previous shown, enabled item. | | `Home` or `End` | Moves the caret in the field, as in any text field. | | `Enter` | Runs the highlighted item. | | `a` | Types into the field, filtering the list. | ## Focus - Focus stays in the field; `aria-activedescendant` points at the highlighted item, which scrolls into view. - Clicking an item runs it. ## Right-to-left Nothing mirrors: the list is vertical, and the field follows the text direction. ## API reference ### NdCommand `[ndCommand]`: A command menu: a search field over a list of commands that it filters as the user types, such as a ⌘K palette (WAI-ARIA combobox pattern with an always-visible listbox). Focus stays in the field and `aria-activedescendant` points at the highlighted command, on the CDK's `ActiveDescendantKeyManager`. Put it in a Dialog for a palette. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `label` | input | `string \| null` | Names the field and the list for assistive technology. | | `filter` | input | `NdFilter` | Tests whether an item matches what was typed. | | `shouldFilter` | input | `boolean` | Hides the items that don't match; `false` for lists filtered elsewhere. | | `loop` | input | `boolean` | Makes the arrow keys wrap at the ends. | | `search` | model | `string` | The field's text. Two-way bindable with `[(search)]`. | ### NdCommandGroup `[ndCommandGroup]`: Groups commands (`role="group"`), named by its `NdCommandGroupHeading`. It's hidden while none of its commands is shown. ### NdCommandEmpty `[ndCommandEmpty]`: What shows while no command matches the search. It has the `hidden` attribute otherwise. ### NdCommandGroupHeading `[ndCommandGroupHeading]`: The heading of a `NdCommandGroup`, which names the group. It's hidden from assistive technology itself, which reads it as the group's name. ### NdCommandInput `input[ndCommandInput]`: The search field (`role="combobox"`), on a native `input`. Typing filters the list; `ArrowDown` and `ArrowUp` move the highlight and `Enter` runs the highlighted command, while focus stays here. ### NdCommandItem `[ndCommandItem]`: One command (`role="option"`). Clicking it, or `Enter` while it's highlighted, emits `triggered`. It's hidden while the search doesn't match its text or `keywords`. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `value` | input | `string \| null` | Identifies it; defaults to its text. | | `keywords` | input | `string` | Other words the search matches it by, separated by spaces. | | `disabled` | input | `boolean` | Makes it inert; the arrow keys skip it. | | `triggered` | output | `void` | Emits when it's run. | ### NdCommandList `[ndCommandList]`: The list of commands (`role="listbox"`), named by the command menu's `label`. ### NdCommandSeparator `[ndCommandSeparator]`: A decorative line between groups, hidden while searching. --- # Context Menu primitive Headless Context Menu: the behavior, keyboard support and ARIA of the WAI-ARIA Menu pattern, with no styles. ```ts import { NdContextMenuTrigger } from '@ndop/primitives/context-menu'; ``` Implements the [Menu](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Shift + F10` | On the focused area, opens the menu and focuses its first item. | ## Focus - Opening focuses the menu; closing returns focus to the area. ## Right-to-left Submenus follow Menu's RTL behavior. ## API reference ### NdContextMenuTrigger `[ndContextMenuTrigger]`: An area with its own menu, composing `CdkContextMenuTrigger`: its value is the `ng-template` of the menu. A right click or long press opens the menu at the pointer; Shift + F10 or the context-menu key opens it at the area's corner and focuses its first item. The menu's parts are `@ndop/primitives/menu`'s. Make the area focusable and named, and offer its actions elsewhere too. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `ndContextMenuTrigger` | input | `TemplateRef \| null` | The `ng-template` of the menu it opens. | | `disabled` | input | `boolean` | Leaves the browser's own context menu. | --- # Dialog primitive Headless Dialog: the behavior, keyboard support and ARIA of the WAI-ARIA Dialog (Modal) pattern, with no styles. ```ts import { NdDialog, NdDialogClose, NdDialogContent, NdDialogDescription, NdDialogOverlay, NdDialogPortal, NdDialogTitle, NdDialogTrigger, } from '@ndop/primitives/dialog'; ``` Implements the [Dialog (Modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Tab` | Moves focus to the next focusable element inside the dialog; from the last one, wraps to the first. | | `Shift + Tab` | Moves focus to the previous focusable element inside the dialog; from the first one, wraps to the last. | | `Escape` | Closes the dialog, unless `closeOnEscape` is `false`. | ## Focus - On open: focus moves to the first focusable element in the content, or to the content element itself when it has none. - While open: focus is trapped in the content. - On close: focus returns to the element that had it before opening, usually the trigger. ## Right-to-left Nothing mirrors: the dialog is centered, and Tab order follows the DOM. ## API reference ### NdDialog `[ndDialog]`: Root of a modal dialog (WAI-ARIA Dialog (Modal) pattern). Holds the open state and renders the `ng-template[ndDialogPortal]` content into a CDK overlay while open: page scroll is blocked, focus is trapped in the content and returned on close, and Escape closes. Put it on an `ng-container` so it adds no element. The dialog never renders open on the server; an initial open state takes effect after hydration. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultOpen` | input | `boolean` | Initial open state when `open` is not bound. | | `closeOnEscape` | input | `boolean` | Whether Escape closes the dialog. | | `closeOnOverlayClick` | input | `boolean` | Whether a click on `[ndDialogOverlay]` closes the dialog. | | `open` | model | `boolean` | Whether the dialog is open. Two-way bindable with `[(open)]`. | ### NdDialogClose `[ndDialogClose]`: Button that closes its `NdDialog`. ### NdDialogContent `[ndDialogContent]`: The dialog box: `role="dialog"`, `aria-modal="true"`, `tabindex="-1"`, and `aria-labelledby` / `aria-describedby` pointing at the title and description. Focus is trapped inside it while open. ### NdDialogDescription `[ndDialogDescription]`: The dialog's description; its id describes the content (`aria-describedby`). Keeps an id the element already has. ### NdDialogOverlay `[ndDialogOverlay]`: Optional backdrop rendered behind the content. Hidden from assistive technology; a click on it closes the dialog unless `closeOnOverlayClick` is `false`. ### NdDialogPortal `ng-template[ndDialogPortal]`: Marks the `ng-template` whose content the `NdDialog` renders while open. ### NdDialogTitle `[ndDialogTitle]`: The dialog's title; its id labels the content (`aria-labelledby`). Keeps an id the element already has. ### NdDialogTrigger `[ndDialogTrigger]`: Button that opens and closes its `NdDialog`. Sets `aria-haspopup="dialog"`, `aria-expanded`, `aria-controls` (only while open, since the content doesn't exist when closed) and `data-state`. --- # Drawer primitive Headless Drawer: the behavior, keyboard support and ARIA of the WAI-ARIA Dialog (Modal) pattern, with no styles. ```ts import { NdDrawer, NdDrawerHandle, NdDrawerClose, NdDrawerContent, NdDrawerDescription, NdDrawerOverlay, NdDrawerPortal, NdDrawerTitle, NdDrawerTrigger, } from '@ndop/primitives/drawer'; ``` Implements the [Dialog (Modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Tab` | Moves focus to the next focusable element inside; wraps from last to first. | | `Shift + Tab` | Moves focus to the previous focusable element inside; wraps from first to last. | | `Escape` | Closes the drawer, unless `closeOnEscape` is `false`. | | `Tab` | Moves focus to the next focusable element inside the dialog; from the last one, wraps to the first. | | `Shift + Tab` | Moves focus to the previous focusable element inside the dialog; from the first one, wraps to the last. | | `Escape` | Closes the dialog, unless `closeOnEscape` is `false`. | ## Focus - On open: focus moves to the first focusable element in the content, or to the content element itself when it has none. - While open: focus is trapped in the content. - On close: focus returns to the element that had it before opening, usually the trigger. ## Right-to-left `start` and `end` drawers come from the right and left in right-to-left layouts, and are dragged toward those edges. Direction comes from the nearest `dir` attribute. Nothing mirrors: the dialog is centered, and Tab order follows the DOM. ## API reference ### NdDrawer `[ndDrawer]`: Root of a drawer: a modal dialog (the Dialog primitive) that slides in from an edge, set by its content's `direction`, and can be dragged back out. `open`, `defaultOpen`, `closeOnEscape` and `closeOnOverlayClick` are Dialog's. Put it on an `ng-container`. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultOpen` | input | `boolean` | Initial open state when `open` is not bound. | | `closeOnEscape` | input | `boolean` | Whether Escape closes the dialog. | | `closeOnOverlayClick` | input | `boolean` | Whether a click on `[ndDialogOverlay]` closes the dialog. | | `open` | model | `boolean` | Whether the dialog is open. Two-way bindable with `[(open)]`. | ### NdDrawerHandle `[ndDrawerHandle]`: A decorative grip that shows the drawer can be dragged, hidden from assistive technology. ### NdDrawerClose `[ndDrawerClose]`: A button that closes its `NdDrawer`. ### NdDrawerContent `[ndDrawerContent]`: The drawer panel: Dialog's content (`role="dialog"`, modal, labelled and described) that can be dragged toward its edge to close. While dragged it has `data-dragging` and the distance in `--nd-drawer-drag`, for the styled layer to move it; the primitive sets no other style. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `direction` | input | `'start' \| 'end' \| 'bottom' \| 'top'` | The edge it slides in from and is dragged back to. | ### NdDrawerDescription `[ndDrawerDescription]`: The description of an `NdDrawer`. ### NdDrawerOverlay `[ndDrawerOverlay]`: The backdrop behind an `NdDrawer`; clicking it closes the drawer unless `closeOnOverlayClick` is `false`. ### NdDrawerPortal `ng-template[ndDrawerPortal]`: Marks the `ng-template` an `NdDrawer` renders while open. ### NdDrawerTitle `[ndDrawerTitle]`: The title of an `NdDrawer`, which labels it. ### NdDrawerTrigger `[ndDrawerTrigger]`: The button that opens an `NdDrawer`. --- # Dropdown Menu primitive Headless Dropdown Menu: the behavior, keyboard support and ARIA of the WAI-ARIA Menu Button pattern, with no styles. ```ts import { NdDropdownMenuTrigger } from '@ndop/primitives/dropdown-menu'; ``` Implements the [Menu Button](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Enter` or `Space` | Opens the menu and focuses its first item. | | `ArrowDown` | Opens the menu and focuses its first item. | | `ArrowUp` | Opens the menu and focuses its last item. | ## Focus - Closing the menu returns focus to the trigger. ## Right-to-left `start` and `end` sides are logical and swap in right-to-left layouts. ## API reference ### NdDropdownMenuTrigger `[ndDropdownMenuTrigger]`: The button of a menu button (WAI-ARIA Menu Button pattern), composing `CdkMenuTrigger`: its value is the `ng-template` of the menu it opens. Enter, Space and the arrow keys open the menu and focus an item; closing returns focus here. The menu's parts are `@ndop/primitives/menu`'s. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `ndDropdownMenuTrigger` | input | `TemplateRef \| null` | The `ng-template` of the menu it opens. | | `defaultOpen` | input | `boolean` | Initial open state when `open` isn't bound, applied after hydration. | | `side` | input | `'start' \| 'end' \| 'bottom' \| 'top'` | Side of the trigger the menu opens on, before flipping to fit. | | `align` | input | `'start' \| 'end' \| 'center'` | Alignment of the menu along that side. | | `sideOffset` | input | `number` | Gap between the trigger and the menu, in pixels. | | `open` | model | `boolean` | Whether the menu is open. Two-way bindable with `[(open)]`. | --- # Form Field primitive Headless Form Field: the behavior, keyboard support and ARIA of the WAI-ARIA `aria-describedby` pattern, with no styles. ```ts import { NdField, NdFieldDescription, NdFieldError, } from '@ndop/primitives/form-field'; ``` Implements the [`aria-describedby`](https://www.w3.org/TR/wai-aria-1.2/#aria-describedby) pattern of the WAI-ARIA Authoring Practices. ## Focus - A field never moves focus. An error appearing doesn't take focus; it's announced. ## Right-to-left Nothing mirrors. ## API reference ### NdField `[ndField]`: Headless form field: ties one control to its description and error. The control's `aria-describedby` lists the description and, while the field is invalid, the error, which is hidden until then and announced when it appears. The field mirrors its control's state as `data-invalid` and `data-disabled`. Ndop's controls join the field around them: `input[ndInput]`, `textarea[ndInput]`, Checkbox, Switch, Radio Group, Slider thumbs and Input OTP. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `invalid` | input | `boolean` | Marks the field invalid by hand, for errors no control reports. | ### NdFieldDescription `[ndFieldDescription]`: Help text for a `NdField`'s control, which lists its id in `aria-describedby`. Keeps its own `id` or gets a generated one. Outside a field, such as under a field set's legend, it's plain text. ### NdFieldError `[ndFieldError]`: The error message of a `NdField`: hidden while the field is valid, and announced (`role="alert"`) when it appears. While shown, the control's `aria-describedby` lists it. Put the messages inside it. --- # Hover Card primitive Headless Hover Card: the behavior, keyboard support and ARIA of its pattern, with no styles. ```ts import { NdHoverCard, NdHoverCardContent, NdHoverCardPortal, NdHoverCardTrigger, } from '@ndop/primitives/hover-card'; ``` ## Keyboard | Key | Action | | --- | ------ | | `Tab` | Focusing the trigger opens the card after the delay; leaving it closes it. | | `Escape` | Closes the card without moving focus. | | `Tab` | Focusing the trigger shows the tooltip; moving focus away hides it. | | `Escape` | Hides the tooltip without moving focus. | ## Focus - The card never takes focus; focus stays on the trigger. - The tooltip never takes focus; focus stays on the trigger. ## Right-to-left `start` and `end` sides are logical and swap in right-to-left layouts. As in Popover's `spec.md`: `start` and `end` sides and alignments swap in RTL. ## API reference ### NdHoverCard `[ndHoverCard]`: Root of a hover card: a preview of what's behind a link, such as a profile behind a username, for sighted pointer and keyboard users. It composes the Tooltip primitive's root, so it shares its delays (700 ms to open, 300 ms to close), positioning and Escape handling, but its content has no role and nothing refers to it: the link must say everything on its own. Put it on an `ng-container`. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultOpen` | input | `boolean` | Initial open state when `open` is not bound. | | `openDelay` | input | `number` | Delay before opening on hover, in milliseconds. | | `closeDelay` | input | `number` | Delay before closing once the pointer has left, in milliseconds. | | `open` | model | `boolean` | Whether the tooltip is open. Two-way bindable with `[(open)]`. | ### NdHoverCardContent `[ndHoverCardContent]`: The card. It stays open while the pointer is over it, and sits on its trigger's `side`, flipping to fit the viewport; `data-side` and `data-align` say where it ended up. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `side` | input | `'start' \| 'end' \| 'bottom' \| 'top'` | Side of the trigger it sits on, before flipping. | | `align` | input | `'start' \| 'end' \| 'center'` | Alignment along that side. | | `sideOffset` | input | `number` | Gap between the trigger and the card, in pixels. | ### NdHoverCardPortal `ng-template[ndHoverCardPortal]`: Marks the `ng-template` an `NdHoverCard` renders while open. ### NdHoverCardTrigger `[ndHoverCardTrigger]`: What opens an `NdHoverCard`, usually a link: hovering it, or focusing it with the keyboard, opens the card after the open delay; leaving it, or blur, closes it after the close delay. --- # Input primitive Headless Input: the behavior, keyboard support and ARIA of its pattern, with no styles. ```ts import { NdInput } from '@ndop/primitives/input'; ``` ## Focus - Unchanged from the native input. Focusing it and leaving it marks the bound field touched, through the forms library. ## Right-to-left Text direction follows the document or the element's `dir`; Ndop changes nothing. ## API reference ### NdInput `input[ndInput], textarea[ndInput]`: Headless text input, on an `input` or a `textarea`. Forms own the value: Signal Forms (`[formField]`) or reactive and template-driven forms bind the native input directly, and Ndop adds no value accessor (ADR-0004, amendment 1). The directive reads the bound field's state and reflects it: - `aria-invalid="true"` and `data-invalid` while invalid, meaning the `invalid` input is set, or the field is invalid and touched; - `data-disabled` while the input is disabled. With an `id`, it publishes that state for a `label[ndLabel]` pointing at it. Inside a form field (`[ndField]`), its `aria-describedby` lists the field's description and, while invalid, its error. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `id` | input | `string \| undefined` | Element id; also the key a `label[ndLabel]` uses to find this input. | | `invalid` | input | `boolean` | Marks the input invalid when no forms library is bound; with one, the field's state decides. | --- # Input OTP primitive Headless Input OTP: the behavior, keyboard support and ARIA of its pattern, with no styles. ```ts import { NdInputOtp, NdInputOtpGroup, NdInputOtpSeparator, NdInputOtpInput, NdInputOtpSlot, } from '@ndop/primitives/input-otp'; ``` ## Keyboard | Key | Action | | --- | ------ | | `Backspace` | Deletes the last character. | ## Focus - Clicking any slot focuses the input, with the caret at the end of the code. - Focus stays on the input as the code fills; the `completed` output lets the app move on. - Leaving the input marks a bound field touched. ## Right-to-left The root has `dir="ltr"`: a code's characters read left to right in every language, as phone numbers do, so the slots don't mirror. ## API reference ### NdInputOtp `[ndInputOtp]`: Headless one-time code input: one native `input[ndInputOtpInput]` holds the code, so screen readers, autofill and password managers see a single field, and `[ndInputOtpSlot]`s draw its characters. Characters `allow` rejects are dropped, and pasting fills the code from the start. The root owns the value, so it's the `ControlValueAccessor` that Signal Forms (`[formField]`), reactive and template-driven forms bind (ADR-0004, amendments 1 and 4). | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultValue` | input | `string` | Initial `value` when it isn't bound. | | `length` | input | `number` | The code's length: how many slots it fills. | | `allow` | input | `RegExp` | A regular expression one character must match to be kept, or its source. Digits by default. | | `inputMode` | input | `'numeric' \| 'text'` | The input's `inputmode`, which picks the on-screen keyboard. | | `disabled` | input | `boolean` | Disables the input. A disabled bound field does too. | | `invalid` | input | `boolean` | Marks the code invalid when no forms library is bound; with one, the field's state decides. | | `value` | model | `string` | The code. Two-way bindable with `[(value)]`. | | `completed` | output | `string` | Emits the code when the user completes it. | ### NdInputOtpGroup `[ndInputOtpGroup]`: A group of `NdInputOtpSlot`s, hidden from assistive technology: the input is what it reads. ### NdInputOtpSeparator `[ndInputOtpSeparator]`: A visual separator between groups of slots, hidden from assistive technology. ### NdInputOtpInput `input[ndInputOtpInput]`: The native input of a `NdInputOtp`, which holds the whole code. Name it with a `label` or `aria-label`; style it to sit, invisible, over the slots, so clicking a slot focuses it. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `id` | input | `string \| undefined` | Element id; also the key a `label[ndLabel]` uses to find it. | ### NdInputOtpSlot `[ndInputOtpSlot]`: One character of a `NdInputOtp`'s code. Its text is that character; `data-active` marks where the next one goes, and `data-caret` an active empty slot, to draw a caret in. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `index` | input | `number` | The character's position in the code, from 0. | --- # Label primitive Headless Label: the behavior, keyboard support and ARIA of its pattern, with no styles. ```ts import { NdLabel } from '@ndop/primitives/label'; ``` ## Focus - Clicking the label moves focus to the associated control (native behavior, unchanged). ## Right-to-left Nothing mirrors. ## API reference ### NdLabel `label[ndLabel]`: Headless label. Keeps native `label` semantics and, when `for` names an Ndop form control, such as `input[ndInput]` or `button[ndCheckbox]`, mirrors its state as `data-disabled` and `data-invalid`, through `NdControlRegistry` rather than DOM queries. Double-clicking the label's own text doesn't select it. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `for` | input | `string \| undefined` | Id of the labelled control. | --- # Menu primitive Headless Menu: the behavior, keyboard support and ARIA of the WAI-ARIA Menu pattern, with no styles. ```ts import { NdMenuContent, NdMenuGroup, NdMenuItemCheckbox, NdMenuItemIndicator, NdMenuLabel, NdMenuRadioGroup, NdMenuSeparator, NdMenuItem, NdMenuItemRadio, NdMenuSubTrigger, } from '@ndop/primitives/menu'; ``` Implements the [Menu](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `ArrowDown` or `ArrowUp` | Moves focus to the next or previous item, wrapping. | | `Home` or `End` | Moves focus to the first or last item. | | `Enter` | Activates the focused item and closes the menu. | | `Space` | Activates the focused item; checkbox and radio items keep the menu open. | | `ArrowRight` | On a sub-trigger, opens its menu and focuses the first item. | | `ArrowLeft` | In a submenu, closes it and focuses its sub-trigger. | | `Escape` | Closes the current menu and returns focus to what opened it. | | `Tab` | Closes every open menu and moves focus on from the trigger. | | `a` | Typeahead: moves focus to the next item whose label starts with the typed letters. | ## Focus - Opened from the keyboard, a menu focuses its first item (its last with `ArrowUp` on a Dropdown Menu trigger); opened with the pointer, the menu itself. - Closing returns focus to the trigger or sub-trigger. ## Right-to-left `ArrowLeft` opens submenus and `ArrowRight` closes them, and submenus open on the left. Direction comes from the app's `Directionality`: the document's `dir`, or a CDK `[dir]` directive. ## API reference ### NdMenuContent `[ndMenuContent]`: A menu: a list of actions in a `role="menu"` element, composing the CDK's `CdkMenu` (ADR-0004, amendment 3). Put it in an `ng-template` that a Dropdown Menu trigger, a Context Menu trigger or a `ndMenuSubTrigger` renders. Arrow keys, Home, End and typeahead move between items; Escape closes it and returns focus to what opened it. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `closed` | output | `void` | Emits when the menu closes. | ### NdMenuGroup `[ndMenuGroup]`: Groups related items (`role="group"`). Name it with `aria-labelledby` pointing at a `ndMenuLabel`. ### NdMenuItemCheckbox `[ndMenuItemCheckbox]`: A menu item that is on or off (`role="menuitemcheckbox"`), composing `CdkMenuItemCheckbox`. Activating it flips `checked`; Space keeps the menu open. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `disabled` | input | `boolean` | Makes it inert but focusable, with `aria-disabled`. | | `checked` | model | `boolean` | Whether it's on. Two-way bindable with `[(checked)]`. | | `triggered` | output | `void` | Emits when it's activated. | ### NdMenuItemIndicator `[ndMenuItemIndicator]`: The check or dot of a checkbox or radio item. It has the `hidden` attribute while its item is unchecked. ### NdMenuLabel `[ndMenuLabel]`: A heading for a group of items, as plain text; point the group's `aria-labelledby` at it. ### NdMenuRadioGroup `[ndMenuRadioGroup]`: Groups radio items (`role="group"`) and holds which one is checked. Name it with `aria-labelledby` pointing at a `ndMenuLabel`. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `value` | model | `string \| null` | The checked radio item's value, or `null`. Two-way bindable with `[(value)]`. | ### NdMenuSeparator `[ndMenuSeparator]`: A line between groups of items (`role="separator"`). ### NdMenuItem `[ndMenuItem]`: One action in a menu (`role="menuitem"`), composing `CdkMenuItem`. Activating it with a click, Enter or Space emits `triggered` and closes every open menu. `disabled` sets `aria-disabled` only, never the native attribute: disabled items stay focusable and do nothing. Use a `div`, as on a `button` browsers turn Space into a click, which closes the menu. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `disabled` | input | `boolean` | Makes it inert but focusable, with `aria-disabled`. | | `triggered` | output | `void` | Emits when it's activated. | ### NdMenuItemRadio `[ndMenuItemRadio]`: One choice in a `NdMenuRadioGroup` (`role="menuitemradio"`), composing `CdkMenuItemRadio`. Activating it makes its `value` the group's; Space keeps the menu open. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `value` | input | `string` | The value its group takes when it's checked. | | `disabled` | input | `boolean` | Makes it inert but focusable, with `aria-disabled`. | | `triggered` | output | `void` | Emits when it's activated. | ### NdMenuSubTrigger `[ndMenuSubTrigger]`: A menu item that opens the submenu in the `ng-template` it's given (`role="menuitem"` with `aria-haspopup="menu"`): with a click, hover, Enter, Space or the arrow key toward the end side. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `ndMenuSubTrigger` | input | `TemplateRef \| null` | The `ng-template` of the submenu. | | `disabled` | input | `boolean` | Makes it inert but focusable, with `aria-disabled`. | --- # Menubar primitive Headless Menubar: the behavior, keyboard support and ARIA of the WAI-ARIA Menubar pattern, with no styles. ```ts import { NdMenubar, NdMenubarTrigger } from '@ndop/primitives/menubar'; ``` Implements the [Menubar](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Tab` | Moves focus into the menubar, to its first trigger; from a trigger, out of the menubar. | | `ArrowRight` or `ArrowLeft` | Moves focus to the next or previous trigger, wrapping; if a menu is open, opens that trigger's menu instead. | | `ArrowDown` | On a trigger, opens its menu and focuses its first item. | | `Enter` or `Space` | On a trigger, opens its menu and focuses its first item. | | `Escape` | Closes the open menu and returns focus to its trigger. | ## Focus - The menubar is one tab stop, whose focus goes to its first trigger. - Closing a menu returns focus to its trigger. - While one menu is open, hovering another trigger opens its menu. ## Right-to-left In right-to-left layouts, the bar runs from right to left: `ArrowLeft` moves to the next trigger and `ArrowRight` to the previous one, and menus align to the trigger's right edge. Direction comes from the app's `Directionality`: the document's `dir`, or a CDK `[dir]` directive. ## API reference ### NdMenubar `[ndMenubar]`: A menubar: the WAI-ARIA Menubar pattern, a horizontal bar of menus such as a desktop app's File and Edit menus, composing the CDK's `CdkMenuBar` (ADR-0004, amendment 3). It is one tab stop; the arrow keys move between its triggers. Name it with `aria-label` or `aria-labelledby`. ### NdMenubarTrigger `[ndMenubarTrigger]`: One menu of a `NdMenubar`: a `role="menuitem"` that opens the menu in the `ng-template` it's given, composing `CdkMenuItem` and `CdkMenuTrigger`. The menu's parts are `@ndop/primitives/menu`'s. Use a `div`: on a `button`, browsers turn Space into a click, which would close the menu it just opened. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `ndMenubarTrigger` | input | `TemplateRef \| null` | The `ng-template` of the menu it opens. | | `side` | input | `'start' \| 'end' \| 'bottom' \| 'top'` | Side of the trigger the menu opens on, before flipping to fit. | | `align` | input | `'start' \| 'end' \| 'center'` | Alignment of the menu along that side. | | `sideOffset` | input | `number` | Gap between the trigger and the menu, in pixels. | --- # Navigation Menu primitive Headless Navigation Menu: the behavior, keyboard support and ARIA of the WAI-ARIA Disclosure navigation pattern, with no styles. ```ts import { NdNavigationMenu, NdNavigationMenuList, NdNavigationMenuContent, NdNavigationMenuItem, NdNavigationMenuLink, NdNavigationMenuTrigger, } from '@ndop/primitives/navigation-menu'; ``` Implements the [Disclosure navigation](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Tab` | Moves focus through the buttons, top-level links and the open panel's links, in document order. | | `Enter` or `Space` | On a button, shows or hides its panel. | | `ArrowDown` | On a button, opens its panel and focuses its first link. | | `ArrowRight` or `ArrowLeft` | On a top-level button or link, moves focus to the next or previous one, wrapping. | | `Home` or `End` | On a top-level button or link, moves focus to the first or last one. | | `Escape` | Closes the open panel and returns focus to its button. | ## Focus - Opening a panel with `Enter`, `Space` or a click keeps focus on the button. - Focus leaving the navigation closes the open panel. ## Right-to-left In right-to-left layouts the top-level items run from right to left, so `ArrowLeft` moves to the next one and `ArrowRight` to the previous one. Direction comes from the nearest `dir` attribute. ## API reference ### NdNavigationMenu `[ndNavigationMenu]`: A site's navigation: the WAI-ARIA disclosure navigation pattern, a list of links and buttons that each show a panel of links. It uses no menu roles, and each panel stays after its button in the document (ADR-0004, amendment 5), so Tab flows through it and its links render on the server. Put it on a `nav` named with `aria-label`. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `openDelay` | input | `number` | How long the pointer rests on a button before its panel opens, in ms. | | `closeDelay` | input | `number` | How long a panel stays after the pointer leaves its item, in ms. | | `value` | model | `string \| null` | The open item's value, or `null` when every panel is closed. Two-way bindable with `[(value)]`. | ### NdNavigationMenuList `[ndNavigationMenuList]`: The list of top-level items. Put it on a `ul`. ### NdNavigationMenuContent `[ndNavigationMenuContent]`: Its item's panel of links. It has the `hidden` attribute while closed and stays after its button in the document. ### NdNavigationMenuItem `[ndNavigationMenuItem]`: One top-level item, on an `li`: a link, or a button with its panel. The pointer leaving it closes its panel after the close delay. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `value` | input | `string` | Its value, which the root's `value` holds while it's open. | ### NdNavigationMenuLink `a[ndNavigationMenuLink]`: A link, at the top level or in a panel. `active` marks the current page with `aria-current="page"`. Following it closes the open panel. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `active` | input | `boolean` | Marks the link to the current page. | ### NdNavigationMenuTrigger `button[ndNavigationMenuTrigger]`: The button that shows and hides its item's panel, on a native `button`. A mouse resting on it opens the panel after the open delay. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `disabled` | input | `boolean` | Disables it. | --- # Popover primitive Headless Popover: the behavior, keyboard support and ARIA of the WAI-ARIA dialog role pattern, with no styles. ```ts import { NdPopover, NdPopoverClose, NdPopoverContent, NdPopoverPortal, NdPopoverTrigger, } from '@ndop/primitives/popover'; ``` Implements the [dialog role](https://www.w3.org/TR/wai-aria-1.2/#dialog) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Escape` | Closes the popover and returns focus to the trigger. | | `Tab` | Moves focus through the content; past the last element, the popover closes and focus moves to what follows the trigger. | | `Shift + Tab` | Moves focus backwards through the content; before the first element, the popover closes and focus returns to the trigger. | ## Focus - On open: focus moves to the first focusable element in the content, or to the content element. - Focus is not trapped. - On close with Escape or PopoverClose: focus returns to the trigger. - On close because focus or a pointer left: focus stays where the user put it. ## Right-to-left `side="start"` and `side="end"` place the content on the inline start and end of the trigger, which swap in RTL; `align="start"` and `align="end"` swap likewise. ## API reference ### NdPopover `[ndPopover]`: Root of a popover: non-modal content anchored to a trigger (WAI-ARIA `dialog` role without a focus trap). Renders the `ng-template[ndPopoverPortal]` into a CDK overlay positioned next to the trigger, flipping to the opposite side when it doesn't fit. Escape, an outside click or focus leaving the content closes it. Put it on an `ng-container` so it adds no element. The popover never renders open on the server; an initial open state takes effect after hydration. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultOpen` | input | `boolean` | Initial open state when `open` is not bound. | | `open` | model | `boolean` | Whether the popover is open. Two-way bindable with `[(open)]`. | ### NdPopoverClose `[ndPopoverClose]`: Button that closes its `NdPopover` and returns focus to where it was before opening. ### NdPopoverContent `[ndPopoverContent]`: The popover panel: `role="dialog"`, `tabindex="-1"`, with `data-side` and `data-align` reporting where it actually sits. Name it with `aria-label` or `aria-labelledby`. Focus moving outside it, other than to the trigger, closes the popover. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `side` | input | `'start' \| 'end' \| 'bottom' \| 'top'` | Preferred side of the trigger; `start` and `end` swap in RTL. | | `align` | input | `'start' \| 'end' \| 'center'` | Preferred alignment along that side. | | `sideOffset` | input | `number` | Gap from the trigger, in pixels. | ### NdPopoverPortal `ng-template[ndPopoverPortal]`: Marks the `ng-template` whose content the `NdPopover` renders while open. ### NdPopoverTrigger `[ndPopoverTrigger]`: Button that opens and closes its `NdPopover`, and anchors the content. Sets `aria-haspopup="dialog"`, `aria-expanded`, `aria-controls` while open, and `data-state`. --- # Progress primitive Headless Progress: the behavior, keyboard support and ARIA of the WAI-ARIA progressbar role pattern, with no styles. ```ts import { NdProgress, NdProgressIndicator } from '@ndop/primitives/progress'; ``` Implements the [progressbar role](https://www.w3.org/TR/wai-aria-1.2/#progressbar) pattern of the WAI-ARIA Authoring Practices. ## Focus - The progress bar never receives focus and never changes focus. ## Right-to-left The indicator fills from the start side: from the right in right-to-left layouts. ## API reference ### NdProgress `[ndProgress]`: Headless progress bar, the WAI-ARIA `progressbar` role: how far a task has got, from 0 to `max`, or indeterminate while `value` is `null`. It announces the percentage, and exposes it for styling the indicator's width. Give it a name with `aria-label` or `aria-labelledby`. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `value` | input | `number \| null` | How far the task has got, from 0 to `max`; `null` while unknown. | | `max` | input | `number` | The value of a finished task. | ### NdProgressIndicator `[ndProgressIndicator]`: The filled part of a `NdProgress`. Presentational: the root announces the value. --- # Radio Group primitive Headless Radio Group: the behavior, keyboard support and ARIA of the WAI-ARIA Radio Group pattern, with no styles. ```ts import { NdRadioGroup, NdRadioGroupIndicator, NdRadioGroupItem, } from '@ndop/primitives/radio-group'; ``` Implements the [Radio Group](https://www.w3.org/WAI/ARIA/apg/patterns/radio/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Tab` | Moves focus into the group, to the checked item or, if none, the first; then out of it. | | `Space` | Checks the focused item, if it isn't checked. | | `Enter` | Does nothing, as for native radio buttons; the default is prevented. | | `ArrowDown` or `ArrowRight` | Moves focus to the next enabled item and checks it; from the last, wraps to the first. | | `ArrowUp` or `ArrowLeft` | Moves focus to the previous enabled item and checks it; from the first, wraps to the last. | ## Focus - Arrow keys move focus and the selection together; disabled items are skipped. - Clicking an item focuses and checks it. - Focus leaving the group marks a bound field touched. ## Right-to-left In right-to-left layouts, `ArrowLeft` moves to the next item and `ArrowRight` to the previous one. Direction comes from the nearest `dir` attribute, on the item or an ancestor. ## API reference ### NdRadioGroup `[ndRadioGroup]`: Headless radio group: the WAI-ARIA radio group pattern, a set of `button[ndRadioGroupItem]`s of which one at most is checked. Only one item is in the tab order, and arrow keys move focus and the selection together, wrapping, mirrored in right-to-left layouts. It owns its value, so it's a `ControlValueAccessor` that Signal Forms (`[formField]`), reactive and template-driven forms bind (ADR-0004, amendments 1 and 4). | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultValue` | input | `string \| null` | Initial `value` when it isn't bound. | | `orientation` | input | `'vertical' \| 'horizontal'` | The items' layout, for `aria-orientation` and `data-orientation`. All four arrow keys work either way. | | `disabled` | input | `boolean` | Disables every item. A disabled bound field does too. | | `required` | input | `boolean` | Marks the group required: `aria-required="true"`. | | `invalid` | input | `boolean` | Marks the group invalid when no forms library is bound; with one, the field's state decides. | | `value` | model | `string \| null` | The checked item's value, or `null` when none is checked. Two-way bindable with `[(value)]`. | ### NdRadioGroupIndicator `[ndRadioGroupIndicator]`: The dot of a `NdRadioGroupItem`. It has the `hidden` attribute while its item is unchecked. ### NdRadioGroupItem `button[ndRadioGroupItem]`: One radio button of a `NdRadioGroup`, on a native `button`. Name it with a `label[ndLabel]` whose `for` is its `id`, or with `aria-label`. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `value` | input | `string` | The value the group takes when this item is checked. | | `disabled` | input | `boolean` | Disables this item; arrow keys skip it. | --- # Resizable primitive Headless Resizable: the behavior, keyboard support and ARIA of the WAI-ARIA Window Splitter pattern, with no styles. ```ts import { NdResizablePanelGroup, NdResizableHandle, NdResizablePanel, } from '@ndop/primitives/resizable'; ``` Implements the [Window Splitter](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Tab` | Moves focus to the next handle, in document order. | | `ArrowLeft` or `ArrowRight` | In a horizontal group, moves the focused handle toward that side by `keyboardStep`. | | `ArrowUp` or `ArrowDown` | In a vertical group, moves the focused handle up or down by `keyboardStep`. | | `Home` | Shrinks the panel before the handle to its `minSize`, as far as the panel after it allows. | | `End` | Grows the panel before the handle to its `maxSize`, as far as the panel after it allows. | ## Focus - Handles are in the tab order unless disabled; panels aren't. - Dragging a handle with the pointer doesn't move focus. ## Right-to-left In a horizontal group in a right-to-left layout, the panels run from right to left, so `ArrowLeft` grows the panel before the handle and `ArrowRight` shrinks it, and dragging follows the pointer. Direction comes from the nearest `dir` attribute. ## API reference ### NdResizablePanelGroup `[ndResizablePanelGroup]`: A group of resizable panels: the WAI-ARIA Window Splitter pattern. Its panels share its width (or height) and each `NdResizableHandle` between two panels moves the boundary between them, with the keyboard or a pointer. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `orientation` | input | `'vertical' \| 'horizontal'` | Whether the panels sit side by side or stacked. | | `keyboardStep` | input | `number` | How far an arrow key moves a handle, in percent. | | `sizes` | model | `readonly number[]` | The panels' sizes in percent, in document order. Two-way bindable with `[(sizes)]`; when unset, each panel's `defaultSize` applies. | ### NdResizableHandle `[ndResizableHandle]`: The handle between two panels (`role="separator"`): arrow keys, `Home` and `End`, or a pointer drag move the boundary between the panel before it and the panel after it. Name it with `aria-label` or `aria-labelledby`. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `disabled` | input | `boolean` | Stops it moving and takes it out of the tab order. | ### NdResizablePanel `[ndResizablePanel]`: One panel of a `NdResizablePanelGroup`. Its `flex` style follows its size, and its content is clipped to it. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultSize` | input | `number \| undefined` | Initial size in percent; without one, it shares the space left. | | `minSize` | input | `number` | Smallest size in percent. | | `maxSize` | input | `number` | Largest size in percent. | --- # Select primitive Headless Select: the behavior, keyboard support and ARIA of the WAI-ARIA Select-only combobox pattern, with no styles. ```ts import { NdSelect, NdSelectGroup, NdSelectItemText, NdSelectLabel, NdSelectSeparator, NdSelectContent, NdSelectItem, NdSelectItemIndicator, NdSelectTrigger, NdSelectValue, } from '@ndop/primitives/select'; ``` Implements the [Select-only combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Enter` or `Space` | On the trigger, opens the listbox; on an option, selects it, closes and focuses the trigger. | | `ArrowDown` or `ArrowUp` | On the trigger, opens the listbox; in it, moves to the next or previous enabled option. | | `Home` or `End` | In the listbox, moves to the first or last enabled option. | | `a` | Typeahead: on the closed trigger, selects the next option starting with the typed letters; in the listbox, moves to it. | | `Escape` | Closes the listbox and focuses the trigger. | | `Tab` | Closes the listbox and moves focus on from the trigger. | ## Focus - Opening focuses the selected option, or the first enabled one. - Selecting an option, `Escape` and `Tab` close the listbox; the first two return focus to the trigger. A click outside closes it where focus is. - Focus leaving the trigger and listbox marks a bound field touched. ## Right-to-left `start` and `end` alignments are logical and swap in right-to-left layouts. The listbox is vertical, so its arrow keys don't change. ## API reference ### NdSelect `[ndSelect]`: Root of a select: the WAI-ARIA select-only combobox pattern, a button that shows its value and opens a listbox of options. The listbox composes the CDK's `CdkListbox` and is written in place; opening moves it into a CDK overlay below the trigger. Put it on an `ng-container`, and bind forms on the `NdSelectTrigger`. The listbox is never shown on the server; an initial open state takes effect after hydration. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultValue` | input | `string \| null` | Initial `value` when it isn't bound. | | `defaultOpen` | input | `boolean` | Initial open state when `open` isn't bound, applied after hydration. | | `disabled` | input | `boolean` | Disables the trigger. A disabled bound field does too. | | `required` | input | `boolean` | Marks the select required: `aria-required="true"` on the trigger. | | `invalid` | input | `boolean` | Marks the select invalid when no forms library is bound; with one, the field's state decides. | | `value` | model | `string \| null` | The selected option's value, or `null` when none is. Two-way bindable with `[(value)]`. | | `open` | model | `boolean` | Whether the listbox is open. Two-way bindable with `[(open)]`. | ### NdSelectGroup `[ndSelectGroup]`: Groups options (`role="group"`). Name it with `aria-labelledby` pointing at a `NdSelectLabel`. ### NdSelectItemText `[ndSelectItemText]`: The text of an option, which the trigger shows while it's selected and typeahead matches. Without one, the option's own text is used. ### NdSelectLabel `[ndSelectLabel]`: A heading for a group of options, as plain text; point the group's `aria-labelledby` at it. ### NdSelectSeparator `[ndSelectSeparator]`: A decorative line between groups of options, hidden from assistive technology: a listbox may contain only options and groups. ### NdSelectContent `[ndSelectContent]`: The listbox of options (`role="listbox"`), composing `CdkListbox`. Write it after the trigger: it has the `hidden` attribute while closed, and opening moves it into an overlay below the trigger. It takes its name from the trigger's labels, or its own `aria-label`. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `side` | input | `'start' \| 'end' \| 'bottom' \| 'top'` | Side of the trigger it opens on, before flipping. | | `align` | input | `'start' \| 'end' \| 'center'` | Alignment along that side. | | `sideOffset` | input | `number` | Gap between the trigger and the listbox, in pixels. | ### NdSelectItem `[ndSelectItem]`: One option (`role="option"`), composing `CdkOption`. Clicking it, or `Enter` or `Space` on it, selects it and closes the listbox. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `value` | input | `string` | The value the select takes when it's chosen. | | `disabled` | input | `boolean` | Makes it unselectable; arrow keys skip it. | | `label` | input | `string \| null` | Its text, when it differs from what it renders. | ### NdSelectItemIndicator `[ndSelectItemIndicator]`: The check of an option. It has the `hidden` attribute unless its option is selected. ### NdSelectTrigger `button[ndSelectTrigger]`: The button that shows the select's value and opens its listbox (`role="combobox"`), on a native `button`. Name it with a `label[ndLabel]` whose `for` is its `id`, or with `aria-label`. The select owns its value, so this is a `ControlValueAccessor`: bind Signal Forms (`[formField]`), reactive or template-driven forms here (ADR-0004, amendments 1 and 4). ### NdSelectValue `[ndSelectValue]`: The selected option's text, or `placeholder` while none is selected. Put it inside the trigger. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `placeholder` | input | `string` | What it shows while no option is selected. | --- # Separator primitive Headless Separator: the behavior, keyboard support and ARIA of the WAI-ARIA separator role pattern, with no styles. ```ts import { NdSeparator } from '@ndop/primitives/separator'; ``` Implements the [separator role](https://www.w3.org/TR/wai-aria-1.2/#separator) pattern of the WAI-ARIA Authoring Practices. ## Focus - The separator never receives focus and never changes focus. ## Right-to-left Nothing mirrors: orientation is the same in both directions. ## API reference ### NdSeparator `[ndSeparator]`: Headless separator between groups of content. Sets `role="separator"`, with `aria-orientation` when vertical, or `role="none"` when decorative so assistive technology skips it. `data-orientation` always reflects the orientation, for styling. Not focusable. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `orientation` | input | `'vertical' \| 'horizontal'` | Layout direction of the separator. | | `decorative` | input | `boolean` | Whether the separator is purely visual. Decorative separators are hidden from assistive technology. | --- # Sidebar primitive Headless Sidebar: the behavior, keyboard support and ARIA of the WAI-ARIA Dialog (modal) pattern, with no styles. ```ts import { NdSidebarGap, NdSidebarMenuButton, NdSidebarProvider, NdSidebar, NdSidebarContainer, NdSidebarRail, NdSidebarTrigger, } from '@ndop/primitives/sidebar'; ``` Implements the [Dialog (modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Ctrl+b` | Toggles the sidebar (`⌘B` on a Mac), unless `shortcut` is `null`. | | `Enter` | On the trigger, toggles the sidebar. | | `Escape` | Closes the mobile sidebar and returns focus. | | `Tab` | Moves through the sidebar's content; on mobile, it stays inside. | ## Focus - Opening the mobile sidebar focuses its first focusable element; closing it returns focus to the trigger, or wherever focus was before. - Collapsing the desktop sidebar off-canvas while focus is inside moves focus to the trigger that collapsed it. ## Right-to-left `start` and `end` are logical: the `start` sidebar is on the right in right-to-left layouts, on desktop and on mobile. ## API reference ### NdSidebarGap `[ndSidebarGap]`: Keeps the sidebar's room in the page's flow; decorative. ### NdSidebarMenuButton `[ndSidebarMenuButton]`: A link or button in the sidebar's menu. `active` marks the current page with `aria-current="page"`; on mobile, following it when it's a link closes the sidebar. A button stays put, since it may open a submenu or a menu inside the sidebar. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `active` | input | `boolean` | Marks the link to the current page. | ### NdSidebarProvider `[ndSidebarProvider]`: Holds a sidebar's state for the page around it: expanded or collapsed on desktop, open or closed on mobile, and which applies, from the CDK's `BreakpointObserver`. `Ctrl+B` (`⌘B` on a Mac) toggles it. The server renders the desktop layout. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultOpen` | input | `boolean` | Whether the desktop sidebar starts expanded, when `open` isn't bound. | | `mobileQuery` | input | `string` | The CSS media query below which the sidebar is a mobile dialog. | | `shortcut` | input | `string \| null` | The letter that toggles the sidebar with `Ctrl` or `⌘`, or `null`. | | `open` | model | `boolean` | Whether the desktop sidebar is expanded. Two-way bindable. | | `openMobile` | model | `boolean` | Whether the mobile sidebar is open. Two-way bindable. | ### NdSidebar `[ndSidebar]`: The sidebar: its side and how it collapses, as data attributes for the styled layer. Its `NdSidebarContainer` holds the content. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `side` | input | `'start' \| 'end'` | The side it sits on: `start` or `end`. | | `collapsible` | input | `'none' \| 'offcanvas' \| 'icon'` | How it collapses on desktop. | ### NdSidebarContainer `[ndSidebarContainer]`: The panel holding the sidebar's content: a named landmark (`role="region"`). Collapsed off-canvas on desktop, it's `inert`. On mobile it's hidden until opened, then moves into an overlay at its side as a modal dialog with a focus trap, closed by `Escape` or a backdrop click. Name it with `aria-label`. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `backdropClass` | input | `string` | Classes of the mobile overlay's backdrop. | ### NdSidebarRail `button[ndSidebarRail]`: A thin strip along the sidebar's edge that toggles it with the pointer. It repeats the trigger, so it's out of the tab order and hidden from assistive technology. ### NdSidebarTrigger `button[ndSidebarTrigger]`: The button that toggles the sidebar: the desktop one, or the mobile one on mobile. Name it with text, visible or `sr-only`. --- # Slider primitive Headless Slider: the behavior, keyboard support and ARIA of the WAI-ARIA Slider pattern, with no styles. ```ts import { NdSlider, NdSliderRange, NdSliderThumb, NdSliderTrack, } from '@ndop/primitives/slider'; ``` Implements the [Slider](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `ArrowRight` or `ArrowUp` | Increases the focused thumb's value by one step. | | `ArrowLeft` or `ArrowDown` | Decreases the focused thumb's value by one step. | | `PageUp` | Increases the value by ten steps. | | `PageDown` | Decreases the value by ten steps. | | `Home` | Sets the value to its minimum: the slider's, or the previous thumb's. | | `End` | Sets the value to its maximum: the slider's, or the next thumb's. | ## Focus - Keys and dragging keep focus on the thumb; pressing the slider's track focuses the thumb it moves. - Leaving a thumb marks its bound field touched. ## Right-to-left Horizontal sliders run from the start side, so the minimum is on the right in right-to-left layouts, and `ArrowLeft` increases the value while `ArrowRight` decreases it. Vertical sliders run bottom to top in either direction. Direction comes from the nearest `dir` attribute, on the slider or an ancestor. ## API reference ### NdSlider `[ndSlider]`: Headless slider: the WAI-ARIA slider pattern, or with several `[ndSliderThumb]`s, the multi-thumb pattern. The root sets the scale (`min`, `max`, `step`) and handles the pointer; each thumb owns one value and is a form control of its own. Horizontal sliders run from the start side, so they mirror in right-to-left layouts. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `min` | input | `number` | The lowest value. | | `max` | input | `number` | The highest value. | | `step` | input | `number` | The distance between allowed values, from `min`. | | `orientation` | input | `'vertical' \| 'horizontal'` | Horizontal (from the start side) or vertical (from the bottom). | | `disabled` | input | `boolean` | Disables every thumb. | ### NdSliderRange `[ndSliderRange]`: The filled part of a `NdSlider`'s track. Its `start` and `end` are percentages for the styled layer to position it with; the primitive sets no styles. ### NdSliderThumb `[ndSliderThumb]`: One thumb of a `NdSlider`: a `role="slider"` element that owns one value. It's a `ControlValueAccessor` for a number, which Signal Forms (`[formField]`), reactive and template-driven forms bind (ADR-0004, amendments 1 and 4). Name it with `aria-label` or `aria-labelledby`. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultValue` | input | `number \| undefined` | Initial `value` when it isn't bound. | | `valueText` | input | `string \| undefined` | Text for `aria-valuetext`, when the number alone doesn't say it. | | `invalid` | input | `boolean` | Marks the thumb invalid when no forms library is bound; with one, the field's state decides. | | `value` | model | `number` | This thumb's value. Two-way bindable with `[(value)]`. | ### NdSliderTrack `[ndSliderTrack]`: The track of a `NdSlider`: presentational, with the slider's data attributes for styling. --- # Switch primitive Headless Switch: the behavior, keyboard support and ARIA of the WAI-ARIA Switch pattern, with no styles. ```ts import { NdSwitch, NdSwitchThumb } from '@ndop/primitives/switch'; ``` Implements the [Switch](https://www.w3.org/WAI/ARIA/apg/patterns/switch/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Space` | Turns it on or off. | | `Enter` | Turns it on or off. | ## Focus - Unchanged from a native button: activating it keeps focus on it. - Leaving it marks a bound field touched. ## Right-to-left The thumb's position is styling: the styled item moves it toward the end side, so it mirrors in right-to-left layouts. ## API reference ### NdSwitch `button[ndSwitch]`: Headless switch on a native `button`: the WAI-ARIA switch pattern, an on/off control that takes effect at once. It owns its value, so it's a `ControlValueAccessor` that Signal Forms (`[formField]`), reactive and template-driven forms bind (ADR-0004, amendments 1 and 4). Space and Enter turn it on or off. A label whose `for` is its `id` does too. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultChecked` | input | `boolean` | Initial `checked` when it isn't bound. | | `disabled` | input | `boolean` | Disables it. A disabled bound field disables it too. | | `required` | input | `boolean` | Marks it required: `aria-required="true"`. | | `invalid` | input | `boolean` | Marks it invalid when no forms library is bound; with one, the field's state decides. | | `id` | input | `string \| undefined` | Element id; also the key a `label[ndLabel]` uses to find it. | | `checked` | model | `boolean` | Whether it's on. Two-way bindable with `[(checked)]`. | ### NdSwitchThumb `[ndSwitchThumb]`: The thumb of a `NdSwitch`: presentational, with the switch's `data-state` for styling its position. --- # Tabs primitive Headless Tabs: the behavior, keyboard support and ARIA of the WAI-ARIA Tabs pattern, with no styles. ```ts import { NdTabs, NdTabsContent, NdTabsList, NdTabsTrigger, } from '@ndop/primitives/tabs'; ``` Implements the [Tabs](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Tab` | Moves focus into the tab list, to the selected tab; then to the active panel. | | `ArrowRight` or `ArrowLeft` | In a horizontal list, moves focus to the next or previous enabled tab, wrapping; selects it if automatic. | | `ArrowDown` or `ArrowUp` | In a vertical list, moves focus to the next or previous enabled tab, wrapping; selects it if automatic. | | `Home` | Moves focus to the first enabled tab; selects it if automatic. | | `End` | Moves focus to the last enabled tab; selects it if automatic. | | `Enter` or `Space` | Selects the focused tab. | ## Focus - The selected tab is the list's only tab stop; arrow keys move focus without leaving the list, and disabled tabs are skipped. - Clicking a tab focuses and selects it. ## Right-to-left In a horizontal list in a right-to-left layout, `ArrowLeft` moves to the next tab and `ArrowRight` to the previous one. Direction comes from the nearest `dir` attribute. ## API reference ### NdTabs `[ndTabs]`: Root of a set of tabs: the WAI-ARIA Tabs pattern, layered sections of content shown one at a time. It holds which tab is selected; its `NdTabsList` holds the tabs and each `NdTabsContent` a panel. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultValue` | input | `string \| null` | Initial `value` when it isn't bound. | | `orientation` | input | `'vertical' \| 'horizontal'` | The tab list's layout: which arrow keys move between tabs. | | `activationMode` | input | `'automatic' \| 'manual'` | Whether moving focus to a tab selects it. | | `value` | model | `string \| null` | The selected tab's value, or `null` before one is selected. Two-way bindable with `[(value)]`. | ### NdTabsContent `[ndTabsContent]`: The panel of the tab with the same `value` (`role="tabpanel"`). It's hidden while its tab isn't selected, and stays in the document. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `value` | input | `string` | The value of the tab that shows it. | ### NdTabsList `[ndTabsList]`: The list of tabs (`role="tablist"`). Name it with `aria-label` or `aria-labelledby`. ### NdTabsTrigger `button[ndTabsTrigger]`: One tab (`role="tab"`), on a native `button`: it selects the panel with the same `value`. Only the selected tab is in the tab order; arrow keys, `Home` and `End` move between tabs. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `value` | input | `string` | The value of the panel it shows. | | `disabled` | input | `boolean` | Disables it; arrow keys skip it. | --- # Toast primitive Headless Toast: the behavior, keyboard support and ARIA of the WAI-ARIA live region pattern, with no styles. ```ts import { NdToast, NdToastDescription, NdToasterOutlet, NdToastRegion, NdToastTitle, NdToastAction, NdToastClose, } from '@ndop/primitives/toast'; ``` Implements the [live region](https://www.w3.org/TR/wai-aria-1.2/#aria-live) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `F8` | Focuses the toast region from anywhere on the page. | | `Tab` | Moves through the toasts' buttons, then on to the page. | | `Escape` | With focus in a toast, dismisses it. | ## Focus - Toasts never take focus. F8 moves focus to the region; while it's there, nothing times out. - When a toast with focus inside is dismissed, focus moves to the region. ## Right-to-left Toasts swipe toward the end side, so to the left in right-to-left layouts. Direction comes from the nearest `dir` attribute. ## API reference ### NdToast `[ndToast]`: One toast, whose value is its record from `NdToaster.toasts`. It's read whole when announced (`aria-atomic`). Escape dismisses it while focus is inside; swiping it 50 pixels toward the end side does too. `data-index` (0 is newest), `data-front`, `--nd-toast-index` and `--nd-toast-swipe` are for styling the stack and the swipe. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `ndToast` | input | `NdToastRecord` | The toast's record. | ### NdToastDescription `[ndToastDescription]`: A toast's detail, under its title. ### NdToasterOutlet `ng-template[ndToaster]`: Renders the template of the app's toast region into a CDK global overlay, above everything else, once the app has hydrated, so the live region exists before the first toast. Put one in the app's shell. ### NdToastRegion `[ndToastRegion]`: The landmark the toasts live in: `role="region"`, a polite live region, so new toasts are announced without moving focus. F8 focuses it from anywhere. While the pointer is over it or focus is inside it, timers pause and it has `data-expanded`, for the styled layer to spread the stack out. ### NdToastTitle `[ndToastTitle]`: A toast's message. ### NdToastAction `button[ndToastAction]`: The button of a toast's action: it runs the action, then dismisses the toast. ### NdToastClose `button[ndToastClose]`: The button that dismisses its toast. Name it, such as `aria-label="Close"`. --- # Toggle primitive Headless Toggle: the behavior, keyboard support and ARIA of the WAI-ARIA Button pattern, with no styles. ```ts import { NdToggle } from '@ndop/primitives/toggle'; ``` Implements the [Button](https://www.w3.org/WAI/ARIA/apg/patterns/button/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Space` | Presses or releases it. | | `Enter` | Presses or releases it. | ## Focus - Unchanged from a native button: activating it keeps focus on it. ## Right-to-left Nothing mirrors. ## API reference ### NdToggle `button[ndToggle]`: Headless toggle button on a native `button`: pressed or not, announced with `aria-pressed`. Space and Enter press or release it. It's also a `ControlValueAccessor` for a boolean, which every forms library binds (ADR-0004, amendments 1 and 4). Keep its label the same in both states: "Bold", not "Bold on". | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultPressed` | input | `boolean` | Initial `pressed` when it isn't bound. | | `disabled` | input | `boolean` | Disables it. A disabled bound field disables it too. | | `pressed` | model | `boolean` | Whether it's pressed. Two-way bindable with `[(pressed)]`. | --- # Toggle Group primitive Headless Toggle Group: the behavior, keyboard support and ARIA of the WAI-ARIA toggle buttons pattern, with no styles. ```ts import { NdToggleGroup, NdToggleGroupItem, } from '@ndop/primitives/toggle-group'; ``` Implements the [toggle buttons](https://www.w3.org/WAI/ARIA/apg/patterns/button/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Tab` | Moves focus into the group, to its tab stop; then out of it. | | `Space` or `Enter` | Presses or releases the focused item. | | `ArrowRight` or `ArrowLeft` | Horizontal groups: moves focus to the next or previous enabled item, wrapping, without pressing it. | | `ArrowDown` or `ArrowUp` | Vertical groups: moves focus to the next or previous enabled item, wrapping, without pressing it. | | `Home` or `End` | Moves focus to the first or last enabled item. | ## Focus - Arrow keys, Home and End move focus only; pressing stays with Space and Enter. Disabled items are skipped. ## Right-to-left In right-to-left layouts, `ArrowLeft` moves to the next item and `ArrowRight` to the previous one. Direction comes from the nearest `dir` attribute, on the item or an ancestor. ## API reference ### NdToggleGroup `[ndToggleGroup]`: Headless toggle group: a set of toggle buttons, `button[ndToggleGroupItem]`, of which one (`type="single"`) or any number (`type="multiple"`) are pressed. Only one item is in the tab order; arrow keys move focus between items, wrapping, and Home and End jump to the ends. Its value is always an array of the pressed items' values. It's a `ControlValueAccessor` that every forms library binds (ADR-0004, amendments 1 and 4). | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultValue` | input | `readonly string[]` | Initial `value` when it isn't bound. | | `type` | input | `'single' \| 'multiple'` | `single`: pressing an item releases the others. `multiple`: items are pressed independently. | | `orientation` | input | `'vertical' \| 'horizontal'` | Which arrow keys move focus: left and right, or up and down. | | `disabled` | input | `boolean` | Disables every item. A disabled bound field does too. | | `value` | model | `readonly string[]` | The pressed items' values, in both modes. Two-way bindable with `[(value)]`. | ### NdToggleGroupItem `button[ndToggleGroupItem]`: One toggle button of a `NdToggleGroup`, on a native `button`. Give it a name that doesn't change with its state, such as `aria-label="Bold"`. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `value` | input | `string` | The value this item adds to the group's while pressed. | | `disabled` | input | `boolean` | Disables this item; arrow keys skip it. | --- # Tooltip primitive Headless Tooltip: the behavior, keyboard support and ARIA of the WAI-ARIA Tooltip pattern, with no styles. ```ts import { NdTooltip, NdTooltipContent, NdTooltipPortal, NdTooltipTrigger, } from '@ndop/primitives/tooltip'; ``` Implements the [Tooltip](https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/) pattern of the WAI-ARIA Authoring Practices. ## Keyboard | Key | Action | | --- | ------ | | `Tab` | Focusing the trigger shows the tooltip; moving focus away hides it. | | `Escape` | Hides the tooltip without moving focus. | ## Focus - The tooltip never takes focus; focus stays on the trigger. ## Right-to-left As in Popover's `spec.md`: `start` and `end` sides and alignments swap in RTL. ## API reference ### NdTooltip `[ndTooltip]`: Root of a tooltip (WAI-ARIA Tooltip pattern): a short description shown next to its trigger. Opens when the pointer rests on the trigger (after `openDelay`) or when the trigger gets keyboard focus (at once); closes when the pointer leaves trigger and content (after `closeDelay`), on blur and on Escape. The content can be hovered, as WCAG 1.4.13 requires, and never takes focus. Put it on an `ng-container` so it adds no element. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `defaultOpen` | input | `boolean` | Initial open state when `open` is not bound. | | `openDelay` | input | `number` | Delay before opening on hover, in milliseconds. | | `closeDelay` | input | `number` | Delay before closing once the pointer has left, in milliseconds. | | `open` | model | `boolean` | Whether the tooltip is open. Two-way bindable with `[(open)]`. | ### NdTooltipContent `[ndTooltipContent]`: The tooltip panel: `role="tooltip"`, never focusable, with `data-side` and `data-align` reporting where it actually sits. While it is rendered, its id is in the trigger's `aria-describedby`. Hovering it keeps the tooltip open. | Name | Kind | Type | Description | | ---- | ---- | ---- | ----------- | | `side` | input | `'start' \| 'end' \| 'bottom' \| 'top'` | Preferred side of the trigger; `start` and `end` swap in RTL. Defaults to `top`, as in Radix and shadcn. | | `align` | input | `'start' \| 'end' \| 'center'` | Preferred alignment along that side. | | `sideOffset` | input | `number` | Gap from the trigger, in pixels. | ### NdTooltipPortal `ng-template[ndTooltipPortal]`: Marks the `ng-template` whose content the `NdTooltip` renders while open. ### NdTooltipTrigger `[ndTooltipTrigger]`: The element the tooltip describes. Hovering it opens the tooltip after the open delay, keyboard focus opens it at once, and leaving or blurring it closes it. While open, the content's id is added to its `aria-describedby`, keeping ids already there.