Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
756541fae8 | ||
|
|
a2b0a9d133 | ||
|
|
17d239d11b | ||
|
|
8e28d44e8e |
@@ -0,0 +1,190 @@
|
||||
# Hanzo UI Consolidation — one library, all polish, no duplication
|
||||
|
||||
**Goal.** RIP the fragmentation (`@hanzo/data` vs `@hanzo/ui` vs in-console duplicates) into ONE canonical, cross-platform, presentational, clean-room library: **`@hanzo/ui`, built on `@hanzo/gui`**. Preserve every polished component + token; zero loss; no fork.
|
||||
|
||||
**Status.**
|
||||
- **Step 1 — Audit:** complete (this document; four source trees read file-by-file).
|
||||
- **Step 2 — Establish `@hanzo/ui` + migrate the stable foundation:** **DONE & GREEN** (`pkg/ui` is now a real package; `tsc --noEmit` = 0 errors, `vitest` = 12/12).
|
||||
- **Step 3 — Console re-point:** **STAGED, not merged.** Gate is split (backend live, app lanes in-flight) — see [Step 3](#step-3--console-re-point-staged).
|
||||
|
||||
**One decision needs CTO sign-off before publish** — see [Naming / version](#naming--version--the-one-decision).
|
||||
|
||||
---
|
||||
|
||||
## Architecture — three layers, decomplected
|
||||
|
||||
```
|
||||
@hanzo/gui (Tamagui/One) cross-platform primitives: Text, XStack/YStack, Button, Input,
|
||||
│ Card, Popover, Select, Switch, Slider, Spinner, ScrollView …
|
||||
▼
|
||||
@hanzo/data (pkg/data) the metadata-driven RECORD layer: fields → records → views
|
||||
│ (RecordsView, DataTable, BoardView, RecordDetail, field editors)
|
||||
▼
|
||||
@hanzo/ui (pkg/ui) ◄──── THE ONE LIBRARY. Two orthogonal concerns, one package:
|
||||
• product/app layer → import { … } from '@hanzo/ui'
|
||||
• record layer → import { … } from '@hanzo/ui/data' (re-exports @hanzo/data)
|
||||
```
|
||||
|
||||
`@hanzo/data` stays the **source of truth** for the record layer; `@hanzo/ui` **composes** it (`export * from '@hanzo/data'` on the `./data` subpath) rather than copying — so the CMS/ERP/Help app lanes that consume `@hanzo/data` today keep working untouched, and there is exactly one home. This mirrors how the gui base wraps `hanzogui`.
|
||||
|
||||
Both layers own a `DataTable` (product = generic typed `<T>` list; data = field-driven record grid). Keeping the record layer on the `./data` subpath keeps each name unambiguous — no rename, no collision.
|
||||
|
||||
---
|
||||
|
||||
## The preserve-and-merge manifest
|
||||
|
||||
Legend: **✅ migrated** (in `pkg/ui`, GREEN) · **↪ re-exported** (composed from `@hanzo/data`) · **⏳ staged** (Step 3, after app lanes land) · **🔧 pending prop-injection** (app-coupled; decouple before it can live in a presentational lib) · **🏠 stays in console** (app glue, not a UI component).
|
||||
|
||||
### A. Record layer — `@hanzo/data` → `@hanzo/ui/data` ↪
|
||||
|
||||
Source: `pkg/data/src/*` (v1.2.0, clean-room, already published). Surfaced through `@hanzo/ui/data` and `@hanzo/ui/primitives/bases/data`. Nothing re-implemented.
|
||||
|
||||
| Component / module | Source | New home |
|
||||
|---|---|---|
|
||||
| `RecordsView` (flagship: toolbar over table⇆board, filter/sort/search/group, saved views) | `view/RecordsView.tsx`, `view/Toolbar.tsx` | `@hanzo/ui/data` |
|
||||
| `DataTable` (field-driven record grid: sort headers, drag resize/reorder, row select, inline edit, pagination) | `table/DataTable.tsx` | `@hanzo/ui/data` |
|
||||
| `BoardView` (kanban, optimistic drag-move) · `RecordCard` | `board/BoardView.tsx`, `table/RecordCard.tsx` | `@hanzo/ui/data` |
|
||||
| `RecordDetail` / `RecordForm` | `record/RecordDetail.tsx` | `@hanzo/ui/data` |
|
||||
| Field model + registry (`FieldType` ×24, `FieldDefinition`, `registerField`, routers) | `field/{types,registry,registerDefaults,FieldDisplay,FieldInput}` | `@hanzo/ui/data` |
|
||||
| Field displays + **editors** (select/currency/date/relation/file/JSON/boolean/fullName/address…) | `field/{displays,inputs}.tsx` | `@hanzo/ui/data` |
|
||||
| Composable primitives: `Menu`, `CheckBox`, `Toggle`, `Calendar` | `primitives/*` | `@hanzo/ui/data` |
|
||||
| Pure logic: `applyView`, `sortRecords`, `filterRecords`, `searchRecords`, `paginate`, `boardColumns`, … | `{table,board,view}/logic.ts` | `@hanzo/ui/data` (+ `@hanzo/data/*/logic` subpaths for Node hosts) |
|
||||
| Tokens: `tokens`, `TAG_TONES`, `tagTone` | `theme.ts` | **also top-level `@hanzo/ui`** (token surface) |
|
||||
|
||||
### B. Console `components/ui/*` product primitives → `@hanzo/ui/product` ✅ / 🔧
|
||||
|
||||
Source: `console2/src/components/ui/*`. The pure subset was ported **byte-identical** (verified by `diff`) — 100% of the polish preserved (Charts' hover tooltips/axis-ticks/honest-null, DataTable width/flex/hover-gating, Field's 6 variants, SlideOver's focus-trap + scroll-lock, ComboBox's ReDoS-safe filter).
|
||||
|
||||
| Component | Source (`console2/src/components/ui/`) | New home | Status |
|
||||
|---|---|---|---|
|
||||
| `DataTable<T>` (generic typed list) + `Column` | `DataTable.tsx` | `@hanzo/ui` (`product/DataTable`) | ✅ |
|
||||
| `PageHeader` | `PageHeader.tsx` | `@hanzo/ui` | ✅ |
|
||||
| `Field*` (`FieldRow/Text/TextArea/Switch/Select/Slider`) | `Field.tsx` | `@hanzo/ui` | ✅ |
|
||||
| **Charts** (`Sparkline/LineChart/BarChart/Donut/BarRows`, `CHART_PALETTE`) | `Charts.tsx` | `@hanzo/ui` | ✅ |
|
||||
| `Donut` (standalone dependency-free ring) | `Donut.tsx` | `@hanzo/ui` as **`DonutRing`** (alias — Charts.Donut is canonical) | ✅ |
|
||||
| `StatusTag` (health verdict → pill) | `StatusTag.tsx` | `@hanzo/ui` | ✅ |
|
||||
| `EmptyState` (DO/Vercel-class first-run) | `EmptyState.tsx` | `@hanzo/ui` | ✅ (**decoupled** — see §I) |
|
||||
| `Metric*` (`MetricCard/MiniBars/UtilBar/LegendDot/Panel/HintButton`, `SERIES`) | `Metric.tsx` | `@hanzo/ui` (its `Sparkline` preserved as **`MetricSparkline`**) | ✅ |
|
||||
| `ComboBox` + pure `filterOptions`/`isKnownOption` | `ComboBox.tsx`, `combobox/filter.ts` | `@hanzo/ui` | ✅ (+ `filter.test.ts`) |
|
||||
| `PrimaryButton` · `HanzoMark` · `ProductIcon` · `ProviderLogo` | resp. files | `@hanzo/ui` | ✅ |
|
||||
| `SlideOver` (a11y drawer) · `SelectMenu` · `Toast` (`useToast`) | resp. files | `@hanzo/ui` | ✅ (SlideOver import decoupled `~/…/color`→`./color`) |
|
||||
| `Reorder<T>` (pointer DnD) · `FadeIn` · `ThemeToggle` · `color` (`asColor`/`IconLike`) | resp. files | `@hanzo/ui` | ✅ (+ `Reorder.test.ts`) |
|
||||
| `DetailPane` (right-side pane over SlideOver) | `components/DetailPane.tsx` | `@hanzo/ui` | ⏳ (liftable; lands with Step 3 — depends only on SlideOver) |
|
||||
| `ChunkGuard` (stale-deploy chunk-error reload) | `components/ChunkGuard.tsx` | `@hanzo/ui` | ⏳ (fully portable; lands with Step 3) |
|
||||
|
||||
### C. Design tokens + motion → `@hanzo/ui` ✅
|
||||
|
||||
The brief's premise (`globals.css` `t_dark`/`t_light` token blocks) is **corrected**: `t_dark`/`t_light` are `@hanzo/gui` (Tamagui) theme **class names**; the calm values live in three real places, all preserved:
|
||||
|
||||
| Polish | Source | New home |
|
||||
|---|---|---|
|
||||
| Calm dark-first tokens (surface, text, accent `#60a5fa`, tag tones) — raw hex, theme-config independent | `pkg/data/src/theme.ts` | `@hanzo/ui` (top-level `tokens`/`TAG_TONES`/`tagTone`) |
|
||||
| OKLCH shadcn-compatible palette (soft-charcoal `oklch(0.145 0 0)`, `--radius: 0.5rem`, indigo sidebar accent) | `pkgs/ui/style/hanzo-default-colors.css`, `@hanzo/tokens` | unchanged (legacy shadcn line); the gui line uses the hex tokens above |
|
||||
| **Motion vocabulary** — `hz-fade-up` (FadeIn), `hz-collapse`/`hz-slide`/`hz-fade` (shell/drawer), `hz-drag-item`, `hz-skeleton`/`hz-pulse`/`hz-row-in`; all `prefers-reduced-motion`-guarded | `console2/app/globals.css` (motion section) | **`@hanzo/ui/styles/motion.css`** (verbatim) — `import '@hanzo/ui/styles/motion.css'` once at app root |
|
||||
|
||||
### D. Generic DocType renderer → `@hanzo/ui` ⏳ (Step 3, FINAL — active lane)
|
||||
|
||||
Source: `console2/src/components/doctype/*`. **Actively edited by the CMS/ERP/Help lanes (untracked).** Dependency-**injected** (`client: FrameworkClient` prop) — not `~/lib/api`-coupled — so the real move-blockers are `~/lib/framework/{types,fields}` + the `ui/*` primitives (now in `@hanzo/ui`) + `@hanzo/data` (now `@hanzo/ui/data`).
|
||||
|
||||
| File | Class | Step-3 disposition |
|
||||
|---|---|---|
|
||||
| `MediaGrid.tsx` | presentational | → `@hanzo/ui` (cleanest lift; props-only DAM gallery) |
|
||||
| `DocTypeRecords.tsx` / `DocTypeDetail.tsx` | mixed (data-bound shell over `@hanzo/data` views) | shell **stays in console**; renders `@hanzo/ui/data` views (already true) |
|
||||
| `CollectionsBrowser.tsx` | mixed (card grid + install/create orchestration) | presentational card-grid → `@hanzo/ui`; orchestration stays |
|
||||
| `data.ts` | glue (relation loading) | stays in console |
|
||||
|
||||
### E. LivingOverview → `@hanzo/ui` ⏳ (Step 3 — active lane)
|
||||
|
||||
Source: `console2/src/components/products/overview/living/*` (untracked/modified). Pure/presentational parts portable; app-glue concentrated in `registry.ts` (live API clients).
|
||||
|
||||
| Part | Class | Step-3 disposition |
|
||||
|---|---|---|
|
||||
| `motion.ts`, `hooks.ts`, `logic.ts`, `config.ts` (types) | pure | → `@hanzo/ui` (unit-tested; count-up, poll clock, unit formatting) |
|
||||
| `tiles.tsx`, `LivingOverview.tsx` | presentational (reuse `ui/Charts` verbatim) | → `@hanzo/ui` (once `config.ts`'s one `~/lib/products/registry` `ProductIcon` type is swapped for `@hanzo/ui`'s `IconLike`) |
|
||||
| `adapters.ts`, `registry.ts` | glue (map REAL `/v1` sources → `OverviewData`) | **stay in console** |
|
||||
|
||||
### F. Framework / Base-data libs → stay in console 🏠
|
||||
|
||||
`src/lib/framework/*` (DocType wire contract + `FrameworkApi` client + pure `fields.ts` mapper to `@hanzo/data`) and `src/lib/base-data/*` (Base REST client + schema→field mapper) are **app data-access glue**, not UI. They stay in the console. Their pure mappers (`docTypeToFields`, `baseCollectionToFields`) could later publish as a small `@hanzo/base-data` adapter, but they are not UI components and are out of scope for `@hanzo/ui`.
|
||||
|
||||
### G. App-coupled console components → 🔧 pending prop-injection (NOT yet in the lib)
|
||||
|
||||
These import app `~/lib`/session/router/branding and must be made presentational (inject props) before they belong in a host-agnostic lib. Kept in console for now; the injection contract is specified so the eventual lift is mechanical.
|
||||
|
||||
| Component | Coupling | Inject to lift |
|
||||
|---|---|---|
|
||||
| `BackendStateCard` (`BackendState.tsx`) | `~/lib/api` `ApiError` | accept a numeric `status` (or shared error shape) instead of `ApiError` |
|
||||
| `BrandLogo` | session + live IAM `organization()` + `~/config` + branding | `orgName`, `logoUrl` (or a `loadOrgLogo` loader), brand mark/name as props |
|
||||
| `Breadcrumbs` | `next/navigation` + catalog lookups | `crumbs[]` (or `pathname` + resolver) + `onNavigate` |
|
||||
| `Loader` / `BrandMark` | `~/config` + brand logo pkgs | `brand`/`brandName` + the animated-SVG getter |
|
||||
| `States` (`ErrorState`, `OperatorAccessRequired`) | `~/lib/api` + session + branding | the error, the account identity, brand strings |
|
||||
|
||||
---
|
||||
|
||||
## What this pass built (Step 2)
|
||||
|
||||
New in **`pkg/ui`** (was a bare `src/` staging dir — no package):
|
||||
|
||||
- `package.json` — `@hanzo/ui` `8.0.0`, source-published (`files: ["src"]`, exports map to `.ts`), peers `@hanzo/gui >=7.2.2`, `@hanzo/data >=1.2.0`, `react >=19`; BSD-3-Clause. Exports: `.`, `./product`, `./data`, `./primitives/bases/data`, `./styles/motion.css`.
|
||||
- `src/index.ts` — top-level barrel (product layer + tokens).
|
||||
- `src/product/index.ts` — product barrel; **collision-free** (Charts `Sparkline`/`Donut` canonical; Metric's → `MetricSparkline`; standalone ring → `DonutRing`; `ComboOption` from the one filter module) — **zero loss**.
|
||||
- `src/data.ts` — `export * from '@hanzo/data'` (the record layer, one home).
|
||||
- `src/styles/hanzo-motion.css` — the motion vocabulary, verbatim.
|
||||
- `tsconfig.json`, `vitest.config.ts`, `gui.config.ts`, `gui.d.ts`, `.npmignore`, `README.md` — mirror the proven `pkg/data` setup.
|
||||
- **One decouple fix:** `product/EmptyState.tsx` no longer imports `~/lib/products/registry`; it uses the local `IconLike` type — the last host coupling in the product tree is gone.
|
||||
|
||||
**Green:** `tsc --noEmit` = 0 errors (strict); `vitest run` = 12/12 (`combobox/filter`, `Reorder`). Every product component is host-agnostic (imports only `react`, `@hanzo/gui`, `@hanzogui/*`, `@hanzo/data`, relative).
|
||||
|
||||
## Clean-room guarantee (preserved)
|
||||
|
||||
`pkg/data` audited for GPL/Twenty contamination: **none.** No GPL, no copied license headers/SPDX, no Twenty entity/decorator architecture. The field/record model is an independent `FieldType` union + `FieldDefinition` + runtime `Map` registry (records are plain `Record<string, unknown>`). "Twenty" appears **only as benchmark prose** ("Airtable/Twenty-class polish, clean-room"). License: BSD-3-Clause. `@hanzo/ui` inherits the same posture.
|
||||
|
||||
---
|
||||
|
||||
## Naming / version — DECIDED: `@hanzo/ui@8`, shadcn retired to `@hanzo/ui-shadcn`
|
||||
|
||||
**Decision:** the gui-based unified library takes the `@hanzo/ui` name **forward at `8.0.0`** (aligning with the "Hanzo Cloud 8.x" umbrella; major = breaking re-platform). The legacy shadcn/Radix line (`pkgs/ui`, v5.7.0) is **retired by renaming** to `@hanzo/ui-shadcn` (never hard-deleted) — it stays fully alive under the new name, freeing `@hanzo/ui` for v8. Precedent: `pkg/data@1.2.0` already superseded `pkgs/data@1.1.0` under the same name.
|
||||
|
||||
This is **DONE (repo-local):** `pkg/ui/package.json` is `@hanzo/ui@8.0.0`, GREEN. The rest is a coordinated, **sequenced** retire — because publishing `@hanzo/ui@8` (a different, gui-based API with no shadcn `Button/Card/Dialog`) under the name ~20 repos consume for shadcn primitives will BREAK any consumer that resolves to `@8`. So order matters:
|
||||
|
||||
**Blast radius (measured across `~/work/hanzo`).** Declared deps on `@hanzo/ui` in ~20 repos. Most pin `^5.x` (semver-safe from an `@8` bump): paas, chat, platform, app, hanzo.ai, o11y, docs, mdx, hanzobot, ui-repo `pkgs/checkout` `^5.3`, `pkgs/agent-ui` `^5.0`. **Would break on `@8`:** `hanzoai/identity/app` (`"latest"`), ui-repo `pkgs/commerce` (`>=5.0.0`); `app/` uses `workspace:^`. The `ui.hanzo.ai` docs app + `pkgs/{commerce,checkout,agent-ui}` import the shadcn line internally.
|
||||
|
||||
**Publish reality.** `.github/workflows/publish-on-tag.yml` publishes **`pkgs/ui`** (the shadcn line) as `@hanzo/ui` on a `v*` tag — it does not reference `pkg/ui`. So a tag push today publishes shadcn, not v8. Publishing v8 needs the workflow rewired to build/publish `pkg/ui`. `npm` is not authed locally (`npm whoami` empty) — per house rules, publish goes through **CI (self-hosted runners, canonical org `NPM_TOKEN`)**, not a local `npm publish`.
|
||||
|
||||
**Safe sequence to fully land v8 (each step reversible until the tag push):**
|
||||
1. ✅ `pkg/ui` = `@hanzo/ui@8.0.0`, GREEN (done, committed to a branch).
|
||||
2. Rename `pkgs/ui` name → `@hanzo/ui-shadcn`; update the internal ui-repo consumers (`app/`, `pkgs/{commerce,checkout,agent-ui}`) + `check-no-hanzogui`/registry scripts that reference it.
|
||||
3. Migrate external consumers off `@hanzo/ui`→`@hanzo/ui-shadcn` (start with the break-risk ones: `identity` `latest`, `commerce` `>=5`; the `^5` pins are safe to migrate at leisure). ~20 repos — do as a tracked sweep or flag per-repo.
|
||||
4. Rewire `publish-on-tag.yml` (and `release.yml`) to build + publish `pkg/ui` as `@hanzo/ui@8`, and `pkgs/ui-shadcn` as `@hanzo/ui-shadcn`.
|
||||
5. `npm deprecate '@hanzo/ui@<8' 'moved to @hanzo/ui-shadcn; @hanzo/ui@8+ is the @hanzo/gui-based unified lib'`.
|
||||
6. Tag `v8.0.0` → CI publishes `@hanzo/ui@8.0.0`. Verify `npm view @hanzo/ui@8.0.0`.
|
||||
|
||||
**Gated on the user's direct go-ahead** (irreversible / globally-visible / ecosystem-wide): steps 2–6 — the `pkgs/ui` rename, the ~20-repo consumer sweep, the CI rewire, `npm deprecate`, and the `v8.0.0` tag push that triggers publish. These were not executed autonomously.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — console re-point (STAGED)
|
||||
|
||||
**Gate (per the brief):** cloud `/v1/framework/modules` live **AND** console CMS/ERP/Help native.
|
||||
- ✅ Backend: `/v1/framework/modules[/:module[/install]]` is **implemented + wired** (`cloud/clients/framework/framework.go:71-73`, HIP-0106 order 129, bound in `subsystems.go:130`, real tests).
|
||||
- ⏳ Console: `CmsModule.tsx`, `ErpModule.tsx`, `HelpModule.tsx`, `components/doctype/` are **untracked/in-flight** on `feat/console-native-cms`.
|
||||
|
||||
→ Gate is **split**, so the re-point is **staged, not applied.** I deliberately did **not** touch `console2`'s working tree (it holds the lanes' uncommitted work — editing it would collide and risk loss, the opposite of the goal).
|
||||
|
||||
**Ready-to-apply re-point (mechanical, once the lanes land + merge):**
|
||||
1. `console2` deps: keep `@hanzo/gui`, `@hanzo/data`; add `@hanzo/ui` (`^6.0.0`). (`@hanzo/data` may stay as a direct dep or be dropped in favor of `@hanzo/ui/data` — both resolve to the same source.)
|
||||
2. Re-point imports (delete the now-duplicated in-console copies — extract, don't copy):
|
||||
- `~/components/ui/{DataTable,PageHeader,Field,Charts,Donut,StatusTag,EmptyState,Metric,ComboBox,combobox/filter,PrimaryButton,HanzoMark,ProductIcon,ProviderLogo,SlideOver,SelectMenu,Toast,Reorder,FadeIn,ThemeToggle,color}` → **`@hanzo/ui`** (or `@hanzo/ui/product`). Two spot-renames: Metric's `Sparkline`→`MetricSparkline`, standalone `Donut`→`DonutRing`.
|
||||
- `@hanzo/data` (in `doctype/*`, `base-data/*`, `products/*Module`) → **`@hanzo/ui/data`** (identical surface).
|
||||
- `~/components/{DetailPane,ChunkGuard}` → **`@hanzo/ui`**.
|
||||
- `app/globals.css` motion block → `import '@hanzo/ui/styles/motion.css'` (keep base resets local).
|
||||
- Move the 5 app-coupled components (§G) into the lib only after applying their inject-props contract; until then they stay local.
|
||||
3. Verify **identical render**: `tsc --noEmit` + `vitest` + `next build` green, then headless-Playwright the live pages pixel-same (the polish is byte-identical, so parity is expected).
|
||||
|
||||
**Report:** `@hanzo/ui` is the ONE unified library — product layer + record layer (`@hanzo/data`) + charts + calm tokens + motion, all on `@hanzo/gui`, presentational, cross-platform, clean-room, GREEN. Console re-point is **ready to apply once the CMS/ERP/Help lanes land**; it was not merged to avoid colliding with that in-flight work.
|
||||
|
||||
## Follow-ups (semver-minor, with visual e2e)
|
||||
|
||||
- Collapse the preserved Sparkline/Donut variants to one each (`MetricSparkline`→`Sparkline`, `DonutRing`→`Donut`) once call-sites are proven identical.
|
||||
- Lift §G's five components after applying their prop-injection contracts.
|
||||
- Consider publishing the pure `framework`/`base-data` mappers as a small `@hanzo/base-data` adapter (not UI).
|
||||
+1
-1
@@ -36,7 +36,7 @@
|
||||
"@hanzo/docs-mdx": "14.3.0",
|
||||
"@hanzo/docs-ui": "16.5.3",
|
||||
"@hanzo/logo": "^1.0.5",
|
||||
"@hanzo/ui": "workspace:^",
|
||||
"@hanzo/ui": "workspace:@hanzo/ui-shadcn@^",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-accessible-icon": "^1.1.8",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Keep the published tarball lean — ship src, not tests or dev type-check config.
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
tsconfig.json
|
||||
vitest.config.ts
|
||||
gui.config.ts
|
||||
gui.d.ts
|
||||
node_modules
|
||||
@@ -0,0 +1,69 @@
|
||||
# @hanzo/data
|
||||
|
||||
Cross-platform, metadata-driven **data-app components** for any [Hanzo Base](https://github.com/hanzoai/base)-backed app — CRM, CMS, commerce, or a one-off internal tool. One model (typed **fields** → **records** → **views**) renders everywhere, on web, native (iOS), and desktop, because it's built on [`@hanzo/gui`](https://github.com/hanzoai/gui) (Tamagui). **Airtable/Twenty-class polish** — table ⇆ board, filter, sort, group, inline edit, saved views — all clean-room and original.
|
||||
|
||||
An object is a set of typed fields. A record is values for those fields. Every view — table, board, detail — renders from that one model, dispatched through a registry, so adding a field type is **one registration**, never a `switch` edit. A business app (CRM/CMS/ERP) is just a curated set of views over these components.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
bun add @hanzo/data @hanzo/gui
|
||||
```
|
||||
|
||||
`@hanzo/gui` and `react` are peers.
|
||||
|
||||
## The flagship: `RecordsView`
|
||||
|
||||
One shell turns any collection into a Twenty-class surface — a toolbar (view switch, search, filter builder, sort builder, board group-by) over a **DataTable** or a **BoardView**, driven by a single `ViewConfig`. Presentational + data-injected: the host supplies raw records and the persistence callbacks; the view owns interaction state.
|
||||
|
||||
```tsx
|
||||
import { RecordsView, type FieldDefinition } from '@hanzo/data'
|
||||
|
||||
<RecordsView
|
||||
fields={fields}
|
||||
records={records} // raw rows; the view applies search + filter + sort
|
||||
onOpen={openDetail} // expand a record
|
||||
onEditCommit={(rec, field, value) => save(rec, field, value)} // inline cell / board edit
|
||||
onCreate={createRecord}
|
||||
savedViews={{ views, activeId, onSelect, onSave, onDelete }} // optional named views
|
||||
/>
|
||||
```
|
||||
|
||||
- **DataTable** — click-to-sort headers, drag-resize + drag-reorder columns, row selection with a header select-all, inline cell editing (the field's own editor), pagination, hover-reveal open, honest empty/loading.
|
||||
- **BoardView** — kanban grouped by a select/status/boolean/relation field; drag a card to another lane and the record patch is emitted (optimistic, reverts on failure).
|
||||
- **RecordDetail** — a titled panel of fields, optionally inline-editable, with a `related` slot. **RecordForm** — the create/edit form.
|
||||
|
||||
## Field types
|
||||
|
||||
`text · longText · number · percent · currency · boolean · select · multiSelect · date · dateTime · email · url · links · phone · rating · relation · json · uuid · fullName · address · files · richText · position · actor`
|
||||
|
||||
Each type has a read **Display** and (where editable) an **Input**, resolved by the registry. Editors are polished: searchable **select** dropdowns with color chips, a month-grid **calendar**, a **relation** record-picker (over injected candidates), a **file** upload, a validated **JSON** editor, a sliding **boolean** toggle.
|
||||
|
||||
## Extend
|
||||
|
||||
Register a renderer for any type — built-in or custom — and it works in every view:
|
||||
|
||||
```tsx
|
||||
import { registerField } from '@hanzo/data'
|
||||
|
||||
registerField('rating', { Display: MyStars, Input: MyStarPicker })
|
||||
```
|
||||
|
||||
## Pure logic (host-usable, tested)
|
||||
|
||||
The sort / filter / search / group / view helpers are pure and exported (also on subpaths `@hanzo/data/{table,board,view}/logic` so a Node host can import them with no gui runtime):
|
||||
|
||||
```ts
|
||||
import { applyView, sortRecords, filterRecords, boardColumns } from '@hanzo/data'
|
||||
```
|
||||
|
||||
## Surface
|
||||
|
||||
- **Model** — `FieldDefinition`, `FieldType`, `FieldMetadata`, `SelectOption`, `CurrencyValue`, `LinkValue`
|
||||
- **Views** — `RecordsView`, `DataTable`, `BoardView`, `RecordDetail`, `RecordForm`, `RecordCard`, `RecordsToolbar`
|
||||
- **Primitives** — `Menu`, `MenuItem`, `CheckBox`, `Toggle`, `Calendar`
|
||||
- **Routers / Registry** — `FieldDisplay`, `FieldInput`, `isEditable`, `registerField`, `getFieldRenderers`
|
||||
- **Logic** — `applyView`, `sortRecords`, `filterRecords`, `searchRecords`, `paginate`, `boardColumns`, `movePatch`, `operatorsForType`, `emptyView`, `describeView`, …
|
||||
- **Theme** — `tokens`, `TAG_TONES`, `tagTone`
|
||||
|
||||
BSD-3-Clause · Hanzo AI
|
||||
@@ -0,0 +1,13 @@
|
||||
// The @hanzo/gui v5 config this package is authored against. `createGui` with the
|
||||
// shared `@hanzogui/config/v5` default enables the shorthand style props
|
||||
// (bg/px/py/items/justify/rounded/…) the components use; `Conf` feeds the type
|
||||
// augmentation in `gui.d.ts` so `tsc --noEmit` type-checks the shorthands exactly
|
||||
// as the consuming app (console) does. Not shipped — dev/type-check only.
|
||||
import { defaultConfig } from '@hanzogui/config/v5'
|
||||
import { createGui } from '@hanzo/gui'
|
||||
|
||||
export const config = createGui(defaultConfig)
|
||||
|
||||
export default config
|
||||
|
||||
export type Conf = typeof config
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Registers our Gui config with the type system so shorthand style props
|
||||
* (tokens, themes, bg/px/py/items/justify etc.) type-check exactly as they do in
|
||||
* the consuming app. GuiCustomConfig is declared in @hanzogui/web and flows
|
||||
* through @hanzo/gui. Dev/type-check only — never shipped.
|
||||
*/
|
||||
import type { Conf } from './gui.config'
|
||||
|
||||
declare module '@hanzogui/web' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
|
||||
declare module '@hanzogui/core' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@hanzo/data",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"description": "Hanzo Data — cross-platform, metadata-driven data-app components (typed fields, record table, kanban board, record detail, saved views) on @hanzo/gui. The universal object/field/record/view layer that powers any Base-backed CRM, CMS, or commerce app on web, native (iOS), and desktop. Airtable/Twenty-class polish, clean-room.",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./table/logic": {
|
||||
"types": "./src/table/logic.ts",
|
||||
"default": "./src/table/logic.ts"
|
||||
},
|
||||
"./board/logic": {
|
||||
"types": "./src/board/logic.ts",
|
||||
"default": "./src/board/logic.ts"
|
||||
},
|
||||
"./view/logic": {
|
||||
"types": "./src/view/logic.ts",
|
||||
"default": "./src/view/logic.ts"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"module": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"sideEffects": [
|
||||
"**/registerDefaults.ts",
|
||||
"**/index.ts"
|
||||
],
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"tc": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@hanzo/gui": ">=7.2.2",
|
||||
"react": ">=19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hanzo/gui": "7.3.0",
|
||||
"@hanzogui/config": "7.3.0",
|
||||
"@hanzogui/lucide-icons-2": "7.3.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"react": "^19.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"author": "Hanzo AI <dev@hanzo.ai>"
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
// BoardView — the kanban view. Records are grouped into columns by a select /
|
||||
// status / boolean / relation field (board/logic), each rendered as a RecordCard.
|
||||
// Dragging a card to another column emits the record patch that move implies
|
||||
// (movePatch) via onRecordChange; the move is optimistic (the card follows the
|
||||
// pointer and lands immediately) and reverts if persistence fails — responsive,
|
||||
// never fabricated. The same FieldDisplay engine as the table, so the card
|
||||
// understands every field type. Presentational + data-injected.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Plus } from '@hanzogui/lucide-icons-2'
|
||||
import { ScrollView, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import type { FieldDefinition } from '../field/types'
|
||||
import { RecordCard } from '../table/RecordCard'
|
||||
import { Tag } from '../field/displays'
|
||||
import { tokens, tagTone } from '../theme'
|
||||
import { boardColumns, movePatch, groupKeyOf, NO_VALUE, type BoardColumn } from './logic'
|
||||
|
||||
type Record_ = Record<string, unknown>
|
||||
|
||||
const COL_W = 300
|
||||
|
||||
export interface BoardViewProps {
|
||||
fields: FieldDefinition[]
|
||||
records: Record_[]
|
||||
/** The field records are grouped into columns by (a select / status / boolean / relation). */
|
||||
groupField: string
|
||||
/** The card title field (defaults to the first non-group field). */
|
||||
titleField?: string
|
||||
/** Fields shown on each card (defaults to a few beyond the title/group). */
|
||||
cardFields?: FieldDefinition[]
|
||||
onOpen?: (record: Record_) => void
|
||||
/** Persist a card's move to another column. May be async; reverts on failure. */
|
||||
onRecordChange?: (record: Record_, patch: Record<string, unknown>) => void | Promise<void>
|
||||
/** Create a record in a column (the column value is pre-filled). */
|
||||
onCreate?: (columnKey: string) => void
|
||||
getRowKey?: (record: Record_) => string
|
||||
}
|
||||
|
||||
export function BoardView(props: BoardViewProps) {
|
||||
const { fields, records, groupField, titleField, cardFields, onOpen, onRecordChange, onCreate, getRowKey } = props
|
||||
const keyOf = (r: Record_) => (getRowKey ? getRowKey(r) : String(r.id ?? ''))
|
||||
|
||||
const field = fields.find((f) => f.name === groupField)
|
||||
const [overrides, setOverrides] = useState<Record<string, string>>({})
|
||||
useEffect(() => { setOverrides({}) }, [records])
|
||||
|
||||
const boardRef = useRef<HTMLElement | null>(null)
|
||||
const colRefs = useRef<Array<HTMLElement | null>>([])
|
||||
const [drag, setDrag] = useState<{ id: string; from: string; x: number; y: number; target: string } | null>(null)
|
||||
|
||||
const effective = useMemo(
|
||||
() => (field ? records.map((r) => (overrides[keyOf(r)] != null ? { ...r, [field.name]: overrides[keyOf(r)] === NO_VALUE ? null : overrides[keyOf(r)] } : r)) : records),
|
||||
[records, overrides, field],
|
||||
)
|
||||
const columns: BoardColumn[] = useMemo(() => (field ? boardColumns(effective, field) : []), [effective, field])
|
||||
|
||||
if (!field) {
|
||||
return (
|
||||
<YStack p={24} items="center" borderWidth={1} rounded={10} style={{ borderColor: tokens.border, backgroundColor: tokens.surface }}>
|
||||
<Text style={{ color: tokens.textFaint }}>Pick a select or status field to group the board by.</Text>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
const startDrag = (record: Record_) => (e: { clientX?: number; clientY?: number; nativeEvent?: { clientX?: number; clientY?: number } }) => {
|
||||
if (!onRecordChange) return
|
||||
const id = keyOf(record)
|
||||
const from = groupKeyOf(field, record[field.name])
|
||||
const rects = colRefs.current.map((n) => n?.getBoundingClientRect() ?? null)
|
||||
const cx = e.clientX ?? e.nativeEvent?.clientX ?? 0
|
||||
const cy = e.clientY ?? e.nativeEvent?.clientY ?? 0
|
||||
setDrag({ id, from, x: cx, y: cy, target: from })
|
||||
const move = (ev: PointerEvent) => {
|
||||
let target = from
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
const r = rects[i]
|
||||
if (r && ev.clientX >= r.left && ev.clientX <= r.right) { target = columns[i]?.key ?? from; break }
|
||||
}
|
||||
setDrag({ id, from, x: ev.clientX, y: ev.clientY, target })
|
||||
}
|
||||
const end = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', end)
|
||||
setDrag((d) => {
|
||||
if (d && d.target !== d.from) {
|
||||
setOverrides((o) => ({ ...o, [d.id]: d.target }))
|
||||
Promise.resolve(onRecordChange(record, movePatch(field, d.target))).catch(() =>
|
||||
setOverrides((o) => { const next = { ...o }; delete next[d.id]; return next }),
|
||||
)
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', end)
|
||||
}
|
||||
|
||||
const draggedRecord = drag ? effective.find((r) => keyOf(r) === drag.id) : undefined
|
||||
const boardRect = () => boardRef.current?.getBoundingClientRect()
|
||||
|
||||
return (
|
||||
<YStack ref={(node: unknown) => { boardRef.current = node as HTMLElement | null }} style={{ position: 'relative' }}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<XStack gap={12} py={2} items="flex-start">
|
||||
{columns.map((col, i) => {
|
||||
const isTarget = drag && drag.target === col.key && drag.from !== col.key
|
||||
return (
|
||||
<YStack
|
||||
key={col.key || '∅'}
|
||||
width={COL_W}
|
||||
ref={(node: unknown) => { colRefs.current[i] = (node as HTMLElement | null) }}
|
||||
gap={8}
|
||||
p={8}
|
||||
rounded={12}
|
||||
style={{ backgroundColor: tokens.surfaceRaised, borderWidth: 1, borderColor: isTarget ? tokens.accent : tokens.border }}
|
||||
>
|
||||
<XStack items="center" justify="space-between" px={4} py={2}>
|
||||
<XStack items="center" gap={8}>
|
||||
{col.key === NO_VALUE ? (
|
||||
<Text fontSize={13} fontWeight="700" style={{ color: tokens.textMuted }}>{col.label}</Text>
|
||||
) : (
|
||||
<Tag label={col.label} color={col.color} />
|
||||
)}
|
||||
<Text fontSize={12} style={{ color: tokens.textFaint }}>{col.records.length}</Text>
|
||||
</XStack>
|
||||
{onCreate ? (
|
||||
<YStack cursor="pointer" p={3} rounded={6} hoverStyle={{ bg: tokens.hover }} onPress={() => onCreate(col.key)}>
|
||||
<Plus size={14} color={tokens.textMuted} />
|
||||
</YStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
|
||||
<YStack gap={8} style={{ minHeight: 40 }}>
|
||||
{col.records.map((r) => {
|
||||
const dragging = drag?.id === keyOf(r)
|
||||
return (
|
||||
<YStack key={keyOf(r)} onPointerDown={startDrag(r)} style={{ opacity: dragging ? 0.35 : 1, cursor: onRecordChange ? 'grab' : undefined }}>
|
||||
<RecordCard titleField={titleField} fields={cardFields ?? fields.filter((f) => f.name !== field.name)} record={r} onPress={onOpen} />
|
||||
</YStack>
|
||||
)
|
||||
})}
|
||||
{col.records.length === 0 ? (
|
||||
<YStack items="center" justify="center" py={16} rounded={8} style={{ borderWidth: 1, borderColor: tokens.border, borderStyle: 'dashed' }}>
|
||||
<Text fontSize={12} style={{ color: tokens.textFaint }}>Drop here</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
})}
|
||||
</XStack>
|
||||
</ScrollView>
|
||||
|
||||
{/* floating card follows the pointer during a drag */}
|
||||
{drag && draggedRecord ? (
|
||||
<YStack
|
||||
width={COL_W - 20}
|
||||
style={{ position: 'absolute', left: (drag.x - (boardRect()?.left ?? 0)) - (COL_W - 20) / 2, top: (drag.y - (boardRect()?.top ?? 0)) - 20, pointerEvents: 'none', zIndex: 50, transform: [{ rotate: '2deg' }], boxShadow: '0 12px 32px rgba(0,0,0,0.4)' }}
|
||||
>
|
||||
<RecordCard titleField={titleField} fields={cardFields ?? fields.filter((f) => f.name !== field.name)} record={draggedRecord} />
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { FieldDefinition } from '../field/types'
|
||||
import {
|
||||
groupFieldCandidates,
|
||||
defaultGroupField,
|
||||
groupKeyOf,
|
||||
boardColumns,
|
||||
movePatch,
|
||||
NO_VALUE,
|
||||
} from './logic'
|
||||
|
||||
const stage: FieldDefinition = {
|
||||
name: 'stage',
|
||||
label: 'Stage',
|
||||
type: 'select',
|
||||
metadata: { options: [
|
||||
{ value: 'NEW', label: 'New', color: 'blue' },
|
||||
{ value: 'WON', label: 'Won', color: 'green' },
|
||||
{ value: 'LOST', label: 'Lost', color: 'red' },
|
||||
] },
|
||||
}
|
||||
const active: FieldDefinition = { name: 'active', label: 'Active', type: 'boolean' }
|
||||
const name: FieldDefinition = { name: 'name', label: 'Name', type: 'text' }
|
||||
|
||||
const rows: Record<string, unknown>[] = [
|
||||
{ id: '1', name: 'A', stage: 'NEW', active: true },
|
||||
{ id: '2', name: 'B', stage: 'WON', active: false },
|
||||
{ id: '3', name: 'C', stage: 'NEW', active: true },
|
||||
{ id: '4', name: 'D', stage: '', active: false },
|
||||
{ id: '5', name: 'E', stage: 'ARCHIVED', active: true }, // value not in options
|
||||
]
|
||||
|
||||
describe('group field candidates', () => {
|
||||
it('offers select/boolean/relation fields only', () => {
|
||||
expect(groupFieldCandidates([name, stage, active]).map((f) => f.name)).toEqual(['stage', 'active'])
|
||||
})
|
||||
it('defaults to the first select', () => {
|
||||
expect(defaultGroupField([name, active, stage])).toBe('stage')
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupKeyOf', () => {
|
||||
it('maps boolean and empties', () => {
|
||||
expect(groupKeyOf(active, true)).toBe('true')
|
||||
expect(groupKeyOf(active, false)).toBe('false')
|
||||
expect(groupKeyOf(active, null)).toBe(NO_VALUE)
|
||||
expect(groupKeyOf(stage, 'NEW')).toBe('NEW')
|
||||
expect(groupKeyOf(stage, '')).toBe(NO_VALUE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('boardColumns', () => {
|
||||
it('keeps declared option order, shows empty lanes, and appends unknown values', () => {
|
||||
const cols = boardColumns(rows, stage)
|
||||
// no-value lane appears (row 4 has ''), then NEW, WON, LOST (declared), then ARCHIVED (unknown)
|
||||
expect(cols.map((c) => c.key)).toEqual([NO_VALUE, 'NEW', 'WON', 'LOST', 'ARCHIVED'])
|
||||
const byKey = Object.fromEntries(cols.map((c) => [c.key, c.records.map((r) => r.id)]))
|
||||
expect(byKey['NEW']).toEqual(['1', '3'])
|
||||
expect(byKey['WON']).toEqual(['2'])
|
||||
expect(byKey['LOST']).toEqual([]) // declared but empty — still a lane
|
||||
expect(byKey['ARCHIVED']).toEqual(['5'])
|
||||
expect(byKey[NO_VALUE]).toEqual(['4'])
|
||||
})
|
||||
it('drops the empty no-value lane when nothing is unset', () => {
|
||||
const cols = boardColumns(rows.filter((r) => r.stage !== ''), stage)
|
||||
expect(cols.map((c) => c.key)).not.toContain(NO_VALUE)
|
||||
})
|
||||
it('groups booleans into Yes/No lanes', () => {
|
||||
const cols = boardColumns(rows, active)
|
||||
const byKey = Object.fromEntries(cols.map((c) => [c.key, c.records.map((r) => r.id)]))
|
||||
expect(byKey['true']).toEqual(['1', '3', '5'])
|
||||
expect(byKey['false']).toEqual(['2', '4'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('movePatch', () => {
|
||||
it('produces the field patch for a target lane', () => {
|
||||
expect(movePatch(stage, 'WON')).toEqual({ stage: 'WON' })
|
||||
expect(movePatch(stage, NO_VALUE)).toEqual({ stage: null })
|
||||
expect(movePatch(active, 'true')).toEqual({ active: true })
|
||||
expect(movePatch(active, 'false')).toEqual({ active: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
// Pure board (kanban) logic — group a collection's records into ordered columns
|
||||
// by a select / status / boolean / relation field, and describe the patch that
|
||||
// moving a card between columns implies. No React, no @hanzo/gui: data in, data
|
||||
// out, testable in plain Node.
|
||||
import type { FieldDefinition, FieldType, SelectOption, TagColor } from '../field/types'
|
||||
|
||||
type Record_ = Record<string, unknown>
|
||||
|
||||
const asStr = (v: unknown): string => (v == null ? '' : String(v))
|
||||
|
||||
/** One board column: a group value + the records in it, in input order. */
|
||||
export interface BoardColumn {
|
||||
/** The group value (`''` for the no-value column). */
|
||||
key: string
|
||||
label: string
|
||||
color?: TagColor
|
||||
records: Record_[]
|
||||
}
|
||||
|
||||
/** The sentinel column key for records with no value in the group field. */
|
||||
export const NO_VALUE = ''
|
||||
|
||||
/** Field types that make sensible board lanes (a bounded, single set of values). */
|
||||
const GROUPABLE: ReadonlySet<FieldType> = new Set<FieldType>(['select', 'boolean', 'relation'])
|
||||
|
||||
/** The fields a board can group by — the same order they appear in the schema. */
|
||||
export function groupFieldCandidates(fields: FieldDefinition[]): FieldDefinition[] {
|
||||
return fields.filter((f) => GROUPABLE.has(f.type))
|
||||
}
|
||||
|
||||
/** The best default group field (first select, else first candidate), or undefined. */
|
||||
export function defaultGroupField(fields: FieldDefinition[]): string | undefined {
|
||||
const selects = fields.filter((f) => f.type === 'select')
|
||||
if (selects.length > 0) return selects[0].name
|
||||
const c = groupFieldCandidates(fields)
|
||||
return c[0]?.name
|
||||
}
|
||||
|
||||
/** The group key for one record's value in a group field (the option value / bool / id). */
|
||||
export function groupKeyOf(field: FieldDefinition, value: unknown): string {
|
||||
if (field.type === 'boolean') {
|
||||
if (value == null || value === '') return NO_VALUE
|
||||
return value === true || value === 'true' || value === 1 ? 'true' : 'false'
|
||||
}
|
||||
if (field.type === 'relation') {
|
||||
if (value && typeof value === 'object') {
|
||||
const rec = value as Record<string, unknown>
|
||||
return asStr(rec.id ?? rec.value ?? '')
|
||||
}
|
||||
return asStr(value)
|
||||
}
|
||||
return asStr(value)
|
||||
}
|
||||
|
||||
/** The declared column keys for a group field, in display order (before data). */
|
||||
function declaredColumns(field: FieldDefinition): { key: string; label: string; color?: TagColor }[] {
|
||||
if (field.type === 'boolean') {
|
||||
const meta = (field.metadata ?? {}) as { trueLabel?: string; falseLabel?: string }
|
||||
return [
|
||||
{ key: 'true', label: meta.trueLabel ?? 'Yes', color: 'green' },
|
||||
{ key: 'false', label: meta.falseLabel ?? 'No', color: 'gray' },
|
||||
]
|
||||
}
|
||||
const options = (field.metadata as { options?: SelectOption[] } | undefined)?.options ?? []
|
||||
return options.map((o) => ({ key: o.value, label: o.label, color: o.color }))
|
||||
}
|
||||
|
||||
export interface BoardColumnsOptions {
|
||||
/** Include an explicit "no value" column even when empty (default true). */
|
||||
includeEmpty?: boolean
|
||||
/** A label for the no-value column (default "No <field label>"). */
|
||||
emptyLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Partition records into board columns. Declared option columns come first in
|
||||
* their schema order (so an empty stage still shows as a lane); any value not in
|
||||
* the schema (or a relation id) becomes a trailing lane; records with no value
|
||||
* collect in the leading "no value" lane. Order within a lane is preserved.
|
||||
*/
|
||||
export function boardColumns(
|
||||
records: Record_[],
|
||||
field: FieldDefinition,
|
||||
opts: BoardColumnsOptions = {},
|
||||
): BoardColumn[] {
|
||||
const includeEmpty = opts.includeEmpty ?? true
|
||||
const declared = declaredColumns(field)
|
||||
const order: string[] = []
|
||||
const meta = new Map<string, { label: string; color?: TagColor }>()
|
||||
const buckets = new Map<string, Record_[]>()
|
||||
|
||||
const ensure = (key: string, label: string, color?: TagColor) => {
|
||||
if (!buckets.has(key)) { buckets.set(key, []); order.push(key); meta.set(key, { label, color }) }
|
||||
}
|
||||
|
||||
// Leading no-value lane, then declared option lanes (empty-but-present).
|
||||
if (includeEmpty) ensure(NO_VALUE, opts.emptyLabel ?? `No ${field.label.toLowerCase()}`)
|
||||
for (const c of declared) ensure(c.key, c.label, c.color)
|
||||
|
||||
for (const rec of records) {
|
||||
const key = groupKeyOf(field, rec[field.name])
|
||||
if (key === NO_VALUE && !includeEmpty) continue
|
||||
if (!buckets.has(key)) ensure(key, key || (opts.emptyLabel ?? `No ${field.label.toLowerCase()}`))
|
||||
buckets.get(key)!.push(rec)
|
||||
}
|
||||
|
||||
// Drop the no-value lane if it ended up empty and wasn't forced.
|
||||
return order
|
||||
.filter((key) => !(key === NO_VALUE && (buckets.get(key)?.length ?? 0) === 0 && declared.length > 0))
|
||||
.map((key) => ({ key, ...(meta.get(key) as { label: string; color?: TagColor }), records: buckets.get(key) ?? [] }))
|
||||
}
|
||||
|
||||
/** The record patch that moving a card into a column implies (`{ [field]: value }`). */
|
||||
export function movePatch(field: FieldDefinition, columnKey: string): Record<string, unknown> {
|
||||
if (field.type === 'boolean') return { [field.name]: columnKey === 'true' }
|
||||
return { [field.name]: columnKey === NO_VALUE ? null : columnKey }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
// A self-contained example exercising the public API end-to-end: a small object
|
||||
// schema (the kind a CRM/CMS/commerce app declares), a few records, and the
|
||||
// flagship RecordsView (table ⇆ board, filter, sort, group, inline edit, detail).
|
||||
// Doubles as a render smoke — if it composes, the surface is coherent.
|
||||
import { useState } from 'react'
|
||||
import { YStack, Text } from '@hanzo/gui'
|
||||
import { RecordsView } from './view/RecordsView'
|
||||
import { RecordDetail } from './record/RecordDetail'
|
||||
import type { FieldDefinition } from './field/types'
|
||||
import { tokens } from './theme'
|
||||
|
||||
const fields: FieldDefinition[] = [
|
||||
{ name: 'name', label: 'Name', type: 'text', width: 180 },
|
||||
{ name: 'email', label: 'Email', type: 'email' },
|
||||
{
|
||||
name: 'stage',
|
||||
label: 'Stage',
|
||||
type: 'select',
|
||||
width: 130,
|
||||
metadata: {
|
||||
options: [
|
||||
{ value: 'lead', label: 'Lead', color: 'blue' },
|
||||
{ value: 'won', label: 'Won', color: 'green' },
|
||||
{ value: 'lost', label: 'Lost', color: 'red' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ name: 'amount', label: 'Amount', type: 'currency', width: 130, metadata: { currencyCode: 'USD' } },
|
||||
{ name: 'rating', label: 'Rating', type: 'rating', width: 120, metadata: { max: 5 } },
|
||||
{ name: 'active', label: 'Active', type: 'boolean', width: 100 },
|
||||
]
|
||||
|
||||
const seed: Array<Record<string, unknown>> = [
|
||||
{ id: '1', name: 'Acme Inc', email: 'hi@acme.com', stage: 'won', amount: { amount: 4200, currencyCode: 'USD' }, rating: 5, active: true },
|
||||
{ id: '2', name: 'Globex', email: 'sales@globex.io', stage: 'lead', amount: { amount: 900, currencyCode: 'USD' }, rating: 3, active: false },
|
||||
{ id: '3', name: 'Initech', email: 'ap@initech.com', stage: 'lost', amount: { amount: 0, currencyCode: 'USD' }, rating: 2, active: false },
|
||||
]
|
||||
|
||||
export function DataAppDemo() {
|
||||
const [records, setRecords] = useState(seed)
|
||||
const [open, setOpen] = useState<Record<string, unknown> | null>(null)
|
||||
|
||||
const editCommit = (record: Record<string, unknown>, field: FieldDefinition, value: unknown) =>
|
||||
setRecords((rs) => rs.map((r) => (r.id === record.id ? { ...r, [field.name]: value } : r)))
|
||||
|
||||
return (
|
||||
<YStack gap={24} p={24} style={{ backgroundColor: tokens.surfaceRaised, minHeight: '100vh' }}>
|
||||
<RecordsView
|
||||
title="Companies"
|
||||
fields={fields}
|
||||
records={records}
|
||||
onOpen={setOpen}
|
||||
onEditCommit={editCommit}
|
||||
onCreate={() => {}}
|
||||
createLabel="New company"
|
||||
/>
|
||||
{open ? (
|
||||
<YStack maxW={560} p={20} rounded={12} borderWidth={1} style={{ borderColor: tokens.border, backgroundColor: tokens.surface }}>
|
||||
<RecordDetail
|
||||
title={String(open.name ?? 'Record')}
|
||||
fields={fields}
|
||||
record={open}
|
||||
editable
|
||||
onEditCommit={(field, value) => { editCommit(open, field, value); setOpen({ ...open, [field.name]: value }) }}
|
||||
/>
|
||||
</YStack>
|
||||
) : (
|
||||
<Text fontSize={13} style={{ color: tokens.textFaint }}>Open a record to see the detail panel.</Text>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// The display router — resolves a field's read-only renderer by type and renders
|
||||
// it. The single dispatch point: callers never branch on field type. Unknown
|
||||
// types fall back to a safe string display rather than crashing.
|
||||
import { getFieldRenderers } from './registry'
|
||||
import { FallbackDisplay } from './displays'
|
||||
import type { FieldDisplayProps } from './types'
|
||||
import './registerDefaults'
|
||||
|
||||
export function FieldDisplay(props: FieldDisplayProps) {
|
||||
const Display = getFieldRenderers(props.field.type)?.Display ?? FallbackDisplay
|
||||
return <Display {...props} />
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// The input router — resolves a field's editor by type. Read-only types (and
|
||||
// any without a registered Input yet) render nothing, so a host can safely ask
|
||||
// for an editor and get one only when the type supports editing.
|
||||
import { getFieldRenderers } from './registry'
|
||||
import type { FieldInputProps } from './types'
|
||||
import './registerDefaults'
|
||||
|
||||
export function FieldInput(props: FieldInputProps) {
|
||||
const Input = getFieldRenderers(props.field.type)?.Input
|
||||
if (!Input) return null
|
||||
return <Input {...props} />
|
||||
}
|
||||
|
||||
/** True when a field type can be edited (has a registered Input). */
|
||||
export function isEditable(type: FieldInputProps['field']['type']): boolean {
|
||||
return Boolean(getFieldRenderers(type)?.Input)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Read-only field renderers. Each is standalone and value-agnostic: it coerces
|
||||
// the raw record value for its own type and renders with @hanzo/gui primitives
|
||||
// (cross-platform — web, native, desktop). Static token colors use literal
|
||||
// props; runtime palette colors (tag tones) go through `style` so they satisfy
|
||||
// Gui's typed color props.
|
||||
import { Text, XStack } from '@hanzo/gui'
|
||||
import type {
|
||||
FieldDisplayProps,
|
||||
SelectOption,
|
||||
CurrencyValue,
|
||||
LinkValue,
|
||||
TagColor,
|
||||
} from './types'
|
||||
import { tagTone, tokens } from '../theme'
|
||||
|
||||
// ── coercion helpers (one place; components stay tiny) ───────────────────────
|
||||
const asStr = (v: unknown): string => (v == null ? '' : String(v))
|
||||
const asNum = (v: unknown): number | null => {
|
||||
if (v == null || v === '') return null
|
||||
const n = typeof v === 'number' ? v : Number(v)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
const asArray = (v: unknown): unknown[] => (Array.isArray(v) ? v : v == null ? [] : [v])
|
||||
const asDate = (v: unknown): Date | null => {
|
||||
if (v == null || v === '') return null
|
||||
const d = v instanceof Date ? v : new Date(v as string | number)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
/** Em-dash placeholder for empty values — one consistent "no value" glyph. */
|
||||
function Empty() {
|
||||
return <Text color={tokens.textFaint}>—</Text>
|
||||
}
|
||||
|
||||
// ── Tag: the shared pill for select / multiSelect / status ───────────────────
|
||||
export function Tag({ label, color }: { label: string; color?: TagColor }) {
|
||||
const t = tagTone(color)
|
||||
return (
|
||||
<XStack px={8} py={2} rounded={4} items="center" style={{ backgroundColor: t.bg }}>
|
||||
<Text fontSize={12} fontWeight="600" numberOfLines={1} style={{ color: t.fg }}>
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
const optionFor = (options: SelectOption[] | undefined, value: string): SelectOption =>
|
||||
options?.find((o) => o.value === value) ?? { value, label: value }
|
||||
|
||||
// ── text family ──────────────────────────────────────────────────────────────
|
||||
export function TextDisplay({ value }: FieldDisplayProps) {
|
||||
const s = asStr(value)
|
||||
return s ? <Text color={tokens.text} numberOfLines={1}>{s}</Text> : <Empty />
|
||||
}
|
||||
|
||||
export function LongTextDisplay({ value }: FieldDisplayProps) {
|
||||
const s = asStr(value)
|
||||
return s ? <Text color={tokens.text} numberOfLines={2}>{s}</Text> : <Empty />
|
||||
}
|
||||
|
||||
export function EmailDisplay({ value }: FieldDisplayProps) {
|
||||
const s = asStr(value)
|
||||
return s ? <Text color={tokens.accent} numberOfLines={1}>{s}</Text> : <Empty />
|
||||
}
|
||||
|
||||
export function UrlDisplay({ value }: FieldDisplayProps) {
|
||||
const s = asStr(value)
|
||||
return s ? <Text color={tokens.accent} numberOfLines={1}>{s}</Text> : <Empty />
|
||||
}
|
||||
|
||||
export function PhoneDisplay({ value }: FieldDisplayProps) {
|
||||
const s = asStr(value)
|
||||
return s ? <Text color={tokens.text} numberOfLines={1}>{s}</Text> : <Empty />
|
||||
}
|
||||
|
||||
// ── numeric family ────────────────────────────────────────────────────────────
|
||||
export function NumberDisplay({ field, value }: FieldDisplayProps) {
|
||||
const n = asNum(value)
|
||||
if (n == null) return <Empty />
|
||||
const meta = (field.metadata ?? {}) as { decimals?: number; prefix?: string; suffix?: string }
|
||||
const body = meta.decimals != null ? n.toFixed(meta.decimals) : n.toLocaleString()
|
||||
return <Text color={tokens.text}>{`${meta.prefix ?? ''}${body}${meta.suffix ?? ''}`}</Text>
|
||||
}
|
||||
|
||||
export function PercentDisplay({ field, value }: FieldDisplayProps) {
|
||||
const n = asNum(value)
|
||||
if (n == null) return <Empty />
|
||||
const decimals = ((field.metadata ?? {}) as { decimals?: number }).decimals ?? 0
|
||||
return <Text color={tokens.text}>{`${n.toFixed(decimals)}%`}</Text>
|
||||
}
|
||||
|
||||
export function CurrencyDisplay({ field, value }: FieldDisplayProps) {
|
||||
const meta = (field.metadata ?? {}) as { currencyCode?: string; decimals?: number }
|
||||
const raw = (value ?? {}) as Partial<CurrencyValue>
|
||||
const amount = typeof raw === 'object' && raw != null ? asNum(raw.amount) : asNum(value)
|
||||
if (amount == null) return <Empty />
|
||||
const code = raw.currencyCode || meta.currencyCode || 'USD'
|
||||
let text: string
|
||||
try {
|
||||
text = new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: code,
|
||||
maximumFractionDigits: meta.decimals ?? 2,
|
||||
}).format(amount)
|
||||
} catch {
|
||||
text = `${amount} ${code}`
|
||||
}
|
||||
return <Text color={tokens.text}>{text}</Text>
|
||||
}
|
||||
|
||||
export function RatingDisplay({ field, value }: FieldDisplayProps) {
|
||||
const n = asNum(value) ?? 0
|
||||
const max = ((field.metadata ?? {}) as { max?: number }).max ?? 5
|
||||
const stars = '★'.repeat(Math.max(0, Math.min(max, Math.round(n)))) +
|
||||
'☆'.repeat(Math.max(0, max - Math.round(n)))
|
||||
return <Text style={{ color: tagTone('amber').fg }}>{stars}</Text>
|
||||
}
|
||||
|
||||
// ── boolean ───────────────────────────────────────────────────────────────────
|
||||
export function BooleanDisplay({ field, value }: FieldDisplayProps) {
|
||||
const meta = (field.metadata ?? {}) as { trueLabel?: string; falseLabel?: string }
|
||||
const on = value === true || value === 'true' || value === 1
|
||||
return <Tag label={on ? meta.trueLabel ?? 'Yes' : meta.falseLabel ?? 'No'} color={on ? 'green' : 'gray'} />
|
||||
}
|
||||
|
||||
// ── select / multiSelect ──────────────────────────────────────────────────────
|
||||
export function SelectDisplay({ field, value }: FieldDisplayProps) {
|
||||
const s = asStr(value)
|
||||
if (!s) return <Empty />
|
||||
const options = (field.metadata as { options?: SelectOption[] } | undefined)?.options
|
||||
const opt = optionFor(options, s)
|
||||
return <Tag label={opt.label} color={opt.color} />
|
||||
}
|
||||
|
||||
export function MultiSelectDisplay({ field, value }: FieldDisplayProps) {
|
||||
const values = asArray(value).map(asStr).filter(Boolean)
|
||||
if (values.length === 0) return <Empty />
|
||||
const options = (field.metadata as { options?: SelectOption[] } | undefined)?.options
|
||||
return (
|
||||
<XStack gap={4} items="center" style={{ flexWrap: 'wrap' }}>
|
||||
{values.map((v) => {
|
||||
const opt = optionFor(options, v)
|
||||
return <Tag key={v} label={opt.label} color={opt.color} />
|
||||
})}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── date family ───────────────────────────────────────────────────────────────
|
||||
export function DateDisplay({ value }: FieldDisplayProps) {
|
||||
const d = asDate(value)
|
||||
return d ? <Text color={tokens.text}>{d.toLocaleDateString()}</Text> : <Empty />
|
||||
}
|
||||
|
||||
export function DateTimeDisplay({ value }: FieldDisplayProps) {
|
||||
const d = asDate(value)
|
||||
return d ? <Text color={tokens.text}>{d.toLocaleString()}</Text> : <Empty />
|
||||
}
|
||||
|
||||
// ── links ─────────────────────────────────────────────────────────────────────
|
||||
export function LinksDisplay({ value }: FieldDisplayProps) {
|
||||
const links = asArray(value)
|
||||
.map((l) => (typeof l === 'string' ? { url: l } : (l as LinkValue)))
|
||||
.filter((l) => l && l.url)
|
||||
if (links.length === 0) return <Empty />
|
||||
return (
|
||||
<XStack gap={4} items="center" style={{ flexWrap: 'wrap' }}>
|
||||
{links.map((l, i) => (
|
||||
<Tag key={`${l.url}-${i}`} label={l.label || l.url} color="blue" />
|
||||
))}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── relation ──────────────────────────────────────────────────────────────────
|
||||
export function RelationDisplay({ field, value }: FieldDisplayProps) {
|
||||
const labelField = ((field.metadata ?? {}) as { labelField?: string }).labelField ?? 'name'
|
||||
let label = ''
|
||||
if (typeof value === 'string') label = value
|
||||
else if (value && typeof value === 'object') {
|
||||
const rec = value as Record<string, unknown>
|
||||
label = asStr(rec[labelField] ?? rec.label ?? rec.name ?? rec.id)
|
||||
}
|
||||
return label ? <Tag label={label} color="purple" /> : <Empty />
|
||||
}
|
||||
|
||||
// ── json / fallback ───────────────────────────────────────────────────────────
|
||||
export function JsonDisplay({ value }: FieldDisplayProps) {
|
||||
if (value == null) return <Empty />
|
||||
let text: string
|
||||
try { text = JSON.stringify(value) } catch { text = String(value) }
|
||||
return <Text color={tokens.textMuted} numberOfLines={1}>{text}</Text>
|
||||
}
|
||||
|
||||
export function FallbackDisplay({ value }: FieldDisplayProps) {
|
||||
const s = asStr(value)
|
||||
return s ? <Text color={tokens.text} numberOfLines={1}>{s}</Text> : <Empty />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
// Editable field renderers — Twenty-grade. Each coerces the raw value, renders a
|
||||
// polished control on @hanzo/gui primitives + this package's small dropdown /
|
||||
// calendar / toggle / checkbox primitives, and emits the next value via onChange;
|
||||
// the host owns draft/save (matching the display/edit split). One editor per type,
|
||||
// resolved through the registry, so a new type is a `registerField` call — never a
|
||||
// switch. Palette is the package's raw-hex tokens (theme-config independent).
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { Button, Input, Text, TextArea, XStack, YStack } from '@hanzo/gui'
|
||||
import { Calendar as CalendarIcon, ChevronDown, Upload, X } from '@hanzogui/lucide-icons-2'
|
||||
import type { FieldInputProps, SelectOption, CurrencyValue } from './types'
|
||||
import { Tag } from './displays'
|
||||
import { Menu, MenuItem, Toggle, Calendar } from '../primitives'
|
||||
import { tokens } from '../theme'
|
||||
|
||||
const asStr = (v: unknown): string => (v == null ? '' : String(v))
|
||||
const asNum = (v: unknown): number | null => {
|
||||
if (v == null || v === '') return null
|
||||
const n = typeof v === 'number' ? v : Number(v)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
const asArray = (v: unknown): string[] =>
|
||||
(Array.isArray(v) ? v : v == null ? [] : [v]).map(asStr).filter(Boolean)
|
||||
|
||||
const optionsOf = (props: FieldInputProps): SelectOption[] =>
|
||||
(props.field.metadata as { options?: SelectOption[] } | undefined)?.options ?? []
|
||||
|
||||
/** A bordered trigger pill — the shared look for select / relation / date pickers. */
|
||||
function TriggerPill({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<XStack
|
||||
items="center"
|
||||
justify="space-between"
|
||||
gap={8}
|
||||
px={10}
|
||||
py={7}
|
||||
rounded={8}
|
||||
borderWidth={1}
|
||||
cursor="pointer"
|
||||
minH={36}
|
||||
hoverStyle={{ borderColor: tokens.textMuted }}
|
||||
style={{ borderColor: tokens.border, backgroundColor: tokens.surface }}
|
||||
>
|
||||
<XStack items="center" gap={6} flex={1} style={{ flexWrap: 'wrap' }}>{children}</XStack>
|
||||
<ChevronDown size={14} color={tokens.textFaint} />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
const Placeholder = ({ text }: { text: string }) => <Text style={{ color: tokens.textFaint }}>{text}</Text>
|
||||
|
||||
const SearchBox = ({ q, onQ, placeholder }: { q: string; onQ: (v: string) => void; placeholder: string }) => (
|
||||
<Input size="$2" value={q} onChangeText={onQ} placeholder={placeholder} autoFocus />
|
||||
)
|
||||
|
||||
// ── text family ──────────────────────────────────────────────────────────────
|
||||
export function TextInput({ field, value, onChange, onSubmit, autoFocus }: FieldInputProps) {
|
||||
const placeholder = (field.metadata as { placeholder?: string } | undefined)?.placeholder
|
||||
return (
|
||||
<Input
|
||||
value={asStr(value)}
|
||||
onChangeText={(t: string) => onChange(t)}
|
||||
onSubmitEditing={onSubmit}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmailInput(props: FieldInputProps) { return <TextInput {...props} /> }
|
||||
export function UrlInput(props: FieldInputProps) { return <TextInput {...props} /> }
|
||||
export function PhoneInput(props: FieldInputProps) { return <TextInput {...props} /> }
|
||||
|
||||
export function LongTextInput({ field, value, onChange, autoFocus }: FieldInputProps) {
|
||||
const meta = (field.metadata ?? {}) as { placeholder?: string; rows?: number }
|
||||
return (
|
||||
<TextArea
|
||||
value={asStr(value)}
|
||||
onChangeText={(t: string) => onChange(t)}
|
||||
placeholder={meta.placeholder}
|
||||
autoFocus={autoFocus}
|
||||
numberOfLines={meta.rows ?? 4}
|
||||
style={{ minHeight: (meta.rows ?? 4) * 22 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── numeric family ────────────────────────────────────────────────────────────
|
||||
export function NumberInput({ field, value, onChange, onSubmit, autoFocus }: FieldInputProps) {
|
||||
const placeholder = (field.metadata as { placeholder?: string } | undefined)?.placeholder
|
||||
return (
|
||||
<Input
|
||||
value={value == null ? '' : String(value)}
|
||||
onChangeText={(t: string) => onChange(t === '' ? null : asNum(t))}
|
||||
onSubmitEditing={onSubmit}
|
||||
placeholder={placeholder}
|
||||
inputMode="numeric"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export function PercentInput(props: FieldInputProps) { return <NumberInput {...props} /> }
|
||||
|
||||
export function CurrencyInput({ field, value, onChange, onSubmit, autoFocus }: FieldInputProps) {
|
||||
const meta = (field.metadata ?? {}) as { currencyCode?: string }
|
||||
const raw = (value ?? {}) as Partial<CurrencyValue>
|
||||
const amount = typeof raw === 'object' && raw != null ? raw.amount : asNum(value)
|
||||
const code = raw.currencyCode || meta.currencyCode || 'USD'
|
||||
return (
|
||||
<XStack items="center" gap={8}>
|
||||
<Text style={{ color: tokens.textMuted }} fontSize={13}>{code}</Text>
|
||||
<Input
|
||||
flex={1}
|
||||
value={amount == null ? '' : String(amount)}
|
||||
onChangeText={(t: string) => onChange({ amount: t === '' ? null : asNum(t), currencyCode: code })}
|
||||
onSubmitEditing={onSubmit}
|
||||
inputMode="decimal"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function RatingInput({ field, value, onChange }: FieldInputProps) {
|
||||
const max = ((field.metadata ?? {}) as { max?: number }).max ?? 5
|
||||
const current = asNum(value) ?? 0
|
||||
return (
|
||||
<XStack gap={2} items="center">
|
||||
{Array.from({ length: max }, (_, i) => i + 1).map((n) => (
|
||||
<YStack key={n} cursor="pointer" onPress={() => onChange(n === current ? 0 : n)}>
|
||||
<Text fontSize={18} style={{ color: n <= current ? '#fbbf24' : tokens.border }}>{n <= current ? '★' : '☆'}</Text>
|
||||
</YStack>
|
||||
))}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── boolean (sliding toggle) ──────────────────────────────────────────────────
|
||||
export function BooleanInput({ field, value, onChange }: FieldInputProps) {
|
||||
const meta = (field.metadata ?? {}) as { trueLabel?: string; falseLabel?: string }
|
||||
const on = value === true || value === 'true' || value === 1
|
||||
return (
|
||||
<XStack items="center" gap={8}>
|
||||
<Toggle on={on} onChange={(next) => onChange(next)} />
|
||||
<Text fontSize={13} style={{ color: tokens.textMuted }}>{on ? meta.trueLabel ?? 'Yes' : meta.falseLabel ?? 'No'}</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── select (searchable dropdown, chip value) ──────────────────────────────────
|
||||
export function SelectInput(props: FieldInputProps) {
|
||||
const { value, onChange } = props
|
||||
const options = optionsOf(props)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [q, setQ] = useState('')
|
||||
const current = asStr(value)
|
||||
const selected = options.find((o) => o.value === current)
|
||||
const filtered = useMemo(
|
||||
() => options.filter((o) => o.label.toLowerCase().includes(q.trim().toLowerCase())),
|
||||
[options, q],
|
||||
)
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={(o) => { setOpen(o); if (!o) setQ('') }}
|
||||
width={260}
|
||||
header={options.length > 6 ? <SearchBox q={q} onQ={setQ} placeholder="Search options…" /> : undefined}
|
||||
trigger={<TriggerPill>{selected ? <Tag label={selected.label} color={selected.color} /> : <Placeholder text="Select…" />}</TriggerPill>}
|
||||
>
|
||||
{current ? (
|
||||
<MenuItem onPress={() => { onChange(null); setOpen(false) }}>
|
||||
<Text fontSize={13} style={{ color: tokens.textFaint }}>Clear</Text>
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{filtered.map((o) => (
|
||||
<MenuItem key={o.value} active={o.value === current} onPress={() => { onChange(o.value); setOpen(false) }}>
|
||||
<Tag label={o.label} color={o.color} />
|
||||
</MenuItem>
|
||||
))}
|
||||
{filtered.length === 0 ? <Text px={10} py={6} fontSize={12} style={{ color: tokens.textFaint }}>No options</Text> : null}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
// ── multiSelect (chips + checkable dropdown) ──────────────────────────────────
|
||||
export function MultiSelectInput(props: FieldInputProps) {
|
||||
const { value, onChange } = props
|
||||
const options = optionsOf(props)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [q, setQ] = useState('')
|
||||
const selected = new Set(asArray(value))
|
||||
const toggle = (v: string) => {
|
||||
const next = new Set(selected)
|
||||
if (next.has(v)) next.delete(v)
|
||||
else next.add(v)
|
||||
onChange([...next])
|
||||
}
|
||||
const chosen = options.filter((o) => selected.has(o.value))
|
||||
const filtered = options.filter((o) => o.label.toLowerCase().includes(q.trim().toLowerCase()))
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={(o) => { setOpen(o); if (!o) setQ('') }}
|
||||
width={260}
|
||||
header={options.length > 6 ? <SearchBox q={q} onQ={setQ} placeholder="Search options…" /> : undefined}
|
||||
trigger={
|
||||
<TriggerPill>
|
||||
{chosen.length > 0 ? chosen.map((o) => <Tag key={o.value} label={o.label} color={o.color} />) : <Placeholder text="Select…" />}
|
||||
</TriggerPill>
|
||||
}
|
||||
>
|
||||
{filtered.map((o) => (
|
||||
<MenuItem
|
||||
key={o.value}
|
||||
active={selected.has(o.value)}
|
||||
onPress={() => toggle(o.value)}
|
||||
trailing={selected.has(o.value) ? <Text style={{ color: tokens.accent }}>✓</Text> : null}
|
||||
>
|
||||
<Tag label={o.label} color={o.color} />
|
||||
</MenuItem>
|
||||
))}
|
||||
{filtered.length === 0 ? <Text px={10} py={6} fontSize={12} style={{ color: tokens.textFaint }}>No options</Text> : null}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
// ── date / dateTime (calendar picker) ─────────────────────────────────────────
|
||||
export function DateInput({ value, onChange, autoFocus }: FieldInputProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const isoDay = value == null ? '' : value instanceof Date ? value.toISOString().slice(0, 10) : String(value).slice(0, 10)
|
||||
const label = isoDay ? new Date(`${isoDay}T00:00:00`).toLocaleDateString() : ''
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
width={272}
|
||||
trigger={
|
||||
<TriggerPill>
|
||||
<XStack items="center" gap={8} flex={1}>
|
||||
<CalendarIcon size={15} color={tokens.textFaint} />
|
||||
{label ? <Text style={{ color: tokens.text }}>{label}</Text> : <Placeholder text="Pick a date…" />}
|
||||
</XStack>
|
||||
</TriggerPill>
|
||||
}
|
||||
>
|
||||
<Calendar value={isoDay} onSelect={(d) => { onChange(d); setOpen(false) }} />
|
||||
{isoDay ? (
|
||||
<MenuItem onPress={() => { onChange(null); setOpen(false) }}>
|
||||
<Text fontSize={13} style={{ color: tokens.textFaint }}>Clear</Text>
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<YStack px={10} pb={8}>
|
||||
<Input size="$2" value={isoDay} placeholder="YYYY-MM-DD" onChangeText={(t: string) => onChange(t)} autoFocus={autoFocus} />
|
||||
</YStack>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
// ── relation (record picker over injected options, else id text) ──────────────
|
||||
export function RelationInput(props: FieldInputProps) {
|
||||
const { value, onChange } = props
|
||||
const options = optionsOf(props)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
// Presentational: when the host injects candidate records as options, pick one;
|
||||
// otherwise fall back to a raw id entry (honest — no fabricated candidate list).
|
||||
if (options.length === 0) {
|
||||
const label = typeof value === 'object' && value != null
|
||||
? asStr((value as Record<string, unknown>).name ?? (value as Record<string, unknown>).id)
|
||||
: asStr(value)
|
||||
return <Input value={label} onChangeText={(t: string) => onChange(t)} placeholder="Related record id…" />
|
||||
}
|
||||
|
||||
const current = asStr(
|
||||
typeof value === 'object' && value != null
|
||||
? (value as Record<string, unknown>).id ?? (value as Record<string, unknown>).value
|
||||
: value,
|
||||
)
|
||||
const selected = options.find((o) => o.value === current)
|
||||
const filtered = options.filter((o) => o.label.toLowerCase().includes(q.trim().toLowerCase()))
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={(o) => { setOpen(o); if (!o) setQ('') }}
|
||||
width={280}
|
||||
header={<SearchBox q={q} onQ={setQ} placeholder="Search records…" />}
|
||||
trigger={<TriggerPill>{selected ? <Tag label={selected.label} color="purple" /> : <Placeholder text="Link a record…" />}</TriggerPill>}
|
||||
>
|
||||
{current ? (
|
||||
<MenuItem onPress={() => { onChange(null); setOpen(false) }}>
|
||||
<Text fontSize={13} style={{ color: tokens.textFaint }}>Clear</Text>
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{filtered.map((o) => (
|
||||
<MenuItem key={o.value} active={o.value === current} onPress={() => { onChange(o.value); setOpen(false) }}>
|
||||
<Text fontSize={13} style={{ color: tokens.text }}>{o.label}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
{filtered.length === 0 ? <Text px={10} py={6} fontSize={12} style={{ color: tokens.textFaint }}>No matches</Text> : null}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
// ── files (web upload; host persists) ─────────────────────────────────────────
|
||||
export function FileInput({ value, onChange }: FieldInputProps) {
|
||||
const names = (Array.isArray(value) ? value : value == null ? [] : [value]).map((f) =>
|
||||
typeof f === 'string' ? f : asStr((f as { name?: string })?.name ?? 'file'),
|
||||
)
|
||||
const pick = () => {
|
||||
if (typeof document === 'undefined') return
|
||||
const el = document.createElement('input')
|
||||
el.type = 'file'
|
||||
el.multiple = true
|
||||
el.onchange = () => onChange(el.files ? Array.from(el.files) : [])
|
||||
el.click()
|
||||
}
|
||||
const supported = typeof document !== 'undefined'
|
||||
return (
|
||||
<YStack gap={8}>
|
||||
{supported ? (
|
||||
<Button size="$2" icon={<Upload size={15} />} onPress={pick}>Choose files</Button>
|
||||
) : (
|
||||
<Text fontSize={13} style={{ color: tokens.textFaint }}>File upload is available on the web.</Text>
|
||||
)}
|
||||
{names.length > 0 ? (
|
||||
<XStack gap={6} items="center" style={{ flexWrap: 'wrap' }}>
|
||||
{names.map((n, i) => (
|
||||
<XStack key={`${n}-${i}`} items="center" gap={4} px={8} py={3} rounded={6} style={{ backgroundColor: tokens.hover }}>
|
||||
<Text fontSize={12} style={{ color: tokens.text }}>{n}</Text>
|
||||
<YStack cursor="pointer" onPress={() => onChange((Array.isArray(value) ? value : []).filter((_, j) => j !== i))}>
|
||||
<X size={12} color={tokens.textFaint} />
|
||||
</YStack>
|
||||
</XStack>
|
||||
))}
|
||||
</XStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── json (validated textarea) ─────────────────────────────────────────────────
|
||||
function safeStringify(v: unknown): string {
|
||||
try { return JSON.stringify(v, null, 2) } catch { return String(v) }
|
||||
}
|
||||
|
||||
export function JsonInput({ value, onChange, autoFocus }: FieldInputProps) {
|
||||
const initial = value == null ? '' : typeof value === 'string' ? value : safeStringify(value)
|
||||
const [text, setText] = useState(initial)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const commit = (t: string) => {
|
||||
setText(t)
|
||||
if (t.trim() === '') { setError(null); onChange(null); return }
|
||||
try { onChange(JSON.parse(t)); setError(null) } catch { setError('Invalid JSON') }
|
||||
}
|
||||
return (
|
||||
<YStack gap={4}>
|
||||
<TextArea
|
||||
value={text}
|
||||
onChangeText={commit}
|
||||
autoFocus={autoFocus}
|
||||
numberOfLines={6}
|
||||
placeholder="{ }"
|
||||
style={{ minHeight: 132, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' }}
|
||||
/>
|
||||
{error ? <Text fontSize={12} style={{ color: tokens.danger }}>{error}</Text> : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Wire the built-in field types to their renderers. Importing this module (the
|
||||
// barrel does) registers everything. Read-only types (uuid, position, actor,
|
||||
// json, files, address, relation, links) omit an Input until a richer editor
|
||||
// (record picker, file upload, calendar) is registered over them — the table
|
||||
// and detail views already work with display-only types.
|
||||
import { registerField } from './registry'
|
||||
import {
|
||||
TextDisplay, LongTextDisplay, NumberDisplay, PercentDisplay, CurrencyDisplay,
|
||||
BooleanDisplay, SelectDisplay, MultiSelectDisplay, DateDisplay, DateTimeDisplay,
|
||||
EmailDisplay, UrlDisplay, PhoneDisplay, LinksDisplay, RatingDisplay,
|
||||
RelationDisplay, JsonDisplay, FallbackDisplay,
|
||||
} from './displays'
|
||||
import {
|
||||
TextInput, LongTextInput, NumberInput, PercentInput, CurrencyInput,
|
||||
BooleanInput, SelectInput, MultiSelectInput, DateInput, EmailInput,
|
||||
UrlInput, PhoneInput, RatingInput, RelationInput, FileInput, JsonInput,
|
||||
} from './inputs'
|
||||
|
||||
let done = false
|
||||
|
||||
/** Idempotent — registers the built-in renderers once. */
|
||||
export function registerDefaultFields(): void {
|
||||
if (done) return
|
||||
done = true
|
||||
|
||||
registerField('text', { Display: TextDisplay, Input: TextInput })
|
||||
registerField('longText', { Display: LongTextDisplay, Input: LongTextInput })
|
||||
registerField('richText', { Display: LongTextDisplay, Input: LongTextInput })
|
||||
registerField('number', { Display: NumberDisplay, Input: NumberInput })
|
||||
registerField('percent', { Display: PercentDisplay, Input: PercentInput })
|
||||
registerField('currency', { Display: CurrencyDisplay, Input: CurrencyInput })
|
||||
registerField('boolean', { Display: BooleanDisplay, Input: BooleanInput })
|
||||
registerField('select', { Display: SelectDisplay, Input: SelectInput })
|
||||
registerField('multiSelect', { Display: MultiSelectDisplay, Input: MultiSelectInput })
|
||||
registerField('date', { Display: DateDisplay, Input: DateInput })
|
||||
registerField('dateTime', { Display: DateTimeDisplay, Input: DateInput })
|
||||
registerField('email', { Display: EmailDisplay, Input: EmailInput })
|
||||
registerField('url', { Display: UrlDisplay, Input: UrlInput })
|
||||
registerField('phone', { Display: PhoneDisplay, Input: PhoneInput })
|
||||
registerField('rating', { Display: RatingDisplay, Input: RatingInput })
|
||||
registerField('links', { Display: LinksDisplay })
|
||||
registerField('relation', { Display: RelationDisplay, Input: RelationInput })
|
||||
registerField('json', { Display: JsonDisplay, Input: JsonInput })
|
||||
registerField('files', { Display: FallbackDisplay, Input: FileInput })
|
||||
registerField('uuid', { Display: FallbackDisplay })
|
||||
registerField('fullName', { Display: FallbackDisplay })
|
||||
registerField('address', { Display: FallbackDisplay })
|
||||
registerField('position', { Display: NumberDisplay })
|
||||
registerField('actor', { Display: FallbackDisplay })
|
||||
}
|
||||
|
||||
registerDefaultFields()
|
||||
@@ -0,0 +1,40 @@
|
||||
// The field registry — the one dispatch point from a FieldType to its renderers.
|
||||
//
|
||||
// Why a registry and not a switch: adding (or overriding) a field type is a
|
||||
// single `registerField` call, with zero edits to FieldDisplay/FieldInput/
|
||||
// DataTable. Apps can register custom types (e.g. a domain-specific "sku" or
|
||||
// "geo") and they render everywhere automatically. Orthogonal + open/closed.
|
||||
import type {
|
||||
FieldType,
|
||||
FieldDisplayComponent,
|
||||
FieldInputComponent,
|
||||
} from './types'
|
||||
|
||||
export interface FieldRenderers {
|
||||
/** Read-only renderer. Required — every type can at least display. */
|
||||
Display: FieldDisplayComponent
|
||||
/** Edit renderer. Optional — read-only types (uuid, actor, position) omit it. */
|
||||
Input?: FieldInputComponent
|
||||
}
|
||||
|
||||
const registry = new Map<FieldType, FieldRenderers>()
|
||||
|
||||
/** Register (or override) the renderers for a field type. */
|
||||
export function registerField(type: FieldType, renderers: FieldRenderers): void {
|
||||
registry.set(type, renderers)
|
||||
}
|
||||
|
||||
/** Resolve renderers for a type, or undefined if none registered yet. */
|
||||
export function getFieldRenderers(type: FieldType): FieldRenderers | undefined {
|
||||
return registry.get(type)
|
||||
}
|
||||
|
||||
/** True when a type has a registered renderer. */
|
||||
export function hasField(type: FieldType): boolean {
|
||||
return registry.has(type)
|
||||
}
|
||||
|
||||
/** Every registered type — useful for schema editors / type pickers. */
|
||||
export function registeredFieldTypes(): FieldType[] {
|
||||
return [...registry.keys()]
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// The field type system — the universal vocabulary every Base-backed app
|
||||
// (CRM, CMS, commerce, internal tool) is built from. An "object" is a set of
|
||||
// typed fields; a "record" is values for those fields; every view (table,
|
||||
// board, detail) renders from this one model. One source of truth.
|
||||
//
|
||||
// Each FieldType maps to exactly one display component (read) and at most one
|
||||
// input component (edit), resolved through the registry — so adding a type is
|
||||
// adding one entry, never editing a switch.
|
||||
import type { ReactElement } from 'react'
|
||||
|
||||
/**
|
||||
* The built-in field types. Extensible: register a new type's renderers and it
|
||||
* works everywhere. Covers the universal CRM/CMS/commerce set; niche types
|
||||
* (actor/position/json/files/richText/address/fullName) are declared here so
|
||||
* schemas can reference them and slot renderers in incrementally.
|
||||
*/
|
||||
export type FieldType =
|
||||
| 'text' // single-line text
|
||||
| 'longText' // multi-line text
|
||||
| 'number' // integer or decimal
|
||||
| 'percent' // 0–100(%), stored as a number
|
||||
| 'currency' // money: { amount, currencyCode }
|
||||
| 'boolean' // true/false toggle
|
||||
| 'select' // one color-tagged option
|
||||
| 'multiSelect' // many color-tagged options
|
||||
| 'date' // calendar date (no time)
|
||||
| 'dateTime' // date + time
|
||||
| 'email' // single email address
|
||||
| 'url' // single URL
|
||||
| 'links' // many labelled URLs
|
||||
| 'phone' // phone number
|
||||
| 'rating' // 0–N stars
|
||||
| 'relation' // reference to another record
|
||||
| 'json' // arbitrary JSON
|
||||
| 'uuid' // immutable identifier
|
||||
| 'fullName' // { first, last }
|
||||
| 'address' // postal address
|
||||
| 'files' // file attachments
|
||||
| 'richText' // block/markdown rich text
|
||||
| 'position' // sort order
|
||||
| 'actor' // audit: who/what created the record
|
||||
|
||||
/** Tag palette key — resolved to bg/fg by the theme. Brand-neutral by name. */
|
||||
export type TagColor =
|
||||
| 'gray' | 'blue' | 'green' | 'amber' | 'red'
|
||||
| 'purple' | 'pink' | 'teal' | 'orange'
|
||||
|
||||
/** One option of a select/multiSelect field. */
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
color?: TagColor
|
||||
}
|
||||
|
||||
/** A labelled URL, for `links`. */
|
||||
export interface LinkValue {
|
||||
url: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
/** A money value, for `currency`. */
|
||||
export interface CurrencyValue {
|
||||
/** Major-unit amount (e.g. dollars), not micros — kept simple at the edge. */
|
||||
amount: number | null
|
||||
/** ISO 4217 code, e.g. "USD". */
|
||||
currencyCode: string
|
||||
}
|
||||
|
||||
/** Per-type metadata — the schema knobs each type understands. */
|
||||
export interface FieldMetadataMap {
|
||||
text: { placeholder?: string }
|
||||
longText: { placeholder?: string; rows?: number }
|
||||
number: { placeholder?: string; decimals?: number; prefix?: string; suffix?: string }
|
||||
percent: { decimals?: number }
|
||||
currency: { currencyCode?: string; decimals?: number }
|
||||
boolean: { trueLabel?: string; falseLabel?: string }
|
||||
select: { options: SelectOption[] }
|
||||
multiSelect: { options: SelectOption[] }
|
||||
date: { display?: 'relative' | 'absolute' }
|
||||
dateTime: { display?: 'relative' | 'absolute' }
|
||||
email: { placeholder?: string }
|
||||
url: { placeholder?: string }
|
||||
links: Record<string, never>
|
||||
phone: { placeholder?: string }
|
||||
rating: { max?: number }
|
||||
relation: { objectName?: string; labelField?: string }
|
||||
json: Record<string, never>
|
||||
uuid: Record<string, never>
|
||||
fullName: Record<string, never>
|
||||
address: Record<string, never>
|
||||
files: Record<string, never>
|
||||
richText: Record<string, never>
|
||||
position: Record<string, never>
|
||||
actor: Record<string, never>
|
||||
}
|
||||
|
||||
export type FieldMetadata<T extends FieldType = FieldType> = FieldMetadataMap[T]
|
||||
|
||||
/**
|
||||
* A field's definition — the schema unit. Mirrors a Base collection field:
|
||||
* a stable `name`, a human `label`, a `type`, and type-specific `metadata`.
|
||||
*/
|
||||
export interface FieldDefinition<T extends FieldType = FieldType> {
|
||||
/** Stable key — the record property / collection field name. */
|
||||
name: string
|
||||
/** Human-readable column/label text. */
|
||||
label: string
|
||||
/** Drives which display + input render. */
|
||||
type: T
|
||||
/** Type-specific options (select options, currency code, …). */
|
||||
metadata?: FieldMetadata<T>
|
||||
/** When true, the field never enters edit mode. */
|
||||
readOnly?: boolean
|
||||
/** Fixed pixel width when used as a table column; otherwise it flexes. */
|
||||
width?: number
|
||||
}
|
||||
|
||||
// ── Component contracts ──────────────────────────────────────────────────────
|
||||
// Display + Input are intentionally tiny and value-agnostic: each component
|
||||
// owns the coercion of `value` for its own type, so they stay standalone and
|
||||
// composable. The router (FieldDisplay/FieldInput) only resolves by type.
|
||||
|
||||
export interface FieldDisplayProps {
|
||||
field: FieldDefinition
|
||||
/** The record's raw value for this field — each component coerces it. */
|
||||
value: unknown
|
||||
}
|
||||
|
||||
export interface FieldInputProps {
|
||||
field: FieldDefinition
|
||||
value: unknown
|
||||
/** Emit the next value. The host owns persistence (draft vs save). */
|
||||
onChange: (value: unknown) => void
|
||||
/** Commit (Enter / blur). */
|
||||
onSubmit?: () => void
|
||||
/** Discard (Escape). */
|
||||
onCancel?: () => void
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
export type FieldDisplayComponent = (props: FieldDisplayProps) => ReactElement | null
|
||||
export type FieldInputComponent = (props: FieldInputProps) => ReactElement | null
|
||||
@@ -0,0 +1,119 @@
|
||||
// @hanzo/data — the cross-platform, metadata-driven data-app layer.
|
||||
//
|
||||
// One model (typed fields → records → views) powers any Base-backed app: CRM,
|
||||
// CMS, commerce, internal tools. Built on @hanzo/gui so the same components run on
|
||||
// web, native (iOS), and desktop. Airtable/Twenty-class polish, clean-room.
|
||||
// Importing the package registers the built-in field renderers; register more to
|
||||
// extend. A business app is a curated set of views over these components.
|
||||
//
|
||||
// import { RecordsView, DataTable, BoardView, RecordDetail, registerField } from '@hanzo/data'
|
||||
|
||||
// Register the built-in field renderers as a side effect of importing the lib.
|
||||
import './field/registerDefaults'
|
||||
|
||||
// ── Field model + contracts ──────────────────────────────────────────────────
|
||||
export type {
|
||||
FieldType,
|
||||
TagColor,
|
||||
SelectOption,
|
||||
LinkValue,
|
||||
CurrencyValue,
|
||||
FieldMetadata,
|
||||
FieldMetadataMap,
|
||||
FieldDefinition,
|
||||
FieldDisplayProps,
|
||||
FieldInputProps,
|
||||
FieldDisplayComponent,
|
||||
FieldInputComponent,
|
||||
} from './field/types'
|
||||
|
||||
// Registry (extensibility)
|
||||
export {
|
||||
registerField,
|
||||
getFieldRenderers,
|
||||
hasField,
|
||||
registeredFieldTypes,
|
||||
type FieldRenderers,
|
||||
} from './field/registry'
|
||||
export { registerDefaultFields } from './field/registerDefaults'
|
||||
|
||||
// Routers + built-in renderers (usable standalone / for composition)
|
||||
export { FieldDisplay } from './field/FieldDisplay'
|
||||
export { FieldInput, isEditable } from './field/FieldInput'
|
||||
export * from './field/displays'
|
||||
export * from './field/inputs'
|
||||
|
||||
// ── Views ────────────────────────────────────────────────────────────────────
|
||||
export { DataTable, type DataTableProps } from './table/DataTable'
|
||||
export { RecordCard, type RecordCardProps } from './table/RecordCard'
|
||||
export { BoardView, type BoardViewProps } from './board/BoardView'
|
||||
export {
|
||||
RecordDetail,
|
||||
RecordForm,
|
||||
type RecordDetailProps,
|
||||
type RecordFormProps,
|
||||
} from './record/RecordDetail'
|
||||
export {
|
||||
RecordsView,
|
||||
type RecordsViewProps,
|
||||
type SavedViews,
|
||||
} from './view/RecordsView'
|
||||
export { RecordsToolbar, type RecordsToolbarProps } from './view/Toolbar'
|
||||
export type { ViewConfig, ViewKind } from './view/types'
|
||||
|
||||
// ── Composable primitives (dropdown, checkbox, toggle, calendar) ──────────────
|
||||
export { Menu, MenuItem, CheckBox, Toggle, Calendar } from './primitives'
|
||||
export type { MenuProps, MenuItemProps, CheckBoxProps, ToggleProps, CalendarProps } from './primitives'
|
||||
|
||||
// ── Pure logic (sort / filter / search / group / view) — host-usable, testable ──
|
||||
export {
|
||||
comparable,
|
||||
relationLabel,
|
||||
sortRecords,
|
||||
cycleSort,
|
||||
sortDirOf,
|
||||
operatorsForType,
|
||||
matchesRule,
|
||||
filterRecords,
|
||||
searchRecords,
|
||||
paginate,
|
||||
moveItem,
|
||||
orderedColumns,
|
||||
columnIndexAtX,
|
||||
OP_LABELS,
|
||||
type SortRule,
|
||||
type SortDir,
|
||||
type FilterRule,
|
||||
type FilterOp,
|
||||
type Page,
|
||||
} from './table/logic'
|
||||
export {
|
||||
boardColumns,
|
||||
movePatch,
|
||||
groupKeyOf,
|
||||
groupFieldCandidates,
|
||||
defaultGroupField,
|
||||
NO_VALUE,
|
||||
type BoardColumn,
|
||||
} from './board/logic'
|
||||
export {
|
||||
applyView,
|
||||
emptyView,
|
||||
defaultRuleFor,
|
||||
addFilter,
|
||||
updateFilter,
|
||||
removeFilter,
|
||||
setSorts,
|
||||
cycleColumnSort,
|
||||
setSearch,
|
||||
setKind,
|
||||
setGroupField,
|
||||
setColumnOrder,
|
||||
toggleFieldHidden,
|
||||
upsertView,
|
||||
removeView,
|
||||
describeView,
|
||||
} from './view/logic'
|
||||
|
||||
// ── Theme ────────────────────────────────────────────────────────────────────
|
||||
export { tokens, TAG_TONES, tagTone, type TagTone } from './theme'
|
||||
@@ -0,0 +1,98 @@
|
||||
// Calendar — a compact month-grid day picker for date / dateTime fields. Pure
|
||||
// primitives + the data palette; no date library. Emits an ISO `YYYY-MM-DD` on
|
||||
// select. Month navigation is local state; today and the selected day are marked.
|
||||
import { useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from '@hanzogui/lucide-icons-2'
|
||||
import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { tokens, tagTone } from '../theme'
|
||||
|
||||
const DOW = ['S', 'M', 'T', 'W', 'T', 'F', 'S']
|
||||
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
||||
|
||||
const iso = (d: Date): string =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
|
||||
export interface CalendarProps {
|
||||
/** Selected day as ISO `YYYY-MM-DD`, or undefined. */
|
||||
value?: string
|
||||
onSelect: (isoDate: string) => void
|
||||
}
|
||||
|
||||
export function Calendar({ value, onSelect }: CalendarProps) {
|
||||
const selected = value ? new Date(`${value}T00:00:00`) : null
|
||||
const init = selected && !Number.isNaN(selected.getTime()) ? selected : new Date()
|
||||
const [view, setView] = useState({ year: init.getFullYear(), month: init.getMonth() })
|
||||
const today = iso(new Date())
|
||||
|
||||
const first = new Date(view.year, view.month, 1)
|
||||
const startPad = first.getDay()
|
||||
const daysInMonth = new Date(view.year, view.month + 1, 0).getDate()
|
||||
const cells: (number | null)[] = [
|
||||
...Array.from({ length: startPad }, () => null),
|
||||
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||
]
|
||||
while (cells.length % 7 !== 0) cells.push(null)
|
||||
|
||||
const shift = (delta: number) => {
|
||||
const m = view.month + delta
|
||||
setView({ year: view.year + Math.floor(m / 12), month: ((m % 12) + 12) % 12 })
|
||||
}
|
||||
const selectedIso = selected ? iso(selected) : ''
|
||||
|
||||
return (
|
||||
<YStack width={252} p={10} gap={8}>
|
||||
<XStack items="center" justify="space-between">
|
||||
<YStack width={28} height={28} rounded={6} items="center" justify="center" cursor="pointer" hoverStyle={{ bg: tokens.hover }} onPress={() => shift(-1)}>
|
||||
<ChevronLeft size={16} color={tokens.textMuted} />
|
||||
</YStack>
|
||||
<Text fontSize={13} fontWeight="700" style={{ color: tokens.text }}>{`${MONTHS[view.month]} ${view.year}`}</Text>
|
||||
<YStack width={28} height={28} rounded={6} items="center" justify="center" cursor="pointer" hoverStyle={{ bg: tokens.hover }} onPress={() => shift(1)}>
|
||||
<ChevronRight size={16} color={tokens.textMuted} />
|
||||
</YStack>
|
||||
</XStack>
|
||||
|
||||
<XStack>
|
||||
{DOW.map((d, i) => (
|
||||
<YStack key={i} width={32} items="center">
|
||||
<Text fontSize={11} fontWeight="600" style={{ color: tokens.textFaint }}>{d}</Text>
|
||||
</YStack>
|
||||
))}
|
||||
</XStack>
|
||||
|
||||
<YStack>
|
||||
{Array.from({ length: cells.length / 7 }, (_, row) => (
|
||||
<XStack key={row}>
|
||||
{cells.slice(row * 7, row * 7 + 7).map((day, col) => {
|
||||
if (day == null) return <YStack key={col} width={32} height={30} />
|
||||
const dISO = iso(new Date(view.year, view.month, day))
|
||||
const isSel = dISO === selectedIso
|
||||
const isToday = dISO === today
|
||||
return (
|
||||
<YStack
|
||||
key={col}
|
||||
width={32}
|
||||
height={30}
|
||||
items="center"
|
||||
justify="center"
|
||||
rounded={6}
|
||||
cursor="pointer"
|
||||
hoverStyle={{ bg: tokens.hover }}
|
||||
onPress={() => onSelect(dISO)}
|
||||
style={isSel ? { backgroundColor: tokens.accent } : undefined}
|
||||
>
|
||||
<Text
|
||||
fontSize={12}
|
||||
fontWeight={isSel || isToday ? '700' : '400'}
|
||||
style={{ color: isSel ? '#0b1220' : isToday ? tagTone('blue').fg : tokens.text }}
|
||||
>
|
||||
{day}
|
||||
</Text>
|
||||
</YStack>
|
||||
)
|
||||
})}
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// CheckBox — a small, self-contained selection box (no composite gui Checkbox
|
||||
// API), used for row selection in the table. Checked / indeterminate states with
|
||||
// the data palette; press toggles. Keeps the package on its minimal primitive
|
||||
// surface so it type-checks everywhere.
|
||||
import { Check, Minus } from '@hanzogui/lucide-icons-2'
|
||||
import { YStack } from '@hanzo/gui'
|
||||
import { tokens } from '../theme'
|
||||
|
||||
export interface CheckBoxProps {
|
||||
checked?: boolean
|
||||
/** Some-but-not-all selected (header checkbox). */
|
||||
indeterminate?: boolean
|
||||
onChange?: (checked: boolean) => void
|
||||
size?: number
|
||||
}
|
||||
|
||||
export function CheckBox({ checked, indeterminate, onChange, size = 16 }: CheckBoxProps) {
|
||||
const on = checked || indeterminate
|
||||
return (
|
||||
<YStack
|
||||
width={size}
|
||||
height={size}
|
||||
rounded={4}
|
||||
items="center"
|
||||
justify="center"
|
||||
borderWidth={1}
|
||||
cursor="pointer"
|
||||
onPress={onChange ? () => onChange(!checked) : undefined}
|
||||
hoverStyle={{ borderColor: tokens.accent }}
|
||||
style={{
|
||||
backgroundColor: on ? tokens.accent : 'transparent',
|
||||
borderColor: on ? tokens.accent : tokens.border,
|
||||
}}
|
||||
>
|
||||
{indeterminate ? (
|
||||
<Minus size={size - 4} color="#0b1220" strokeWidth={3} />
|
||||
) : checked ? (
|
||||
<Check size={size - 4} color="#0b1220" strokeWidth={3} />
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Menu — the ONE dropdown/overlay primitive the data views use (column sort menu,
|
||||
// filter builder, select/relation pickers, view switcher). A thin, controlled
|
||||
// wrapper over @hanzo/gui's proven Popover with the package's raw-hex palette
|
||||
// (theme-config independent) and a scrolling content panel. Everything that pops
|
||||
// over goes through here, so menus look and behave identically everywhere.
|
||||
import type { ReactNode } from 'react'
|
||||
import { Popover, ScrollView, XStack, YStack } from '@hanzo/gui'
|
||||
import { tokens } from '../theme'
|
||||
|
||||
export interface MenuProps {
|
||||
/** The element that opens the menu (wrapped in a Popover.Trigger). */
|
||||
trigger: ReactNode
|
||||
children: ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
placement?: 'bottom-start' | 'bottom-end' | 'bottom' | 'top-start' | 'top-end'
|
||||
width?: number
|
||||
/** Max content height before the panel scrolls (default 320). */
|
||||
maxHeight?: number
|
||||
/** A fixed panel above the scrolling body (e.g. a search box). */
|
||||
header?: ReactNode
|
||||
}
|
||||
|
||||
/** A controlled popover menu with the data palette. Trigger toggles; select closes. */
|
||||
export function Menu({ trigger, children, open, onOpenChange, placement = 'bottom-start', width = 240, maxHeight = 320, header }: MenuProps) {
|
||||
return (
|
||||
<Popover placement={placement} open={open} onOpenChange={onOpenChange} allowFlip>
|
||||
<Popover.Trigger asChild>{trigger}</Popover.Trigger>
|
||||
<Popover.Content
|
||||
borderWidth={1}
|
||||
p={0}
|
||||
rounded={10}
|
||||
overflow="hidden"
|
||||
elevate
|
||||
width={width}
|
||||
style={{ backgroundColor: tokens.surfaceRaised, borderColor: tokens.border }}
|
||||
>
|
||||
{header ? (
|
||||
<YStack p={6} style={{ borderBottomWidth: 1, borderBottomColor: tokens.border }}>
|
||||
{header}
|
||||
</YStack>
|
||||
) : null}
|
||||
<ScrollView style={{ maxHeight }}>
|
||||
<YStack p={4}>{children}</YStack>
|
||||
</ScrollView>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export interface MenuItemProps {
|
||||
children: ReactNode
|
||||
onPress?: () => void
|
||||
active?: boolean
|
||||
/** A leading glyph / control. */
|
||||
icon?: ReactNode
|
||||
/** A trailing glyph (e.g. a check). */
|
||||
trailing?: ReactNode
|
||||
tone?: 'default' | 'danger'
|
||||
}
|
||||
|
||||
/** One selectable row in a Menu — hover-highlighted, with optional lead/trail slots. */
|
||||
export function MenuItem({ children, onPress, active, icon, trailing, tone = 'default' }: MenuItemProps) {
|
||||
return (
|
||||
<YStack
|
||||
onPress={onPress}
|
||||
cursor="pointer"
|
||||
px={10}
|
||||
py={7}
|
||||
rounded={6}
|
||||
hoverStyle={{ bg: tokens.hover }}
|
||||
style={active ? { backgroundColor: tokens.hover } : undefined}
|
||||
>
|
||||
<XStack items="center" gap={8}>
|
||||
{icon ? <XStack items="center" justify="center">{icon}</XStack> : null}
|
||||
<YStack flex={1}>{children}</YStack>
|
||||
{trailing ?? null}
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Toggle — a compact switch (track + sliding thumb) for boolean fields. Built from
|
||||
// primitives (no composite gui Switch API) so it type-checks everywhere and reads
|
||||
// the data palette. Press flips; `on` is green.
|
||||
import { YStack } from '@hanzo/gui'
|
||||
import { tagTone, tokens } from '../theme'
|
||||
|
||||
export interface ToggleProps {
|
||||
on?: boolean
|
||||
onChange?: (on: boolean) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function Toggle({ on, onChange, disabled }: ToggleProps) {
|
||||
const w = 34
|
||||
const h = 20
|
||||
const pad = 2
|
||||
const thumb = h - pad * 2
|
||||
return (
|
||||
<YStack
|
||||
width={w}
|
||||
height={h}
|
||||
rounded={h}
|
||||
justify="center"
|
||||
cursor={disabled ? undefined : 'pointer'}
|
||||
onPress={disabled || !onChange ? undefined : () => onChange(!on)}
|
||||
style={{
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
backgroundColor: on ? tagTone('green').border : tokens.border,
|
||||
paddingLeft: pad,
|
||||
paddingRight: pad,
|
||||
}}
|
||||
>
|
||||
<YStack
|
||||
width={thumb}
|
||||
height={thumb}
|
||||
rounded={thumb}
|
||||
style={{
|
||||
backgroundColor: '#f4f4f5',
|
||||
transform: [{ translateX: on ? w - thumb - pad * 2 : 0 }],
|
||||
}}
|
||||
/>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { Menu, MenuItem, type MenuProps, type MenuItemProps } from './Menu'
|
||||
export { CheckBox, type CheckBoxProps } from './CheckBox'
|
||||
export { Toggle, type ToggleProps } from './Toggle'
|
||||
export { Calendar, type CalendarProps } from './Calendar'
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
|
||||
// RecordDetail (view, optionally inline-editable) + RecordForm (create/edit form)
|
||||
// — the single-record views, Twenty-grade.
|
||||
//
|
||||
// RecordDetail: a titled panel of label → value fields. When `editable`, clicking
|
||||
// a value edits it in place through the field's own editor (dropdown selects on
|
||||
// change, text on Enter/blur), persisting via onEditCommit — the same inline model
|
||||
// as the table. A `related` slot hosts linked-record lists. Read-only without the
|
||||
// edit props (backward compatible).
|
||||
// RecordForm: a label → editor list for create/edit, emitting per-field changes;
|
||||
// the host owns the draft + save.
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
import type { FieldDefinition, SelectOption } from '../field/types'
|
||||
import { FieldDisplay } from '../field/FieldDisplay'
|
||||
import { FieldInput, isEditable } from '../field/FieldInput'
|
||||
import { tokens } from '../theme'
|
||||
|
||||
const INSTANT = new Set(['boolean', 'select', 'multiSelect', 'date', 'dateTime', 'relation', 'rating', 'files'])
|
||||
|
||||
const FieldLabel = ({ children }: { children: ReactNode }) => (
|
||||
<Text fontSize={11} fontWeight="700" style={{ color: tokens.textFaint, letterSpacing: 0.4, textTransform: 'uppercase' }}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
|
||||
export interface RecordDetailProps {
|
||||
fields: FieldDefinition[]
|
||||
record: Record<string, unknown>
|
||||
/** Big title above the fields (e.g. the record's name). */
|
||||
title?: string
|
||||
/** Enable click-to-edit on each value. */
|
||||
editable?: boolean
|
||||
/** Persist one field edit (async allowed). */
|
||||
onEditCommit?: (field: FieldDefinition, value: unknown) => void | Promise<void>
|
||||
/** Inject candidate options for a field's editor (e.g. relation records). */
|
||||
fieldOptions?: (field: FieldDefinition) => SelectOption[] | undefined
|
||||
/** Linked-records / activity slot, rendered below the fields. */
|
||||
related?: ReactNode
|
||||
}
|
||||
|
||||
/** One label → value row; click-to-edit when editable + the type has an input. */
|
||||
function DetailField({ field, value, editable, onCommit, options }: {
|
||||
field: FieldDefinition
|
||||
value: unknown
|
||||
editable?: boolean
|
||||
onCommit?: (value: unknown) => void | Promise<void>
|
||||
options?: SelectOption[]
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState<unknown>(value)
|
||||
const canEdit = editable && !field.readOnly && isEditable(field.type) && Boolean(onCommit)
|
||||
const withOpts: FieldDefinition = options ? { ...field, metadata: { ...(field.metadata as object), options } as FieldDefinition['metadata'] } : field
|
||||
|
||||
const commit = (v: unknown) => { setEditing(false); void onCommit?.(v) }
|
||||
|
||||
return (
|
||||
<YStack gap={5} py={8} style={{ borderBottomWidth: 1, borderBottomColor: tokens.border }}>
|
||||
<FieldLabel>{field.label}</FieldLabel>
|
||||
{editing ? (
|
||||
<FieldInput
|
||||
field={withOpts}
|
||||
value={draft}
|
||||
autoFocus
|
||||
onChange={(v) => (INSTANT.has(field.type) ? commit(v) : setDraft(v))}
|
||||
onSubmit={() => commit(draft)}
|
||||
onCancel={() => { setDraft(value); setEditing(false) }}
|
||||
/>
|
||||
) : (
|
||||
<XStack
|
||||
items="center"
|
||||
minH={26}
|
||||
cursor={canEdit ? 'text' : undefined}
|
||||
rounded={6}
|
||||
px={canEdit ? 6 : 0}
|
||||
py={canEdit ? 3 : 0}
|
||||
hoverStyle={canEdit ? { bg: tokens.hover } : undefined}
|
||||
style={{ marginLeft: canEdit ? -6 : 0 }}
|
||||
onPress={canEdit ? () => { setDraft(value); setEditing(true) } : undefined}
|
||||
>
|
||||
<FieldDisplay field={field} value={value} />
|
||||
</XStack>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** The record detail panel — titled, per-field, optionally inline-editable. */
|
||||
export function RecordDetail({ fields, record, title, editable, onEditCommit, fieldOptions, related }: RecordDetailProps) {
|
||||
return (
|
||||
<YStack gap={2}>
|
||||
{title ? (
|
||||
<YStack pb={12} mb={4} style={{ borderBottomWidth: 1, borderBottomColor: tokens.border }}>
|
||||
<Text fontSize={22} fontWeight="800" numberOfLines={2} style={{ color: tokens.text }}>{title}</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
{fields.map((f) => (
|
||||
<DetailField
|
||||
key={f.name}
|
||||
field={f}
|
||||
value={record[f.name]}
|
||||
editable={editable}
|
||||
onCommit={onEditCommit ? (v) => onEditCommit(f, v) : undefined}
|
||||
options={fieldOptions?.(f)}
|
||||
/>
|
||||
))}
|
||||
{related ? <YStack pt={16} gap={8}>{related}</YStack> : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
export interface RecordFormProps {
|
||||
fields: FieldDefinition[]
|
||||
/** Current draft values. */
|
||||
values: Record<string, unknown>
|
||||
/** Emit a single field's next value; the host owns the draft + save. */
|
||||
onChange: (name: string, value: unknown) => void
|
||||
/** Inject candidate options for a field's editor (e.g. relation records). */
|
||||
fieldOptions?: (field: FieldDefinition) => SelectOption[] | undefined
|
||||
}
|
||||
|
||||
/** Editable field list — create/edit form. Read-only fields render as display. */
|
||||
export function RecordForm({ fields, values, onChange, fieldOptions }: RecordFormProps) {
|
||||
return (
|
||||
<YStack gap={14}>
|
||||
{fields.map((f) => {
|
||||
const opts = fieldOptions?.(f)
|
||||
const withOpts: FieldDefinition = opts ? { ...f, metadata: { ...(f.metadata as object), options: opts } as FieldDefinition['metadata'] } : f
|
||||
return (
|
||||
<YStack key={f.name} gap={6}>
|
||||
<FieldLabel>{f.label}</FieldLabel>
|
||||
{f.readOnly ? (
|
||||
<XStack><FieldDisplay field={f} value={values[f.name]} /></XStack>
|
||||
) : (
|
||||
<FieldInput field={withOpts} value={values[f.name]} onChange={(v) => onChange(f.name, v)} />
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
})}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
'use client'
|
||||
|
||||
// DataTable — the Twenty-grade, metadata-driven record grid. Columns are
|
||||
// FieldDefinitions, rows are records; every cell renders through FieldDisplay /
|
||||
// FieldInput, so the grid understands every field type and a new type needs zero
|
||||
// table changes. It adds the record-app essentials on the same one model:
|
||||
// • click-to-sort headers (controlled by the view; carets show direction)
|
||||
// • drag-resize + drag-reorder columns (pointer events; snapshot rects)
|
||||
// • row selection with a header select-all (indeterminate) + bulk callback
|
||||
// • inline cell editing (click an editable cell → the field's own editor)
|
||||
// • pagination, hover-reveal "open" affordance, honest empty / loading
|
||||
// Presentational + data-injected: the host supplies records (already filtered +
|
||||
// sorted by the view) and the persistence callbacks; the table owns only its
|
||||
// interaction state. Palette is the package's raw-hex tokens (theme-independent).
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsUpDown, Maximize2 } from '@hanzogui/lucide-icons-2'
|
||||
import type { FieldDefinition, SelectOption } from '../field/types'
|
||||
import { FieldDisplay } from '../field/FieldDisplay'
|
||||
import { FieldInput, isEditable } from '../field/FieldInput'
|
||||
import { CheckBox } from '../primitives'
|
||||
import { tokens } from '../theme'
|
||||
import { cycleSort, orderedColumns, paginate, sortDirOf, type SortRule } from './logic'
|
||||
|
||||
type Record_ = Record<string, unknown>
|
||||
|
||||
/** Field types whose editor commits on selection (a dropdown / toggle), not on Enter. */
|
||||
const INSTANT = new Set(['boolean', 'select', 'multiSelect', 'date', 'dateTime', 'relation', 'rating', 'files'])
|
||||
|
||||
const SELECT_COL_W = 44
|
||||
const MIN_COL_W = 80
|
||||
const DEFAULT_ROW_H = 44
|
||||
|
||||
export interface DataTableProps {
|
||||
fields: FieldDefinition[]
|
||||
/** Rows, already filtered + sorted by the host/view (a flat name→value map each). */
|
||||
records: Record_[]
|
||||
getRowKey?: (record: Record_, index: number) => string
|
||||
loading?: boolean
|
||||
empty?: ReactNode
|
||||
|
||||
// sort — controlled by the view; a header click cycles the column
|
||||
sorts?: SortRule[]
|
||||
onSortChange?: (sorts: SortRule[]) => void
|
||||
|
||||
// open a record (an expand affordance appears on row hover)
|
||||
onOpen?: (record: Record_) => void
|
||||
|
||||
// selection
|
||||
selectable?: boolean
|
||||
onSelectionChange?: (ids: string[]) => void
|
||||
/** Bulk-action bar content, shown when ≥1 row is selected. */
|
||||
bulkActions?: (ids: string[]) => ReactNode
|
||||
|
||||
// inline editing
|
||||
editable?: boolean
|
||||
/** Persist one cell edit. May be async; the cell shows a saving state until it settles. */
|
||||
onEditCommit?: (record: Record_, field: FieldDefinition, value: unknown) => void | Promise<void>
|
||||
/** Inject candidate options for a field's editor (e.g. relation records). */
|
||||
fieldOptions?: (field: FieldDefinition) => SelectOption[] | undefined
|
||||
|
||||
// column layout (uncontrolled internal state, mirrored up when provided)
|
||||
columnOrder?: string[]
|
||||
onColumnOrderChange?: (order: string[]) => void
|
||||
hiddenFields?: string[]
|
||||
|
||||
// pagination (0 / undefined → show all)
|
||||
pageSize?: number
|
||||
rowHeight?: number
|
||||
}
|
||||
|
||||
export function DataTable(props: DataTableProps) {
|
||||
const {
|
||||
fields, records, getRowKey, loading, empty = 'No records yet.',
|
||||
sorts = [], onSortChange, onOpen,
|
||||
selectable, onSelectionChange, bulkActions,
|
||||
editable, onEditCommit, fieldOptions,
|
||||
columnOrder, onColumnOrderChange, hiddenFields,
|
||||
pageSize = 25, rowHeight = DEFAULT_ROW_H,
|
||||
} = props
|
||||
|
||||
const hidden = useMemo(() => new Set(hiddenFields ?? []), [hiddenFields])
|
||||
const [order, setOrder] = useState<string[] | undefined>(columnOrder)
|
||||
useEffect(() => { if (columnOrder) setOrder(columnOrder) }, [columnOrder])
|
||||
const columns = useMemo(() => orderedColumns(fields, order, hidden), [fields, order, hidden])
|
||||
|
||||
const [widths, setWidths] = useState<Record<string, number>>({})
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [editing, setEditing] = useState<{ key: string; field: string } | null>(null)
|
||||
const [draft, setDraft] = useState<unknown>(undefined)
|
||||
const [savingCell, setSavingCell] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const keyOf = useCallback(
|
||||
(r: Record_, i: number) => (getRowKey ? getRowKey(r, i) : r.id != null ? String(r.id) : String(i)),
|
||||
[getRowKey],
|
||||
)
|
||||
|
||||
const pageData = useMemo(
|
||||
() => (pageSize > 0 ? paginate(records, page, pageSize) : { items: records, page: 1, pageCount: 1, total: records.length, from: records.length ? 1 : 0, to: records.length }),
|
||||
[records, page, pageSize],
|
||||
)
|
||||
useEffect(() => { if (page > pageData.pageCount) setPage(pageData.pageCount) }, [page, pageData.pageCount])
|
||||
|
||||
const emit = useCallback((next: Set<string>) => { setSelected(next); onSelectionChange?.([...next]) }, [onSelectionChange])
|
||||
const pageKeys = pageData.items.map((r, i) => keyOf(r, i))
|
||||
const allOnPage = pageKeys.length > 0 && pageKeys.every((k) => selected.has(k))
|
||||
const someOnPage = pageKeys.some((k) => selected.has(k))
|
||||
const toggleAll = () => {
|
||||
const next = new Set(selected)
|
||||
if (allOnPage) pageKeys.forEach((k) => next.delete(k))
|
||||
else pageKeys.forEach((k) => next.add(k))
|
||||
emit(next)
|
||||
}
|
||||
const toggleOne = (k: string) => {
|
||||
const next = new Set(selected)
|
||||
next.has(k) ? next.delete(k) : next.add(k)
|
||||
emit(next)
|
||||
}
|
||||
|
||||
const onHeaderSort = (name: string) => onSortChange?.(cycleSort(sorts, name))
|
||||
|
||||
// ── column resize + reorder (pointer events; snapshot rects at drag start) ──
|
||||
const headerRefs = useRef<Array<HTMLElement | null>>([])
|
||||
const [dragCol, setDragCol] = useState<{ from: number; x: number; target: number } | null>(null)
|
||||
|
||||
const beginResize = (name: string, startNode: HTMLElement | null) => (e: { clientX?: number; nativeEvent?: { clientX?: number } }) => {
|
||||
const startX = e.clientX ?? e.nativeEvent?.clientX ?? 0
|
||||
const startW = startNode?.getBoundingClientRect().width ?? MIN_COL_W
|
||||
const move = (ev: PointerEvent) => setWidths((w) => ({ ...w, [name]: Math.max(MIN_COL_W, startW + (ev.clientX - startX)) }))
|
||||
const end = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', end)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', end)
|
||||
}
|
||||
|
||||
const beginReorder = (from: number) => (e: { clientX?: number; nativeEvent?: { clientX?: number } }) => {
|
||||
if (!onColumnOrderChange && columnOrder) return
|
||||
const rects = headerRefs.current.map((n) => n?.getBoundingClientRect() ?? null)
|
||||
const startX = e.clientX ?? e.nativeEvent?.clientX ?? 0
|
||||
setDragCol({ from, x: startX, target: from })
|
||||
const move = (ev: PointerEvent) => {
|
||||
let target = rects.length - 1
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
const r = rects[i]
|
||||
if (r && ev.clientX < r.left + r.width / 2) { target = i; break }
|
||||
}
|
||||
setDragCol({ from, x: ev.clientX, target })
|
||||
}
|
||||
const end = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', end)
|
||||
setDragCol((d) => {
|
||||
if (d && d.target !== d.from) {
|
||||
const names = columns.map((c) => c.name)
|
||||
const [moved] = names.splice(d.from, 1)
|
||||
names.splice(d.target, 0, moved)
|
||||
setOrder(names)
|
||||
onColumnOrderChange?.(names)
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', end)
|
||||
}
|
||||
|
||||
// ── inline edit ─────────────────────────────────────────────────────────────
|
||||
const startEdit = (rowKey: string, field: FieldDefinition, current: unknown) => {
|
||||
if (!editable || field.readOnly || !isEditable(field.type)) return
|
||||
setEditing({ key: rowKey, field: field.name })
|
||||
setDraft(current)
|
||||
}
|
||||
const cancelEdit = () => { setEditing(null); setDraft(undefined) }
|
||||
const commitEdit = useCallback(async (record: Record_, field: FieldDefinition, value: unknown) => {
|
||||
const cellId = `${editing?.key}:${field.name}`
|
||||
setEditing(null)
|
||||
setDraft(undefined)
|
||||
if (!onEditCommit) return
|
||||
try { setSavingCell(cellId); await onEditCommit(record, field, value) } finally { setSavingCell(null) }
|
||||
}, [editing, onEditCommit])
|
||||
|
||||
// Read-only rows open on a full-row press; editable rows open via the expand
|
||||
// affordance (so a cell click edits instead of opening — no interaction conflict).
|
||||
const rowPressable = !editable && Boolean(onOpen)
|
||||
const gridMinWidth = (selectable ? SELECT_COL_W : 0) + columns.reduce((s, c) => s + (widths[c.name] ?? c.width ?? 160), 0)
|
||||
|
||||
const cellWidthProps = (c: FieldDefinition) => {
|
||||
const w = widths[c.name] ?? c.width
|
||||
return w ? { width: w } : { flex: 1, minW: 140 }
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack rounded={10} overflow="hidden" borderWidth={1} style={{ borderColor: tokens.border, backgroundColor: tokens.surface }}>
|
||||
{selectable && selected.size > 0 ? (
|
||||
<XStack items="center" justify="space-between" px={12} py={8} style={{ backgroundColor: tokens.surfaceRaised, borderBottomWidth: 1, borderBottomColor: tokens.border }}>
|
||||
<Text fontSize={13} fontWeight="600" style={{ color: tokens.text }}>{selected.size} selected</Text>
|
||||
<XStack items="center" gap={8}>
|
||||
{bulkActions?.([...selected])}
|
||||
<Text fontSize={13} cursor="pointer" onPress={() => emit(new Set())} style={{ color: tokens.accent }}>Clear</Text>
|
||||
</XStack>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
<YStack style={{ minWidth: gridMinWidth }}>
|
||||
{/* header */}
|
||||
<XStack style={{ backgroundColor: tokens.surfaceRaised, borderBottomWidth: 1, borderBottomColor: tokens.border }}>
|
||||
{selectable ? (
|
||||
<XStack width={SELECT_COL_W} items="center" justify="center" py={10}>
|
||||
<CheckBox checked={allOnPage} indeterminate={!allOnPage && someOnPage} onChange={toggleAll} />
|
||||
</XStack>
|
||||
) : null}
|
||||
{columns.map((c, i) => {
|
||||
const dir = sortDirOf(sorts, c.name)
|
||||
const isDropTarget = dragCol && dragCol.target === i && dragCol.from !== i
|
||||
return (
|
||||
<XStack
|
||||
key={c.name}
|
||||
{...cellWidthProps(c)}
|
||||
items="center"
|
||||
gap={4}
|
||||
px={12}
|
||||
py={10}
|
||||
ref={(node: unknown) => { headerRefs.current[i] = (node as HTMLElement | null) }}
|
||||
style={{ position: 'relative', borderLeftWidth: isDropTarget ? 2 : 0, borderLeftColor: tokens.accent, opacity: dragCol?.from === i ? 0.5 : 1 }}
|
||||
>
|
||||
<XStack items="center" gap={5} flex={1} cursor="pointer" onPress={() => onHeaderSort(c.name)} onPointerDown={beginReorder(i)}>
|
||||
<Text fontSize={11} fontWeight="700" numberOfLines={1} style={{ color: tokens.textFaint, letterSpacing: 0.4, textTransform: 'uppercase' }}>
|
||||
{c.label}
|
||||
</Text>
|
||||
{dir === 'asc' ? <ArrowUp size={12} color={tokens.textMuted} /> : dir === 'desc' ? <ArrowDown size={12} color={tokens.textMuted} /> : onSortChange ? <ChevronsUpDown size={12} color={tokens.border} /> : null}
|
||||
</XStack>
|
||||
{/* resize handle */}
|
||||
<YStack
|
||||
width={8}
|
||||
height="100%"
|
||||
cursor="col-resize"
|
||||
onPointerDown={beginResize(c.name, headerRefs.current[i])}
|
||||
style={{ position: 'absolute', right: -4, top: 0, zIndex: 2 }}
|
||||
/>
|
||||
</XStack>
|
||||
)
|
||||
})}
|
||||
</XStack>
|
||||
|
||||
{/* body */}
|
||||
{loading ? (
|
||||
<XStack px={16} py={32} items="center" justify="center" gap={10}>
|
||||
<Spinner size="small" color={tokens.textMuted} />
|
||||
<Text style={{ color: tokens.textFaint }}>Loading…</Text>
|
||||
</XStack>
|
||||
) : pageData.items.length === 0 ? (
|
||||
<YStack px={16} py={40} items="center" gap={4}>
|
||||
{typeof empty === 'string' ? <Text style={{ color: tokens.textFaint }}>{empty}</Text> : empty}
|
||||
</YStack>
|
||||
) : (
|
||||
pageData.items.map((record, i) => {
|
||||
const rowKey = keyOf(record, i)
|
||||
const isSel = selected.has(rowKey)
|
||||
return (
|
||||
<XStack
|
||||
key={rowKey}
|
||||
group
|
||||
items="stretch"
|
||||
cursor={rowPressable ? 'pointer' : undefined}
|
||||
onPress={rowPressable ? () => onOpen?.(record) : undefined}
|
||||
hoverStyle={{ bg: tokens.hover }}
|
||||
style={{ borderBottomWidth: 1, borderBottomColor: tokens.border, backgroundColor: isSel ? tokens.hover : 'transparent', minHeight: rowHeight }}
|
||||
>
|
||||
{selectable ? (
|
||||
<XStack width={SELECT_COL_W} items="center" justify="center">
|
||||
<CheckBox checked={isSel} onChange={() => toggleOne(rowKey)} />
|
||||
</XStack>
|
||||
) : null}
|
||||
{columns.map((c, ci) => {
|
||||
const isEditingCell = editable && editing?.key === rowKey && editing.field === c.name
|
||||
const canEdit = editable && !c.readOnly && isEditable(c.type)
|
||||
const cellId = `${rowKey}:${c.name}`
|
||||
const withOpts: FieldDefinition = fieldOptions?.(c)
|
||||
? { ...c, metadata: { ...(c.metadata as object), options: fieldOptions(c) } as FieldDefinition['metadata'] }
|
||||
: c
|
||||
return (
|
||||
<XStack key={c.name} {...cellWidthProps(c)} items="center" px={12} py={6} style={{ position: 'relative' }}>
|
||||
{isEditingCell ? (
|
||||
<YStack flex={1} onPress={(e: { stopPropagation?: () => void }) => e.stopPropagation?.()}>
|
||||
<FieldInput
|
||||
field={withOpts}
|
||||
value={draft}
|
||||
autoFocus
|
||||
onChange={(v) => (INSTANT.has(c.type) ? void commitEdit(record, c, v) : setDraft(v))}
|
||||
onSubmit={() => void commitEdit(record, c, draft)}
|
||||
onCancel={cancelEdit}
|
||||
/>
|
||||
</YStack>
|
||||
) : (
|
||||
<XStack
|
||||
flex={1}
|
||||
items="center"
|
||||
minH={rowHeight - 12}
|
||||
cursor={canEdit ? 'text' : undefined}
|
||||
onPress={canEdit ? () => startEdit(rowKey, c, record[c.name]) : undefined}
|
||||
hoverStyle={canEdit ? { bg: tokens.surfaceRaised } : undefined}
|
||||
rounded={6}
|
||||
px={canEdit ? 6 : 0}
|
||||
style={{ marginLeft: canEdit ? -6 : 0 }}
|
||||
>
|
||||
<FieldDisplay field={c} value={record[c.name]} />
|
||||
{savingCell === cellId ? <Spinner size="small" color={tokens.textFaint} /> : null}
|
||||
</XStack>
|
||||
)}
|
||||
{/* open affordance on the first column (hover-revealed) — editable rows only; read-only rows open on full-row press */}
|
||||
{ci === 0 && onOpen && editable && !isEditingCell ? (
|
||||
<XStack
|
||||
$group-hover={{ opacity: 1 }}
|
||||
items="center"
|
||||
justify="center"
|
||||
width={26}
|
||||
height={26}
|
||||
rounded={6}
|
||||
cursor="pointer"
|
||||
onPress={() => onOpen(record)}
|
||||
hoverStyle={{ bg: tokens.border }}
|
||||
style={{ position: 'absolute', right: 6, opacity: 0, backgroundColor: tokens.surfaceRaised }}
|
||||
>
|
||||
<Maximize2 size={13} color={tokens.textMuted} />
|
||||
</XStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
)
|
||||
})}
|
||||
</XStack>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
{/* footer / pagination */}
|
||||
{!loading && pageSize > 0 && pageData.total > 0 ? (
|
||||
<XStack items="center" justify="space-between" px={12} py={8} style={{ backgroundColor: tokens.surfaceRaised, borderTopWidth: 1, borderTopColor: tokens.border }}>
|
||||
<Text fontSize={12} style={{ color: tokens.textFaint }}>{`${pageData.from}–${pageData.to} of ${pageData.total}`}</Text>
|
||||
<XStack items="center" gap={4}>
|
||||
<PagerButton disabled={pageData.page <= 1} onPress={() => setPage((p) => Math.max(1, p - 1))}><ChevronLeft size={15} color={pageData.page <= 1 ? tokens.border : tokens.textMuted} /></PagerButton>
|
||||
<Text fontSize={12} px={6} style={{ color: tokens.textMuted }}>{`${pageData.page} / ${pageData.pageCount}`}</Text>
|
||||
<PagerButton disabled={pageData.page >= pageData.pageCount} onPress={() => setPage((p) => Math.min(pageData.pageCount, p + 1))}><ChevronRight size={15} color={pageData.page >= pageData.pageCount ? tokens.border : tokens.textMuted} /></PagerButton>
|
||||
</XStack>
|
||||
</XStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
function PagerButton({ children, disabled, onPress }: { children: ReactNode; disabled?: boolean; onPress: () => void }) {
|
||||
return (
|
||||
<XStack
|
||||
width={28}
|
||||
height={28}
|
||||
items="center"
|
||||
justify="center"
|
||||
rounded={6}
|
||||
cursor={disabled ? undefined : 'pointer'}
|
||||
onPress={disabled ? undefined : onPress}
|
||||
hoverStyle={disabled ? undefined : { bg: tokens.hover }}
|
||||
>
|
||||
{children}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// RecordCard — the compact card used by board (kanban) and gallery views. Shows
|
||||
// a title field bold, then a few field displays. Same FieldDisplay engine as the
|
||||
// table, so it understands every type. Drag/drop + column grouping live in the
|
||||
// board host; this is the standalone card unit.
|
||||
import { Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import type { FieldDefinition } from '../field/types'
|
||||
import { FieldDisplay } from '../field/FieldDisplay'
|
||||
import { tokens } from '../theme'
|
||||
|
||||
export interface RecordCardProps {
|
||||
/** The field whose value is the card title (defaults to the first field). */
|
||||
titleField?: string
|
||||
/** Fields shown in the card body (the title field is excluded automatically). */
|
||||
fields: FieldDefinition[]
|
||||
record: Record<string, unknown>
|
||||
onPress?: (record: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
export function RecordCard({ titleField, fields, record, onPress }: RecordCardProps) {
|
||||
const titleName = titleField ?? fields[0]?.name
|
||||
const titleVal = titleName != null ? record[titleName] : undefined
|
||||
const bodyFields = fields.filter((f) => f.name !== titleName)
|
||||
|
||||
return (
|
||||
<Card
|
||||
p={12}
|
||||
gap={8}
|
||||
borderWidth={1}
|
||||
rounded={8}
|
||||
cursor={onPress ? 'pointer' : undefined}
|
||||
hoverStyle={onPress ? { bg: tokens.hover } : undefined}
|
||||
onPress={onPress ? () => onPress(record) : undefined}
|
||||
style={{ backgroundColor: tokens.surface, borderColor: tokens.border }}
|
||||
>
|
||||
<Text fontSize={14} fontWeight="700" numberOfLines={1} style={{ color: tokens.text }}>
|
||||
{titleVal == null || titleVal === '' ? 'Untitled' : String(titleVal)}
|
||||
</Text>
|
||||
{bodyFields.map((f) => (
|
||||
<XStack key={f.name} gap={8} items="center">
|
||||
<Text fontSize={11} numberOfLines={1} width={84} style={{ color: tokens.textFaint }}>
|
||||
{f.label}
|
||||
</Text>
|
||||
<YStack flex={1}>
|
||||
<FieldDisplay field={f} value={record[f.name]} />
|
||||
</YStack>
|
||||
</XStack>
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { FieldDefinition } from '../field/types'
|
||||
import {
|
||||
comparable,
|
||||
sortRecords,
|
||||
cycleSort,
|
||||
sortDirOf,
|
||||
operatorsForType,
|
||||
matchesRule,
|
||||
filterRecords,
|
||||
searchRecords,
|
||||
paginate,
|
||||
moveItem,
|
||||
orderedColumns,
|
||||
columnIndexAtX,
|
||||
} from './logic'
|
||||
|
||||
const fields: FieldDefinition[] = [
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
{ name: 'employees', label: 'Employees', type: 'number' },
|
||||
{ name: 'stage', label: 'Stage', type: 'select', metadata: { options: [
|
||||
{ value: 'NEW', label: 'New', color: 'blue' },
|
||||
{ value: 'WON', label: 'Won', color: 'green' },
|
||||
] } },
|
||||
{ name: 'active', label: 'Active', type: 'boolean' },
|
||||
{ name: 'arr', label: 'ARR', type: 'currency' },
|
||||
{ name: 'signed', label: 'Signed', type: 'date' },
|
||||
]
|
||||
|
||||
const rows: Record<string, unknown>[] = [
|
||||
{ id: '1', name: 'Beta', employees: 10, stage: 'NEW', active: true, arr: { amount: 500, currencyCode: 'USD' }, signed: '2024-02-01' },
|
||||
{ id: '2', name: 'alpha', employees: 3, stage: 'WON', active: false, arr: { amount: 1500, currencyCode: 'USD' }, signed: '2024-01-01' },
|
||||
{ id: '3', name: 'Gamma', employees: null, stage: '', active: true, arr: { amount: null, currencyCode: 'USD' }, signed: '' },
|
||||
]
|
||||
|
||||
describe('comparable', () => {
|
||||
it('coerces per field type', () => {
|
||||
expect(comparable(fields[1], 10)).toBe(10)
|
||||
expect(comparable(fields[4], { amount: 500, currencyCode: 'USD' })).toBe(500) // currency
|
||||
expect(comparable(fields[2], 'WON')).toBe('won') // select → label lowercased
|
||||
expect(comparable(fields[4], { amount: null })).toBeNull()
|
||||
expect(comparable(fields[0], '')).toBeNull()
|
||||
expect(comparable(fields[0], 'Hi')).toBe('hi')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortRecords', () => {
|
||||
it('sorts text case-insensitively, ascending', () => {
|
||||
const out = sortRecords(rows, [{ field: 'name', dir: 'asc' }], fields)
|
||||
expect(out.map((r) => r.name)).toEqual(['alpha', 'Beta', 'Gamma'])
|
||||
})
|
||||
it('sorts numbers descending with null last', () => {
|
||||
const out = sortRecords(rows, [{ field: 'employees', dir: 'desc' }], fields)
|
||||
expect(out.map((r) => r.id)).toEqual(['1', '2', '3']) // 10, 3, null
|
||||
})
|
||||
it('is stable and does not mutate input', () => {
|
||||
const before = rows.map((r) => r.id)
|
||||
const out = sortRecords(rows, [{ field: 'active', dir: 'desc' }], fields)
|
||||
expect(rows.map((r) => r.id)).toEqual(before)
|
||||
// both active=true rows keep their relative order (1 before 3)
|
||||
expect(out.filter((r) => r.active).map((r) => r.id)).toEqual(['1', '3'])
|
||||
})
|
||||
it('returns a copy when no sort rules', () => {
|
||||
const out = sortRecords(rows, [], fields)
|
||||
expect(out).not.toBe(rows)
|
||||
expect(out.map((r) => r.id)).toEqual(['1', '2', '3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('cycleSort / sortDirOf', () => {
|
||||
it('cycles unsorted → asc → desc → unsorted', () => {
|
||||
let s = cycleSort([], 'name')
|
||||
expect(s).toEqual([{ field: 'name', dir: 'asc' }])
|
||||
expect(sortDirOf(s, 'name')).toBe('asc')
|
||||
s = cycleSort(s, 'name')
|
||||
expect(s).toEqual([{ field: 'name', dir: 'desc' }])
|
||||
s = cycleSort(s, 'name')
|
||||
expect(s).toEqual([])
|
||||
})
|
||||
it('switching column starts a fresh asc', () => {
|
||||
const s = cycleSort([{ field: 'name', dir: 'desc' }], 'employees')
|
||||
expect(s).toEqual([{ field: 'employees', dir: 'asc' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('operatorsForType', () => {
|
||||
it('offers numeric operators for numbers/dates', () => {
|
||||
expect(operatorsForType('number')).toContain('gte')
|
||||
expect(operatorsForType('date')).toContain('lt')
|
||||
})
|
||||
it('offers set operators for selects', () => {
|
||||
expect(operatorsForType('select')).toContain('isAnyOf')
|
||||
})
|
||||
it('offers text operators for text', () => {
|
||||
expect(operatorsForType('text')).toContain('contains')
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchesRule / filterRecords', () => {
|
||||
it('text contains (case-insensitive)', () => {
|
||||
const out = filterRecords(rows, [{ field: 'name', op: 'contains', value: 'A' }], fields)
|
||||
expect(out.map((r) => r.id).sort()).toEqual(['1', '2', '3']) // Beta, alpha, Gamma all contain "a"
|
||||
expect(filterRecords(rows, [{ field: 'name', op: 'contains', value: 'ph' }], fields).map((r) => r.id)).toEqual(['2']) // alpha only
|
||||
})
|
||||
it('number gte', () => {
|
||||
const out = filterRecords(rows, [{ field: 'employees', op: 'gte', value: '10' }], fields)
|
||||
expect(out.map((r) => r.id)).toEqual(['1'])
|
||||
})
|
||||
it('select isAnyOf', () => {
|
||||
const out = filterRecords(rows, [{ field: 'stage', op: 'isAnyOf', value: ['WON'] }], fields)
|
||||
expect(out.map((r) => r.id)).toEqual(['2'])
|
||||
})
|
||||
it('isEmpty / isNotEmpty', () => {
|
||||
expect(filterRecords(rows, [{ field: 'employees', op: 'isEmpty' }], fields).map((r) => r.id)).toEqual(['3'])
|
||||
expect(filterRecords(rows, [{ field: 'stage', op: 'isNotEmpty' }], fields).map((r) => r.id).sort()).toEqual(['1', '2'])
|
||||
})
|
||||
it('boolean is', () => {
|
||||
expect(filterRecords(rows, [{ field: 'active', op: 'is', value: 'true' }], fields).map((r) => r.id).sort()).toEqual(['1', '3'])
|
||||
})
|
||||
it('date lt/gte compares ISO operands by time', () => {
|
||||
expect(filterRecords(rows, [{ field: 'signed', op: 'lt', value: '2024-01-15' }], fields).map((r) => r.id)).toEqual(['2']) // 2024-01-01
|
||||
expect(filterRecords(rows, [{ field: 'signed', op: 'gte', value: '2024-01-15' }], fields).map((r) => r.id)).toEqual(['1']) // 2024-02-01 (row 3 empty → excluded)
|
||||
})
|
||||
it('AND-combines rules; empty rules keep all', () => {
|
||||
const out = filterRecords(rows, [
|
||||
{ field: 'active', op: 'is', value: 'true' },
|
||||
{ field: 'name', op: 'contains', value: 'g' },
|
||||
], fields)
|
||||
expect(out.map((r) => r.id)).toEqual(['3'])
|
||||
expect(filterRecords(rows, [], fields)).toHaveLength(3)
|
||||
})
|
||||
it('ignores a rule whose value is not yet set', () => {
|
||||
expect(filterRecords(rows, [{ field: 'name', op: 'contains' }], fields)).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchRecords', () => {
|
||||
it('matches across fields, case-insensitive', () => {
|
||||
expect(searchRecords(rows, 'won', fields).map((r) => r.id)).toEqual(['2'])
|
||||
expect(searchRecords(rows, 'amma', fields).map((r) => r.id)).toEqual(['3'])
|
||||
expect(searchRecords(rows, '', fields)).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('paginate', () => {
|
||||
it('slices and clamps the page', () => {
|
||||
const p = paginate([1, 2, 3, 4, 5], 1, 2)
|
||||
expect(p.items).toEqual([1, 2])
|
||||
expect(p).toMatchObject({ page: 1, pageCount: 3, total: 5, from: 1, to: 2 })
|
||||
expect(paginate([1, 2, 3, 4, 5], 99, 2).items).toEqual([5])
|
||||
expect(paginate([], 1, 10)).toMatchObject({ from: 0, to: 0, pageCount: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('moveItem / orderedColumns', () => {
|
||||
it('moves an element and returns a new array', () => {
|
||||
expect(moveItem(['a', 'b', 'c'], 0, 2)).toEqual(['b', 'c', 'a'])
|
||||
expect(moveItem(['a', 'b', 'c'], 2, 0)).toEqual(['c', 'a', 'b'])
|
||||
})
|
||||
it('orders columns by order, appends missing, drops hidden', () => {
|
||||
const cols = orderedColumns(fields, ['stage', 'name'], new Set(['arr', 'signed']))
|
||||
expect(cols.map((f) => f.name)).toEqual(['stage', 'name', 'employees', 'active'])
|
||||
})
|
||||
it('columnIndexAtX finds the column under a pixel offset', () => {
|
||||
const w = [40, 200, 120, 120] // selection, name, employees, stage
|
||||
expect(columnIndexAtX(w, 10)).toBe(0)
|
||||
expect(columnIndexAtX(w, 100)).toBe(1)
|
||||
expect(columnIndexAtX(w, 250)).toBe(2)
|
||||
expect(columnIndexAtX(w, 9999)).toBe(3) // past the end clamps to last
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,384 @@
|
||||
// Pure table logic — the decisions the record grid takes, isolated from React +
|
||||
// @hanzo/gui so they are trivially testable (data in, data out) and reusable by a
|
||||
// host doing server-side sort/filter. Imports only `type` from the field model,
|
||||
// so this module pulls in no runtime and runs in plain Node.
|
||||
//
|
||||
// One vocabulary for sort, filter, search, and pagination powers the table, the
|
||||
// board, and any saved view — decomplected from rendering. A field type maps to a
|
||||
// comparable primitive HERE, once, so every view orders and filters identically.
|
||||
import type { FieldDefinition, FieldType, SelectOption, CurrencyValue, LinkValue } from '../field/types'
|
||||
|
||||
export type Record_ = Record<string, unknown>
|
||||
|
||||
// ── comparable coercion (one place; mirrors the display coercions) ───────────
|
||||
const asStr = (v: unknown): string => (v == null ? '' : String(v))
|
||||
const asNum = (v: unknown): number | null => {
|
||||
if (v == null || v === '') return null
|
||||
const n = typeof v === 'number' ? v : Number(v)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
const asArray = (v: unknown): unknown[] => (Array.isArray(v) ? v : v == null ? [] : [v])
|
||||
const asTime = (v: unknown): number | null => {
|
||||
if (v == null || v === '') return null
|
||||
const d = v instanceof Date ? v : new Date(v as string | number)
|
||||
const t = d.getTime()
|
||||
return Number.isNaN(t) ? null : t
|
||||
}
|
||||
|
||||
const optionLabel = (field: FieldDefinition | undefined, value: string): string => {
|
||||
const options = (field?.metadata as { options?: SelectOption[] } | undefined)?.options
|
||||
return options?.find((o) => o.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
/** The record's relation/label text — the same resolution the display uses. */
|
||||
export function relationLabel(field: FieldDefinition | undefined, value: unknown): string {
|
||||
const labelField = (field?.metadata as { labelField?: string } | undefined)?.labelField ?? 'name'
|
||||
if (typeof value === 'string') return value
|
||||
if (value && typeof value === 'object') {
|
||||
const rec = value as Record<string, unknown>
|
||||
return asStr(rec[labelField] ?? rec.label ?? rec.name ?? rec.id)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* A field value reduced to a comparable primitive (number | string | null) for
|
||||
* sorting and relational filters. Numeric types compare numerically; dates by
|
||||
* timestamp; selects by their human label; multi-value types by their joined
|
||||
* labels; everything else case-folded text. `null` sorts last.
|
||||
*/
|
||||
export function comparable(field: FieldDefinition | undefined, value: unknown): number | string | null {
|
||||
const type: FieldType | undefined = field?.type
|
||||
switch (type) {
|
||||
case 'number':
|
||||
case 'percent':
|
||||
case 'rating':
|
||||
case 'position':
|
||||
return asNum(value)
|
||||
case 'currency': {
|
||||
const raw = (value ?? {}) as Partial<CurrencyValue>
|
||||
return typeof raw === 'object' && raw != null ? asNum(raw.amount) : asNum(value)
|
||||
}
|
||||
case 'boolean':
|
||||
return value === true || value === 'true' || value === 1 ? 1 : 0
|
||||
case 'date':
|
||||
case 'dateTime':
|
||||
return asTime(value)
|
||||
case 'select':
|
||||
return value == null || value === '' ? null : optionLabel(field, asStr(value)).toLowerCase()
|
||||
case 'multiSelect':
|
||||
return asArray(value).map((v) => optionLabel(field, asStr(v))).join(', ').toLowerCase() || null
|
||||
case 'relation':
|
||||
return relationLabel(field, value).toLowerCase() || null
|
||||
case 'links':
|
||||
return (
|
||||
asArray(value)
|
||||
.map((l) => (typeof l === 'string' ? l : (l as LinkValue)?.url ?? ''))
|
||||
.join(', ')
|
||||
.toLowerCase() || null
|
||||
)
|
||||
default: {
|
||||
const s = asStr(value)
|
||||
return s === '' ? null : s.toLowerCase()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── sort ──────────────────────────────────────────────────────────────────────
|
||||
export type SortDir = 'asc' | 'desc'
|
||||
export interface SortRule { field: string; dir: SortDir }
|
||||
|
||||
const fieldByName = (fields: FieldDefinition[], name: string): FieldDefinition | undefined =>
|
||||
fields.find((f) => f.name === name)
|
||||
|
||||
/** Compare two NON-null comparable primitives (numbers numerically, else by locale). */
|
||||
function cmpNonNull(a: number | string, b: number | string): number {
|
||||
if (typeof a === 'number' && typeof b === 'number') return a - b
|
||||
return String(a).localeCompare(String(b))
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable multi-key sort. Records are ordered by each rule in turn; equal records
|
||||
* keep their input order (stable). Empty values always sort LAST regardless of
|
||||
* direction (a descending sort doesn't float blanks to the top). Returns a new
|
||||
* array; the input is untouched.
|
||||
*/
|
||||
export function sortRecords(records: Record_[], sorts: SortRule[], fields: FieldDefinition[]): Record_[] {
|
||||
if (sorts.length === 0) return records.slice()
|
||||
return records
|
||||
.map((r, i) => [r, i] as const)
|
||||
.sort(([a, ia], [b, ib]) => {
|
||||
for (const s of sorts) {
|
||||
const f = fieldByName(fields, s.field)
|
||||
const av = comparable(f, a[s.field])
|
||||
const bv = comparable(f, b[s.field])
|
||||
if (av === null && bv === null) continue
|
||||
if (av === null) return 1 // a is empty → last
|
||||
if (bv === null) return -1 // b is empty → last
|
||||
const d = cmpNonNull(av, bv)
|
||||
if (d !== 0) return s.dir === 'asc' ? d : -d
|
||||
}
|
||||
return ia - ib
|
||||
})
|
||||
.map(([r]) => r)
|
||||
}
|
||||
|
||||
/**
|
||||
* Header-click sort cycle for a single column: unsorted → asc → desc → unsorted.
|
||||
* Sorting a column replaces the sort set (single-column sort is the common case;
|
||||
* a host can compose multi-sort with `sortRecords` directly).
|
||||
*/
|
||||
export function cycleSort(sorts: SortRule[], field: string): SortRule[] {
|
||||
const current = sorts.length === 1 && sorts[0].field === field ? sorts[0] : undefined
|
||||
if (!current) return [{ field, dir: 'asc' }]
|
||||
if (current.dir === 'asc') return [{ field, dir: 'desc' }]
|
||||
return []
|
||||
}
|
||||
|
||||
/** The current sort direction for a column, or undefined when it isn't sorted. */
|
||||
export function sortDirOf(sorts: SortRule[], field: string): SortDir | undefined {
|
||||
return sorts.find((s) => s.field === field)?.dir
|
||||
}
|
||||
|
||||
// ── filter ──────────────────────────────────────────────────────────────────
|
||||
export type FilterOp =
|
||||
| 'contains'
|
||||
| 'notContains'
|
||||
| 'is'
|
||||
| 'isNot'
|
||||
| 'gt'
|
||||
| 'gte'
|
||||
| 'lt'
|
||||
| 'lte'
|
||||
| 'isEmpty'
|
||||
| 'isNotEmpty'
|
||||
| 'isAnyOf'
|
||||
|
||||
export interface FilterRule {
|
||||
field: string
|
||||
op: FilterOp
|
||||
/** Operand — a string for text/number ops, a string[] for `isAnyOf`. */
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
/** Human labels for the operators — for the filter UI menu. */
|
||||
export const OP_LABELS: Record<FilterOp, string> = {
|
||||
contains: 'contains',
|
||||
notContains: 'does not contain',
|
||||
is: 'is',
|
||||
isNot: 'is not',
|
||||
gt: 'is greater than',
|
||||
gte: 'is at least',
|
||||
lt: 'is less than',
|
||||
lte: 'is at most',
|
||||
isEmpty: 'is empty',
|
||||
isNotEmpty: 'is not empty',
|
||||
isAnyOf: 'is any of',
|
||||
}
|
||||
|
||||
const TEXT_OPS: FilterOp[] = ['contains', 'notContains', 'is', 'isNot', 'isEmpty', 'isNotEmpty']
|
||||
const NUM_OPS: FilterOp[] = ['is', 'isNot', 'gt', 'gte', 'lt', 'lte', 'isEmpty', 'isNotEmpty']
|
||||
const SELECT_OPS: FilterOp[] = ['is', 'isNot', 'isAnyOf', 'isEmpty', 'isNotEmpty']
|
||||
const BOOL_OPS: FilterOp[] = ['is']
|
||||
|
||||
/** The operators that make sense for a field type — drives the filter menu. */
|
||||
export function operatorsForType(type: FieldType): FilterOp[] {
|
||||
switch (type) {
|
||||
case 'number':
|
||||
case 'percent':
|
||||
case 'currency':
|
||||
case 'rating':
|
||||
case 'date':
|
||||
case 'dateTime':
|
||||
return NUM_OPS
|
||||
case 'select':
|
||||
case 'multiSelect':
|
||||
case 'relation':
|
||||
return SELECT_OPS
|
||||
case 'boolean':
|
||||
return BOOL_OPS
|
||||
default:
|
||||
return TEXT_OPS
|
||||
}
|
||||
}
|
||||
|
||||
const isEmptyValue = (field: FieldDefinition | undefined, value: unknown): boolean => {
|
||||
if (value == null || value === '') return true
|
||||
if (Array.isArray(value)) return value.length === 0
|
||||
if (field?.type === 'currency') return asNum((value as Partial<CurrencyValue>)?.amount) == null
|
||||
return false
|
||||
}
|
||||
|
||||
const numericType = (type: FieldType | undefined): boolean =>
|
||||
type === 'number' || type === 'percent' || type === 'currency' || type === 'rating' || type === 'date' || type === 'dateTime'
|
||||
|
||||
/** The record's raw stored keys for a set-like field (option values / relation ids). */
|
||||
function rawKeys(field: FieldDefinition | undefined, value: unknown): string[] {
|
||||
if (field?.type === 'multiSelect') return asArray(value).map(asStr).filter((s) => s !== '')
|
||||
if (field?.type === 'relation') {
|
||||
const k = relationKey(value)
|
||||
return k ? [k] : []
|
||||
}
|
||||
const s = asStr(value)
|
||||
return s === '' ? [] : [s]
|
||||
}
|
||||
|
||||
const relationKey = (value: unknown): string => {
|
||||
if (typeof value === 'string') return value
|
||||
if (value && typeof value === 'object') {
|
||||
const rec = value as Record<string, unknown>
|
||||
return asStr(rec.id ?? rec.value ?? '')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* True when one record's value satisfies one filter rule. Filters compare RAW
|
||||
* stored values (a select's option value, a relation's id, a boolean's truth) —
|
||||
* not the display label — so equality is exact; text ops fold case; numeric ops
|
||||
* compare magnitude. `null`/empty answers `isNot`-style predicates truthfully.
|
||||
*/
|
||||
export function matchesRule(field: FieldDefinition | undefined, value: unknown, rule: FilterRule): boolean {
|
||||
if (rule.op === 'isEmpty') return isEmptyValue(field, value)
|
||||
if (rule.op === 'isNotEmpty') return !isEmptyValue(field, value)
|
||||
|
||||
const type = field?.type
|
||||
|
||||
if (type === 'boolean') {
|
||||
const on = value === true || value === 'true' || value === 1
|
||||
const want = rule.value === true || rule.value === 'true' || rule.value === 1
|
||||
return rule.op === 'isNot' ? on !== want : on === want
|
||||
}
|
||||
|
||||
if (type === 'select' || type === 'multiSelect' || type === 'relation') {
|
||||
const keys = new Set(rawKeys(field, value))
|
||||
if (rule.op === 'isAnyOf') {
|
||||
const set = new Set((Array.isArray(rule.value) ? rule.value : []).map(asStr))
|
||||
if (set.size === 0) return true
|
||||
return [...keys].some((k) => set.has(k))
|
||||
}
|
||||
const target = asStr(rule.value)
|
||||
if (rule.op === 'is') return keys.has(target)
|
||||
if (rule.op === 'isNot') return !keys.has(target)
|
||||
return true
|
||||
}
|
||||
|
||||
if (numericType(type)) {
|
||||
const left = comparable(field, value)
|
||||
const l = typeof left === 'number' ? left : null
|
||||
const isDate = type === 'date' || type === 'dateTime'
|
||||
const right = isDate ? asTime(rule.value) : asNum(rule.value)
|
||||
if (l == null || right == null) return rule.op === 'isNot'
|
||||
switch (rule.op) {
|
||||
case 'is': return l === right
|
||||
case 'isNot': return l !== right
|
||||
case 'gt': return l > right
|
||||
case 'gte': return l >= right
|
||||
case 'lt': return l < right
|
||||
case 'lte': return l <= right
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
|
||||
const l = asStr(comparable(field, value))
|
||||
const r = asStr(rule.value).toLowerCase()
|
||||
switch (rule.op) {
|
||||
case 'contains': return l.includes(r)
|
||||
case 'notContains': return !l.includes(r)
|
||||
case 'is': return l === r
|
||||
case 'isNot': return l !== r
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep records that satisfy EVERY rule (AND). Empty rules → all records. */
|
||||
export function filterRecords(records: Record_[], filters: FilterRule[], fields: FieldDefinition[]): Record_[] {
|
||||
const active = filters.filter((r) => r.op === 'isEmpty' || r.op === 'isNotEmpty' || r.value != null)
|
||||
if (active.length === 0) return records.slice()
|
||||
return records.filter((rec) => active.every((rule) => matchesRule(fieldByName(fields, rule.field), rec[rule.field], rule)))
|
||||
}
|
||||
|
||||
// ── quick search (across all text-ish fields) ─────────────────────────────────
|
||||
/** Substring match of `query` against every field's displayed text (case-insensitive). */
|
||||
export function searchRecords(records: Record_[], query: string, fields: FieldDefinition[]): Record_[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return records.slice()
|
||||
return records.filter((rec) =>
|
||||
fields.some((f) => {
|
||||
const c = comparable(f, rec[f.name])
|
||||
return c != null && String(c).toLowerCase().includes(q)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ── pagination ────────────────────────────────────────────────────────────────
|
||||
export interface Page<T> {
|
||||
items: T[]
|
||||
page: number
|
||||
pageCount: number
|
||||
total: number
|
||||
/** 1-based index of the first item on this page (0 when empty). */
|
||||
from: number
|
||||
/** 1-based index of the last item on this page. */
|
||||
to: number
|
||||
}
|
||||
|
||||
/** Slice records into a page; `page` is clamped to a valid range. */
|
||||
export function paginate<T>(records: T[], page: number, pageSize: number): Page<T> {
|
||||
const total = records.length
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||
const clamped = Math.min(Math.max(1, page), pageCount)
|
||||
const start = (clamped - 1) * pageSize
|
||||
const items = records.slice(start, start + pageSize)
|
||||
return {
|
||||
items,
|
||||
page: clamped,
|
||||
pageCount,
|
||||
total,
|
||||
from: total === 0 ? 0 : start + 1,
|
||||
to: start + items.length,
|
||||
}
|
||||
}
|
||||
|
||||
// ── column order helpers ──────────────────────────────────────────────────────
|
||||
/** Move an array element from index `from` to index `to` (returns a new array). */
|
||||
export function moveItem<T>(arr: T[], from: number, to: number): T[] {
|
||||
const next = arr.slice()
|
||||
if (from < 0 || from >= next.length) return next
|
||||
const [item] = next.splice(from, 1)
|
||||
const dest = Math.min(Math.max(0, to), next.length)
|
||||
next.splice(dest, 0, item)
|
||||
return next
|
||||
}
|
||||
|
||||
/** The 0-based column index whose horizontal band contains `x` px (from the grid left). */
|
||||
export function columnIndexAtX(widths: number[], x: number): number {
|
||||
let acc = 0
|
||||
for (let i = 0; i < widths.length; i++) {
|
||||
acc += widths[i]
|
||||
if (x < acc) return i
|
||||
}
|
||||
return Math.max(0, widths.length - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ordered, visible columns for the table: fields ordered by `order` (names),
|
||||
* with any field missing from `order` appended in schema order, and any hidden
|
||||
* name removed. One place decides column order so header + rows stay in lockstep.
|
||||
*/
|
||||
export function orderedColumns(
|
||||
fields: FieldDefinition[],
|
||||
order: string[] | undefined,
|
||||
hidden: ReadonlySet<string> | undefined,
|
||||
): FieldDefinition[] {
|
||||
const visible = fields.filter((f) => !hidden?.has(f.name))
|
||||
if (!order || order.length === 0) return visible
|
||||
const byName = new Map(visible.map((f) => [f.name, f]))
|
||||
const out: FieldDefinition[] = []
|
||||
for (const name of order) {
|
||||
const f = byName.get(name)
|
||||
if (f) { out.push(f); byName.delete(name) }
|
||||
}
|
||||
for (const f of visible) if (byName.has(f.name)) out.push(f)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Hanzo data-app tokens. Brand-neutral, dark-first — the zinc-on-black identity
|
||||
// the Base/console surfaces use. Tag colors are muted fills with legible text,
|
||||
// resolved once here so every tag (select, multiSelect, status) reads the same.
|
||||
import type { TagColor } from './field/types'
|
||||
|
||||
export interface TagTone {
|
||||
bg: string
|
||||
fg: string
|
||||
border: string
|
||||
}
|
||||
|
||||
/** TagColor → tone. Muted, dark-surface friendly. One source of truth. */
|
||||
export const TAG_TONES: Record<TagColor, TagTone> = {
|
||||
gray: { bg: '#27272a', fg: '#e4e4e7', border: '#3f3f46' },
|
||||
blue: { bg: '#1e3a5f', fg: '#bfdbfe', border: '#1e40af' },
|
||||
green: { bg: '#14432a', fg: '#bbf7d0', border: '#166534' },
|
||||
amber: { bg: '#451a03', fg: '#fde68a', border: '#92400e' },
|
||||
red: { bg: '#450a0a', fg: '#fecaca', border: '#991b1b' },
|
||||
purple: { bg: '#2e1065', fg: '#ddd6fe', border: '#6d28d9' },
|
||||
pink: { bg: '#500724', fg: '#fbcfe8', border: '#9d174d' },
|
||||
teal: { bg: '#042f2e', fg: '#99f6e4', border: '#0f766e' },
|
||||
orange: { bg: '#431407', fg: '#fed7aa', border: '#9a3412' },
|
||||
}
|
||||
|
||||
export function tagTone(color: TagColor | undefined): TagTone {
|
||||
return TAG_TONES[color ?? 'gray']
|
||||
}
|
||||
|
||||
/** Core surface tokens (raw hex so components are theme-config independent). */
|
||||
export const tokens = {
|
||||
text: '#f4f4f5',
|
||||
textMuted: '#a1a1aa',
|
||||
textFaint: '#71717a',
|
||||
surface: '#09090b',
|
||||
surfaceRaised: '#0a0a0a',
|
||||
border: '#27272a',
|
||||
hover: '#18181b',
|
||||
accent: '#60a5fa',
|
||||
danger: '#fca5a5',
|
||||
} as const
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
// RecordsView — the one shell that makes any Base collection an Airtable/Twenty-
|
||||
// class surface: a toolbar (view switch, search, filter, sort, group) over a
|
||||
// DataTable OR a BoardView, driven by a single ViewConfig applied through the pure
|
||||
// view/logic. Presentational + data-injected: the host supplies raw records and
|
||||
// the persistence callbacks (open / create / inline-edit / move); RecordsView owns
|
||||
// only the view state (or reflects a controlled one) and re-applies it. Optional
|
||||
// saved views let a host persist named configurations. CRM, CMS, commerce — every
|
||||
// business app is a curated set of these views over the same components.
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Plus, X } from '@hanzogui/lucide-icons-2'
|
||||
import type { FieldDefinition, SelectOption } from '../field/types'
|
||||
import { DataTable } from '../table/DataTable'
|
||||
import { BoardView } from '../board/BoardView'
|
||||
import { defaultGroupField, movePatch } from '../board/logic'
|
||||
import { tokens } from '../theme'
|
||||
import type { ViewConfig, ViewKind } from './types'
|
||||
import { applyView, emptyView, setSorts } from './logic'
|
||||
import { RecordsToolbar } from './Toolbar'
|
||||
|
||||
type Record_ = Record<string, unknown>
|
||||
|
||||
/** Optional saved-view persistence (the host owns the store). */
|
||||
export interface SavedViews {
|
||||
views: ViewConfig[]
|
||||
activeId?: string
|
||||
onSelect: (id: string) => void
|
||||
onSave: (view: ViewConfig) => void
|
||||
onDelete?: (id: string) => void
|
||||
}
|
||||
|
||||
export interface RecordsViewProps {
|
||||
fields: FieldDefinition[]
|
||||
/** Raw records; RecordsView applies search + filter + sort from the view. */
|
||||
records: Record_[]
|
||||
loading?: boolean
|
||||
empty?: ReactNode
|
||||
|
||||
// view state — controlled (view + onViewChange) or internal (defaultKind)
|
||||
view?: ViewConfig
|
||||
onViewChange?: (v: ViewConfig) => void
|
||||
defaultKind?: ViewKind
|
||||
|
||||
// saved views (optional)
|
||||
savedViews?: SavedViews
|
||||
|
||||
// header + actions
|
||||
title?: ReactNode
|
||||
onOpen?: (record: Record_) => void
|
||||
onCreate?: () => void
|
||||
createLabel?: string
|
||||
/** Persist an inline cell/field edit (table + board). */
|
||||
onEditCommit?: (record: Record_, field: FieldDefinition, value: unknown) => void | Promise<void>
|
||||
/** Persist a board card move; defaults to onEditCommit over the group field. */
|
||||
onRecordChange?: (record: Record_, patch: Record<string, unknown>) => void | Promise<void>
|
||||
fieldOptions?: (field: FieldDefinition) => SelectOption[] | undefined
|
||||
|
||||
selectable?: boolean
|
||||
editable?: boolean
|
||||
bulkActions?: (ids: string[]) => ReactNode
|
||||
/** Toolbar extra (e.g. Refresh). */
|
||||
toolbarExtra?: ReactNode
|
||||
boardDisabled?: boolean
|
||||
titleField?: string
|
||||
/** Fields shown on board cards (defaults to all but the group field). */
|
||||
cardFields?: FieldDefinition[]
|
||||
}
|
||||
|
||||
export function RecordsView(props: RecordsViewProps) {
|
||||
const {
|
||||
fields, records, loading, empty,
|
||||
view: controlledView, onViewChange, defaultKind = 'table',
|
||||
savedViews, title, onOpen, onCreate, createLabel,
|
||||
onEditCommit, onRecordChange, fieldOptions,
|
||||
selectable = true, editable = true, bulkActions, toolbarExtra, boardDisabled, titleField, cardFields,
|
||||
} = props
|
||||
|
||||
const [internalView, setInternalView] = useState<ViewConfig>(() => {
|
||||
const v = emptyView(defaultKind)
|
||||
return defaultKind === 'board' ? { ...v, groupField: defaultGroupField(fields) } : v
|
||||
})
|
||||
const view = controlledView ?? internalView
|
||||
const setView = (v: ViewConfig) => {
|
||||
// Entering board without a group field → pick a sensible default.
|
||||
const next = v.kind === 'board' && !v.groupField ? { ...v, groupField: defaultGroupField(fields) } : v
|
||||
onViewChange ? onViewChange(next) : setInternalView(next)
|
||||
}
|
||||
|
||||
const visible = useMemo(() => applyView(records, view, fields), [records, view, fields])
|
||||
|
||||
const boardChange = onRecordChange
|
||||
?? (onEditCommit
|
||||
? (record: Record_, patch: Record<string, unknown>) => {
|
||||
const [name] = Object.keys(patch)
|
||||
const f = fields.find((ff) => ff.name === name)
|
||||
if (f) return onEditCommit(record, f, patch[name])
|
||||
}
|
||||
: undefined)
|
||||
|
||||
return (
|
||||
<YStack gap={12}>
|
||||
{title ? (typeof title === 'string' ? <Text fontSize={20} fontWeight="800" style={{ color: tokens.text }}>{title}</Text> : title) : null}
|
||||
|
||||
{savedViews ? <SavedViewsBar saved={savedViews} current={view} onLoad={setView} /> : null}
|
||||
|
||||
<RecordsToolbar
|
||||
fields={fields}
|
||||
view={view}
|
||||
onView={setView}
|
||||
onCreate={onCreate}
|
||||
createLabel={createLabel}
|
||||
fieldOptions={fieldOptions}
|
||||
extra={toolbarExtra}
|
||||
boardDisabled={boardDisabled}
|
||||
/>
|
||||
|
||||
{view.kind === 'board' ? (
|
||||
<BoardView
|
||||
fields={fields}
|
||||
records={visible}
|
||||
groupField={view.groupField ?? defaultGroupField(fields) ?? ''}
|
||||
titleField={titleField}
|
||||
cardFields={cardFields}
|
||||
onOpen={onOpen}
|
||||
onRecordChange={boardChange}
|
||||
onCreate={onCreate ? () => onCreate() : undefined}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
fields={fields}
|
||||
records={visible}
|
||||
loading={loading}
|
||||
empty={empty}
|
||||
sorts={view.sorts}
|
||||
onSortChange={(sorts) => setView(setSorts(view, sorts))}
|
||||
onOpen={onOpen}
|
||||
selectable={selectable}
|
||||
bulkActions={bulkActions}
|
||||
editable={editable}
|
||||
onEditCommit={onEditCommit}
|
||||
fieldOptions={fieldOptions}
|
||||
hiddenFields={view.hiddenFields}
|
||||
columnOrder={view.columnOrder}
|
||||
onColumnOrderChange={(columnOrder) => setView({ ...view, columnOrder })}
|
||||
pageSize={view.pageSize ?? 25}
|
||||
/>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
function SavedViewsBar({ saved, current, onLoad }: { saved: SavedViews; current: ViewConfig; onLoad: (v: ViewConfig) => void }) {
|
||||
return (
|
||||
<XStack items="center" gap={4} style={{ flexWrap: 'wrap' }}>
|
||||
{saved.views.map((v) => {
|
||||
const active = v.id === (saved.activeId ?? current.id)
|
||||
return (
|
||||
<XStack
|
||||
key={v.id}
|
||||
items="center"
|
||||
gap={6}
|
||||
px={10}
|
||||
py={6}
|
||||
rounded={8}
|
||||
cursor="pointer"
|
||||
onPress={() => { saved.onSelect(v.id); onLoad(v) }}
|
||||
style={{ backgroundColor: active ? tokens.hover : 'transparent', borderBottomWidth: 2, borderBottomColor: active ? tokens.accent : 'transparent' }}
|
||||
hoverStyle={active ? undefined : { bg: tokens.surfaceRaised }}
|
||||
>
|
||||
<Text fontSize={13} fontWeight={active ? '700' : '500'} style={{ color: active ? tokens.text : tokens.textMuted }}>{v.name}</Text>
|
||||
{saved.onDelete && saved.views.length > 1 ? (
|
||||
<YStack cursor="pointer" onPress={() => saved.onDelete?.(v.id)}><X size={12} color={tokens.textFaint} /></YStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
)
|
||||
})}
|
||||
<XStack
|
||||
items="center"
|
||||
gap={4}
|
||||
px={10}
|
||||
py={6}
|
||||
rounded={8}
|
||||
cursor="pointer"
|
||||
hoverStyle={{ bg: tokens.surfaceRaised }}
|
||||
onPress={() => saved.onSave({ ...current, id: `view-${Date.now()}`, name: `View ${saved.views.length + 1}` })}
|
||||
>
|
||||
<Plus size={13} color={tokens.textFaint} />
|
||||
<Text fontSize={13} style={{ color: tokens.textFaint }}>New view</Text>
|
||||
</XStack>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
'use client'
|
||||
|
||||
// RecordsToolbar — the view control surface: switch table ⇆ board, quick search,
|
||||
// a filter builder, a sort builder, a board group-by picker, and a New action.
|
||||
// Every control edits the one ViewConfig through the pure view/logic helpers and
|
||||
// emits it via onView; the shell re-applies the view. Filter VALUES are entered
|
||||
// with the field's OWN editor (FieldInput) — one editor vocabulary everywhere.
|
||||
import { Fragment, useState, type ReactNode } from 'react'
|
||||
import { Button, Input, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ArrowDownUp, ArrowDown, ArrowUp, Columns3, LayoutGrid, ListFilter, Plus, Search, Table2, X } from '@hanzogui/lucide-icons-2'
|
||||
import type { FieldDefinition, SelectOption } from '../field/types'
|
||||
import { FieldInput } from '../field/FieldInput'
|
||||
import { Menu, MenuItem } from '../primitives'
|
||||
import { tokens } from '../theme'
|
||||
import { OP_LABELS, operatorsForType, type FilterOp, type SortRule } from '../table/logic'
|
||||
import { groupFieldCandidates } from '../board/logic'
|
||||
import type { ViewConfig } from './types'
|
||||
import { addFilter, defaultRuleFor, removeFilter, setGroupField, setKind, setSearch, setSorts, updateFilter } from './logic'
|
||||
|
||||
const byName = (fields: FieldDefinition[], name: string) => fields.find((f) => f.name === name)
|
||||
|
||||
export interface RecordsToolbarProps {
|
||||
fields: FieldDefinition[]
|
||||
view: ViewConfig
|
||||
onView: (v: ViewConfig) => void
|
||||
onCreate?: () => void
|
||||
createLabel?: string
|
||||
fieldOptions?: (f: FieldDefinition) => SelectOption[] | undefined
|
||||
/** Extra controls (e.g. Refresh), shown at the right. */
|
||||
extra?: ReactNode
|
||||
/** Hide the board view option (table-only surfaces). */
|
||||
boardDisabled?: boolean
|
||||
}
|
||||
|
||||
/** A compact bordered control used as a Menu trigger. */
|
||||
function Control({ icon, label, count, active }: { icon: ReactNode; label: string; count?: number; active?: boolean }) {
|
||||
return (
|
||||
<XStack
|
||||
items="center"
|
||||
gap={6}
|
||||
px={10}
|
||||
py={7}
|
||||
rounded={8}
|
||||
borderWidth={1}
|
||||
cursor="pointer"
|
||||
hoverStyle={{ borderColor: tokens.textMuted }}
|
||||
style={{ borderColor: active ? tokens.accent : tokens.border, backgroundColor: active ? tokens.hover : 'transparent' }}
|
||||
>
|
||||
{icon}
|
||||
<Text fontSize={13} fontWeight="600" style={{ color: active ? tokens.text : tokens.textMuted }}>{label}</Text>
|
||||
{count ? (
|
||||
<XStack items="center" justify="center" width={18} height={18} rounded={9} style={{ backgroundColor: tokens.accent }}>
|
||||
<Text fontSize={11} fontWeight="700" style={{ color: '#0b1220' }}>{count}</Text>
|
||||
</XStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
function Segmented({ view, onView, boardDisabled }: { view: ViewConfig; onView: (v: ViewConfig) => void; boardDisabled?: boolean }) {
|
||||
const seg = (kind: 'table' | 'board', icon: ReactNode, label: string) => {
|
||||
const active = view.kind === kind
|
||||
return (
|
||||
<XStack
|
||||
items="center"
|
||||
gap={6}
|
||||
px={10}
|
||||
py={6}
|
||||
rounded={7}
|
||||
cursor="pointer"
|
||||
onPress={() => onView(setKind(view, kind))}
|
||||
style={{ backgroundColor: active ? tokens.surface : 'transparent' }}
|
||||
hoverStyle={active ? undefined : { bg: tokens.hover }}
|
||||
>
|
||||
{icon}
|
||||
<Text fontSize={13} fontWeight="600" style={{ color: active ? tokens.text : tokens.textFaint }}>{label}</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<XStack p={3} gap={2} rounded={9} borderWidth={1} style={{ borderColor: tokens.border, backgroundColor: tokens.surfaceRaised }}>
|
||||
{seg('table', <Table2 size={14} color={view.kind === 'table' ? tokens.text : tokens.textFaint} />, 'Table')}
|
||||
{boardDisabled ? null : seg('board', <LayoutGrid size={14} color={view.kind === 'board' ? tokens.text : tokens.textFaint} />, 'Board')}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A field picker menu (used by filter rows). */
|
||||
function FieldPicker({ fields, value, onPick, trigger }: { fields: FieldDefinition[]; value?: string; onPick: (name: string) => void; trigger: ReactNode }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<Menu open={open} onOpenChange={setOpen} width={220} trigger={trigger}>
|
||||
{fields.map((f) => (
|
||||
<MenuItem key={f.name} active={f.name === value} onPress={() => { onPick(f.name); setOpen(false) }}>
|
||||
<Text fontSize={13} style={{ color: tokens.text }}>{f.label}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
function OpPicker({ ops, value, onPick }: { ops: FilterOp[]; value: FilterOp; onPick: (op: FilterOp) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<Menu open={open} onOpenChange={setOpen} width={200} trigger={<PillText text={OP_LABELS[value]} />}>
|
||||
{ops.map((op) => (
|
||||
<MenuItem key={op} active={op === value} onPress={() => { onPick(op); setOpen(false) }}>
|
||||
<Text fontSize={13} style={{ color: tokens.text }}>{OP_LABELS[op]}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
function PillText({ text }: { text: string }) {
|
||||
return (
|
||||
<XStack items="center" px={8} py={5} rounded={7} borderWidth={1} cursor="pointer" hoverStyle={{ borderColor: tokens.textMuted }} style={{ borderColor: tokens.border, backgroundColor: tokens.surface }}>
|
||||
<Text fontSize={12} numberOfLines={1} style={{ color: tokens.text }}>{text}</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterBuilder({ fields, view, onView, fieldOptions }: RecordsToolbarProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const active = view.filters.filter((r) => r.op === 'isEmpty' || r.op === 'isNotEmpty' || r.value != null).length
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
width={420}
|
||||
trigger={<Control icon={<ListFilter size={14} color={tokens.textMuted} />} label="Filter" count={active} active={active > 0} />}
|
||||
>
|
||||
<YStack gap={8} p={4}>
|
||||
{view.filters.length === 0 ? (
|
||||
<Text px={6} py={4} fontSize={12} style={{ color: tokens.textFaint }}>No filters yet.</Text>
|
||||
) : null}
|
||||
{view.filters.map((rule, i) => {
|
||||
const field = byName(fields, rule.field) ?? fields[0]
|
||||
const ops = operatorsForType(field.type)
|
||||
const needsValue = rule.op !== 'isEmpty' && rule.op !== 'isNotEmpty'
|
||||
const valueField: FieldDefinition = rule.op === 'isAnyOf' ? { ...field, type: 'multiSelect' } : field
|
||||
const withOpts: FieldDefinition = fieldOptions?.(field)
|
||||
? { ...valueField, metadata: { ...(valueField.metadata as object), options: fieldOptions(field) } as FieldDefinition['metadata'] }
|
||||
: valueField
|
||||
return (
|
||||
<XStack key={i} items="center" gap={6}>
|
||||
<Text fontSize={12} width={28} style={{ color: tokens.textFaint }}>{i === 0 ? 'Where' : 'and'}</Text>
|
||||
<FieldPicker
|
||||
fields={fields}
|
||||
value={rule.field}
|
||||
onPick={(name) => { const nf = byName(fields, name)!; onView(updateFilter(view, i, { field: name, op: operatorsForType(nf.type)[0], value: undefined })) }}
|
||||
trigger={<PillText text={field.label} />}
|
||||
/>
|
||||
<OpPicker ops={ops} value={rule.op} onPick={(op) => onView(updateFilter(view, i, { op }))} />
|
||||
{needsValue ? (
|
||||
<XStack flex={1} minW={120}>
|
||||
<FieldInput field={withOpts} value={rule.value} onChange={(v) => onView(updateFilter(view, i, { value: v }))} />
|
||||
</XStack>
|
||||
) : <XStack flex={1} />}
|
||||
<YStack cursor="pointer" p={4} rounded={6} hoverStyle={{ bg: tokens.hover }} onPress={() => onView(removeFilter(view, i))}>
|
||||
<X size={14} color={tokens.textFaint} />
|
||||
</YStack>
|
||||
</XStack>
|
||||
)
|
||||
})}
|
||||
<XStack>
|
||||
<Button size="$2" icon={<Plus size={14} />} onPress={() => onView(addFilter(view, defaultRuleFor(fields[0])))}>Add filter</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
function SortBuilder({ fields, view, onView }: { fields: FieldDefinition[]; view: ViewConfig; onView: (v: ViewConfig) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const dirOf = (name: string) => view.sorts.find((s) => s.field === name)?.dir
|
||||
const toggle = (name: string) => {
|
||||
const cur = view.sorts.find((s) => s.field === name)
|
||||
let sorts: SortRule[]
|
||||
if (!cur) sorts = [...view.sorts, { field: name, dir: 'asc' }]
|
||||
else if (cur.dir === 'asc') sorts = view.sorts.map((s) => (s.field === name ? { ...s, dir: 'desc' } : s))
|
||||
else sorts = view.sorts.filter((s) => s.field !== name)
|
||||
onView(setSorts(view, sorts))
|
||||
}
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
width={240}
|
||||
trigger={<Control icon={<ArrowDownUp size={14} color={tokens.textMuted} />} label="Sort" count={view.sorts.length} active={view.sorts.length > 0} />}
|
||||
>
|
||||
{fields.map((f) => {
|
||||
const dir = dirOf(f.name)
|
||||
return (
|
||||
<MenuItem
|
||||
key={f.name}
|
||||
active={Boolean(dir)}
|
||||
onPress={() => toggle(f.name)}
|
||||
trailing={dir === 'asc' ? <ArrowUp size={13} color={tokens.accent} /> : dir === 'desc' ? <ArrowDown size={13} color={tokens.accent} /> : null}
|
||||
>
|
||||
<Text fontSize={13} style={{ color: tokens.text }}>{f.label}</Text>
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupPicker({ fields, view, onView }: { fields: FieldDefinition[]; view: ViewConfig; onView: (v: ViewConfig) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const candidates = groupFieldCandidates(fields)
|
||||
const current = byName(fields, view.groupField ?? '')
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
width={220}
|
||||
trigger={<Control icon={<Columns3 size={14} color={tokens.textMuted} />} label={current ? `Group: ${current.label}` : 'Group by'} active={Boolean(current)} />}
|
||||
>
|
||||
{candidates.length === 0 ? <Text px={10} py={6} fontSize={12} style={{ color: tokens.textFaint }}>No groupable fields</Text> : null}
|
||||
{candidates.map((f) => (
|
||||
<MenuItem key={f.name} active={f.name === view.groupField} onPress={() => { onView(setGroupField(view, f.name)); setOpen(false) }}>
|
||||
<Text fontSize={13} style={{ color: tokens.text }}>{f.label}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export function RecordsToolbar(props: RecordsToolbarProps) {
|
||||
const { fields, view, onView, onCreate, createLabel = 'New', extra, boardDisabled } = props
|
||||
return (
|
||||
<XStack items="center" gap={8} style={{ flexWrap: 'wrap' }}>
|
||||
<Segmented view={view} onView={onView} boardDisabled={boardDisabled} />
|
||||
<FilterBuilder {...props} />
|
||||
<SortBuilder fields={fields} view={view} onView={onView} />
|
||||
{view.kind === 'board' ? <GroupPicker fields={fields} view={view} onView={onView} /> : null}
|
||||
|
||||
<XStack flex={1} minW={160} items="center" gap={6} px={10} py={6} rounded={8} borderWidth={1} style={{ borderColor: tokens.border, backgroundColor: tokens.surface }}>
|
||||
<Search size={14} color={tokens.textFaint} />
|
||||
<Input
|
||||
flex={1}
|
||||
size="$2"
|
||||
value={view.search ?? ''}
|
||||
onChangeText={(t: string) => onView(setSearch(view, t))}
|
||||
placeholder="Search…"
|
||||
style={{ borderWidth: 0, backgroundColor: 'transparent', paddingLeft: 0, paddingRight: 0 }}
|
||||
/>
|
||||
</XStack>
|
||||
|
||||
{extra ? <Fragment>{extra}</Fragment> : null}
|
||||
{onCreate ? <Button size="$3" icon={<Plus size={16} />} theme="blue" onPress={onCreate}>{createLabel}</Button> : null}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { FieldDefinition } from '../field/types'
|
||||
import {
|
||||
emptyView,
|
||||
applyView,
|
||||
defaultRuleFor,
|
||||
addFilter,
|
||||
updateFilter,
|
||||
removeFilter,
|
||||
cycleColumnSort,
|
||||
setSearch,
|
||||
setGroupField,
|
||||
upsertView,
|
||||
removeView,
|
||||
describeView,
|
||||
} from './logic'
|
||||
|
||||
const fields: FieldDefinition[] = [
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
{ name: 'count', label: 'Count', type: 'number' },
|
||||
{ name: 'stage', label: 'Stage', type: 'select', metadata: { options: [{ value: 'NEW', label: 'New' }, { value: 'WON', label: 'Won' }] } },
|
||||
]
|
||||
|
||||
const rows: Record<string, unknown>[] = [
|
||||
{ id: '1', name: 'Acme', count: 5, stage: 'NEW' },
|
||||
{ id: '2', name: 'Beta', count: 2, stage: 'WON' },
|
||||
{ id: '3', name: 'Ace', count: 9, stage: 'NEW' },
|
||||
]
|
||||
|
||||
describe('applyView', () => {
|
||||
it('composes search → filter → sort', () => {
|
||||
let v = emptyView('table')
|
||||
v = setSearch(v, 'ac') // Acme, Ace
|
||||
v = addFilter(v, { field: 'stage', op: 'is', value: 'NEW' })
|
||||
v = { ...v, sorts: [{ field: 'count', dir: 'desc' }] }
|
||||
const out = applyView(rows, v, fields)
|
||||
expect(out.map((r) => r.id)).toEqual(['3', '1']) // Ace(9), Acme(5)
|
||||
})
|
||||
it('empty view returns all in order', () => {
|
||||
expect(applyView(rows, emptyView('table'), fields).map((r) => r.id)).toEqual(['1', '2', '3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('filter edits (immutable)', () => {
|
||||
it('adds, updates, removes without mutating', () => {
|
||||
const v0 = emptyView('table')
|
||||
const v1 = addFilter(v0, defaultRuleFor(fields[0]))
|
||||
expect(v0.filters).toHaveLength(0)
|
||||
expect(v1.filters).toHaveLength(1)
|
||||
expect(v1.filters[0]).toMatchObject({ field: 'name', op: 'contains' })
|
||||
const v2 = updateFilter(v1, 0, { op: 'is', value: 'Acme' })
|
||||
expect(v2.filters[0]).toMatchObject({ op: 'is', value: 'Acme' })
|
||||
expect(v1.filters[0].op).toBe('contains') // v1 untouched
|
||||
const v3 = removeFilter(v2, 0)
|
||||
expect(v3.filters).toHaveLength(0)
|
||||
})
|
||||
it('defaultRuleFor picks a type-appropriate operator', () => {
|
||||
expect(defaultRuleFor(fields[1]).op).toBe('is') // number → first numeric op
|
||||
expect(defaultRuleFor(fields[2]).op).toBe('is') // select
|
||||
})
|
||||
})
|
||||
|
||||
describe('cycleColumnSort', () => {
|
||||
it('cycles a column through asc/desc/off', () => {
|
||||
let v = emptyView('table')
|
||||
v = cycleColumnSort(v, 'name')
|
||||
expect(v.sorts).toEqual([{ field: 'name', dir: 'asc' }])
|
||||
v = cycleColumnSort(v, 'name')
|
||||
expect(v.sorts).toEqual([{ field: 'name', dir: 'desc' }])
|
||||
v = cycleColumnSort(v, 'name')
|
||||
expect(v.sorts).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('saved views', () => {
|
||||
it('upsert appends new and replaces by id', () => {
|
||||
const a = emptyView('table', 'a', 'A')
|
||||
const b = emptyView('board', 'b', 'B')
|
||||
let views = upsertView([], a)
|
||||
views = upsertView(views, b)
|
||||
expect(views.map((v) => v.id)).toEqual(['a', 'b'])
|
||||
views = upsertView(views, { ...a, name: 'A2' })
|
||||
expect(views.find((v) => v.id === 'a')?.name).toBe('A2')
|
||||
expect(views).toHaveLength(2)
|
||||
views = removeView(views, 'a')
|
||||
expect(views.map((v) => v.id)).toEqual(['b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeView', () => {
|
||||
it('summarizes filters, sort, and grouping', () => {
|
||||
let v = emptyView('board')
|
||||
v = addFilter(v, { field: 'stage', op: 'is', value: 'NEW' })
|
||||
v = { ...v, sorts: [{ field: 'count', dir: 'asc' }] }
|
||||
v = setGroupField(v, 'stage')
|
||||
expect(describeView(v, fields)).toBe('1 filter · sorted by Count · grouped by Stage')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
// Pure view-state logic — composing a saved view (search → filter → sort) over a
|
||||
// record set, and the immutable edits the view toolbar makes (add/update/remove a
|
||||
// filter, cycle a column's sort, change search / group / kind, save & delete
|
||||
// views). No React, no @hanzo/gui: every helper is data-in/data-out and tested in
|
||||
// plain Node. The ONE place a view is applied, so table + board agree.
|
||||
import type { FieldDefinition } from '../field/types'
|
||||
import {
|
||||
filterRecords,
|
||||
searchRecords,
|
||||
sortRecords,
|
||||
cycleSort,
|
||||
operatorsForType,
|
||||
type FilterRule,
|
||||
type SortRule,
|
||||
} from '../table/logic'
|
||||
import type { ViewConfig, ViewKind } from './types'
|
||||
|
||||
type Record_ = Record<string, unknown>
|
||||
|
||||
/** A blank view of a given kind. */
|
||||
export function emptyView(kind: ViewKind, id = 'default', name = kind === 'board' ? 'Board' : 'All records'): ViewConfig {
|
||||
return { id, name, kind, filters: [], sorts: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a view to a record set: quick-search, then filter (AND), then sort. Pure
|
||||
* — returns a new array, input untouched. Board grouping is applied by the board
|
||||
* view over this same filtered+sorted result, so both views stay consistent.
|
||||
*/
|
||||
export function applyView(records: Record_[], view: ViewConfig, fields: FieldDefinition[]): Record_[] {
|
||||
let out = records
|
||||
if (view.search && view.search.trim()) out = searchRecords(out, view.search, fields)
|
||||
out = filterRecords(out, view.filters, fields)
|
||||
out = sortRecords(out, view.sorts, fields)
|
||||
return out
|
||||
}
|
||||
|
||||
// ── filter edits (immutable) ──────────────────────────────────────────────────
|
||||
/** The sensible starting rule for a field — its first operator, no value yet. */
|
||||
export function defaultRuleFor(field: FieldDefinition): FilterRule {
|
||||
const ops = operatorsForType(field.type)
|
||||
return { field: field.name, op: ops[0] ?? 'contains' }
|
||||
}
|
||||
|
||||
export function addFilter(view: ViewConfig, rule: FilterRule): ViewConfig {
|
||||
return { ...view, filters: [...view.filters, rule] }
|
||||
}
|
||||
|
||||
export function updateFilter(view: ViewConfig, index: number, patch: Partial<FilterRule>): ViewConfig {
|
||||
return { ...view, filters: view.filters.map((r, i) => (i === index ? { ...r, ...patch } : r)) }
|
||||
}
|
||||
|
||||
export function removeFilter(view: ViewConfig, index: number): ViewConfig {
|
||||
return { ...view, filters: view.filters.filter((_, i) => i !== index) }
|
||||
}
|
||||
|
||||
// ── sort edits ────────────────────────────────────────────────────────────────
|
||||
export function setSorts(view: ViewConfig, sorts: SortRule[]): ViewConfig {
|
||||
return { ...view, sorts }
|
||||
}
|
||||
|
||||
/** Header-click: cycle a column through unsorted → asc → desc → unsorted. */
|
||||
export function cycleColumnSort(view: ViewConfig, field: string): ViewConfig {
|
||||
return { ...view, sorts: cycleSort(view.sorts, field) }
|
||||
}
|
||||
|
||||
// ── search / kind / group ─────────────────────────────────────────────────────
|
||||
export function setSearch(view: ViewConfig, search: string): ViewConfig {
|
||||
return { ...view, search }
|
||||
}
|
||||
export function setKind(view: ViewConfig, kind: ViewKind): ViewConfig {
|
||||
return { ...view, kind }
|
||||
}
|
||||
export function setGroupField(view: ViewConfig, groupField: string | undefined): ViewConfig {
|
||||
return { ...view, groupField }
|
||||
}
|
||||
|
||||
// ── column layout edits (table) ───────────────────────────────────────────────
|
||||
export function setColumnOrder(view: ViewConfig, columnOrder: string[]): ViewConfig {
|
||||
return { ...view, columnOrder }
|
||||
}
|
||||
export function toggleFieldHidden(view: ViewConfig, name: string): ViewConfig {
|
||||
const hidden = new Set(view.hiddenFields ?? [])
|
||||
if (hidden.has(name)) hidden.delete(name)
|
||||
else hidden.add(name)
|
||||
return { ...view, hiddenFields: [...hidden] }
|
||||
}
|
||||
|
||||
// ── saved views collection ────────────────────────────────────────────────────
|
||||
/** Insert or replace a view by id (order preserved; appended when new). */
|
||||
export function upsertView(views: ViewConfig[], view: ViewConfig): ViewConfig[] {
|
||||
const i = views.findIndex((v) => v.id === view.id)
|
||||
if (i < 0) return [...views, view]
|
||||
return views.map((v, j) => (j === i ? view : v))
|
||||
}
|
||||
|
||||
export function removeView(views: ViewConfig[], id: string): ViewConfig[] {
|
||||
return views.filter((v) => v.id !== id)
|
||||
}
|
||||
|
||||
/** A short human summary of a view's filter + sort state — for the switcher. */
|
||||
export function describeView(view: ViewConfig, fields: FieldDefinition[]): string {
|
||||
const label = (name: string) => fields.find((f) => f.name === name)?.label ?? name
|
||||
const parts: string[] = []
|
||||
if (view.filters.length) parts.push(`${view.filters.length} filter${view.filters.length > 1 ? 's' : ''}`)
|
||||
if (view.sorts.length) parts.push(`sorted by ${label(view.sorts[0].field)}`)
|
||||
if (view.kind === 'board' && view.groupField) parts.push(`grouped by ${label(view.groupField)}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// The saved-view model — a named, serializable configuration of how a collection
|
||||
// is presented: which view kind (table / board), what it's filtered + sorted +
|
||||
// grouped by, and the table's column layout. A host persists these (per user, per
|
||||
// collection); the components render from them and emit the next config. One
|
||||
// value describes a view, so switching, saving, and restoring are all data.
|
||||
import type { SortRule, FilterRule } from '../table/logic'
|
||||
|
||||
export type ViewKind = 'table' | 'board'
|
||||
|
||||
export interface ViewConfig {
|
||||
/** Stable id (host-assigned). */
|
||||
id: string
|
||||
/** Human name shown in the view switcher. */
|
||||
name: string
|
||||
kind: ViewKind
|
||||
/** AND-combined filter rules. */
|
||||
filters: FilterRule[]
|
||||
/** Ordered sort rules (first is primary). */
|
||||
sorts: SortRule[]
|
||||
/** Quick full-text search across fields. */
|
||||
search?: string
|
||||
/** Board: the select/status/boolean field records are grouped into columns by. */
|
||||
groupField?: string
|
||||
/** Table: column order by field name (missing names append in schema order). */
|
||||
columnOrder?: string[]
|
||||
/** Table: field names hidden from the grid. */
|
||||
hiddenFields?: string[]
|
||||
/** Table: rows per page. */
|
||||
pageSize?: number
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["react"]
|
||||
},
|
||||
"include": ["gui.config.ts", "gui.d.ts", "src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
/**
|
||||
* Unit tests run in a plain Node env. Every suite targets the package's PURE
|
||||
* logic (sort / filter / group / view state) — the modules that import only
|
||||
* `type` from the field model, so no @hanzo/gui runtime is pulled in. The view
|
||||
* components themselves are verified by the consuming app's build + visual e2e.
|
||||
*/
|
||||
export default defineConfig({
|
||||
test: { environment: 'node', include: ['src/**/*.test.ts'] },
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
# Keep the published tarball lean — ship src, not tests or dev type-check config.
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
tsconfig.json
|
||||
vitest.config.ts
|
||||
gui.config.ts
|
||||
gui.d.ts
|
||||
node_modules
|
||||
@@ -0,0 +1,61 @@
|
||||
# @hanzo/ui
|
||||
|
||||
The **one** Hanzo component library, built on [`@hanzo/gui`](https://github.com/hanzoai/gui) (Tamagui) so every component runs on **web, native (iOS), and desktop**. It unifies what used to be three fragmented homes — `@hanzo/data`, the console's in-app `components/ui/*`, and ad-hoc duplicates — into one canonical, presentational, host-agnostic, clean-room library.
|
||||
|
||||
> This is the gui-based line (**v8+**). The legacy shadcn/Radix `@hanzo/ui` (v5.x) is a different architecture; it is being retired to `@hanzo/ui-shadcn` so the `@hanzo/ui` name carries the unified gui-based library forward — see `CONSOLIDATION.md` at the repo root.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
bun add @hanzo/ui @hanzo/gui @hanzo/data
|
||||
```
|
||||
|
||||
`@hanzo/gui`, `@hanzo/data`, and `react` are peers.
|
||||
|
||||
## Two orthogonal layers, one package
|
||||
|
||||
```tsx
|
||||
// product / app layer — charts, metrics, headers, status, empty states, combobox…
|
||||
import { PageHeader, Sparkline, LineChart, EmptyState, StatusTag, ComboBox, tokens } from '@hanzo/ui'
|
||||
|
||||
// metadata-driven record layer — RecordsView & the typed field editors (@hanzo/data)
|
||||
import { RecordsView, DataTable, BoardView, RecordDetail, registerField } from '@hanzo/ui/data'
|
||||
```
|
||||
|
||||
- **`@hanzo/ui`** — the product/app component layer:
|
||||
- **Charts** (dependency-free inline SVG): `Sparkline`, `LineChart`, `BarChart`, `Donut`, `BarRows` — hover tooltips, axis ticks, honest `null` under two real points.
|
||||
- **Metric** tiles/panels: `MetricCard`, `MiniBars`, `UtilBar`, `LegendDot`, `Panel`, `HintButton` (+ `MetricSparkline`).
|
||||
- **Chrome**: `PageHeader`, `StatusTag`, `EmptyState` (DO/Vercel-class first-run), `PrimaryButton`, `HanzoMark`, `ProductIcon`, `ProviderLogo`.
|
||||
- **Interaction**: `ComboBox` (typeable, ReDoS-safe filter), `SelectMenu`, `SlideOver` (a11y drawer), `Toast` (`useToast`), `Reorder` (pointer DnD), `Field*` rows, `FadeIn`, `ThemeToggle`, generic `DataTable<T>`.
|
||||
- **Tokens**: `tokens`, `TAG_TONES`, `tagTone` — the calm, dark-first, zinc-on-black identity.
|
||||
- **`@hanzo/ui/data`** — the metadata-driven record layer (source of truth: `@hanzo/data`): `RecordsView` (table ⇆ board, filter/sort/search/group, inline edit, saved views), the record-grid `DataTable`, `BoardView`, `RecordDetail`/`RecordForm`, every typed field editor, the field registry, and the pure view/sort/filter logic.
|
||||
|
||||
Both layers are **presentational + data-injected**: the host supplies rows and persistence callbacks; the components own only interaction state. Nothing imports an app's `~/lib`.
|
||||
|
||||
> Each layer owns a `DataTable` — the product one is a generic typed `<T>` list; the data one is the field-driven record grid. Keeping the record layer on the `./data` subpath keeps each name unambiguous.
|
||||
|
||||
## Motion
|
||||
|
||||
The calm motion vocabulary (`FadeIn`, drawer/backdrop transitions, skeleton shimmer, live pulse) ships as one stylesheet. Import it once at the app root:
|
||||
|
||||
```ts
|
||||
import '@hanzo/ui/styles/motion.css'
|
||||
```
|
||||
|
||||
Every animation honors `prefers-reduced-motion`.
|
||||
|
||||
## Extend the record layer
|
||||
|
||||
```tsx
|
||||
import { registerField } from '@hanzo/ui/data'
|
||||
registerField('rating', { Display: MyStars, Input: MyStarPicker })
|
||||
```
|
||||
|
||||
## Design principles
|
||||
|
||||
- **One home, no duplication** — components are extracted, never copied. The record layer's source of truth stays in `@hanzo/data`; `@hanzo/ui` composes it.
|
||||
- **Host-agnostic** — data + effects injected; zero coupling to any app.
|
||||
- **Clean-room** — original implementation, no GPL / Twenty code. Airtable/Twenty-*class* polish, our own code.
|
||||
- **Cross-platform** — web + native + desktop, because it's only `@hanzo/gui`.
|
||||
|
||||
BSD-3-Clause · Hanzo AI
|
||||
@@ -0,0 +1,13 @@
|
||||
// The @hanzo/gui v5 config this package is authored against. `createGui` with the
|
||||
// shared `@hanzogui/config/v5` default enables the shorthand style props
|
||||
// (bg/px/py/items/justify/rounded/…) the components use; `Conf` feeds the type
|
||||
// augmentation in `gui.d.ts` so `tsc --noEmit` type-checks the shorthands exactly
|
||||
// as the consuming app (console) does. Not shipped — dev/type-check only.
|
||||
import { defaultConfig } from '@hanzogui/config/v5'
|
||||
import { createGui } from '@hanzo/gui'
|
||||
|
||||
export const config = createGui(defaultConfig)
|
||||
|
||||
export default config
|
||||
|
||||
export type Conf = typeof config
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Registers our Gui config with the type system so shorthand style props
|
||||
* (tokens, themes, bg/px/py/items/justify etc.) type-check exactly as they do in
|
||||
* the consuming app. GuiCustomConfig is declared in @hanzogui/web and flows
|
||||
* through @hanzo/gui. Dev/type-check only — never shipped.
|
||||
*/
|
||||
import type { Conf } from './gui.config'
|
||||
|
||||
declare module '@hanzogui/web' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
|
||||
declare module '@hanzogui/core' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@hanzo/ui",
|
||||
"version": "8.0.0",
|
||||
"type": "module",
|
||||
"description": "Hanzo UI — the one cross-platform component library on @hanzo/gui. The product/app layer (charts, metrics, page headers, status tags, rich empty states, combobox, slide-over, toasts, drag-reorder, labeled field rows, provider/product marks) + the metadata-driven record layer (@hanzo/data: RecordsView, DataTable, board, typed field editors) + the calm dark-first tokens and motion. Presentational, host-agnostic (data/effects injected), clean-room. Web + native (iOS) + desktop.",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./product": {
|
||||
"types": "./src/product/index.ts",
|
||||
"default": "./src/product/index.ts"
|
||||
},
|
||||
"./data": {
|
||||
"types": "./src/data.ts",
|
||||
"default": "./src/data.ts"
|
||||
},
|
||||
"./primitives/bases/data": {
|
||||
"types": "./src/primitives/bases/data/index.ts",
|
||||
"default": "./src/primitives/bases/data/index.ts"
|
||||
},
|
||||
"./styles/motion.css": "./src/styles/hanzo-motion.css",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"module": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"tc": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@hanzo/data": ">=1.2.0",
|
||||
"@hanzo/gui": ">=7.2.2",
|
||||
"react": ">=19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hanzo/data": "1.2.0",
|
||||
"@hanzo/gui": "7.3.0",
|
||||
"@hanzogui/config": "7.3.0",
|
||||
"@hanzogui/lucide-icons-2": "7.3.0",
|
||||
"@hanzogui/next-theme": "7.3.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"author": "Hanzo AI <dev@hanzo.ai>"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// @hanzo/ui/data — the metadata-driven record layer.
|
||||
//
|
||||
// RecordsView, DataTable, BoardView, RecordDetail/RecordForm, the typed field
|
||||
// editors (select/relation/calendar/file/JSON/boolean…), the field registry, the
|
||||
// pure sort/filter/search/group/view logic, and the tokens. The source of truth
|
||||
// is @hanzo/data (pkg/data) — this is the canonical @hanzo/ui entry point for it,
|
||||
// so an app depends on ONE package (@hanzo/ui) for both the product layer and the
|
||||
// record layer. Do NOT re-implement anything here; add it upstream in @hanzo/data
|
||||
// and it flows through.
|
||||
//
|
||||
// import { RecordsView, DataTable, registerField } from '@hanzo/ui/data'
|
||||
export * from '@hanzo/data'
|
||||
@@ -0,0 +1,22 @@
|
||||
// @hanzo/ui — the one Hanzo component library, on @hanzo/gui.
|
||||
//
|
||||
// import { PageHeader, Sparkline, EmptyState, tokens } from '@hanzo/ui' // product + tokens
|
||||
// import { RecordsView, DataTable, registerField } from '@hanzo/ui/data' // metadata record layer
|
||||
//
|
||||
// Two orthogonal concerns, ONE package, zero duplication:
|
||||
// • the product/app component layer (this barrel — charts, metrics, headers,
|
||||
// status tags, empty states, combobox, slide-over, toasts, reorder, fields), and
|
||||
// • the metadata-driven record layer (the `./data` subpath — RecordsView and the
|
||||
// typed field editors, whose source of truth is @hanzo/data).
|
||||
// Both cross-platform (web + native + desktop), presentational, host-agnostic
|
||||
// (data + effects injected), clean-room. The two layers each own a `DataTable`
|
||||
// (product = generic typed <T> list; data = field-driven record grid); keeping the
|
||||
// record layer on the `./data` subpath keeps each name unambiguous.
|
||||
|
||||
// Product/app component layer.
|
||||
export * from './product'
|
||||
|
||||
// Design tokens — the calm, dark-first zinc-on-black identity (surface, text,
|
||||
// accent, tag tones). One source of truth (@hanzo/data's theme), surfaced here
|
||||
// for convenience so `import { tokens } from '@hanzo/ui'` works.
|
||||
export { tokens, TAG_TONES, tagTone, type TagTone } from '@hanzo/data'
|
||||
@@ -0,0 +1,13 @@
|
||||
// @hanzo/ui/primitives/bases/data — the Hanzo Base data-app layer.
|
||||
//
|
||||
// Source of truth: the `@hanzo/data` workspace package (~/work/hanzo/hanzoai/ui/
|
||||
// pkg/data). This file is a passthrough re-export so consumers of @hanzo/ui can
|
||||
// pull the metadata-driven record views (RecordsView, DataTable, BoardView,
|
||||
// RecordDetail, field editors) without depending on @hanzo/data directly — the
|
||||
// same convention the `gui` base uses for `hanzogui`. Subpath resolution makes
|
||||
// `@hanzo/ui/primitives/bases/data` equivalent to `@hanzo/data`.
|
||||
//
|
||||
// Do NOT re-implement components here. Add them upstream in pkg/data and let them
|
||||
// flow through this re-export.
|
||||
|
||||
export * from '@hanzo/data'
|
||||
@@ -0,0 +1,471 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Charts — the ONE way the console draws a trend, a series, a share, or a
|
||||
* distribution. Dependency-free: inline SVG for the data-dense temporal charts
|
||||
* (Sparkline / LineChart / BarChart) and @hanzo/gui primitives for the
|
||||
* token-native ones (BarRows, and the Donut's optional legend), so everything
|
||||
* themes to the shell.
|
||||
*
|
||||
* Color is data-driven, not a mode flag: a `Slice` is drawn in its own `color`
|
||||
* when it has one (categorical/polychrome — e.g. spend-by-model) and in a
|
||||
* monochrome opacity ramp off the foreground token when it doesn't (on-brand —
|
||||
* e.g. collections-by-model). One Donut serves both; a consumer that needs a
|
||||
* richer legend (amounts, click targets) leaves `legend` off and composes its own.
|
||||
*
|
||||
* Honest by construction: a Sparkline with fewer than two points renders nothing
|
||||
* (no invented trend); Donut / BarRows with no positive value render an em-dash.
|
||||
* Callers pass REAL series — these never fabricate data.
|
||||
*
|
||||
* SVG marks can't read Tamagui tokens, so concrete colors for SVG come from the
|
||||
* live theme (`useTheme().<token>.get()` → `var(--token)` on web, stays reactive).
|
||||
*/
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Text, XStack, YStack, useTheme } from '@hanzo/gui'
|
||||
|
||||
/** Distinct hues for categorical series (e.g. spend-by-model donut), dark-tuned. */
|
||||
export const CHART_PALETTE = [
|
||||
'#7c5cff',
|
||||
'#23c562',
|
||||
'#ff9f45',
|
||||
'#3aa0ff',
|
||||
'#ff5d8f',
|
||||
'#16c0c8',
|
||||
'#c084fc',
|
||||
'#f4c245',
|
||||
]
|
||||
export const CHART_OTHER = '#64748b'
|
||||
|
||||
const ACCENT = CHART_PALETTE[0]
|
||||
const GRID = 'rgba(148,163,184,0.16)'
|
||||
const AXIS = 'rgba(148,163,184,0.85)'
|
||||
|
||||
/** A point on a temporal/sequential axis (LineChart / BarChart). */
|
||||
export type ChartPoint = { label: string; value: number }
|
||||
|
||||
/** A labelled magnitude in a categorical chart (Donut / BarRows). */
|
||||
export type Slice = { label: string; value: number; color?: string; hint?: string }
|
||||
|
||||
type Themed = ReturnType<typeof useTheme>
|
||||
|
||||
/** Read a concrete color for a theme token (for SVG), with a safe fallback. */
|
||||
const tone = (theme: Themed, key: string, fallback: string): string => {
|
||||
const v = (theme as unknown as Record<string, { get?: () => unknown } | undefined>)[key]
|
||||
const got = v?.get?.()
|
||||
return typeof got === 'string' ? got : fallback
|
||||
}
|
||||
|
||||
/** Opacity ramp so N monochrome slices/areas stay visually distinct. */
|
||||
const rampOpacity = (i: number, n: number): number => (n <= 1 ? 0.9 : 0.95 - (i / (n - 1)) * 0.62)
|
||||
|
||||
function useContainerWidth() {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [w, setW] = useState(0)
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const measure = () => setW(el.clientWidth)
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
return { ref, w }
|
||||
}
|
||||
|
||||
// ── Sparkline (fixed size, for metric cards) ─────────────────────────────────
|
||||
|
||||
export function Sparkline({
|
||||
values,
|
||||
width = 120,
|
||||
height = 36,
|
||||
color,
|
||||
}: {
|
||||
values: number[]
|
||||
width?: number
|
||||
height?: number
|
||||
color?: string
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const clean = (values ?? []).filter((v) => Number.isFinite(v))
|
||||
if (clean.length < 2) return null
|
||||
const stroke = color ?? tone(theme, 'color11', 'currentColor')
|
||||
const max = Math.max(...clean)
|
||||
const min = Math.min(...clean)
|
||||
const span = max - min || 1
|
||||
const n = clean.length
|
||||
const px = (i: number) => (i / (n - 1)) * (width - 2) + 1
|
||||
const py = (v: number) => height - 2 - ((v - min) / span) * (height - 4)
|
||||
const line = clean.map((v, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(v).toFixed(1)}`).join(' ')
|
||||
const area = `${line} L${(width - 1).toFixed(1)},${height} L1,${height} Z`
|
||||
return (
|
||||
<svg width={width} height={height} aria-hidden style={{ display: 'block' }}>
|
||||
<path d={area} fill={stroke} fillOpacity={0.12} />
|
||||
<path d={line} fill="none" stroke={stroke} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shared hover read-out (LineChart / BarChart) ─────────────────────────────
|
||||
|
||||
function Tooltip({ x, height, label, value }: { x: number; height: number; label: string; value: string }) {
|
||||
// Keep the box on-screen near the guide line.
|
||||
const left = Math.max(0, x - 60)
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left,
|
||||
top: 0,
|
||||
height,
|
||||
pointerEvents: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(15,17,21,0.94)',
|
||||
border: '1px solid rgba(148,163,184,0.25)',
|
||||
borderRadius: 8,
|
||||
padding: '6px 9px',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.35,
|
||||
color: '#e2e8f0',
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
|
||||
}}
|
||||
>
|
||||
<div style={{ color: AXIS }}>{label}</div>
|
||||
<div style={{ fontWeight: 700 }}>{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function nearestIndex(clientX: number, rect: DOMRect, padL: number, innerW: number, n: number): number {
|
||||
if (n <= 1) return 0
|
||||
const mx = clientX - rect.left - padL
|
||||
const i = Math.round((mx / (innerW || 1)) * (n - 1))
|
||||
return Math.max(0, Math.min(n - 1, i))
|
||||
}
|
||||
|
||||
/** Pick a few evenly-spaced x labels (first/mid/last) to avoid crowding. */
|
||||
function axisTicks(data: ChartPoint[]): { i: number; label: string; anchor: 'start' | 'middle' | 'end' }[] {
|
||||
const n = data.length
|
||||
if (n === 0) return []
|
||||
if (n === 1) return [{ i: 0, label: data[0].label, anchor: 'middle' }]
|
||||
const mid = Math.floor((n - 1) / 2)
|
||||
const out: { i: number; label: string; anchor: 'start' | 'middle' | 'end' }[] = [
|
||||
{ i: 0, label: data[0].label, anchor: 'start' },
|
||||
{ i: n - 1, label: data[n - 1].label, anchor: 'end' },
|
||||
]
|
||||
if (n >= 5) out.splice(1, 0, { i: mid, label: data[mid].label, anchor: 'middle' })
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Line chart (a value over time) ───────────────────────────────────────────
|
||||
|
||||
export function LineChart({
|
||||
data,
|
||||
height = 210,
|
||||
color = ACCENT,
|
||||
formatValue = (v) => String(v),
|
||||
}: {
|
||||
data: ChartPoint[]
|
||||
height?: number
|
||||
color?: string
|
||||
formatValue?: (v: number) => string
|
||||
}) {
|
||||
const { ref, w } = useContainerWidth()
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const padL = 10
|
||||
const padR = 10
|
||||
const padT = 12
|
||||
const padB = 24
|
||||
const n = data.length
|
||||
const innerW = Math.max(1, w - padL - padR)
|
||||
const innerH = Math.max(1, height - padT - padB)
|
||||
const max = Math.max(...data.map((d) => d.value), 1)
|
||||
const x = (i: number) => padL + (n <= 1 ? innerW / 2 : (i / (n - 1)) * innerW)
|
||||
const y = (v: number) => padT + innerH - (v / max) * innerH
|
||||
|
||||
const line = data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(d.value).toFixed(1)}`).join(' ')
|
||||
const area = n >= 2 ? `${line} L${x(n - 1).toFixed(1)},${(padT + innerH).toFixed(1)} L${x(0).toFixed(1)},${(padT + innerH).toFixed(1)} Z` : ''
|
||||
const ticks = axisTicks(data)
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', width: '100%' }}>
|
||||
{w > 0 ? (
|
||||
<svg
|
||||
width={w}
|
||||
height={height}
|
||||
style={{ display: 'block' }}
|
||||
onMouseMove={(e) => setHover(nearestIndex(e.clientX, e.currentTarget.getBoundingClientRect(), padL, innerW, n))}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
{[0, 0.5, 1].map((g) => (
|
||||
<line key={g} x1={padL} x2={w - padR} y1={padT + innerH * g} y2={padT + innerH * g} stroke={GRID} strokeWidth={1} />
|
||||
))}
|
||||
{area ? <path d={area} fill={color} fillOpacity={0.1} /> : null}
|
||||
{n >= 2 ? <path d={line} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" /> : null}
|
||||
{hover != null && data[hover] ? (
|
||||
<>
|
||||
<line x1={x(hover)} x2={x(hover)} y1={padT} y2={padT + innerH} stroke={AXIS} strokeWidth={1} strokeDasharray="3 3" />
|
||||
<circle cx={x(hover)} cy={y(data[hover].value)} r={4} fill={color} stroke="#0f1115" strokeWidth={2} />
|
||||
</>
|
||||
) : null}
|
||||
{ticks.map((t) => (
|
||||
<text key={t.i} x={x(t.i)} y={height - 7} fontSize={10} fill={AXIS} textAnchor={t.anchor}>
|
||||
{t.label}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
) : null}
|
||||
{hover != null && data[hover] ? (
|
||||
<Tooltip x={x(hover)} height={height} label={data[hover].label} value={formatValue(data[hover].value)} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Bar chart (a value over time, vertical bars) ─────────────────────────────
|
||||
|
||||
export function BarChart({
|
||||
data,
|
||||
height = 210,
|
||||
color = ACCENT,
|
||||
formatValue = (v) => String(v),
|
||||
}: {
|
||||
data: ChartPoint[]
|
||||
height?: number
|
||||
color?: string
|
||||
formatValue?: (v: number) => string
|
||||
}) {
|
||||
const { ref, w } = useContainerWidth()
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const padL = 10
|
||||
const padR = 10
|
||||
const padT = 12
|
||||
const padB = 24
|
||||
const n = data.length
|
||||
const innerW = Math.max(1, w - padL - padR)
|
||||
const innerH = Math.max(1, height - padT - padB)
|
||||
const max = Math.max(...data.map((d) => d.value), 1)
|
||||
const slot = innerW / Math.max(1, n)
|
||||
const barW = Math.max(1, Math.min(slot * 0.62, 36))
|
||||
const cx = (i: number) => padL + slot * i + slot / 2
|
||||
const ticks = axisTicks(data)
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', width: '100%' }}>
|
||||
{w > 0 ? (
|
||||
<svg
|
||||
width={w}
|
||||
height={height}
|
||||
style={{ display: 'block' }}
|
||||
onMouseMove={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const i = Math.floor((e.clientX - rect.left - padL) / (slot || 1))
|
||||
setHover(Math.max(0, Math.min(n - 1, i)))
|
||||
}}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
{[0, 0.5, 1].map((g) => (
|
||||
<line key={g} x1={padL} x2={w - padR} y1={padT + innerH * g} y2={padT + innerH * g} stroke={GRID} strokeWidth={1} />
|
||||
))}
|
||||
{data.map((d, i) => {
|
||||
const h = (d.value / max) * innerH
|
||||
return (
|
||||
<rect
|
||||
key={i}
|
||||
x={cx(i) - barW / 2}
|
||||
y={padT + innerH - h}
|
||||
width={barW}
|
||||
height={Math.max(0, h)}
|
||||
rx={Math.min(3, barW / 2)}
|
||||
fill={color}
|
||||
fillOpacity={hover === i ? 1 : 0.78}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{ticks.map((t) => (
|
||||
<text key={t.i} x={cx(t.i)} y={height - 7} fontSize={10} fill={AXIS} textAnchor="middle">
|
||||
{t.label}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
) : null}
|
||||
{hover != null && data[hover] ? (
|
||||
<Tooltip x={cx(hover)} height={height} label={data[hover].label} value={formatValue(data[hover].value)} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Donut (categorical share) ────────────────────────────────────────────────
|
||||
|
||||
export function Donut({
|
||||
slices,
|
||||
size = 160,
|
||||
thickness = 22,
|
||||
center,
|
||||
legend = false,
|
||||
}: {
|
||||
slices: Slice[]
|
||||
size?: number
|
||||
thickness?: number
|
||||
/** Rendered, centered, inside the ring (a number, a label+sub, anything). */
|
||||
center?: ReactNode
|
||||
/**
|
||||
* Render a built-in label/percentage legend beside the ring. Off by default so
|
||||
* a consumer can compose a richer legend (amounts, click targets) itself.
|
||||
*/
|
||||
legend?: boolean
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const fg = tone(theme, 'color12', 'currentColor')
|
||||
const positive = slices.filter((s) => s.value > 0)
|
||||
const total = positive.reduce((sum, s) => sum + s.value, 0)
|
||||
const r = size / 2
|
||||
const rInner = r - thickness
|
||||
const mid = (r + rInner) / 2
|
||||
|
||||
if (total <= 0) {
|
||||
return (
|
||||
<XStack items="center" justify="center" height={size}>
|
||||
<Text fontSize="$6" color="$color10">
|
||||
—
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
const ring = (
|
||||
<div style={{ position: 'relative', width: size, height: size }}>
|
||||
<svg width={size} height={size} style={{ display: 'block' }}>
|
||||
{positive.length === 1 ? (
|
||||
<circle
|
||||
cx={r}
|
||||
cy={r}
|
||||
r={mid}
|
||||
fill="none"
|
||||
stroke={positive[0].color ?? fg}
|
||||
strokeOpacity={positive[0].color ? 1 : 0.9}
|
||||
strokeWidth={thickness}
|
||||
/>
|
||||
) : (
|
||||
renderArcs(positive, total, r, rInner, fg)
|
||||
)}
|
||||
</svg>
|
||||
{center ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{center}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!legend) return ring
|
||||
|
||||
return (
|
||||
<XStack gap="$4" items="center" flexWrap="wrap">
|
||||
{ring}
|
||||
<YStack gap="$1.5" flex={1} minW={150}>
|
||||
{positive.map((s, i) => (
|
||||
<XStack key={s.label} gap="$2" items="center">
|
||||
<div
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 2,
|
||||
backgroundColor: s.color ?? AXIS,
|
||||
opacity: s.color ? 1 : rampOpacity(i, positive.length),
|
||||
}}
|
||||
/>
|
||||
<Text fontSize="$2" color="$color11" flex={1} numberOfLines={1}>
|
||||
{s.label}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color12" fontWeight="600">
|
||||
{Math.round((s.value / total) * 100)}%
|
||||
</Text>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
function renderArcs(slices: Slice[], total: number, rOuter: number, rInner: number, fg: string): ReactNode {
|
||||
let a0 = -Math.PI / 2
|
||||
const paths: ReactNode[] = []
|
||||
slices.forEach((s, idx) => {
|
||||
const frac = Math.max(0, s.value) / total
|
||||
if (frac <= 0) return
|
||||
const a1 = a0 + frac * Math.PI * 2
|
||||
paths.push(
|
||||
<path
|
||||
key={s.label || idx}
|
||||
d={annularArc(rOuter, rOuter, rOuter, rInner, a0, Math.min(a1, a0 + Math.PI * 1.9999))}
|
||||
fill={s.color ?? fg}
|
||||
fillOpacity={s.color ? 1 : rampOpacity(idx, slices.length)}
|
||||
/>,
|
||||
)
|
||||
a0 = a1
|
||||
})
|
||||
return <>{paths}</>
|
||||
}
|
||||
|
||||
function polar(cx: number, cy: number, rad: number, a: number): [number, number] {
|
||||
return [cx + rad * Math.cos(a), cy + rad * Math.sin(a)]
|
||||
}
|
||||
|
||||
function annularArc(cx: number, cy: number, rOuter: number, rInner: number, a0: number, a1: number): string {
|
||||
const large = a1 - a0 > Math.PI ? 1 : 0
|
||||
const [x0, y0] = polar(cx, cy, rOuter, a0)
|
||||
const [x1, y1] = polar(cx, cy, rOuter, a1)
|
||||
const [x2, y2] = polar(cx, cy, rInner, a1)
|
||||
const [x3, y3] = polar(cx, cy, rInner, a0)
|
||||
return `M${x0},${y0} A${rOuter},${rOuter} 0 ${large} 1 ${x1},${y1} L${x2},${y2} A${rInner},${rInner} 0 ${large} 0 ${x3},${y3} Z`
|
||||
}
|
||||
|
||||
// ── Bar rows (a ranked distribution, horizontal, token-native) ───────────────
|
||||
|
||||
export function BarRows({ bars }: { bars: Slice[] }) {
|
||||
const max = Math.max(0, ...bars.map((b) => b.value))
|
||||
if (max <= 0) {
|
||||
return (
|
||||
<Text fontSize="$3" color="$color10">
|
||||
—
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<YStack gap="$2.5">
|
||||
{bars.map((b) => (
|
||||
<XStack key={b.label} gap="$3" items="center">
|
||||
<Text width={64} fontSize="$2" color="$color11" text="right">
|
||||
{b.label}
|
||||
</Text>
|
||||
<YStack flex={1} height={10} bg="$color3" rounded="$2" overflow="hidden">
|
||||
<YStack height={10} width={`${Math.max(2, (b.value / max) * 100)}%`} bg="$color9" rounded="$2" />
|
||||
</YStack>
|
||||
<Text width={56} fontSize="$2" color="$color12" fontWeight="600">
|
||||
{b.value.toLocaleString()}
|
||||
</Text>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ComboBox — a typeable select: a text input the user can type any value into,
|
||||
* PLUS a popover of LIVE options (filtered by what's typed) they can pick from.
|
||||
* The value is always exactly the input text, so a custom id is inherently
|
||||
* supported — selecting an option just fills the input. This is the ONE way the
|
||||
* console offers "pick from a live list OR type your own" (the model field, the
|
||||
* tool field). Prop-driven + self-contained (options/loading/error injected by the
|
||||
* caller), so it is orthogonal to the data source and lifts into `@hanzo/ui`.
|
||||
*
|
||||
* Idiom: the same @hanzo/gui Popover shell as OrgSwitcher/SelectMenu (bordered,
|
||||
* elevate, `$color2`). Options render in a portal above the DetailPane SlideOver.
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button, Input, Popover, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Check, ChevronDown, RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { filterOptions, type ComboOption } from './combobox/filter'
|
||||
|
||||
export type { ComboOption } from './combobox/filter'
|
||||
|
||||
export function ComboBox({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
loading = false,
|
||||
error = null,
|
||||
onRetry,
|
||||
placeholder,
|
||||
disabled,
|
||||
emptyText = 'No matches — press to use what you typed.',
|
||||
minWidth = 240,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
/** The live option list. Filtered against the typed text. */
|
||||
options: ComboOption[]
|
||||
/** True while the caller is loading the option list (shows a spinner row). */
|
||||
loading?: boolean
|
||||
/** A human message when the option list failed to load (list still typeable). */
|
||||
error?: string | null
|
||||
/** Retry the option load (shown next to the error). */
|
||||
onRetry?: () => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
/** Row shown when the filter matches nothing (the typed value is still usable). */
|
||||
emptyText?: string
|
||||
minWidth?: number
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const filtered = useMemo(() => filterOptions(options, value), [options, value])
|
||||
|
||||
const pick = (v: string) => {
|
||||
onChange(v)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} placement="bottom-start" allowFlip>
|
||||
<XStack items="center" gap="$2" minW={minWidth}>
|
||||
<Input
|
||||
flex={1}
|
||||
value={value}
|
||||
onChangeText={(v) => {
|
||||
onChange(v)
|
||||
if (!open) setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
disabled={disabled}
|
||||
icon={<ChevronDown size={16} opacity={0.7} />}
|
||||
onPress={() => setOpen((o) => !o)}
|
||||
aria-label="Show options"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
</XStack>
|
||||
|
||||
<Popover.Content bordered elevate p="$1.5" minW={minWidth} bg="$color2" borderColor="$borderColor">
|
||||
<YStack gap="$0.5" minW={minWidth} maxH={300} overflow="scroll">
|
||||
{loading ? (
|
||||
<XStack items="center" gap="$2" px="$2.5" py="$2">
|
||||
<Spinner size="small" color="$color11" />
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Loading options…
|
||||
</Text>
|
||||
</XStack>
|
||||
) : error ? (
|
||||
<XStack items="center" gap="$2" px="$2.5" py="$2">
|
||||
<Text fontSize="$2" color="$color10" flex={1} numberOfLines={2}>
|
||||
{error}
|
||||
</Text>
|
||||
{onRetry ? (
|
||||
<Button size="$1" chromeless icon={<RefreshCw size={12} />} onPress={onRetry} aria-label="Retry" />
|
||||
) : null}
|
||||
</XStack>
|
||||
) : filtered.length === 0 ? (
|
||||
<Text fontSize="$2" color="$color10" px="$2.5" py="$2">
|
||||
{emptyText}
|
||||
</Text>
|
||||
) : (
|
||||
filtered.map((o) => (
|
||||
<Row key={o.value} option={o} active={o.value === value} onPress={() => pick(o.value)} />
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ option, active, onPress }: { option: ComboOption; active: boolean; onPress: () => void }) {
|
||||
return (
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$2.5"
|
||||
py="$1.5"
|
||||
rounded="$3"
|
||||
cursor="pointer"
|
||||
hoverStyle={{ bg: '$color4' }}
|
||||
bg={active ? '$color4' : 'transparent'}
|
||||
onPress={onPress}
|
||||
>
|
||||
<XStack width={14} items="center" justify="center">
|
||||
{active ? <Check size={13} /> : null}
|
||||
</XStack>
|
||||
<YStack flex={1} minW={0}>
|
||||
<Text fontSize="$2" color="$color12" numberOfLines={1}>
|
||||
{option.label ?? option.value}
|
||||
</Text>
|
||||
{option.hint ? (
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{option.hint}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Generic data table — the shared list primitive for every product module.
|
||||
*
|
||||
* Rows are typed `<T>`; columns declare a key, header, and optional cell
|
||||
* renderer. Built on Hanzo GUI Stacks (shorthand style props per the v5 config)
|
||||
* so it adapts across platforms. Loading and empty states are first-class.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
export type Column<T> = {
|
||||
key: string
|
||||
header: string
|
||||
/** Cell renderer; defaults to `String(row[key])`. */
|
||||
render?: (row: T) => ReactNode
|
||||
/** Fixed width (px) for the column; otherwise flexes. */
|
||||
width?: number
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
rows,
|
||||
loading,
|
||||
empty = 'Nothing here yet.',
|
||||
rowKey,
|
||||
onRowPress,
|
||||
}: {
|
||||
columns: Column<T>[]
|
||||
rows: T[]
|
||||
loading?: boolean
|
||||
empty?: string
|
||||
rowKey: (row: T) => string
|
||||
onRowPress?: (row: T) => void
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<XStack p="$6" justify="center">
|
||||
<Spinner size="large" color="$color11" />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<YStack p="$6" items="center" borderWidth={1} borderColor="$borderColor" rounded="$4">
|
||||
<Text color="$color11">{empty}</Text>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden">
|
||||
<XStack bg="$color2" py="$2.5" px="$3" gap="$3">
|
||||
{columns.map((c) => (
|
||||
<Text
|
||||
key={c.key}
|
||||
width={c.width}
|
||||
flex={c.width ? undefined : 1}
|
||||
fontSize="$2"
|
||||
fontWeight="700"
|
||||
color="$color11"
|
||||
>
|
||||
{c.header}
|
||||
</Text>
|
||||
))}
|
||||
</XStack>
|
||||
<YStack>
|
||||
{rows.map((row) => (
|
||||
<XStack
|
||||
key={rowKey(row)}
|
||||
py="$2.5"
|
||||
px="$3"
|
||||
gap="$3"
|
||||
borderTopWidth={1}
|
||||
borderColor="$borderColor"
|
||||
items="center"
|
||||
hoverStyle={onRowPress ? { bg: '$color2' } : undefined}
|
||||
cursor={onRowPress ? 'pointer' : undefined}
|
||||
onPress={onRowPress ? () => onRowPress(row) : undefined}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<YStack key={c.key} width={c.width} flex={c.width ? undefined : 1}>
|
||||
{c.render ? (
|
||||
c.render(row)
|
||||
) : (
|
||||
<Text fontSize="$3" numberOfLines={1}>
|
||||
{String((row as Record<string, unknown>)[c.key] ?? '')}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
))}
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Donut — a tiny dependency-free SVG ring for a categorical breakdown
|
||||
* (e.g. machines by status). Standard library first: no chart dependency for a
|
||||
* single ring. It renders ONLY the real segments it is given and returns `null`
|
||||
* when the total is zero, so the caller shows an honest empty state instead of an
|
||||
* empty circle. Purely presentational — all values come from the caller.
|
||||
*/
|
||||
export type DonutSegment = { label: string; value: number; color: string }
|
||||
|
||||
export function Donut({
|
||||
segments,
|
||||
size = 160,
|
||||
thickness = 18,
|
||||
centerValue,
|
||||
centerLabel,
|
||||
trackColor = '#2a2d2e',
|
||||
valueColor = '#ecedee',
|
||||
labelColor = '#9ba1a6',
|
||||
}: {
|
||||
segments: DonutSegment[]
|
||||
size?: number
|
||||
thickness?: number
|
||||
/** Big number in the middle (e.g. total). */
|
||||
centerValue?: string
|
||||
/** Caption under the center value. */
|
||||
centerLabel?: string
|
||||
/** Unfilled-ring color (explicit hex so it never depends on theme var names). */
|
||||
trackColor?: string
|
||||
valueColor?: string
|
||||
labelColor?: string
|
||||
}) {
|
||||
const total = segments.reduce((n, s) => n + Math.max(0, s.value), 0)
|
||||
if (total <= 0) return null
|
||||
|
||||
const r = (size - thickness) / 2
|
||||
const cx = size / 2
|
||||
const cy = size / 2
|
||||
const circ = 2 * Math.PI * r
|
||||
|
||||
let offset = 0
|
||||
const arcs = segments
|
||||
.filter((s) => s.value > 0)
|
||||
.map((s) => {
|
||||
const frac = s.value / total
|
||||
const dash = frac * circ
|
||||
const node = (
|
||||
<circle
|
||||
key={s.label}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth={thickness}
|
||||
strokeDasharray={`${dash} ${circ - dash}`}
|
||||
strokeDashoffset={-offset}
|
||||
/>
|
||||
)
|
||||
offset += dash
|
||||
return node
|
||||
})
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} role="img" aria-label="Donut chart">
|
||||
{/* track */}
|
||||
<circle cx={cx} cy={cy} r={r} fill="none" stroke={trackColor} strokeWidth={thickness} />
|
||||
{/* segments, drawn from 12 o'clock */}
|
||||
<g transform={`rotate(-90 ${cx} ${cy})`}>{arcs}</g>
|
||||
{centerValue ? (
|
||||
<text x={cx} y={cy - 2} textAnchor="middle" fontSize={size * 0.2} fontWeight="800" fill={valueColor}>
|
||||
{centerValue}
|
||||
</text>
|
||||
) : null}
|
||||
{centerLabel ? (
|
||||
<text x={cx} y={cy + size * 0.13} textAnchor="middle" fontSize={size * 0.08} fill={labelColor}>
|
||||
{centerLabel}
|
||||
</text>
|
||||
) : null}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Rich empty state — the ONE way the console turns a real, honest "nothing here
|
||||
* yet" into a helpful onboarding moment (DigitalOcean / Vercel class) instead of
|
||||
* a dead screen.
|
||||
*
|
||||
* It is NOT a fabricated-data fallback: callers render it only when a load
|
||||
* genuinely succeeded with zero rows (or a resource has no surface yet). It shows
|
||||
* the product icon, a one-line "what this is", a couple of guidance bullets, a
|
||||
* single primary call-to-action (Deploy / Create / Connect …) and an optional
|
||||
* secondary link (Docs / CLI). Orthogonal to `BackendStateCard` (failures) and
|
||||
* `DataTable`'s thin inline empty (in-table) — this is the full-bleed first-run
|
||||
* surface a module shows in place of its table.
|
||||
*/
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ArrowRight, Check, ExternalLink } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import type { IconLike } from './color'
|
||||
import { PrimaryButton } from './PrimaryButton'
|
||||
|
||||
/** A single call-to-action — primary (filled) or a secondary/external link. */
|
||||
export type EmptyAction = {
|
||||
label: string
|
||||
/** In-app navigation / handler. */
|
||||
onPress?: () => void
|
||||
/** External URL — opens in a new tab with an external-link affordance. */
|
||||
href?: string
|
||||
icon?: ReactElement
|
||||
}
|
||||
|
||||
/** Open an external URL in a new tab (no-opener), guarded for SSR. */
|
||||
function openHref(href: string): void {
|
||||
if (typeof window !== 'undefined') window.open(href, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
bullets,
|
||||
primary,
|
||||
secondary,
|
||||
}: {
|
||||
/** Product/section icon — any lucide/Gui icon component (size + optional color). */
|
||||
icon: IconLike
|
||||
title: string
|
||||
description: string
|
||||
/** Optional "here's how to get started" guidance bullets. */
|
||||
bullets?: string[]
|
||||
/** The single high-emphasis action (Deploy / Create / Connect). */
|
||||
primary?: EmptyAction
|
||||
/** An optional lower-emphasis action (Docs / CLI / Learn more). */
|
||||
secondary?: EmptyAction
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
borderStyle="dashed"
|
||||
p="$6"
|
||||
gap="$4"
|
||||
items="center"
|
||||
maxWidth={640}
|
||||
self="center"
|
||||
width="100%"
|
||||
>
|
||||
<YStack
|
||||
width={48}
|
||||
height={48}
|
||||
items="center"
|
||||
justify="center"
|
||||
rounded="$4"
|
||||
bg="$color3"
|
||||
>
|
||||
<Icon size={24} />
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2" items="center" maxW={480}>
|
||||
<Text fontSize="$6" fontWeight="800" text="center">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11" text="center">
|
||||
{description}
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
{bullets && bullets.length ? (
|
||||
<YStack gap="$2" self="stretch" maxW={420} mx="auto">
|
||||
{bullets.map((b) => (
|
||||
<XStack key={b} gap="$2" items="flex-start">
|
||||
<YStack pt="$1">
|
||||
<Check size={14} color="$color10" />
|
||||
</YStack>
|
||||
<Text fontSize="$3" color="$color11" flex={1}>
|
||||
{b}
|
||||
</Text>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{primary || secondary ? (
|
||||
<XStack gap="$2" items="center" flexWrap="wrap" justify="center">
|
||||
{primary ? (
|
||||
<PrimaryButton
|
||||
icon={primary.icon}
|
||||
iconAfter={primary.href ? <ExternalLink size={15} /> : <ArrowRight size={15} />}
|
||||
onPress={() => (primary.href ? openHref(primary.href) : primary.onPress?.())}
|
||||
>
|
||||
{primary.label}
|
||||
</PrimaryButton>
|
||||
) : null}
|
||||
{secondary ? (
|
||||
<Button
|
||||
chromeless
|
||||
icon={secondary.icon}
|
||||
iconAfter={secondary.href ? <ExternalLink size={14} /> : undefined}
|
||||
onPress={() => (secondary.href ? openHref(secondary.href) : secondary.onPress?.())}
|
||||
>
|
||||
{secondary.label}
|
||||
</Button>
|
||||
) : null}
|
||||
</XStack>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* FadeIn — the ONE entrance animation for the console.
|
||||
*
|
||||
* A thin wrapper that applies the shared `.hz-fade-up` keyframe (defined once in
|
||||
* globals.css) plus an optional stagger delay, so sections and cards rise in the
|
||||
* way the hanzo.ai marketing surface does. Framework-free (no animation driver,
|
||||
* no JS observers) so it works under static export and honors
|
||||
* `prefers-reduced-motion` (handled in the CSS). Pass `index` to stagger a list
|
||||
* (delay = index * step), or `delayMs` for an explicit delay.
|
||||
*/
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
|
||||
export function FadeIn({
|
||||
children,
|
||||
index = 0,
|
||||
step = 50,
|
||||
delayMs,
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode
|
||||
/** Position in a list — staggers the entrance by `index * step` ms. */
|
||||
index?: number
|
||||
/** Per-index stagger in ms (default 50). */
|
||||
step?: number
|
||||
/** Explicit delay in ms; overrides the index-based stagger. */
|
||||
delayMs?: number
|
||||
/** Extra styles for the wrapper (e.g. layout/centering). */
|
||||
style?: CSSProperties
|
||||
}) {
|
||||
const delay = delayMs ?? index * step
|
||||
return (
|
||||
<div className="hz-fade-up" style={{ animationDelay: `${delay}ms`, ...style }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Form field primitives — labeled rows over Hanzo GUI inputs.
|
||||
*
|
||||
* One way to render a labeled control. Every editor field goes through these, so
|
||||
* spacing, disabled styling, and the label column stay consistent. Style props
|
||||
* use the v5 shorthand set (p/px/items/justify/...).
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
Switch,
|
||||
TextArea,
|
||||
Slider,
|
||||
Text,
|
||||
XStack,
|
||||
YStack,
|
||||
} from '@hanzo/gui'
|
||||
import { ChevronDown } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
/** Labeled row: fixed label column + flexing control. */
|
||||
export function FieldRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<XStack gap="$3" items="flex-start" flexWrap="wrap">
|
||||
<Label width={180} pt="$2" color="$color11" fontSize="$3">
|
||||
{label}
|
||||
</Label>
|
||||
<YStack flex={1} minW={240}>
|
||||
{children}
|
||||
</YStack>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function FieldText({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
secure,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
disabled?: boolean
|
||||
secure?: boolean
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
disabled={disabled}
|
||||
secureTextEntry={secure}
|
||||
placeholder={placeholder}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function FieldTextArea({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
rows = 6,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
disabled?: boolean
|
||||
rows?: number
|
||||
}) {
|
||||
return (
|
||||
<TextArea value={value} onChangeText={onChange} disabled={disabled} numberOfLines={rows} />
|
||||
)
|
||||
}
|
||||
|
||||
export function FieldSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Switch checked={checked} onCheckedChange={onChange} disabled={disabled} size="$3">
|
||||
<Switch.Thumb />
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
export function FieldSelect({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (v: string) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange} disablePreventBodyScroll native>
|
||||
<Select.Trigger disabled={disabled} iconAfter={<ChevronDown size={16} />}>
|
||||
<Select.Value placeholder="Select…" />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Viewport>
|
||||
{options.map((opt, i) => (
|
||||
<Select.Item key={opt} index={i} value={opt}>
|
||||
<Select.ItemText>{opt}</Select.ItemText>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export function FieldSlider({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
onChange: (v: number) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<XStack gap="$3" items="center">
|
||||
<Text width={56} fontSize="$3" color="$color11">
|
||||
{value}
|
||||
</Text>
|
||||
<Slider
|
||||
flex={1}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={[value]}
|
||||
onValueChange={(v) => onChange(v[0] ?? min)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Slider.Track>
|
||||
<Slider.TrackActive />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb index={0} circular />
|
||||
</Slider>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* The Hanzo "H" mark — the canonical brand glyph (same geometry as app/icon.svg).
|
||||
*
|
||||
* Rendered as inline SVG with `fill: currentColor`, so it inherits the
|
||||
* surrounding text color and adapts to the dark/light theme with no per-theme
|
||||
* asset. ONE source for the mark across the chrome (sidebar + header).
|
||||
*/
|
||||
export function HanzoMark({ size = 22, color = 'currentColor' }: { size?: number; color?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 67 67"
|
||||
role="img"
|
||||
aria-label="Hanzo"
|
||||
fill={color}
|
||||
style={{ display: 'block', flexShrink: 0 }}
|
||||
>
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" />
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" />
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" />
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" />
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Console metric primitives — the ONE shared set of stat tiles, panels, legend
|
||||
* rows, util bars, and "real action or honest-disabled" buttons that every
|
||||
* resource console (GPUs, Kubernetes, Containers, Training, Tasks) renders.
|
||||
*
|
||||
* These were first written for the GPU surface; promoted here so a sibling
|
||||
* product never reaches into `gpus/` for shell chrome (separation of concerns).
|
||||
* `gpus/charts.tsx` now re-exports them, so there is exactly one definition.
|
||||
*
|
||||
* Honest by construction: a `MetricCard` shows the pre-formatted `value` the
|
||||
* caller passes (an absent metric is an em-dash, never a fabricated number); a
|
||||
* `Sparkline`/`MiniBars` with no real series renders nothing; a `HintButton`
|
||||
* with no `onPress` is visibly disabled with a tooltip saying WHY. Inline SVG is
|
||||
* the house pattern (see ui/Loader, ui/HanzoMark); style props use the v5
|
||||
* shorthand set.
|
||||
*/
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
/** Distinct, theme-neutral series colors (mid-saturation reads on dark + light). */
|
||||
export const SERIES = ['#6ea8fe', '#7ee787', '#f0a868', '#c792ea', '#56d4c4', '#e879a6', '#d6c15a', '#8b9bb4'] as const
|
||||
export const colorForIndex = (i: number): string => SERIES[i % SERIES.length]
|
||||
const TRACK = 'rgba(128,128,128,0.18)'
|
||||
|
||||
/** Tone for a utilization/temperature value (green calm → red hot). */
|
||||
export function utilColor(pct: number): string {
|
||||
if (pct >= 90) return '#e5534b'
|
||||
if (pct >= 70) return '#f0a868'
|
||||
return '#7ee787'
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-series sparkline over real points. Renders nothing meaningful for <2
|
||||
* points (the caller shows `—` instead). The y-range is the data's own min/max.
|
||||
*/
|
||||
export function Sparkline({
|
||||
points,
|
||||
width = 104,
|
||||
height = 30,
|
||||
color = SERIES[0],
|
||||
}: {
|
||||
points: number[]
|
||||
width?: number
|
||||
height?: number
|
||||
color?: string
|
||||
}): ReactElement | null {
|
||||
if (!points || points.length < 2) return null
|
||||
const min = Math.min(...points)
|
||||
const max = Math.max(...points)
|
||||
const span = max - min || 1
|
||||
const stepX = width / (points.length - 1)
|
||||
const y = (v: number) => height - 2 - ((v - min) / span) * (height - 4)
|
||||
const d = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${(i * stepX).toFixed(1)},${y(p).toFixed(1)}`).join(' ')
|
||||
return (
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label="trend">
|
||||
<path d={d} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** A small vertical-bar chart (e.g. cost per day). One bar per real datum. */
|
||||
export function MiniBars({
|
||||
bars,
|
||||
width = 320,
|
||||
height = 96,
|
||||
color = SERIES[0],
|
||||
}: {
|
||||
bars: { label: string; value: number }[]
|
||||
width?: number
|
||||
height?: number
|
||||
color?: string
|
||||
}): ReactElement | null {
|
||||
if (!bars.length) return null
|
||||
const max = Math.max(...bars.map((b) => b.value), 1)
|
||||
const gap = 4
|
||||
const bw = (width - gap * (bars.length - 1)) / bars.length
|
||||
return (
|
||||
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" role="img" aria-label="bars">
|
||||
{bars.map((b, i) => {
|
||||
const h = Math.max(1, (b.value / max) * (height - 2))
|
||||
return (
|
||||
<rect key={b.label + i} x={i * (bw + gap)} y={height - h} width={bw} height={h} rx={2} fill={color} opacity={0.85}>
|
||||
<title>{`${b.label}: ${b.value}`}</title>
|
||||
</rect>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A thin horizontal bar (0–100). By default the fill tones by value (green→red,
|
||||
* for utilization/temperature). Pass a fixed `color` for a neutral meter like
|
||||
* training PROGRESS, where a high value is good, not "hot".
|
||||
*/
|
||||
export function UtilBar({ value, width = 120, color }: { value: number; width?: number; color?: string }): ReactElement {
|
||||
const v = Math.max(0, Math.min(100, value))
|
||||
return (
|
||||
<svg width={width} height={8} viewBox={`0 0 ${width} 8`} role="img" aria-label={`${Math.round(v)}%`}>
|
||||
<rect x={0} y={1} width={width} height={6} rx={3} fill={TRACK} />
|
||||
<rect x={0} y={1} width={(v / 100) * width} height={6} rx={3} fill={color ?? utilColor(v)} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** A legend row: color swatch + label + value. */
|
||||
export function LegendDot({ color, label, value }: { color: string; label: string; value?: ReactNode }) {
|
||||
return (
|
||||
<XStack items="center" gap="$2" justify="space-between">
|
||||
<XStack items="center" gap="$2">
|
||||
<YStack width={10} height={10} rounded="$1" bg={color as never} />
|
||||
<Text fontSize="$2" color="$color11">
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
{value != null ? (
|
||||
<Text fontSize="$2" color="$color12" fontWeight="600">
|
||||
{value}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One overview metric tile. `value` is pre-formatted by the caller (so an absent
|
||||
* metric is an honest `—`, never a fabricated number). The sparkline only appears
|
||||
* when the caller has a REAL series; the delta only when a real comparison exists.
|
||||
*/
|
||||
export function MetricCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
caption,
|
||||
spark,
|
||||
sparkColor,
|
||||
delta,
|
||||
}: {
|
||||
icon: ReactElement
|
||||
label: string
|
||||
value: string
|
||||
caption?: string
|
||||
spark?: number[]
|
||||
sparkColor?: string
|
||||
delta?: { pct: number }
|
||||
}) {
|
||||
const up = delta ? delta.pct >= 0 : false
|
||||
return (
|
||||
<Card p="$3.5" gap="$2" borderWidth={1} borderColor="$borderColor" flex={1} minW={172}>
|
||||
<XStack items="center" gap="$2" justify="space-between">
|
||||
<XStack items="center" gap="$2">
|
||||
{icon}
|
||||
<Text fontSize="$2" color="$color11" fontWeight="600">
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
{delta ? (
|
||||
<Text fontSize="$1" color={up ? '#7ee787' : '#e5534b'}>
|
||||
{up ? '▲' : '▼'} {Math.abs(delta.pct)}%
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
<XStack items="flex-end" justify="space-between" gap="$2">
|
||||
<Text fontSize="$8" fontWeight="900" color="$color12" numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
{spark && spark.length >= 2 ? <Sparkline points={spark} color={sparkColor ?? SERIES[0]} /> : null}
|
||||
</XStack>
|
||||
{caption ? (
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{caption}
|
||||
</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A button that is either a real action (onPress) or HONESTLY disabled with a
|
||||
* native tooltip explaining why (the action has no backend yet). One way to
|
||||
* render the "real route or honest-disabled" affordance the consoles use.
|
||||
*/
|
||||
export function HintButton({
|
||||
icon,
|
||||
iconAfter,
|
||||
children,
|
||||
onPress,
|
||||
disabled,
|
||||
hint,
|
||||
theme,
|
||||
}: {
|
||||
icon?: ReactElement
|
||||
iconAfter?: ReactElement
|
||||
children: ReactNode
|
||||
onPress?: () => void
|
||||
disabled?: boolean
|
||||
/** Native tooltip text (shown on hover) — say WHY it is disabled. */
|
||||
hint?: string
|
||||
theme?: 'light'
|
||||
}) {
|
||||
const btn = (
|
||||
<Button
|
||||
size="$2"
|
||||
theme={theme}
|
||||
icon={icon}
|
||||
iconAfter={iconAfter}
|
||||
disabled={disabled}
|
||||
onPress={disabled ? undefined : onPress}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
if (!hint) return btn
|
||||
return (
|
||||
<span title={hint} style={{ display: 'inline-flex' }}>
|
||||
{btn}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A titled panel wrapper used across the consoles (chart + list cards). `grow`
|
||||
* (default true) sets `flex={1}` so panels SHARE width in a ROW; pass `grow={false}`
|
||||
* inside a vertical rail (a column YStack) so each panel sizes to its content and
|
||||
* stacks — flex-grow in a column with no fixed height collapses them onto each other.
|
||||
*/
|
||||
export function Panel({
|
||||
title,
|
||||
right,
|
||||
children,
|
||||
minW = 280,
|
||||
grow = true,
|
||||
}: {
|
||||
title: string
|
||||
right?: ReactNode
|
||||
children: ReactNode
|
||||
minW?: number
|
||||
grow?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" flex={grow ? 1 : undefined} minW={minW} width={grow ? undefined : '100%'}>
|
||||
<XStack items="center" justify="space-between" gap="$2">
|
||||
<Text fontSize="$4" fontWeight="800" color="$color12">
|
||||
{title}
|
||||
</Text>
|
||||
{right}
|
||||
</XStack>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
/** Section title + optional subtitle and right-aligned actions. */
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<XStack justify="space-between" items="flex-start" gap="$4">
|
||||
<YStack gap="$1">
|
||||
<Text fontSize="$7" fontWeight="800">
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
{actions ? <XStack gap="$2">{actions}</XStack> : null}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Primary button — the one white, high-emphasis action for the console.
|
||||
*
|
||||
* Monochrome brand: `theme="light"` flips the button to the light theme inside
|
||||
* the dark console, giving a white fill with a near-black label and icon — no
|
||||
* hue accent. Use it for the single primary action in a view (sign in, save,
|
||||
* get started). Secondary and destructive actions use the default neutral
|
||||
* `Button`.
|
||||
*/
|
||||
import type { ComponentProps } from 'react'
|
||||
import { Button } from '@hanzo/gui'
|
||||
|
||||
export function PrimaryButton(props: ComponentProps<typeof Button>) {
|
||||
return <Button theme="light" {...props} />
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ProductIcon — the Linear-style product tile: a rounded-square block filled with
|
||||
* the product's accent color, with the product glyph knocked out in near-white so
|
||||
* it reads "cut out" of the color. This is the ONE way a product icon renders in
|
||||
* the console chrome (sidebar nav, command palette, product overview header) — a
|
||||
* bare tinted glyph is replaced everywhere by this tile.
|
||||
*
|
||||
* Prop-driven and pure: the accent hex is resolved by the caller (registry-curated
|
||||
* → per-user override → stable hash, via `useProductColors().colorOf`) and passed
|
||||
* in as `color`, so this component owns only the look, never the palette. With no
|
||||
* color it falls back to a tasteful neutral chip ($color12 fill + $color1 glyph) —
|
||||
* never a random hue — matching the first-party `ProviderLogo` mark, so first-party
|
||||
* marks and product tiles read as one family.
|
||||
*
|
||||
* The knock-out is the same technique the color-swatch button uses (a raw-hex
|
||||
* `backgroundColor` fill + a `#ffffff` glyph) so an arbitrary product accent tints
|
||||
* cleanly outside Gui's token palette, in the ONE sanctioned place.
|
||||
*/
|
||||
import { XStack } from '@hanzo/gui'
|
||||
|
||||
import { asColor, type IconLike } from './color'
|
||||
|
||||
export function ProductIcon({
|
||||
icon: Icon,
|
||||
color,
|
||||
size = 24,
|
||||
}: {
|
||||
icon: IconLike
|
||||
/** The product's resolved accent hex (e.g. `#5E6AD2`). Omit for the neutral chip. */
|
||||
color?: string
|
||||
size?: number
|
||||
}) {
|
||||
const radius = Math.round(size * 0.28)
|
||||
const glyph = Math.round(size * 0.58)
|
||||
const tinted = typeof color === 'string' && color.trim() !== ''
|
||||
|
||||
// Accent tile — product color fill + near-white glyph (the Linear "cut out").
|
||||
if (tinted) {
|
||||
return (
|
||||
<XStack
|
||||
width={size}
|
||||
height={size}
|
||||
items="center"
|
||||
justify="center"
|
||||
rounded={radius}
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
<Icon size={glyph} color={asColor('#ffffff')} />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// No accent — a tasteful neutral chip (consistent with ProviderLogo's mark).
|
||||
return (
|
||||
<XStack width={size} height={size} items="center" justify="center" rounded={radius} bg="$color12">
|
||||
<Icon size={glyph} color="$color1" />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ProviderLogo — a small provider/model avatar resolved from a provider NAME
|
||||
* alone (no external logo URLs, no network). Self-contained and prop-driven so
|
||||
* it lifts cleanly into `@hanzo/ui` for hanzo.ai / @hanzo/dev / the desktop app.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. First-party — Zen renders the real ensō mark, Hanzo the real block-H mark
|
||||
* (same geometry as @zenlm/logo / @hanzo/logo), knocked out of a filled
|
||||
* rounded tile so our own models read distinctly and on-brand.
|
||||
* 2. A known provider → its mapped @hanzogui/lucide-icons-2 glyph.
|
||||
* 3. Otherwise → a stable initials chip (1–2 letters from the name).
|
||||
*
|
||||
* Honest by construction: we never claim an official brand logo we don't ship —
|
||||
* unknown providers get clean initials, not a guessed icon. Colors are @hanzo/gui
|
||||
* theme tokens so the mark themes with the shell.
|
||||
*/
|
||||
import { Text, XStack, useTheme } from '@hanzo/gui'
|
||||
import { Boxes, Server, Globe, Cpu } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
type IconCmp = typeof Globe
|
||||
|
||||
/** Curated, extendable provider→glyph map (lowercased keys). Brand-neutral avatars. */
|
||||
const KNOWN: Record<string, IconCmp> = {
|
||||
openrouter: Globe,
|
||||
nvidia: Cpu,
|
||||
}
|
||||
|
||||
const isFirstParty = (provider: string): boolean => {
|
||||
const p = provider.trim().toLowerCase()
|
||||
return p === 'hanzo' || p === 'zen'
|
||||
}
|
||||
|
||||
/** The Zen ensō mark — identical geometry to @zenlm/logo (svg/zen-enso.svg). */
|
||||
function EnsoMark({ size, color }: { size: number; color: string }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 100 100" aria-hidden="true">
|
||||
<path d="M66.22 83.26 A37 37 0 1 1 85.57 60.20" fill="none" stroke={color} strokeWidth={11} strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** The Hanzo block-H mark — identical geometry to @hanzo/logo (app/icon.svg). */
|
||||
const HANZO_PATHS = [
|
||||
'M22.21 67V44.6369H0V67H22.21Z',
|
||||
'M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z',
|
||||
'M22.21 0H0V22.3184H22.21V0Z',
|
||||
'M66.7198 0H44.5098V22.3184H66.7198V0Z',
|
||||
'M66.7198 67V44.6369H44.5098V67H66.7198Z',
|
||||
]
|
||||
function HanzoHMark({ size, color }: { size: number; color: string }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 67 67" aria-hidden="true">
|
||||
{HANZO_PATHS.map((d) => (
|
||||
<path key={d} d={d} fill={color} />
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 1–2 uppercase initials from a provider name: words→first letters, else first 2 chars. */
|
||||
export function providerInitials(provider: string): string {
|
||||
const name = provider.trim()
|
||||
if (!name) return '•'
|
||||
const words = name.split(/[\s/_-]+/).filter(Boolean)
|
||||
if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase()
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export function ProviderLogo({ provider, size = 24 }: { provider: string; size?: number }) {
|
||||
const theme = useTheme()
|
||||
const radius = Math.round(size * 0.28)
|
||||
const iconSize = Math.round(size * 0.56)
|
||||
|
||||
// First-party (Zen/Hanzo) — the real brand mark knocked out of a filled tile.
|
||||
if (isFirstParty(provider)) {
|
||||
const fg = theme.color1?.get() ?? '#000000' // cut-out mark: the tile's contrast color
|
||||
const markSize = Math.round(size * 0.66)
|
||||
const isZen = provider.trim().toLowerCase() === 'zen'
|
||||
return (
|
||||
<XStack width={size} height={size} items="center" justify="center" rounded={radius} bg="$color12">
|
||||
{isZen ? <EnsoMark size={markSize} color={fg} /> : <HanzoHMark size={Math.round(size * 0.56)} color={fg} />}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// A known provider with a curated, brand-neutral glyph.
|
||||
const Known = KNOWN[provider.trim().toLowerCase()]
|
||||
if (Known) {
|
||||
return (
|
||||
<XStack
|
||||
width={size}
|
||||
height={size}
|
||||
items="center"
|
||||
justify="center"
|
||||
rounded={radius}
|
||||
bg="$color3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
>
|
||||
<Known size={iconSize} color="$color11" />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// Otherwise — a clean, stable initials chip (no fabricated brand logo).
|
||||
return (
|
||||
<XStack
|
||||
width={size}
|
||||
height={size}
|
||||
items="center"
|
||||
justify="center"
|
||||
rounded={radius}
|
||||
bg="$color3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
>
|
||||
<Text fontSize={Math.round(size * 0.4)} fontWeight="800" color="$color11">
|
||||
{providerInitials(provider)}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A generic fallback mark for an unspecified provider (used sparingly). */
|
||||
export function GenericLogo({ size = 24 }: { size?: number }) {
|
||||
return (
|
||||
<XStack width={size} height={size} items="center" justify="center" rounded={Math.round(size * 0.28)} bg="$color3" borderWidth={1} borderColor="$borderColor">
|
||||
<Server size={Math.round(size * 0.56)} color="$color11" />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// Boxes is re-exported as the conventional "custom model" mark for callers that
|
||||
// render a non-provider tile next to ProviderLogo (keeps the icon source single).
|
||||
export { Boxes as CustomModelMark }
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { rowShift, targetIndex } from './Reorder'
|
||||
|
||||
describe('targetIndex', () => {
|
||||
it('rounds travel to the nearest row and clamps to range', () => {
|
||||
// rowH=44, from=2, drag down 100px → +2.27 → +2 → index 4
|
||||
expect(targetIndex(2, 100, 44, 6)).toBe(4)
|
||||
// drag up 100px → −2 → index 0
|
||||
expect(targetIndex(2, -100, 44, 6)).toBe(0)
|
||||
// clamp at the ends
|
||||
expect(targetIndex(5, 999, 44, 6)).toBe(5)
|
||||
expect(targetIndex(0, -999, 44, 6)).toBe(0)
|
||||
// sub-half-row travel stays put
|
||||
expect(targetIndex(3, 20, 44, 6)).toBe(3)
|
||||
})
|
||||
|
||||
it('is a safe no-op for degenerate inputs', () => {
|
||||
expect(targetIndex(2, 100, 0, 6)).toBe(2)
|
||||
expect(targetIndex(2, 100, 44, 0)).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rowShift — displacement of siblings during a drag', () => {
|
||||
const rowH = 44
|
||||
it('the dragged row follows the pointer', () => {
|
||||
expect(rowShift(2, 2, 4, 90, rowH)).toBe(90)
|
||||
})
|
||||
it('rows between from and to (dragging down) move up by one row', () => {
|
||||
// from=1 → to=3: rows 2,3 shift up
|
||||
expect(rowShift(2, 1, 3, 90, rowH)).toBe(-rowH)
|
||||
expect(rowShift(3, 1, 3, 90, rowH)).toBe(-rowH)
|
||||
expect(rowShift(4, 1, 3, 90, rowH)).toBe(0)
|
||||
})
|
||||
it('rows between to and from (dragging up) move down by one row', () => {
|
||||
// from=4 → to=2: rows 2,3 shift down
|
||||
expect(rowShift(2, 4, 2, -90, rowH)).toBe(rowH)
|
||||
expect(rowShift(3, 4, 2, -90, rowH)).toBe(rowH)
|
||||
expect(rowShift(1, 4, 2, -90, rowH)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Reorder — a tiny pointer-driven drag-to-reorder list. No dependency: it uses
|
||||
* Pointer Events (so it works with mouse AND touch) and CSS transforms, matching
|
||||
* the shell's transform-driven motion. Rows are a FIXED height (all pinned rows
|
||||
* are), which keeps the "where does it drop?" math exact and pure (`targetIndex`)
|
||||
* and the reflow buttery — the lifted row follows the pointer 1:1 while its
|
||||
* displaced siblings ease into place.
|
||||
*
|
||||
* The list is controlled: `onReorder(from, to)` fires once on drop; the parent
|
||||
* owns the data (here, the account-backed pins model). `renderItem` gets a
|
||||
* `handle` to spread on the drag affordance so the rest of the row stays
|
||||
* interactive (tap to open, etc.).
|
||||
*/
|
||||
import { useCallback, useRef, useState, type ReactNode } from 'react'
|
||||
import { YStack } from '@hanzo/gui'
|
||||
|
||||
/** Final index for a drag: start + rounded rows travelled, clamped. PURE. */
|
||||
export function targetIndex(from: number, dy: number, rowH: number, count: number): number {
|
||||
if (rowH <= 0 || count <= 0) return from
|
||||
const raw = from + Math.round(dy / rowH)
|
||||
return Math.max(0, Math.min(raw, count - 1))
|
||||
}
|
||||
|
||||
/** Vertical shift for row `i` during a drag from `from`→`to` (the dragged row
|
||||
* follows the pointer; rows between them make room). PURE. */
|
||||
export function rowShift(i: number, from: number, to: number, dy: number, rowH: number): number {
|
||||
if (i === from) return dy
|
||||
if (from < to && i > from && i <= to) return -rowH
|
||||
if (from > to && i < from && i >= to) return rowH
|
||||
return 0
|
||||
}
|
||||
|
||||
export type DragHandle = { onPointerDown: (e: { clientY?: number; pointerId?: number; nativeEvent?: { clientY?: number } }) => void }
|
||||
|
||||
export function Reorder<T>({
|
||||
items,
|
||||
keyOf,
|
||||
rowHeight = 44,
|
||||
onReorder,
|
||||
renderItem,
|
||||
}: {
|
||||
items: T[]
|
||||
keyOf: (t: T) => string
|
||||
rowHeight?: number
|
||||
onReorder: (from: number, to: number) => void
|
||||
renderItem: (item: T, handle: DragHandle, dragging: boolean) => ReactNode
|
||||
}) {
|
||||
const [drag, setDrag] = useState<{ from: number; dy: number } | null>(null)
|
||||
const startRef = useRef<{ from: number; startY: number } | null>(null)
|
||||
|
||||
const to = drag ? targetIndex(drag.from, drag.dy, rowHeight, items.length) : -1
|
||||
|
||||
const begin = useCallback(
|
||||
(index: number) =>
|
||||
(e: { clientY?: number; pointerId?: number; nativeEvent?: { clientY?: number } }) => {
|
||||
const startY = e.clientY ?? e.nativeEvent?.clientY ?? 0
|
||||
startRef.current = { from: index, startY }
|
||||
setDrag({ from: index, dy: 0 })
|
||||
|
||||
const move = (ev: PointerEvent) => {
|
||||
if (!startRef.current) return
|
||||
setDrag({ from: startRef.current.from, dy: ev.clientY - startRef.current.startY })
|
||||
}
|
||||
const end = () => {
|
||||
const s = startRef.current
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', end)
|
||||
window.removeEventListener('pointercancel', end)
|
||||
startRef.current = null
|
||||
setDrag((d) => {
|
||||
if (s && d) {
|
||||
const t = targetIndex(s.from, d.dy, rowHeight, items.length)
|
||||
if (t !== s.from) onReorder(s.from, t)
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', end)
|
||||
window.addEventListener('pointercancel', end)
|
||||
},
|
||||
[items.length, rowHeight, onReorder],
|
||||
)
|
||||
|
||||
return (
|
||||
<YStack>
|
||||
{items.map((item, i) => {
|
||||
const dragging = drag?.from === i
|
||||
const shift = drag && to >= 0 ? rowShift(i, drag.from, to, drag.dy, rowHeight) : 0
|
||||
return (
|
||||
<YStack
|
||||
key={keyOf(item)}
|
||||
height={rowHeight}
|
||||
justify="center"
|
||||
className="hz-drag-item"
|
||||
style={{
|
||||
transform: `translateY(${shift}px)`,
|
||||
zIndex: dragging ? 2 : 1,
|
||||
...(dragging ? { transition: 'none', boxShadow: '0 8px 24px rgba(0,0,0,0.28)' } : null),
|
||||
}}
|
||||
>
|
||||
{renderItem(item, { onPointerDown: begin(i) }, dragging)}
|
||||
</YStack>
|
||||
)
|
||||
})}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* SelectMenu — a compact, reusable dropdown select on @hanzo/gui Popover (the
|
||||
* console's proven dropdown idiom, same as OrgSwitcher/ScopeSwitcher). Renders a
|
||||
* labelled trigger ("All types", or the chosen option) and a popover list with a
|
||||
* check on the active option. Prop-driven + self-contained so it lifts into
|
||||
* `@hanzo/ui`. `value === null` is the "all"/unfiltered state.
|
||||
*/
|
||||
import type { ReactElement } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ChevronDown, Check } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
export type SelectOption<T extends string> = { key: T; label: string }
|
||||
|
||||
export function SelectMenu<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
allLabel = 'All',
|
||||
icon,
|
||||
minWidth = 130,
|
||||
}: {
|
||||
options: SelectOption<T>[]
|
||||
value: T | null
|
||||
onChange: (v: T | null) => void
|
||||
/** Label shown (and first menu row) for the unfiltered `null` state. */
|
||||
allLabel?: string
|
||||
/** Optional leading icon in the trigger. */
|
||||
icon?: ReactElement
|
||||
minWidth?: number
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const active = value === null ? null : options.find((o) => o.key === value) ?? null
|
||||
const triggerLabel = active ? active.label : allLabel
|
||||
|
||||
const pick = (v: T | null) => {
|
||||
onChange(v)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} placement="bottom-start">
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
size="$2"
|
||||
minW={minWidth}
|
||||
justify="space-between"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg={value !== null ? '$color5' : 'transparent'}
|
||||
icon={icon}
|
||||
iconAfter={<ChevronDown size={14} opacity={0.6} />}
|
||||
>
|
||||
<Text fontSize="$2" color="$color12" numberOfLines={1} flex={1}>
|
||||
{triggerLabel}
|
||||
</Text>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content bordered elevate p="$1.5" minW={minWidth} bg="$color2" borderColor="$borderColor">
|
||||
<YStack gap="$0.5" minW={minWidth} maxH={320} overflow="scroll">
|
||||
<Row label={allLabel} active={value === null} onPress={() => pick(null)} />
|
||||
{options.map((o) => (
|
||||
<Row key={o.key} label={o.label} active={value === o.key} onPress={() => pick(o.key)} />
|
||||
))}
|
||||
</YStack>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
|
||||
return (
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$2.5"
|
||||
py="$1.5"
|
||||
rounded="$3"
|
||||
cursor="pointer"
|
||||
hoverStyle={{ bg: '$color4' }}
|
||||
bg={active ? '$color4' : 'transparent'}
|
||||
onPress={onPress}
|
||||
>
|
||||
<XStack width={14} items="center" justify="center">
|
||||
{active ? <Check size={13} /> : null}
|
||||
</XStack>
|
||||
<Text fontSize="$2" color="$color12" flex={1} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* SlideOver — the ONE right-side (or left) slide-out overlay for the console.
|
||||
*
|
||||
* It is the single mechanism behind every off-canvas surface: the mobile nav
|
||||
* drawer, the item DetailPane, and the account/settings menu. One definition, many
|
||||
* mounts (DRY) — so every drawer slides, dims, traps, and closes identically.
|
||||
*
|
||||
* Smoothness is CSS-transform driven (never a JS layout branch): the panel is
|
||||
* ALWAYS mounted and toggles `translateX(0) ↔ translateX(±100%)` via the shared
|
||||
* `.hz-slide` class, and the backdrop cross-fades via `.hz-fade`. Because it never
|
||||
* unmounts, the EXIT animates too (a `Dialog` that unmounts on close cannot). Both
|
||||
* classes are reduced-motion-aware in `globals.css`, so the whole thing snaps
|
||||
* instantly for users who ask for that — no bespoke logic here.
|
||||
*
|
||||
* Responsive by construction: FULL-WIDTH (full-screen) below `lg`, a fixed-width
|
||||
* pane at `lg+` (`size`). Uses `100dvh` so the mobile browser chrome never clips it.
|
||||
*
|
||||
* A11y: `role="dialog" aria-modal`, Escape to close, backdrop click to close, body
|
||||
* scroll lock while open (ref-counted so stacked overlays don't fight), and focus
|
||||
* moves into the panel on open and returns to the opener on close.
|
||||
*/
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
import { Button, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { asColor, type IconLike } from './color'
|
||||
|
||||
/** The lg breakpoint (px) — matches the shell's one responsive threshold. */
|
||||
const LG = 1024
|
||||
|
||||
// ── Body scroll lock, ref-counted so nested/stacked overlays don't clobber it ──
|
||||
let lockCount = 0
|
||||
let savedOverflow = ''
|
||||
function lockScroll() {
|
||||
if (typeof document === 'undefined') return
|
||||
if (lockCount === 0) {
|
||||
savedOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
lockCount++
|
||||
}
|
||||
function unlockScroll() {
|
||||
if (typeof document === 'undefined') return
|
||||
lockCount = Math.max(0, lockCount - 1)
|
||||
if (lockCount === 0) document.body.style.overflow = savedOverflow
|
||||
}
|
||||
|
||||
export type SlideOverProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** Which edge the panel slides from. Default right. */
|
||||
side?: 'right' | 'left'
|
||||
/** Panel width at lg+ (px). Below lg it is always full-screen. Default 420. */
|
||||
size?: number
|
||||
/** Optional header title — when set, SlideOver renders a header + a scroll body. */
|
||||
title?: ReactNode
|
||||
/** Optional header icon (tinted with `iconColor`). */
|
||||
icon?: IconLike
|
||||
iconColor?: string
|
||||
/** Extra header content, right-aligned before the close button. */
|
||||
headerRight?: ReactNode
|
||||
/** Accessible label when no visible title is given. */
|
||||
ariaLabel?: string
|
||||
/** z-index for stacking (a pane opened over a drawer passes a higher value). */
|
||||
zIndex?: number
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function SlideOver({
|
||||
open,
|
||||
onClose,
|
||||
side = 'right',
|
||||
size = 420,
|
||||
title,
|
||||
icon: Icon,
|
||||
iconColor,
|
||||
headerRight,
|
||||
ariaLabel,
|
||||
zIndex = 1000,
|
||||
children,
|
||||
}: SlideOverProps) {
|
||||
const panelRef = useRef<HTMLElement | null>(null)
|
||||
const openerRef = useRef<Element | null>(null)
|
||||
|
||||
// Body scroll lock + focus management, driven by `open`.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
openerRef.current = typeof document !== 'undefined' ? document.activeElement : null
|
||||
lockScroll()
|
||||
// Move focus into the panel (next frame so it is laid out + on-screen).
|
||||
const t = window.setTimeout(() => {
|
||||
const el = panelRef.current
|
||||
if (el && typeof el.focus === 'function') el.focus()
|
||||
}, 0)
|
||||
return () => {
|
||||
window.clearTimeout(t)
|
||||
unlockScroll()
|
||||
const opener = openerRef.current
|
||||
if (opener && opener instanceof HTMLElement && typeof opener.focus === 'function') opener.focus()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Escape closes.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [open, onClose])
|
||||
|
||||
const offscreen = side === 'right' ? 'translateX(100%)' : 'translateX(-100%)'
|
||||
const edge = side === 'right' ? { r: 0 } : { l: 0 }
|
||||
|
||||
return (
|
||||
<YStack
|
||||
// Fixed to the viewport; overflow hidden so the off-screen panel (absolute
|
||||
// child) is CLIPPED and never adds a horizontal scrollbar.
|
||||
overflow="hidden"
|
||||
pointerEvents={open ? 'auto' : 'none'}
|
||||
aria-hidden={!open}
|
||||
style={{ position: 'fixed', inset: 0, zIndex }}
|
||||
>
|
||||
{/* Backdrop — cross-fades; click to dismiss. */}
|
||||
<YStack
|
||||
position="absolute"
|
||||
t={0}
|
||||
l={0}
|
||||
r={0}
|
||||
b={0}
|
||||
bg="rgba(0,0,0,0.55)"
|
||||
className="hz-fade"
|
||||
style={{ opacity: open ? 1 : 0 }}
|
||||
onPress={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel — slides in from the edge. Full-screen below lg, fixed width at lg+. */}
|
||||
<YStack
|
||||
// @ts-expect-error — web DOM ref; Gui forwards it to the underlying node.
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal={open ? true : undefined}
|
||||
aria-label={typeof title === 'string' ? title : ariaLabel}
|
||||
position="absolute"
|
||||
t={0}
|
||||
b={0}
|
||||
{...edge}
|
||||
width="100%"
|
||||
$lg={{ width: size, maxW: '100vw' }}
|
||||
bg="$color1"
|
||||
borderLeftWidth={side === 'right' ? 1 : 0}
|
||||
borderRightWidth={side === 'left' ? 1 : 0}
|
||||
borderColor="$borderColor"
|
||||
className="hz-slide"
|
||||
style={{ transform: open ? 'translateX(0)' : offscreen, height: '100dvh' }}
|
||||
>
|
||||
{title !== undefined ? (
|
||||
<>
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$2.5"
|
||||
px="$4"
|
||||
height={56}
|
||||
borderBottomWidth={1}
|
||||
borderColor="$borderColor"
|
||||
>
|
||||
{Icon ? <Icon size={18} color={iconColor ? asColor(iconColor) : undefined} /> : null}
|
||||
<Text flex={1} fontSize="$5" fontWeight="700" color="$color12" numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{headerRight}
|
||||
<Button size="$2" chromeless icon={<X size={18} />} onPress={onClose} aria-label="Close" />
|
||||
</XStack>
|
||||
<ScrollView flex={1}>
|
||||
<YStack flex={1} p="$4" gap="$3">
|
||||
{children}
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
</>
|
||||
) : (
|
||||
// Bare mode — the caller owns the full panel layout (e.g. the nav drawer
|
||||
// renders its own header + scroll + footer).
|
||||
children
|
||||
)}
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
export { LG as SLIDEOVER_LG }
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Status pill — maps a resource/cluster status string to a tone, one place.
|
||||
*
|
||||
* Provisioned things (managed resources, clusters) report free-form lifecycle
|
||||
* strings; this normalizes them to green/yellow/red/neutral so every list reads
|
||||
* the same. Style props use the v5 shorthand set.
|
||||
*/
|
||||
import { Text } from '@hanzo/gui'
|
||||
|
||||
type Tone = 'green' | 'yellow' | 'red' | 'neutral'
|
||||
|
||||
const toneOf = (status: string): Tone => {
|
||||
const s = status.toLowerCase()
|
||||
// Platform health verdicts map straight to a tone (the apps inventory reports
|
||||
// green/yellow/red directly).
|
||||
if (s === 'green') return 'green'
|
||||
if (s === 'yellow') return 'yellow'
|
||||
if (s === 'red') return 'red'
|
||||
if (['ready', 'active', 'running', 'available', 'ok'].includes(s)) return 'green'
|
||||
if (['creating', 'provisioning', 'pending', 'updating', 'attaching'].includes(s)) return 'yellow'
|
||||
if (['error', 'failed', 'degraded', 'down'].includes(s)) return 'red'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
// `as const` keeps the literal token types (not widened to `string`) so they
|
||||
// satisfy the GUI `bg`/`color` token unions.
|
||||
const TONE_BG = {
|
||||
green: '$color5',
|
||||
yellow: '$color4',
|
||||
red: '$color4',
|
||||
neutral: '$color3',
|
||||
} as const
|
||||
const TONE_FG = {
|
||||
green: '$color12',
|
||||
yellow: '$color12',
|
||||
red: '$color12',
|
||||
neutral: '$color11',
|
||||
} as const
|
||||
|
||||
export function StatusTag({ status }: { status?: string }) {
|
||||
const tone = toneOf(status ?? '')
|
||||
return (
|
||||
<Text fontSize="$1" px="$2" py="$1" rounded="$2" bg={TONE_BG[tone]} color={TONE_FG[tone]}>
|
||||
{status || 'unknown'}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Theme toggle — flips the console between dark and light via next-theme. The
|
||||
* provider already wires `NextThemeProvider` → `GuiProvider`, so setting the
|
||||
* theme here re-themes the whole tree. Shows the action's target icon (a sun in
|
||||
* dark mode, a moon in light mode).
|
||||
*/
|
||||
import { Button } from '@hanzo/gui'
|
||||
import { useThemeSetting } from '@hanzogui/next-theme'
|
||||
import { Moon, Sun } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { current, resolvedTheme, set } = useThemeSetting()
|
||||
const isDark = (resolvedTheme ?? current ?? 'dark') !== 'light'
|
||||
return (
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
icon={isDark ? <Sun size={16} /> : <Moon size={16} />}
|
||||
onPress={() => set(isDark ? 'light' : 'dark')}
|
||||
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Toast — the ONE feedback primitive. Every mutation reports through `useToast()`
|
||||
* so success/failure looks and behaves the same everywhere. A single provider
|
||||
* holds the queue and renders the viewport (portalled top-right); there is no
|
||||
* per-call markup. Built from GUI primitives only.
|
||||
*
|
||||
* Mount `<ToastProvider>` once (the dashboard layout does). Call:
|
||||
* const toast = useToast()
|
||||
* toast.success('Created my-db')
|
||||
* toast.error(err instanceof ApiError ? err.message : 'Failed to create')
|
||||
*/
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { CircleCheck, CircleX, Info, X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
export type ToastKind = 'success' | 'error' | 'info'
|
||||
|
||||
export type ToastInput = {
|
||||
title: string
|
||||
description?: string
|
||||
kind?: ToastKind
|
||||
/** Auto-dismiss after this many ms; 0 keeps it until dismissed. */
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
type Toast = Required<Omit<ToastInput, 'description'>> & { id: number; description?: string }
|
||||
|
||||
type ToastApi = {
|
||||
toast: (t: ToastInput) => void
|
||||
success: (title: string, description?: string) => void
|
||||
error: (title: string, description?: string) => void
|
||||
info: (title: string, description?: string) => void
|
||||
dismiss: (id: number) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastApi | null>(null)
|
||||
|
||||
/** Theme-aware accent per kind (adapts to light/dark). */
|
||||
const ACCENT = {
|
||||
success: '$green10',
|
||||
error: '$red10',
|
||||
info: '$color11',
|
||||
} as const
|
||||
|
||||
const ICON: Record<ToastKind, typeof Info> = {
|
||||
success: CircleCheck,
|
||||
error: CircleX,
|
||||
info: Info,
|
||||
}
|
||||
|
||||
function ToastCard({ t, onClose }: { t: Toast; onClose: () => void }) {
|
||||
const Icon = ICON[t.kind]
|
||||
const accent = ACCENT[t.kind]
|
||||
return (
|
||||
<Card
|
||||
width={360}
|
||||
maxWidth="90%"
|
||||
p="$3"
|
||||
gap="$2"
|
||||
bg="$color2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
borderLeftWidth={3}
|
||||
borderLeftColor={accent}
|
||||
rounded="$4"
|
||||
elevation="$2"
|
||||
>
|
||||
<XStack gap="$2.5" items="flex-start">
|
||||
<YStack pt={1}>
|
||||
<Icon size={18} color={accent} />
|
||||
</YStack>
|
||||
<YStack flex={1} gap="$1">
|
||||
<Text fontSize="$3" fontWeight="700" color="$color12">
|
||||
{t.title}
|
||||
</Text>
|
||||
{t.description ? (
|
||||
<Text fontSize="$2" color="$color11">
|
||||
{t.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
<Button size="$1" chromeless icon={<X size={14} />} onPress={onClose} aria-label="Dismiss" />
|
||||
</XStack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/** Portalled, fixed top-right stack. Rendered only after mount (SSR-safe). */
|
||||
function ToastViewport({ toasts, dismiss }: { toasts: Toast[]; dismiss: (id: number) => void }) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
if (!mounted || typeof document === 'undefined') return null
|
||||
return createPortal(
|
||||
<div style={{ position: 'fixed', top: 16, right: 16, zIndex: 100000, pointerEvents: 'none' }}>
|
||||
<YStack gap="$2" items="flex-end">
|
||||
{toasts.map((t) => (
|
||||
<YStack key={t.id} pointerEvents="auto">
|
||||
<ToastCard t={t} onClose={() => dismiss(t.id)} />
|
||||
</YStack>
|
||||
))}
|
||||
</YStack>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>())
|
||||
const seq = useRef(0)
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
setToasts((ts) => ts.filter((t) => t.id !== id))
|
||||
const timer = timers.current.get(id)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timers.current.delete(id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toast = useCallback(
|
||||
(input: ToastInput) => {
|
||||
const id = ++seq.current
|
||||
const kind = input.kind ?? 'info'
|
||||
const durationMs = input.durationMs ?? (kind === 'error' ? 6000 : 3500)
|
||||
setToasts((ts) => [...ts, { id, kind, title: input.title, description: input.description, durationMs }])
|
||||
if (durationMs > 0) {
|
||||
timers.current.set(
|
||||
id,
|
||||
setTimeout(() => dismiss(id), durationMs),
|
||||
)
|
||||
}
|
||||
},
|
||||
[dismiss],
|
||||
)
|
||||
|
||||
// Clear any pending timers on unmount.
|
||||
useEffect(() => {
|
||||
const map = timers.current
|
||||
return () => map.forEach((t) => clearTimeout(t))
|
||||
}, [])
|
||||
|
||||
const api: ToastApi = {
|
||||
toast,
|
||||
success: (title, description) => toast({ title, description, kind: 'success' }),
|
||||
error: (title, description) => toast({ title, description, kind: 'error' }),
|
||||
info: (title, description) => toast({ title, description, kind: 'info' }),
|
||||
dismiss,
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{children}
|
||||
<ToastViewport toasts={toasts} dismiss={dismiss} />
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
const ctx = useContext(ToastContext)
|
||||
if (!ctx) throw new Error('useToast must be used within <ToastProvider>')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import type { GetThemeValueForKey } from '@hanzo/gui'
|
||||
|
||||
/**
|
||||
* Bridge between raw CSS colors (product hex accents) and Gui's typed `color`
|
||||
* prop. Gui types `color` as a theme-token template (`$color12`, …), but ANY CSS
|
||||
* color is valid at runtime — so tinting an icon with a product hex needs one
|
||||
* sanctioned cast, in ONE place, rather than `as any` scattered around.
|
||||
*/
|
||||
export type IconColor = GetThemeValueForKey<'color'>
|
||||
|
||||
/** Cast a raw CSS color (e.g. `#5E6AD2`) to Gui's `color` prop type. */
|
||||
export const asColor = (hex: string): IconColor => hex as unknown as IconColor
|
||||
|
||||
/** A lucide/Gui icon component that accepts a size + a (token-or-cast) color —
|
||||
* the shape the shell passes around for product/nav icons. */
|
||||
export type IconLike = ComponentType<{ size?: number; color?: IconColor }>
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { filterOptions, isKnownOption, type ComboOption } from './filter'
|
||||
|
||||
const opts: ComboOption[] = [
|
||||
{ value: 'zen-omni', label: 'Zen Omni', hint: 'hanzo' },
|
||||
{ value: 'gpt-4o-mini', label: 'GPT-4o mini', hint: 'openai' },
|
||||
{ value: 'claude-sonnet-4-5', hint: 'anthropic' },
|
||||
]
|
||||
|
||||
describe('filterOptions', () => {
|
||||
it('returns every option for an empty / whitespace query', () => {
|
||||
expect(filterOptions(opts, '')).toHaveLength(3)
|
||||
expect(filterOptions(opts, ' ')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('matches on value (case-insensitive substring)', () => {
|
||||
expect(filterOptions(opts, 'ZEN').map((o) => o.value)).toEqual(['zen-omni'])
|
||||
expect(filterOptions(opts, 'sonnet').map((o) => o.value)).toEqual(['claude-sonnet-4-5'])
|
||||
})
|
||||
|
||||
it('matches on label', () => {
|
||||
expect(filterOptions(opts, 'GPT-4o').map((o) => o.value)).toEqual(['gpt-4o-mini'])
|
||||
})
|
||||
|
||||
it('matches on hint (the provider)', () => {
|
||||
expect(filterOptions(opts, 'anthropic').map((o) => o.value)).toEqual(['claude-sonnet-4-5'])
|
||||
expect(filterOptions(opts, 'openai').map((o) => o.value)).toEqual(['gpt-4o-mini'])
|
||||
})
|
||||
|
||||
it('returns nothing when nothing matches (the typed value is still usable by the caller)', () => {
|
||||
expect(filterOptions(opts, 'llama')).toEqual([])
|
||||
})
|
||||
|
||||
it('treats the query as a LITERAL string, never a regex (no ReDoS / injection)', () => {
|
||||
// A regex-special query must match literally (or not at all) — never be compiled.
|
||||
expect(filterOptions(opts, '.*')).toEqual([]) // no option literally contains ".*"
|
||||
expect(filterOptions(opts, '(')).toEqual([]) // an unbalanced paren would throw if compiled
|
||||
expect(filterOptions(opts, '[a-z')).toEqual([]) // an invalid char class would throw if compiled
|
||||
// A literal hyphen (regex-benign but proves substring semantics):
|
||||
expect(filterOptions(opts, '4o-mini').map((o) => o.value)).toEqual(['gpt-4o-mini'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isKnownOption', () => {
|
||||
it('is true only for an exact value match (case-sensitive)', () => {
|
||||
expect(isKnownOption(opts, 'zen-omni')).toBe(true)
|
||||
expect(isKnownOption(opts, 'ZEN-OMNI')).toBe(false)
|
||||
expect(isKnownOption(opts, 'custom-model')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Combobox option filtering — pure, unit-tested.
|
||||
*
|
||||
* The ONE matcher the ComboBox uses to narrow a live option list against typed
|
||||
* text. Matching is a LITERAL, case-insensitive substring over the option's
|
||||
* value + label + hint — NEVER a compiled RegExp of user input (that would be a
|
||||
* ReDoS / injection surface; the whole codebase filters with literal `includes`,
|
||||
* e.g. marketplace/logic + filterOrgs). Kept dependency-free so it lifts into
|
||||
* `@hanzo/ui` alongside the ComboBox.
|
||||
*/
|
||||
|
||||
/** One option in a combobox list. `value` is what gets committed; label/hint are display. */
|
||||
export type ComboOption = {
|
||||
/** The value committed to the field when this option is chosen. */
|
||||
value: string
|
||||
/** Display label (defaults to `value`). */
|
||||
label?: string
|
||||
/** Optional secondary text (e.g. a provider, a description) — also searched. */
|
||||
hint?: string
|
||||
}
|
||||
|
||||
/** The searchable haystack for one option (value + label + hint), lowercased. */
|
||||
const hay = (o: ComboOption): string => `${o.value} ${o.label ?? ''} ${o.hint ?? ''}`.toLowerCase()
|
||||
|
||||
/**
|
||||
* Filter `options` by `query` — a literal, case-insensitive substring over
|
||||
* value/label/hint. An empty/whitespace query returns every option (no filter).
|
||||
* PURE. The input is treated as a plain string, never a pattern.
|
||||
*/
|
||||
export function filterOptions(options: ComboOption[], query: string): ComboOption[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return options
|
||||
return options.filter((o) => hay(o).includes(q))
|
||||
}
|
||||
|
||||
/** True iff `value` exactly matches an option's `value` (case-sensitive). */
|
||||
export function isKnownOption(options: ComboOption[], value: string): boolean {
|
||||
return options.some((o) => o.value === value)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// @hanzo/ui/product — the product/app component layer on @hanzo/gui.
|
||||
//
|
||||
// Charts, metrics, page headers, status tags, rich empty states, combobox,
|
||||
// slide-over, toasts, drag-reorder, labeled field rows, provider/product marks,
|
||||
// theme toggle. Presentational + host-agnostic: data and effects are INJECTED as
|
||||
// props — nothing here imports an app's `~/lib`. Cross-platform (web + native +
|
||||
// desktop) because it builds only on @hanzo/gui primitives. Clean-room.
|
||||
//
|
||||
// Single collision-free namespace: where a name exists in two files the canonical
|
||||
// (Charts) keeps the name and the variant is aliased, so NOTHING is dropped.
|
||||
|
||||
// Charts — the canonical Sparkline + Donut live here (dependency-free inline SVG:
|
||||
// hover tooltips, axis ticks, honest null under 2 real points).
|
||||
export * from './Charts'
|
||||
|
||||
// Metric — resource-console stat tiles/panels/bars. Its own near-identical
|
||||
// Sparkline is preserved as `MetricSparkline` (zero loss; see CONSOLIDATION.md
|
||||
// for the eventual single-Sparkline dedup).
|
||||
export {
|
||||
SERIES,
|
||||
colorForIndex,
|
||||
utilColor,
|
||||
Sparkline as MetricSparkline,
|
||||
MiniBars,
|
||||
UtilBar,
|
||||
LegendDot,
|
||||
MetricCard,
|
||||
HintButton,
|
||||
Panel,
|
||||
} from './Metric'
|
||||
|
||||
// Standalone dependency-free ring — aliased to avoid the Charts.Donut clash.
|
||||
export { Donut as DonutRing, type DonutSegment } from './Donut'
|
||||
|
||||
// ComboBox — the typeable select. `ComboOption` comes from the ONE pure filter
|
||||
// module (ReDoS-safe literal substring), so export the component here and the
|
||||
// option type + matchers from there.
|
||||
export { ComboBox } from './ComboBox'
|
||||
export * from './combobox/filter'
|
||||
|
||||
// The rest — every exported name below is unique across the layer.
|
||||
export * from './DataTable'
|
||||
export * from './EmptyState'
|
||||
export * from './FadeIn'
|
||||
export * from './Field'
|
||||
export * from './HanzoMark'
|
||||
export * from './PageHeader'
|
||||
export * from './PrimaryButton'
|
||||
export * from './ProductIcon'
|
||||
export * from './ProviderLogo'
|
||||
export * from './Reorder'
|
||||
export * from './SelectMenu'
|
||||
export * from './SlideOver'
|
||||
export * from './StatusTag'
|
||||
export * from './ThemeToggle'
|
||||
export * from './Toast'
|
||||
export * from './color'
|
||||
@@ -0,0 +1,136 @@
|
||||
/* @hanzo/ui motion primitives — the calm, restrained animation vocabulary the
|
||||
Hanzo surfaces share. One place defines the easing; components apply the class.
|
||||
`className` forwards to the underlying DOM node on web, so the browser
|
||||
transitions the Gui-driven inline width/transform/opacity. Every animation is
|
||||
guarded by `prefers-reduced-motion: reduce`.
|
||||
|
||||
Import once at the app root: import '@hanzo/ui/styles/motion.css' */
|
||||
|
||||
/* Fade-up entrance — <FadeIn> applies the class + a per-item stagger delay.
|
||||
~0.4s ease-out, small upward travel (matches the hanzo.ai marketing feel). */
|
||||
@keyframes hz-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hz-fade-up {
|
||||
animation: hz-fade-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-fade-up {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shell chrome motion — sidebar collapse (width) and the Linear-style two-level
|
||||
nav slide (transform); backdrop cross-fade behind a SlideOver / dialog. */
|
||||
.hz-collapse {
|
||||
transition: width 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: width;
|
||||
}
|
||||
|
||||
.hz-slide {
|
||||
transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.hz-fade {
|
||||
transition: opacity 240ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
/* Drag-to-reorder — a pinned row while it is being dragged (pointer DnD). The
|
||||
lifted row gets a subtle lift; siblings ease into place via `.hz-slide`. */
|
||||
.hz-drag-item {
|
||||
touch-action: none;
|
||||
transition: transform 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.hz-drag-item[data-dragging='true'] {
|
||||
transition: none;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
||||
opacity: 0.96;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-collapse,
|
||||
.hz-slide,
|
||||
.hz-fade,
|
||||
.hz-drag-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Living-overview motion — the "videogame-like" living dashboard. The count-up +
|
||||
live sparkline are driven in JS (rAF, gated by prefers-reduced-motion in the
|
||||
hooks); these are the pure-CSS bits: a loading shimmer, a live-feed pulse, and
|
||||
a brief highlight when a freshly-arrived row lands. */
|
||||
|
||||
/* Skeleton shimmer — an honest "loading", never fabricated content. */
|
||||
@keyframes hz-shimmer {
|
||||
0% {
|
||||
background-position: -160px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 160px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hz-skeleton {
|
||||
background-color: var(--color3, rgba(148, 163, 184, 0.14));
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
var(--color4, rgba(148, 163, 184, 0.22)) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 160px 100%;
|
||||
background-repeat: no-repeat;
|
||||
animation: hz-shimmer 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Live-feed pulse — the "Live" dot on a streaming panel. */
|
||||
@keyframes hz-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
}
|
||||
|
||||
/* Entrance for a freshly-arrived activity row (staggerless — one row at a time). */
|
||||
@keyframes hz-row-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hz-row-in {
|
||||
animation: hz-row-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-skeleton {
|
||||
animation: none;
|
||||
}
|
||||
.hz-row-in {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["react"]
|
||||
},
|
||||
"include": ["gui.config.ts", "gui.d.ts", "src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
/**
|
||||
* Unit tests run in a plain Node env. Every suite targets the package's PURE
|
||||
* logic (drag-reorder math, combobox option filtering) — the modules that import
|
||||
* no @hanzo/gui runtime. The view components themselves are verified by the
|
||||
* consuming app's build + visual e2e.
|
||||
*/
|
||||
export default defineConfig({
|
||||
test: { environment: 'node', include: ['src/**/*.test.ts'] },
|
||||
})
|
||||
@@ -52,6 +52,7 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hanzo/ui": "workspace:@hanzo/ui-shadcn@^",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"cross-fetch": "^4.1.0",
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
"react": ">=19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hanzo/gui": "7.2.2",
|
||||
"@hanzogui/config": "7.2.2",
|
||||
"@hanzo/gui": "7.3.0",
|
||||
"@hanzogui/config": "7.3.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"react": "^19.0.0",
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -166,3 +166,120 @@ export function RatingInput({ field, value, onChange }: FieldInputProps) {
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── relation (record picker — single or to-many over host-injected options) ───
|
||||
// The host injects candidate records as metadata.options ({value:id, label}).
|
||||
// maxSelect>1 (or 0/undefined with an array value) = to-many → array of ids;
|
||||
// otherwise a single id (or null when cleared by re-tapping).
|
||||
export function RelationInput({ field, value, onChange }: FieldInputProps) {
|
||||
const meta = (field.metadata ?? {}) as { options?: SelectOption[]; maxSelect?: number }
|
||||
const options = meta.options ?? []
|
||||
const multi = (meta.maxSelect ?? 1) !== 1 || Array.isArray(value)
|
||||
const selected = new Set(multi ? asArray(value) : [asStr(value)].filter(Boolean))
|
||||
const tap = (v: string) => {
|
||||
if (multi) {
|
||||
const next = new Set(selected)
|
||||
next.has(v) ? next.delete(v) : next.add(v)
|
||||
onChange([...next])
|
||||
} else {
|
||||
onChange(selected.has(v) ? null : v) // re-tap clears
|
||||
}
|
||||
}
|
||||
if (options.length === 0) {
|
||||
// No candidates injected — fall back to raw id entry so the field is still editable.
|
||||
return (
|
||||
<Input
|
||||
value={multi ? asArray(value).join(', ') : asStr(value)}
|
||||
onChangeText={(t: string) => onChange(multi ? t.split(',').map((s) => s.trim()).filter(Boolean) : (t || null))}
|
||||
placeholder="record id"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<XStack gap={6} items="center" style={{ flexWrap: 'wrap' }}>
|
||||
{options.map((o) => (
|
||||
<Button key={o.value} size="$2" theme={selected.has(o.value) ? 'blue' : undefined} onPress={() => tap(o.value)}>
|
||||
{o.label}
|
||||
</Button>
|
||||
))}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── files (filename/URL list — add + remove; upload is a host concern) ────────
|
||||
export function FilesInput({ value, onChange }: FieldInputProps) {
|
||||
const files = asArray(value)
|
||||
const add = (t: string) => { const v = t.trim(); if (v) onChange([...files, v]) }
|
||||
const remove = (i: number) => onChange(files.filter((_, idx) => idx !== i))
|
||||
return (
|
||||
<XStack gap={6} items="center" style={{ flexWrap: 'wrap' }}>
|
||||
{files.map((f, i) => (
|
||||
<Button key={`${f}-${i}`} size="$2" onPress={() => remove(i)}>
|
||||
{shortName(f)} ✕
|
||||
</Button>
|
||||
))}
|
||||
<Input placeholder="Add file URL/ref…" onSubmitEditing={(e: { nativeEvent: { text: string } }) => add(e.nativeEvent.text)} />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── links (url list — add + remove) ───────────────────────────────────────────
|
||||
export function LinksInput({ value, onChange }: FieldInputProps) {
|
||||
const links = asArray(value)
|
||||
const add = (t: string) => { const v = t.trim(); if (v) onChange([...links, v]) }
|
||||
const remove = (i: number) => onChange(links.filter((_, idx) => idx !== i))
|
||||
return (
|
||||
<XStack gap={6} items="center" style={{ flexWrap: 'wrap' }}>
|
||||
{links.map((l, i) => (
|
||||
<Button key={`${l}-${i}`} size="$2" onPress={() => remove(i)}>{l} ✕</Button>
|
||||
))}
|
||||
<Input placeholder="Add link…" onSubmitEditing={(e: { nativeEvent: { text: string } }) => add(e.nativeEvent.text)} />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── json (raw text; parses on change, keeps text on invalid so typing isn't lost)
|
||||
export function JsonInput({ value, onChange }: FieldInputProps) {
|
||||
const text = typeof value === 'string' ? value : value == null ? '' : safeStringify(value)
|
||||
return (
|
||||
<Input
|
||||
value={text}
|
||||
onChangeText={(t: string) => { try { onChange(JSON.parse(t)) } catch { onChange(t) } }}
|
||||
placeholder='{ }'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── fullName (first + last sub-fields → { first, last }) ──────────────────────
|
||||
export function FullNameInput({ value, onChange }: FieldInputProps) {
|
||||
const v = (value ?? {}) as { first?: string; last?: string }
|
||||
return (
|
||||
<XStack gap={8} items="center">
|
||||
<Input value={asStr(v.first)} placeholder="First" onChangeText={(t: string) => onChange({ ...v, first: t })} />
|
||||
<Input value={asStr(v.last)} placeholder="Last" onChangeText={(t: string) => onChange({ ...v, last: t })} />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── address (street/city/state/postal sub-fields) ─────────────────────────────
|
||||
export function AddressInput({ value, onChange }: FieldInputProps) {
|
||||
const v = (value ?? {}) as { street?: string; city?: string; state?: string; postalCode?: string }
|
||||
const set = (k: string, t: string) => onChange({ ...v, [k]: t })
|
||||
return (
|
||||
<XStack gap={6} items="center" style={{ flexWrap: 'wrap' }}>
|
||||
<Input value={asStr(v.street)} placeholder="Street" onChangeText={(t: string) => set('street', t)} />
|
||||
<Input value={asStr(v.city)} placeholder="City" onChangeText={(t: string) => set('city', t)} />
|
||||
<Input value={asStr(v.state)} placeholder="State" onChangeText={(t: string) => set('state', t)} />
|
||||
<Input value={asStr(v.postalCode)} placeholder="ZIP" onChangeText={(t: string) => set('postalCode', t)} />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
const shortName = (s: string): string => {
|
||||
const base = s.split(/[/\\]/).pop() || s
|
||||
return base.length > 24 ? base.slice(0, 21) + '…' : base
|
||||
}
|
||||
const safeStringify = (v: unknown): string => {
|
||||
try { return JSON.stringify(v) } catch { return String(v) }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Wire the built-in field types to their renderers. Importing this module (the
|
||||
// barrel does) registers everything. Read-only types (uuid, position, actor,
|
||||
// json, files, address, relation, links) omit an Input until a richer editor
|
||||
// (record picker, file upload, calendar) is registered over them — the table
|
||||
// and detail views already work with display-only types.
|
||||
// barrel does) registers everything. Every non-system type is now fully
|
||||
// EDITABLE — relation (record picker), files, links, json, fullName, address
|
||||
// have Inputs alongside their Displays. Only the truly system/read-only types
|
||||
// (uuid, position, actor) stay display-only.
|
||||
import { registerField } from './registry'
|
||||
import {
|
||||
TextDisplay, LongTextDisplay, NumberDisplay, PercentDisplay, CurrencyDisplay,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
TextInput, LongTextInput, NumberInput, PercentInput, CurrencyInput,
|
||||
BooleanInput, SelectInput, MultiSelectInput, DateInput, EmailInput,
|
||||
UrlInput, PhoneInput, RatingInput,
|
||||
RelationInput, FilesInput, LinksInput, JsonInput, FullNameInput, AddressInput,
|
||||
} from './inputs'
|
||||
|
||||
let done = false
|
||||
@@ -38,13 +39,14 @@ export function registerDefaultFields(): void {
|
||||
registerField('url', { Display: UrlDisplay, Input: UrlInput })
|
||||
registerField('phone', { Display: PhoneDisplay, Input: PhoneInput })
|
||||
registerField('rating', { Display: RatingDisplay, Input: RatingInput })
|
||||
registerField('links', { Display: LinksDisplay })
|
||||
registerField('relation', { Display: RelationDisplay })
|
||||
registerField('json', { Display: JsonDisplay })
|
||||
registerField('links', { Display: LinksDisplay, Input: LinksInput })
|
||||
registerField('relation', { Display: RelationDisplay, Input: RelationInput })
|
||||
registerField('json', { Display: JsonDisplay, Input: JsonInput })
|
||||
registerField('fullName', { Display: FallbackDisplay, Input: FullNameInput })
|
||||
registerField('address', { Display: FallbackDisplay, Input: AddressInput })
|
||||
registerField('files', { Display: FallbackDisplay, Input: FilesInput })
|
||||
// System / read-only types (never edited directly).
|
||||
registerField('uuid', { Display: FallbackDisplay })
|
||||
registerField('fullName', { Display: FallbackDisplay })
|
||||
registerField('address', { Display: FallbackDisplay })
|
||||
registerField('files', { Display: FallbackDisplay })
|
||||
registerField('position', { Display: NumberDisplay })
|
||||
registerField('actor', { Display: FallbackDisplay })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, describe } from 'bun:test'
|
||||
import { test, expect, describe, mock } from 'bun:test'
|
||||
import {
|
||||
registerField,
|
||||
getFieldRenderers,
|
||||
@@ -7,6 +7,15 @@ import {
|
||||
} from './registry'
|
||||
import type { FieldType } from './types'
|
||||
|
||||
// @hanzo/gui is a runtime peer (resolves only in a consuming app), so stub it
|
||||
// here — the CRM-editability tests only need registerDefaults to WIRE the
|
||||
// renderers into the registry, not to render them.
|
||||
const guiStub = () => null
|
||||
mock.module('@hanzo/gui', () => ({
|
||||
Button: guiStub, Input: guiStub, Text: guiStub, XStack: guiStub, YStack: guiStub,
|
||||
Stack: guiStub, View: guiStub, styled: (c: unknown) => c,
|
||||
}))
|
||||
|
||||
// The registry is the dispatch core: a FieldType → its renderers. Pure (a Map +
|
||||
// functions, type-only imports) so it tests without pulling @hanzo/gui. Proves
|
||||
// register / retrieve / override / read-only-omits-Input / enumeration.
|
||||
@@ -54,3 +63,32 @@ describe('field registry', () => {
|
||||
expect(new Set(types).size).toBe(types.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('default renderers — CRM editability', () => {
|
||||
test('every non-system field type is now EDITABLE (has an Input)', async () => {
|
||||
const { registerDefaultFields } = await import('./registerDefaults')
|
||||
registerDefaultFields()
|
||||
// The full set that a CRM/CMS record form must be able to edit.
|
||||
const editable: FieldType[] = [
|
||||
'text', 'longText', 'richText', 'number', 'percent', 'currency', 'boolean',
|
||||
'select', 'multiSelect', 'date', 'dateTime', 'email', 'url', 'phone', 'rating',
|
||||
'relation', 'files', 'links', 'json', 'fullName', 'address',
|
||||
]
|
||||
for (const t of editable) {
|
||||
const r = getFieldRenderers(t)
|
||||
expect(r, `${t} must be registered`).toBeDefined()
|
||||
expect(r?.Display, `${t} needs a Display`).toBeDefined()
|
||||
expect(r?.Input, `${t} must be editable (Input)`).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
test('system/read-only types render but are not directly editable', async () => {
|
||||
const { registerDefaultFields } = await import('./registerDefaults')
|
||||
registerDefaultFields()
|
||||
for (const t of ['uuid', 'position', 'actor'] as FieldType[]) {
|
||||
const r = getFieldRenderers(t)
|
||||
expect(r?.Display, `${t} needs a Display`).toBeDefined()
|
||||
expect(r?.Input, `${t} stays read-only`).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,12 +83,14 @@ export interface FieldMetadataMap {
|
||||
links: Record<string, never>
|
||||
phone: { placeholder?: string }
|
||||
rating: { max?: number }
|
||||
relation: { objectName?: string; labelField?: string }
|
||||
// objectName/labelField name the target; options are the candidate records the
|
||||
// host injects ({value: id, label: display}); maxSelect>1 (or 0) = to-many.
|
||||
relation: { objectName?: string; labelField?: string; options?: SelectOption[]; maxSelect?: number }
|
||||
json: Record<string, never>
|
||||
uuid: Record<string, never>
|
||||
fullName: Record<string, never>
|
||||
address: Record<string, never>
|
||||
files: Record<string, never>
|
||||
files: { accept?: string; maxSelect?: number }
|
||||
richText: Record<string, never>
|
||||
position: Record<string, never>
|
||||
actor: Record<string, never>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hanzo/ui",
|
||||
"name": "@hanzo/ui-shadcn",
|
||||
"version": "5.7.0",
|
||||
"description": "Multi-framework UI library with React, Vue, Svelte, and React Native support. Based on shadcn/ui with comprehensive framework coverage.",
|
||||
"description": "Multi-framework UI library with React, Vue, Svelte, and React Native support. Based on shadcn/ui with comprehensive framework coverage. (Formerly @hanzo/ui ≤5.x; @hanzo/ui@8+ is the @hanzo/gui-based unified lib.)",
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "public",
|
||||
|
||||
Reference in New Issue
Block a user