# Introduction Source: /docs/get-started/index.md # Introduction Frontile is a component library for Ember.js applications, built with [Tailwind CSS](https://tailwindcss.com/) and [Tailwind Variants](https://www.tailwind-variants.org/). ## Why Frontile? Frontile provides components, helpers, modifiers, and styles for building Ember.js apps, offering both low-level primitives and high-level components. ### Key Features - **Built for Ember** – Integrates with Ember Octane & Glimmer components - **Accessibility** – ARIA attributes, keyboard navigation, and focus management built into every component - **Customizable** – Uses Tailwind CSS with Tailwind Variants for styling - **TypeScript & Glint Support** – Fully typed templates with Glint - **Theme Support** – Dark & light mode support with theme-aware components - **Responsive foundations** – Components can be composed with Tailwind CSS responsive utilities - **Composable & Extensible** – Designed to be customized and extended to fit any design system ## Quick Start Get started with Frontile in just a few steps: ### Installation ```sh pnpm install frontile @frontile/theme ``` ### Basic Setup Add Frontile's theme configuration to your `app/styles/app.css` for Tailwind CSS v4: ```css title="app/styles/app.css" @import 'tailwindcss' source('../../'); @plugin "@frontile/theme/plugin/default"; @import "@frontile/theme"; /* Tailwind skips node_modules when scanning for classes, so Frontile's own templates have to be pointed at or their classes get purged. */ @source '../../node_modules/frontile'; @source '../../node_modules/@frontile'; ``` Paths are relative to the CSS file. Under Vite the entry stylesheet is usually `app/app.css`, one level shallower, so the sources become `'../node_modules/...'`. The `dark` variant comes from `@import "@frontile/theme"` — you do not need to declare `@custom-variant dark` yourself, and the version the theme ships also handles `theme-inverse`. ### Your First Component ```gts import Component from '@glimmer/component'; import { Button } from 'frontile'; export default class Example extends Component { onPress = () => { alert('Welcome to Frontile!'); }; } ``` ### Legacy Packages > **Note:** The following packages are deprecated and will be removed in 0.19.0. See the [migration guides](/docs/migrations/v0-18/) for details: > > - [`@frontile/changeset-form`](/docs/migrations/v0-18/changeset-form) - Use `frontile` forms instead > - [`@frontile/forms-legacy`](/docs/migrations/v0-18/forms-legacy) - Migrate to the new `frontile` forms ## Architecture Philosophy Frontile follows a component composition pattern that prioritizes: - **Flexibility** – Components are designed to be composed together in various ways - **Consistency** – Unified design language across all components - **Accessibility** – Keyboard, focus, labeling, and ARIA behavior are built into interactive components - **Performance** – Optimized for Ember's rendering system - **Developer Experience** – Clear APIs with TypeScript support ## Browser Support Frontile supports all modern browsers and follows Ember.js compatibility guidelines: - **Ember.js** v4.12 or above - **Modern Browsers** – Chrome, Firefox, Safari, Edge ## Development Status Frontile is currently in active development and approaching v1.0. While the API is stabilizing, breaking changes may still occur. We recommend using Frontile in production with appropriate version pinning. ## Getting Help - 📖 **Documentation** – Visit [frontile.dev](https://frontile.dev/) for comprehensive guides and examples - 🐛 **Issues** – Report bugs or request features on [GitHub](https://github.com/josemarluedke/frontile/issues) - 💬 **Community** – Join discussions on the Ember.js Discord server ## Acknowledgments Frontile draws on patterns from other component libraries and design systems: - **[HeroUI](https://www.heroui.com/)** – React UI library with accessibility and theming - **[IntentUI](https://intentui.com/)** – Design system focused on developer experience and component composition - **[Mantine](https://mantine.dev/)** – Full-featured React components library with theming - **[Chakra UI](https://chakra-ui.com/)** – Modular, accessible component library for React - **[Ember Primitives](https://ember-primitives.pages.dev/)** – Low-level UI primitives for Ember applications These projects have influenced Frontile's approach to component design, accessibility, theming, and developer experience. --- See the [Installation Guide](./installation.md) to start building with Frontile. --- # Installation Source: /docs/get-started/installation.md # Installation Frontile is a component library for Ember.js that ships all its components in a single package. Modern build tools with tree-shaking include only the components you use in your final bundle. ## Install Frontile Install the main `frontile` package along with the theme: :::code-tabs ```sh title="pnpm" pnpm install frontile @frontile/theme ``` ```sh title="npm" npm install frontile @frontile/theme ``` ```sh title="yarn" yarn add frontile @frontile/theme ``` ::: You now have access to all Frontile components: ```js import { Button, Input, Modal, Table } from 'frontile'; ``` With modern build tools and explicit imports (`.gts`/`.gjs`), only the components you import will be included in your application bundle through tree-shaking. > **Note:** The following packages are deprecated and will be removed in 0.19.0. See the [migration guides](/docs/migrations/v0-18/) for details: > > - [`@frontile/changeset-form`](/docs/migrations/v0-18/changeset-form) - Use `frontile` forms instead > - [`@frontile/forms-legacy`](/docs/migrations/v0-18/forms-legacy) - Migrate to the new `frontile` forms > > If you're currently using the separate scoped packages (`@frontile/buttons`, `@frontile/forms`, etc.), you can migrate to the consolidated `frontile` package by updating your imports: > > ```diff > - import { Button } from '@frontile/buttons'; > + import { Button } from 'frontile'; > ``` ## Setup Theme ### Using Default Theme For the default theme, add this to your `app/styles/app.css`: ```css title="app/styles/app.css" {7-8} @import 'tailwindcss' source('../../'); @plugin "@frontile/theme/plugin/default"; @import "@frontile/theme"; /* Tailwind v4 skips node_modules when scanning for classes. Without these, Frontile's own component classes are purged and components render unstyled. */ @source '../../node_modules/frontile'; @source '../../node_modules/@frontile'; ``` Paths are relative to this file — under Vite, where the entry stylesheet is usually `app/app.css`, they are one level shallower. ### Customizing Frontile Theme To customize the frontile theme, create a file in the root of your project named `frontile.js` with the following content: ```js title="frontile.js" const { frontile } = require('@frontile/theme/plugin'); module.exports = frontile({ /* your config */ }); ``` Then update your `app/styles/app.css` to use the custom configuration: ```css title="app/styles/app.css" @import 'tailwindcss' source('../../'); @plugin "./../../frontile.js"; @import "@frontile/theme"; ``` For more advanced configuration options, see the [Theme documentation](../theming/overview.md). --- # Overview Source: /docs/get-started/ai/index.md # AI & Agents Frontile publishes its documentation in forms a coding agent can read directly: a machine index, plain-Markdown mirrors of every page, and an installable skill. Nothing here needs a server, an account, or a plugin. All of it exists so an agent can read the actual API instead of inventing one that looks plausible. ## Start here **Using a coding agent** such as Claude Code, Cursor, or Codex, install the skill: ```bash npx skills add josemarluedke/frontile ``` It teaches the agent where to look, which component to reach for, and how the styling system works. See [Agent Skill](./skill.md). **Using a chat tool**, or wiring something up yourself, point it at [`/llms.txt`](/llms.txt) and let it follow the links. See [LLMs.txt](./llms-txt.md). Neither is required. Every page on this site is available as Markdown at its own URL plus `.md`, and that works with no setup at all. ## When to reach for Frontile Frontile is an Ember.js component library built on Tailwind CSS and Tailwind Variants. It fits when the task is: - Building UI in an Ember app where Tailwind is acceptable, and you want accessible components rather than primitives to style yourself. - Forms that handle their own labelling, validation display, and sizing. - Collections such as tables, listboxes, dropdowns, calendars, and command palettes. - Overlays driven from a template or from code: modals, drawers, popovers, toasts. - Theming an app to a brand through semantic color tokens, without forking component code. Reach for something else when the project is React, Vue, Svelte, or plain HTML, when Tailwind is off the table, or when you want unstyled headless primitives with no prebuilt UI. ## Where an agent should look In this order, cheapest and most trustworthy first. **1. The type declarations in your own `node_modules`.** ``` node_modules/frontile/declarations/**/*.d.ts ``` Authoritative for arguments, types, and defaults, and exact for the version you installed, which no published document can be. `frontile` ships `declarations/` and Glint needs it, so this is already on disk in every app. The JSDoc survives the build: ```ts /** * The button variant. * * @defaultValue 'solid' */ variant?: 'solid' | 'soft' | 'subtle' | 'outline' | 'ghost' | 'plain' | 'custom'; ``` Anything deprecated is marked `@deprecated` here too, with the replacement named. **2. The Markdown mirror of the component's page.** Every page on this site is also served as plain Markdown at the same URL plus `.md`, carrying the prose, examples, and yielded blocks that type declarations cannot express. See [LLMs.txt](./llms-txt.md). **3. The index**, when the component's name is not yet known: [`/llms.txt`](/llms.txt). Tier 1 decides whether the generated code compiles, and it always matches the installed version. Tiers 2 and 3 supply prose and discovery, where reading documentation slightly ahead of your installed version costs far less. ## Which origin serves these files They are emitted by the documentation build, so every version subdomain serves the files for its own version, but only for versions built after the export existed. | Origin | Serves the agent surface | | --------------------------------------------------------- | ------------------------ | | [`next.frontile.dev`](https://next.frontile.dev/llms.txt) | Yes, in-development docs | | `frontile.dev` | From 0.18 onward | | `v0.16.frontile.dev` | No, predates it | > **Note:** An origin that does not serve them answers with the site's HTML shell and HTTP > 200, not a 404. A fetch that returns HTML where Markdown was expected has failed, whatever > the status code says. --- # LLMs.txt Source: /docs/get-started/ai/llms-txt.md # LLMs.txt Every page of these docs is published twice: once as the HTML you are reading, and once as plain Markdown for anything that would rather not parse a single-page app. Alongside them sits an index and a set of bulk files, following the [llms.txt standard](https://llmstxt.org). All of it is generated by the documentation build, so nothing drifts from the site. ## Which file to use Start with `/llms.txt`. It fits in any context window and links to everything else. | File | Contents | Approx. tokens | | ---------------------------------------------- | --------------------------------------- | -------------- | | [`/llms.txt`](/llms.txt) | Index, with a description per component | 3K | | [`/llms-components.txt`](/llms-components.txt) | Every component page in full | 230K | | [`/llms-theming.txt`](/llms-theming.txt) | Theming, design tokens, configuration | 31K | | [`/llms-migrations.txt`](/llms-migrations.txt) | Migration guides | 28K | | [`/llms-full.txt`](/llms-full.txt) | Everything | 302K | `llms-full.txt` exceeds most context windows and is rarely the right choice. Reach for the narrowest file that covers the task: an agent building a form never needs the migration prose. ## Per-page Markdown mirrors Any page URL plus `.md` returns that page as Markdown: ``` /docs/components/buttons/button → the HTML page /docs/components/buttons/button.md → the same content, as Markdown ``` The mirror is the full page, not a summary. Component pages carry the generated API table: every argument, its type, its default, and a Deprecated marker with the migration instruction where one applies. The `` tag is expanded into a real Markdown table before export, so nothing arrives as an unresolved component tag. Fetch the `.md`. Fetching the HTML and stripping tags is slower and loses the API table. ## What the index contains Every page, grouped by section, each linking to its `.md` mirror: ``` ## Buttons - [Button](/docs/components/buttons/button.md): The base pressable action, with colors, variants, and sizes. - [Chip](/docs/components/buttons/chip.md): A compact label for statuses, filters, and removable tags. ``` Component entries carry a one-line description, so an agent can usually pick the right component without fetching anything. Guide pages are listed without one. The file opens with a preamble covering the conventions most often got wrong, and marks deprecated sections so `Forms (Legacy)` is not mistaken for current `Forms`. ## Using it with your tools ### Claude Code Point it at the index and let it follow the links, fetching `.md` pages on demand: ``` Use Frontile documentation from https://frontile.dev/llms.txt ``` Put that line in your project's `CLAUDE.md` to apply it to every session. The [Frontile skill](./skill.md) does this and more, without the manual step. ### Cursor Add the URL to your project context with `@Docs`: ``` @Docs https://frontile.dev/llms.txt ``` > **Note:** Type the `@` by hand. Pasting it stops Cursor from recognising it as a context > reference. See [Cursor's @Docs guide](https://cursor.com/docs/context/@-symbols/@-docs). ### Windsurf Add a line to `.windsurfrules` so it applies to every conversation: ``` #docs https://frontile.dev/llms.txt ``` ### ChatGPT, Claude, and other chat tools Paste a `.md` URL and ask about that component: ``` https://frontile.dev/docs/components/forms/select.md How do I make this filter as the user types? ``` For a whole topic, `llms-components.txt` fits in a 200K-token context. `llms-full.txt` generally does not. ### Anything else These are static text files over HTTPS with no authentication, so `curl`, `fetch`, or a scheduled job all work: ```bash curl https://frontile.dev/docs/components/buttons/button.md ``` ## The component inventory [`/component-inventory.json`](/component-inventory.json) lists every current component with its path and one-line description, the same data behind the [components overview](/docs/components/overview). Useful when something needs the catalogue as structured data rather than prose. Legacy packages are excluded from it. ## Version-matched origins These files are built per version, so each documentation origin serves its own. Only versions built after the export existed have them: | Origin | Agent surface | | --------------------------------------------------------- | ---------------- | | [`next.frontile.dev`](https://next.frontile.dev/llms.txt) | Yes | | `frontile.dev` | From 0.18 onward | | `v0.16.frontile.dev` | No | > **Note:** An origin without these files answers with the site's HTML shell and HTTP 200, > not a 404. Check the body rather than the status code: Markdown that starts with > `` is a failed fetch. For anything version-sensitive, such as an argument's exact type or whether it is deprecated, read `node_modules/frontile/declarations/**/*.d.ts` in the consuming app instead. That is exact for the installed version by construction. ## Found a problem? If an agent generates wrong Frontile code and the cause is something these files say, or fail to say, that is a documentation bug worth reporting. [Open an issue](https://github.com/josemarluedke/frontile/issues). --- # Agent Skill Source: /docs/get-started/ai/skill.md # Agent Skill ```bash npx skills add josemarluedke/frontile ``` Once installed, ask your agent for UI and it builds with Frontile instead of guessing at an API it half-remembers: - _"Add a settings form with a name field, an email field, and a save button."_ - _"Put this destructive action behind a confirmation modal."_ - _"Make this table sortable and paginated."_ - _"Restyle these buttons to the danger color."_ - _"This Select should let people type to filter."_ ## What a skill is A skill is a set of instructions an agent loads when it recognises that a task involves Frontile. It sits in your project, is plain Markdown you can read and edit, and needs no server, account, or running process. The [`skills` CLI](https://skills.sh) installs it and supports 35+ agents including Claude Code, Cursor, Codex, and Windsurf. An MCP server answers questions over a live connection. A skill is loaded text. For a library whose facts are already on disk in your `node_modules`, text is enough, which is why Frontile ships a skill and no server. ## What it carries The skill holds judgment and routes to facts. It does not restate arguments, types, or defaults. An installed skill is a copy of a file, frozen at install time, while your `frontile` dependency moves independently. Any argument list written into it would eventually describe a version nobody has. So it states where to look instead: first `node_modules/frontile/declarations/**/*.d.ts`, which is exact for the version you installed, then the [Markdown mirrors](./llms-txt.md) for prose and examples. What it does carry: - **The lookup order**, so the agent checks your installed types before inventing an argument. - **Component selection** for the genuinely ambiguous choices: Modal versus Drawer versus Popover, Select versus NativeSelect versus Autocomplete, Table versus SimpleTable. - **The semantic color system:** categories with named levels, and why `bg-primary-500` does not exist. - **`.gts` conventions**, including the imports that are not where an agent expects them and the Tailwind `@source` lines that, when missing, leave every component unstyled. - **The pre-0.18 argument names**, for upgrades and for the older examples still circulating. Reference files load on demand, so a question about colors does not pull in the migration guidance: ``` skills/frontile/ ├── SKILL.md # Lookup order, common traps, routing table └── references/ ├── api-naming.md # Current vs pre-0.18 argument names ├── colors.md # Semantic categories and levels ├── component-selection.md # Which component for which job └── gts-conventions.md # Template authoring and setup ``` ## Using it Most agents load the skill on their own once they recognise the task involves Frontile. In agents that support invoking a skill directly, `/frontile` does it explicitly. ## Installing Project-level by default, which is usually what you want: the skill is committed with the project, so everyone working on it gets the same guidance. Use `--global` when only some of your projects use Frontile and you would rather not carry it in every agent's context. ```bash npx skills add josemarluedke/frontile # this project npx skills add josemarluedke/frontile --global # every project on this machine ``` The CLI writes into whichever directory each detected agent reads from. For Claude Code that is `.claude/skills/frontile`, and the files are plain Markdown you can open, edit, or commit. Target one agent rather than every detected one: ```bash npx skills add josemarluedke/frontile --agent claude-code npx skills add josemarluedke/frontile --agent cursor ``` Installing offers a second skill, `frontile-contributor-docs`. That one is for work inside the Frontile repository itself, writing the component documentation that lives beside the source, and is of no use in an app that consumes Frontile. To skip the prompt: ```bash npx skills add josemarluedke/frontile --skill frontile ``` ## Keeping it current Updates are manual. The CLI pulls; nothing is pushed to you: ```bash npx skills update ``` Worth running after upgrading Frontile, particularly across a minor version. An installed skill can sit arbitrarily far behind the version in your `package.json`, or ahead of it if you installed while tracking the development branch. A stale skill costs you guidance rather than correctness: the facts that decide whether generated code compiles come from your own `node_modules`. ## Without installing anything Everything the skill points at is fetchable directly, and [`/llms.txt`](/llms.txt) states the same lookup order in its preamble, so an agent pointed at the index alone arrives at the same place. See [LLMs.txt](./llms-txt.md). ## Found a problem? If the skill steers an agent wrong, that is a bug in the skill. [Open an issue](https://github.com/josemarluedke/frontile/issues). ## Related - [Agent Skills specification](https://agentskills.io/home) - [Claude Agent Skills](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) - [`skills` CLI](https://skills.sh) --- # Theme System Source: /docs/theming/overview.md # Theme System Frontile's theme system is an accessible design foundation built on semantic colors, a surface system, and automatic dark mode support. ## Installation Install the theme package using Ember CLI: ```bash ember install @frontile/theme ``` ## Configuration ### Using Default Theme Add this to your `app/styles/app.css`: ```css title="app/styles/app.css" @import 'tailwindcss' source('../../'); @plugin "@frontile/theme/plugin/default"; @import "@frontile/theme"; ``` ### Custom Theme Configuration Create `frontile.js` in your project root: ```js title="frontile.js" const { frontile } = require('@frontile/theme/plugin'); module.exports = frontile({ /* your configuration */ }); ``` Then update `app/styles/app.css`: ```css title="app/styles/app.css" @import 'tailwindcss' source('../../'); @plugin "./../../frontile.js"; @import "@frontile/theme"; ``` ## Key Concepts ### Semantic Colors Meaningful color categories (neutral, primary, success, danger, warning) with intuitive levels (subtle, soft, firm, strong) that adapt between light and dark themes. ### Surface System Flexible depth system with opaque surface roles and two families of translucent veils — overlay (recedes) and lift (advances), each with subtle, soft, mild, firm, strong. ### Dark Mode Automatic theme adaptation with color inversion. Toggle the `dark` class to switch between light and dark modes. ## Quick Example Frontile components resolve semantic colors automatically: ```gts preview import { Button } from 'frontile'; ``` The Button already resolves its own semantic colors, so it adapts to light and dark themes without additional code. Under the hood the `primary` color provides the emphasis, while `on-primary` calculates the optimal contrasting text color (black or white) for accessibility — the same tokens you'd reach for on your own markup: ```gts preview ``` ## Theme Switching Toggle between light and dark themes by adding or removing the `dark` class on a parent element (typically `` or ``): ```html ``` Remove the `dark` class to revert to light theme. You can also explicitly use the `light` class if needed. ### How Theme Classes Work Frontile uses custom CSS variants that apply theme-specific styles: - **`.dark`** - When present on a parent element, all descendant elements use dark theme colors - **`.light`** - When present on a parent element, all descendant elements use light theme colors (default) - **`.theme-inverse`** - Inverts the current theme for a section and its descendants The theme system uses CSS selectors to ensure proper inheritance: ```css /* Light theme applies to .light elements and .theme-inverse within .dark */ .light *, .dark .theme-inverse * /* Dark theme applies to .dark elements and .theme-inverse within .light */ .dark *, .light .theme-inverse * ``` This means you can nest themes and use theme-inverse sections anywhere in your application, and the colors will automatically adapt. ## Theme Inverse Create sections with inverted theme colors using the `theme-inverse` utility class. In light mode, these sections display dark theme colors, and vice versa. ```gts preview import { Button } from 'frontile'; ``` [Learn more about theme-inverse →](configuration/theme-switching.md) ## Color System Frontile provides six semantic color categories: - **Neutral** - Default interface colors - **Primary** - Primary brand colors (blue) - **Accent** - Visual emphasis and highlights (violet) - **Success** - Positive states and actions (green) - **Danger** - Errors and destructive actions (red) - **Warning** - Warnings and cautions (orange) Each category spans two bands: a **surface band** of fills (`subtle`, `muted`, `soft`, `mild`, `DEFAULT`, `firm`) and an **ink band** of legible foregrounds (`strong`, `bolder`). The `on-{color}-{level}` prefix automatically provides optimal contrasting text colors (black or white) for accessibility. ### How Colors Adapt to Themes Colors automatically adapt based on the current theme (`.dark`, `.light`, or `.theme-inverse`). Frontile uses CSS variables that change their values based on the theme context: ```css /* Example: primary adapts to the theme */ .light { --color-primary: oklch(55.86% 0.2094 263.84); /* Vivid teal in light mode */ } .dark { --color-primary: oklch(69.83% 0.1526 252.37); /* Lighter teal in dark mode */ } ``` When you use `bg-primary`, the actual color value comes from the CSS variable, which automatically switches based on whether the element is in a `.light` or `.dark` context. ### Using Colors in Your CSS You can use semantic colors in custom CSS using the Tailwind `theme()` function or CSS variables: ```css /* Using Tailwind theme function */ .my-component { background-color: theme(colors.primary.DEFAULT); color: theme(colors.on-primary.DEFAULT); } /* Using CSS variables directly */ .my-component { background-color: var(--color-primary); color: var(--color-on-primary); } ``` ### Customizing Colors You can customize semantic colors using the JavaScript plugin configuration: ```js const { frontile } = require('@frontile/theme/plugin'); module.exports = frontile({ themes: { light: { colors: { primary: { subtle: '#eff6ff', soft: '#93c5fd', DEFAULT: '#3b82f6', strong: '#1e40af' } } }, dark: { colors: { primary: { subtle: '#1e3a8a', soft: '#3b82f6', DEFAULT: '#60a5fa', strong: '#dbeafe' } } } } }); ``` [Learn more about semantic colors →](design-tokens/colors.md) ## Surface System The Surface system provides two types of backgrounds: - **Surface Solid** - Opaque base layers (0-11 scale) - **Surface Overlay** - Translucent layers that recede into the page (subtle, soft, mild, firm, strong) - **Surface Lift** - Translucent layers that float above the page (subtle, soft, mild, firm, strong) [Learn more about the surface system →](design-tokens/surfaces.md) ## Basic Customization ### Customizing Colors Override semantic colors in your configuration: ```js const { frontile } = require('@frontile/theme/plugin'); module.exports = frontile({ themes: { light: { colors: { primary: { subtle: '#eff6ff', soft: '#93c5fd', DEFAULT: '#3b82f6', strong: '#1e40af' } } }, dark: { colors: { primary: { subtle: '#1e3a8a', soft: '#3b82f6', DEFAULT: '#60a5fa', strong: '#dbeafe' } } } } }); // Note: on-{color}-{level} classes are automatically generated // based on the background colors you define above ``` ### Setting Default Theme Specify which theme loads by default: ```js module.exports = frontile({ defaultTheme: 'light', // or 'dark' themes: { // ... your themes } }); ``` ## Advanced Configuration ### Customizing with CSS Variables The recommended approach for Tailwind v4 is to customize theme values directly using CSS variables. This provides fine-grained control without JavaScript configuration and better performance. #### Override Theme Defaults Add your customizations in your `app/styles/app.css` after importing the theme: ```css title="app/styles/app.css" @import 'tailwindcss' source('../../'); @plugin "@frontile/theme/plugin/default"; @import "@frontile/theme"; /* Override Tailwind theme utilities */ @theme { /* Custom border radius values */ --radius: 0.75rem; /* One knob: scales the whole rounded-* scale */ --radius-pill: 2rem; /* Custom pill radius (absolute, does not scale) */ /* Custom opacity values */ --opacity-hover: .9; --opacity-disabled: .4; } /* Override component-specific values */ :root { /* Modal sizes */ --modal-md: 32rem; --modal-lg: 48rem; /* Drawer sizes */ --drawer-md: 32rem; --drawer-lg: 48rem; } ``` #### Theme-Specific Customization Customize layout values for specific themes using the `.dark` and `.light` classes. **Important:** To properly target all instances of a theme including `.theme-inverse` sections, use both selectors: ```css @import "@frontile/theme"; /* Light theme customization - targets .light AND .theme-inverse within .dark */ .light, .dark .theme-inverse { --opacity-hover: .85; } /* Dark theme customization - targets .dark AND .theme-inverse within .light */ .dark, .light .theme-inverse { --opacity-hover: .75; --modal-lg: 56rem; } ``` **Why both selectors?** - `.light` targets elements in light mode - `.dark .theme-inverse` targets inverted sections within dark mode (which display light theme) - This ensures your customizations apply consistently across both regular theme usage and theme-inverse sections > **Note:** Variables in the `@theme` block automatically generate Tailwind utilities (e.g., `--radius-xl` creates `rounded-xl` class). Variables in `:root` or theme-specific selectors are for component-specific values that don't need utility classes. **Colors should be customized using the JavaScript plugin configuration, not CSS variables.** ### JavaScript Configuration You can also configure the theme using JavaScript by creating a `frontile.js` file in your project root: ```js title="frontile.js" collapsible const { frontile } = require('@frontile/theme/plugin'); module.exports = frontile({ defaultTheme: 'dark', themes: { light: { colors: { // Customize semantic colors primary: { subtle: '#eff6ff', soft: '#93c5fd', DEFAULT: '#3b82f6', strong: '#1e40af' } }, layout: { // Customize layout values opacity: { hover: .85, disabled: .4 }, radius: { DEFAULT: '0.75rem', pill: '2rem' } } }, dark: { colors: { primary: { subtle: '#1e3a8a', soft: '#3b82f6', DEFAULT: '#60a5fa', strong: '#dbeafe' } } } } }); ``` Then reference it in your CSS: ```css @import 'tailwindcss' source('../../'); @plugin "./../../frontile.js"; @import "@frontile/theme"; ``` ## Best Practices ### Use Semantic Colors Always prefer semantic color classes over generic Tailwind utilities: ```hbs {{! Good - uses semantic colors }} {{! Avoid - hard-coded colors don't adapt to theme }} ``` ### Use Surface System for Backgrounds Use the surface system for component backgrounds: ```hbs {{! Good - uses a surface role for the base }}
{{! Good - uses overlay for elevated surface }}
Card content
``` ### Test in Both Modes Always verify your UI in both light and dark modes: ```html ``` --- # Component Styles Source: /docs/theming/component-styles.md # Customizing Component Styles Frontile components can be customized with Tailwind Variants, either globally or per-instance. This guide covers both approaches. ## Overview Frontile uses [Tailwind Variants](https://www.tailwind-variants.org/) to manage and customize component styles. Tailwind Variants provide a structured way to define different visual representations (or variants) for components while keeping your styles consistent and maintainable. A **slot** represents a specific part of a component that can be styled or customized separately. Slots allow you to apply different styles to different parts of a complex component, providing more granular control. When customizing styles in Frontile, you can either apply styles globally using `registerCustomStyles` or customize individual components using class arguments. You can override default component styles by passing your own class names to the `class` or `classes` argument, depending on whether the component has slots. ## DOM Anatomy: `data-component` and `data-part` Every Frontile component renders a stable, documented DOM anatomy, independent of its CSS classes. Two attributes carry it: - **`data-component=""`** — the kebab-cased `tv()` config name (e.g. the `notificationCard` config produces `notification-card`), written on the component's **outermost rendered element only**. This is the name the component is registered under, not its filename — `commandDialog` → `command-dialog`. - **`data-part=""`** — the kebab-cased `tv()` slot key, written on **every** element that renders a slot (`startContent` → `start-content`). Slot names are exactly the keys you already pass to `@classes`, so if you can style a part with `@classes={{hash startContent='...'}}`, you can also select it with `[data-part="start-content"]`. The root element's part is usually `base`, but not always — it carries whichever slot it actually renders. `Table`'s root is its wrapper `
`, so that element is `data-part="wrapper"`; `SimpleTable` used on its own roots on the `` element, so that one is `data-part="table"`. `ProgressBar`'s outer `
` renders no slot at all, so it has `data-component="progress-bar"` and no `data-part`. Check the component's page if you need the exact shape. Both attributes can be overridden: a `data-component` or `data-part` you pass to a component wins over the one it would render itself. A nested component's root legitimately carries **both** its own `data-component` and a `data-part` belonging to its parent. `CloseButton` rendered inside `Alert`, for instance, is simultaneously `data-component="close-button"` (its own root) and `data-part="close-button"` (the part it fills in `Alert`'s anatomy). Two components can also render the same `data-component` value: `TabNav` is a second renderer of the `tabs` config, alongside `Tabs` itself. ### Selecting by anatomy Scope a selector to one component's own parts with: ```css [data-component="modal"] [data-part="header"] { /* ... */ } ``` ### Known limitation: a nested component can share a part name `[data-component="x"] [data-part="y"]` is a plain CSS descendant combinator — it matches **any** descendant, not just `y` parts that belong directly to `x`. That's ambiguous whenever a nested component happens to have a part with the same name. `Alert` renders a `CloseButton`, and `CloseButton` has its own `icon` part (its SVG); `Alert` also has its own `icon` part (the alert's leading icon). `[data-component="alert"] [data-part="icon"]` matches both — the alert's own icon *and* the close button's icon glyph, because the close button is a descendant of the alert's root. What you usually mean is "the parts whose nearest `[data-component]` ancestor is `x`". CSS has no "nearest enclosing" combinator, so in a stylesheet you narrow the selector yourself, using child combinators down the path you want: ```css /* The alert's own icon. The close button's icon is nested one level deeper, inside the close button, so it doesn't match. */ [data-component="alert"] > [data-part="inner"] > [data-part="icon"] { /* ... */ } ``` In tests, use the `ownParts(root, part)` helper from `frontile/test-support`, which returns only the parts belonging to `root` itself: ```ts import { ownParts } from 'frontile/test-support'; // Only Alert's own `icon` part, not CloseButton's. const icons = ownParts(alertRootElement, 'icon'); ``` ### Not `data-slot` If you're coming from shadcn/ui, Nuxt UI, or HeroUI, this convention will look familiar but the attribute name differs — those use `data-slot`, Frontile uses `data-part`. In Ember, "slot" already means a named block (`{{yield to="title"}}`), which is a different thing from a styleable piece of the DOM. `@frontile/forms-legacy` predates this convention and does not carry these attributes. ## Customizing Styles ### Setting Up Global Customization To apply global styles to your Frontile components, it's recommended to create a separate file for your theme settings. You can create a file named `app/theme.js` to register your global custom styles, and then import it in `app/app.js` to ensure the styles are applied across your application. #### Example: Setting Up Global Styles 1. **Create a Theme File**: Create a new file called `app/theme.js` and add your global customizations. ```javascript // app/theme.js import { useStyles, registerCustomStyles, tv } from '@frontile/theme'; const components = useStyles(); registerCustomStyles({ // stuff ... }); ``` 2. **Import the Theme File**: Import the theme file in `app/app.js` to ensure the global styles are applied. ```javascript // app/app.js import Application from '@ember/application'; // stuff... import './theme'; // Import your theme file here export default class App extends Application { // stuff... } loadInitializers(App, config.modulePrefix); ``` ### Global Customization with `registerCustomStyles` The `registerCustomStyles` function allows you to override the default styles of Frontile components globally. This means that every instance of a specific component throughout your application will inherit the styles defined in `registerCustomStyles`. #### Example: Global Customization To globally change the styles of the Drawer, Modal, and Button components: ```javascript import { useStyles, registerCustomStyles, tv } from '@frontile/theme'; const components = useStyles(); registerCustomStyles({ modal: tv({ extend: components.modal, slots: { header: 'font-header text-2xl pt-12 pl-6 pb-6', body: 'pt-4 pb-4 pl-6 pr-12', footer: 'bg-transparent py-4 px-12 pb-8' }, variants: { size: { lg: 'max-w-[48rem]', xl: 'max-w-[64rem]' } } }), drawer: tv({ extend: components.drawer, slots: { header: 'text-2xl px-4 py-6 bg-black text-white dark:bg-white dark:text-black rounded-none', footer: 'px-4 py-6' }, variants: { size: { lg: 'max-w-[48rem]', xl: 'max-w-[64rem]' } } }), button: tv({ ...components.button, base: ['font-header text-xl'], variants: { ...components.button.variants, appearance: { ...components.button.variants.appearance, default: 'shadow-elevation-2' } }, compoundVariants: [ ...components.button.compoundVariants, { appearance: 'default', intent: 'default', class: 'bg-black text-white hover:bg-black/80 dark:bg-white dark:text-black dark:hover:bg-white/80' } ] }) }); ``` In this example: - The `modal` component is customized globally, including changes to the header, body, and footer slots, and additional size variants. - The `drawer` component is updated with new styles for the header and footer slots, as well as size variants. - The `button` component's base styles and compound variants are customized, adding new default appearances. Use global customization when you want consistency across your entire application for a particular component. ### Local Customization with Component Arguments If you only need to customize the styles of a specific component instance, use arguments like `class` or `classes`, depending on the component. Frontile uses Tailwind Variants with `tw-merge` to merge Tailwind classes, so any class passed locally overwrites the default styles. #### Example: Local Customization with Drawer Component ```hbs ``` In this example, the `Drawer` component is provided with specific styles for the `header`, `body`, and `footer` slots. The `header` and `footer` have a dark background (`bg-neutral-strong`) with automatically contrasting text (`text-on-neutral-strong`), while the `body` has a light background (`bg-neutral-subtle`). This approach allows for context-specific customizations without impacting other instances of the Drawer component. --- # Design Tokens Overview Source: /docs/theming/design-tokens/overview.md # Design Tokens Frontile extends Tailwind CSS with additional design tokens for building consistent, accessible user interfaces. These tokens automatically generate Tailwind utility classes. ## What are Design Tokens? Design tokens are named entities that store visual design attributes. They provide a consistent language for design decisions across your application. Frontile's tokens use Tailwind v4's `@theme` directive to define CSS variables that automatically generate utility classes. For example, defining `--radius: 8px` in your theme automatically generates the `rounded` utility class. ## Token Categories Frontile adds these design token categories to Tailwind: ### [Colors](colors.md) Semantic color system with five categories (neutral, primary, success, danger, warning) that automatically adapt between light and dark themes. **Generated utilities:** `bg-primary`, `text-danger-strong`, `border-success-soft` ### [Typography](typography.md) Text styles with semantic sizing across six categories (marquee, header, body, code, caption, label) built on a modular scale. **Generated utilities:** `text-header-lg`, `text-body-md`, `text-label-sm` ### [Icons](icons.md) Consistent icon sizing scale that pairs harmoniously with typography. **Generated utilities:** `size-icon-sm`, `size-icon-md`, `size-icon-lg` ### [Borders](borders.md) Border widths and radius tokens for consistent UI styling. **Generated utilities:** `border-thin`, `border-heavy`, `rounded-xl`, `rounded-pill` ### [Elevation](elevation.md) Shadow system for creating visual depth with six elevation levels (0-5). **Generated utilities:** `shadow-elevation-1`, `shadow-elevation-3` ### [Surfaces](surfaces.md) Background color system for creating distinct surface layers in your UI. **Generated utilities:** `bg-surface-app`, `bg-surface-modal`, `bg-surface-overlay-soft`, `bg-surface-lift-soft` ## How Tokens Work Frontile uses Tailwind v4's `@theme` directive to define tokens as CSS variables: ```css @theme { --radius: 8px; /* Generates: rounded */ --radius-xl: 12px; /* Generates: rounded-xl */ --size-icon-md: 16px; /* Generates: size-icon-md */ } ``` These variables: - Automatically generate Tailwind utility classes - Can be customized globally or per-theme - Maintain consistency across your application - Support light and dark theme variations ## Standard Tailwind Utilities For layout, spacing, flexbox, grid, and other standard utilities, refer to the [Tailwind CSS documentation](https://tailwindcss.com/docs). Frontile uses Tailwind as-is for these utilities and only documents the design tokens it adds. --- # Colors Source: /docs/theming/design-tokens/colors.md # Semantic Colors Frontile's semantic color system provides meaningful, accessible colors that adapt automatically between light and dark themes. ## Overview Instead of using arbitrary gray scales like `bg-gray-300`, Frontile's semantic colors use meaningful names that describe their purpose and visual weight. Each color automatically adapts between light and dark themes, ensuring consistent contrast and accessibility. ## Color Levels Every category exposes the same set of levels, organized into **two bands**. The bands share one emphasis vocabulary but are consumed by different CSS properties — the band a level belongs to tells you what it's for. | Band | Levels (low → high emphasis) | Use for | | ----------- | ----------------------------------------------------------- | ------------------------------------------------ | | **Surface** | `subtle` · `muted` · `soft` · `mild` · `DEFAULT` · `firm` | Fills — `bg-*` and decorative `border-*` | | **Ink** | `strong` · `bolder` | Legible foregrounds — `text-*`, outlined borders | - **`DEFAULT`** is the resting fill. It has no suffix, so the bare class works: `bg-primary` is the DEFAULT fill, `text-primary` the DEFAULT-level text. - **`mild`** sits between `soft` and `DEFAULT` — a lower-emphasis fill for cases where `soft` reads too faint but the full resting fill is too strong. - **`firm`** is the most emphatic _fill_ (e.g. a pressed background). It sits at the top of the surface band, below the ink band it never competes with — `firm` is a background, `strong`/`bolder` are text, so they live in different property slots even though they share the ladder. - **`on-{category}-{level}`** is the auto-generated black/white text that meets WCAG contrast on that fill (e.g. `text-on-primary` on `bg-primary`). ### Names are ranks, not brightness A level name describes **emphasis rank**, never a specific lightness. The same token can be dark in light mode and light in dark mode, because interaction direction inverts between schemes (light mode lightens on hover and darkens on press; dark mode does the reverse). A brightness word like "deep" or "light" would be correct in one theme and wrong in the other, so the vocabulary stays abstract. ### Why two bands instead of one numbered scale Tailwind maps one token name to exactly one color value, reused across every property (`bg-primary`, `text-primary`, and `border-primary` are always the same color). A fill and the text that must stay legible on top of it are different colors, so they can't share a single rung — they need two names. The band split makes that boundary explicit instead of hiding it inside a flat `50…900`-style scale. ## Color Categories ### Neutral **Usage Examples:** Text colors: - `text-neutral-firm` - Secondary text for descriptions and metadata - `text-neutral-strong` - Default text color for body content - `text-neutral-bolder` - Maximum emphasis for headings Background colors with automatic text colors: - `bg-neutral-subtle` with `text-on-neutral-subtle` - Subtle background for hover states - `bg-neutral-soft` with `text-on-neutral-soft` - Soft background for cards - `bg-neutral` with `text-on-neutral` - Medium emphasis background - `bg-neutral-strong` with `text-on-neutral-strong` - Strong emphasis background ### Primary **Usage Examples:** ```gts preview collapsible ``` Banners: ```gts preview ``` ### Accent **Usage Examples:** ```gts preview ``` ### Success **Usage Examples:** ```gts preview ``` ### Danger **Usage Examples:** ```gts preview ``` ### Warning **Usage Examples:** ```gts preview ``` ## Choosing the Right Color Level Pick from the band that matches the property you're setting. ### Backgrounds & decorative borders (surface band) - **`subtle`**: Hover states, selected rows, tonal/hairline backgrounds - **`muted`**: Hover on tonal fills, chip and close-button backgrounds - **`soft`**: Card backgrounds, secondary surfaces, the hover step for solid fills - **`DEFAULT`** (bare `bg-{color}`): Primary buttons, badges, tags, resting borders - **`firm`**: Pressed/active backgrounds ### Text & outlined borders (ink band) - **`strong`**: Default body text, readable content, outlined-control text and borders - **`bolder`**: Headings, hover/active text, highest-emphasis foregrounds - **`on-{color}-{level}`**: Automatic contrasting text on a colored fill (e.g., `text-on-primary` on `bg-primary`) > **Note:** Surface-band levels (`subtle` … `firm`) are fills, not text colors. > In dark mode they map to values that are illegible as text on dark surfaces. > For visible text use an ink-band level (`strong`/`bolder`) or the matching > `on-{color}` token. ## Accessibility All color combinations in Frontile meet WCAG 2.1 Level AA contrast requirements (minimum 4.5:1 contrast ratio). ### Automatic "On-" Colors Frontile automatically generates optimal contrasting text colors for every background color using WCAG contrast calculations. Use the `on-` prefix with the same color level: ```html ``` **How it works:** 1. Frontile calculates the relative luminance of each background color 2. Determines contrast ratio with both black and white text 3. Selects the color with better contrast (≥4.5:1 ratio) 4. Generates the appropriate `on-{color}-{level}` class automatically **Benefits:** - No need to manually determine text colors - Always meets WCAG AA standards - Automatically adapts to theme changes (light/dark mode) - Works with custom theme colors ### Customizing On-Colors While auto-generated on-colors work for most cases, you can override them for brand consistency or specific design requirements: ```js const { frontile } = require('@frontile/theme/plugin'); module.exports = frontile({ themes: { light: { colors: { primary: { subtle: '#eff6ff', soft: '#93c5fd', DEFAULT: '#3b82f6', strong: '#1e40af' }, // Override specific on-colors 'on-primary': { DEFAULT: '#ffffff', // Force white text on the bare primary fill strong: '#e0f2fe' // Use light blue instead of auto-generated white } // on-primary-subtle and on-primary-soft are still auto-generated } } } }); ``` Partial overrides are supported — only define the levels you want to customize, and the rest will be auto-generated as usual. This works for all semantic color categories: `on-neutral`, `on-primary`, `on-secondary`, `on-tertiary`, `on-success`, `on-warning`, `on-danger`, and `on-surface-modal`. > **Note:** If a color value is a CSS variable reference (e.g., `var(--my-color)`), auto-generation is skipped for that color since contrast cannot be calculated at build time. ## Migrating from Old Colors If you're upgrading from an older version of Frontile, see the [Semantic Colors v2 Migration Guide](../../migrations/v0.18/semantic-colors.md) for detailed instructions on updating your code. --- # Typography Source: /docs/theming/design-tokens/typography.md # Typography System Frontile's typography system is a scalable type system built on semantic text styles and a modular scale. It uses Tailwind v4's `@theme` directive for base tokens and `@layer utilities` for explicit utility classes. ## Overview The typography system includes: - **7 text style categories**: Marquee, Header, Strong, Body, Code, Caption, and Label - **Multiple size variants** for each category - **Modular scale** based on a mathematical ratio for harmonic sizing - **Composite utility classes** that bundle all typography properties together - **Full customization** through CSS variables ## Text Style Categories ### Marquee Large, semibold serif display text (Domine) for hero sections and prominent headings. ```gts preview ``` **Available sizes:** `5xs`, `4xs`, `3xs`, `2xs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl` **Font:** Domine (serif) · **Weight:** semibold · **Best for:** Hero sections, landing page titles, major marketing headings ### Header Semantic headings for content hierarchy with bold weight; larger sizes (`lg` and up) tighten tracking and leading. ```gts preview ``` **Available sizes:** `4xs`, `3xs`, `2xs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl` **Best for:** Content headings (h1-h6), section titles, hierarchical structure ### Strong Emphasis text for numerics, prices, and metrics. Shares the Header size, tracking, and leading scale but is a distinct role so it can be styled independently. ```gts preview ``` **Available sizes:** `4xs`, `3xs`, `2xs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl` **Font:** Open Sans · **Weight:** bold · **Best for:** Prices, counts, metrics, any numeric emphasis ### Body Standard body text with spacious line height optimized for readability. ```gts preview collapsible ``` **Available sizes:** `5xs`, `4xs`, `3xs`, `2xs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl` **Best for:** Paragraphs, article content, descriptions, general text ### Code Monospace text for code snippets and technical content. ```gts preview ``` **Available sizes:** `sm`, `md` **Best for:** Code blocks, inline code, technical identifiers ### Caption Secondary descriptive text with relaxed letter spacing. ```gts preview ``` **Available sizes:** `sm`, `md` **Best for:** Image captions, figure labels, supplementary text ### Label UI labels and form labels with semibold weight and tight (100%) leading. ```gts preview ``` **Available sizes:** `nano`, `micro`, `2xs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl` **Best for:** Form labels, UI labels, buttons, navigation items, badges ## Available Utilities All text style utilities require both a font-family utility and a sized text style utility: ```html

...

...

...

... ... ``` ## Combining with Other Utilities Typography utilities work with other Tailwind utilities: ```gts preview ``` ## How It Works The typography system uses Tailwind v4's `@theme` directive to automatically generate utilities from CSS variables: 1. **Base tokens** defined in `@theme` automatically generate individual utilities: - `--font-header` → `font-header` utility - `--font-size-14` → `text-14` utility - `--font-weight-bold` → `font-bold` utility - `--letter-spacing-tight` → `tracking-tight` utility - `--font-weight-semibold` → `font-semibold` utility 2. **Composite text style tokens** generate size/weight/spacing utilities (but NOT font-family): - `--text-header-md` → `text-header-md` utility (sets size, weight, spacing, line-height) - `--text-body-lg` → `text-body-lg` utility (sets size, weight, spacing, line-height) **Usage pattern:** Combine font-family with text style for complete typography: ```html

Heading Text

Body text with larger size

Small label

Heading Text

``` The separation of font-family from text styles provides flexibility to mix font families with different text styles while maintaining consistent sizing, weight, spacing, and line-height. ## Customization ### Using CSS Variables Customize typography by overriding CSS variables in your app's CSS file: ```css @import "@frontile/theme"; /* Override font families */ @theme { --font-header: "Inter", system-ui, sans-serif; --font-body: "Inter", system-ui, sans-serif; --font-code: "JetBrains Mono", monospace; } ``` ### Theme-Specific Typography Customize for light and dark themes using theme selectors: ```css /* Light theme customization */ .light, .dark .theme-inverse { --font-body: "Inter", system-ui, sans-serif; } /* Dark theme customization */ .dark, .light .theme-inverse { --font-body: "Geist", system-ui, sans-serif; } ``` ## Using Custom Fonts Add custom fonts by declaring them with `@font-face` and overriding the font family variables: ```css @font-face { font-family: 'Inter'; src: url('/fonts/inter-var.woff2') format('woff2'); font-weight: 100 900; font-display: swap; } @theme { --font-header: 'Inter', system-ui, sans-serif; --font-body: 'Inter', system-ui, sans-serif; --font-label: 'Inter', system-ui, sans-serif; } ``` ## Best Practices ### Use Semantic Text Styles Always prefer semantic text style utilities over combining individual properties: ```gts preview ``` ### Maintain Visual Hierarchy Use size variants to create clear content hierarchy: ```gts preview ``` ### Responsive Typography Scale typography for different screen sizes: ```gts preview ``` --- # Icon Sizes Source: /docs/theming/design-tokens/icons.md # Icon Sizes Frontile provides an icon sizing scale that pairs with typography for consistent visual alignment. ## Overview Icon sizes ensure visual consistency between icons and text throughout your interface. They use the same modular scale as typography, making it easy to pair icons with text styles. The scale ranges from `pico` (smallest) to `mega` (largest), providing 14 distinct sizes for different UI contexts. ## Using Icon Sizes Apply icon sizes using the `size-icon-*` utilities, which set both width and height: ```gts preview import { StarIcon } from 'site/components/icons'; ``` ## Available Sizes | Size | Utility | Usage Context | |------|---------|---------------| | Pico | `size-icon-pico` | Extremely small UI elements | | Nano | `size-icon-nano` | Tiny decorative icons | | Micro | `size-icon-micro` | Very small inline icons | | 3XS | `size-icon-3xs` | Extra extra small icons | | 2XS | `size-icon-2xs` | Extra small icons | | XS | `size-icon-xs` | Small inline icons | | SM | `size-icon-sm` | Small UI icons | | MD | `size-icon-md` | Default icon size | | LG | `size-icon-lg` | Large prominent icons | | XL | `size-icon-xl` | Extra large icons | | 2XL | `size-icon-2xl` | Double extra large icons | | 3XL | `size-icon-3xl` | Triple extra large icons | | Kilo | `size-icon-kilo` | Very large decorative icons | | Mega | `size-icon-mega` | Largest decorative icons | ## All Icon Sizes Here are all available icon sizes with visual examples: ```gts preview collapsible import { CheckIcon } from 'site/components/icons'; ``` ## Pairing with Typography Icon sizes are designed to pair harmoniously with text styles. Use these recommended pairings for visual alignment: ```gts preview import { CheckIcon, ViewIcon, StarIcon } from 'site/components/icons'; ``` ### Recommended Pairings | Text Style | Recommended Icon Size | |------------|----------------------| | `text-body-sm` | `size-icon-xs` or `size-icon-sm` | | `text-body-md` | `size-icon-sm` or `size-icon-md` | | `text-body-lg` | `size-icon-md` or `size-icon-lg` | | `text-header-sm` | `size-icon-md` | | `text-header-md` | `size-icon-md` or `size-icon-lg` | | `text-header-lg` | `size-icon-lg` or `size-icon-xl` | | `text-header-xl` | `size-icon-xl` or `size-icon-2xl` | ## Customization Override icon sizes using CSS variables in your `@theme` block: ```css @import "@frontile/theme"; @theme { /* Adjust specific sizes */ --size-icon-sm: 14px; --size-icon-md: 18px; --size-icon-lg: 22px; } ``` For more customization options, see the [Configuration Guide](../configuration/customization.md). --- # Borders & Radius Source: /docs/theming/design-tokens/borders.md # Borders & Radius Frontile provides border width and radius tokens for consistent UI styling across your application. ## Border Widths Control border thickness with semantic width tokens. These tokens provide meaningful names that describe the visual weight of borders. ### Available Widths ```gts preview ``` | Width | Utility | Common Uses | |-------|---------|-------------| | Thin | `border-thin` | Subtle dividers, table borders, minimal separation | | Default | `border` | Standard card borders, input fields, default UI elements | | Heavy | `border-heavy` | Focus states, selected items, emphasized borders | | Aggressive | `border-aggressive` | High-contrast borders, debug indicators, strong emphasis | ### Border Width Examples ```gts preview ``` ## Border Radius Control corner rounding with radius tokens that range from sharp edges to fully rounded elements. Every step is a multiple of a single `--radius` base (`0.5rem` by default), so the scale is proportional rather than arbitrary. Changing `--radius` alone re-rounds the entire library — see [Scaling the whole system](#scaling-the-whole-system) below. ### Available Radii ```gts preview collapsible ``` | Radius | Utility | Multiplier | Default | Common Uses | |--------|---------|-----------|---------|-------------| | None | `rounded-none` | — | 0px | Sharp corners, technical interfaces, no rounding | | XS | `rounded-xs` | 0.25x | 2px | Hairline rounding on very small marks | | SM | `rounded-sm` | 0.5x | 4px | Keyboard shortcuts, checkboxes, tight layouts | | MD | `rounded-md` | 0.75x | 6px | Compact controls, inline chips | | Default | `rounded` | 1x | 8px | The base unit itself | | LG | `rounded-lg` | 1x | 8px | Menu items nested inside a popover | | XL | `rounded-xl` | 1.5x | 12px | Inputs, selects, popovers, dropdowns, notifications | | 2XL | `rounded-2xl` | 2x | 16px | Modals, drawers | | Default (Custom) | `rounded-default` | 2.5x | 20px | Tables, skeletons, soft friendly corners | | 3XL | `rounded-3xl` | 3x | 24px | Oversized surfaces | | 4XL | `rounded-4xl` | 4x | 32px | Hero-scale surfaces | | Pill | `rounded-pill` | — | 9999px | Pills, badges, fully rounded buttons | `rounded-none` and `rounded-pill` are absolutes: they stay 0 and 9999px no matter what `--radius` is set to. ## Combining Borders and Radius Use border widths and radius together to create various UI patterns: ```gts preview ``` ## Best Practices ### Maintain Consistency Use consistent border styles for similar UI elements throughout your application: - **Forms**: Use default border width with default or small radius - **Cards**: Use thin borders with medium to large radius - **Alerts**: Use thin to heavy borders with appropriate radius for the context - **Buttons**: Use default radius or pill for interactive elements ### Visual Hierarchy Use border weights to establish hierarchy: - **Thin**: Background elements, subtle divisions - **Default**: Primary UI elements - **Heavy**: Active states, selections, focus indicators - **Aggressive**: Critical states, errors, important boundaries ### Radius and Component Size Pair border radius with component size: - **Small components** (badges, small buttons): Use `rounded` or `rounded-sm` - **Medium components** (buttons, inputs): Use `rounded` or `rounded-md` - **Large components** (cards, panels): Use `rounded-lg` or `rounded-xl` - **Extra large components** (heroes, features): Use `rounded-xl` or `rounded-2xl` ## Customization Override border widths and radius using CSS variables: ```css @import "@frontile/theme"; @theme { /* Customize border widths */ --border-width-thin: 1px; --border-width-default: 1.5px; --border-width-heavy: 3px; /* Customize border radius — one knob for the whole scale */ --radius: 0.75rem; } ``` ### Scaling the whole system Because every step is `calc(var(--radius) * n)`, one value controls how round the entire library looks. Nothing else has to change: | `--radius` | Menus / inputs | Menu items | Modals | |-----------|----------------|-----------|--------| | `0` | 0px | 0px | 0px | | `0.25rem` | 6px | 4px | 8px | | `0.5rem` (default) | 12px | 8px | 16px | | `0.75rem` | 18px | 12px | 24px | | `1rem` | 24px | 16px | 32px | The same knob is available from the JavaScript plugin config, where it can also differ per theme: ```js frontile({ layout: { radius: { DEFAULT: '0.75rem' } } }); ``` Individual steps remain overridable when you need to break one out of the proportional scale: ```css @theme { --radius: 0.5rem; --radius-2xl: 28px; /* modals rounder than the scale would give */ } ``` For more customization options, see the [Configuration Guide](../configuration/customization.md). --- # Surfaces Source: /docs/theming/design-tokens/surfaces.md # Surface System The Surface system creates depth and hierarchy in your interfaces using opaque roles and two families of translucent veils. ## Overview The Surface system consists of three types: - **Surface Roles** - Semantic, opaque surface tokens for specific UI contexts (app, canvas, card, table, modal, input) - **Surface Overlay** (subtle, soft, mild, firm, strong) - Translucent layers that push an element _into_ its background - **Surface Lift** (subtle, soft, mild, firm, strong) - Translucent layers that pull an element _up off_ its background ## Surface Roles Surface roles provide semantic meaning to your surfaces based on their context in the UI hierarchy. Instead of choosing arbitrary scale values, use these semantic tokens that automatically adapt to the active theme. ### Available Roles #### App (`bg-surface-app`) Root application background layer (hierarchy level 0). **When to use:** - App shell root - Full-viewport base layer - Root layout behind navigation ```gts preview ``` #### Canvas (`bg-surface-canvas`) Component contrast baseline (hierarchy level 1). **When to use:** - Main content areas - Page containers that need contrast from app background - Component baseline surfaces ```gts preview ``` #### Card (`bg-surface-card`) Elevated card container surface (hierarchy level 1). Opaque white in light mode; in dark mode it is a translucent veil (white @ 7%, the same value as `overlay-subtle`), so whatever sits behind it shows through faintly. Where a surface has to stay opaque, use `surface-table` — the same colour without the transparency. **When to use:** - Product cards - Article previews - Content blocks ```gts preview ``` #### Table (`bg-surface-table`) Data table surface (hierarchy level 1). Usually the same color as a card, but must be opaque. Tables need that guarantee. A sticky header, a frozen row, or a pinned column paints over the rows and columns scrolling beneath it, and anything translucent lets them show through. **When to use:** - Data table containers, and the sticky parts inside them ```gts preview ``` #### Modal (`bg-surface-modal`) Modal, drawer, and popover container surface, highest elevation (hierarchy level 3). **When to use:** - Modal dialogs - Drawers - Popovers and dropdown menus - Confirmation prompts Pair it with `bg-surface-overlay-strong dark:bg-surface-lift-strong` for the backdrop behind modals and drawers. ```gts preview ``` #### Input (`bg-surface-input`) Form control surface for inputs, checkboxes, radios, and similar controls (hierarchy level -1). **When to use:** - Text inputs - Checkboxes and radios - Any native form control background ```gts preview ``` ### Hierarchy Levels Surface roles are designed to create visual depth through a hierarchy system: | Level | Role | Purpose | | ----- | ------------------- | -------------------------------------------------------- | | -1 | Input | Recessed below canvas | | 0 | App | Root application background | | 1 | Canvas, Card, Table | Component contrast baseline and first level of elevation | | 3 | Modal | Highest elevation (modals, drawers, popovers, dropdowns) | In **light mode**, elevated surfaces appear brighter (white) against gray backgrounds following the elevation-luminance principle. In **dark mode**, elevated surfaces appear progressively lighter against near-black backgrounds. ## Surface Overlay (subtle, soft, mild, firm, strong) Translucent overlays with alpha channel that stack on top of solid surfaces. These add depth through transparency rather than changing the base color. ### When to Use - **Sections within components**: Modal footers, card headers, dropdown sections - **Cards/panels on pages**: Elevated surfaces that float on top of the page background - **Hover states**: Subtle translucent effect on hover - **Progress bars, selection highlights**: Semi-transparent UI elements - **`strong`**: The heaviest step (75% in light, 95% in dark), far beyond a hover or elevation hint. In light mode this is the backdrop behind modals and drawers; in dark mode the black scrim is `lift-strong` instead, since the families mirror each other — see [Surface Lift](#surface-lift-subtle-soft-mild-firm-strong) ### Example: Modal with Overlay Footer ```hbs {{! Modal base uses the modal surface role }} <:default> Modal content on opaque base <:footer> Footer with subtle overlay effect ``` ### Example: Card on Page ```hbs {{! Page has a solid app background }}
{{! Card floats on top with translucent overlay }}

Card Title

Card content with depth

``` ### Stacking Overlays Overlays can stack on top of each other to create progressive depth: ```hbs
Layer 1 (subtle)
Layer 2 (soft)
Layer 3 (mild)
Layer 4 (firm)
``` ### Example: Modal/Drawer Backdrop ```hbs Modal content ``` ## Surface Lift (subtle, soft, mild, firm, strong) Lift is the mirror of overlay. Overlay darkens in light mode and lightens in dark mode, pressing an element _into_ the page. Lift does the opposite — a white veil on a light page, a black veil on a dark one — so the element reads as floating _above_ whatever it covers. Both families share the same level names, and both adapt to the active theme automatically: | Level | Light mode | Dark mode | | -------- | ----------- | ----------- | | `subtle` | white @ 30% | black @ 10% | | `soft` | white @ 50% | black @ 20% | | `mild` | white @ 70% | black @ 30% | | `firm` | white @ 90% | black @ 40% | | `strong` | white @ 95% | black @ 75% | Every lift level flips with the scheme, `strong` included. The two families are exact mirrors, which has one consequence worth knowing: the heavy black veil used as a modal backdrop is `overlay-strong` in light mode but `lift-strong` in dark mode. A component that wants a dark scrim in both schemes pairs them — `bg-surface-overlay-strong dark:bg-surface-lift-strong` — which is what Overlay's backdrop does. ### When to Use - **Frosted/glass panels**: Pair with `backdrop-blur-*` for a translucent panel that stays legible over busy content - **Sticky headers and toolbars**: Content scrolls under them without washing them out - **Floating controls over imagery**: Media captions, hero overlays, map controls - **Anything that must read as _above_ the page** rather than recessed into it Reach for `surface-overlay-*` instead when the element belongs to the surface it sits on — hover states, table stripes, section fills. ### Example: Frosted Sticky Header ```hbs
``` ### Example: Caption Over an Image ```hbs
Floats above the image in either theme
``` ### Example: Overlay vs Lift The same nesting, one family each — overlay recedes, lift advances: ```hbs
{{! Recedes into the page }}
Overlay
{{! Floats above the page }}
Lift
``` ## Choosing the Right Surface Follow this decision flow: 1. **Does this match a specific UI context?** - If yes → Use `surface-{role}` (app, canvas, card, table, modal, input) - Surface roles provide semantic meaning and automatically adapt to themes. 2. **Is this the base container and no role matches?** - Use `surface-app` for most cases. 3. **Does it need to be translucent?** - If yes → Continue to next step - If no → Use a surface role 4. **Should it read as recessed into the surface, or floating above it?** - Recessed (hover states, section fills, table stripes) → Use `surface-overlay-*` - Floating (frosted panels, sticky headers, captions over media) → Use `surface-lift-*` - If it's a modal/drawer backdrop → Use `surface-overlay-strong dark:surface-lift-strong` ## Common Patterns ### Pattern 1: Page Layout with Cards ```gts preview ``` ### Pattern 2: Sidebar Layout ```gts preview ``` ### Pattern 3: Form with Inputs ```gts preview ``` ## Migrating from content1-4 If you're upgrading from an older version that used `content1-4`, here's the migration mapping: | Old | New | Reason | | ------------------------------ | ----------------------------------- | --------------------------------- | | `bg-content1` | `bg-surface-app` | Base opaque surface | | `bg-content3 dark:bg-content2` | `bg-surface-overlay-soft` | Single value works in both themes | | `selection:bg-content3` | `selection:bg-surface-overlay-soft` | Translucent selection highlight | ## Best Practices ### Do - **Prefer surface roles** (`surface-canvas`, `surface-card`, etc.) over ad hoc colors when they match your UI context - Stack overlays on a surface role for depth - Use `overlay-strong` with `dark:lift-strong` for modal/drawer backdrops - Test in both light and dark modes - Follow the hierarchy levels for visual consistency ### Don't - Don't use overlay without a solid role beneath it - Don't mix surface system with old content1-4 - Don't use too many overlay layers (max 3-4) - Don't forget to check contrast ratios --- # Elevation Source: /docs/theming/design-tokens/elevation.md # Elevation Frontile provides an elevation system using shadows to create visual depth and hierarchy in your interface. ## Overview Elevation helps establish spatial relationships and visual hierarchy by making elements appear "closer" or "further" from the user. Frontile provides six elevation levels (0-5), each designed for specific UI contexts. Higher elevations indicate elements that are more prominent or interactive, such as floating menus, modals, and tooltips. ## Elevation Levels ```gts preview collapsible ``` ## When to Use Each Level | Level | Utility | Use Cases | Examples | | ----- | -------------------- | --------------------------------- | --------------------------------------------------------- | | 0 | `shadow-elevation-0` | Flat elements, no depth needed | Inline content, flat buttons, embedded content | | 1 | `shadow-elevation-1` | Subtle separation from background | Hover states, subtle cards, list items | | 2 | `shadow-elevation-2` | Standard raised surfaces | Cards, panels, tiles, navigation bars | | 3 | `shadow-elevation-3` | Floating elements above content | Dropdowns, tooltips, popovers, date pickers | | 4 | `shadow-elevation-4` | Modal overlays | Modals, dialogs, drawers, sheets | | 5 | `shadow-elevation-5` | Maximum emphasis | Critical alerts, system notifications, top-layer elements | ## Common Use Cases ### Cards and Panels ```gts preview collapsible ``` ### Interactive States ```gts preview ``` ## Best Practices ### Establish Clear Hierarchy Use elevation consistently to indicate the z-axis position of elements: - **Base layer** (level 0-1): Page content, static elements - **Raised layer** (level 2): Cards, panels, navigation - **Floating layer** (level 3): Dropdowns, popovers, tooltips - **Overlay layer** (level 4-5): Modals, critical alerts ### Avoid Elevation Confusion Don't use high elevation for non-interactive or less important elements. Reserve levels 4-5 for elements that need to command attention. ### Transition Elevations Animate elevation changes for smooth interactions: ```html
Smooth elevation transition
``` ### Consider Dark Mode Shadows may be less visible in dark mode. Consider combining elevation with subtle borders: ```html
Enhanced depth with border
``` ## Accessibility ### Don't Rely on Shadows Alone Never use shadows as the only indicator of important information. Always provide additional visual cues: - Use borders for critical boundaries - Use semantic colors for status - Provide clear text labels - Ensure proper focus indicators ### Test in Different Lighting Shadows may be difficult to perceive: - On certain displays (high brightness, poor contrast) - For users with visual impairments - In different lighting conditions Always provide alternative visual cues beyond elevation. ## Customization Override elevation shadows using CSS variables: ```css @import '@frontile/theme'; @theme { /* Customize specific elevation levels */ --shadow-elevation-1: 0px 2px 4px rgba(0, 0, 0, 0.05); --shadow-elevation-2: 0px 4px 12px rgba(0, 0, 0, 0.1); --shadow-elevation-3: 0px 8px 24px rgba(0, 0, 0, 0.15); --shadow-elevation-4: 0px 16px 48px rgba(0, 0, 0, 0.2); } ``` ### Theme-Specific Shadows Adjust shadows for light and dark themes: ```css /* Light theme */ .light, .dark .theme-inverse { --shadow-elevation-2: 0px 4px 12px rgba(0, 0, 0, 0.1); } /* Dark theme */ .dark, .light .theme-inverse { --shadow-elevation-2: 0px 4px 12px rgba(0, 0, 0, 0.4); } ``` For more customization options, see the [Configuration Guide](../configuration/customization.md). --- # Configuration Overview Source: /docs/theming/configuration/overview.md # Configuration Learn how to configure and customize Frontile's theme system to match your application's design requirements. ## Overview Frontile provides two primary methods for configuring your theme: 1. **CSS Variables** - Recommended for most customizations 2. **JavaScript Configuration** - For complex theme setups and color customization ## CSS Variables (Recommended) The most straightforward way to customize Frontile is using CSS variables in your stylesheet. This approach uses Tailwind v4's `@theme` directive for simple, performant customization. ### Quick Start Add customizations after importing the theme in your `app/styles/app.css`: ```css title="app/styles/app.css" @import 'tailwindcss' source('../../'); @plugin "@frontile/theme/plugin/default"; @import "@frontile/theme"; @theme { /* Customize design tokens */ --radius: 12px; --border-width-default: 2px; --size-icon-md: 20px; --opacity-hover: .85; } ``` ### What You Can Customize Use CSS variables to customize: - **Border radius** - `--radius` (one knob; the whole `rounded-*` scale is derived from it), plus `--radius-pill` and individual steps as escape hatches - **Border widths** - `--border-width-thin`, `--border-width-heavy` - **Icon sizes** - `--size-icon-sm`, `--size-icon-md`, `--size-icon-lg` - **Shadows** - `--shadow-elevation-1`, `--shadow-elevation-2` - **Opacity** - `--opacity-hover`, `--opacity-disabled` - **Typography** - Font families, sizes, and text styles - **Component sizes** - Modal and drawer sizes ### Benefits of CSS Variables - **Simple** - No JavaScript configuration needed - **Fast** - Changes are applied at CSS level - **Flexible** - Can be changed at runtime - **Theme-specific** - Easy to customize per theme (light/dark) ### When to Use CSS Variables Choose CSS variables when you need to: - Override specific design token values - Adjust border radius, spacing, or sizing - Customize per-theme values (different in light vs dark) - Make runtime changes via JavaScript ## JavaScript Configuration For more complex customization, especially colors, use JavaScript configuration. This method provides programmatic control and type safety. ### Quick Start Create `frontile.js` in your project root: ```js title="frontile.js" const { frontile } = require('@frontile/theme/plugin'); module.exports = frontile({ defaultTheme: 'light', themes: { light: { colors: { primary: { subtle: '#eff6ff', soft: '#93c5fd', DEFAULT: '#3b82f6', strong: '#1e40af' } } }, dark: { colors: { primary: { subtle: '#1e3a8a', soft: '#3b82f6', DEFAULT: '#60a5fa', strong: '#dbeafe' } } } } }); ``` Then reference it in your CSS: ```css @import 'tailwindcss' source('../../'); @plugin "./../../frontile.js"; @import "@frontile/theme"; ``` ### What You Can Customize Use JavaScript configuration for: - **Semantic colors** - Primary, success, danger, warning, neutral - **Default theme** - Which theme loads by default - **Theme variants** - Create multiple theme variations - **Complex configurations** - Programmatic theme generation ### When to Use JavaScript Configuration Choose JavaScript configuration when you need to: - Customize semantic colors (primary, success, danger, etc.) - Set the default theme (light or dark) - Create multiple theme variants - Generate themes programmatically - Share configuration across projects ## Comparison | Feature | CSS Variables | JavaScript Config | |---------|---------------|-------------------| | **Border radius** | ✅ Recommended | ❌ Not needed | | **Semantic colors** | ❌ Use JS instead | ✅ Recommended | | **Icon sizes** | ✅ Recommended | ❌ Not needed | | **Elevation shadows** | ✅ Recommended | ❌ Not needed | | **Typography** | ✅ Recommended | ❌ Not needed | | **Default theme** | ❌ Not possible | ✅ Use JS | | **Runtime changes** | ✅ Easy | ❌ Requires rebuild | | **Per-theme values** | ✅ Simple | ⚠️ More complex | ## Common Patterns ### Pattern 1: Simple Adjustments For minor adjustments to existing tokens, use CSS variables: ```css @theme { --radius: 6px; /* scales every rounded-* step down proportionally */ --opacity-hover: .9; } ``` ### Pattern 2: Custom Primary Colors For primary color customization, use JavaScript: ```js module.exports = frontile({ themes: { light: { colors: { primary: { subtle: '#f0f9ff', soft: '#7dd3fc', DEFAULT: '#0ea5e9', strong: '#0c4a6e' } } } } }); ``` ### Pattern 3: Mixed Approach Combine both methods for comprehensive customization: ```js // frontile.js - Customize colors module.exports = frontile({ themes: { light: { colors: { /* custom colors */ } } } }); ``` ```css /* app.css - Customize design tokens */ @theme { --radius: 8px; --size-icon-md: 18px; } ``` ### Pattern 4: Theme-Specific Overrides Use CSS for theme-specific token values: ```css /* Light theme */ .light, .dark .theme-inverse { --opacity-hover: .85; --shadow-elevation-2: 0px 4px 12px rgba(0, 0, 0, 0.1); } /* Dark theme */ .dark, .light .theme-inverse { --opacity-hover: .75; --shadow-elevation-2: 0px 4px 12px rgba(0, 0, 0, 0.3); } ``` ## Next Steps - [CSS Variables Reference](css-variables.md) - Complete list of available variables - [Theme Switching](theme-switching.md) - Implement light/dark mode - [Customization Guide](customization.md) - Detailed customization examples --- # CSS Variables Source: /docs/theming/configuration/css-variables.md # CSS Variables Reference Complete reference of CSS variables available in Frontile's theme system for customization. ## How CSS Variables Work Frontile uses CSS variables in two ways: ### 1. Theme Variables (`@theme` block) Variables defined in the `@theme` block automatically generate Tailwind utility classes: ```css @theme { --radius: 8px; /* Generates: rounded utility */ --radius-xl: 12px; /* Generates: rounded-xl utility */ } ``` These are the primary customization points for design tokens. ### 2. Component Variables (`:root` or theme selectors) Variables defined in `:root` or theme-specific selectors are for component-specific values: ```css :root { --modal-lg: 32rem; /* Used by Modal component */ --drawer-md: 28rem; /* Used by Drawer component */ } ``` ## Typography Variables ### Font Families ```css @theme { --font-header: system-ui, sans-serif; --font-body: system-ui, sans-serif; --font-code: 'Courier New', monospace; --font-label: system-ui, sans-serif; --font-caption: system-ui, sans-serif; --font-marquee: system-ui, sans-serif; } ``` ### Text Styles Frontile provides text style variables. Each text style category has multiple size variants: - **Marquee**: `--text-marquee-5xs` through `--text-marquee-3xl` - **Header**: `--text-header-4xs` through `--text-header-3xl` - **Strong**: `--text-strong-4xs` through `--text-strong-3xl` - **Body**: `--text-body-5xs` through `--text-body-3xl` - **Code**: `--text-code-sm`, `--text-code-md` - **Caption**: `--text-caption-sm`, `--text-caption-md` - **Label**: `--text-label-nano` through `--text-label-3xl` Each text style includes font-size, font-family, font-weight, letter-spacing, and line-height properties. See [Typography documentation](../design-tokens/typography.md) for detailed information. ## Layout Variables ### Border Width ```css @theme { --border-width-thin: 0.5px; --border-width-default: 1px; --border-width-heavy: 2px; --border-width-aggressive: 4px; } ``` **Generated utilities:** `border-thin`, `border`, `border-heavy`, `border-aggressive` ### Border Radius Every step is derived from a single `--radius` base, so you can dial the roundness of the whole library with one value: ```css @theme { --radius: 0.5rem; /* 8px — the one knob */ --radius-none: 0px; --radius-xs: calc(var(--radius) * 0.25); /* 2px */ --radius-sm: calc(var(--radius) * 0.5); /* 4px */ --radius-md: calc(var(--radius) * 0.75); /* 6px */ --radius-lg: calc(var(--radius) * 1); /* 8px */ --radius-xl: calc(var(--radius) * 1.5); /* 12px */ --radius-2xl: calc(var(--radius) * 2); /* 16px */ --radius-3xl: calc(var(--radius) * 3); /* 24px */ --radius-4xl: calc(var(--radius) * 4); /* 32px */ --radius-default: calc(var(--radius) * 2.5); /* 20px */ --radius-pill: 9999px; } ``` **Generated utilities:** `rounded-none`, `rounded-xs`, `rounded-sm`, `rounded-md`, `rounded`, `rounded-lg`, `rounded-xl`, `rounded-2xl`, `rounded-3xl`, `rounded-4xl`, `rounded-default`, `rounded-pill` To make every component softer or sharper, override `--radius` alone — the rest of the scale follows: ```css @theme { --radius: 0.75rem; /* menus 18px, list items 12px, modals 24px */ } ``` ```css @theme { --radius: 0; /* square everything off */ } ``` `--radius-none` and `--radius-pill` are absolutes and deliberately do not scale, so `rounded-full` buttons stay pills at any base value. ### Icon Sizes ```css @theme { --size-icon-pico: /* ... */; --size-icon-nano: /* ... */; --size-icon-micro: /* ... */; --size-icon-3xs: /* ... */; --size-icon-2xs: /* ... */; --size-icon-xs: /* ... */; --size-icon-sm: /* ... */; --size-icon-md: /* ... */; --size-icon-lg: /* ... */; --size-icon-xl: /* ... */; --size-icon-2xl: /* ... */; --size-icon-3xl: /* ... */; --size-icon-kilo: /* ... */; --size-icon-mega: /* ... */; } ``` **Generated utilities:** `size-icon-pico` through `size-icon-mega` See [Icon Sizes documentation](../design-tokens/icons.md) for usage guidelines. ### Elevation Shadows ```css @theme { --shadow-elevation-0: /* ... */; --shadow-elevation-1: /* ... */; --shadow-elevation-2: /* ... */; --shadow-elevation-3: /* ... */; --shadow-elevation-4: /* ... */; --shadow-elevation-5: /* ... */; } ``` **Generated utilities:** `shadow-elevation-0` through `shadow-elevation-5` See [Elevation documentation](../design-tokens/elevation.md) for usage guidelines. ### Opacity ```css @theme { --opacity-hover: .8; --opacity-disabled: .5; } ``` **Generated utilities:** `opacity-hover`, `opacity-disabled` ## Component Variables These variables are used by specific Frontile components and should be defined in `:root` or theme-specific selectors (not in `@theme` block): ### Modal Sizes ```css :root { --modal-xs: 22rem; --modal-sm: 30rem; --modal-md: 35rem; --modal-lg: 48rem; --modal-xl: 60rem; --modal-full: 100%; } ``` ### Drawer Sizes ```css :root { --drawer-xs: 22rem; --drawer-sm: 30rem; --drawer-md: 48rem; --drawer-lg: 64rem; --drawer-xl: 80rem; --drawer-full: 100%; } ``` ## Color Variables **Important:** Color variables should be customized using JavaScript configuration, not CSS variables directly. See [Color Customization](customization.md#colors) for details. Frontile's semantic colors use a system that automatically calculates: - Light and dark theme variations - Contrasting text colors (`on-{color}` classes) - Colors in OKLCH format for perceptual uniformity ## Overriding Variables ### Global Overrides Override in the `@theme` block for design tokens: ```css @import "@frontile/theme"; @theme { --radius: 12px; --border-width-default: 2px; --opacity-hover: .9; --size-icon-md: 20px; } ``` ### Theme-Specific Overrides Use theme selectors for per-theme customization: ```css /* Light theme - targets .light AND .theme-inverse within .dark */ .light, .dark .theme-inverse { --opacity-hover: .85; --shadow-elevation-2: 0px 4px 12px rgba(0, 0, 0, 0.1); } /* Dark theme - targets .dark AND .theme-inverse within .light */ .dark, .light .theme-inverse { --opacity-hover: .75; --shadow-elevation-2: 0px 4px 12px rgba(0, 0, 0, 0.3); } ``` **Why both selectors?** This ensures customizations apply to: - Regular theme usage (`.light` or `.dark`) - Theme-inverse sections (`.theme-inverse` within opposite theme) ### Component-Specific Overrides Override component variables in `:root`: ```css :root { --modal-lg: 48rem; --drawer-md: 32rem; } ``` Or per-theme: ```css .dark { --modal-lg: 56rem; } ``` ## Usage Examples ### Example 1: Customize Border Radius ```css @theme { /* Softer corners throughout the app */ --radius: 12px; --radius-xl: 20px; --radius-pill: 9999px; } ``` ### Example 2: Adjust Icon Sizes ```css @theme { /* Slightly larger icons */ --size-icon-sm: 15px; --size-icon-md: 19px; --size-icon-lg: 23px; } ``` ### Example 3: Theme-Specific Shadows ```css /* Subtler shadows in light mode */ .light, .dark .theme-inverse { --shadow-elevation-1: 0px 1px 3px rgba(0, 0, 0, 0.05); --shadow-elevation-2: 0px 2px 8px rgba(0, 0, 0, 0.08); } /* Stronger shadows in dark mode */ .dark, .light .theme-inverse { --shadow-elevation-1: 0px 2px 4px rgba(0, 0, 0, 0.3); --shadow-elevation-2: 0px 4px 12px rgba(0, 0, 0, 0.4); } ``` ### Example 4: Custom Font Families ```css @font-face { font-family: 'Inter'; src: url('/fonts/inter-var.woff2') format('woff2'); font-weight: 100 900; } @theme { --font-header: 'Inter', system-ui, sans-serif; --font-body: 'Inter', system-ui, sans-serif; --font-label: 'Inter', system-ui, sans-serif; } ``` ### Example 5: Adjust Component Sizes ```css :root { /* Larger modals for desktop */ --modal-md: 36rem; --modal-lg: 48rem; --modal-xl: 64rem; } /* Smaller on mobile */ @media (max-width: 768px) { :root { --modal-md: 90vw; --modal-lg: 90vw; } } ``` ## Best Practices ### Use @theme for Design Tokens Always use `@theme` block for variables that should generate utility classes: ```css /* ✓ Good */ @theme { --radius: 10px; } /* ✗ Avoid */ :root { --radius: 10px; /* Won't generate rounded utility */ } ``` ### Use :root for Component Values Use `:root` for component-specific values that don't need utilities: ```css /* ✓ Good */ :root { --modal-lg: 48rem; --drawer-md: 32rem; } ``` ### Keep Theme Selectors Consistent Always use both selectors for theme-specific overrides: ```css /* ✓ Good - covers all cases */ .light, .dark .theme-inverse { --opacity-hover: .85; } /* ✗ Incomplete - misses .theme-inverse */ .light { --opacity-hover: .85; } ``` ### Document Your Customizations Add comments to explain custom values: ```css @theme { /* Brand requires 16px minimum for icon clarity */ --size-icon-md: 20px; /* Softer corners match brand guidelines */ --radius: 12px; } ``` ## Runtime Customization CSS variables can be changed at runtime using JavaScript: ```js // Change globally document.documentElement.style.setProperty('--radius', '16px'); // Change for specific element element.style.setProperty('--opacity-hover', '0.9'); // Read current value const radius = getComputedStyle(document.documentElement) .getPropertyValue('--radius'); ``` This is useful for user preferences, theme builders, or dynamic customization features. ## Next Steps - [Customization Guide](customization.md) - Detailed customization examples - [Theme Switching](theme-switching.md) - Light/dark mode implementation - [Design Tokens](../design-tokens/overview.md) - Understanding design tokens --- # Theme Switching Source: /docs/theming/configuration/theme-switching.md # Theme Switching Frontile supports light and dark themes, and theme inversion for creating visual contrast within your application. ## Basic Light/Dark Mode Toggle between light and dark themes by adding or removing the `dark` class on a parent element (typically `` or ``): ```html ``` The `light` class is optional since light mode is the default. However, it's useful when you want to be explicit. ## Implementing Theme Switching ### Client-Side Toggle Here's a basic implementation. It stays a static snippet rather than a live demo because it writes to `localStorage` and swaps classes on ``, which would fight this site's own theme switcher. ```gts import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { Button } from 'frontile'; export default class ThemeToggle extends Component { @tracked isDark = false; constructor(owner: unknown, args: Record) { super(owner as never, args); // Saved choice wins; fall back to the system preference. const saved = localStorage.getItem('theme'); this.isDark = saved ? saved === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches; this.applyTheme(); } @action toggleTheme(): void { this.isDark = !this.isDark; this.applyTheme(); localStorage.setItem('theme', this.isDark ? 'dark' : 'light'); } applyTheme(): void { const html = document.documentElement; // Both classes are meaningful — `light` is what makes `theme-inverse` // work inside a dark region — so set one and clear the other. html.classList.toggle('dark', this.isDark); html.classList.toggle('light', !this.isDark); } } ``` Frontile's own documentation site does exactly this — see [`docfy-theme-switcher.gts`](https://github.com/josemarluedke/frontile/blob/main/site/app/components/docfy/docfy-theme-switcher.gts) for a version that also reacts to the system preference changing while the page is open, and guards against running during server-side rendering. ### Respecting System Preference Detect and respond to the user's system theme preference: ```js // Check system preference on load const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; // Listen for system preference changes window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { const isDark = e.matches; // Update theme document.documentElement.classList.toggle('dark', isDark); document.documentElement.classList.toggle('light', !isDark); }); ``` ### Persisting Theme Choice Store the user's theme preference: ```js // Save preference localStorage.setItem('theme', 'dark'); // Load preference const savedTheme = localStorage.getItem('theme'); if (savedTheme) { document.documentElement.classList.add(savedTheme); } // Or use system preference if no saved preference if (!savedTheme) { const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; document.documentElement.classList.add(prefersDark ? 'dark' : 'light'); } ``` ## How Theme Classes Work Frontile uses CSS selectors that apply theme-specific styles: ```css /* Light theme styles apply to: */ .light *, /* Elements in light mode */ .dark .theme-inverse * /* Theme-inverse within dark mode */ /* Dark theme styles apply to: */ .dark *, /* Elements in dark mode */ .light .theme-inverse * /* Theme-inverse within light mode */ ``` This means themes properly cascade and can be nested anywhere in your application. ## Theme Inverse Create sections with inverted theme colors using the `theme-inverse` utility. In light mode, these sections display dark theme colors, and vice versa. ### Basic Usage ```gts preview import { Button } from 'frontile'; ``` ### Use Cases for Theme Inverse Theme inverse is perfect for creating visual contrast and emphasis: - **Hero sections** - Dark hero on light page, or vice versa - **Feature highlights** - Draw attention to specific sections - **Sidebars/panels** - Create visual separation - **Marketing sections** - Alternate theme for variety ### Component Examples All Frontile components work automatically with theme-inverse: ```gts preview import { Button, Chip, ProgressBar } from 'frontile'; ``` ## Important: Modals and Overlays ### Default Behavior By default, Modal and Drawer components render at the top level of the DOM using portals. This means they inherit the theme from `` or ``, **not** from `theme-inverse` sections: ```gts import { Modal, Button, toggleState } from 'frontile'; const state = toggleState(false); ``` ### Rendering in Place To make modals inherit `theme-inverse`, render them in place: ```gts import { Modal, Button, toggleState } from 'frontile'; const state = toggleState(false); ``` ## Best Practices ### Always Set Base Backgrounds Surface overlay colors are semi-transparent. Always provide a solid base background: ```html
Content
Content (might be invisible!)
``` ### Use Semantic Colors Theme-inverse works automatically with semantic colors: ```html

Heading

Body text

``` ### Test Both Themes Always test your UI in both light and dark modes: ```html ``` ### Avoid Hard-Coded Colors Don't use hard-coded Tailwind colors that don't adapt to themes: ```html
Content
Content
``` ## Dark/Light Variants Tailwind's `dark:` and `light:` variants work correctly with `theme-inverse`: ```html
Content
``` The variants understand theme-inverse contexts: - `dark:` applies in dark mode AND in `.light .theme-inverse` - `light:` applies in light mode AND in `.dark .theme-inverse` ## How Theme Inverse Works Under the hood: - Theme-inverse only works with base themes (light and dark) - In light mode, `.light .theme-inverse` applies dark theme colors - In dark mode, `.dark .theme-inverse` applies light theme colors - All CSS variables are automatically swapped - Components work without modification ## Advanced: Multiple Theme Levels You can nest theme-inverse multiple times, though it's rarely needed: ```html
``` ## Next Steps - [CSS Variables Reference](css-variables.md) - Customize theme tokens - [Customization Guide](customization.md) - Theme customization examples - [Colors Documentation](../design-tokens/colors.md) - Understanding semantic colors --- # Customization Source: /docs/theming/configuration/customization.md # Customization Guide Complete guide to customizing Frontile's theme system, from simple token adjustments to comprehensive brand theming. ## Quick Start The fastest way to customize Frontile is using CSS variables in your `app/styles/app.css`: ```css title="app/styles/app.css" @import 'tailwindcss' source('../../'); @plugin "@frontile/theme/plugin/default"; @import '@frontile/theme'; @theme { --radius: 12px; --size-icon-md: 20px; --opacity-hover: 0.9; } ``` ## Customizing Colors ### Using JavaScript Configuration Colors should be customized using JavaScript configuration for best results. Create `frontile.js` in your project root: ```js title="frontile.js" collapsible const { frontile } = require('@frontile/theme/plugin'); module.exports = frontile({ themes: { light: { colors: { primary: { subtle: '#f0f9ff', soft: '#7dd3fc', DEFAULT: '#0ea5e9', strong: '#0c4a6e' }, success: { subtle: '#f0fdf4', soft: '#86efac', DEFAULT: '#22c55e', strong: '#14532d' }, danger: { subtle: '#fef2f2', soft: '#fca5a5', DEFAULT: '#ef4444', strong: '#7f1d1d' }, warning: { subtle: '#fffbeb', soft: '#fde68a', DEFAULT: '#f59e0b', strong: '#78350f' } } }, dark: { colors: { primary: { subtle: '#0c4a6e', soft: '#0ea5e9', DEFAULT: '#38bdf8', strong: '#e0f2fe' } // ... other colors } } } }); ``` Then reference it in your CSS: ```css @import 'tailwindcss' source('../../'); @plugin "./../../frontile.js"; @import '@frontile/theme'; ``` ### How Color Customization Works Frontile automatically: - Generates contrasting text colors (`on-{color}-{level}`) - Converts your colors to OKLCH format for perceptual uniformity - Accepts any color format as input (hex, rgb, hsl, oklch, CSS variables) - Creates theme-aware CSS variables - Ensures proper contrast ratios ### Customizing On-Colors By default, Frontile auto-generates optimal `on-{color}-{level}` text colors (black or white) based on WCAG contrast calculations. If you need specific on-colors for brand consistency, you can override them in your theme configuration: ```js const { frontile } = require('@frontile/theme/plugin'); module.exports = frontile({ themes: { light: { colors: { primary: { subtle: '#eff6ff', soft: '#93c5fd', DEFAULT: '#3b82f6', strong: '#1e40af' }, // Override specific on-colors 'on-primary': { DEFAULT: '#ffffff', // Force white text on the bare primary fill strong: '#e0f2fe' // Use light blue instead of white } // on-primary-subtle and on-primary-soft will still be auto-generated } } } }); ``` **Key behavior:** - Partial overrides work — define only the levels you want to customize, and the rest will be auto-generated - Works for: `on-neutral`, `on-primary`, `on-secondary`, `on-tertiary`, `on-success`, `on-warning`, `on-danger`, `on-surface-modal` - CSS variable references (e.g., `var(--my-color)`) are passed through as-is — auto-generation is skipped since contrast can't be calculated ## Customizing Typography ### Font Families Override font families for different text categories: ```css @font-face { font-family: 'Inter'; src: url('/fonts/inter-var.woff2') format('woff2'); font-weight: 100 900; font-display: swap; } @font-face { font-family: 'JetBrains Mono'; src: url('/fonts/jetbrains-mono.woff2') format('woff2'); font-display: swap; } @theme { --font-header: 'Inter', system-ui, sans-serif; --font-body: 'Inter', system-ui, sans-serif; --font-label: 'Inter', system-ui, sans-serif; --font-code: 'JetBrains Mono', monospace; } ``` ### Theme-Specific Fonts Use different fonts for light and dark themes: ```css .light, .dark .theme-inverse { --font-body: 'Inter', system-ui, sans-serif; } .dark, .light .theme-inverse { --font-body: 'Geist', system-ui, sans-serif; } ``` ## Customizing Border Radius ### Global Radius Adjust border radius throughout your application: ```css @theme { /* Softer, more rounded design */ --radius: 12px; --radius-xl: 20px; --radius-2xl: 28px; --radius-pill: 9999px; } ``` ### Sharp Design Create a more angular design: ```css @theme { /* Sharp, geometric design */ --radius: 2px; --radius-sm: 1px; --radius-md: 3px; --radius-xl: 6px; --radius-2xl: 8px; } ``` ## Customizing Icon Sizes Adjust icon sizes to match your design: ```css @theme { /* Slightly larger icons */ --size-icon-xs: 13px; --size-icon-sm: 15px; --size-icon-md: 19px; --size-icon-lg: 23px; --size-icon-xl: 27px; } ``` ## Customizing Borders ### Border Widths Adjust border thickness: ```css @theme { /* Thicker borders throughout */ --border-width-thin: 1px; --border-width-default: 2px; --border-width-heavy: 3px; --border-width-aggressive: 5px; } ``` ## Customizing Elevation ### Shadow Styles Adjust shadow intensity and style: ```css @theme { /* Subtler shadows */ --shadow-elevation-1: 0px 1px 2px rgba(0, 0, 0, 0.04); --shadow-elevation-2: 0px 2px 8px rgba(0, 0, 0, 0.06); --shadow-elevation-3: 0px 8px 16px rgba(0, 0, 0, 0.08); --shadow-elevation-4: 0px 16px 32px rgba(0, 0, 0, 0.12); } ``` ### Theme-Specific Shadows Different shadows for light and dark modes: ```css /* Light theme - softer shadows */ .light, .dark .theme-inverse { --shadow-elevation-2: 0px 2px 8px rgba(0, 0, 0, 0.08); --shadow-elevation-3: 0px 8px 20px rgba(0, 0, 0, 0.12); } /* Dark theme - stronger shadows for contrast */ .dark, .light .theme-inverse { --shadow-elevation-2: 0px 4px 12px rgba(0, 0, 0, 0.4); --shadow-elevation-3: 0px 12px 28px rgba(0, 0, 0, 0.5); } ``` ## Customizing Opacity Adjust hover and disabled opacity: ```css @theme { --opacity-hover: 0.9; --opacity-disabled: 0.4; } ``` ## Customizing Component Sizes ### Modal Sizes Adjust modal dimensions: ```css :root { --modal-xs: 18rem; --modal-sm: 22rem; --modal-md: 32rem; --modal-lg: 48rem; --modal-xl: 64rem; } /* Responsive sizing */ @media (max-width: 768px) { :root { --modal-md: 90vw; --modal-lg: 90vw; --modal-xl: 95vw; } } ``` ### Drawer Sizes ```css :root { --drawer-sm: 20rem; --drawer-md: 32rem; --drawer-lg: 48rem; --drawer-xl: 64rem; } ``` ## Setting Default Theme Specify which theme loads by default: ```js // frontile.js module.exports = frontile({ defaultTheme: 'dark', // or 'light' themes: { light: {/* ... */}, dark: {/* ... */} } }); ``` ## Complete Brand Example Here's a complete example of customizing Frontile for a brand: ### frontile.js ```js const { frontile } = require('@frontile/theme/plugin'); module.exports = frontile({ defaultTheme: 'light', themes: { light: { colors: { // Purple brand color primary: { subtle: '#faf5ff', soft: '#c084fc', DEFAULT: '#9333ea', strong: '#581c87' }, // Success remains green success: { subtle: '#f0fdf4', soft: '#86efac', DEFAULT: '#22c55e', strong: '#14532d' }, // Custom orange warning warning: { subtle: '#fff7ed', soft: '#fdba74', DEFAULT: '#f97316', strong: '#7c2d12' } } }, dark: { colors: { primary: { subtle: '#581c87', soft: '#9333ea', DEFAULT: '#a855f7', strong: '#f3e8ff' } // ... other colors } } } }); ``` ### app/styles/app.css ```css @import 'tailwindcss' source('../../'); @plugin "./../../frontile.js"; @import '@frontile/theme'; /* Load custom fonts */ @font-face { font-family: 'Poppins'; src: url('/fonts/poppins-var.woff2') format('woff2'); font-weight: 100 900; font-display: swap; } @theme { /* Typography */ --font-header: 'Poppins', system-ui, sans-serif; --font-body: 'Poppins', system-ui, sans-serif; --font-label: 'Poppins', system-ui, sans-serif; /* Rounded design */ --radius: 12px; --radius-xl: 20px; --radius-2xl: 24px; /* Slightly larger icons */ --size-icon-sm: 16px; --size-icon-md: 20px; --size-icon-lg: 24px; /* Subtle opacity changes */ --opacity-hover: 0.9; --opacity-disabled: 0.5; } /* Component customization */ :root { /* Wider modals */ --modal-md: 36rem; --modal-lg: 52rem; } ``` ## Common Customization Patterns ### Pattern 1: Minimal/Sharp Design ```css @theme { --radius: 2px; --radius-xl: 4px; --radius-2xl: 6px; --border-width-default: 1px; --shadow-elevation-2: 0px 1px 3px rgba(0, 0, 0, 0.06); } ``` ### Pattern 2: Soft/Rounded Design ```css @theme { --radius: 16px; --radius-xl: 24px; --radius-2xl: 32px; --shadow-elevation-2: 0px 8px 24px rgba(0, 0, 0, 0.08); } ``` ### Pattern 3: Bold/High Contrast ```css @theme { --border-width-default: 2px; --border-width-heavy: 3px; --opacity-hover: 0.95; } .light, .dark .theme-inverse { --shadow-elevation-2: 0px 4px 12px rgba(0, 0, 0, 0.15); } ``` ### Pattern 4: Subtle/Minimal ```css @theme { --border-width-thin: 0.5px; --border-width-default: 1px; --opacity-hover: 0.85; --shadow-elevation-2: 0px 2px 4px rgba(0, 0, 0, 0.04); } ``` ## Advanced: Per-Component Customization Override styles for specific components using Tailwind variants: ```css /* Larger buttons */ .btn-lg { @apply px-6 py-3 text-label-lg; } /* Card with custom shadow */ .card { @apply rounded-2xl shadow-elevation-3; } /* Custom modal backdrop */ .modal-backdrop { @apply bg-surface-overlay-strong dark:bg-surface-lift-strong backdrop-blur-md; } ``` ## Testing Your Customizations ### Visual Regression Testing Test your theme in different scenarios: ```html
``` ### Browser DevTools Use browser DevTools to inspect CSS variables: ```js // Check current value getComputedStyle(document.documentElement).getPropertyValue('--radius'); // Test changes document.documentElement.style.setProperty('--radius', '16px'); ``` ## Best Practices ### Document Your Customizations Add comments explaining why values were chosen: ```css @theme { /* Brand guideline requires 16px minimum border radius */ --radius: 16px; /* Larger icons for better visibility on mobile */ --size-icon-md: 22px; } ``` ### Use Consistent Scales Maintain proportional relationships when customizing: ```css /* Good - maintains scale relationships */ @theme { --radius-sm: 4px; --radius: 8px; --radius-xl: 16px; --radius-2xl: 24px; } /* Avoid - breaks scale relationships */ @theme { --radius-sm: 2px; --radius: 20px; --radius-xl: 15px; --radius-2xl: 8px; } ``` ### Test Accessibility Ensure customizations maintain accessibility: - Maintain sufficient color contrast (WCAG AA minimum 4.5:1) - Don't reduce opacity below readable levels - Test with screen readers - Verify keyboard navigation remains clear ### Performance Considerations - CSS variable changes are performant - Avoid excessive theme switching - Use `prefers-reduced-motion` for users who need it: ```css @media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; } } ``` ## Troubleshooting ### Colors Not Updating **Problem**: Changed color values but UI doesn't update **Solution**: Colors must be configured via JavaScript, not CSS variables: ```js // Correct module.exports = frontile({ themes: { light: { colors: { primary: {/* ... */} } } } }); ``` ### Utilities Not Generated **Problem**: Custom CSS variable doesn't generate utility class **Solution**: Make sure the variable is in the `@theme` block: ```css /* Generates utilities */ @theme { --radius-custom: 10px; } /* Does NOT generate utilities */ :root { --radius-custom: 10px; } ``` ### Theme-Specific Values Not Applying **Problem**: Theme-specific overrides don't work **Solution**: Use both selectors for proper theme-inverse support: ```css /* Correct */ .light, .dark .theme-inverse { --opacity-hover: 0.85; } /* Incomplete */ .light { --opacity-hover: 0.85; } ``` ## Next Steps - [CSS Variables Reference](css-variables.md) - Complete variable list - [Design Tokens](../design-tokens/overview.md) - Understanding tokens - [Theme Switching](theme-switching.md) - Implement theme toggling --- # All Components Source: /docs/components/overview.md # Components Every component Frontile ships today, grouped by category. Pick one below to jump straight into its docs and live demos. --- # ButtonGroup Source: /docs/components/buttons/button-group.md # ButtonGroup A button group is used to group buttons whose actions are related. ## Import ```js import { ButtonGroup } from 'frontile'; ``` ## Usage ```gts preview import { ButtonGroup } from 'frontile'; ``` ## Using with ToggleButton ```gts preview collapsible import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { fn } from '@ember/helper'; import { ButtonGroup } from 'frontile'; export default class Example extends Component { @tracked isSelected = { first: false, second: false, third: false }; @action onChange(ty: keyof typeof this.isSelected, value: boolean): void { this.isSelected[ty] = value; this.isSelected = { ...this.isSelected }; } } ``` ## ButtonGroup use case A common use case for `ButtonGroup` is to create a split button. ```gts preview import { ButtonGroup } from 'frontile'; import { ChevronDownIcon } from 'site/components/icons'; ``` ## Arguments Arguments passed to `ButtonGroup` are forwarded to every yielded component. Any of them can be overridden on an individual button. ```gts preview import { ButtonGroup } from 'frontile'; ``` ## Accessibility `ButtonGroup` renders `role="group"`, which tells assistive technology that the buttons belong together — but a group with no name is announced as an unlabelled container, so the relationship is stated without being explained. Name it with `aria-label` (or `aria-labelledby` pointing at a visible heading); both pass through to the element via `...attributes`. ```gts preview import { ButtonGroup } from 'frontile'; ``` Grouping does not change how the buttons themselves behave: each stays in the tab order and is reached with `Tab`, not with arrow keys. If you want one selection out of several with arrow-key navigation, that is a radio group rather than a button group — see [RadioGroup](/docs/components/forms/radio-group). Yielded `g.ToggleButton`s each carry their own `aria-pressed`, so a group of them is announced as several independent toggles. That is right for a formatting toolbar where bold and italic can both be on; it is misleading for a set where only one value can be active at a time. ## API ### ButtonGroup **Element:** `HTMLDivElement` **Arguments** | Name | Type | Default | Description | | --- | --- | --- | --- | | `appearance` | `'soft' \| 'custom' \| 'default' \| 'outlined' \| 'minimal' \| 'tonal'` | - | **Deprecated.** Use `variant`. `default` is now `solid`, `outlined` is `outline`, and `minimal` is `plain`. | | `class` | `string` | - | Custom class name, it will override the default ones using Tailwind Merge library. | | `color` | `'neutral' \| 'primary' \| 'secondary' \| 'tertiary' \| 'success' \| 'warning' \| 'danger'` | - | The color of the button | | `intent` | `'default' \| 'primary' \| 'secondary' \| 'tertiary' \| 'success' \| 'warning' \| 'danger'` | - | **Deprecated.** Use `color`. `default` is now `neutral`. | | `size` | `'sm' \| 'md' \| 'lg' \| 'xs' \| 'xl' \| '2xl'` | - | The size of the button | | `variant` | `'solid' \| 'soft' \| 'subtle' \| 'outline' \| 'ghost' \| 'plain' \| 'custom'` | `'solid'` | The button variant. | **Blocks** | Name | Type | Default | Description | | --- | --- | --- | --- | | `default *` | `[{ Button: Button (isInGroup bound); ToggleButton: ToggleButton (isInGroup bound); }]` | - | | --- # Button Source: /docs/components/buttons/button.md # Button The Button component can be used to trigger an action, such as submitting a form, opening a modal, and more. ## Import ```js import { Button } from 'frontile'; ``` ## Usage ```gts preview import { Button } from 'frontile'; ``` ## Button Variants ```gts preview import { Button } from 'frontile'; ``` The `custom` variant is available for the cases where you might want to fully customize the appearance of the button. The default styles are mainly structural. Colors are applied as `color`. ## Button Colors Every color is available in every variant. The label on each row is the `@variant` value; the button labels are the `@color` values. ```gts preview collapsible import { Button } from 'frontile'; import { array } from '@ember/helper'; const colors = [ 'neutral', 'primary', 'secondary', 'tertiary', 'success', 'warning', 'danger' ]; ``` ## Button Sizes ```gts preview import { Button } from 'frontile'; ``` ## With Icons ```gts preview import { Button } from 'frontile'; import { DownloadIcon, ShareIcon, CheckIcon } from 'site/components/icons'; ``` Icons can be placed before or after text. They inherit the button's text color via `currentColor`. ```gts preview import { Button } from 'frontile'; import { DownloadIcon, ShareIcon, CheckIcon } from 'site/components/icons'; ``` Icons passed as plain content keep working exactly as above. The `icon` named block is opt-in sugar: it buys you `@iconPlacement` and, when the button is loading, it is the slot the spinner takes over. ```gts preview import { Button } from 'frontile'; import { ShareIcon } from 'site/components/icons'; ``` ## Label with a Unit A label can carry a smaller trailing unit — a price suffix like `/mo`, a count, or an abbreviation. The label keeps the bold `strong` text role that the size variant already applies; the unit uses the regular-weight `body` role, two steps down the scale. Wrap the pair so the unit sits tight against the label: the button's own `gap` spaces the icon slots, while the wrapper's narrower gap spaces label from unit. ```gts preview import { Button } from 'frontile'; import { StarIcon } from 'site/components/icons'; ``` The unit token pairs with the label token the size variant sets, so it needs to change with `@size`: | `@size` | label (automatic) | unit | wrapper gap | | ------- | ----------------- | --------------- | ----------- | | `xs` | `text-strong-sm` | `text-body-3xs` | `gap-0.5` | | `sm` | `text-strong-md` | `text-body-2xs` | `gap-0.5` | | `md` | `text-strong-lg` | `text-body-xs` | `gap-1` | | `lg` | `text-strong-xl` | `text-body-sm` | `gap-1` | | `xl` | `text-strong-2xl` | `text-body-md` | `gap-1` | | `2xl` | `text-strong-3xl` | `text-body-lg` | `gap-1.5` | ```gts preview import { Button } from 'frontile'; ``` ## Disabled ```gts preview import { Button } from 'frontile'; ``` ## Loading `@isLoading` renders a spinner and disables the button. ```gts preview import { Button } from 'frontile'; ``` Use the `loading` block to swap the label while the action is in flight. ```gts preview import { Button } from 'frontile'; ``` The spinner takes the place of the `icon` block, so a button with an icon keeps its width while loading. ```gts preview import { Button } from 'frontile'; import { ShareIcon } from 'site/components/icons'; ``` `@iconPlacement='end'` moves both the icon and the spinner after the label. ```gts preview import { Button } from 'frontile'; import { ShareIcon } from 'site/components/icons'; ``` ## Renderless Button Sometimes a button element is not ideal for a given case, but the same styles are still desired. Frontile provides the option to disable rendering the `button` element, but instead it yields back an object with the class names it would use. ```gts preview import { Button } from 'frontile'; ``` ## Composition You can compose variant with colors and more to create the button that best fits your needs. ```gts preview import { Button } from 'frontile'; ``` ## Customization You can use TailwindCSS classes to customize even further. ```gts preview import { Button } from 'frontile'; ``` Here is another example using TailwindCSS classes with the `custom` variant. ```gts preview import { Button } from 'frontile'; ``` Note that here we used the HTML attribute `class`, instead of the argument `@class`. Using the class attribute will just append the class names passed in, while the argument `@class` will override and merge TailwindCSS class names. ## Press Interactions The Button component supports press interactions through the `@onPress` callback, which provides cross-platform support for mouse, touch, and keyboard events. ```gts preview import { Button } from 'frontile'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; export default class ButtonPressExample extends Component { @tracked pressCount = 0; handlePress = () => { this.pressCount++; }; } ``` ### Press State Buttons automatically track their pressed state and add a `data-pressed` attribute when being pressed, which can be used for styling: ```css button[data-pressed='true'] { transform: scale(0.95); transition: transform 0.1s ease; } ``` ## Accessibility `Button` renders a native `` — stays on screen alongside the spinner, rendering both. If you want the loading-swap behavior, move the icon into `<:icon>`. ### Renderless buttons `@isRenderless` hands back only class names, so every semantic the ` Content here ``` > **Note:** Transitions are registered with an Ember test waiter, so `await settled()` in tests resolves only after the open or close animation has finished. ## API ### Collapsible **Element:** `HTMLDivElement` **Arguments** | Name | Type | Default | Description | | --- | --- | --- | --- | | `isOpen *` | `boolean` | - | If true, the content will be visible | | `initialHeight` | `string` | `0` | The height for the content in it's collapsed state. The unit of the value should be included, eg. '10px'. Any CSS height is accepted (2rem, 50%, calc(1rem + 2px), …); a value the CSS parser rejects is ignored and the content collapses to 0. | **Blocks** | Name | Type | Default | Description | | --- | --- | --- | --- | | `default *` | `[]` | - | | --- # Divider Source: /docs/components/utilities/divider.md # Divider A Divider is designed to delineate and separate content. ## Import ```js import { Divider } from 'frontile'; ``` ## Usage A horizontal divider renders an `
`. ```gts preview import { Divider } from 'frontile'; ``` ## Variants `@variant='sketch'` swaps the flat rule for a hand-drawn one. It stretches to any width without distorting: the artwork is a near-horizontal filled shape, so scaling it horizontally changes only how often it wobbles, never its thickness. ```gts preview import { Divider } from 'frontile'; ``` The line takes its colour from the element's background. By default that is the `divider` token, which is translucent — 15% ink over whatever sits behind it — so one divider reads correctly on a page, a card or a tinted panel without being retuned for each. A utility class recolours it: ```gts preview import { Divider } from 'frontile'; ``` `sketch` is horizontal only. The artwork cannot be squashed into a vertical rule, so `@orientation='vertical'` ignores it and renders the plain line. ## Orientation `@orientation='vertical'` renders a `
` instead, because `
` cannot express a vertical rule. `@variant='sketch'` has no effect on a vertical divider — see Variants. The vertical divider is styled `h-full`, which resolves against its parent — so the parent needs a **definite** height. `items-stretch` alone is not enough: `height: 100%` of an auto-height container computes to zero, and the divider disappears. ```gts preview import { Divider } from 'frontile'; ``` If the row's height has to stay content-driven, override the height on the divider itself instead: ```gts preview import { Divider } from 'frontile'; ``` ## Changing the element `@as` renders a different tag from the one the orientation would pick. This matters when the surrounding markup constrains what is valid — an `
` is not allowed as a direct child of `
`, ``, ``, `` — with Frontile's styling, for manual composition when you want full control over layout instead of automatic rendering from data. **Key Features:** - **Manual composition** with block-form yielding - Size variants and striped rows - Sticky support when used with the Table component - No data management — just the table structure and styling For automatic rendering with sticky elements and data management, use [Table](./table) instead. ## Import ```js import { SimpleTable } from 'frontile'; ``` ## Usage SimpleTable uses block form composition where you manually define the table structure: ```gts preview collapsible import Component from '@glimmer/component'; import { SimpleTable } from 'frontile'; export default class DemoComponent extends Component { items = [ { id: '1', name: 'John Doe', email: 'john@example.com', role: 'admin' }, { id: '2', name: 'Jane Smith', email: 'jane@example.com', role: 'user' } ]; } ``` ## Anatomy `SimpleTable` yields `Header`, `Body`, `Footer`, `Column`, `Row`, and `Cell` components. Compose those pieces in native table order; unlike `Table`, SimpleTable does not create columns or rows from data for you. ## Advanced Composition ### Custom Headers Create complex header layouts with custom content: ```gts preview collapsible import Component from '@glimmer/component'; import { SimpleTable } from 'frontile'; import { Button, Chip } from 'frontile'; export default class DemoComponent extends Component { users = [ { id: '1', name: 'John Doe', email: 'john@example.com', status: 'Active', chipColor: 'success' }, { id: '2', name: 'Jane Smith', email: 'jane@example.com', status: 'Inactive', chipColor: 'neutral' }, { id: '3', name: 'Bob Wilson', email: 'bob@example.com', status: 'Pending', chipColor: 'warning' } ]; } ``` ### Custom Cell Content SimpleTable excels at complex cell layouts and interactive content: ```gts preview collapsible import Component from '@glimmer/component'; import { SimpleTable } from 'frontile'; import { Button, Chip } from 'frontile'; export default class DemoComponent extends Component { products = [ { id: '1', name: 'Wireless Headphones', price: 99.99, stock: 15, stockStatus: 'In Stock', stockChipColor: 'success' }, { id: '2', name: 'Smart Watch', price: 299.99, stock: 3, stockStatus: 'Low Stock', stockChipColor: 'warning' }, { id: '3', name: 'Bluetooth Speaker', price: 59.99, stock: 0, stockStatus: 'Out of Stock', stockChipColor: 'danger' } ]; } ``` ## Styling & Layout ### Size Variants Control table spacing with size variants: ```gts preview collapsible import Component from '@glimmer/component'; import { SimpleTable } from 'frontile'; export default class DemoComponent extends Component { data = [ { name: 'John Doe', role: 'Developer' }, { name: 'Jane Smith', role: 'Designer' } ]; } ``` ### Layout Options Control column sizing behavior: ```gts preview collapsible import Component from '@glimmer/component'; import { SimpleTable } from 'frontile'; export default class DemoComponent extends Component { data = [ { id: '1', name: 'John Doe', email: 'john.doe.longname@example-company.com', department: 'Engineering' }, { id: '2', name: 'Jane', email: 'jane@ex.co', department: 'Design' } ]; } ``` ### Striped Rows Enable alternating row colors for better readability: ```gts preview collapsible import Component from '@glimmer/component'; import { SimpleTable } from 'frontile'; import { Chip } from 'frontile'; export default class DemoComponent extends Component { users = [ { name: 'John Doe', email: 'john@example.com', status: 'Active', statusColor: 'success' }, { name: 'Jane Smith', email: 'jane@example.com', status: 'Active', statusColor: 'success' }, { name: 'Bob Johnson', email: 'bob@example.com', status: 'Inactive', statusColor: 'neutral' }, { name: 'Alice Brown', email: 'alice@example.com', status: 'Active', statusColor: 'success' } ]; } ``` ### Custom Classes Apply custom styling to specific table elements: ```gts preview collapsible import Component from '@glimmer/component'; import { SimpleTable } from 'frontile'; import { Chip } from 'frontile'; import { hash } from '@ember/helper'; export default class DemoComponent extends Component { items = [ { name: 'Critical Server Issue', value: '$1,000', priority: 'High', priorityColor: 'danger' }, { name: 'Feature Enhancement', value: '$500', priority: 'Medium', priorityColor: 'warning' }, { name: 'Documentation Update', value: '$100', priority: 'Low', priorityColor: 'success' } ]; } ``` ## Table Footers Add footers for summaries and totals: ```gts preview collapsible import Component from '@glimmer/component'; import { SimpleTable } from 'frontile'; export default class DemoComponent extends Component { orders = [ { id: '#001', product: 'Laptop', quantity: 2, price: 999.99 }, { id: '#002', product: 'Mouse', quantity: 5, price: 29.99 }, { id: '#003', product: 'Keyboard', quantity: 3, price: 79.99 } ]; get totalQuantity() { return this.orders.reduce((sum, order) => sum + order.quantity, 0); } get totalValue() { return this.orders.reduce( (sum, order) => sum + order.quantity * order.price, 0 ); } calculateTotal = (quantity, price) => (quantity * price).toFixed(2); } ``` ## Loading State SimpleTable supports loading states with different color variants to indicate when data is being fetched or processed. ```gts preview collapsible import Component from '@glimmer/component'; import { SimpleTable } from 'frontile'; import { Select } from 'frontile'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { on } from '@ember/modifier'; import { Button } from 'frontile'; interface Product { id: string; name: string; price: number; category: string; } export default class DemoComponent extends Component { @tracked isLoading = true; @tracked loadingColor = 'primary'; items: Product[] = [ { id: '1', name: 'Wireless Headphones', price: 199.99, category: 'Electronics' }, { id: '2', name: 'Coffee Mug', price: 12.99, category: 'Kitchen' }, { id: '3', name: 'Notebook Set', price: 24.99, category: 'Office' } ]; colorOptions = [ { key: 'default', name: 'Default' }, { key: 'primary', name: 'Primary' }, { key: 'success', name: 'Success' }, { key: 'warning', name: 'Warning' }, { key: 'danger', name: 'Danger' } ]; @action toggleLoading() { this.isLoading = !this.isLoading; } @action updateLoadingColor(color) { this.loadingColor = color; } } ``` The loading feature supports five color variants: - **`default`** - Standard gray loading animation - **`primary`** - Uses the primary theme color - **`success`** - Green loading animation for success states - **`warning`** - Orange/yellow loading animation for warnings - **`danger`** - Red loading animation for error states ## Accessibility SimpleTable renders real table markup — `
`, ``, ``, ``, ``, `` itself is not reachable by keyboard. If the table scrolls (`@isScrollable`), the scroll container needs to be keyboard reachable so someone can scroll it without a mouse — give it `tabindex="0"` and a label. ## API ### SimpleTable **Element:** `HTMLTableElement` **Arguments** | Name | Type | Default | Description | | --- | --- | --- | --- | | `classes` | `SlotsToClasses<'table' \| 'tbody' \| 'td' \| 'tfoot' \| 'th' \| 'thead' \| 'tr' \| 'separator' \| 'wrapper' \| 'empty' \| 'toolbar' \| 'sortButton' \| 'sortIcon' \| 'columnVisibilityButton' \| 'columnVisibilityIcon' \| 'skeleton' \| 'skeletonRow'>` | - | Custom CSS classes for different table elements (wrapper, table, th, td, etc.) | | `hasCustomLoading` | `boolean` | - | Whether a custom loading block is provided (disables CSS loading indicator) | | `hasWrapper` | `boolean` | `true` | Whether to render the wrapper div. | | `isLoading` | `boolean` | - | Enable loading state styling and behavior | | `isScrollable` | `boolean` | - | Enable scrolling for the table container | | `isStriped` | `boolean` | - | Enable striped rows (alternating background colors) | | `layout` | `'auto' \| 'fixed'` | `'auto'` | Table layout algorithm - 'auto' sizes columns by content, 'fixed' uses first row for sizing. | | `loadingColor` | `'default' \| 'primary' \| 'success' \| 'warning' \| 'danger'` | `'default'` | Color variant for loading animation. | | `selectionColor` | `'default' \| 'primary' \| 'success' \| 'warning' \| 'danger'` | `'primary'` | Color variant for selection highlight. | | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Size variant for table cells and headers. | **Blocks** | Name | Type | Default | Description | | --- | --- | --- | --- | | `default *` | `[{ Header: SimpleTableHeader (styleFns, classes bound); Body: SimpleTableBody (styleFns, classes bound); Footer: SimpleTableFooter (styleFns, classes bound); Column: SimpleTableColumn (styleFns bound); Row: SimpleTableRow (styleFns, classes bound); Cell: SimpleTableCell (styleFns, classes bound); }]` | - | | ### SimpleTableHeader **Element:** `HTMLTableSectionElement` **Arguments** | Name | Type | Default | Description | | --- | --- | --- | --- | | `class` | `string` | - | Additional CSS class to apply to the header section | | `isSticky` | `boolean` | - | Whether the header should be sticky during vertical scrolling | **Blocks** | Name | Type | Default | Description | | --- | --- | --- | --- | | `default *` | `[{ Column: typeof SimpleTableColumn; }]` | - | | ### SimpleTableBody **Element:** `HTMLTableSectionElement` **Arguments** | Name | Type | Default | Description | | --- | --- | --- | --- | | `class` | `string` | - | Additional CSS class to apply to the body section | **Blocks** | Name | Type | Default | Description | | --- | --- | --- | --- | | `default *` | `[{ Row: SimpleTableRow (styleFns, classes bound); Cell: SimpleTableCell (styleFns, classes bound); }]` | - | | ### SimpleTableFooter **Element:** `HTMLTableSectionElement` **Arguments** | Name | Type | Default | Description | | --- | --- | --- | --- | | `class` | `string` | - | Additional CSS class to apply to the footer element | | `isSticky` | `boolean` | - | Whether this footer should be sticky during vertical scrolling | **Blocks** | Name | Type | Default | Description | | --- | --- | --- | --- | | `default *` | `[{ Column: typeof SimpleTableColumn; }]` | - | | ### SimpleTableColumn **Element:** `HTMLTableCellElement` **Arguments** | Name | Type | Default | Description | | --- | --- | --- | --- | | `class` | `string` | - | Additional CSS class to apply to the header cell | | `isSticky` | `boolean` | - | Whether this column should be sticky during horizontal scrolling | | `stickyPosition` | `'left' \| 'right'` | - | Position where the sticky column should stick. | **Blocks** | Name | Type | Default | Description | | --- | --- | --- | --- | | `default *` | `[]` | - | | ### SimpleTableRow **Element:** `HTMLTableRowElement` **Arguments** | Name | Type | Default | Description | | --- | --- | --- | --- | | `class` | `string` | - | Additional CSS class to apply to the row | | `hasStickyHeader` | `boolean` | - | Whether the table has a sticky header (affects positioning of sticky rows) | | `isSticky` | `boolean` | - | Whether this row should be sticky during vertical scrolling | **Blocks** | Name | Type | Default | Description | | --- | --- | --- | --- | | `default *` | `[{ Cell: SimpleTableCell (styleFns, classes bound); }]` | - | | ### SimpleTableCell **Element:** `HTMLTableCellElement` **Arguments** | Name | Type | Default | Description | | --- | --- | --- | --- | | `class` | `string` | - | Additional CSS class to apply to the cell | | `isInStickyRow` | `boolean` | - | Whether this cell is part of a sticky row (used for intersection styling) | | `isSticky` | `boolean` | - | Whether this cell should be sticky during horizontal scrolling | | `stickyPosition` | `'left' \| 'right'` | - | Position where the sticky cell should stick. | **Blocks** | Name | Type | Default | Description | | --- | --- | --- | --- | | `default *` | `[]` | - | | --- # Table Source: /docs/components/collections/table.md # Table Renders structured data automatically from `@columns` and `@items`, with sticky headers, sorting, column visibility, and scrollable containers. **Key Features:** - Automatic rendering with type-safe column definitions - Row selection (single or multiple with checkboxes) - Sticky elements (headers, footers, columns, rows) - Scrollable containers for large datasets - Column sorting and visibility controls - Custom cell and header rendering - Loading and empty states For manual composition and custom layouts, use [SimpleTable](./simple-table) instead. ## Import ```js import { Table, type ColumnConfig } from 'frontile'; ``` ## Usage Define columns and items to render a table automatically: ```gts preview import Component from '@glimmer/component'; import { Table, type ColumnConfig } from 'frontile'; import { users, type User } from 'site/components/table-demo-data'; export default class DemoComponent extends Component { columns = [ { key: 'id', name: 'ID' }, { key: 'name', name: 'Name' }, { key: 'email', name: 'Email' }, { key: 'role', name: 'Role' } ] as const satisfies ColumnConfig[]; } ``` ## Anatomy `Table` combines a scroll container, header, body, rows, columns, and cells from `@columns` and `@items`. Custom header and cell components receive the same column and row context used by the default renderers. ## Column Configuration ### Custom Value Functions Transform or compute values dynamically: ```gts preview import Component from '@glimmer/component'; import { Table, type ColumnConfig } from 'frontile'; import { users, type User } from 'site/components/table-demo-data'; export default class DemoComponent extends Component { columns = [ { key: 'name', name: 'Name' }, { key: 'email', name: 'Contact', value: (ctx) => ctx.row.data.email.toUpperCase() }, { key: 'status', name: 'Admin', value: (ctx) => (ctx.row.data.role === 'admin' ? 'Yes' : 'No') } ] as const satisfies ColumnConfig[]; } ``` ### Column-Level Cell Components Define reusable Cell components in your column configuration: ```gts preview collapsible import Component from '@glimmer/component'; import { Table, type ColumnConfig, type CellSignature } from 'frontile'; import { Chip } from 'frontile'; import { users, type User } from 'site/components/table-demo-data'; import type { TOC } from '@ember/component/template-only'; const StatusCell: TOC> = ; export default class DemoComponent extends Component { columns = [ { key: 'name', name: 'Name' }, { key: 'email', name: 'Email' }, { key: 'status', name: 'Status', Cell: StatusCell } ] as const satisfies ColumnConfig[]; } function eq(a: string | undefined, b: string) { return a === b; } ``` ## Styling Control table appearance with size, striping, and layout options: ```gts preview import Component from '@glimmer/component'; import { Table, type ColumnConfig } from 'frontile'; import { users, type User } from 'site/components/table-demo-data'; import { hash } from '@ember/helper'; export default class DemoComponent extends Component { columns = [ { key: 'name', name: 'Name' }, { key: 'email', name: 'Email' }, { key: 'role', name: 'Role' } ] as const satisfies ColumnConfig[]; } ``` **Options:** - `@size` - `sm`, `md` (default), `lg` - `@isStriped` - Alternating row colors - `@layout` - `auto` (default), `fixed` - `@classes` - Custom CSS classes ## Scrollable Tables Enable scrolling with fixed heights or widths: ```gts preview import Component from '@glimmer/component'; import { Table, type ColumnConfig } from 'frontile'; import { employees, type Employee } from 'site/components/table-demo-data'; import { hash } from '@ember/helper'; export default class DemoComponent extends Component { columns = [ { key: 'id', name: 'ID' }, { key: 'name', name: 'Name' }, { key: 'email', name: 'Email' }, { key: 'department', name: 'Department' }, { key: 'role', name: 'Role' } ] as const satisfies ColumnConfig[]; } ``` ## Sticky Elements Sticky parts of a table — a frozen header or footer, sticky rows, pinned columns — are painted with `surface-table`, the same opaque surface as the table itself, so they cover the rows and columns that scroll under them. Pinned columns keep their row's selection, hover, and striping. Tables assume they sit on a canvas-backed page. On any other background, point `--color-surface-table` at it — in CSS, or through `@classes`: ```gts
`, `` — so screen readers get row and column structure, dimensions, and cell navigation from the browser rather than from ARIA. That is the whole reason to reach for it over a grid of divs. Header cells carry `scope="col"`, which associates a column's data cells with its header for assistive technology. If you need a row header, put `scope="row"` on that cell yourself — the `...attributes` spread means it overrides the default. Two things the component cannot do for you: - **Give the table an accessible name** when the surrounding page doesn't already. Add a `
` in the default block, or `aria-labelledby` on the table pointing at a heading. - **Keep the markup semantic if you nest interactive content in cells.** A button inside a `
` is fine; a click handler on the `
``` Whatever you point it at has to be opaque. See [Surfaces](/docs/theming/design-tokens/surfaces) for the role itself. ### Sticky Header Keep the header visible while scrolling: ```gts preview import Component from '@glimmer/component'; import { Table, type ColumnConfig } from 'frontile'; import { users, type User } from 'site/components/table-demo-data'; import { hash } from '@ember/helper'; export default class DemoComponent extends Component { columns = [ { key: 'name', name: 'Name' }, { key: 'email', name: 'Email' }, { key: 'role', name: 'Role' } ] as const satisfies ColumnConfig[]; moreUsers = [ ...users, ...users.map((u, i) => ({ ...u, id: `${parseInt(u.id) + 3 + i}` })), ...users.map((u, i) => ({ ...u, id: `${parseInt(u.id) + 6 + i}` })) ]; } ``` ### Sticky Columns Pin columns to the left or right during horizontal scrolling: ```gts preview collapsible import Component from '@glimmer/component'; import { Table, type ColumnConfig } from 'frontile'; import { employees, type Employee } from 'site/components/table-demo-data'; import { hash } from '@ember/helper'; export default class DemoComponent extends Component { columns = [ { key: 'id', name: 'ID', isSticky: true, stickyPosition: 'left' }, { key: 'name', name: 'Name' }, { key: 'email', name: 'Email' }, { key: 'phone', name: 'Phone' }, { key: 'department', name: 'Department' }, { key: 'role', name: 'Role' }, { key: 'location', name: 'Location' }, { key: 'actions', name: 'Actions', isSticky: true, stickyPosition: 'right', value: () => 'Edit' } ] as const satisfies ColumnConfig[]; } ``` ### Sticky Rows Freeze specific rows by their keys: ```gts preview collapsible import Component from '@glimmer/component'; import { Table, type ColumnConfig } from 'frontile'; import { type User } from 'site/components/table-demo-data'; import { array, hash } from '@ember/helper'; export default class DemoComponent extends Component { columns = [ { key: 'id', name: 'ID' }, { key: 'name', name: 'Name' }, { key: 'email', name: 'Email' }, { key: 'role', name: 'Role' } ] as const satisfies ColumnConfig[]; items: User[] = [ { id: 'admin', name: 'Admin User', email: 'admin@example.com', role: 'Administrator' }, { id: '1', name: 'John Doe', email: 'john@example.com', role: 'Developer' }, { id: '2', name: 'Jane Smith', email: 'jane@example.com', role: 'Designer' }, { id: '3', name: 'Bob Johnson', email: 'bob@example.com', role: 'Manager' }, { id: 'guest', name: 'Guest User', email: 'guest@example.com', role: 'Read-only' } ]; } ``` ## Table Footer Display summary information with `@footerColumns`: ```gts preview import Component from '@glimmer/component'; import { Table, type ColumnConfig } from 'frontile'; import { products, type Product } from 'site/components/table-demo-data'; export default class DemoComponent extends Component { columns = [ { key: 'name', name: 'Product' }, { key: 'price', name: 'Price', value: (ctx) => `$${ctx.row.data.price}` }, { key: 'category', name: 'Category' } ] as const satisfies ColumnConfig[]; footerColumns = [ { key: 'label', name: 'Total Items' }, { key: 'total', name: '$1,109.97' }, { key: 'categories', name: '2 Categories' } ] as const satisfies ColumnConfig[]; } ``` **Sticky Footer:** Add `@isStickyFooter={{true}}` to keep the footer visible. ## Loading State Show loading indicators while data is being fetched: ```gts preview collapsible import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { Table, type ColumnConfig } from 'frontile'; import { products, type Product } from 'site/components/table-demo-data'; import { Button } from 'frontile'; import { Select } from 'frontile'; export default class DemoComponent extends Component { @tracked isLoading = true; @tracked loadingColor = 'primary'; columns = [ { key: 'name', name: 'Product' }, { key: 'price', name: 'Price', value: (ctx) => `$${ctx.row.data.price}` }, { key: 'category', name: 'Category' } ] as const satisfies ColumnConfig[]; colorOptions = [ { key: 'default', name: 'Default' }, { key: 'primary', name: 'Primary' }, { key: 'success', name: 'Success' }, { key: 'warning', name: 'Warning' }, { key: 'danger', name: 'Danger' } ]; @action toggleLoading() { this.isLoading = !this.isLoading; } @action updateLoadingColor(color: string) { this.loadingColor = color; } } ``` The hairline under the header is a subtle indicator, for tables that already have content and are merely refreshing it. When a table is loading with nothing on screen yet, prefer the built-in skeleton rows below instead. ### Built-in skeleton rows `@skeletonRows` renders placeholder rows while the table is loading and has no items. It is opt-in, and the row count is required — there is no default. Load the data below to watch the placeholders hand off to real rows. ```gts preview collapsible import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { on } from '@ember/modifier'; import { Table, Button, type ColumnConfig } from 'frontile'; import { users, type User } from 'site/components/table-demo-data'; export default class DemoComponent extends Component { columns = [ { key: 'name', name: 'Name' }, { key: 'email', name: 'Email' }, { key: 'role', name: 'Role' } ] as const satisfies ColumnConfig[]; @tracked isLoading = true; @tracked items: User[] = []; timer?: ReturnType; load = () => { this.reset(); this.timer = setTimeout(() => { this.items = users; this.isLoading = false; }, 1600); }; reset = () => { clearTimeout(this.timer); this.isLoading = true; this.items = []; }; willDestroy() { super.willDestroy(); clearTimeout(this.timer); } } ``` Placeholder rows fade in one after another, 60ms apart, so they arrive the way the real rows will rather than appearing as one block. The stagger stops growing after ten rows, and `prefers-reduced-motion` turns it off entirely. Skeleton rows render only when `@isLoading` is true **and** there are no items, so a refresh, a filter requery, or loading page two never throws away rows the user is already reading. `@emptyContent` and the `empty` block stay suppressed while they render, and a `loading` block takes precedence when present. Each placeholder cell is a [`Skeleton`](/docs/components/utilities/skeleton) component, sized from the table's `@size` so the bars match the row height. #### Shaping a column's placeholder A text bar is wrong for a column that holds an avatar. Set `skeleton` on the column to pick a shape — it accepts the same values as `Skeleton`'s `@shape`, and columns that omit it stay text bars. ```gts preview import { array } from '@ember/helper'; import { Table } from 'frontile'; const columns = [ { key: 'avatar', name: '', skeleton: 'circle' }, { key: 'name', name: 'Name' }, { key: 'thumb', name: 'Preview', skeleton: 'square' }, { key: 'role', name: 'Role' } ]; ``` Because `circle` and `square` reuse Avatar's size scale, a placeholder in an `@size="md"` table is the same 32px as the `` it stands in for. `skeleton` sets the shape and nothing else. For anything richer — cells that stack an icon, a name and a chip, or per-column widths — use `bodyTop` with the yielded columns and render `Skeleton` yourself for each piece of content. If neither the built-in skeleton rows nor a custom `bodyTop` layout fit — for example, an overlay that needs to sit over content that is already on screen — use the `loading` named block described next. ### Custom Loading Indicator Use the `loading` named block for custom indicators: ```gts preview collapsible import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { Table, type ColumnConfig } from 'frontile'; import { products, type Product } from 'site/components/table-demo-data'; import { Button } from 'frontile'; import { Spinner } from 'frontile'; export default class DemoComponent extends Component { @tracked isLoading = true; columns = [ { key: 'name', name: 'Product' }, { key: 'price', name: 'Price' }, { key: 'category', name: 'Category' } ] as const satisfies ColumnConfig[]; @action toggleLoading() { this.isLoading = !this.isLoading; } } ``` ## Empty State ### Custom Empty Content Display custom content when there are no items: ```gts preview import Component from '@glimmer/component'; import { Table, type ColumnConfig } from 'frontile'; import { type User } from 'site/components/table-demo-data'; import { Button } from 'frontile'; export default class DemoComponent extends Component { columns = [ { key: 'id', name: 'ID' }, { key: 'name', name: 'Name' }, { key: 'email', name: 'Email' } ] as const satisfies ColumnConfig[]; emptyItems: User[] = []; } ``` ### Simple Text Use `@emptyContent` for plain text messages: ```gts preview import Component from '@glimmer/component'; import { Table, type ColumnConfig } from 'frontile'; import { type User } from 'site/components/table-demo-data'; export default class DemoComponent extends Component { columns = [ { key: 'id', name: 'ID' }, { key: 'name', name: 'Name' }, { key: 'email', name: 'Email' } ] as const satisfies ColumnConfig[]; emptyItems: User[] = []; } ``` ## Custom Cell Rendering Use the `:cell` block for custom cell content. Alternatively, you can define a `Cell` component in the [column configuration](#column-level-cell-components) for reusable cell rendering. ```gts preview collapsible import Component from '@glimmer/component'; import { Table, type ColumnConfig } from 'frontile'; import { users, type User } from 'site/components/table-demo-data'; import { Avatar } from 'frontile'; import { Chip } from 'frontile'; export default class DemoComponent extends Component { columns = [ { key: 'name', name: 'Name' }, { key: 'email', name: 'Email' }, { key: 'role', name: 'Role' }, { key: 'status', name: 'Status' } ] as const satisfies ColumnConfig[]; } function eq(a: string | undefined, b: string) { return a === b; } ``` **Context:** - `c.column` - Column configuration - `c.row` - Row data - `c.value` - Computed cell value - `c.For` - Render for specific column key - `c.Default` - Fallback for unmatched columns ## Custom Header Rendering Customize column headers with the `header` block: ```gts preview collapsible import Component from '@glimmer/component'; import { Table, type ColumnConfig } from 'frontile'; import { users, type User } from 'site/components/table-demo-data'; import { UserIcon } from 'site/components/icons'; export default class DemoComponent extends Component { columns = [ { key: 'name', name: 'Full Name' }, { key: 'email', name: 'Email' }, { key: 'role', name: 'Role' } ] as const satisfies ColumnConfig[]; } function eq(a: string, b: string) { return a === b; } ``` ## Body Sections Add custom rows at the top or bottom of the table body: ```gts preview collapsible import Component from '@glimmer/component'; import { Table, type ColumnConfig } from 'frontile'; import { products, type Product } from 'site/components/table-demo-data'; export default class DemoComponent extends Component { columns = [ { key: 'name', name: 'Product' }, { key: 'price', name: 'Price', value: (ctx) => `$${ctx.row.data.price}` } ] as const satisfies ColumnConfig[]; get total() { return products.reduce((sum, p) => sum + p.price, 0).toFixed(2); } } ``` ### Column-aligned rows in `bodyTop` / `bodyBottom` `bodyTop` and `bodyBottom` yield the columns the table is actually rendering, along with style-bound `Row` and `Cell` components. Use these instead of hand-written ``/`` | the row's key, from `@getKey` | Use them for column-targeted styling and test selectors. Every element rendering one of Table's styled parts also carries a stable `data-part` attribute (kebab-cased from the slot name — e.g. `wrapper`, `table`, `thead`, `tbody`, `tr`, `th`, `td`, `sort-button`, `skeleton-row`), and the outermost wrapping `
` carries `data-component="table"` (it is the element that encloses everything Table renders, including the optional toolbar, so it is the one stable anchor for `[data-component="table"] ...` scoping). These mirror the slot names accepted by `@classes` and are safe to use as test selectors, e.g. `[data-part="tr"]`. ## Column Visibility Enable users to show/hide columns with the toolbar: ```gts preview import Component from '@glimmer/component'; import { Table, type ColumnConfig } from 'frontile'; import { users, type User } from 'site/components/table-demo-data'; export default class DemoComponent extends Component { columns = [ { key: 'id', name: 'ID', isVisible: true }, { key: 'name', name: 'Name', isVisible: true }, { key: 'email', name: 'Email', isVisible: false }, { key: 'role', name: 'Role', isVisible: true } ] as const satisfies ColumnConfig[]; } ``` **Initial State:** Set `isVisible: false` in column config to hide by default. The `<:toolbar>` block's own wrapping element is styled through the `toolbar` key on `@classes` (e.g. `@classes={{hash toolbar='...'}}`), the same way `wrapper` and `table` are. ## Sorting Enable column sorting with `isSortable` and `@onSort`: ```gts preview collapsible import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { Table, type ColumnConfig, type SortItem } from 'frontile'; import { type User } from 'site/components/table-demo-data'; export default class DemoComponent extends Component { @tracked items: User[] = [ { id: '1', name: 'Charlie', email: 'charlie@example.com', role: 'user' }, { id: '2', name: 'Alice', email: 'alice@example.com', role: 'admin' }, { id: '3', name: 'Bob', email: 'bob@example.com', role: 'user' } ]; columns = [ { key: 'name', name: 'Name', isSortable: true }, { key: 'email', name: 'Email', isSortable: true }, { key: 'role', name: 'Role' } ] as const satisfies ColumnConfig[]; handleSort = (items: User[], sort: SortItem) => { if (sort.direction === 'none') return items; return [...items].sort((a, b) => { const aVal = a[sort.property]; const bVal = b[sort.property]; if (!aVal || !bVal) return 0; if (sort.direction === 'ascending') { return aVal < bVal ? -1 : aVal > bVal ? 1 : 0; } return aVal > bVal ? -1 : aVal < bVal ? 1 : 0; }); }; } ``` **Features:** - Tri-state sorting: descending → ascending → none - Custom sort property: Use `sortProperty` to sort by a different field - Initial sort: Set with `@initialSort` ## Row Selection Enable row selection with `@selectionMode` for single or multiple row selection. ### Multiple Selection Use checkboxes for multi-select with `selectionMode="multiple"`: ```gts preview collapsible import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { Table, type ColumnConfig } from 'frontile'; import { type User } from 'site/components/table-demo-data'; export default class DemoComponent extends Component { @tracked selectedKeys = new Set(); items: User[] = [ { id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' }, { id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' }, { id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'user' } ]; columns = [ { key: 'name', name: 'Name' }, { key: 'email', name: 'Email' }, { key: 'role', name: 'Role' } ] as const satisfies ColumnConfig[]; handleSelectionChange = (keys: Set) => { this.selectedKeys = keys; }; } ``` ### Single Selection Use row clicks for single selection with `selectionMode="single"`: ```gts preview collapsible import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { Table, type ColumnConfig } from 'frontile'; import { type User } from 'site/components/table-demo-data'; export default class DemoComponent extends Component { @tracked selectedKeys = new Set(); items: User[] = [ { id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' }, { id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' }, { id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'user' } ]; columns = [ { key: 'name', name: 'Name' }, { key: 'email', name: 'Email' }, { key: 'role', name: 'Role' } ] as const satisfies ColumnConfig[]; handleSelectionChange = (keys: Set) => { this.selectedKeys = keys; }; get selectedUser() { const key = [...this.selectedKeys][0]; return this.items.find((item) => item.id === key); } } ``` ### Disabled Rows Prevent specific rows from being selected with `@disabledKeys`: ```gts preview collapsible import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { Table, type ColumnConfig } from 'frontile'; import { type User } from 'site/components/table-demo-data'; export default class DemoComponent extends Component { @tracked selectedKeys = new Set(); items: User[] = [ { id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' }, { id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' }, { id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'user' } ]; columns = [ { key: 'name', name: 'Name' }, { key: 'email', name: 'Email' }, { key: 'role', name: 'Role' } ] as const satisfies ColumnConfig[]; disabledKeys = ['1']; handleSelectionChange = (keys: Set) => { this.selectedKeys = keys; }; } ``` ### Custom Key Extraction Provide a custom function to extract unique keys from items: ```gts preview collapsible import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { Table, type ColumnConfig } from 'frontile'; interface Product { sku: string; name: string; price: number; } export default class DemoComponent extends Component { @tracked selectedKeys = new Set(); items: Product[] = [ { sku: 'ABC-123', name: 'Widget', price: 29.99 }, { sku: 'DEF-456', name: 'Gadget', price: 49.99 }, { sku: 'GHI-789', name: 'Doohickey', price: 19.99 } ]; columns = [ { key: 'sku', name: 'SKU' }, { key: 'name', name: 'Product' }, { key: 'price', name: 'Price' } ] as const satisfies ColumnConfig[]; getItemKey = (item: Product) => item.sku; handleSelectionChange = (keys: Set) => { this.selectedKeys = keys; }; } ``` ### Uncontrolled Selection The Table supports uncontrolled selection, where internal state is managed automatically. Omit `@selectedKeys` and provide `@onSelectionChange` to monitor selections: ```gts preview collapsible import Component from '@glimmer/component'; import { Table, type ColumnConfig } from 'frontile'; import { type User } from 'site/components/table-demo-data'; export default class DemoComponent extends Component { items: User[] = [ { id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' }, { id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' }, { id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'user' } ]; columns = [ { key: 'name', name: 'Name' }, { key: 'email', name: 'Email' }, { key: 'role', name: 'Role' } ] as const satisfies ColumnConfig[]; handleSelectionChange = (keys: Set) => { console.log('Selected keys:', Array.from(keys)); }; } ``` ### Keyboard Navigation When selection is enabled, rows follow the WAI-ARIA grid pattern: - **Tab**: Moves into the rows. The table is a single tab stop — it uses a roving `tabindex`, so exactly one row is tabbable at a time. That is the row focus was last on, else the first selected row, else the first row. - **Arrow Down** / **Arrow Up**: Move focus between rows, carrying the `tabindex="0"` along with focus. Focus wraps: down past the last row lands on the first, up past the first lands on the last. - **Home** / **End**: Move focus to the first / last row. - **Space** or **Enter**: Toggle selection (multiple mode) or select row (single mode) - Disabled rows (`@disabledKeys`) are marked `aria-disabled="true"`. Arrow keys, `Home` and `End` skip them entirely, and they never hold the tab stop — a table whose first row is disabled hands it to the first enabled row instead. They can still be focused with the pointer, but not selected. - Rows added, removed or reordered are picked up on their own; the tab stop and the navigation order always follow what is rendered. - Interactive content inside cells keeps its own keys: a button, link or input in a cell handles Enter, Space and the arrow keys itself, and the row does not toggle its selection. Row keyboard handling only applies to keys pressed on the row itself. This is the shared [`rovingFocus`](/docs/components/utilities/roving-focus) utility in vertical, manual-activation mode — arrows move focus only, and selection waits for Space or Enter. ```gts preview collapsible import Component from '@glimmer/component'; import { Table, type ColumnConfig } from 'frontile'; import { type User } from 'site/components/table-demo-data'; export default class DemoComponent extends Component { items: User[] = [ { id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' }, { id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' }, { id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'user' } ]; columns = [ { key: 'name', name: 'Name' }, { key: 'email', name: 'Email' }, { key: 'role', name: 'Role' } ] as const satisfies ColumnConfig[]; } ``` ### Selection Color Customize the selection highlight color with `@selectionColor`. Available colors: `default`, `primary`, `success`, `warning`, `danger`: ```gts preview collapsible import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { Table, type ColumnConfig } from 'frontile'; import { type User } from 'site/components/table-demo-data'; import { Select } from 'frontile'; export default class DemoComponent extends Component { @tracked selectedKeys = new Set(['1', '2']); @tracked selectionColor = 'primary'; items: User[] = [ { id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' }, { id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' }, { id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'user' } ]; columns = [ { key: 'name', name: 'Name' }, { key: 'email', name: 'Email' }, { key: 'role', name: 'Role' } ] as const satisfies ColumnConfig[]; colorOptions = [ { key: 'default', name: 'Default' }, { key: 'primary', name: 'Primary' }, { key: 'success', name: 'Success' }, { key: 'warning', name: 'Warning' }, { key: 'danger', name: 'Danger' } ]; handleSelectionChange = (keys: Set) => { this.selectedKeys = keys; }; @action updateSelectionColor(color: string) { this.selectionColor = color; } } ``` **Features:** - **Multiple selection**: Checkbox column with select all/none and indeterminate state - **Single selection**: Row clicks, no checkboxes - **Controlled/Uncontrolled modes**: Provide `@selectedKeys` for controlled, omit for uncontrolled - **Keyboard navigation**: Use Space or Enter keys to select rows - **Disabled rows**: Prevent selection with `@disabledKeys` - **Custom keys**: Use `@getKey` for non-standard key extraction - **Selection colors**: Customize highlight with `@selectionColor` (default, primary, success, warning, danger) - **Sticky selection column**: Auto-sticky on horizontal scroll - **Select all control**: Hide with `@showSelectAll={{false}}` ## Accessibility Table builds on [SimpleTable](./simple-table.md), so it inherits real table markup and `scope="col"` header cells — structure and navigation come from the browser rather than ARIA. On top of that: | Feature | What it exposes | | ------------------- | -------------------------------------------------------------------------------------------- | | Sortable column | `aria-sort` on the header cell, tracking `none` / `ascending` / `descending` | | Sort control | A real `
` so your rows stay aligned when a column is hidden via `ColumnVisibility` and when `@selectionMode="multiple"` adds its checkbox column. ```gts preview import { array } from '@ember/helper'; import { Table, Skeleton } from 'frontile'; const columns = [ { key: 'name', name: 'Name' }, { key: 'email', name: 'Email' } ]; ``` `b.columns` is the rendered column list, not the `@columns` argument you passed in. Treat `b.columns` as read-only. It is the table's live column list, not a copy — mutating it in place (`sort`, `push`, `splice`) will corrupt the header and the rendered rows. Copy it first if you need a different order. `b.Row` and `b.Cell` carry the table's resolved `@size` padding, sticky handling, and `@classes` overrides. The `loading` block yields `{ columns }` only — it renders inside a single spanning cell, so `Row` and `Cell` would not be valid there. Use it for an overlay or spinner over a table that already has content; use `bodyTop` for rows that stand in for content that has not arrived yet. ### Data attributes These are a supported contract, stable across minor versions: | Attribute | Element | Value | | ------------- | ------- | ----------------------------- | | `data-key` | `` | the column's `key` | | `data-column` | `` | the column's `key` | | `data-key` | `
`, or `aria-labelledby` pointing at the heading above it. - **Better selection labels when rows are identifiable.** Every row checkbox is labelled "Select row", which is unambiguous only when a screen reader user is already inside the row. If your rows have a natural name, render your own checkbox in a cell with a label that includes it. Sticky headers and footers are positioned with CSS and do not change the reading order. ## API ### Table **Element:** `HTMLTableElement` **Arguments** | Name | Type | Default | Description | | --- | --- | --- | --- | | `columns *` | `TColumns` | - | Array of column configurations for automatic table generation | | `items *` | `T[]` | - | Array of data items to display in the table | | `classes` | `SlotsToClasses<'table' \| 'tbody' \| 'td' \| 'tfoot' \| 'th' \| 'thead' \| 'tr' \| 'separator' \| 'wrapper' \| 'empty' \| 'toolbar' \| 'sortButton' \| 'sortIcon' \| 'columnVisibilityButton' \| 'columnVisibilityIcon' \| 'skeleton' \| 'skeletonRow'>` | - | Custom CSS classes for different table elements (wrapper, table, th, td, etc.) | | `disabledKeys` | `string[]` | - | Array of keys that should be disabled from selection | | `emptyContent` | `ContentValue` | - | Content to display when no data items are provided | | `footerColumns` | `ColumnConfig[]` | - | Array of column configurations for automatic footer generation | | `getKey` | `(item: T) => string` | - | Function to extract unique key from an item. Defaults to using keyAndLabelForItem helper. | | `initialSort` | `SortItem` | - | Initial sort descriptor to apply on mount. Only used for initial render, subsequent changes are ignored. | | `isLoading` | `boolean` | - | Enable loading state styling and behavior | | `isScrollable` | `boolean` | - | Enable scrolling for the table container | | `isStickyFooter` | `boolean` | - | Make the table footer sticky during vertical scrolling | | `isStickyHeader` | `boolean` | - | Make the table header sticky during vertical scrolling | | `isStriped` | `boolean` | - | Enable striped rows (alternating background colors) | | `layout` | `'auto' \| 'fixed'` | `'auto'` | Table layout algorithm - 'auto' sizes columns by content, 'fixed' uses first row for sizing. | | `loadingColor` | `'default' \| 'primary' \| 'success' \| 'warning' \| 'danger'` | `'default'` | Color variant for loading animation. | | `onSelectionChange` | `(selectedKeys: Set) => void` | - | Callback when selection changes | | `onSort` | `(items: T[], sortDescriptor: SortItem) => T[]` | - | Function to sort items when sorting is triggered. Receives the items array and sort descriptor. Returns sorted items. | | `selectedKeys` | `any` | - | Set of selected item keys | | `selectionColor` | `'default' \| 'primary' \| 'success' \| 'warning' \| 'danger'` | `'primary'` | Color variant for selection highlight. | | `selectionMode` | `SelectionMode` | `'none'` | Selection mode - 'none' (default), 'single' (row clicks only), or 'multiple' (with checkboxes). | | `showSelectAll` | `boolean` | `true` | Show "select all" checkbox in header (only for multiple selection mode). | | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Size variant for table cells and headers. | | `skeletonRows` | `number` | - | Number of placeholder rows to render while loading with no items. Omit or 0 for no skeleton. | | `stickyKeys` | `string[]` | - | Array of item keys that should be sticky during vertical scrolling | **Blocks** | Name | Type | Default | Description | | --- | --- | --- | --- | | `default *` | `never` | - | | | `toolbar *` | `[{ ColumnVisibility: ColumnVisibility (tableInstance bound); }]` | - | | | `cell *` | `[{ column: Column; value: ContentValue; row: Row; For: CellFor (column, registry bound); Default: CellDefault (column, registry bound); }]` | - | | | `empty *` | `[]` | - | | | `header *` | `[{ column: Column; isSortable: boolean; sortDirection: string; isSorted: boolean; onSort: () => void; }]` | - | | | `loading *` | `[{ columns: Column[]; }]` | - | | | `bodyTop *` | `[{ columns: Column[]; Row: SimpleTableRow (styleFns, classes bound); Cell: SimpleTableCell (styleFns, classes bound); }]` | - | | | `bodyBottom *` | `[{ columns: Column[]; Row: SimpleTableRow (styleFns, classes bound); Cell: SimpleTableCell (styleFns, classes bound); }]` | - | | ### ColumnConfig ```ts interface ColumnConfig { key: string; name: string; value?: (ctx: CellContext) => ContentValue; isSticky?: boolean; stickyPosition?: 'left' | 'right'; isVisible?: boolean; isSortable?: boolean; sortProperty?: string; Cell?: ComponentLike>; } ``` ### CellSignature ```ts interface CellSignature { Args: { row: { data: T }; column: ColumnConfig; value?: ContentValue; }; } ``` --- # Calendar Source: /docs/components/collections/calendar.md # Calendar A month-grid date picker for choosing a single day or a range, with full keyboard navigation and localization through `Intl`. Calendar renders no popover, trigger, or text input of its own—you can compose it with an input and popover to build a date picker. ## Import ```js import { Calendar } from 'frontile/collections'; ``` ## Usage ```gts preview import { Calendar } from 'frontile/collections'; const today = new Date(); ``` The first visible month is resolved in this order: `@defaultMonth`, then the month of `@defaultValue`, then the month of `@value`, then today. A calendar seeded with a selection opens showing that selection rather than today. ## Anatomy Calendar provides four optional named blocks for replacing or extending its rendered parts: | Block | Purpose | | ------------ | --------------------------------------------------------------------- | | `<:header>` | Replaces the month caption and previous/next controls. | | `<:weekday>` | Replaces each localized weekday label. | | `<:day>` | Replaces the contents of each day button. | | `<:footer>` | Adds content below the month grid, such as presets or selection help. | The default rendering is available when a block is omitted. See [Custom rendering](#custom-rendering) for the values yielded to each block. ## Controlled Pass `@value` and `@onChange` to own the selection yourself. Passing `@value` at all — including as `undefined` — puts selection in controlled mode; omit it entirely for uncontrolled use as in the demo above. ```gts preview import { Calendar } from 'frontile/collections'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; export default class ControlledExample extends Component { @tracked value: Date | null = new Date(); get label(): string { return this.value ? `Selected: ${this.value.toDateString()}` : 'No date selected'; } handleChange = (value: Date | null): void => { this.value = value; }; } ``` ## Range Set `@mode="range"` to select a start and end day; the first click sets the anchor and the second commits the range. `@visibleMonths={{2}}` shows two months side by side, which is the usual pairing for a range picker. ```gts preview import { Calendar } from 'frontile/collections'; ``` A controlled range `@value` whose `end` is `null` is a half-open, mid-interaction state — Calendar won't paint a range from it. Pass a full `{ start, end }` object once both ends are chosen. `@showOutsideDays` defaults to `true` with one visible month and `false` once `@visibleMonths` is greater than one, where a boundary date would otherwise appear in both grids. Passing an explicit value always wins, in either direction. `@pageBehavior` controls how far the previous/next buttons move: `'visible'` (the default) pages by the whole window, `'single'` always by one month. `@fixedWeeks` renders six week rows in every month, so the calendar's height doesn't change as you page. ## Min and max `@minValue` and `@maxValue` bound which days are selectable. Navigation is bounded too: the previous/next buttons disable once paging would land entirely outside the bounds, and the month and year pickers offer only the months and years the bounds allow. `@isDateUnavailable` does not affect navigation — you can still page to a month whose every day is unavailable. ```gts preview import { Calendar } from 'frontile/collections'; const septemberFirst = new Date(2026, 8, 1); const minValue = new Date(2026, 8, 5); const maxValue = new Date(2026, 8, 20); ``` ## Unavailable dates `@isDateUnavailable` marks specific days as present but not selectable — a holiday, a booked night — shown struck through rather than dimmed. It's distinct from `@minValue`/ `@maxValue`: an unavailable day is still in range, just not choosable. ```gts preview import { Calendar } from 'frontile/collections'; function isWeekend(date: Date): boolean { const day = date.getDay(); return day === 0 || day === 6; } ``` In range mode, an unavailable date also blocks any range from being drawn across it — once one endpoint is chosen, days on the far side of an unavailable day become unreachable. Three kinds of day look muted, and they don't all behave the same way: | Day | Looks | Selectable | | --------------------------------- | -------------- | --------------------------------------------------- | | Outside `@minValue`/`@maxValue` | Dimmed | No | | Matched by `@isDateUnavailable` | Struck through | No | | Belonging to a neighbouring month | Dimmed | Yes — selecting it pages the calendar to that month | ## Month and year dropdowns `@captionLayout="dropdown"` replaces the plain month/year caption with a native month `` — so a pending range can always be canceled without first tabbing back into the grid. In range mode the first click emits a half-open `{ start, end: null }`, and `Escape` emits `null` to retract it — so a controlled `@value` is cleared rather than left holding a start date. Each month grid has `role="grid"` with an accessible label naming the month and year, and day cells use `role="gridcell"` with `aria-selected`. Each day button also carries a full `aria-label` (weekday, month, day, and year) built from `Intl.DateTimeFormat`, so crossing a month boundary with the arrow keys announces the complete new date rather than a bare day-of-month number. The month caption is also announced through a visually hidden live region when navigation changes it, so month changes reach screen reader users even though focus stays on the grid. `@autofocus` moves DOM focus into the grid on insert and only then; rendering a calendar otherwise never moves focus. `@isReadOnly` marks each grid `aria-readonly="true"` so assistive technology knows the days are inert. ## API ### Calendar **Element:** `HTMLDivElement` **Arguments** | Name | Type | Default | Description | | --- | --- | --- | --- | | `autofocus` | `boolean` | `false` | Moves DOM focus into the grid on insert. This is the only thing that may focus the calendar on mount -- rendering a calendar must never otherwise steal focus. | | `captionLayout` | `'label' \| 'dropdown'` | `'label'` | 'label' renders the plain month/year caption; 'dropdown' swaps it for a native month ` for form submission. Use `Autocomplete` when the list is long enough that typing beats scrolling — assigning a country, a time zone, a teammate. Use `Select` when scanning a short list is faster than typing, or when you need multiple selection (`Select` supports `@selectionMode="multiple"` together with `@isFilterable`); `Autocomplete` is single-selection only. ## Import ```js import { Autocomplete } from 'frontile'; ``` ## Usage ### Basic Autocomplete Type into the input to filter the options. The default filter ranks by relevance, so the closest match is listed first — and it matches acronyms (`nz` finds "New Zealand") as well as substrings. Pass `@filter` to score or match options yourself. ```gts preview import { Autocomplete } from 'frontile'; const countries = [ 'Argentina', 'Australia', 'Brazil', 'Canada', 'Denmark', 'France', 'Germany', 'Japan', 'Mexico', 'Netherlands', 'New Zealand', 'Portugal', 'South Korea', 'Spain', 'United Kingdom', 'United States' ]; ``` ### Selection Pass `@selectedKey` and update it in `@onSelectionChange` to maintain two-way binding, the same data flow as `Select`. ```gts preview collapsible import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { Autocomplete } from 'frontile'; const languages = [ 'Elixir', 'Go', 'JavaScript', 'Python', 'Ruby', 'Rust', 'TypeScript' ]; export default class LanguagePicker extends Component { @tracked selectedKey: string | null = null; onSelectionChange = (key: string | null) => { this.selectedKey = key; }; } ``` ### Async search with an API Pass `@onSearch` to load options from an API as the user types. The component debounces calls (250ms by default, tune with `@searchDebounce`), shows a loading spinner while the returned promise is pending, and ignores stale responses so the latest query always wins. Clearing the input restores `@items` without triggering a search. This example simulates a request with network latency: ```gts preview collapsible import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { Autocomplete } from 'frontile'; interface City { key: string; label: string; } const cities: City[] = [ { key: 'amsterdam', label: 'Amsterdam' }, { key: 'barcelona', label: 'Barcelona' }, { key: 'berlin', label: 'Berlin' }, { key: 'buenos-aires', label: 'Buenos Aires' }, { key: 'lisbon', label: 'Lisbon' }, { key: 'london', label: 'London' }, { key: 'melbourne', label: 'Melbourne' }, { key: 'mexico-city', label: 'Mexico City' }, { key: 'new-york', label: 'New York' }, { key: 'sao-paulo', label: 'São Paulo' }, { key: 'seoul', label: 'Seoul' }, { key: 'tokyo', label: 'Tokyo' } ]; // Stand-in for a real API call, e.g. fetch(`/api/cities?q=${query}`) const searchCities = (query: string): Promise => { return new Promise((resolve) => { setTimeout(() => { resolve( cities.filter((city) => city.label.toLowerCase().includes(query.toLowerCase()) ) ); }, 600); }); }; export default class CitySearch extends Component { @tracked selectedKey: string | null = null; onSelectionChange = (key: string | null) => { this.selectedKey = key; }; } ``` Pass `@searchMessage` (or a `:searchMessage` block for rich content) to prompt users who open the dropdown before typing — it shows while the query is blank and there are no options to display, as in the example above. Without it, an opened async autocomplete with no default `@items` shows the empty content instead. ### External filtering If you want full control over filtering — for example, filtering server-side while controlling the request lifecycle yourself — pass `@disableFiltering={{true}}` and update `@items` from `@onInputChange`: ```gts preview collapsible import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { Autocomplete } from 'frontile'; const allTimezones = [ 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/New_York', 'Asia/Seoul', 'Asia/Tokyo', 'Australia/Sydney', 'Europe/Berlin', 'Europe/Lisbon', 'Europe/London' ]; export default class TimezonePicker extends Component { @tracked items = allTimezones; onInputChange = (value: string) => { // Replace with your own request/filter logic this.items = allTimezones.filter((tz) => tz.toLowerCase().includes(value.toLowerCase()) ); }; } ``` ### Custom filter Pass `@filter` to change how items match the typed text — here, matching only from the start of the word: ```gts preview import { Autocomplete } from 'frontile'; const fruits = ['Apple', 'Apricot', 'Banana', 'Cherry', 'Grape', 'Pineapple']; const startsWith = (itemValue: string, inputValue: string) => itemValue.toLowerCase().startsWith(inputValue.toLowerCase()); ``` ### Custom items Use the `:item` block to render richer options. Objects with `key` and `label` properties work out of the box. ```gts preview collapsible import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { Autocomplete } from 'frontile'; const teammates = [ { key: 'ana', label: 'Ana Souza', role: 'Design' }, { key: 'devon', label: 'Devon Lane', role: 'Engineering' }, { key: 'kim', label: 'Kim Park', role: 'Product' }, { key: 'marta', label: 'Marta Silva', role: 'Engineering' }, { key: 'ravi', label: 'Ravi Patel', role: 'Support' } ]; export default class AssigneePicker extends Component { @tracked selectedKey: string | null = null; onSelectionChange = (key: string | null) => { this.selectedKey = key; }; } ``` ### Custom values By default the input reverts to the selected option's label when the dropdown closes. Pass `@allowsCustomValue={{true}}` to keep whatever the user typed — useful when suggestions are helpful but not required. Read the final text via `@onInputChange`. ```gts preview import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { Autocomplete } from 'frontile'; const commonRoles = ['Admin', 'Editor', 'Viewer']; export default class RoleInput extends Component { @tracked value = ''; onInputChange = (value: string) => { this.value = value; }; } ``` ### Clear button Pass `@isClearable={{true}}` to show a button that clears both the selection and the typed text. Like `Select`, this overrides `@allowEmpty` — that argument governs deselecting an option in the listbox, while the clear button is the affordance for emptying the field. No clear button renders on a disabled Autocomplete. ```gts preview import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { Autocomplete } from 'frontile'; const browsers = ['Chrome', 'Edge', 'Firefox', 'Safari']; export default class BrowserPicker extends Component { @tracked selectedKey: string | null = 'Firefox'; onSelectionChange = (key: string | null) => { this.selectedKey = key; }; } ``` ### Sizes and validation `Autocomplete` accepts the same form control arguments as other Frontile inputs: `@label`, `@description`, `@errors`, `@isInvalid`, `@isRequired`, and `@inputSize`. `@onBlur` (and so `blur` in a Field's `@validateOn`) fires only once focus has left the whole control — the input and its dropdown. Picking an option moves focus into the dropdown, so a selection is never reported as a blur. ```gts preview import { Autocomplete } from 'frontile'; import { array } from '@ember/helper'; const plans = ['Free', 'Pro', 'Enterprise']; ``` ## Keyboard interaction | Key | Action | | --- | --- | | Type characters | Opens the dropdown and filters the options | | ArrowDown / ArrowUp | Opens the dropdown / moves the highlight | | Enter | Selects the highlighted option | | Escape | Closes the dropdown | | Home / End | Moves the highlight to the first / last option | ## Accessibility The input uses `role="combobox"` with `aria-autocomplete="list"`, `aria-expanded`, and `aria-controls` pointing at the popover. Focus stays on the input while ArrowUp/ArrowDown move a virtual highlight communicated through `aria-activedescendant`. A visually hidden native ` plus a year trigger that opens a year-grid picker. | | `classes` | `SlotsToClasses<'base' \| 'footer' \| 'input' \| 'icon' \| 'innerContainer' \| 'startContent' \| 'endContent' \| 'clearButton' \| 'placeholder' \| 'calendar'>` | - | | | `closeOnEscapeKey` | `boolean` | `true` | Whether to close when the escape key is pressed | | `closeOnOutsideClick` | `boolean` | `true` | Whether to close when the area outside (the backdrop) is clicked | | `color` | `'neutral' \| 'primary' \| 'secondary' \| 'tertiary' \| 'success' \| 'warning' \| 'danger'` | `'primary'` | The color used for the selected day and the range band. | | `defaultValue` | `DatePickerRangeInput \| DatePickerInput` | - | Seeds the range before any @value is supplied. Seeds the value before any @value is supplied. | | `description` | `string` | - | Help text rendered between the label and the control, and referenced by the ids describedBy returns. | | `didClose` | `() => void` | - | Callback when closing has finished, including any exit transition. | | `disableTransitions` | `boolean` | `false` | Disable css transitions | | `endContentPointerEvents` | `'none' \| 'auto'` | `'none'` | Whether the cluster at the end of the field (the calendar icon, or the clear button) receives pointer events. Defaults to 'none' so that a click anywhere in the field -- the icon included -- falls through to the trigger and opens the picker. The clear button opts back in on its own. | | `errors` | `string \| string[]` | - | Validation messages for the field. A non-empty value also marks the control invalid, and an array is joined with ; when displayed. | | `fixedWeeks` | `boolean` | `false` | Renders six week rows in every month, so the calendar keeps the same height as you page between months of different lengths. | | `flipOptions` | `{ padding?: Padding; mainAxis?: boolean; crossAxis?: boolean \| 'alignment'; fallbackPlacements?: Placement[]; fallbackStrategy?: 'bestFit' \| 'initialPlacement'; fallbackAxisSideDirection?: 'start' \| ... 1 more ... \| 'none'; ... 4 more ...; boundary?: Boundary; }` | - | Options for the floating-ui flip middleware, which moves the content to the opposite side when it would overflow the viewport. | | `formatOptions` | `Object` | `{ dateStyle: 'medium' }` | How the value is rendered in the trigger. Localized with @locale. | | `id` | `string` | - | The unique identifier for the control. | | `inputSize` | `'sm' \| 'md' \| 'lg'` | - | The size of the field. Matches Select's @inputSize. | | `isClearable` | `boolean` | `false` | Whether a clear button replaces the calendar icon when there is a value. | | `isDateUnavailable` | `(date: Date) => boolean` | - | Marks a date as present but unselectable -- a holiday, a booked night. Distinct from @minValue/@maxValue, which put a date out of range entirely. | | `isDisabled` | `boolean` | `false` | Whether the field is disabled. FormControl passes this through for styling; the control it wraps is responsible for the disabled attribute. | | `isInvalid` | `boolean` | `false` | Marks the control invalid without supplying messages, for validation that is reported elsewhere. | | `isReadOnly` | `boolean` | `false` | Allows paging between months but blocks selecting a day. Unlike @isDisabled, the days stay focusable so the calendar can still be read with the keyboard. | | `isRequired` | `boolean` | `false` | Whether the field is required. Adds an asterisk to the label; it does not set the required attribute on the control itself. | | `label` | `string` | - | The label text rendered above the control and associated with it via for. Use the :label block instead when the label needs markup. | | `locale` | `string` | - | BCP-47 tag. All human-readable text is produced by Intl from this. | | `maxValue` | `Object` | - | Latest selectable date. Also clamps month navigation. | | `middleware` | `{ name: string; options?: any; fn: (state: { placement: Placement; strategy: Strategy; x: number; y: number; initialPlacement: Placement; middlewareData: MiddlewareData; rects: ElementRects; platform: Platform; elements: Elements; }) => Promisable<...>; }[]` | - | Additional floating-ui middleware for the popover positioning the calendar, beyond what placement, offsetOptions, flipOptions, and shiftOptions cover. Forwarded to Popover's own @middleware. | | `minValue` | `Object` | - | Earliest selectable date. Also clamps month navigation. | | `mode` | `'single' \| 'range'` | `'single'` | Switches the picker, and the calendar it wraps, to range selection. Selects a single day. Set @mode="range" for a start/end range instead. | | `name` | `string` | - | The name the value submits under. See the hidden inputs in date-picker.gts. | | `offsetOptions` | `OffsetOptions` | `5` | | | `onBlur` | `() => void` | - | Fires when focus leaves the trigger and the popover. | | `onChange` | `((value: DateRange) => void) \| ((value: Date) => void)` | - | Fires with the { start, end } range, or null after clearing. Fires with the picked Date, or null after clearing. | | `placeholder` | `string` | - | Text shown in the trigger when there is no value. | | `placement` | `'bottom' \| 'left' \| 'right' \| 'top' \| 'top-start' \| 'top-end' \| 'right-start' \| 'right-end' \| 'bottom-start' \| 'bottom-end' \| 'left-start' \| 'left-end'` | `'bottom-start'` | Placement of the menu when open | | `popoverSize` | `'sm' \| 'md' \| 'lg' \| 'trigger' \| 'xl' \| 'auto'` | `'auto'` | The width of the popover holding the calendar. Defaults to 'auto' -- the calendar's own width is the meaningful one, and it changes with @visibleMonths, so a fixed width would clip the grid. 'trigger' matches the field's width; the named sizes are fixed. | | `renderInPlace` | `boolean` | `false` | Whether to render in place or in the specified/default destination | | `shiftOptions` | `{ padding?: Padding; mainAxis?: boolean; crossAxis?: boolean; rootBoundary?: RootBoundary; elementContext?: ElementContext; altBoundary?: boolean; limiter?: { ...; }; boundary?: Boundary; }` | - | Options for the floating-ui shift middleware, which nudges the content along its axis to keep it in view. | | `showOutsideDays` | `boolean` | ``true` when a single month is visible, `false` once `@visibleMonths` is greater than one -- otherwise a boundary date would render twice, once per adjacent grid.` | Whether days from the adjacent month fill out a grid's leading/trailing weeks. | | `strategy` | `Strategy` | `'absolute'` | | | `target` | `string \| Element` | - | The target where to render the portal. There are 3 options: 1) Element object, 2) element id, 3) portal target name. For element id, string must be prefixed with #. If no value is passed in, we will render to the closest unnamed portal target, parent portal or document.body. | | `transitionDuration` | `number` | `200` | Duration of the animation | | `value` | `DatePickerRangeInput \| DatePickerInput` | - | A { start, end } pair, each a Date or a yyyy-MM-dd string; end may be null while only the anchor is chosen. Synced the same way as single mode's @value: setting it replaces the displayed range, undefined is ignored, and the field updates on its own as the user picks. A Date, or the same yyyy-MM-dd string this component writes to its hidden input. The field keeps its own selection and syncs from this argument: setting it replaces what is displayed, while picking a date updates the field immediately rather than waiting for @value to come back. undefined is ignored, which is why a -bound picker — which always passes a value key — still honors @defaultValue before form data exists. | | `visibleMonths` | `number` | `1` | How many months to render side by side, starting from the visible month. | | `weekStartsOn` | `WeekDay` | - | Overrides the first day of week implied by @locale. | **Blocks** | Name | Type | Default | Description | | --- | --- | --- | --- | | `value *` | `[DatePickerValueBlockArg]` | - | | | `calendar *` | `[DatePickerCalendarArgs]` | - | | | `footer *` | `[DatePickerFooterArg]` | - | | --- # Field Source: /docs/components/forms/field.md # Field A component wrapper that provides conveniences for form fields when using built-in validation. It automatically binds the appropriate form errors by name to yielded components, simplifying error handling in validated forms. **Important:** The Field component is designed specifically for use with the Form component's built-in validation system (`@schema` or `@validate` props). It should only be used when implementing form validation. `@name` is looked up verbatim in the form's `errors` object and read out of the form data with Ember's `get`, so dotted paths work — including numeric segments for array elements. `@name='items.0.name'` picks up an issue whose path is `['items', 0, 'name']`; the index is part of the key, so `items.0.name` and `items.1.name` never collide, and neither collapses onto `items.name`. ## Import ```js import { Form } from 'frontile'; // Field is yielded from Form when using validation ``` ## Usage ### Basic Field with Validation The Field component is yielded from the Form component when validation is configured. It automatically passes validation errors to the appropriate form components. ```gts preview collapsible import Component from '@glimmer/component'; import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; import { Form, type FormResultData } from 'frontile'; import { Button } from 'frontile'; import * as v from 'valibot'; // Define validation schema const schema = v.object({ email: v.pipe( v.string(), v.nonEmpty('Email is required'), v.email('Please enter a valid email address') ), username: v.pipe( v.string(), v.nonEmpty('Username is required'), v.minLength(3, 'Username must be at least 3 characters') ) }); type Schema = v.InferOutput; export default class BasicFieldExample extends Component { @tracked formData: Schema = { username: 'rememberme' }; handleFormChange = (result: FormResultData) => { this.formData = result.data; }; handleFormSubmit = (result: FormResultData) => { console.log('Form submitted:', result); }; @action changeEmail() { this.formData.email = 'test@test.com'; this.formData = this.formData; } } ``` ### Field with Multiple Component Types The Field component yields bound versions of all form components, each automatically receiving the correct errors for its field name. ```gts preview collapsible import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { Form, type FormResultData } from 'frontile'; import { Button } from 'frontile'; import * as v from 'valibot'; const schema = v.object({ name: v.pipe(v.string(), v.nonEmpty('Name is required')), bio: v.pipe( v.string(), v.nonEmpty('Bio is required'), v.minLength(10, 'Bio must be at least 10 characters') ), accountType: v.pipe( v.fallback(v.string(), ''), v.string(), v.nonEmpty('Please select an account type') ), newsletter: v.pipe( v.boolean(), v.literal(true, 'You must subscribe to continue') ) }); type Schema = v.InferOutput; export default class FieldComponentTypes extends Component { @tracked formData: Schema = { accountType: 'personal', newsletter: true }; accountTypes = [ { label: 'Personal', key: 'personal' }, { label: 'Business', key: 'business' }, { label: 'Enterprise', key: 'enterprise' } ]; handleFormSubmit = (data: FormResultData) => { console.log('Form submitted:', data); }; } ``` ### Field with Custom Validation Use custom validation functions alongside Field components for complex validation logic. ```gts preview collapsible import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { Form, type FormResultData } from 'frontile'; import { Button } from 'frontile'; import * as v from 'valibot'; const schema = v.object({ password: v.pipe( v.string(), v.nonEmpty('Password is required'), v.minLength(8, 'Password must be at least 8 characters'), v.regex(/[A-Z]/, 'Must contain an uppercase letter'), v.regex(/[0-9]/, 'Must contain a number') ), confirmPassword: v.pipe( v.string(), v.nonEmpty('Please confirm your password') ) }); type Schema = v.InferOutput; export default class CustomValidationField extends Component { // Custom validator for password matching customValidator = (data: Schema) => { if (data['password'] !== data['confirmPassword']) { return [ { message: 'Passwords must match', path: [{ key: 'confirmPassword' }] } ]; } }; handleFormSubmit = (data: FormResultData) => { console.log('Form submitted:', data); }; } ``` ### Field with Radio and Checkbox Groups `RadioGroup` supports field-level validation (validates on change/blur/input events based on `@validateOn`), while `CheckboxGroup` currently only supports validation on form submit. Both components display validation errors and work with `form.Field`, but only RadioGroup will automatically validate when the user changes their selection before submitting the form. **Note on Radio Components:** When using individual `field.Radio` components (not within `field.RadioGroup`), you must manually specify the `@value` prop on each radio to identify it uniquely (e.g., `@value="junior"`, `@value="senior"`). The Field component automatically binds the form data to the `@checkedValue` parameter to control which radio is selected. For most use cases, prefer using `field.RadioGroup` which handles this binding automatically. ```gts preview collapsible import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { Form, type FormResultData } from 'frontile'; import { Button } from 'frontile'; import * as v from 'valibot'; const schema = v.object({ experience: v.pipe( v.fallback(v.string(), ''), v.string(), v.nonEmpty('Please select your experience level') ), skills: v.pipe( v.array(v.string()), v.custom((value) => { return value.length >= 2; }, 'Please select at least 2 skills') ) }); type Schema = v.InferOutput; export default class FieldWithGroups extends Component { handleFormSubmit = (data: FormResultData) => { console.log('Form submitted:', data); }; } ``` ### Complex Form with Multiple Fields Field components used together in a realistic form. ```gts preview collapsible import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { Form, type FormResultData } from 'frontile'; import { Button } from 'frontile'; import * as v from 'valibot'; const schema = v.object({ // Personal Information firstName: v.pipe( v.string(), v.nonEmpty('First name is required'), v.minLength(2, 'Must be at least 2 characters') ), lastName: v.pipe( v.string(), v.nonEmpty('Last name is required'), v.minLength(2, 'Must be at least 2 characters') ), email: v.pipe( v.string(), v.nonEmpty('Email is required'), v.email('Please enter a valid email') ), phone: v.pipe( v.string(), v.nonEmpty('Phone number is required'), v.regex(/^\+?[\d\s-()]+$/, 'Please enter a valid phone number') ), // Address Information country: v.pipe( v.fallback(v.string(), ''), v.string(), v.nonEmpty('Please select a country') ), bio: v.pipe( v.string(), v.nonEmpty('Bio is required'), v.minLength(20, 'Bio must be at least 20 characters'), v.maxLength(500, 'Bio must be less than 500 characters') ), // Preferences contactMethod: v.pipe( v.fallback(v.string(), ''), v.string(), v.nonEmpty('Please select a contact method') ), notifications: v.pipe( v.array(v.string()), v.custom((value) => { return value.length >= 1; }, 'Please select at least 1 notification method') ), // Legal terms: v.pipe(v.boolean(), v.literal(true, 'You must accept the terms')), privacy: v.pipe( v.boolean(), v.literal(true, 'You must accept the privacy policy') ) }); type Schema = v.InferOutput; export default class CompleteFieldForm extends Component { @tracked submitMessage = ''; countries = [ { label: 'United States', key: 'us' }, { label: 'Canada', key: 'ca' }, { label: 'United Kingdom', key: 'uk' }, { label: 'Germany', key: 'de' }, { label: 'France', key: 'fr' } ]; handleFormSubmit = async (data: FormResultData) => { // Simulate API call await new Promise((resolve) => setTimeout(resolve, 1500)); this.submitMessage = 'Registration completed successfully!'; console.log('Form submitted:', data); }; } ``` ## Select Components with Field When using select components **outside of `Field`**, you use the generic `Select` component. Rendering of single vs. multi is handled by `Select`. However, when using select components **with `Field`**, you must explicitly specify which variant you need: - **`field.SingleSelect`** - For selecting a single item - **`field.MultiSelect`** - For selecting multiple items `DatePicker` splits the same way, and for the same reason — `field.DatePicker` picks one date, `field.DateRangePicker` picks a `{ start, end }` range: ```gjs ``` A range submits two dotted names, `stay.start` and `stay.end`, which `Form` unflattens into a single `{ stay: { start, end } }`. This distinction allows the Field component to properly bind values to the components. ### Field Select (with validation) ```gts {{! Must specify SingleSelect or MultiSelect explicitly }} ``` **Key Differences:** - With Field: Use `field.SingleSelect` or `field.MultiSelect` - Field automatically handles value binding based on the component type ## Automatic Error Binding The Field component automatically: - Extracts errors for the specified field name from the form's validation errors - Passes these errors to any yielded form component - Updates error display in real-time as validation occurs ## Automatic Value Binding When used with a Form component that provides `@data`, Field automatically binds values to form controls: - Extracts the current value for the field from `form.data` - Passes the appropriate value prop to each component type - Provides a no-op change handler, which puts the component in controlled mode so that form-level `@onChange` handles every update **Example:** ```gts
{{! Value automatically bound from formData.email }} {{! onChange automatically handled by Form }}
``` ## Field-Level Validation When the Form component's `@validateOn` argument includes `'change'` or `'input'` (defaults to `['change', 'submit']`), Field automatically validates individual fields as users interact with them. This provides feedback without waiting for form submission. **Change Validation (`'change'`)** Validates when a field loses focus (blur) after being modified, so errors appear once the user has moved on to the next field. **Note:** The `'change'` option validates on the HTML `change` event, which fires when a field loses focus (blur) after its value has been modified. It does NOT fire on every keystroke. **Input Validation (`'input'`)** Validates as the user types, so errors appear and clear on every keystroke. **Note:** The `'input'` option validates on the HTML `input` event, which fires on every keystroke as the user types. This provides immediate feedback but may be distracting for some use cases. **Example with change validation enabled (default):** ```gts import * as v from 'valibot'; const schema = v.object({ email: v.pipe(v.string(), v.email('Please enter a valid email address')) }); ``` **Example with input validation enabled:** ```gts import * as v from 'valibot'; const schema = v.object({ password: v.pipe( v.string(), v.minLength(8, 'Password must be at least 8 characters') ) }); ``` **Example with change validation disabled:** ```gts ``` **When to use input validation:** - Password fields with strength requirements - Fields with character limits or specific format requirements - Username fields that check availability - Fields where immediate feedback improves the user experience **When to disable field-level validation:** - Long forms where validation on every interaction might be distracting - Forms where you want to allow incomplete data entry until final submission - Multi-step forms where validation should only run at specific steps **Overriding validation timing per field:** Individual Fields can override the Form's `@validateOn` setting to customize when that specific field validates: ```gts ``` **Important notes:** - Field-level validation (`'change'` or `'input'`) requires using `form.Field` components - The Field component automatically handles the validation logic - Both `@schema` and `@validate` custom validators work with field-level validation - Field-level validation uses the same validation rules as submit validation - The `'change'` event fires when a field loses focus (blur) after being modified, not on every keystroke - The `'input'` event fires on every keystroke as the user types - Input validation may trigger many validation calls, so consider performance for complex validation logic ## Anatomy Field yields bound versions of all form components: - `field.Input` - Text input with automatic error and value binding - `field.Textarea` - Textarea with automatic error and value binding - `field.SingleSelect` - Select dropdown with automatic error and selectedKey binding - `field.MultiSelect` - Select dropdown with automatic error and selectedKeys binding - `field.DatePicker` - Date picker with automatic error and value binding - `field.DateRangePicker` - Date picker in range mode; the field arrives as `{ start, end }` - `field.Checkbox` - Single checkbox with automatic error and checked binding - `field.CheckboxGroup` - Checkbox group with automatic error binding - `field.RadioGroup` - Radio group with automatic error and value binding - `field.Switch` - Toggle switch with automatic error and isSelected binding - `field.Radio` - Individual radio button with automatic error and checkedValue binding (requires manual `@value` for each radio's unique identifier) Each component automatically receives: - The `@name` prop from the Field - Any validation errors for that field name - The current value from form data (if provided) - A controlled change handler (when form data is provided) - All other props passed through normally ## When to Use Field **Use the Field component when:** - You're implementing form validation with `@schema` or `@validate` - You want automatic error handling for form fields - You need consistent error binding across multiple form components - You're building forms with complex validation requirements **Don't use the Field component when:** - You're building simple forms without validation - You want direct control over error handling - You're not using the Form component's validation features ## Matching Schema and Field Names Ensure field names match between your validation schema and Field components: ```typescript // Schema definition schema = v.object({ userEmail: v.string().email(), // Field name: 'userEmail' }); // Field usage {{! Must match schema }} ``` ## Select Fields in Validated Forms **Important:** When working with select fields in validated forms, follow these guidelines: 1. **Schema Definition**: Always use `v.fallback(v.string(), '')` before `v.string()` to ensure null values are normalized to empty strings: ```typescript // Correct schema for select fields schema = v.object({ country: v.pipe( v.fallback(v.string(), ''), // Normalize null to empty string v.string(), v.nonEmpty('Please select a country') ) }); ``` 2. **Component Configuration**: Specify `@allowEmpty={{true}}` on the Select component when there is no initial value: ```handlebars ``` These patterns ensure proper handling of select field validation, especially when fields start without a selected value. ### Blur validation on selects With `blur` in `@validateOn`, a select-shaped field validates when focus leaves the **whole control** — the field and its dropdown. Picking an option is not a blur, even though the trigger does lose focus to the option: in `@selectionMode="multiple"` the dropdown stays open, and errors like "select at least three" would otherwise appear while the user is still on their first choice. `SingleSelect`, `MultipleSelect` and `Autocomplete` all report blur this way, once focus has actually landed outside. ## Where Errors Appear Field components automatically display errors, but you can customize the display: ```gts ``` ## Accessibility Field itself renders no markup and sets no attributes — it binds `errors` down to the control it yields, and the control's `FormControl` wrapper does the rest. So the behavior below comes from the wrapped component, and holds for `field.Input`, `field.Select`, `field.Checkbox`, `field.Textarea` and the rest alike: | Behavior | Where it comes from | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | `aria-invalid` on the control when the field has errors | `FormControl`, from the bound `errors` | | `aria-describedby` linking the control to its description and feedback | `FormControl.describedBy` | | Errors announced as they appear | `FormFeedback` renders `aria-live="assertive"` for errors, `"polite"` otherwise | | Label associated via `for` | `FormControl` + `Label` | Two consequences. Because error feedback is `aria-live="assertive"`, a form using `@validateOn={{array "input"}}` interrupts a screen reader on **every keystroke** — prefer `change` or `blur` for validation a user hears. And because the announcement is tied to the feedback element rather than the control, a field whose errors you render yourself, outside `FormControl`, gets no announcement at all. ## API ### Field Field is a component wrapper that provides conveniences for form fields. It automatically binds the appropriate form errors by name to yielded components. **Element:** `HTMLElement` **Arguments** | Name | Type | Default | Description | | --- | --- | --- | --- | | `name *` | `string` | - | The name of the form field. | | `disabled` | `boolean` | - | Whether the field should be disabled. | | `errors` | `FormErrors` | - | The validation errors for the form, keyed by field name. | | `formData` | `T` | - | The form data as key/value pairs. | | `validateField` | `(data: T, name: string) => Promise` | - | Function to validate a single field by name. | | `validateOn` | `('input' \| 'blur' \| 'change')[]` | - | When to run validation. | **Blocks** | Name | Type | Default | Description | | --- | --- | --- | --- | | `default *` | `[{ Checkbox: Checkbox (name, errors, checked, onChange, onBlur, isDisabled bound); CheckboxGroup: CheckboxGroup (name, errors, isDisabled bound); Input: Input (name, errors, value, onChange, onInput, onBlur, isDisabled bound); InputOtp: InputOtp (name, errors, value, onChange, onInput, onBlur, isDisabled bound); Radio: Radio (name, errors, checkedValue, onChange, onBlur, isDisabled bound); RadioGroup: RadioGroup (name, errors, value, onChange, isDisabled bound); SingleSelect: Select (name, errors, selectedKey, onBlur, isDisabled bound); MultiSelect: Select (selectionMode, name, errors, selectedKeys, onBlur, isDisabled bound); DatePicker: DatePicker<'single'> (name, errors, value, onBlur, isDisabled bound); DateRangePicker: DatePicker<'range'> (mode, name, errors, value, onBlur, isDisabled bound); Switch: Switch (name, errors, isSelected, onChange, onBlur, isDisabled bound); Textarea: Textarea (name, errors, value, onChange, onInput, onBlur, isDisabled bound); }]` | - | | --- # FormControl Source: /docs/components/forms/form-control.md # FormControl FormControl provides the label, description and error feedback that surround a form control, and the ids that tie them together for assistive technology. Reach for it when you need a field Frontile doesn't ship — a file picker, a range slider, a third-party date picker — and want it to look and announce itself like `Input`, `Select` and `Checkbox`, which are all built on it. ## Import ```js import { FormControl } from 'frontile'; ``` ## Usage Pass `@label` and give the wrapped control the yielded `id`. That single wiring is what associates the rendered `