Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 43 additions & 23 deletions .agents/references/component-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Review whether the shipped contract stays stable and understandable for consumer
- consumers should not need repo-only imports or example-only setup for the component to work as documented
- JS entrypoints should not pick up CSS side effects unless that contract is explicit
- docs, examples, exports, and implementation should tell the same import story
- `components/internals/*` is for internal-only shared UI building blocks; do not re-export those paths from package root or document them as consumer imports

---

Expand All @@ -62,9 +63,47 @@ Review whether the chosen pattern matches the actual component shape.

Be skeptical when a heavier pattern adds public surface without reducing real complexity.

### Pattern A — Simple single-file component
### Standard filesystem shape

Use when the component is mostly a styled element with variants and a small prop surface.
Every public component or helper family under `components/` gets its own directory.
Implementation size does **not** decide whether something is a file or a directory.
The stable entrypoint is always `components/<name>/index.ts`.

For internal-only shared UI building blocks that are reused across multiple
component families, use `components/internals/<name>/index.ts` instead.
Do not surface those paths from `packages/*/src/index.ts`.

If a piece has a narrower owner than `components/` — for example routing-owned
UI like document head helpers, or sidebar-owned UI like a dynamic breadcrumb —
keep it with that owner instead of putting it under `components/internals/`.

```text
components/
component-name/
index.ts
component-name.tsx
```

If the implementation grows, add files in the same directory instead of changing
its outer shape.

```text
components/
component-name/
index.ts
component-name.tsx
types.ts
use-component-name.ts
component-name.test.tsx
```

```tsx
// index.ts
export * from "./component-name";
// export { default } from "./component-name"; // only when a default export exists
```

A small component can still be a single implementation file internally:

```tsx
import * as React from "react";
Expand Down Expand Up @@ -94,7 +133,7 @@ function Component({ className, variant, size, ...props }: ComponentProps) {
export { Component, componentVariants, type ComponentProps };
```

### Pattern B — Compound namespace object
### Compound namespace object

Use when the consumer must compose meaningful sub-components directly.

Expand Down Expand Up @@ -142,26 +181,7 @@ const ComponentName = {
export { ComponentName };
```

### Pattern C — Directory component

Use when the implementation needs multiple internal files, but the public API should still stay small.

```text
components/
component-name/
ComponentName.tsx
types.ts
index.ts
```

```tsx
// index.ts
export { ComponentName, default } from "./ComponentName";
export type { ComponentNameProps } from "./types";
// DO NOT export internal types, type guards, or enums
```

### Pattern D — Standalone + `Parts`
### Standalone + `Parts`

Use when there is a dominant pre-assembled use case, but advanced consumers still need composition escape hatches.

Expand Down
4 changes: 2 additions & 2 deletions .agents/references/composite-field-controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ For controls with richer selection behavior, also review:

## Current AppShell examples

- `packages/core/src/components/field.tsx` defines the baseline `Field.Root` contract that all field-like controls should fit into.
- `packages/core/src/components/select.tsx`, `packages/core/src/components/combobox.tsx`, and `packages/core/src/components/autocomplete.tsx` show the simpler control-family contract that composite controls should still resemble from the outside.
- `packages/core/src/components/field/field.tsx` defines the baseline `Field.Root` contract that all field-like controls should fit into.
- `packages/core/src/components/select/select.tsx`, `packages/core/src/components/combobox/combobox.tsx`, and `packages/core/src/components/autocomplete/autocomplete.tsx` show the simpler control-family contract that composite controls should still resemble from the outside.
- `packages/core/src/components/date-field/date-field.tsx` splits the problem into a11y labeling, proxy-input ownership, and `Field` / `Form` bridging.
- `packages/core/src/components/date-field/use-date-field-state.ts` owns the segmented editing state machine, shortcut handling, controlled/uncontrolled behavior, and invalid-reason semantics.
- `packages/core/src/components/date-field/date-input-group.tsx` renders the visible segmented `role="group"` UI and wires keyboard shortcuts plus popover opening.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
XCircleIcon,
XIcon,
} from "lucide-react";
import { cn } from "../lib/utils";
import { cn } from "../../lib/utils";

const alertVariants = cva(
"astw:relative astw:w-full astw:rounded-lg astw:border astw:px-4 astw:py-3 astw:text-sm astw:grid astw:grid-cols-[calc(var(--spacing)*4.5)_1fr_auto] astw:gap-x-3 astw:gap-y-0.5 astw:items-start astw:[&>svg]:size-[17.5px] astw:[&>svg]:self-center astw:[&>svg]:shrink-0 astw:[&>svg]:text-current",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/components/alert/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./alert";
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

import { COLOR_THEME_OPTIONS, ThemeProvider } from "@/contexts/theme-context";
import { createAppShellWrapper } from "../../tests/test-utils";
import { createAppShellWrapper } from "../../../tests/test-utils";
import { AppearanceSwitcher } from "./appearance-switcher";

/** happy-dom / Node can omit a full `localStorage`; ThemeProvider persists via it. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./appearance-switcher";
1 change: 1 addition & 0 deletions packages/core/src/components/appshell/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./appshell";
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderRHFForm } from "../../tests/rhf-test-utils";
import { Field } from "./field";
import { renderRHFForm } from "../../../tests/rhf-test-utils";
import { Field } from "../field";
import { Autocomplete } from "./autocomplete-standalone";

afterEach(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import {
useAsync,
useFilter,
} from "./autocomplete";
import { defaultMapItem, isGroupedItems } from "./dropdown-items";
import type { MappedItem, ItemGroup, ExtractItem } from "./dropdown-items";
import { AsyncErrorState, resolveAsyncContent } from "./async-error-state";
import { defaultMapItem, isGroupedItems } from "../dropdown-items";
import type { MappedItem, ItemGroup, ExtractItem } from "../dropdown-items";
import { AsyncErrorState, resolveAsyncContent } from "../internals/async-error-state";
import type { AsyncFetcher } from "@/hooks/use-async-items";

/** Fetcher type for `Autocomplete.Async`. */
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/components/autocomplete/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./autocomplete";
export * from "./autocomplete-standalone";
1 change: 1 addition & 0 deletions packages/core/src/components/avatar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./avatar";
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createAppShellWrapper } from "../../tests/test-utils";
import { createAppShellWrapper } from "../../../tests/test-utils";
import { BadgeList, resolveBadgeVariant, resolveBadgeLabel } from "./badge-list";

afterEach(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import * as React from "react";
import { Popover } from "@base-ui/react/popover";
import { Badge } from "./badge";
import type { BadgeProps } from "./badge";
import { Badge } from "../badge";
import type { BadgeProps } from "../badge";

// ============================================================================
// BADGE UTILITIES (types & helpers)
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/components/badge-list/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./badge-list";
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../lib/utils";
import { cn } from "../../lib/utils";

const badgeVariants = cva(
"astw:inline-flex astw:cursor-default astw:items-center astw:rounded-md astw:border astw:px-2 astw:py-0.5 astw:text-xs astw:font-medium astw:transition-colors astw:focus:outline-none astw:focus:ring-2 astw:focus:ring-ring astw:focus:ring-offset-2",
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/components/badge/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./badge";
export { default } from "./badge";
1 change: 1 addition & 0 deletions packages/core/src/components/button/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./button";
1 change: 1 addition & 0 deletions packages/core/src/components/card/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./card";
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import { renderRHFForm } from "../../tests/rhf-test-utils";
import { renderRHFForm } from "../../../tests/rhf-test-utils";
import { Checkbox } from "./checkbox";
import { Field } from "./field";
import { Field } from "../field";

afterEach(() => {
cleanup();
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/components/checkbox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./checkbox";
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderRHFForm } from "../../tests/rhf-test-utils";
import { Field } from "./field";
import { renderRHFForm } from "../../../tests/rhf-test-utils";
import { Field } from "../field";
import { Combobox } from "./combobox-standalone";

afterEach(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ import {
useFilter,
} from "./combobox";

import { defaultMapItem, isGroupedItems } from "./dropdown-items";
import type { MappedItem, ItemGroup, ExtractItem } from "./dropdown-items";
import { AsyncErrorState, resolveAsyncContent } from "./async-error-state";
import { defaultMapItem, isGroupedItems } from "../dropdown-items";
import type { MappedItem, ItemGroup, ExtractItem } from "../dropdown-items";
import { AsyncErrorState, resolveAsyncContent } from "../internals/async-error-state";
import type { AsyncFetcher } from "@/hooks/use-async-items";

/** Fetcher type for `Combobox.Async`. */
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/components/combobox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./combobox";
export * from "./combobox-standalone";
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
import { MemoryRouter } from "react-router";
import type { ReactNode } from "react";
import type { Resource } from "@/resource";
import type { NavItem } from "../routing/navigation";
import type { NavItem } from "../../routing/navigation";

// Mock NavItems for testing
const mockNavItems: NavItem[] = [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useReducer, useEffect, useMemo, useCallback, useRef, Suspense } from "react";
import { useNavigate, Await } from "react-router";
import { useAppInfoPageRoute } from "@/components/app-info";
import { useAppInfoPageRoute } from "@/components/internals/app-info";
import { SearchIcon, LoaderCircleIcon } from "lucide-react";
import { Dialog } from "@/components/dialog";
import { Input } from "@/components/input";
import { useT } from "@/i18n-labels";
import { cn } from "@/lib/utils";
import { filterRoutes, NavigatableRoute } from "@/routing/path";
import { useNavItems, NavItem, NavItemResource } from "../routing/navigation";
import { useNavItems, NavItem, NavItemResource } from "../../routing/navigation";
import {
useCommandPaletteActions,
useCommandPaletteState,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/components/command-palette/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./command-palette";
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from "lucide-react";

import { cn } from "@/lib/utils";
import { Combobox } from "@/components/combobox-standalone";
import { Combobox } from "@/components/combobox";
import type {
CsvImporterProps,
CsvImporterStep,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/data-table/pagination.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight } from "lucide-react";
import { Button } from "@/components/button";
import { Select } from "@/components/select-standalone";
import { Select } from "@/components/select";
import { useDataTableContext } from "./data-table-context";
import { useDataTableT } from "./i18n";

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/data-table/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useCollectionControlOptional } from "@/contexts/collection-control-cont
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Checkbox } from "@/components/checkbox";
import { Select } from "@/components/select-standalone";
import { Select } from "@/components/select";
import { DatePicker } from "@/components/date-field";
import { Calendar } from "@/components/calendar";
import { Tooltip } from "@/components/tooltip";
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/components/dialog/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./dialog";
1 change: 1 addition & 0 deletions packages/core/src/components/dropdown-items/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./dropdown-items";
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderRHFForm } from "../../tests/rhf-test-utils";
import { renderRHFForm } from "../../../tests/rhf-test-utils";
import { Field } from "./field";

afterEach(() => {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/components/field/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./field";
1 change: 1 addition & 0 deletions packages/core/src/components/fieldset/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./fieldset";
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Controller, useForm } from "react-hook-form";
import { Form } from "./form";
import { Field } from "./field";
import { Field } from "../field";

afterEach(() => {
cleanup();
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/components/form/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./form";
1 change: 1 addition & 0 deletions packages/core/src/components/input/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./input";
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderRHFForm } from "../../tests/rhf-test-utils";
import { renderRHFForm } from "../../../tests/rhf-test-utils";
import { Input } from "./input";

afterEach(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import packageJson from "../../package.json";
import packageJson from "../../../../package.json";
import { BuiltInCommandPalette } from "@/components/command-palette";
import {
AppShellConfigContext,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import packageJson from "../../package.json";
import packageJson from "../../../../package.json";
import { Button } from "@/components/button";
import { Card } from "@/components/card";
import { useAppShellConfig, type AppInfoEntry } from "@/contexts/appshell-context";
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/components/internals/app-info/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./app-info";
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./async-error-state";
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./breadcrumb";
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRouteError, isRouteErrorResponse } from "react-router";
import { Button } from "./button";
import { Button } from "../../button";
import { AlertCircle } from "lucide-react";
import { useT } from "@/i18n-labels";

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./default-error-boundary";
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./separator";
1 change: 1 addition & 0 deletions packages/core/src/components/internals/sonner/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./sonner";
1 change: 1 addition & 0 deletions packages/core/src/components/layout/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./Layout";
1 change: 1 addition & 0 deletions packages/core/src/components/menu/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./menu";
2 changes: 2 additions & 0 deletions packages/core/src/components/select/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./select";
export * from "./select-standalone";
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderRHFForm } from "../../tests/rhf-test-utils";
import { Field } from "./field";
import { renderRHFForm } from "../../../tests/rhf-test-utils";
import { Field } from "../field";
import { Select } from "./select-standalone";

afterEach(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import {
SelectGroupLabel,
SelectParts,
} from "./select";
import { defaultMapItem, isGroupedItems } from "./dropdown-items";
import type { MappedItem, ItemGroup, ExtractItem } from "./dropdown-items";
import { AsyncErrorState, resolveAsyncContent } from "./async-error-state";
import { defaultMapItem, isGroupedItems } from "../dropdown-items";
import type { MappedItem, ItemGroup, ExtractItem } from "../dropdown-items";
import { AsyncErrorState, resolveAsyncContent } from "../internals/async-error-state";

/**
* Fetcher type for `Select.Async`.
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/components/sheet/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./sheet";
Loading
Loading