Compare commits
209
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
756541fae8 | ||
|
|
a2b0a9d133 | ||
|
|
17d239d11b | ||
|
|
8e28d44e8e | ||
|
|
0de2f425a3 | ||
|
|
609500d090 | ||
|
|
55ad4e63c6 | ||
|
|
a9ca7017f3 | ||
|
|
8bdf0004e0 | ||
|
|
9b62e07efd | ||
|
|
721467d806 | ||
|
|
ded5315a97 | ||
|
|
9dc5e717ca | ||
|
|
9e564a6201 | ||
|
|
52e9b50470 | ||
|
|
70f449f170 | ||
|
|
614a5ea1f7 | ||
|
|
60b691435e | ||
|
|
ff392dd16d | ||
|
|
03cc20917f | ||
|
|
ce68baead2 | ||
|
|
1a5c64bcde | ||
|
|
6f4ccb921b | ||
|
|
7d519e6f6b | ||
|
|
93fa9dfd70 | ||
|
|
aa826cad2a | ||
|
|
4486e0dfd6 | ||
|
|
dbf846a300 | ||
|
|
1f0bd82390 | ||
|
|
cacc8606c6 | ||
|
|
6ce3c11e08 | ||
|
|
deaae518c4 | ||
|
|
3d64e4162c | ||
|
|
2dff79192b | ||
|
|
70dd5156fa | ||
|
|
5c4a1cc227 | ||
|
|
dd757fce66 | ||
|
|
ef5ef73f5e | ||
|
|
992b2ec8cc | ||
|
|
f0f74d48e1 | ||
|
|
ebf8228094 | ||
|
|
c6a9ee63c3 | ||
|
|
b9eb5bfe84 | ||
|
|
5b50570c45 | ||
|
|
3e7d605123 | ||
|
|
a42b8a5110 | ||
|
|
16e6422111 | ||
|
|
090c334b89 | ||
|
|
0bdb3fc9d8 | ||
|
|
3a394932c4 | ||
|
|
ad216ff330 | ||
|
|
59f84d1e4d | ||
|
|
7d6ca0e686 | ||
|
|
337c68bc77 | ||
|
|
aae5a87b8c | ||
|
|
865f99f6d2 | ||
|
|
311e177e1c | ||
|
|
0b79f23ad1 | ||
|
|
ff5c9c89b5 | ||
|
|
457dd99856 | ||
|
|
ca350670ec | ||
|
|
a490a9d4f1 | ||
|
|
3b55d8f64d | ||
|
|
a1c53689db | ||
|
|
8f0368eeab | ||
|
|
c55074acf0 | ||
|
|
814a093ca4 | ||
|
|
60f1881345 | ||
|
|
e7f4e087d6 | ||
|
|
9152dab189 | ||
|
|
545d41142e | ||
|
|
c774c24ae5 | ||
|
|
bcef982329 | ||
|
|
9d70d30247 | ||
|
|
97d2bc1743 | ||
|
|
1037f96257 | ||
|
|
689d8e8f62 | ||
|
|
8241adf62d | ||
|
|
5efcbc2902 | ||
|
|
6d8c07e6ec | ||
|
|
0b7d793988 | ||
|
|
36d1a1fe1c | ||
|
|
b20442a376 | ||
|
|
b7bdcc9ef1 | ||
|
|
0ce4288b14 | ||
|
|
12b7514982 | ||
|
|
3a820cf678 | ||
|
|
c588394881 | ||
|
|
3686b4f0b4 | ||
|
|
5a13ab5b0a | ||
|
|
b4d3f0130d | ||
|
|
e65aaa067e | ||
|
|
76e51dc2d0 | ||
|
|
042ed3a3cd | ||
|
|
15b872cb19 | ||
|
|
8cc0ce5f08 | ||
|
|
cb143de14f | ||
|
|
5bdd553985 | ||
|
|
73f1f283e4 | ||
|
|
f1b1a62826 | ||
|
|
3d20aeed19 | ||
|
|
72beb3db0f | ||
|
|
20f960c4ff | ||
|
|
4a9267c271 | ||
|
|
a669885879 | ||
|
|
b769bafcae | ||
|
|
3ca53144cd | ||
|
|
6fc19b257e | ||
|
|
c6c45fe03f | ||
|
|
c79c47a52e | ||
|
|
e7cf07d6d3 | ||
|
|
638491af79 | ||
|
|
6a0c974a06 | ||
|
|
68871624f8 | ||
|
|
3c0ab7ce4a | ||
|
|
c77146332a | ||
|
|
13dc3c4e86 | ||
|
|
d5693fc864 | ||
|
|
264704c84a | ||
|
|
dbe5096e95 | ||
|
|
3c8583c39e | ||
|
|
834d09d2e5 | ||
|
|
eb0daae1ce | ||
|
|
71b69254a1 | ||
|
|
18768197b9 | ||
|
|
48e5887087 | ||
|
|
844fe49f3f | ||
|
|
0eab6ee723 | ||
|
|
5f9df0a24f | ||
|
|
ed6e878470 | ||
|
|
6bb9106312 | ||
|
|
6cd4236dd7 | ||
|
|
7bee72276b | ||
|
|
ca1d75369f | ||
|
|
3b0138d746 | ||
|
|
de8231c590 | ||
|
|
c8bed7869a | ||
|
|
0256b51383 | ||
|
|
c3e098200e | ||
|
|
88b26cb498 | ||
|
|
407ba4509d | ||
|
|
92065761d4 | ||
|
|
d8bf393e3e | ||
|
|
296810b144 | ||
|
|
14b9ad1826 | ||
|
|
addb0a2d29 | ||
|
|
eebdfb0040 | ||
|
|
418615e4db | ||
|
|
d901cb2413 | ||
|
|
47563da3fd | ||
|
|
892ef0901e | ||
|
|
fe3f0b251e | ||
|
|
08045d5e65 | ||
|
|
71a14906b1 | ||
|
|
e5ba46f582 | ||
|
|
56a29a2a8a | ||
|
|
6a34a6912b | ||
|
|
e8e47f3f64 | ||
|
|
b15fee02f7 | ||
|
|
60aa11ddd8 | ||
|
|
62e6c09c95 | ||
|
|
23eb970eb6 | ||
|
|
dad3600ca4 | ||
|
|
bcc05e8ec2 | ||
|
|
021587dc43 | ||
|
|
7dfbaaad4c | ||
|
|
913f5e7fb7 | ||
|
|
e1a2b8289e | ||
|
|
db5f07eb37 | ||
|
|
93d607b9b7 | ||
|
|
cf960de5f1 | ||
|
|
094f0b6558 | ||
|
|
c74756c642 | ||
|
|
07245e4c6a | ||
|
|
c459010476 | ||
|
|
49d6f39bac | ||
|
|
dd71767385 | ||
|
|
56c2f782fb | ||
|
|
7f650655f1 | ||
|
|
4b9ba765c4 | ||
|
|
0110be638c | ||
|
|
ccf51e0a04 | ||
|
|
c9abcec8fb | ||
|
|
d3faddb017 | ||
|
|
ac8e07e3d6 | ||
|
|
b973dec011 | ||
|
|
adf5f36fcc | ||
|
|
460787d2a3 | ||
|
|
eadd812805 | ||
|
|
9e4846a017 | ||
|
|
57eea59d52 | ||
|
|
42fd56d5d3 | ||
|
|
5c4304c0fa | ||
|
|
bfb7ab2ebf | ||
|
|
05e749fed9 | ||
|
|
fda484b0f3 | ||
|
|
f71cc049de | ||
|
|
3e24bb45d2 | ||
|
|
d1b4fdd1da | ||
|
|
0bf8089ce7 | ||
|
|
7cae39177c | ||
|
|
55e1deb687 | ||
|
|
b1aa25ea04 | ||
|
|
74ac18ab13 | ||
|
|
47407cb088 | ||
|
|
00312230a4 | ||
|
|
171d1ea064 | ||
|
|
1aa1c1a398 | ||
|
|
549f6d0fa8 |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="ui">
|
||||
<rect width="1280" height="640" fill="#0A0A0A"/>
|
||||
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
|
||||
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">ui</text>
|
||||
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">React component library for AI applications</text>
|
||||
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
|
||||
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
|
||||
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -4,7 +4,7 @@
|
||||
import { exec } from "child_process"
|
||||
import fs from "fs"
|
||||
|
||||
const pkgJsonPath = "packages/shadcn/package.json"
|
||||
const pkgJsonPath = "pkgs/shadcn/package.json"
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath))
|
||||
exec("git rev-parse --short HEAD", (err, stdout) => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { exec } from "child_process"
|
||||
import fs from "fs"
|
||||
|
||||
const pkgJsonPath = "packages/shadcn/package.json"
|
||||
const pkgJsonPath = "pkgs/shadcn/package.json"
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath))
|
||||
exec("git rev-parse --short HEAD", (err, stdout) => {
|
||||
|
||||
+11
-11
@@ -47,7 +47,7 @@ jobs:
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Run linting
|
||||
run: pnpm run lint
|
||||
@@ -83,15 +83,15 @@ jobs:
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Build UI package
|
||||
run: cd pkg/ui && pnpm run build
|
||||
run: cd pkgs/ui && pnpm run build
|
||||
|
||||
- name: Run type checking
|
||||
run: |
|
||||
cd app && pnpm run typecheck
|
||||
cd ../pkg/ui && pnpm run tc --noEmit
|
||||
cd ../pkgs/ui && pnpm run tc --noEmit
|
||||
|
||||
build:
|
||||
name: Build
|
||||
@@ -124,10 +124,10 @@ jobs:
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Build UI package
|
||||
run: cd pkg/ui && pnpm run build:full
|
||||
run: cd pkgs/ui && pnpm run build:full
|
||||
|
||||
- name: Build app
|
||||
run: cd app && pnpm run build
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
name: build-artifacts
|
||||
path: |
|
||||
app/.next
|
||||
pkg/ui/dist
|
||||
pkgs/ui/dist
|
||||
|
||||
test:
|
||||
name: Test
|
||||
@@ -173,10 +173,10 @@ jobs:
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Run pkg/ui tests
|
||||
run: cd pkg/ui && pnpm test -- --run
|
||||
- name: Run pkgs/ui tests
|
||||
run: cd pkgs/ui && pnpm test -- --run
|
||||
|
||||
- name: Run app tests
|
||||
run: |
|
||||
@@ -190,7 +190,7 @@ jobs:
|
||||
if: always()
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: ./pkg/ui/coverage/lcov.info
|
||||
files: ./pkgs/ui/coverage/lcov.info
|
||||
flags: unit
|
||||
|
||||
deploy-preview:
|
||||
|
||||
@@ -26,15 +26,15 @@ jobs:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: cd pkg/ui && pnpm test:coverage
|
||||
run: cd pkgs/ui && pnpm test:coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: ./pkg/ui/coverage/lcov.info
|
||||
files: ./pkgs/ui/coverage/lcov.info
|
||||
flags: unit
|
||||
name: hanzo-ui-coverage
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const coverage = JSON.parse(fs.readFileSync('./pkg/ui/coverage/coverage-summary.json', 'utf8'));
|
||||
const coverage = JSON.parse(fs.readFileSync('./pkgs/ui/coverage/coverage-summary.json', 'utf8'));
|
||||
const total = coverage.total;
|
||||
const comment = `## Test Coverage Report\n\n` +
|
||||
`Lines: ${total.lines.pct}%\n` +
|
||||
|
||||
@@ -53,11 +53,11 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Build @hanzo/ui package
|
||||
run: |
|
||||
cd pkg/ui && pnpm build
|
||||
cd pkgs/ui && pnpm build
|
||||
|
||||
- name: Capture screenshots (optional)
|
||||
if: github.event.inputs.capture_screenshots == 'true'
|
||||
|
||||
@@ -43,7 +43,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -52,31 +51,31 @@ jobs:
|
||||
- name: Build packages
|
||||
run: |
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "ui" ]; then
|
||||
cd pkg/ui && pnpm build
|
||||
cd pkgs/ui && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "ui-mcp" ]; then
|
||||
cd pkg/ui-mcp && pnpm build
|
||||
cd pkgs/ui-mcp && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "auth" ]; then
|
||||
cd pkg/auth && pnpm build
|
||||
cd pkgs/auth && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "commerce" ]; then
|
||||
cd pkg/commerce && pnpm build
|
||||
cd pkgs/commerce && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "checkout" ]; then
|
||||
cd pkg/checkout && pnpm build
|
||||
cd pkgs/checkout && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "brand" ]; then
|
||||
cd pkg/brand && pnpm build
|
||||
cd pkgs/brand && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "react" ]; then
|
||||
cd pkg/react && pnpm build
|
||||
cd pkgs/react && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
|
||||
@@ -142,34 +141,34 @@ jobs:
|
||||
|
||||
case "${{ github.event.inputs.package }}" in
|
||||
ui)
|
||||
publish_package "pkg/ui"
|
||||
publish_package "pkgs/ui"
|
||||
;;
|
||||
ui-mcp)
|
||||
publish_package "pkg/ui-mcp"
|
||||
publish_package "pkgs/ui-mcp"
|
||||
;;
|
||||
auth)
|
||||
publish_package "pkg/auth"
|
||||
publish_package "pkgs/auth"
|
||||
;;
|
||||
commerce)
|
||||
publish_package "pkg/commerce"
|
||||
publish_package "pkgs/commerce"
|
||||
;;
|
||||
checkout)
|
||||
publish_package "pkg/checkout"
|
||||
publish_package "pkgs/checkout"
|
||||
;;
|
||||
brand)
|
||||
publish_package "pkg/brand"
|
||||
publish_package "pkgs/brand"
|
||||
;;
|
||||
react)
|
||||
publish_package "pkg/react"
|
||||
publish_package "pkgs/react"
|
||||
;;
|
||||
all)
|
||||
publish_package "pkg/ui"
|
||||
publish_package "pkg/ui-mcp"
|
||||
publish_package "pkg/auth"
|
||||
publish_package "pkg/commerce"
|
||||
publish_package "pkg/checkout"
|
||||
publish_package "pkg/brand"
|
||||
publish_package "pkg/react"
|
||||
publish_package "pkgs/ui"
|
||||
publish_package "pkgs/ui-mcp"
|
||||
publish_package "pkgs/auth"
|
||||
publish_package "pkgs/commerce"
|
||||
publish_package "pkgs/checkout"
|
||||
publish_package "pkgs/brand"
|
||||
publish_package "pkgs/react"
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Update npm for OIDC support
|
||||
run: npm install -g npm@latest
|
||||
@@ -55,10 +54,10 @@ jobs:
|
||||
id: package-version
|
||||
uses: martinbeentjes/npm-get-version-action@main
|
||||
with:
|
||||
path: packages/shadcn
|
||||
path: pkgs/shadcn
|
||||
|
||||
- name: Upload packaged artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
|
||||
path: packages/shadcn/dist/index.js
|
||||
path: pkgs/shadcn/dist/index.js
|
||||
|
||||
@@ -20,21 +20,20 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Build packages
|
||||
run: |
|
||||
cd pkg/ui && pnpm build && cd ../..
|
||||
cd pkg/commerce && pnpm build && cd ../..
|
||||
cd pkg/brand && pnpm build && cd ../..
|
||||
cd pkg/react && pnpm build && cd ../..
|
||||
cd pkgs/ui && pnpm build && cd ../..
|
||||
cd pkgs/commerce && pnpm build && cd ../..
|
||||
cd pkgs/brand && pnpm build && cd ../..
|
||||
cd pkgs/react && pnpm build && cd ../..
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd pkg/ui && pnpm test
|
||||
cd pkgs/ui && pnpm test
|
||||
cd ../react && pnpm test
|
||||
cd ../..
|
||||
|
||||
@@ -56,29 +55,28 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Build all packages
|
||||
run: |
|
||||
cd pkg/ui && pnpm build && cd ../..
|
||||
cd pkg/commerce && pnpm build && cd ../..
|
||||
cd pkg/brand && pnpm build && cd ../..
|
||||
cd pkg/react && pnpm build && cd ../..
|
||||
cd pkgs/ui && pnpm build && cd ../..
|
||||
cd pkgs/commerce && pnpm build && cd ../..
|
||||
cd pkgs/brand && pnpm build && cd ../..
|
||||
cd pkgs/react && pnpm build && cd ../..
|
||||
|
||||
- name: Configure npm authentication
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
npm config set //registry.npmjs.org/:_authToken $NODE_AUTH_TOKEN
|
||||
npm whoami
|
||||
|
||||
- name: Check and publish packages
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
echo "Checking all packages for unpublished versions..."
|
||||
|
||||
@@ -87,7 +85,7 @@ jobs:
|
||||
PUBLISHED_PACKAGES=""
|
||||
|
||||
for package in ui commerce brand react; do
|
||||
cd "pkg/$package"
|
||||
cd "pkgs/$package"
|
||||
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
PACKAGE_NAME=$(node -p "require('./package.json').name")
|
||||
|
||||
@@ -4,8 +4,8 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'pkg/*/package.json'
|
||||
- 'packages/*/package.json'
|
||||
- 'pkgs/*/package.json'
|
||||
- 'pkgs/*/package.json'
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
id: changed
|
||||
run: |
|
||||
CHANGED=()
|
||||
for pkg_json in pkg/*/package.json packages/*/package.json; do
|
||||
for pkg_json in pkgs/*/package.json pkgs/*/package.json; do
|
||||
[ ! -f "$pkg_json" ] && continue
|
||||
pkg_dir=$(dirname "$pkg_json")
|
||||
|
||||
@@ -73,7 +73,6 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -33,7 +33,6 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Update npm for OIDC support
|
||||
run: npm install -g npm@latest
|
||||
|
||||
@@ -5,6 +5,7 @@ node_modules
|
||||
**/node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
/pnpm-lock.yaml
|
||||
|
||||
# testing
|
||||
coverage
|
||||
|
||||
Vendored
+3
-3
@@ -4,8 +4,8 @@
|
||||
"typescript.preferences.quoteStyle": "single",
|
||||
"eslint.workingDirectories": [
|
||||
{ "pattern": "apps/*/" },
|
||||
{ "pattern": "packages/*/" },
|
||||
{ "pattern": "pkg/*/" }
|
||||
{ "pattern": "pkgs/*/" },
|
||||
{ "pattern": "pkgs/*/" }
|
||||
],
|
||||
"tailwindCSS.classFunctions": ["cva", "cn"],
|
||||
"vitest.debugExclude": [
|
||||
@@ -19,6 +19,6 @@
|
||||
"search.exclude": {
|
||||
"apps/v4/registry/radix-*": true,
|
||||
"apps/v4/public/r/*": true,
|
||||
"packages/shadcn/test/fixtures/*": true
|
||||
"pkgs/shadcn/test/fixtures/*": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
@@ -0,0 +1,6 @@
|
||||
ui
|
||||
Copyright (c) 2023 Hanzo AI, Inc.
|
||||
|
||||
This product includes software from shadcn/ui (https://github.com/shadcn-ui/ui), licensed under MIT:
|
||||
|
||||
Copyright (c) 2023 shadcn
|
||||
@@ -1,3 +1,5 @@
|
||||
<p align="center"><img src=".github/hero.svg" alt="ui" width="880"></p>
|
||||
|
||||
# @hanzo/ui
|
||||
|
||||
Accessible and customizable components for React, Vue, Svelte, and React Native. **Built on shadcn/ui with multi-framework support, 3D components, AI components, and advanced features.**
|
||||
|
||||
@@ -42,8 +42,8 @@ describe("@hanzo/ui namespace imports - Type Resolution", () => {
|
||||
* import { GridPattern } from '@hanzo/ui/pattern/grid'
|
||||
* ```
|
||||
*
|
||||
* The package exports are properly configured in pkg/ui/package.json.
|
||||
* Build artifacts exist in pkg/ui/dist/{code,3d,pattern}/.
|
||||
* The package exports are properly configured in pkgs/ui/package.json.
|
||||
* Build artifacts exist in pkgs/ui/dist/{code,3d,pattern}/.
|
||||
*
|
||||
* Vitest runtime resolution fails due to internal cross-package imports
|
||||
* (e.g., CodeBlock imports from @hanzo/ui/lib/utils), which require
|
||||
|
||||
@@ -62,7 +62,15 @@ export function CommandMenu({ ...props }: DialogProps) {
|
||||
const { recentSearches, addRecentSearch, clearRecentSearches } =
|
||||
useRecentSearches()
|
||||
|
||||
const modKey = React.useMemo(() => (isMacOS() ? "⌘" : "Ctrl"), [])
|
||||
// SSR renders without `window`, so the first client (hydration) render MUST
|
||||
// also produce "Ctrl" — computing the platform-specific glyph during render
|
||||
// makes the hydrated text ("⌘" on macOS) diverge from the server text
|
||||
// ("Ctrl") → React #418 (hydration text mismatch). Start from the SSR-stable
|
||||
// value and upgrade to the platform glyph after mount (client-only effect).
|
||||
const [modKey, setModKey] = React.useState("Ctrl")
|
||||
React.useEffect(() => {
|
||||
if (isMacOS()) setModKey("⌘")
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
|
||||
+2
-2
@@ -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",
|
||||
@@ -156,7 +156,7 @@
|
||||
"postcss-selector-parser": "^7.1.1",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"puppeteer": "^24.40.0",
|
||||
"puppeteer": "^25.1.0",
|
||||
"rehype-pretty-code": "^0.14.3",
|
||||
"rimraf": "^6.1.3",
|
||||
"sharp": "^0.34.5",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { docs } from "@/.source"
|
||||
import { docs } from "@/.source/server"
|
||||
import { loader } from "fumadocs-core/source"
|
||||
|
||||
export const source = loader({
|
||||
|
||||
@@ -50,10 +50,10 @@
|
||||
"embla-carousel-autoplay": "8.5.2",
|
||||
"embla-carousel-react": "8.5.2",
|
||||
"front-matter": "^4.0.2",
|
||||
"fumadocs-core": "16.0.5",
|
||||
"fumadocs-docgen": "2.0.0",
|
||||
"fumadocs-mdx": "13.0.2",
|
||||
"fumadocs-ui": "16.0.5",
|
||||
"fumadocs-core": "16.10.3",
|
||||
"fumadocs-docgen": "3.0.10",
|
||||
"fumadocs-mdx": "15.0.12",
|
||||
"fumadocs-ui": "16.10.3",
|
||||
"input-otp": "^1.4.2",
|
||||
"jotai": "^2.15.0",
|
||||
"little-date": "^1.0.0",
|
||||
@@ -103,7 +103,7 @@
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5",
|
||||
"unist-builder": "3.0.0",
|
||||
"unist-builder": "4.0.0",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
},
|
||||
"prettier": {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* The composable sign-in surface, end to end.
|
||||
*
|
||||
* This file is the CONFIGURATION layer — zero auth code. It picks a method set
|
||||
* (`brand.providers`) and hands it to `<SignIn>`. The PRESENTATION layer
|
||||
* (`@hanzo/ui` `<SignIn>` / `<SocialButton>` / `<PasswordForm>`) renders it,
|
||||
* brand-themed by CSS tokens. The MECHANISM layer (`@hanzo/iam`) runs the one
|
||||
* Authorization-Code + PKCE flow — `provider` is the only knob.
|
||||
*
|
||||
* Adoption: every app collapses its hand-rolled `iam-auth.ts` + bespoke login
|
||||
* page to exactly this — `import { SignIn } from '@hanzo/ui/auth'` +
|
||||
* `import { configureIam, startLogin, getIam } from '@hanzo/iam'`.
|
||||
*/
|
||||
|
||||
import * as React from 'react'
|
||||
import { SignIn } from '../src/auth'
|
||||
|
||||
// ── App brand config (the ONLY app-level knob) ──────────────────────────────
|
||||
//
|
||||
// Social/web3 are LIVE: one shared org-level OAuth provider per network in IAM
|
||||
// (admin/provider-google, admin/provider-github), reused by every app via a
|
||||
// `canSignIn` toggle. Apps NEVER register per-app Google/GitHub clients — the
|
||||
// button just delegates with `provider`.
|
||||
const brand = {
|
||||
serverUrl: 'https://hanzo.id',
|
||||
clientId: 'hanzo-app',
|
||||
providers: ['password', 'google', 'github', 'web3'] as const,
|
||||
}
|
||||
|
||||
export function SignInDemo() {
|
||||
// The mechanism comes from `@hanzo/iam`. Configure once; pass the SDK's
|
||||
// `startLogin` (provider knob) and `loginWithPassword` (embedded credential)
|
||||
// into the presentation. Lazy-imported so this demo file stays render-only.
|
||||
const [iam, setIam] = React.useState<null | {
|
||||
startLogin: (o: { provider?: string }) => Promise<void>
|
||||
loginWithPassword: (email: string, password: string) => Promise<string>
|
||||
}>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
let alive = true
|
||||
void import('@hanzo/iam').then((sdk) => {
|
||||
sdk.configureIam({ issuer: brand.serverUrl, clientId: brand.clientId })
|
||||
const engine = sdk.getIam()
|
||||
if (alive) {
|
||||
setIam({
|
||||
startLogin: (o) => sdk.startLogin(o),
|
||||
loginWithPassword: (email, password) => engine.loginWithPassword(email, password),
|
||||
})
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[480px] w-full max-w-sm items-center justify-center">
|
||||
<SignIn
|
||||
title="Sign in"
|
||||
providers={[...brand.providers]}
|
||||
config={{ serverUrl: brand.serverUrl, clientId: brand.clientId }}
|
||||
// Social/web3: delegate to the SDK's startLogin (adds &provider=…).
|
||||
onLogin={iam ? (opts) => iam.startLogin({ provider: opts.provider }) : undefined}
|
||||
// Password: the SDK's embedded credential login → PKCE-bound code.
|
||||
onPasswordSubmit={
|
||||
iam
|
||||
? async ({ email, password }) => {
|
||||
const callbackUrl = await iam.loginWithPassword(email, password)
|
||||
window.location.href = callbackUrl
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+4
-5
@@ -11,8 +11,7 @@
|
||||
"workspaces": [
|
||||
"app",
|
||||
"apps/*",
|
||||
"packages/*",
|
||||
"pkg/*"
|
||||
"pkgs/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
@@ -51,10 +50,10 @@
|
||||
"format:write": "turbo run format:write",
|
||||
"format:check": "turbo run format:check",
|
||||
"sync:templates": "./scripts/sync-templates.sh \"templates/*\"",
|
||||
"check": "turbo lint typecheck format:check",
|
||||
"check": "bash scripts/check-no-brand-pkg.sh && turbo lint typecheck format:check",
|
||||
"release": "changeset version",
|
||||
"pub:beta": "cd packages/hanzo && pnpm pub:beta",
|
||||
"pub:release": "cd packages/hanzo && pnpm pub:release",
|
||||
"pub:beta": "cd pkgs/hanzo && pnpm pub:beta",
|
||||
"pub:release": "cd pkgs/hanzo && pnpm pub:release",
|
||||
"test:dev": "turbo run test --filter=!hanzo-ui --force",
|
||||
"test": "start-server-and-test v4:dev http://localhost:4000 test:dev",
|
||||
"validate:registries": "pnpm --filter=v4 validate:registries",
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
DATABASE_URL="file:./data.db?connection_limit=1"
|
||||
SESSION_SECRET="super-duper-s3cret"
|
||||
@@ -1 +0,0 @@
|
||||
legacy-peer-deps=true
|
||||
@@ -1,5 +0,0 @@
|
||||
.npmrc
|
||||
node_modules/
|
||||
dist/
|
||||
.turbo/
|
||||
*.tsbuildinfo
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "@hanzo/brand",
|
||||
"version": "1.3.1",
|
||||
"description": "Brand assets and configuration for Hanzo UI",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"assets"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"keywords": [
|
||||
"hanzo",
|
||||
"brand",
|
||||
"ui",
|
||||
"design-system"
|
||||
],
|
||||
"author": "Hanzo AI",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* @hanzo/brand — Hanzo brand only.
|
||||
*
|
||||
* Each org owns its own brand package. Do NOT add other orgs here.
|
||||
* Zoo → @zooai/brand
|
||||
* Lux → @luxfi/brand
|
||||
* Tenant → @<tenant>/brand
|
||||
*/
|
||||
|
||||
export type {
|
||||
OrgId,
|
||||
ColorMode,
|
||||
BrandColors,
|
||||
BrandLogo,
|
||||
BrandSocial,
|
||||
BrandSeo,
|
||||
BrandConfig,
|
||||
} from './types'
|
||||
|
||||
export {
|
||||
hanzoMark,
|
||||
hanzoMarkDark,
|
||||
hanzoMarkLight,
|
||||
hanzoWordmark,
|
||||
hanzoWordmarkDark,
|
||||
hanzoWordmarkLight,
|
||||
hanzoFavicon,
|
||||
svgToDataUri,
|
||||
} from './svg'
|
||||
|
||||
export { hanzo, orgs, getOrg } from './orgs'
|
||||
export { hanzo as defaultBrand } from './orgs'
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* Hanzo brand configuration.
|
||||
*
|
||||
* Separation of concerns: this file owns ONLY Hanzo's identity.
|
||||
* @zooai/brand → Zoo (github.com/zooai/brand)
|
||||
* @luxfi/brand → Lux (github.com/luxfi/brand)
|
||||
* @<tenant>/brand → Tenant (github.com/<tenant>/brand)
|
||||
*
|
||||
* Never add other orgs' identities here.
|
||||
*/
|
||||
import type { BrandConfig } from './types'
|
||||
import {
|
||||
hanzoMark,
|
||||
hanzoMarkDark,
|
||||
hanzoMarkLight,
|
||||
hanzoWordmark,
|
||||
hanzoWordmarkDark,
|
||||
hanzoWordmarkLight,
|
||||
hanzoFavicon,
|
||||
} from './svg'
|
||||
|
||||
export const hanzo: BrandConfig = {
|
||||
id: 'hanzo',
|
||||
name: 'Hanzo',
|
||||
orgName: 'Hanzo AI',
|
||||
orgHandle: 'hanzoai',
|
||||
tagline: 'AI infrastructure for the future',
|
||||
description: 'Frontier AI infrastructure, foundational models, and developer tools.',
|
||||
|
||||
npmOrg: '@hanzo',
|
||||
|
||||
domain: 'hanzo.ai',
|
||||
iamDomain: 'hanzo.id',
|
||||
githubOrg: 'hanzoai',
|
||||
docsUrl: 'https://docs.hanzo.ai',
|
||||
|
||||
logo: {
|
||||
mark: hanzoMark,
|
||||
markDark: hanzoMarkDark,
|
||||
markLight: hanzoMarkLight,
|
||||
wordmark: hanzoWordmark,
|
||||
wordmarkDark: hanzoWordmarkDark,
|
||||
wordmarkLight: hanzoWordmarkLight,
|
||||
favicon: hanzoFavicon,
|
||||
width: 67,
|
||||
height: 67,
|
||||
},
|
||||
|
||||
colors: {
|
||||
primary: '222.2 47.4% 11.2%',
|
||||
primaryForeground: '210 40% 98%',
|
||||
secondary: '210 40% 96.1%',
|
||||
secondaryForeground: '222.2 47.4% 11.2%',
|
||||
accent: '210 40% 96.1%',
|
||||
accentForeground: '222.2 47.4% 11.2%',
|
||||
},
|
||||
|
||||
social: {
|
||||
twitter: '@hanzoai',
|
||||
github: 'https://github.com/hanzoai',
|
||||
},
|
||||
|
||||
seo: {
|
||||
title: 'Hanzo AI — Infrastructure for AI',
|
||||
description: 'Frontier AI infrastructure and developer tools.',
|
||||
keywords: ['ai', 'infrastructure', 'hanzo', 'llm', 'mcp'],
|
||||
},
|
||||
}
|
||||
|
||||
export const orgs = { hanzo }
|
||||
export function getOrg(): BrandConfig {
|
||||
return hanzo
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Raw SVG strings for brand marks.
|
||||
*
|
||||
* These are plain strings, not React components.
|
||||
* Consumers can use dangerouslySetInnerHTML, inline them in <img> via data URIs,
|
||||
* or pass them to any framework.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hanzo H-mark (viewBox 0 0 67 67)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Hanzo H-mark -- white fill on transparent, for dark backgrounds */
|
||||
export const hanzoMarkDark = `<svg viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#ffffff"/><path d="M0 44.6369L22.21 46.8285V44.6369H0Z" fill="#DDDDDD"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#ffffff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#ffffff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#ffffff"/><path d="M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z" fill="#DDDDDD"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#ffffff"/></svg>`
|
||||
|
||||
/** Hanzo H-mark -- dark fill on transparent, for light backgrounds */
|
||||
export const hanzoMarkLight = `<svg viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#111113"/><path d="M0 44.6369L22.21 46.8285V44.6369H0Z" fill="#333333"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#111113"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#111113"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#111113"/><path d="M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z" fill="#333333"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#111113"/></svg>`
|
||||
|
||||
/** Hanzo H-mark -- currentColor fill, inherits CSS color */
|
||||
export const hanzoMark = `<svg viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="currentColor"/><path d="M0 44.6369L22.21 46.8285V44.6369H0Z" fill="currentColor" opacity="0.85"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="currentColor"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="currentColor"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="currentColor"/><path d="M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z" fill="currentColor" opacity="0.85"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="currentColor"/></svg>`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hanzo wordmark (text-based SVG)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Hanzo wordmark -- white, for dark backgrounds */
|
||||
export const hanzoWordmarkDark = `<svg viewBox="0 0 200 40" xmlns="http://www.w3.org/2000/svg"><text x="0" y="32" font-family="system-ui,-apple-system,sans-serif" font-size="36" font-weight="700" fill="#ffffff" letter-spacing="-0.02em">HANZO</text></svg>`
|
||||
|
||||
/** Hanzo wordmark -- dark, for light backgrounds */
|
||||
export const hanzoWordmarkLight = `<svg viewBox="0 0 200 40" xmlns="http://www.w3.org/2000/svg"><text x="0" y="32" font-family="system-ui,-apple-system,sans-serif" font-size="36" font-weight="700" fill="#111113" letter-spacing="-0.02em">HANZO</text></svg>`
|
||||
|
||||
/** Hanzo wordmark -- currentColor */
|
||||
export const hanzoWordmark = `<svg viewBox="0 0 200 40" xmlns="http://www.w3.org/2000/svg"><text x="0" y="32" font-family="system-ui,-apple-system,sans-serif" font-size="36" font-weight="700" fill="currentColor" letter-spacing="-0.02em">HANZO</text></svg>`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Favicon (viewBox 0 0 67 67, same as mark -- suitable for <link rel="icon">)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Favicon SVG -- white on dark background with rounded rect */
|
||||
export const hanzoFavicon = `<svg viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg"><rect width="67" height="67" rx="12" fill="#111113"/><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#ffffff" transform="scale(0.7) translate(13,13)"/><path d="M0 44.6369L22.21 46.8285V44.6369H0Z" fill="#DDDDDD" transform="scale(0.7) translate(13,13)"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#ffffff" transform="scale(0.7) translate(13,13)"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#ffffff" transform="scale(0.7) translate(13,13)"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#ffffff" transform="scale(0.7) translate(13,13)"/><path d="M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z" fill="#DDDDDD" transform="scale(0.7) translate(13,13)"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#ffffff" transform="scale(0.7) translate(13,13)"/></svg>`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Convert an SVG string to a data URI for use in <img src> or CSS url() */
|
||||
export function svgToDataUri(svg: string): string {
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Shared types for brand configuration.
|
||||
*/
|
||||
|
||||
export type OrgId = 'hanzo'
|
||||
|
||||
export type ColorMode = 'light' | 'dark'
|
||||
|
||||
export interface BrandColors {
|
||||
/** HSL value (without hsl() wrapper), e.g. "222.2 47.4% 11.2%" */
|
||||
primary: string
|
||||
primaryForeground: string
|
||||
secondary: string
|
||||
secondaryForeground: string
|
||||
accent: string
|
||||
accentForeground: string
|
||||
}
|
||||
|
||||
export interface BrandLogo {
|
||||
/** SVG string -- currentColor variant */
|
||||
mark: string
|
||||
/** SVG string -- dark background variant (light fill) */
|
||||
markDark: string
|
||||
/** SVG string -- light background variant (dark fill) */
|
||||
markLight: string
|
||||
/** SVG string -- currentColor wordmark */
|
||||
wordmark: string
|
||||
/** SVG string -- dark background wordmark */
|
||||
wordmarkDark: string
|
||||
/** SVG string -- light background wordmark */
|
||||
wordmarkLight: string
|
||||
/** SVG string for favicon */
|
||||
favicon: string
|
||||
/** viewBox width */
|
||||
width: number
|
||||
/** viewBox height */
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface BrandSocial {
|
||||
twitter?: string
|
||||
discord?: string
|
||||
github: string
|
||||
}
|
||||
|
||||
export interface BrandSeo {
|
||||
title: string
|
||||
description: string
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
export interface BrandConfig {
|
||||
/** Short org identifier */
|
||||
id: OrgId
|
||||
/** Display name */
|
||||
name: string
|
||||
/** Full organization name */
|
||||
orgName: string
|
||||
/** Org handle (lowercase, no spaces) */
|
||||
orgHandle: string
|
||||
/** One-liner */
|
||||
tagline: string
|
||||
/** Longer description */
|
||||
description: string
|
||||
|
||||
/** npm scope, e.g. "@hanzo" */
|
||||
npmOrg: string
|
||||
|
||||
/** Primary domain */
|
||||
domain: string
|
||||
/** IAM domain for auth */
|
||||
iamDomain: string
|
||||
/** GitHub org handle */
|
||||
githubOrg: string
|
||||
/** Docs URL */
|
||||
docsUrl: string
|
||||
|
||||
/** Logo/mark assets (SVG strings) */
|
||||
logo: BrandLogo
|
||||
|
||||
/** Brand colors (HSL values) */
|
||||
colors: BrandColors
|
||||
|
||||
/** Social links */
|
||||
social: BrandSocial
|
||||
|
||||
/** SEO defaults */
|
||||
seo: BrandSeo
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// tsup.config.ts
|
||||
import { defineConfig } from "tsup";
|
||||
var tsup_config_default = defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true
|
||||
});
|
||||
export {
|
||||
tsup_config_default as default
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidHN1cC5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9faW5qZWN0ZWRfZmlsZW5hbWVfXyA9IFwiL1VzZXJzL3ovd29yay9oYW56by91aS9wa2cvYnJhbmQvdHN1cC5jb25maWcudHNcIjtjb25zdCBfX2luamVjdGVkX2Rpcm5hbWVfXyA9IFwiL1VzZXJzL3ovd29yay9oYW56by91aS9wa2cvYnJhbmRcIjtjb25zdCBfX2luamVjdGVkX2ltcG9ydF9tZXRhX3VybF9fID0gXCJmaWxlOi8vL1VzZXJzL3ovd29yay9oYW56by91aS9wa2cvYnJhbmQvdHN1cC5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tIFwidHN1cFwiXG5cbmV4cG9ydCBkZWZhdWx0IGRlZmluZUNvbmZpZyh7XG4gIGVudHJ5OiBbXCJzcmMvaW5kZXgudHNcIl0sXG4gIGZvcm1hdDogW1wiY2pzXCIsIFwiZXNtXCJdLFxuICBkdHM6IHRydWUsXG4gIGNsZWFuOiB0cnVlLFxuICBzb3VyY2VtYXA6IHRydWUsXG59KVxuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUE4TyxTQUFTLG9CQUFvQjtBQUUzUSxJQUFPLHNCQUFRLGFBQWE7QUFBQSxFQUMxQixPQUFPLENBQUMsY0FBYztBQUFBLEVBQ3RCLFFBQVEsQ0FBQyxPQUFPLEtBQUs7QUFBQSxFQUNyQixLQUFLO0FBQUEsRUFDTCxPQUFPO0FBQUEsRUFDUCxXQUFXO0FBQ2IsQ0FBQzsiLAogICJuYW1lcyI6IFtdCn0K
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig } from "tsup"
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
})
|
||||
@@ -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
|
||||
+42
-210
@@ -1,229 +1,61 @@
|
||||
# @hanzo/ui
|
||||
|
||||
A comprehensive UI component library for Hanzo applications, built with React and TypeScript.
|
||||
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.
|
||||
|
||||
## Version 4.5.6
|
||||
> 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.
|
||||
|
||||
## Installation
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @hanzo/ui
|
||||
# or
|
||||
pnpm add @hanzo/ui
|
||||
# or
|
||||
yarn add @hanzo/ui
|
||||
```sh
|
||||
bun add @hanzo/ui @hanzo/gui @hanzo/data
|
||||
```
|
||||
|
||||
## Components
|
||||
`@hanzo/gui`, `@hanzo/data`, and `react` are peers.
|
||||
|
||||
### Primitives
|
||||
|
||||
Core UI components based on Radix UI primitives:
|
||||
|
||||
- **Accordion** - Collapsible content panels
|
||||
- **Alert** - Informative alert messages
|
||||
- **AlertDialog** - Modal dialogs for important alerts
|
||||
- **Avatar** - User avatar display
|
||||
- **Badge** - Status and label badges
|
||||
- **Breadcrumb** - Navigation breadcrumbs
|
||||
- **Button** - Interactive buttons with variants
|
||||
- **Calendar** - Date picker calendar
|
||||
- **Card** - Container cards for content
|
||||
- **Carousel** - Image/content carousel
|
||||
- **Checkbox** - Checkbox input
|
||||
- **Collapsible** - Collapsible content sections
|
||||
- **Combobox** - Searchable select dropdown
|
||||
- **Command** - Command palette component
|
||||
- **ContextMenu** - Right-click context menus
|
||||
- **Dialog** - Modal dialogs
|
||||
- **Drawer** - Slide-out drawer panels
|
||||
- **DropdownMenu** - Dropdown menu component
|
||||
- **Form** - Form components with validation
|
||||
- **HoverCard** - Hover-triggered info cards
|
||||
- **Input** - Text input field
|
||||
- **InputOTP** - One-time password input
|
||||
- **Label** - Form labels
|
||||
- **NavigationMenu** - Navigation menu bar
|
||||
- **Popover** - Popover overlays
|
||||
- **Progress** - Progress indicators
|
||||
- **RadioGroup** - Radio button groups
|
||||
- **ResizablePanel** - Resizable panel layouts
|
||||
- **ScrollArea** - Custom scrollable areas
|
||||
- **SearchInput** - Search input with icon
|
||||
- **Select** - Select dropdown
|
||||
- **Separator** - Visual separator line
|
||||
- **Sheet** - Side sheet panels
|
||||
- **Skeleton** - Loading skeleton screens
|
||||
- **Slider** - Range slider input
|
||||
- **Switch** - Toggle switch
|
||||
- **Table** - Data tables
|
||||
- **Tabs** - Tabbed interfaces
|
||||
- **TextArea** - Multi-line text input
|
||||
- **TextField** - Enhanced text input
|
||||
- **Toast** - Toast notifications (via Sonner)
|
||||
- **Toggle** - Toggle buttons
|
||||
- **ToggleGroup** - Grouped toggle buttons
|
||||
- **Tooltip** - Hover tooltips
|
||||
- **VideoPlayer** - Video playback component
|
||||
|
||||
### Assets
|
||||
|
||||
Icon components and visual assets:
|
||||
|
||||
#### AI Provider Icons
|
||||
- **AnthropicIcon** - Anthropic AI logo
|
||||
- **OpenAIIcon** - OpenAI logo
|
||||
- **GeminiIcon** - Google Gemini logo
|
||||
- **DeepSeekIcon** - DeepSeek logo
|
||||
- **MistralIcon** - Mistral AI logo
|
||||
- **MetaIcon** - Meta AI logo
|
||||
- **GroqIcon** - Groq logo
|
||||
- **OllamaIcon** - Ollama logo
|
||||
- **HanzoIcon** - Hanzo AI logo
|
||||
- **TogetherAI** - Together AI logo
|
||||
- **ExoIcon** - Exo logo
|
||||
- **GrokIcon** - Grok logo
|
||||
- **LmStudioIcon** - LM Studio logo
|
||||
- **OpenRouterIcon** - OpenRouter logo
|
||||
- **PerplexityIcon** - Perplexity logo
|
||||
- **QwenIcon** - Qwen logo
|
||||
- **AyaCohereIcon** - Aya/Cohere logo
|
||||
|
||||
#### Feature Icons
|
||||
- **AIAgentIcon** - AI agent indicator
|
||||
- **AisIcon** - AI services icon
|
||||
- **ReactJsIcon** - React.js logo
|
||||
- **ReasoningIcon** - AI reasoning indicator
|
||||
- **ToolsIcon** - Tools/utilities icon
|
||||
- **TracingIcon** - Tracing/monitoring icon
|
||||
- **ScheduledTasksIcon** - Scheduled tasks icon
|
||||
- **SendIcon** - Send/submit icon
|
||||
|
||||
#### File Type Icons
|
||||
- **FileTypeIcon** - Dynamic file type icon based on extension
|
||||
- **DirectoryTypeIcon** - Folder/directory icon
|
||||
|
||||
### Utilities
|
||||
|
||||
Helper functions and hooks:
|
||||
|
||||
- **cn()** - Class name utility (clsx + tailwind-merge)
|
||||
- **markdown()** - Markdown to JSX converter
|
||||
- **formatText()** - Text formatting utilities
|
||||
- **useDebounce()** - Debounce hook
|
||||
- **useMap()** - Map state management hook
|
||||
- **formatDateToLocaleStringWithTime()** - Date formatting
|
||||
- **getFileExt()** - File extension extraction
|
||||
- **hexToRgb()** - Color conversion utilities
|
||||
|
||||
### Custom Components
|
||||
|
||||
Additional enhanced components:
|
||||
|
||||
- **ChatInput** - Chat message input
|
||||
- **ChatInputArea** - Multi-line chat input
|
||||
- **ChatSettingsIcon** - Chat settings icon
|
||||
- **CopyToClipboardIcon** - Copy to clipboard button
|
||||
- **DotsLoader** - Loading dots animation
|
||||
- **FileList** - File list display
|
||||
- **FileUploader** - File upload component
|
||||
- **JsonForm** - JSON-based dynamic forms
|
||||
- **MarkdownText** - Markdown renderer
|
||||
- **PrettyJsonPrint** - Formatted JSON display
|
||||
|
||||
## Styling
|
||||
|
||||
The library uses Tailwind CSS for styling. Make sure your application includes Tailwind CSS configuration.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Key peer dependencies:
|
||||
- React 18.3.1+
|
||||
- React DOM 18.3.1+
|
||||
- @hookform/resolvers ^3.3.2
|
||||
- react-hook-form 7.51.4
|
||||
- lucide-react 0.456.0
|
||||
- next-themes ^0.2.1
|
||||
- embla-carousel ^8.1.6
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Button
|
||||
## Two orthogonal layers, one package
|
||||
|
||||
```tsx
|
||||
import { Button } from '@hanzo/ui';
|
||||
// product / app layer — charts, metrics, headers, status, empty states, combobox…
|
||||
import { PageHeader, Sparkline, LineChart, EmptyState, StatusTag, ComboBox, tokens } from '@hanzo/ui'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Button variant="primary" onClick={() => console.log('clicked')}>
|
||||
Click me
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
// metadata-driven record layer — RecordsView & the typed field editors (@hanzo/data)
|
||||
import { RecordsView, DataTable, BoardView, RecordDetail, registerField } from '@hanzo/ui/data'
|
||||
```
|
||||
|
||||
### Alert Dialog
|
||||
- **`@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 {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel
|
||||
} from '@hanzo/ui';
|
||||
|
||||
function ConfirmDialog() {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger>
|
||||
<Button>Open Dialog</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction>Continue</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
import { registerField } from '@hanzo/ui/data'
|
||||
registerField('rating', { Display: MyStars, Input: MyStarPicker })
|
||||
```
|
||||
|
||||
### AI Provider Icons
|
||||
## Design principles
|
||||
|
||||
```tsx
|
||||
import { OpenAIIcon, AnthropicIcon, GeminiIcon } from '@hanzo/ui/assets';
|
||||
- **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`.
|
||||
|
||||
function AIProviders() {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<OpenAIIcon className="h-6 w-6" />
|
||||
<AnthropicIcon className="h-6 w-6" />
|
||||
<GeminiIcon className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
BSD-3-Clause
|
||||
|
||||
## Author
|
||||
|
||||
Hanzo AI, Inc.
|
||||
|
||||
## Repository
|
||||
|
||||
https://github.com/hanzoai/react-sdk
|
||||
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 {}
|
||||
}
|
||||
+52
-890
@@ -1,901 +1,63 @@
|
||||
{
|
||||
"name": "@hanzo/ui",
|
||||
"version": "5.6.2",
|
||||
"description": "Multi-framework UI library with React, Vue, Svelte, and React Native support. Based on shadcn/ui with comprehensive framework coverage.",
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "public",
|
||||
"scope": "@hanzo"
|
||||
"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"
|
||||
},
|
||||
"author": "Hanzo AI, Inc.",
|
||||
"license": "BSD-3-Clause",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"sideEffects": false,
|
||||
"files": [
|
||||
"dist",
|
||||
"docs",
|
||||
"content",
|
||||
"style",
|
||||
"bin",
|
||||
"finance",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
"main": "./src/index.ts",
|
||||
"module": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/hanzoai/ui.git",
|
||||
"directory": "pkg/ui"
|
||||
},
|
||||
"keywords": [
|
||||
"components",
|
||||
"ecommerce",
|
||||
"analytics",
|
||||
"ai",
|
||||
"chat",
|
||||
"finance",
|
||||
"trading",
|
||||
"tradingview",
|
||||
"hanzo",
|
||||
"hanzo-ai",
|
||||
"hanzoai",
|
||||
"radix-ui",
|
||||
"react",
|
||||
"sdk",
|
||||
"ui"
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"lat": "npm show @hanzo/ui version",
|
||||
"pub": "npm publish",
|
||||
"tc": "tsc --project tsconfig.build.json",
|
||||
"build": "npm run clean && npm run build:minimal && npm run tc",
|
||||
"build:minimal": "tsup --config tsup.config.minimal.ts",
|
||||
"build:namespaces": "tsup --config tsup.config.namespaces.ts",
|
||||
"build:full": "tsup --config tsup.config.production.ts",
|
||||
"build:frameworks": "npm run build",
|
||||
"build:watch": "tsup --watch",
|
||||
"build:check": "tsc --project tsconfig.build.json --noEmit",
|
||||
"clean": "rm -rf dist",
|
||||
"prepublishOnly": "npm run build",
|
||||
"update-registry": "node ./bin/update-registry.js",
|
||||
"registry:mcp": "node ./bin/cli.js registry:mcp",
|
||||
"registry:mcp:http": "node ./bin/cli.js registry:mcp --http",
|
||||
"analyze:frameworks": "node analyze-frameworks.js",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:jest": "jest test/multi-framework.test.js",
|
||||
"test:frameworks": "jest test/multi-framework.test.js --verbose"
|
||||
},
|
||||
"bin": {
|
||||
"hanzo-ui": "./bin/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"./system": {
|
||||
"types": "./dist/system/index.d.ts",
|
||||
"import": "./dist/system/index.mjs",
|
||||
"require": "./dist/system/index.js"
|
||||
},
|
||||
"./system/*": {
|
||||
"types": "./dist/system/*.d.ts",
|
||||
"import": "./dist/system/*.mjs",
|
||||
"require": "./dist/system/*.js"
|
||||
},
|
||||
"./registry": {
|
||||
"types": "./dist/src/registry/index.d.ts",
|
||||
"import": "./dist/src/registry/index.mjs",
|
||||
"require": "./dist/src/registry/index.js"
|
||||
},
|
||||
"./registry/*": {
|
||||
"types": "./dist/registry/*.d.ts",
|
||||
"import": "./dist/registry/*.mjs",
|
||||
"require": "./dist/registry/*.js"
|
||||
},
|
||||
"./primitives/bases/gui": {
|
||||
"types": "./dist/primitives/bases/gui/index.d.ts",
|
||||
"import": "./dist/primitives/bases/gui/index.mjs",
|
||||
"require": "./dist/primitives/bases/gui/index.js"
|
||||
},
|
||||
"./primitives/bases/gui/*": {
|
||||
"types": "./dist/primitives/bases/gui/*.d.ts",
|
||||
"import": "./dist/primitives/bases/gui/*.mjs",
|
||||
"require": "./dist/primitives/bases/gui/*.js"
|
||||
},
|
||||
"./primitives/bases/admin": {
|
||||
"types": "./dist/primitives/bases/admin/index.d.ts",
|
||||
"import": "./dist/primitives/bases/admin/index.mjs",
|
||||
"require": "./dist/primitives/bases/admin/index.js"
|
||||
},
|
||||
"./primitives/bases/admin/*": {
|
||||
"types": "./dist/primitives/bases/admin/*.d.ts",
|
||||
"import": "./dist/primitives/bases/admin/*.mjs",
|
||||
"require": "./dist/primitives/bases/admin/*.js"
|
||||
},
|
||||
"./primitives/bases/svelte": {
|
||||
"types": "./dist/primitives/bases/svelte/index.d.ts",
|
||||
"import": "./dist/primitives/bases/svelte/index.mjs",
|
||||
"require": "./dist/primitives/bases/svelte/index.js"
|
||||
},
|
||||
"./primitives/bases/vue": {
|
||||
"types": "./dist/primitives/bases/vue/index.d.ts",
|
||||
"import": "./dist/primitives/bases/vue/index.mjs",
|
||||
"require": "./dist/primitives/bases/vue/index.js"
|
||||
},
|
||||
"./calendar": {
|
||||
"types": "./dist/calendar.d.ts",
|
||||
"import": "./dist/calendar.mjs",
|
||||
"require": "./dist/calendar.js"
|
||||
},
|
||||
"./command": {
|
||||
"types": "./dist/command.d.ts",
|
||||
"import": "./dist/command.mjs",
|
||||
"require": "./dist/command.js"
|
||||
},
|
||||
"./accordion": {
|
||||
"types": "./dist/accordion.d.ts",
|
||||
"import": "./dist/accordion.mjs",
|
||||
"require": "./dist/accordion.js"
|
||||
},
|
||||
"./alert": {
|
||||
"types": "./dist/alert.d.ts",
|
||||
"import": "./dist/alert.mjs",
|
||||
"require": "./dist/alert.js"
|
||||
},
|
||||
"./alert-dialog": {
|
||||
"types": "./dist/alert-dialog.d.ts",
|
||||
"import": "./dist/alert-dialog.mjs",
|
||||
"require": "./dist/alert-dialog.js"
|
||||
},
|
||||
"./avatar": {
|
||||
"types": "./dist/avatar.d.ts",
|
||||
"import": "./dist/avatar.mjs",
|
||||
"require": "./dist/avatar.js"
|
||||
},
|
||||
"./badge": {
|
||||
"types": "./dist/badge.d.ts",
|
||||
"import": "./dist/badge.mjs",
|
||||
"require": "./dist/badge.js"
|
||||
},
|
||||
"./breadcrumb": {
|
||||
"types": "./dist/breadcrumb.d.ts",
|
||||
"import": "./dist/breadcrumb.mjs",
|
||||
"require": "./dist/breadcrumb.js"
|
||||
},
|
||||
"./checkbox": {
|
||||
"types": "./dist/checkbox.d.ts",
|
||||
"import": "./dist/checkbox.mjs",
|
||||
"require": "./dist/checkbox.js"
|
||||
},
|
||||
"./collapsible": {
|
||||
"types": "./dist/collapsible.d.ts",
|
||||
"import": "./dist/collapsible.mjs",
|
||||
"require": "./dist/collapsible.js"
|
||||
},
|
||||
"./context-menu": {
|
||||
"types": "./dist/context-menu.d.ts",
|
||||
"import": "./dist/context-menu.mjs",
|
||||
"require": "./dist/context-menu.js"
|
||||
},
|
||||
"./dialog": {
|
||||
"types": "./dist/dialog.d.ts",
|
||||
"import": "./dist/dialog.mjs",
|
||||
"require": "./dist/dialog.js"
|
||||
},
|
||||
"./dropdown-menu": {
|
||||
"types": "./dist/dropdown-menu.d.ts",
|
||||
"import": "./dist/dropdown-menu.mjs",
|
||||
"require": "./dist/dropdown-menu.js"
|
||||
},
|
||||
"./hover-card": {
|
||||
"types": "./dist/hover-card.d.ts",
|
||||
"import": "./dist/hover-card.mjs",
|
||||
"require": "./dist/hover-card.js"
|
||||
},
|
||||
"./navigation-menu": {
|
||||
"types": "./dist/navigation-menu.d.ts",
|
||||
"import": "./dist/navigation-menu.mjs",
|
||||
"require": "./dist/navigation-menu.js"
|
||||
},
|
||||
"./popover": {
|
||||
"types": "./dist/popover.d.ts",
|
||||
"import": "./dist/popover.mjs",
|
||||
"require": "./dist/popover.js"
|
||||
},
|
||||
"./progress": {
|
||||
"types": "./dist/progress.d.ts",
|
||||
"import": "./dist/progress.mjs",
|
||||
"require": "./dist/progress.js"
|
||||
},
|
||||
"./radio-group": {
|
||||
"types": "./dist/radio-group.d.ts",
|
||||
"import": "./dist/radio-group.mjs",
|
||||
"require": "./dist/radio-group.js"
|
||||
},
|
||||
"./scroll-area": {
|
||||
"types": "./dist/scroll-area.d.ts",
|
||||
"import": "./dist/scroll-area.mjs",
|
||||
"require": "./dist/scroll-area.js"
|
||||
},
|
||||
"./select": {
|
||||
"types": "./dist/select.d.ts",
|
||||
"import": "./dist/select.mjs",
|
||||
"require": "./dist/select.js"
|
||||
},
|
||||
"./separator": {
|
||||
"types": "./dist/separator.d.ts",
|
||||
"import": "./dist/separator.mjs",
|
||||
"require": "./dist/separator.js"
|
||||
},
|
||||
"./sheet": {
|
||||
"types": "./dist/sheet.d.ts",
|
||||
"import": "./dist/sheet.mjs",
|
||||
"require": "./dist/sheet.js"
|
||||
},
|
||||
"./skeleton": {
|
||||
"types": "./dist/skeleton.d.ts",
|
||||
"import": "./dist/skeleton.mjs",
|
||||
"require": "./dist/skeleton.js"
|
||||
},
|
||||
"./slider": {
|
||||
"types": "./dist/slider.d.ts",
|
||||
"import": "./dist/slider.mjs",
|
||||
"require": "./dist/slider.js"
|
||||
},
|
||||
"./switch": {
|
||||
"types": "./dist/switch.d.ts",
|
||||
"import": "./dist/switch.mjs",
|
||||
"require": "./dist/switch.js"
|
||||
},
|
||||
"./table": {
|
||||
"types": "./dist/table.d.ts",
|
||||
"import": "./dist/table.mjs",
|
||||
"require": "./dist/table.js"
|
||||
},
|
||||
"./tabs": {
|
||||
"types": "./dist/tabs.d.ts",
|
||||
"import": "./dist/tabs.mjs",
|
||||
"require": "./dist/tabs.js"
|
||||
},
|
||||
"./textarea": {
|
||||
"types": "./dist/textarea.d.ts",
|
||||
"import": "./dist/textarea.mjs",
|
||||
"require": "./dist/textarea.js"
|
||||
},
|
||||
"./toggle": {
|
||||
"types": "./dist/toggle.d.ts",
|
||||
"import": "./dist/toggle.mjs",
|
||||
"require": "./dist/toggle.js"
|
||||
},
|
||||
"./toggle-group": {
|
||||
"types": "./dist/toggle-group.d.ts",
|
||||
"import": "./dist/toggle-group.mjs",
|
||||
"require": "./dist/toggle-group.js"
|
||||
},
|
||||
"./tooltip": {
|
||||
"types": "./dist/tooltip.d.ts",
|
||||
"import": "./dist/tooltip.mjs",
|
||||
"require": "./dist/tooltip.js"
|
||||
},
|
||||
"./carousel": {
|
||||
"types": "./dist/carousel.d.ts",
|
||||
"import": "./dist/carousel.mjs",
|
||||
"require": "./dist/carousel.js"
|
||||
},
|
||||
"./form": {
|
||||
"types": "./dist/form/index.d.ts",
|
||||
"import": "./dist/form/index.mjs",
|
||||
"require": "./dist/form/index.js"
|
||||
},
|
||||
"./drawer": {
|
||||
"types": "./dist/drawer.d.ts",
|
||||
"import": "./dist/drawer.mjs",
|
||||
"require": "./dist/drawer.js"
|
||||
},
|
||||
"./dash": {
|
||||
"types": "./dist/src/dash/index.d.ts",
|
||||
"import": "./dist/dash/index.mjs",
|
||||
"require": "./dist/dash/index.js"
|
||||
},
|
||||
"./dash/tokens.css": "./src/dash/tokens.css",
|
||||
"./dash/*": {
|
||||
"types": "./dist/src/dash/*.d.ts",
|
||||
"import": "./dist/dash/*.mjs",
|
||||
"require": "./dist/dash/*.js"
|
||||
},
|
||||
"./billing": {
|
||||
"types": "./dist/src/billing/index.d.ts",
|
||||
"import": "./dist/billing/index.mjs",
|
||||
"require": "./dist/billing/index.js"
|
||||
},
|
||||
"./finance": {
|
||||
"types": "./dist/finance/index.d.ts",
|
||||
"import": "./dist/finance/index.mjs",
|
||||
"require": "./dist/finance/index.js"
|
||||
},
|
||||
"./finance/components": {
|
||||
"types": "./finance/components/index.ts",
|
||||
"import": "./finance/components/index.ts"
|
||||
},
|
||||
"./finance/AdvancedChart": {
|
||||
"types": "./finance/components/AdvancedChart.tsx",
|
||||
"import": "./finance/components/AdvancedChart.tsx"
|
||||
},
|
||||
"./finance/TickerTape": {
|
||||
"types": "./finance/components/TickerTape.tsx",
|
||||
"import": "./finance/components/TickerTape.tsx"
|
||||
},
|
||||
"./finance/*": {
|
||||
"types": "./finance/components/*.tsx",
|
||||
"import": "./finance/components/*.tsx"
|
||||
},
|
||||
"./sonner": {
|
||||
"types": "./dist/sonner.d.ts",
|
||||
"import": "./dist/sonner.mjs",
|
||||
"require": "./dist/sonner.js"
|
||||
},
|
||||
"./input-otp": {
|
||||
"types": "./dist/input-otp.d.ts",
|
||||
"import": "./dist/input-otp.mjs",
|
||||
"require": "./dist/input-otp.js"
|
||||
},
|
||||
"./resizable": {
|
||||
"types": "./dist/resizable.d.ts",
|
||||
"import": "./dist/resizable.mjs",
|
||||
"require": "./dist/resizable.js"
|
||||
},
|
||||
"./assets": {
|
||||
"types": "./dist/assets/index.d.ts",
|
||||
"import": "./dist/assets/index.mjs",
|
||||
"require": "./dist/assets/index.js"
|
||||
},
|
||||
"./assets/*": {
|
||||
"types": "./dist/assets/*.d.ts",
|
||||
"import": "./dist/assets/*.mjs",
|
||||
"require": "./dist/assets/*.js"
|
||||
},
|
||||
"./blocks": {
|
||||
"types": "./dist/blocks/index.d.ts",
|
||||
"import": "./dist/blocks/index.mjs",
|
||||
"require": "./dist/blocks/index.js"
|
||||
},
|
||||
"./blocks/*": {
|
||||
"types": "./dist/blocks/*.d.ts",
|
||||
"import": "./dist/blocks/*.mjs",
|
||||
"require": "./dist/blocks/*.js"
|
||||
},
|
||||
"./components": {
|
||||
"types": "./dist/components/index.d.ts",
|
||||
"import": "./dist/components/index.mjs",
|
||||
"require": "./dist/components/index.js"
|
||||
},
|
||||
"./components/*": {
|
||||
"types": "./dist/components/*.d.ts",
|
||||
"import": "./dist/components/*.mjs",
|
||||
"require": "./dist/components/*.js"
|
||||
},
|
||||
"./primitives": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"./primitives/*": {
|
||||
"types": "./dist/primitives/*.d.ts",
|
||||
"import": "./dist/primitives/*.mjs",
|
||||
"require": "./dist/primitives/*.js"
|
||||
},
|
||||
"./primitives-common": {
|
||||
"types": "./dist/primitives/index-common.d.ts",
|
||||
"import": "./dist/primitives/index-common.mjs",
|
||||
"require": "./dist/primitives/index-common.js"
|
||||
},
|
||||
"./style/*": "./style/*",
|
||||
"./tailwind": {
|
||||
"types": "./dist/tailwind/index.d.ts",
|
||||
"import": "./dist/tailwind/index.mjs",
|
||||
"require": "./dist/tailwind/index.js"
|
||||
},
|
||||
"./tailwind/*": {
|
||||
"types": "./dist/tailwind/*.d.ts",
|
||||
"import": "./dist/tailwind/*.mjs",
|
||||
"require": "./dist/tailwind/*.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"import": "./dist/types/index.mjs",
|
||||
"require": "./dist/types/index.js"
|
||||
},
|
||||
"./types/*": {
|
||||
"types": "./dist/types/*.d.ts",
|
||||
"import": "./dist/types/*.mjs",
|
||||
"require": "./dist/types/*.js"
|
||||
},
|
||||
"./util": {
|
||||
"types": "./dist/util/index.d.ts",
|
||||
"import": "./dist/util/index.mjs",
|
||||
"require": "./dist/util/index.js"
|
||||
},
|
||||
"./util/*": {
|
||||
"types": "./dist/util/*.d.ts",
|
||||
"import": "./dist/util/*.mjs",
|
||||
"require": "./dist/util/*.js"
|
||||
},
|
||||
"./util-client": {
|
||||
"types": "./dist/util/index-client.d.ts",
|
||||
"import": "./dist/util/index-client.mjs",
|
||||
"require": "./dist/util/index-client.js"
|
||||
},
|
||||
"./utils": {
|
||||
"types": "./dist/src/utils.d.ts",
|
||||
"import": "./dist/src/utils.mjs",
|
||||
"require": "./dist/src/utils.js"
|
||||
},
|
||||
"./lib/utils": {
|
||||
"types": "./dist/src/utils.d.ts",
|
||||
"import": "./dist/src/utils.mjs",
|
||||
"require": "./dist/src/utils.js"
|
||||
},
|
||||
"./mcp": {
|
||||
"types": "./dist/src/mcp/index.d.ts",
|
||||
"import": "./dist/src/mcp/index.mjs",
|
||||
"require": "./dist/src/mcp/index.js"
|
||||
},
|
||||
"./models": {
|
||||
"types": "./dist/src/models/index.d.ts",
|
||||
"import": "./dist/models/index.mjs",
|
||||
"require": "./dist/models/index.js"
|
||||
},
|
||||
"./react": {
|
||||
"types": "./dist/frameworks/react/index.d.ts",
|
||||
"import": "./dist/frameworks/react/index.mjs",
|
||||
"require": "./dist/frameworks/react/index.js"
|
||||
},
|
||||
"./vue": {
|
||||
"types": "./dist/frameworks/vue/index.d.ts",
|
||||
"import": "./dist/frameworks/vue/index.mjs",
|
||||
"require": "./dist/frameworks/vue/index.js"
|
||||
},
|
||||
"./svelte": {
|
||||
"svelte": "./frameworks/svelte/index.ts",
|
||||
"types": "./dist/frameworks/svelte/index.d.ts",
|
||||
"import": "./dist/frameworks/svelte/index.mjs",
|
||||
"require": "./dist/frameworks/svelte/index.js"
|
||||
},
|
||||
"./react-native": {
|
||||
"types": "./dist/frameworks/react-native/index.d.ts",
|
||||
"import": "./dist/frameworks/react-native/index.mjs",
|
||||
"require": "./dist/frameworks/react-native/index.js"
|
||||
},
|
||||
"./content": {
|
||||
"types": "./content/index.ts",
|
||||
"import": "./content/index.ts"
|
||||
},
|
||||
"./code": {
|
||||
"types": "./dist/code/index.d.ts",
|
||||
"import": "./dist/code/index.mjs",
|
||||
"require": "./dist/code/index.js"
|
||||
},
|
||||
"./code/*": {
|
||||
"types": "./dist/code/*.d.ts",
|
||||
"import": "./dist/code/*.mjs",
|
||||
"require": "./dist/code/*.js"
|
||||
},
|
||||
"./spline": {
|
||||
"types": "./dist/spline/index.d.ts",
|
||||
"import": "./dist/spline/index.mjs",
|
||||
"require": "./dist/spline/index.js"
|
||||
},
|
||||
"./spline/*": {
|
||||
"types": "./dist/spline/*.d.ts",
|
||||
"import": "./dist/spline/*.mjs",
|
||||
"require": "./dist/spline/*.js"
|
||||
},
|
||||
"./charts": {
|
||||
"types": "./dist/charts/index.d.ts",
|
||||
"import": "./dist/charts/index.mjs",
|
||||
"require": "./dist/charts/index.js"
|
||||
},
|
||||
"./kanban": {
|
||||
"types": "./dist/kanban/index.d.ts",
|
||||
"import": "./dist/kanban/index.mjs",
|
||||
"require": "./dist/kanban/index.js"
|
||||
},
|
||||
"./mermaid": {
|
||||
"types": "./dist/mermaid/index.d.ts",
|
||||
"import": "./dist/mermaid/index.mjs",
|
||||
"require": "./dist/mermaid/index.js"
|
||||
},
|
||||
"./calendar-ext": {
|
||||
"types": "./dist/calendar-ext/index.d.ts",
|
||||
"import": "./dist/calendar-ext/index.mjs",
|
||||
"require": "./dist/calendar-ext/index.js"
|
||||
},
|
||||
"./3d": {
|
||||
"types": "./dist/3d/index.d.ts",
|
||||
"import": "./dist/3d/index.mjs",
|
||||
"require": "./dist/3d/index.js"
|
||||
},
|
||||
"./3d/*": {
|
||||
"types": "./dist/3d/*.d.ts",
|
||||
"import": "./dist/3d/*.mjs",
|
||||
"require": "./dist/3d/*.js"
|
||||
},
|
||||
"./animation": {
|
||||
"types": "./dist/animation/index.d.ts",
|
||||
"import": "./dist/animation/index.mjs",
|
||||
"require": "./dist/animation/index.js"
|
||||
},
|
||||
"./animation/*": {
|
||||
"types": "./dist/animation/*.d.ts",
|
||||
"import": "./dist/animation/*.mjs",
|
||||
"require": "./dist/animation/*.js"
|
||||
},
|
||||
"./pattern": {
|
||||
"types": "./dist/pattern/index.d.ts",
|
||||
"import": "./dist/pattern/index.mjs",
|
||||
"require": "./dist/pattern/index.js"
|
||||
},
|
||||
"./pattern/*": {
|
||||
"types": "./dist/pattern/*.d.ts",
|
||||
"import": "./dist/pattern/*.mjs",
|
||||
"require": "./dist/pattern/*.js"
|
||||
},
|
||||
"./auth": {
|
||||
"types": "./dist/src/auth/index.d.ts",
|
||||
"import": "./dist/auth/index.mjs",
|
||||
"require": "./dist/auth/index.js"
|
||||
},
|
||||
"./auth/*": {
|
||||
"types": "./dist/src/auth/*.d.ts",
|
||||
"import": "./dist/auth/*.mjs",
|
||||
"require": "./dist/auth/*.js"
|
||||
},
|
||||
"./navigation": {
|
||||
"types": "./dist/src/navigation/index.d.ts",
|
||||
"import": "./dist/navigation/index.mjs",
|
||||
"require": "./dist/navigation/index.js"
|
||||
},
|
||||
"./navigation/*": {
|
||||
"types": "./dist/src/navigation/*.d.ts",
|
||||
"import": "./dist/navigation/*.mjs",
|
||||
"require": "./dist/navigation/*.js"
|
||||
},
|
||||
"./form/*": {
|
||||
"types": "./dist/form/*.d.ts",
|
||||
"import": "./dist/form/*.mjs",
|
||||
"require": "./dist/form/*.js"
|
||||
},
|
||||
"./device": {
|
||||
"types": "./dist/device/index.d.ts",
|
||||
"import": "./dist/device/index.mjs",
|
||||
"require": "./dist/device/index.js"
|
||||
},
|
||||
"./device/*": {
|
||||
"types": "./dist/device/*.d.ts",
|
||||
"import": "./dist/device/*.mjs",
|
||||
"require": "./dist/device/*.js"
|
||||
},
|
||||
"./dock": {
|
||||
"types": "./dist/dock/index.d.ts",
|
||||
"import": "./dist/dock/index.mjs",
|
||||
"require": "./dist/dock/index.js"
|
||||
},
|
||||
"./dock/*": {
|
||||
"types": "./dist/dock/*.d.ts",
|
||||
"import": "./dist/dock/*.mjs",
|
||||
"require": "./dist/dock/*.js"
|
||||
},
|
||||
"./desktop": {
|
||||
"types": "./dist/desktop/index.d.ts",
|
||||
"import": "./dist/desktop/index.mjs",
|
||||
"require": "./dist/desktop/index.js"
|
||||
},
|
||||
"./desktop/*": {
|
||||
"types": "./dist/desktop/*.d.ts",
|
||||
"import": "./dist/desktop/*.mjs",
|
||||
"require": "./dist/desktop/*.js"
|
||||
},
|
||||
"./project": {
|
||||
"types": "./dist/project/index.d.ts",
|
||||
"import": "./dist/project/index.mjs",
|
||||
"require": "./dist/project/index.js"
|
||||
},
|
||||
"./project/*": {
|
||||
"types": "./dist/project/*.d.ts",
|
||||
"import": "./dist/project/*.mjs",
|
||||
"require": "./dist/project/*.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.mjs",
|
||||
"require": "./dist/ui/index.js"
|
||||
},
|
||||
"./ui/*": {
|
||||
"types": "./dist/ui/*.d.ts",
|
||||
"import": "./dist/ui/*.mjs",
|
||||
"require": "./dist/ui/*.js"
|
||||
},
|
||||
"./docs/style.css": "./docs/css/style.css",
|
||||
"./docs/css/*": "./docs/css/*",
|
||||
"./docs/page": {
|
||||
"types": "./docs/page.tsx",
|
||||
"import": "./docs/page.tsx"
|
||||
},
|
||||
"./docs/mdx": {
|
||||
"types": "./docs/mdx.tsx",
|
||||
"import": "./docs/mdx.tsx"
|
||||
},
|
||||
"./docs/og": {
|
||||
"types": "./docs/og.tsx",
|
||||
"import": "./docs/og.tsx"
|
||||
},
|
||||
"./docs/i18n": {
|
||||
"types": "./docs/i18n.tsx",
|
||||
"import": "./docs/i18n.tsx"
|
||||
},
|
||||
"./docs/layouts/docs": {
|
||||
"types": "./docs/layouts/docs/index.tsx",
|
||||
"import": "./docs/layouts/docs/index.tsx"
|
||||
},
|
||||
"./docs/layouts/home": {
|
||||
"types": "./docs/layouts/home/index.tsx",
|
||||
"import": "./docs/layouts/home/index.tsx"
|
||||
},
|
||||
"./docs/layouts/notebook": {
|
||||
"types": "./docs/layouts/notebook/index.tsx",
|
||||
"import": "./docs/layouts/notebook/index.tsx"
|
||||
},
|
||||
"./docs/provider/next": {
|
||||
"types": "./docs/provider/next.tsx",
|
||||
"import": "./docs/provider/next.tsx"
|
||||
},
|
||||
"./docs/provider/*": {
|
||||
"types": "./docs/provider/*.tsx",
|
||||
"import": "./docs/provider/*.tsx"
|
||||
},
|
||||
"./docs/components": {
|
||||
"types": "./docs/components/index.ts",
|
||||
"import": "./docs/components/index.ts"
|
||||
},
|
||||
"./docs/components/*": {
|
||||
"types": "./docs/components/*.tsx",
|
||||
"import": "./docs/components/*.tsx"
|
||||
},
|
||||
"./docs/contexts/*": {
|
||||
"types": "./docs/contexts/*.tsx",
|
||||
"import": "./docs/contexts/*.tsx"
|
||||
},
|
||||
"./docs/utils/*": {
|
||||
"types": "./docs/utils/*.ts",
|
||||
"import": "./docs/utils/*.ts"
|
||||
},
|
||||
"./docs/source": {
|
||||
"types": "./docs/source.ts",
|
||||
"import": "./docs/source.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@next/third-parties": "^16.2.1",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-use-callback-ref": "^1.1.1",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"buffer": "^6.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"commander": "^14.0.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"little-date": "^1.2.1",
|
||||
"lodash.castarray": "^4.4.0",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"postcss-selector-parser": "^7.1.1",
|
||||
"react-intersection-observer": "^10.0.3",
|
||||
"sonner": "^2.0.7",
|
||||
"svg-pan-zoom": "^3.6.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tailwindcss-interaction-media": "^0.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.25.76",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
"tc": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.0.0",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.0",
|
||||
"@hanzo/docs-core": "^16.0.0",
|
||||
"@hanzogui/admin": ">=0.3.0",
|
||||
"@hanzogui/lucide-icons-2": ">=7.0.0",
|
||||
"hanzogui": ">=7.0.0",
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@rainbow-me/rainbowkit": "^2.0.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-table": "^8.0.0",
|
||||
"chrono-node": "^2.0.0",
|
||||
"date-fns": "^3.0.0",
|
||||
"framer-motion": "^11.0.0",
|
||||
"lucide-react": ">=0.456.0",
|
||||
"mermaid": "^10.0.0",
|
||||
"mobx": "^6.12.3",
|
||||
"next": ">=14.2.16",
|
||||
"next-themes": "^0.2.1 || ^0.3.0 || ^0.4.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-day-picker": "^9.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react-hook-form": "^7.0.0",
|
||||
"react-qrcode-logo": "^2.0.0",
|
||||
"react-resizable-panels": "^3.0.0",
|
||||
"recharts": "^2.0.0",
|
||||
"sql.js": "^1.0.0",
|
||||
"styled-components": "^6.0.0",
|
||||
"validator": "^13.11.0",
|
||||
"wagmi": "^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"styled-components": {
|
||||
"optional": true
|
||||
},
|
||||
"@dnd-kit/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@dnd-kit/modifiers": {
|
||||
"optional": true
|
||||
},
|
||||
"@dnd-kit/sortable": {
|
||||
"optional": true
|
||||
},
|
||||
"@dnd-kit/utilities": {
|
||||
"optional": true
|
||||
},
|
||||
"@hanzo/docs-core": {
|
||||
"optional": true
|
||||
},
|
||||
"@hanzogui/admin": {
|
||||
"optional": true
|
||||
},
|
||||
"@hanzogui/lucide-icons-2": {
|
||||
"optional": true
|
||||
},
|
||||
"hanzogui": {
|
||||
"optional": true
|
||||
},
|
||||
"@modelcontextprotocol/sdk": {
|
||||
"optional": true
|
||||
},
|
||||
"@tanstack/react-query": {
|
||||
"optional": true
|
||||
},
|
||||
"@tanstack/react-table": {
|
||||
"optional": true
|
||||
},
|
||||
"chrono-node": {
|
||||
"optional": true
|
||||
},
|
||||
"cmdk": {
|
||||
"optional": true
|
||||
},
|
||||
"date-fns": {
|
||||
"optional": true
|
||||
},
|
||||
"embla-carousel": {
|
||||
"optional": true
|
||||
},
|
||||
"embla-carousel-react": {
|
||||
"optional": true
|
||||
},
|
||||
"framer-motion": {
|
||||
"optional": true
|
||||
},
|
||||
"input-otp": {
|
||||
"optional": true
|
||||
},
|
||||
"mermaid": {
|
||||
"optional": true
|
||||
},
|
||||
"react-day-picker": {
|
||||
"optional": true
|
||||
},
|
||||
"react-hook-form": {
|
||||
"optional": true
|
||||
},
|
||||
"react-qrcode-logo": {
|
||||
"optional": true
|
||||
},
|
||||
"react-resizable-panels": {
|
||||
"optional": true
|
||||
},
|
||||
"recharts": {
|
||||
"optional": true
|
||||
},
|
||||
"sonner": {
|
||||
"optional": true
|
||||
},
|
||||
"sql.js": {
|
||||
"optional": true
|
||||
},
|
||||
"vaul": {
|
||||
"optional": true
|
||||
},
|
||||
"@rainbow-me/rainbowkit": {
|
||||
"optional": true
|
||||
},
|
||||
"wagmi": {
|
||||
"optional": true
|
||||
}
|
||||
"@hanzo/data": ">=1.2.0",
|
||||
"@hanzo/gui": ">=7.2.2",
|
||||
"react": ">=19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@radix-ui/react-primitive": "^2.1.4",
|
||||
"@splinetool/react-spline": "^4.1.0",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/facebook-pixel": "^0.0.31",
|
||||
"@types/gtag.js": "^0.0.20",
|
||||
"@types/lodash.merge": "^4.6.9",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "25.5.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/styled-components": "^5.1.36",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"@vitest/ui": "^4.1.0",
|
||||
"embla-carousel": "^8.6.0",
|
||||
"filesize": "^11.0.13",
|
||||
"glob": "^13.0.6",
|
||||
"jest": "^30.3.0",
|
||||
"jsdom": "^29.0.1",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"styled-components": "^6.3.12",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tsup": "^8.5.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "4.1.0"
|
||||
}
|
||||
"@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>"
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useState } from 'react'
|
||||
|
||||
export interface IAMLoginButtonProps {
|
||||
/** IAM server URL (e.g., https://hanzo.id) */
|
||||
iamUrl?: string
|
||||
/** OAuth client ID */
|
||||
clientId: string
|
||||
/** OAuth redirect URI (defaults to current origin + /auth/callback) */
|
||||
redirectUri?: string
|
||||
/** Button label */
|
||||
label?: string
|
||||
/** Additional CSS classes */
|
||||
className?: string
|
||||
/** Button variant: 'default' (filled) or 'outline' */
|
||||
variant?: 'default' | 'outline'
|
||||
}
|
||||
|
||||
/**
|
||||
* "Sign in with Hanzo" button that initiates IAM OAuth flow.
|
||||
* Zero-dependency — works in any React app.
|
||||
*/
|
||||
export function IAMLoginButton({
|
||||
iamUrl = 'https://hanzo.id',
|
||||
clientId,
|
||||
redirectUri,
|
||||
label = 'Sign in with Hanzo',
|
||||
className = '',
|
||||
variant = 'default',
|
||||
}: IAMLoginButtonProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setLoading(true)
|
||||
const redirect = redirectUri || `${window.location.origin}/auth/callback`
|
||||
const state = crypto.randomUUID()
|
||||
|
||||
const url = new URL(`${iamUrl}/login/oauth/authorize`)
|
||||
url.searchParams.set('client_id', clientId)
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('redirect_uri', redirect)
|
||||
url.searchParams.set('scope', 'openid profile email')
|
||||
url.searchParams.set('state', state)
|
||||
|
||||
window.location.href = url.toString()
|
||||
}, [iamUrl, clientId, redirectUri])
|
||||
|
||||
const baseStyles = 'inline-flex items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50'
|
||||
const variantStyles = variant === 'outline'
|
||||
? 'border border-[#333] bg-transparent text-white hover:bg-[#1a1a1f]'
|
||||
: 'bg-white text-black hover:bg-[#e5e5e5]'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`${baseStyles} ${variantStyles} ${className}`}
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<text x="4" y="26" fontFamily="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" fontSize="28" fontWeight="700" fill="currentColor">H</text>
|
||||
</svg>
|
||||
{loading ? 'Redirecting...' : label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export { IAMLoginButton } from './IAMLoginButton'
|
||||
export type { IAMLoginButtonProps } from './IAMLoginButton'
|
||||
export { AuthGuard } from './AuthGuard'
|
||||
export type { AuthGuardProps } from './AuthGuard'
|
||||
|
||||
// Re-export auth-related navigation components for convenience
|
||||
export { useHanzoAuth } from '../navigation/hanzo-shell/useHanzoAuth'
|
||||
export { UserOrgDropdown } from '../navigation/hanzo-shell/UserOrgDropdown'
|
||||
export type { HanzoUser, HanzoOrg } from '../navigation/hanzo-shell/types'
|
||||
@@ -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'
|
||||
@@ -1,14 +0,0 @@
|
||||
// @hanzo/ui/primitives/bases/admin — admin chrome + primitives.
|
||||
//
|
||||
// Source of truth: `@hanzogui/admin` (in ~/work/hanzo/gui/code/ui-admin).
|
||||
// Re-export the shared admin shell (Sidebar, TopBar, AdminApp,
|
||||
// PageShell, NamespaceSwitcher), primitives (Badge, Empty, Loading,
|
||||
// Alert, CopyField, DataTable, SummaryCard, etc.), data hooks
|
||||
// (useFetch, useEvents), and the IAM admin pages once Bucket A-F port
|
||||
// lands.
|
||||
//
|
||||
// Do NOT re-implement. Upstream lands new components in
|
||||
// ~/work/hanzo/gui/code/ui-admin/ and they appear here through this
|
||||
// re-export.
|
||||
|
||||
export * from '@hanzogui/admin'
|
||||
@@ -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'
|
||||
@@ -1,15 +0,0 @@
|
||||
// @hanzo/ui/primitives/bases/gui — Hanzo GUI v7 base.
|
||||
//
|
||||
// Source of truth: `hanzogui` (the workspace umbrella in
|
||||
// ~/work/hanzo/gui/code/ui/hanzogui). This file is a passthrough
|
||||
// re-export so consumers of @hanzo/ui can pull Hanzo GUI v7 components
|
||||
// without depending on hanzogui directly. Subpath resolution makes
|
||||
// `@hanzo/ui/primitives/bases/gui` equivalent to `hanzogui` from a
|
||||
// caller's perspective, but going through @hanzo/ui keeps the import
|
||||
// path stable across future stack swaps.
|
||||
//
|
||||
// Do NOT re-implement primitives here. If a component is missing from
|
||||
// `hanzogui`, add it upstream in ~/work/hanzo/gui and let it flow
|
||||
// through this re-export.
|
||||
|
||||
export * from 'hanzogui'
|
||||
@@ -1,18 +0,0 @@
|
||||
// @hanzo/ui/primitives/bases/svelte — Svelte adapter (placeholder).
|
||||
//
|
||||
// Status: NOT YET IMPLEMENTED. The Svelte port lives outside this
|
||||
// repo today (e.g., the upstream temporal/ui SvelteKit reference at
|
||||
// ~/work/temporal/ui). When a Svelte port of @hanzogui/admin is
|
||||
// authored, it lands at ~/work/hanzo/gui/code/ui-admin-svelte/ (or
|
||||
// equivalent) and this file re-exports it.
|
||||
//
|
||||
// Importing from this subpath today throws at build time so callers
|
||||
// get a clear signal rather than silent missing components.
|
||||
|
||||
throw new Error(
|
||||
'@hanzo/ui/primitives/bases/svelte is not yet implemented. ' +
|
||||
'Use @hanzo/ui/primitives/bases/admin (Hanzo GUI v7 admin chrome) or @hanzo/ui/primitives/bases/gui (hanzogui umbrella) ' +
|
||||
'until the Svelte port lands.',
|
||||
)
|
||||
|
||||
export {}
|
||||
@@ -1,16 +0,0 @@
|
||||
// @hanzo/ui/primitives/bases/vue — Vue adapter (placeholder).
|
||||
//
|
||||
// Status: NOT YET IMPLEMENTED. When a Vue port of @hanzogui/admin
|
||||
// lands at ~/work/hanzo/gui/code/ui-admin-vue/ (or equivalent), this
|
||||
// file re-exports it.
|
||||
//
|
||||
// Importing from this subpath today throws so callers get a clear
|
||||
// signal.
|
||||
|
||||
throw new Error(
|
||||
'@hanzo/ui/primitives/bases/vue is not yet implemented. ' +
|
||||
'Use @hanzo/ui/primitives/bases/admin (Hanzo GUI v7 admin chrome) or @hanzo/ui/primitives/bases/gui (hanzogui umbrella) ' +
|
||||
'until the Vue port lands.',
|
||||
)
|
||||
|
||||
export {}
|
||||
@@ -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 }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user