diff --git a/packages/bindx-react/src/jsx/withCollector.ts b/packages/bindx-react/src/jsx/withCollector.ts index 6b26d41..42d5a1b 100644 --- a/packages/bindx-react/src/jsx/withCollector.ts +++ b/packages/bindx-react/src/jsx/withCollector.ts @@ -7,39 +7,35 @@ import type { ReactNode } from 'react' * analyzing runtime children. Props contain collector proxies, so field accesses * are tracked automatically. The returned JSX is analyzed for nested components. * + * When called with a single argument, the component function itself is used as + * staticRender. This works when the component is pure JSX (no hooks) and delegates + * to an inner component that has its own `getSelection`. + * * @example * ```tsx - * // Simple: children is a render prop receiving has-one entity - * export const SelectField = withCollector( - * function SelectField({ field, children, ... }) { ... }, - * (props) => props.children(props.field.$entity) - * ) - * - * // Composing with HasMany for iteration - * export const DefaultRepeater = withCollector( - * function DefaultRepeater({ field, children, ... }) { ... }, - * (props) => ( - * - * {item => props.children(item, collectionItemInfo)} - * - * ) + * // Single-arg: component IS the staticRender (no hooks, delegates to inner) + * export const StyledRepeater = withCollector( + * function StyledRepeater({ field, children }) { + * return ( + * + * {(items) =>
{items.map((e, info) => children(e, info))}
} + *
+ * ) + * } * ) * - * // Programmatic field access (no JSX needed) - * export const Uploader = withCollector( - * function Uploader({ field, fileType }) { ... }, - * (props) => { - * const entity = props.field.$entity - * for (const ext of props.fileType.extractors) entity[ext.fieldName] - * return null - * } + * // Two-arg: explicit staticRender for components with hooks or custom collection + * export const SelectField = withCollector( + * function SelectField({ field, children }) { ... }, + * (props) => props.children(props.field.$entity) * ) * ``` */ export function withCollector ReactNode>( component: TComponent, - staticRender: (props: Parameters[0]) => ReactNode, + staticRender?: (props: Parameters[0]) => ReactNode, ): TComponent { - (component as TComponent & { staticRender: typeof staticRender }).staticRender = staticRender + const render = staticRender ?? component as unknown as (props: Parameters[0]) => ReactNode + ;(component as TComponent & { staticRender: typeof render }).staticRender = render return component } diff --git a/packages/bindx-repeater/src/components/BlockRepeater.tsx b/packages/bindx-repeater/src/components/BlockRepeater.tsx index 697223c..9fda3fa 100644 --- a/packages/bindx-repeater/src/components/BlockRepeater.tsx +++ b/packages/bindx-repeater/src/components/BlockRepeater.tsx @@ -71,7 +71,7 @@ export function BlockRepeater< sortableBy, blocks, children, -}: BlockRepeaterProps): ReactElement { +}: BlockRepeaterProps): ReactElement | null { const fieldAccessor = useHasMany(field) const sortedItems = useSortedItems(fieldAccessor, sortableBy) @@ -198,6 +198,9 @@ export function BlockRepeater< } }, [field, sortableBy, blocks, discriminationField]) + if (!children) { + return null + } return <>{children(items, methods)} } @@ -219,44 +222,88 @@ function createBlockRepeaterWithSelection() { : null const scope = new SelectionScope() - const collectorEntity = createCollectorProxy(scope) - - const mockItems: BlockRepeaterItems = { - map: (fn) => { - fn(collectorEntity, { - index: 0, - isFirst: true, - isLast: true, - remove: () => {}, - moveUp: () => {}, - moveDown: () => {}, - blockType: null, - block: undefined, - }) - return [] - }, - length: 0, - } + const collectorEntity = createCollectorProxy(scope) + + const blockNames = Object.keys(props.blocks) as string[] + const blocksRecord = props.blocks as Record + + // Path 1: collect field deps from block renderers + // Calls staticRender if present, or any other callable properties (render, form) + // that accept (entity, info) and return ReactNode. + for (const blockName of blockNames) { + const block = blocksRecord[blockName] + if (!block) continue + + const mockInfo: BlockRepeaterItemInfo = { + index: 0, + isFirst: true, + isLast: true, + remove: () => {}, + moveUp: () => {}, + moveDown: () => {}, + blockType: blockName, + block: { name: blockName, label: block.label }, + } + + const renderers = block.staticRender + ? [block.staticRender] + : collectBlockRenderers(block) - const mockMethods: BlockRepeaterMethods = { - addItem: () => {}, - isEmpty: true, - blockList: [], + for (const renderer of renderers) { + const jsx = renderer(collectorEntity, mockInfo) + if (jsx) { + collectNested(jsx) + } + } } - const syntheticChildren = props.children(mockItems, mockMethods) + // Path 2: children callback — for headless use or when blocks lack staticRender + let jsxSelection: SelectionMeta | undefined + if (props.children) { + const mockItems: BlockRepeaterItems = { + map: (fn) => { + for (const blockName of blockNames) { + fn(collectorEntity, { + index: 0, + isFirst: true, + isLast: true, + remove: () => {}, + moveUp: () => {}, + moveDown: () => {}, + blockType: blockName, + block: { name: blockName, label: blocksRecord[blockName]?.label }, + }) + } + // Also call with null blockType for any fallback/default rendering paths + fn(collectorEntity, { + index: 0, + isFirst: true, + isLast: true, + remove: () => {}, + moveUp: () => {}, + moveDown: () => {}, + blockType: null, + block: undefined, + }) + return [] + }, + length: 0, + } + + const mockMethods: BlockRepeaterMethods = { + addItem: () => {}, + isEmpty: true, + blockList: [], + } - // Call block render/form functions so the collector proxy records field accesses - const blockJsx: ReactNode[] = [] - for (const blockDef of Object.values(props.blocks) as BlockDefinition[]) { - if (blockDef.render) blockJsx.push(blockDef.render(collectorEntity as EntityAccessor)) - if (blockDef.form) blockJsx.push(blockDef.form(collectorEntity as EntityAccessor)) + const syntheticChildren = props.children(mockItems, mockMethods) + jsxSelection = collectNested(syntheticChildren) } - const jsxSelection = collectNested([syntheticChildren, ...blockJsx]) - const nestedSelection = scope.toSelectionMeta() - mergeSelections(nestedSelection, jsxSelection) + if (jsxSelection) { + mergeSelections(nestedSelection, jsxSelection) + } // Add discrimination field to selection nestedSelection.fields.set(props.discriminationField, { @@ -301,3 +348,19 @@ function createBlockRepeaterWithSelection() { } export const BlockRepeaterWithMeta = createBlockRepeaterWithSelection() + +type BlockRenderer = (entity: EntityAccessor, info: BlockRepeaterItemInfo) => ReactNode + +/** + * Discovers callable renderer functions on a block definition (e.g., render, form). + * Used during selection collection to call all renderers with a collector proxy. + */ +function collectBlockRenderers(block: BlockDefinition): BlockRenderer[] { + const renderers: BlockRenderer[] = [] + for (const value of Object.values(block)) { + if (typeof value === 'function') { + renderers.push(value as BlockRenderer) + } + } + return renderers +} diff --git a/packages/bindx-repeater/src/components/index.ts b/packages/bindx-repeater/src/components/index.ts index af224c9..56928c6 100644 --- a/packages/bindx-repeater/src/components/index.ts +++ b/packages/bindx-repeater/src/components/index.ts @@ -1,2 +1,2 @@ -export { Repeater, RepeaterWithMeta } from './Repeater.js' -export { BlockRepeater, BlockRepeaterWithMeta } from './BlockRepeater.js' +export { Repeater } from './Repeater.js' +export { BlockRepeater } from './BlockRepeater.js' diff --git a/packages/bindx-repeater/src/index.ts b/packages/bindx-repeater/src/index.ts index cca662b..e9f5376 100644 --- a/packages/bindx-repeater/src/index.ts +++ b/packages/bindx-repeater/src/index.ts @@ -33,5 +33,5 @@ export { } from './utils/index.js' // Components -export { Repeater, RepeaterWithMeta } from './components/index.js' -export { BlockRepeater, BlockRepeaterWithMeta } from './components/index.js' +export { Repeater } from './components/index.js' +export { BlockRepeater } from './components/index.js' diff --git a/packages/bindx-repeater/src/types.ts b/packages/bindx-repeater/src/types.ts index 82b024b..58867e8 100644 --- a/packages/bindx-repeater/src/types.ts +++ b/packages/bindx-repeater/src/types.ts @@ -133,10 +133,15 @@ export interface RepeaterProps< */ export interface BlockDefinition { label?: ReactNode - /** Render function for block preview. Used for selection collection. */ - render?: (entity: EntityAccessor) => ReactNode - /** Form function for block editing. Used for selection collection. */ - form?: (entity: EntityAccessor) => ReactNode + /** + * Called during selection collection to discover field dependencies for this block type. + * Receives a collector proxy entity and block info. + * Return JSX that accesses all fields this block type needs. + * + * When present, core getSelection calls this directly per block type + * instead of relying on the children callback for field discovery. + */ + staticRender?: (entity: EntityAccessor, info: BlockRepeaterItemInfo) => ReactNode } /** @@ -223,5 +228,5 @@ export interface BlockRepeaterProps< blocks: Record /** Render function that receives items collection and methods */ - children: BlockRepeaterRenderFn + children?: BlockRepeaterRenderFn } diff --git a/packages/bindx-ui/package.json b/packages/bindx-ui/package.json index 00f5716..236b7ce 100644 --- a/packages/bindx-ui/package.json +++ b/packages/bindx-ui/package.json @@ -3,7 +3,10 @@ "version": "0.1.23", "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./vite-plugin": "./src/vite-plugin.ts", + "./utils": "./src/utils/index.ts", + "./_internal/*": "./src/*" }, "scripts": { "build": "vite build", @@ -31,7 +34,13 @@ "tailwind-merge": "^3.3.1" }, "peerDependencies": { - "react": ">=18.0.0" + "react": ">=18.0.0", + "vite": ">=5.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } }, "devDependencies": { "@types/react": "^19.1.8", @@ -43,5 +52,11 @@ "url": "https://github.com/contember/bindx.git", "directory": "packages/bindx-ui" }, - "license": "MIT" + "license": "MIT", + "bin": { + "bindx-ui": "./dist/cli/cli.js" + }, + "imports": { + "#bindx-ui/*": "./src/*" + } } diff --git a/packages/bindx-ui/src/cli/agent-prompt.ts b/packages/bindx-ui/src/cli/agent-prompt.ts new file mode 100644 index 0000000..d02e174 --- /dev/null +++ b/packages/bindx-ui/src/cli/agent-prompt.ts @@ -0,0 +1,129 @@ +export interface AgentPromptArgs { + componentPath: string + ejectVersion: string + currentVersion: string + localDiff: string + upstreamDiff: string + localContent: string + upstreamContent: string + localFilePath: string +} + +export interface AgentBatchSummaryItem { + componentPath: string + ejectVersion: string + status: 'auto-updated' | 'merge-needed' | 'no-git-ref' | 'upstream-removed' | 'local-missing' + localFilePath: string +} + +export function generateAgentPrompt(args: AgentPromptArgs): string { + return `You are backporting upstream changes to an ejected bindx-ui component. + +## Component: ${args.componentPath} +Ejected from @contember/bindx-ui@${args.ejectVersion}, current: @${args.currentVersion} + +## What the user changed (base → local): +\`\`\`diff +${args.localDiff} +\`\`\` + +## What upstream changed (base → upstream): +\`\`\`diff +${args.upstreamDiff} +\`\`\` + +## Current local file: +\`\`\`tsx +${args.localContent} +\`\`\` + +## Current upstream file: +\`\`\`tsx +${args.upstreamContent} +\`\`\` + +## Task +Apply upstream changes while preserving user modifications. +- User changes take priority on conflicts. +- When upstream adds new code, include it. +- When upstream renames/refactors, apply consistently. +- Update header to current version. +- If ambiguous, use AskUserQuestion to clarify. + +Write the merged result to: ${args.localFilePath} +Then run: bindx-ui backport --sync ${args.componentPath} +` +} + +export function generateAgentBatchPrompt( + items: AgentBatchSummaryItem[], + currentVersion: string, + autoUpdated: string[], + upToDate: string[], +): string { + const mergeNeeded = items.filter(i => i.status === 'merge-needed') + const noGitRef = items.filter(i => i.status === 'no-git-ref') + const removed = items.filter(i => i.status === 'upstream-removed') + const localMissing = items.filter(i => i.status === 'local-missing') + + let prompt = `You are backporting upstream changes across multiple ejected bindx-ui components. +Current package version: @contember/bindx-ui@${currentVersion} + +## Summary +` + + if (upToDate.length > 0) { + prompt += `- **Already up to date**: ${upToDate.length} component(s) — no action needed\n` + } + if (autoUpdated.length > 0) { + prompt += `- **Auto-updated** (no local changes): ${autoUpdated.join(', ')}\n` + } + if (mergeNeeded.length > 0) { + prompt += `- **Merge needed**: ${mergeNeeded.length} component(s) — see below\n` + } + if (noGitRef.length > 0) { + prompt += `- **No git ref** (cannot diff): ${noGitRef.map(i => i.componentPath).join(', ')} — re-eject these or merge manually\n` + } + if (removed.length > 0) { + prompt += `- **Removed upstream**: ${removed.map(i => i.componentPath).join(', ')} — review if still needed\n` + } + if (localMissing.length > 0) { + prompt += `- **Local file missing**: ${localMissing.map(i => i.componentPath).join(', ')} — restore or re-eject\n` + } + + if (mergeNeeded.length === 0) { + prompt += `\nNothing to merge — all components were auto-updated or already up to date.\n` + return prompt + } + + if (mergeNeeded.length <= 5) { + prompt += `\n## Components to merge\n\n` + prompt += `For each component below, get the full diff details by running:\n` + prompt += `\`\`\`bash\nbindx-ui backport --agent \n\`\`\`\n\n` + prompt += `Components:\n` + for (const item of mergeNeeded) { + prompt += `- \`${item.componentPath}\` (ejected from v${item.ejectVersion}) — file: ${item.localFilePath}\n` + } + prompt += `\n## Workflow for each component\n` + prompt += `1. Run \`bindx-ui backport --agent ${mergeNeeded[0]!.componentPath}\` to see diffs\n` + prompt += `2. Read the diffs, apply upstream changes while preserving user modifications\n` + prompt += `3. Write the merged file\n` + prompt += `4. Run \`bindx-ui backport --sync \` to update metadata\n` + prompt += `5. If a component should be skipped, run \`bindx-ui backport --skip \`\n` + } else { + prompt += `\n## ${mergeNeeded.length} components need merging\n\n` + prompt += `Too many to show inline. Process them one by one:\n\n` + prompt += `\`\`\`bash\n# Get details for a specific component:\nbindx-ui backport --agent \n\n` + prompt += `# After merging, sync metadata:\nbindx-ui backport --sync \n\n` + prompt += `# To skip a component:\nbindx-ui backport --skip \n\`\`\`\n\n` + prompt += `Components to merge:\n` + for (const item of mergeNeeded) { + prompt += `- \`${item.componentPath}\` (v${item.ejectVersion}) — ${item.localFilePath}\n` + } + } + + prompt += `\n## After all components are processed\n` + prompt += `Run \`bindx-ui status\` to verify everything is up to date.\n` + + return prompt +} diff --git a/packages/bindx-ui/src/cli/backport.ts b/packages/bindx-ui/src/cli/backport.ts new file mode 100644 index 0000000..5f7f026 --- /dev/null +++ b/packages/bindx-ui/src/cli/backport.ts @@ -0,0 +1,365 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { tmpdir } from 'node:os' +import { loadMetadata, saveMetadata, type BindxUIMetadata, type EjectedEntry } from './metadata.js' +import { discoverComponents, type ComponentEntry } from './registry.js' +import { getPackageVersion } from './paths.js' +import { getGitRef, getGitPath, getOriginalSource } from './git.js' +import { threeWayMerge } from './merge.js' +import { generateAgentPrompt, generateAgentBatchPrompt, type AgentBatchSummaryItem } from './agent-prompt.js' +import { hashContent, stripHeader, isExecError } from './utils.js' + +interface BackportOptions { + agent?: boolean + dryRun?: boolean +} + +export function backport(componentPath: string, targetDir: string, options: BackportOptions): void { + const metadata = loadMetadata(targetDir) + const entry = metadata.ejected[componentPath] + + if (!entry) { + console.error(`Component ${componentPath} is not ejected.`) + process.exit(1) + } + + const components = discoverComponents() + const component = components.find(c => c.path === componentPath) + const localPath = resolve(targetDir, componentPath + '.tsx') + + // Edge case: upstream removed + if (!component) { + console.log(` ✗ ${componentPath} — removed from upstream package. Your local file is preserved.`) + return + } + + // Edge case: local file missing + if (!existsSync(localPath)) { + console.log(` ✗ ${componentPath} — local file missing. Run 'bindx-ui eject ${componentPath}' to re-eject.`) + return + } + + // Edge case: no git ref + if (!entry.gitRef || !entry.gitPath) { + if (options.agent) { + console.error(`Git ref not available for ${componentPath}. Cannot generate diffs. Re-eject the component to enable backporting.`) + } else { + console.error(`Git ref not available for ${componentPath}. Re-eject the component or use --agent for AI-assisted merge.`) + } + process.exit(1) + } + + // Edge case: git history unavailable (shallow clone, rewritten history) + let baseSource: string + try { + baseSource = getOriginalSource(entry.gitRef, entry.gitPath) + } catch { + console.error(`Cannot retrieve base version at ${entry.gitRef}:${entry.gitPath}.`) + console.error(`The git history may be shallow or rewritten. Try: git fetch --unshallow`) + process.exit(1) + } + + const upstreamSource = readFileSync(component.sourcePath, 'utf-8') + const localRaw = readFileSync(localPath, 'utf-8') + const localSource = stripHeader(localRaw) + + const baseHash = hashContent(baseSource) + const upstreamHash = hashContent(upstreamSource) + const localHash = hashContent(localSource) + const version = getPackageVersion() + + // Fast path: upstream unchanged + if (baseHash === upstreamHash) { + console.log(` ✓ ${componentPath} — already up to date`) + return + } + + // Fast path: user hasn't modified → auto-update + if (baseHash === localHash) { + if (options.dryRun) { + console.log(` → ${componentPath} — would auto-update (no local changes)`) + return + } + const header = createHeader(version, componentPath) + writeFileSync(localPath, header + upstreamSource, 'utf-8') + updateMetadata(metadata, componentPath, component.sourcePath, version, upstreamSource) + saveMetadata(targetDir, metadata) + console.log(` ✓ ${componentPath} — auto-updated (no local changes)`) + return + } + + // Fast path: local already matches upstream + if (localHash === upstreamHash) { + if (options.dryRun) { + console.log(` ✓ ${componentPath} — local matches upstream, would update metadata`) + return + } + updateMetadata(metadata, componentPath, component.sourcePath, version, upstreamSource) + saveMetadata(targetDir, metadata) + console.log(` ✓ ${componentPath} — local matches upstream, metadata updated`) + return + } + + // Agent mode: print prompt + if (options.agent) { + const diffs = computeDiffs(baseSource, localSource, upstreamSource) + const prompt = generateAgentPrompt({ + componentPath, + ejectVersion: entry.version, + currentVersion: version, + localDiff: diffs.localDiff, + upstreamDiff: diffs.upstreamDiff, + localContent: localRaw, + upstreamContent: upstreamSource, + localFilePath: localPath, + }) + console.log(prompt) + return + } + + if (options.dryRun) { + console.log(` ⚠ ${componentPath} — both changed, merge needed`) + return + } + + // Three-way merge + const result = threeWayMerge(localSource, baseSource, upstreamSource) + + if (result.status === 'clean') { + const header = createHeader(version, componentPath) + writeFileSync(localPath, header + result.content, 'utf-8') + updateMetadata(metadata, componentPath, component.sourcePath, version, upstreamSource) + saveMetadata(targetDir, metadata) + console.log(` ✓ ${componentPath} — merged cleanly`) + return + } + + if (result.status === 'conflict') { + const header = createHeader(version, componentPath) + writeFileSync(localPath, header + result.content, 'utf-8') + // Update metadata to track conflict state — prevents re-merging conflict markers on next run + updateMetadata(metadata, componentPath, component.sourcePath, version, upstreamSource) + saveMetadata(targetDir, metadata) + console.log(` ⚠ ${componentPath} — ${result.conflictCount} conflict(s), resolve manually or use --agent`) + return + } + + console.error(` ✗ ${componentPath} — merge failed, use --agent for AI-assisted merge`) +} + +export function backportAll(targetDir: string, options: BackportOptions): void { + const metadata = loadMetadata(targetDir) + const paths = Object.keys(metadata.ejected).sort() + + if (paths.length === 0) { + console.log('No ejected components.') + return + } + + // In non-agent mode, just iterate + if (!options.agent) { + for (const componentPath of paths) { + backport(componentPath, targetDir, options) + } + return + } + + // Agent mode: collect status for all components, auto-update what we can, then generate batch prompt + const components = discoverComponents() + const componentMap = new Map(components.map(c => [c.path, c])) + const version = getPackageVersion() + const batchItems: AgentBatchSummaryItem[] = [] + const autoUpdated: string[] = [] + const upToDate: string[] = [] + let metadataChanged = false + + for (const componentPath of paths) { + const entry = metadata.ejected[componentPath] + if (!entry) continue + + const component = componentMap.get(componentPath) + const localPath = resolve(targetDir, componentPath + '.tsx') + + // Edge case: upstream removed + if (!component) { + batchItems.push({ componentPath, ejectVersion: entry.version, status: 'upstream-removed', localFilePath: localPath }) + continue + } + + // Edge case: local file missing + if (!existsSync(localPath)) { + batchItems.push({ componentPath, ejectVersion: entry.version, status: 'local-missing', localFilePath: localPath }) + continue + } + + // Edge case: no git ref + if (!entry.gitRef || !entry.gitPath) { + batchItems.push({ componentPath, ejectVersion: entry.version, status: 'no-git-ref', localFilePath: localPath }) + continue + } + + // Edge case: git history unavailable + let baseSource: string + try { + baseSource = getOriginalSource(entry.gitRef, entry.gitPath) + } catch { + batchItems.push({ componentPath, ejectVersion: entry.version, status: 'no-git-ref', localFilePath: localPath }) + continue + } + + const upstreamSource = readFileSync(component.sourcePath, 'utf-8') + const localRaw = readFileSync(localPath, 'utf-8') + const localSource = stripHeader(localRaw) + + const baseHash = hashContent(baseSource) + const upstreamHash = hashContent(upstreamSource) + const localHash = hashContent(localSource) + + // Already up to date + if (baseHash === upstreamHash) { + upToDate.push(componentPath) + continue + } + + // Auto-update: user hasn't modified + if (baseHash === localHash) { + if (!options.dryRun) { + const header = createHeader(version, componentPath) + writeFileSync(localPath, header + upstreamSource, 'utf-8') + updateMetadata(metadata, componentPath, component.sourcePath, version, upstreamSource) + metadataChanged = true + } + autoUpdated.push(componentPath) + continue + } + + // Local matches upstream already + if (localHash === upstreamHash) { + if (!options.dryRun) { + updateMetadata(metadata, componentPath, component.sourcePath, version, upstreamSource) + metadataChanged = true + } + upToDate.push(componentPath) + continue + } + + // Both changed — needs merge + batchItems.push({ componentPath, ejectVersion: entry.version, status: 'merge-needed', localFilePath: localPath }) + } + + // Save metadata for auto-updated and hash-matched components + if (!options.dryRun && metadataChanged) { + saveMetadata(targetDir, metadata) + } + + // Generate batch prompt + const prompt = generateAgentBatchPrompt(batchItems, version, autoUpdated, upToDate) + console.log(prompt) +} + +export function syncMetadata(componentPath: string, targetDir: string): void { + const metadata = loadMetadata(targetDir) + const entry = metadata.ejected[componentPath] + + if (!entry) { + console.error(`Component ${componentPath} is not ejected.`) + process.exit(1) + } + + const components = discoverComponents() + const component = components.find(c => c.path === componentPath) + + if (!component) { + console.error(`Component ${componentPath} not found in package.`) + process.exit(1) + } + + const upstreamSource = readFileSync(component.sourcePath, 'utf-8') + const version = getPackageVersion() + + updateMetadata(metadata, componentPath, component.sourcePath, version, upstreamSource) + saveMetadata(targetDir, metadata) + console.log(` ✓ ${componentPath} — metadata synced to v${version}`) +} + +export function skipComponent(componentPath: string, targetDir: string): void { + const metadata = loadMetadata(targetDir) + const entry = metadata.ejected[componentPath] + + if (!entry) { + console.error(`Component ${componentPath} is not ejected.`) + process.exit(1) + } + + const components = discoverComponents() + const component = components.find(c => c.path === componentPath) + + if (!component) { + // Upstream removed — just update version to mark as acknowledged + entry.version = getPackageVersion() + saveMetadata(targetDir, metadata) + console.log(` ✓ ${componentPath} — skipped (upstream removed, acknowledged)`) + return + } + + // Update git ref to current HEAD so future backports use this as base + // This means: "I've seen the upstream changes and chose to keep my version" + const upstreamSource = readFileSync(component.sourcePath, 'utf-8') + const version = getPackageVersion() + + updateMetadata(metadata, componentPath, component.sourcePath, version, upstreamSource) + saveMetadata(targetDir, metadata) + console.log(` ✓ ${componentPath} — skipped (upstream changes acknowledged, base updated)`) +} + +function updateMetadata( + metadata: BindxUIMetadata, + componentPath: string, + sourcePath: string, + version: string, + upstreamSource: string, +): void { + metadata.ejected[componentPath] = { + path: componentPath, + version, + originalHash: hashContent(upstreamSource), + gitRef: getGitRef(), + gitPath: getGitPath(sourcePath), + } +} + +function computeDiffs(base: string, local: string, upstream: string): { localDiff: string; upstreamDiff: string } { + const tempDir = mkdtempSync(join(tmpdir(), 'bindx-diff-')) + const baseFile = join(tempDir, 'base') + const localFile = join(tempDir, 'local') + const upstreamFile = join(tempDir, 'upstream') + + try { + writeFileSync(baseFile, base, 'utf-8') + writeFileSync(localFile, local, 'utf-8') + writeFileSync(upstreamFile, upstream, 'utf-8') + + return { + localDiff: runDiff(baseFile, localFile), + upstreamDiff: runDiff(baseFile, upstreamFile), + } + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } +} + +function runDiff(fileA: string, fileB: string): string { + try { + return execFileSync('diff', ['-u', fileA, fileB], { encoding: 'utf-8' }) + } catch (error: unknown) { + if (isExecError(error)) { + return error.stdout + } + return '' + } +} + +function createHeader(version: string, path: string): string { + return `// Ejected from @contember/bindx-ui@${version} — ${path}\n` +} diff --git a/packages/bindx-ui/src/cli/cli.ts b/packages/bindx-ui/src/cli/cli.ts new file mode 100644 index 0000000..d72a0c5 --- /dev/null +++ b/packages/bindx-ui/src/cli/cli.ts @@ -0,0 +1,141 @@ +#!/usr/bin/env node + +import { resolve } from 'node:path' +import { eject } from './eject.js' +import { restore } from './restore.js' +import { status } from './status.js' +import { diff } from './diff.js' +import { backport, backportAll, syncMetadata, skipComponent } from './backport.js' + +const args = process.argv.slice(2) +const command = args[0] +const targetDir = resolve(process.cwd(), process.env['BINDX_UI_DIR'] ?? './src/ui') + +function printHelp(): void { + console.log(` +Usage: bindx-ui [options] + +Commands: + eject Eject a component for local customization + eject /* Eject all components in a folder + restore Restore a component to package default + status Show status of ejected components + diff Show diff between local and package version + diff upstream Show diff between base and current upstream + diff local Show diff between base and local file + backport Backport upstream changes to ejected component + backport --all Backport all ejected components + backport --sync Sync metadata after agent-assisted merge + backport --skip Skip backport, acknowledge upstream changes + +Options: + --agent Generate AI agent prompt instead of merging + --dry-run Show what would happen without making changes + --all Apply to all ejected components + +Examples: + bindx-ui eject form/text-input + bindx-ui eject form/* + bindx-ui restore form/text-input + bindx-ui status + bindx-ui diff form/text-input + bindx-ui diff upstream form/text-input + bindx-ui diff local form/text-input + bindx-ui backport form/text-input + bindx-ui backport --all + bindx-ui backport --agent form/text-input + bindx-ui backport --sync form/text-input + +Environment: + BINDX_UI_DIR Override target directory (default: ./src/ui) +`) +} + +function hasFlag(flag: string): boolean { + return args.includes(flag) +} + +function getNonFlagArgs(): string[] { + return args.slice(1).filter(a => !a.startsWith('--')) +} + +switch (command) { + case 'eject': { + const componentPath = args[1] + if (!componentPath) { + console.error('Missing component path. Usage: bindx-ui eject ') + process.exit(1) + } + eject(componentPath, targetDir) + break + } + case 'restore': { + const componentPath = args[1] + if (!componentPath) { + console.error('Missing component path. Usage: bindx-ui restore ') + process.exit(1) + } + restore(componentPath, targetDir) + break + } + case 'status': + status(targetDir) + break + case 'diff': { + const nonFlags = getNonFlagArgs() + const firstArg = nonFlags[0] + + if (firstArg === 'upstream' || firstArg === 'local') { + const componentPath = nonFlags[1] + if (!componentPath) { + console.error(`Missing component path. Usage: bindx-ui diff ${firstArg} `) + process.exit(1) + } + diff(componentPath, targetDir, firstArg) + } else { + if (!firstArg) { + console.error('Missing component path. Usage: bindx-ui diff ') + process.exit(1) + } + diff(firstArg, targetDir) + } + break + } + case 'backport': { + const isSync = hasFlag('--sync') + const isSkip = hasFlag('--skip') + const isAll = hasFlag('--all') + const isAgent = hasFlag('--agent') + const isDryRun = hasFlag('--dry-run') + const nonFlags = getNonFlagArgs() + const componentPath = nonFlags[0] + + if (isSync) { + if (!componentPath) { + console.error('Missing component path. Usage: bindx-ui backport --sync ') + process.exit(1) + } + syncMetadata(componentPath, targetDir) + } else if (isSkip) { + if (!componentPath) { + console.error('Missing component path. Usage: bindx-ui backport --skip ') + process.exit(1) + } + skipComponent(componentPath, targetDir) + } else if (isAll) { + backportAll(targetDir, { agent: isAgent, dryRun: isDryRun }) + } else { + if (!componentPath) { + console.error('Missing component path. Usage: bindx-ui backport ') + process.exit(1) + } + backport(componentPath, targetDir, { agent: isAgent, dryRun: isDryRun }) + } + break + } + default: + printHelp() + if (command && command !== '--help' && command !== '-h') { + process.exit(1) + } +} diff --git a/packages/bindx-ui/src/cli/diff.ts b/packages/bindx-ui/src/cli/diff.ts new file mode 100644 index 0000000..a605f20 --- /dev/null +++ b/packages/bindx-ui/src/cli/diff.ts @@ -0,0 +1,129 @@ +import { readFileSync, existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { discoverComponents } from './registry.js' +import { loadMetadata } from './metadata.js' +import { getOriginalSource } from './git.js' +import { stripHeader, isExecError } from './utils.js' + +export function diff(componentPath: string, targetDir: string, mode?: 'upstream' | 'local'): void { + if (mode === 'upstream') { + diffUpstream(componentPath, targetDir) + return + } + + if (mode === 'local') { + diffLocal(componentPath, targetDir) + return + } + + diffDefault(componentPath, targetDir) +} + +function diffDefault(componentPath: string, targetDir: string): void { + const component = findComponent(componentPath) + const localPath = resolve(targetDir, componentPath + '.tsx') + + if (!existsSync(localPath)) { + console.error(`No local override found: ${localPath}`) + process.exit(1) + } + + printDiff(component.sourcePath, localPath) +} + +function diffUpstream(componentPath: string, targetDir: string): void { + const metadata = loadMetadata(targetDir) + const entry = metadata.ejected[componentPath] + + if (!entry) { + console.error(`Component ${componentPath} is not ejected.`) + process.exit(1) + } + + if (!entry.gitRef || !entry.gitPath) { + console.error(`Git ref not available for ${componentPath}. Re-eject to enable this feature.`) + process.exit(1) + } + + const baseSource = getOriginalSource(entry.gitRef, entry.gitPath) + const component = findComponent(componentPath) + const upstreamSource = readFileSync(component.sourcePath, 'utf-8') + + printDiffFromStrings(baseSource, upstreamSource, 'base', 'upstream') +} + +function diffLocal(componentPath: string, targetDir: string): void { + const metadata = loadMetadata(targetDir) + const entry = metadata.ejected[componentPath] + + if (!entry) { + console.error(`Component ${componentPath} is not ejected.`) + process.exit(1) + } + + if (!entry.gitRef || !entry.gitPath) { + console.error(`Git ref not available for ${componentPath}. Re-eject to enable this feature.`) + process.exit(1) + } + + const baseSource = getOriginalSource(entry.gitRef, entry.gitPath) + const localPath = resolve(targetDir, componentPath + '.tsx') + + if (!existsSync(localPath)) { + console.error(`No local override found: ${localPath}`) + process.exit(1) + } + + const localRaw = readFileSync(localPath, 'utf-8') + const localSource = stripHeader(localRaw) + + printDiffFromStrings(baseSource, localSource, 'base', 'local') +} + +function findComponent(componentPath: string): { path: string; sourcePath: string } { + const components = discoverComponents() + const component = components.find(c => c.path === componentPath) + + if (!component) { + console.error(`Component not found in package: ${componentPath}`) + process.exit(1) + } + + return component +} + +function printDiff(fileA: string, fileB: string): void { + try { + const output = execFileSync('diff', ['-u', fileA, fileB], { encoding: 'utf-8' }) + if (output.length === 0) { + console.log('No differences.') + } else { + console.log(output) + } + } catch (error: unknown) { + if (isExecError(error)) { + console.log(error.stdout) + } else { + throw error + } + } +} + +function printDiffFromStrings(contentA: string, contentB: string, labelA: string, labelB: string): void { + const tempDir = mkdtempSync(join(tmpdir(), 'bindx-diff-')) + const fileA = join(tempDir, labelA) + const fileB = join(tempDir, labelB) + + try { + writeFileSync(fileA, contentA, 'utf-8') + writeFileSync(fileB, contentB, 'utf-8') + + printDiff(fileA, fileB) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } +} diff --git a/packages/bindx-ui/src/cli/eject.ts b/packages/bindx-ui/src/cli/eject.ts new file mode 100644 index 0000000..67fcc56 --- /dev/null +++ b/packages/bindx-ui/src/cli/eject.ts @@ -0,0 +1,79 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { discoverComponents } from './registry.js' +import { loadMetadata, saveMetadata, type EjectedEntry } from './metadata.js' +import { getPackageVersion } from './paths.js' +import { getGitRef, getGitPath } from './git.js' +import { hashContent } from './utils.js' + +export function eject(componentPath: string, targetDir: string): void { + const components = discoverComponents() + + const isGlob = componentPath.endsWith('/*') + const folder = isGlob ? componentPath.slice(0, -2) : null + + const toEject = folder + ? components.filter(c => c.path.startsWith(folder + '/')) + : components.filter(c => c.path === componentPath) + + if (toEject.length === 0) { + console.error(`Component not found: ${componentPath}`) + console.error(`Available components:`) + for (const c of components) { + console.error(` ${c.path}`) + } + process.exit(1) + } + + const version = getPackageVersion() + const metadata = loadMetadata(targetDir) + + for (const component of toEject) { + const targetPath = resolve(targetDir, component.path + '.tsx') + + if (existsSync(targetPath)) { + console.log(` ⚠ Skipping ${component.path} (already exists locally)`) + continue + } + + const source = readFileSync(component.sourcePath, 'utf-8') + const header = `// Ejected from @contember/bindx-ui@${version} — ${component.path}\n` + + mkdirSync(dirname(targetPath), { recursive: true }) + writeFileSync(targetPath, header + source, 'utf-8') + + const entry: EjectedEntry = { + path: component.path, + version, + originalHash: hashContent(source), + gitRef: getGitRef(), + gitPath: getGitPath(component.sourcePath), + } + metadata.ejected[component.path] = entry + + console.log(` ✓ Ejected ${component.path} → ${targetPath}`) + } + + saveMetadata(targetDir, metadata) + + // Skip dependents hint for glob ejections — the pattern won't match real imports + if (!isGlob) { + const dependents = findDependents(componentPath, components) + if (dependents.length > 0) { + console.log(`\n Used by: ${dependents.map(d => d.path).join(', ')}`) + console.log(` (these will auto-resolve your version via Vite plugin)`) + } + } +} + +function findDependents( + componentPath: string, + components: { path: string; sourcePath: string }[], +): { path: string }[] { + const importPattern = `#bindx-ui/${componentPath}` + return components.filter(c => { + if (c.path === componentPath) return false + const content = readFileSync(c.sourcePath, 'utf-8') + return content.includes(importPattern) + }) +} diff --git a/packages/bindx-ui/src/cli/git.ts b/packages/bindx-ui/src/cli/git.ts new file mode 100644 index 0000000..561129f --- /dev/null +++ b/packages/bindx-ui/src/cli/git.ts @@ -0,0 +1,31 @@ +import { execFileSync } from 'node:child_process' +import { relative } from 'node:path' + +export function getGitRef(): string { + try { + return execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf-8' }).trim() + } catch { + throw new Error('Failed to get current git ref. Are you in a git repository?') + } +} + +export function getGitPath(absolutePath: string): string { + try { + const result = execFileSync('git', ['ls-files', '--full-name', absolutePath], { encoding: 'utf-8' }).trim() + if (result.length === 0) { + const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8' }).trim() + return relative(gitRoot, absolutePath).replaceAll('\\', '/') + } + return result + } catch { + throw new Error(`Failed to resolve git path for: ${absolutePath}`) + } +} + +export function getOriginalSource(gitRef: string, gitPath: string): string { + try { + return execFileSync('git', ['show', `${gitRef}:${gitPath}`], { encoding: 'utf-8' }) + } catch { + throw new Error(`Failed to retrieve source at ${gitRef}:${gitPath}. The ref or path may no longer exist.`) + } +} diff --git a/packages/bindx-ui/src/cli/merge.ts b/packages/bindx-ui/src/cli/merge.ts new file mode 100644 index 0000000..382023a --- /dev/null +++ b/packages/bindx-ui/src/cli/merge.ts @@ -0,0 +1,44 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { isExecError } from './utils.js' + +export interface MergeResult { + status: 'clean' | 'conflict' | 'error' + content: string + conflictCount: number +} + +export function threeWayMerge(local: string, base: string, upstream: string): MergeResult { + const tempDir = mkdtempSync(join(tmpdir(), 'bindx-merge-')) + const localFile = join(tempDir, 'local') + const baseFile = join(tempDir, 'base') + const upstreamFile = join(tempDir, 'upstream') + + try { + writeFileSync(localFile, local, 'utf-8') + writeFileSync(baseFile, base, 'utf-8') + writeFileSync(upstreamFile, upstream, 'utf-8') + + const result = execFileSync('diff3', ['-m', localFile, baseFile, upstreamFile], { + encoding: 'utf-8', + }) + + return { status: 'clean', content: result, conflictCount: 0 } + } catch (error: unknown) { + if (isExecError(error) && error.status === 1) { + const conflictCount = countConflictMarkers(error.stdout) + return { status: 'conflict', content: error.stdout, conflictCount } + } + + return { status: 'error', content: '', conflictCount: 0 } + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } +} + +function countConflictMarkers(content: string): number { + const matches = content.match(/^<<<<<<<\s/gm) + return matches?.length ?? 0 +} diff --git a/packages/bindx-ui/src/cli/metadata.ts b/packages/bindx-ui/src/cli/metadata.ts new file mode 100644 index 0000000..388b0c3 --- /dev/null +++ b/packages/bindx-ui/src/cli/metadata.ts @@ -0,0 +1,33 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' + +export interface EjectedEntry { + path: string + version: string + originalHash: string + gitRef?: string + gitPath?: string +} + +export interface BindxUIMetadata { + ejected: Record +} + +const METADATA_FILE = '.bindx-ui.json' + +export function loadMetadata(targetDir: string): BindxUIMetadata { + const filePath = join(targetDir, METADATA_FILE) + + if (!existsSync(filePath)) { + return { ejected: {} } + } + + const content = readFileSync(filePath, 'utf-8') + return JSON.parse(content) as BindxUIMetadata +} + +export function saveMetadata(targetDir: string, metadata: BindxUIMetadata): void { + mkdirSync(targetDir, { recursive: true }) + const filePath = join(targetDir, METADATA_FILE) + writeFileSync(filePath, JSON.stringify(metadata, null, '\t') + '\n', 'utf-8') +} diff --git a/packages/bindx-ui/src/cli/paths.ts b/packages/bindx-ui/src/cli/paths.ts new file mode 100644 index 0000000..38e25e4 --- /dev/null +++ b/packages/bindx-ui/src/cli/paths.ts @@ -0,0 +1,22 @@ +import { readFileSync } from 'node:fs' +import { resolve, dirname } from 'node:path' + +export function getPackageRoot(): string { + return resolve(dirname(new URL(import.meta.url).pathname), '../..') +} + +const COMPONENT_FOLDERS = ['ui', 'form', 'datagrid', 'select', 'upload', 'repeater', 'persist', 'labels', 'errors'] + +export function getComponentFolders(): readonly string[] { + return COMPONENT_FOLDERS +} + +export function getPackageSrcDir(): string { + return resolve(getPackageRoot(), 'src') +} + +export function getPackageVersion(): string { + const packageJsonPath = resolve(getPackageRoot(), 'package.json') + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { version: string } + return packageJson.version +} diff --git a/packages/bindx-ui/src/cli/registry.ts b/packages/bindx-ui/src/cli/registry.ts new file mode 100644 index 0000000..e2ad409 --- /dev/null +++ b/packages/bindx-ui/src/cli/registry.ts @@ -0,0 +1,49 @@ +import { existsSync, readdirSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' +import { getComponentFolders, getPackageSrcDir } from './paths.js' + +export interface ComponentEntry { + /** e.g. 'form/container' */ + path: string + /** Absolute path to source file in package */ + sourcePath: string +} + +export function discoverComponents(): ComponentEntry[] { + const srcDir = getPackageSrcDir() + const entries: ComponentEntry[] = [] + + for (const folder of getComponentFolders()) { + const folderPath = join(srcDir, folder) + if (!existsSync(folderPath)) { + continue + } + walkDir(folderPath, srcDir, entries) + } + + return entries.sort((a, b) => a.path.localeCompare(b.path)) +} + +function walkDir(dir: string, root: string, entries: ComponentEntry[]): void { + for (const entry of readdirSync(dir)) { + const fullPath = join(dir, entry) + const stat = statSync(fullPath) + + if (stat.isDirectory()) { + walkDir(fullPath, root, entries) + continue + } + + if (!isComponentFile(entry)) { + continue + } + + const relPath = relative(root, fullPath) + const componentPath = relPath.replace(/\.(tsx?|jsx?)$/, '').replaceAll('\\', '/') + entries.push({ path: componentPath, sourcePath: fullPath }) + } +} + +function isComponentFile(filename: string): boolean { + return /\.(tsx?|jsx?)$/.test(filename) && !filename.startsWith('index.') +} diff --git a/packages/bindx-ui/src/cli/restore.ts b/packages/bindx-ui/src/cli/restore.ts new file mode 100644 index 0000000..6ba43f5 --- /dev/null +++ b/packages/bindx-ui/src/cli/restore.ts @@ -0,0 +1,24 @@ +import { existsSync, unlinkSync } from 'node:fs' +import { resolve } from 'node:path' +import { loadMetadata, saveMetadata } from './metadata.js' + +export function restore(componentPath: string, targetDir: string): void { + const metadata = loadMetadata(targetDir) + + if (!metadata.ejected[componentPath]) { + console.error(`Component ${componentPath} is not ejected.`) + process.exit(1) + } + + const targetPath = resolve(targetDir, componentPath + '.tsx') + + if (existsSync(targetPath)) { + unlinkSync(targetPath) + console.log(` ✓ Removed ${targetPath}`) + } + + delete metadata.ejected[componentPath] + saveMetadata(targetDir, metadata) + + console.log(` ✓ Restored ${componentPath} to package default`) +} diff --git a/packages/bindx-ui/src/cli/status.ts b/packages/bindx-ui/src/cli/status.ts new file mode 100644 index 0000000..e5c3dff --- /dev/null +++ b/packages/bindx-ui/src/cli/status.ts @@ -0,0 +1,93 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { loadMetadata } from './metadata.js' +import { discoverComponents } from './registry.js' +import { getPackageVersion } from './paths.js' +import { getOriginalSource } from './git.js' +import { hashContent, stripHeader } from './utils.js' + +export function status(targetDir: string): void { + const metadata = loadMetadata(targetDir) + const ejectedPaths = Object.keys(metadata.ejected) + + if (ejectedPaths.length === 0) { + console.log('No ejected components.') + return + } + + const version = getPackageVersion() + const components = discoverComponents() + const componentMap = new Map(components.map(c => [c.path, c])) + + console.log('Ejected components:\n') + + for (const path of ejectedPaths.sort()) { + const entry = metadata.ejected[path] + if (!entry) continue + + const packageComponent = componentMap.get(path) + if (!packageComponent) { + console.log(` ✗ ${path} (removed from package)`) + continue + } + + const statusLabel = resolveStatusLabel(entry, packageComponent, version, targetDir) + console.log(` ${statusLabel}`) + } + + console.log(`\nPackage components: ${components.length}`) +} + +function resolveStatusLabel( + entry: { path: string; version: string; originalHash: string; gitRef?: string; gitPath?: string }, + packageComponent: { path: string; sourcePath: string }, + currentVersion: string, + targetDir: string, +): string { + const upstreamSource = readFileSync(packageComponent.sourcePath, 'utf-8') + const upstreamHash = hashContent(upstreamSource) + + if (!entry.gitRef || !entry.gitPath) { + if (entry.version !== currentVersion || upstreamHash !== entry.originalHash) { + return `⚠ ${entry.path} (ejected from v${entry.version}, current v${currentVersion}, no git ref for merge)` + } + return `✓ ${entry.path} (up to date with v${entry.version})` + } + + const localPath = resolve(targetDir, entry.path + '.tsx') + if (!existsSync(localPath)) { + return `✗ ${entry.path} (local file missing)` + } + + const localRaw = readFileSync(localPath, 'utf-8') + const localSource = stripHeader(localRaw) + const localHash = hashContent(localSource) + + let baseSource: string | undefined + try { + baseSource = getOriginalSource(entry.gitRef, entry.gitPath) + } catch { + if (entry.version !== currentVersion || upstreamHash !== entry.originalHash) { + return `⚠ ${entry.path} (ejected from v${entry.version}, git ref unavailable)` + } + return `✓ ${entry.path} (up to date with v${entry.version})` + } + + const baseHash = hashContent(baseSource) + const upstreamChanged = baseHash !== upstreamHash + const localChanged = baseHash !== localHash + + if (upstreamChanged && localChanged) { + return `⚠ ${entry.path} (both changed — merge needed)` + } + + if (upstreamChanged) { + return `⚠ ${entry.path} (upstream changed — auto-update available)` + } + + if (localChanged) { + return `✓ ${entry.path} (locally modified, upstream unchanged)` + } + + return `✓ ${entry.path} (up to date with v${entry.version})` +} diff --git a/packages/bindx-ui/src/cli/utils.ts b/packages/bindx-ui/src/cli/utils.ts new file mode 100644 index 0000000..49edab2 --- /dev/null +++ b/packages/bindx-ui/src/cli/utils.ts @@ -0,0 +1,28 @@ +import { createHash } from 'node:crypto' + +export function hashContent(content: string): string { + return createHash('sha256').update(content).digest('hex').slice(0, 16) +} + +export function stripHeader(content: string): string { + const firstNewline = content.indexOf('\n') + if (firstNewline === -1) { + return content + } + const firstLine = content.slice(0, firstNewline) + if (firstLine.startsWith('// Ejected from')) { + return content.slice(firstNewline + 1) + } + return content +} + +export function isExecError(error: unknown): error is { stdout: string; status: number } { + return ( + typeof error === 'object' + && error !== null + && 'stdout' in error + && typeof (error as { stdout: unknown }).stdout === 'string' + && 'status' in error + && typeof (error as { status: unknown }).status === 'number' + ) +} diff --git a/packages/bindx-ui/src/datagrid/auto-table.tsx b/packages/bindx-ui/src/datagrid/auto-table.tsx index 27f1fcf..5c97ffb 100644 --- a/packages/bindx-ui/src/datagrid/auto-table.tsx +++ b/packages/bindx-ui/src/datagrid/auto-table.tsx @@ -19,7 +19,7 @@ import { type DataViewItem, type ColumnLeafProps, } from '@contember/bindx-dataview' -import { useFieldLabelFormatter } from '../labels/index.js' +import { useFieldLabelFormatter } from '#bindx-ui/labels/index' import { DataGridTableWrapper, DataGridTable, @@ -30,8 +30,8 @@ import { DataGridHeaderCell, DataGridCell, DataGridEmptyState, -} from './table.js' -import { DataGridColumnHeaderUI } from './column-header.js' +} from '#bindx-ui/datagrid/table' +import { DataGridColumnHeaderUI } from '#bindx-ui/datagrid/column-header' function ColumnFilterRenderer({ filterName, renderFilter }: { filterName: string diff --git a/packages/bindx-ui/src/datagrid/cells.tsx b/packages/bindx-ui/src/datagrid/cells.tsx deleted file mode 100644 index 3b36f21..0000000 --- a/packages/bindx-ui/src/datagrid/cells.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Cell components for DataGrid — render cell values with tooltip filter actions. - * - * These are UI wrappers used inside DataGrid column renderers or custom layouts. - * They compose existing , , with tooltip components. - */ -import type { ReactElement, ReactNode } from 'react' -import type { FieldRef } from '@contember/bindx' -import { DataGridTooltipLabel } from './ui.js' -import { DataGridEnumFieldTooltip, DataGridHasOneTooltip, DataGridHasManyTooltip } from './tooltips.js' - -// ============================================================================ -// DataGridEnumCell -// ============================================================================ - -export interface DataGridEnumCellProps { - field: FieldRef - filterName?: string - value: string | null - options?: Record - tooltipActions?: ReactNode -} - -/** - * Renders an enum cell value with a tooltip for filter actions. - * - * @example - * ```tsx - * - * {(value) => ( - * - * )} - * - * ``` - */ -export function DataGridEnumCell({ field, filterName, value, options, tooltipActions }: DataGridEnumCellProps): ReactElement | null { - if (!value) return null - - return ( - - - {options?.[value] ?? value} - - - ) -} - -// ============================================================================ -// DataGridEnumListCell -// ============================================================================ - -export interface DataGridEnumListCellProps { - field: FieldRef - filterName?: string - values: readonly string[] | null - options?: Record - tooltipActions?: ReactNode -} - -/** - * Renders an enum list cell with tooltips for each value. - */ -export function DataGridEnumListCell({ field, filterName, values, options, tooltipActions }: DataGridEnumListCellProps): ReactElement | null { - if (!values || values.length === 0) return null - - return ( -
- {values.map(value => ( - - - {options?.[value] ?? value} - - - ))} -
- ) -} - -// ============================================================================ -// DataGridHasOneCell -// ============================================================================ - -export interface DataGridHasOneCellProps { - field: FieldRef - filterName?: string - id: string | null - children: ReactNode - tooltipActions?: ReactNode -} - -/** - * Renders a has-one relation cell value with tooltip for filter actions. - * - * @example - * ```tsx - * - * {(author) => ( - * - * {author.name.value} - * - * )} - * - * ``` - */ -export function DataGridHasOneCell({ field, filterName, id, children, tooltipActions }: DataGridHasOneCellProps): ReactElement { - if (!id) { - return <>{children} - } - - return ( - - - {children} - - - ) -} - -// ============================================================================ -// DataGridHasManyCell -// ============================================================================ - -export interface DataGridHasManyCellProps { - field: FieldRef - filterName?: string - id: string - children: ReactNode - tooltipActions?: ReactNode -} - -/** - * Renders a single item in a has-many relation cell with tooltip for filter actions. - * Use inside a map() over has-many items. - * - * @example - * ```tsx - * - * {(tag) => ( - * - * {tag.name.value} - * - * )} - * - * ``` - */ -export function DataGridHasManyCell({ field, filterName, id, children, tooltipActions }: DataGridHasManyCellProps): ReactElement { - return ( - - - {children} - - - ) -} diff --git a/packages/bindx-ui/src/datagrid/cells/enum-cell.tsx b/packages/bindx-ui/src/datagrid/cells/enum-cell.tsx new file mode 100644 index 0000000..8ad61bb --- /dev/null +++ b/packages/bindx-ui/src/datagrid/cells/enum-cell.tsx @@ -0,0 +1,48 @@ +import type { ReactElement, ReactNode } from 'react' +import type { FieldRef } from '@contember/bindx' +import { DataGridTooltipLabel } from '#bindx-ui/datagrid/ui' +import { DataGridEnumFieldTooltip } from '#bindx-ui/datagrid/tooltips' + +export interface EnumCellProps { + field: FieldRef + filterName?: string + value: string | null + options?: Record + tooltipActions?: ReactNode +} + +export function EnumCell({ field, filterName, value, options, tooltipActions }: EnumCellProps): ReactElement | null { + if (!value) return null + + return ( + + + {options?.[value] ?? value} + + + ) +} + +export interface EnumListCellProps { + field: FieldRef + filterName?: string + values: readonly string[] | null + options?: Record + tooltipActions?: ReactNode +} + +export function EnumListCell({ field, filterName, values, options, tooltipActions }: EnumListCellProps): ReactElement | null { + if (!values || values.length === 0) return null + + return ( +
+ {values.map(value => ( + + + {options?.[value] ?? value} + + + ))} +
+ ) +} diff --git a/packages/bindx-ui/src/datagrid/cells/has-many-cell.tsx b/packages/bindx-ui/src/datagrid/cells/has-many-cell.tsx new file mode 100644 index 0000000..aa2b418 --- /dev/null +++ b/packages/bindx-ui/src/datagrid/cells/has-many-cell.tsx @@ -0,0 +1,22 @@ +import type { ReactElement, ReactNode } from 'react' +import type { FieldRef } from '@contember/bindx' +import { DataGridTooltipLabel } from '#bindx-ui/datagrid/ui' +import { DataGridHasManyTooltip } from '#bindx-ui/datagrid/tooltips' + +export interface HasManyCellProps { + field: FieldRef + filterName?: string + id: string + children: ReactNode + tooltipActions?: ReactNode +} + +export function HasManyCell({ field, filterName, id, children, tooltipActions }: HasManyCellProps): ReactElement { + return ( + + + {children} + + + ) +} diff --git a/packages/bindx-ui/src/datagrid/cells/has-one-cell.tsx b/packages/bindx-ui/src/datagrid/cells/has-one-cell.tsx new file mode 100644 index 0000000..62bce13 --- /dev/null +++ b/packages/bindx-ui/src/datagrid/cells/has-one-cell.tsx @@ -0,0 +1,26 @@ +import type { ReactElement, ReactNode } from 'react' +import type { FieldRef } from '@contember/bindx' +import { DataGridTooltipLabel } from '#bindx-ui/datagrid/ui' +import { DataGridHasOneTooltip } from '#bindx-ui/datagrid/tooltips' + +export interface HasOneCellProps { + field: FieldRef + filterName?: string + id: string | null + children: ReactNode + tooltipActions?: ReactNode +} + +export function HasOneCell({ field, filterName, id, children, tooltipActions }: HasOneCellProps): ReactElement { + if (!id) { + return <>{children} + } + + return ( + + + {children} + + + ) +} diff --git a/packages/bindx-ui/src/datagrid/cells/index.ts b/packages/bindx-ui/src/datagrid/cells/index.ts new file mode 100644 index 0000000..92b81bc --- /dev/null +++ b/packages/bindx-ui/src/datagrid/cells/index.ts @@ -0,0 +1,3 @@ +export { EnumCell, type EnumCellProps, EnumListCell, type EnumListCellProps } from '#bindx-ui/datagrid/cells/enum-cell' +export { HasOneCell, type HasOneCellProps } from '#bindx-ui/datagrid/cells/has-one-cell' +export { HasManyCell, type HasManyCellProps } from '#bindx-ui/datagrid/cells/has-many-cell' diff --git a/packages/bindx-ui/src/datagrid/column-header.tsx b/packages/bindx-ui/src/datagrid/column-header.tsx index 8459f07..2ffd6e2 100644 --- a/packages/bindx-ui/src/datagrid/column-header.tsx +++ b/packages/bindx-ui/src/datagrid/column-header.tsx @@ -12,8 +12,8 @@ import type { FieldRef } from '@contember/bindx' import { ArrowDownAZIcon, ArrowUpDownIcon, ArrowUpZaIcon, EyeOffIcon, FilterIcon } from 'lucide-react' import { cn } from '../utils/cn.js' import { dict } from '../dict.js' -import { Button } from '../ui/button.js' -import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover.js' +import { Button } from '#bindx-ui/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '#bindx-ui/ui/popover' export interface DataGridColumnHeaderUIProps { children: ReactNode diff --git a/packages/bindx-ui/src/datagrid/columns.tsx b/packages/bindx-ui/src/datagrid/columns.tsx deleted file mode 100644 index caea9c7..0000000 --- a/packages/bindx-ui/src/datagrid/columns.tsx +++ /dev/null @@ -1,309 +0,0 @@ -/** - * Styled DataGrid column components. - * - * Each column is self-contained — it defines its own cell rendering, filter UI, - * and cell wrapper (e.g. tooltip). - * - * Scalar columns: `createColumn(typeDef, { renderCell, renderFilter })` - * Relation columns: `createRelationColumn(typeDef, cellConfig, { renderFilter, renderCellWrapper })` - */ - -import React, { type ReactElement, type ReactNode } from 'react' -import type { EntityAccessor, EntityDef, EnumFilterArtifact, EnumListFilterArtifact, FieldRef } from '@contember/bindx' -import { - createColumn, - createRelationColumn, - hasOneCellConfig, - hasManyCellConfig, - getRelatedAccessor, - textColumnDef, - numberColumnDef, - dateColumnDef, - dateTimeColumnDef, - booleanColumnDef, - enumColumnDef, - enumListColumnDef, - uuidColumnDef, - isDefinedColumnDef, - hasOneColumnDef, - hasManyColumnDef, - useDataViewFilterName, - useDataViewContext, - DataViewFilterScope, - DataViewEachRow, - DataViewInfiniteLoadProvider, - DataViewInfiniteLoadScrollObserver, - DataViewInfiniteLoadTrigger, - DataViewLoaderState, - SelectDataView, - SelectOptionsContext, - useDataViewRelationFilterFactory, - type UseDataViewRelationFilterResult, -} from '@contember/bindx-dataview' -import type { ColumnRenderProps, ColumnComponentProps, RelationFilterContext, RelationCellWrapperContext, RelationColumnComponent, DataGridHasOneColumnProps, DataGridHasManyColumnProps } from '@contember/bindx-dataview' -import { DataGridTextFilterInner } from './filters/text.js' -import { DataGridBooleanFilterControls } from './filters/boolean.js' -import { DataGridNumberFilterControls } from './filters/number.js' -import { DataGridDateFilterControls } from './filters/date.js' -import { DataGridEnumFilterControls } from './filters/enum.js' -import { DataGridIsDefinedFilterControls } from './filters/defined.js' -import { DataGridNullFilter } from './filters/common.js' -import { useEnumOptionsFormatter } from '../labels/index.js' -import { DataGridHasOneTooltip } from './tooltips.js' -import { DataGridFilterSelectItemUI, DataGridTooltipLabel } from './ui.js' -import { SelectDefaultFilter } from '../select/filter.js' -import { Loader } from '../ui/loader.js' -import { Button } from '../ui/button.js' -import { ArrowBigDownDash } from 'lucide-react' -import { useCallback } from 'react' - -// Re-export action/generic columns from dataview -export { - DataGridActionColumn, - DataGridColumn, - type DataGridTextColumnProps, - type DataGridNumberColumnProps, - type DataGridDateColumnProps, - type DataGridDateTimeColumnProps, - type DataGridBooleanColumnProps, - type DataGridEnumColumnProps, - type DataGridEnumListColumnProps, - type DataGridUuidColumnProps, - type DataGridIsDefinedColumnProps, - type DataGridHasOneColumnProps, - type DataGridHasManyColumnProps, - type DataGridActionColumnProps, - type DataGridColumnProps, -} from '@contember/bindx-dataview' - -// ============================================================================ -// Scalar Column Renderers -// ============================================================================ - -function renderScalarDefault({ value }: ColumnRenderProps): React.ReactNode { - return value != null ? String(value) : '' -} - -function renderBooleanDefault({ value }: ColumnRenderProps): React.ReactNode { - return value != null ? String(value) : '' -} - -function renderIsDefinedDefault({ value }: ColumnRenderProps): React.ReactNode { - return value != null ? '\u2713' : '\u2717' -} - -function renderDateTimeDefault({ value }: ColumnRenderProps): React.ReactNode { - if (typeof value !== 'string') return '' - const date = new Date(value) - if (isNaN(date.getTime())) return value - return date.toLocaleString() -} - -function renderEnumListDefault({ value }: ColumnRenderProps): React.ReactNode { - if (!Array.isArray(value)) return '' - return value.join(', ') -} - -function ColumnEnumFilterControls(): ReactElement { - const filterName = useDataViewFilterName() - const { columns } = useDataViewContext() - const enumFormatter = useEnumOptionsFormatter() - const column = columns.find(c => c.filterName === filterName) - - // Priority: explicit options prop > context formatter > empty - let optionsRecord: Record - if (column?.enumOptions && Object.keys(column.enumOptions).length > 0) { - optionsRecord = column.enumOptions - } else if (column?.enumName) { - optionsRecord = enumFormatter(column.enumName) - } else { - optionsRecord = {} - } - - return -} - -// ============================================================================ -// Styled Scalar Columns -// ============================================================================ - -export const DataGridTextColumn = createColumn(textColumnDef, { - renderCell: renderScalarDefault, - renderFilter: () => , -}) - -export const DataGridNumberColumn = createColumn(numberColumnDef, { - renderCell: renderScalarDefault, - renderFilter: () => , -}) - -export const DataGridDateColumn = createColumn(dateColumnDef, { - renderCell: renderScalarDefault, - renderFilter: () => , -}) - -export const DataGridDateTimeColumn = createColumn(dateTimeColumnDef, { - renderCell: renderDateTimeDefault, - renderFilter: () => , -}) - -export const DataGridBooleanColumn = createColumn(booleanColumnDef, { - renderCell: renderBooleanDefault, - renderFilter: () => , -}) - -const _DataGridEnumColumn = createColumn(enumColumnDef, { - renderCell: renderScalarDefault, - renderFilter: () => , -}) - -type ExtractEnum = F extends FieldRef ? Exclude & string : string - -export const DataGridEnumColumn = >(props: { - field: F - header?: ReactNode - sortable?: boolean - filter?: boolean - children?: (value: ExtractEnum | null, accessor: EntityAccessor) => ReactNode - options?: { [K in ExtractEnum]?: ReactNode } -}): ReactNode => null -DataGridEnumColumn.staticRender = _DataGridEnumColumn.staticRender - -const _DataGridEnumListColumn = createColumn(enumListColumnDef, { - renderCell: renderEnumListDefault, - renderFilter: () => , -}) - -type ExtractEnumList = F extends FieldRef - ? T extends readonly (infer U)[] | null ? U & string : string - : string - -export const DataGridEnumListColumn = >(props: { - field: F - header?: ReactNode - sortable?: boolean - filter?: boolean - children?: (value: ExtractEnumList[] | null, accessor: EntityAccessor) => ReactNode - options?: { [K in ExtractEnumList]?: ReactNode } -}): ReactNode => null -DataGridEnumListColumn.staticRender = _DataGridEnumListColumn.staticRender - -export const DataGridUuidColumn = createColumn(uuidColumnDef, { - renderCell: renderScalarDefault, -}) - -export const DataGridIsDefinedColumn = createColumn(isDefinedColumnDef, { - renderCell: renderIsDefinedDefault, - renderFilter: () => , -}) - -// ============================================================================ -// Styled Relation Columns -// ============================================================================ - -const relationUI = { - renderFilter: (ctx: RelationFilterContext) => ( - - ), - renderCellWrapper: ({ content, item, fieldName, filterName, fieldRef }: RelationCellWrapperContext) => { - const id = getRelatedAccessor(item, fieldName)?.id ?? null - if (!id) return content - return ( - - {content} - - ) - }, -} - -const _DataGridHasOneColumn: RelationColumnComponent = createRelationColumn(hasOneColumnDef, hasOneCellConfig, relationUI) -export const DataGridHasOneColumn: { - (props: DataGridHasOneColumnProps): null - staticRender: typeof _DataGridHasOneColumn.staticRender -} = _DataGridHasOneColumn - -const _DataGridHasManyColumn: RelationColumnComponent = createRelationColumn(hasManyColumnDef, hasManyCellConfig, relationUI) -export const DataGridHasManyColumn: { - (props: DataGridHasManyColumnProps): null - staticRender: typeof _DataGridHasManyColumn.staticRender -} = _DataGridHasManyColumn - -// ============================================================================ -// Relation Filter UI -// ============================================================================ - -function extractScalarFieldNames(selection: unknown): string[] { - if (!selection || typeof selection !== 'object') return [] - const meta = selection as { fields?: Map } - if (!meta.fields) return [] - const names: string[] = [] - for (const [name, field] of meta.fields) { - if (!field.nested) names.push(name) - } - return names -} - -function RelationFilterUI({ filterName, entityName, selection, renderItem }: RelationFilterContext): ReactElement { - const entityDef: EntityDef = { $name: entityName } - const filterFactory = useDataViewRelationFilterFactory(filterName) - const queryFields = extractScalarFieldNames(selection) - - return ( - - - 0 ? queryFields : undefined}> - -
- -
- - - - - - {(item) => ( - - )} - - - - - - - - - - - -
- -
-
-
-
-
- ) -} - -function RelationFilterItem({ item, filterFactory, renderItem }: { - item: EntityAccessor - filterFactory: (id: string) => UseDataViewRelationFilterResult - renderItem: (accessor: EntityAccessor) => ReactNode -}): ReactElement { - const [current, setFilter] = filterFactory(item.id) - const include = useCallback(() => setFilter('toggleInclude'), [setFilter]) - const exclude = useCallback(() => setFilter('toggleExclude'), [setFilter]) - - return ( - - {renderItem(item)} - - ) -} diff --git a/packages/bindx-ui/src/datagrid/columns/boolean-column.tsx b/packages/bindx-ui/src/datagrid/columns/boolean-column.tsx new file mode 100644 index 0000000..0715dcb --- /dev/null +++ b/packages/bindx-ui/src/datagrid/columns/boolean-column.tsx @@ -0,0 +1,12 @@ +import type { ColumnRenderProps } from '@contember/bindx-dataview' +import { createColumn, booleanColumnDef } from '@contember/bindx-dataview' +import { DataGridBooleanFilterControls } from '#bindx-ui/datagrid/filters/boolean' + +function renderBooleanDefault({ value }: ColumnRenderProps): React.ReactNode { + return value != null ? String(value) : '' +} + +export const BooleanColumn = createColumn(booleanColumnDef, { + renderCell: renderBooleanDefault, + renderFilter: () => , +}) diff --git a/packages/bindx-ui/src/datagrid/columns/date-column.tsx b/packages/bindx-ui/src/datagrid/columns/date-column.tsx new file mode 100644 index 0000000..35843d0 --- /dev/null +++ b/packages/bindx-ui/src/datagrid/columns/date-column.tsx @@ -0,0 +1,24 @@ +import type { ColumnRenderProps } from '@contember/bindx-dataview' +import { createColumn, dateColumnDef, dateTimeColumnDef } from '@contember/bindx-dataview' +import { DataGridDateFilterControls } from '#bindx-ui/datagrid/filters/date' + +function renderScalarDefault({ value }: ColumnRenderProps): React.ReactNode { + return value != null ? String(value) : '' +} + +function renderDateTimeDefault({ value }: ColumnRenderProps): React.ReactNode { + if (typeof value !== 'string') return '' + const date = new Date(value) + if (isNaN(date.getTime())) return value + return date.toLocaleString() +} + +export const DateColumn = createColumn(dateColumnDef, { + renderCell: renderScalarDefault, + renderFilter: () => , +}) + +export const DateTimeColumn = createColumn(dateTimeColumnDef, { + renderCell: renderDateTimeDefault, + renderFilter: () => , +}) diff --git a/packages/bindx-ui/src/datagrid/columns/defined-column.tsx b/packages/bindx-ui/src/datagrid/columns/defined-column.tsx new file mode 100644 index 0000000..77bfab6 --- /dev/null +++ b/packages/bindx-ui/src/datagrid/columns/defined-column.tsx @@ -0,0 +1,12 @@ +import type { ColumnRenderProps } from '@contember/bindx-dataview' +import { createColumn, isDefinedColumnDef } from '@contember/bindx-dataview' +import { DataGridIsDefinedFilterControls } from '#bindx-ui/datagrid/filters/defined' + +function renderIsDefinedDefault({ value }: ColumnRenderProps): React.ReactNode { + return value != null ? '\u2713' : '\u2717' +} + +export const IsDefinedColumn = createColumn(isDefinedColumnDef, { + renderCell: renderIsDefinedDefault, + renderFilter: () => , +}) diff --git a/packages/bindx-ui/src/datagrid/columns/enum-column.tsx b/packages/bindx-ui/src/datagrid/columns/enum-column.tsx new file mode 100644 index 0000000..d239a9f --- /dev/null +++ b/packages/bindx-ui/src/datagrid/columns/enum-column.tsx @@ -0,0 +1,75 @@ +import { type ReactElement, type ReactNode } from 'react' +import type { EntityAccessor, FieldRef } from '@contember/bindx' +import { + createColumn, + enumColumnDef, + enumListColumnDef, + useDataViewFilterName, + useDataViewContext, +} from '@contember/bindx-dataview' +import type { ColumnRenderProps } from '@contember/bindx-dataview' +import { DataGridEnumFilterControls } from '#bindx-ui/datagrid/filters/enum' +import { useEnumOptionsFormatter } from '#bindx-ui/labels/index' + +function renderScalarDefault({ value }: ColumnRenderProps): React.ReactNode { + return value != null ? String(value) : '' +} + +function renderEnumListDefault({ value }: ColumnRenderProps): React.ReactNode { + if (!Array.isArray(value)) return '' + return value.join(', ') +} + +function ColumnEnumFilterControls(): ReactElement { + const filterName = useDataViewFilterName() + const { columns } = useDataViewContext() + const enumFormatter = useEnumOptionsFormatter() + const column = columns.find(c => c.filterName === filterName) + + let optionsRecord: Record + if (column?.enumOptions && Object.keys(column.enumOptions).length > 0) { + optionsRecord = column.enumOptions + } else if (column?.enumName) { + optionsRecord = enumFormatter(column.enumName) + } else { + optionsRecord = {} + } + + return +} + +const _EnumColumn = createColumn(enumColumnDef, { + renderCell: renderScalarDefault, + renderFilter: () => , +}) + +type ExtractEnum = F extends FieldRef ? Exclude & string : string + +export const EnumColumn = >(props: { + field: F + header?: ReactNode + sortable?: boolean + filter?: boolean + children?: (value: ExtractEnum | null, accessor: EntityAccessor) => ReactNode + options?: { [K in ExtractEnum]?: ReactNode } +}): ReactNode => null +EnumColumn.staticRender = _EnumColumn.staticRender + +const _EnumListColumn = createColumn(enumListColumnDef, { + renderCell: renderEnumListDefault, + renderFilter: () => , +}) + +type ExtractEnumList = F extends FieldRef + ? T extends readonly (infer U)[] | null ? U & string : string + : string + +export const EnumListColumn = >(props: { + field: F + header?: ReactNode + sortable?: boolean + filter?: boolean + children?: (value: ExtractEnumList[] | null, accessor: EntityAccessor) => ReactNode + options?: { [K in ExtractEnumList]?: ReactNode } +}): ReactNode => null +EnumListColumn.staticRender = _EnumListColumn.staticRender diff --git a/packages/bindx-ui/src/datagrid/columns/has-many-column.tsx b/packages/bindx-ui/src/datagrid/columns/has-many-column.tsx new file mode 100644 index 0000000..012e9cb --- /dev/null +++ b/packages/bindx-ui/src/datagrid/columns/has-many-column.tsx @@ -0,0 +1,123 @@ +import type { EntityAccessor, EntityDef, FieldRef } from '@contember/bindx' +import React, { type ReactElement, type ReactNode, useCallback } from 'react' +import { + createRelationColumn, + hasManyColumnDef, + hasManyCellConfig, + getRelatedAccessor, + useDataViewRelationFilterFactory, + DataViewFilterScope, + DataViewEachRow, + DataViewInfiniteLoadProvider, + DataViewInfiniteLoadScrollObserver, + DataViewInfiniteLoadTrigger, + DataViewLoaderState, + SelectDataView, + SelectOptionsContext, + type UseDataViewRelationFilterResult, +} from '@contember/bindx-dataview' +import type { RelationFilterContext, RelationCellWrapperContext, RelationColumnComponent, DataGridHasManyColumnProps } from '@contember/bindx-dataview' +import { DataGridHasOneTooltip } from '#bindx-ui/datagrid/tooltips' +import { DataGridFilterSelectItemUI, DataGridTooltipLabel } from '#bindx-ui/datagrid/ui' +import { SelectDefaultFilter } from '#bindx-ui/select/filter' +import { Loader } from '#bindx-ui/ui/loader' +import { Button } from '#bindx-ui/ui/button' +import { ArrowBigDownDash } from 'lucide-react' +import { DataGridNullFilter } from '#bindx-ui/datagrid/filters/common' + +function extractScalarFieldNames(selection: unknown): string[] { + if (!selection || typeof selection !== 'object') return [] + const meta = selection as { fields?: Map } + if (!meta.fields) return [] + const names: string[] = [] + for (const [name, field] of meta.fields) { + if (!field.nested) names.push(name) + } + return names +} + +function RelationFilterUI({ filterName, entityName, selection, renderItem }: RelationFilterContext): ReactElement { + const entityDef: EntityDef = { $name: entityName } + const filterFactory = useDataViewRelationFilterFactory(filterName) + const queryFields = extractScalarFieldNames(selection) + + return ( + + + 0 ? queryFields : undefined}> + +
+ +
+ + + + + + {(item) => ( + + )} + + + + + + + + + + + +
+ +
+
+
+
+
+ ) +} + +function RelationFilterItem({ item, filterFactory, renderItem }: { + item: EntityAccessor + filterFactory: (id: string) => UseDataViewRelationFilterResult + renderItem: (accessor: EntityAccessor) => ReactNode +}): ReactElement { + const [current, setFilter] = filterFactory(item.id) + const include = useCallback(() => setFilter('toggleInclude'), [setFilter]) + const exclude = useCallback(() => setFilter('toggleExclude'), [setFilter]) + + return ( + + {renderItem(item)} + + ) +} + +const relationUI = { + renderFilter: (ctx: RelationFilterContext) => ( + + ), + renderCellWrapper: ({ content, item, fieldName, filterName, fieldRef }: RelationCellWrapperContext) => { + const id = getRelatedAccessor(item, fieldName)?.id ?? null + if (!id) return content + return ( + + {content} + + ) + }, +} + +const _HasManyColumn: RelationColumnComponent = createRelationColumn(hasManyColumnDef, hasManyCellConfig, relationUI) +export const HasManyColumn: { + (props: DataGridHasManyColumnProps): null + staticRender: typeof _HasManyColumn.staticRender +} = _HasManyColumn diff --git a/packages/bindx-ui/src/datagrid/columns/has-one-column.tsx b/packages/bindx-ui/src/datagrid/columns/has-one-column.tsx new file mode 100644 index 0000000..a3c45c7 --- /dev/null +++ b/packages/bindx-ui/src/datagrid/columns/has-one-column.tsx @@ -0,0 +1,123 @@ +import React, { type ReactElement, type ReactNode, useCallback } from 'react' +import type { EntityAccessor, EntityDef, FieldRef } from '@contember/bindx' +import { + createRelationColumn, + hasOneColumnDef, + hasOneCellConfig, + getRelatedAccessor, + useDataViewRelationFilterFactory, + DataViewFilterScope, + DataViewEachRow, + DataViewInfiniteLoadProvider, + DataViewInfiniteLoadScrollObserver, + DataViewInfiniteLoadTrigger, + DataViewLoaderState, + SelectDataView, + SelectOptionsContext, + type UseDataViewRelationFilterResult, +} from '@contember/bindx-dataview' +import type { RelationFilterContext, RelationCellWrapperContext, RelationColumnComponent, DataGridHasOneColumnProps } from '@contember/bindx-dataview' +import { DataGridHasOneTooltip } from '#bindx-ui/datagrid/tooltips' +import { DataGridFilterSelectItemUI, DataGridTooltipLabel } from '#bindx-ui/datagrid/ui' +import { SelectDefaultFilter } from '#bindx-ui/select/filter' +import { Loader } from '#bindx-ui/ui/loader' +import { Button } from '#bindx-ui/ui/button' +import { ArrowBigDownDash } from 'lucide-react' +import { DataGridNullFilter } from '#bindx-ui/datagrid/filters/common' + +function extractScalarFieldNames(selection: unknown): string[] { + if (!selection || typeof selection !== 'object') return [] + const meta = selection as { fields?: Map } + if (!meta.fields) return [] + const names: string[] = [] + for (const [name, field] of meta.fields) { + if (!field.nested) names.push(name) + } + return names +} + +function RelationFilterUI({ filterName, entityName, selection, renderItem }: RelationFilterContext): ReactElement { + const entityDef: EntityDef = { $name: entityName } + const filterFactory = useDataViewRelationFilterFactory(filterName) + const queryFields = extractScalarFieldNames(selection) + + return ( + + + 0 ? queryFields : undefined}> + +
+ +
+ + + + + + {(item) => ( + + )} + + + + + + + + + + + +
+ +
+
+
+
+
+ ) +} + +function RelationFilterItem({ item, filterFactory, renderItem }: { + item: EntityAccessor + filterFactory: (id: string) => UseDataViewRelationFilterResult + renderItem: (accessor: EntityAccessor) => ReactNode +}): ReactElement { + const [current, setFilter] = filterFactory(item.id) + const include = useCallback(() => setFilter('toggleInclude'), [setFilter]) + const exclude = useCallback(() => setFilter('toggleExclude'), [setFilter]) + + return ( + + {renderItem(item)} + + ) +} + +const relationUI = { + renderFilter: (ctx: RelationFilterContext) => ( + + ), + renderCellWrapper: ({ content, item, fieldName, filterName, fieldRef }: RelationCellWrapperContext) => { + const id = getRelatedAccessor(item, fieldName)?.id ?? null + if (!id) return content + return ( + + {content} + + ) + }, +} + +const _HasOneColumn: RelationColumnComponent = createRelationColumn(hasOneColumnDef, hasOneCellConfig, relationUI) +export const HasOneColumn: { + (props: DataGridHasOneColumnProps): null + staticRender: typeof _HasOneColumn.staticRender +} = _HasOneColumn diff --git a/packages/bindx-ui/src/datagrid/columns/index.ts b/packages/bindx-ui/src/datagrid/columns/index.ts new file mode 100644 index 0000000..d225b6d --- /dev/null +++ b/packages/bindx-ui/src/datagrid/columns/index.ts @@ -0,0 +1,28 @@ +// Re-export action/generic columns from dataview +export { + DataGridActionColumn, + DataGridColumn, + type DataGridTextColumnProps, + type DataGridNumberColumnProps, + type DataGridDateColumnProps, + type DataGridDateTimeColumnProps, + type DataGridBooleanColumnProps, + type DataGridEnumColumnProps, + type DataGridEnumListColumnProps, + type DataGridUuidColumnProps, + type DataGridIsDefinedColumnProps, + type DataGridHasOneColumnProps, + type DataGridHasManyColumnProps, + type DataGridActionColumnProps, + type DataGridColumnProps, +} from '@contember/bindx-dataview' + +export { TextColumn } from '#bindx-ui/datagrid/columns/text-column' +export { NumberColumn } from '#bindx-ui/datagrid/columns/number-column' +export { DateColumn, DateTimeColumn } from '#bindx-ui/datagrid/columns/date-column' +export { BooleanColumn } from '#bindx-ui/datagrid/columns/boolean-column' +export { EnumColumn, EnumListColumn } from '#bindx-ui/datagrid/columns/enum-column' +export { UuidColumn } from '#bindx-ui/datagrid/columns/uuid-column' +export { IsDefinedColumn } from '#bindx-ui/datagrid/columns/defined-column' +export { HasOneColumn } from '#bindx-ui/datagrid/columns/has-one-column' +export { HasManyColumn } from '#bindx-ui/datagrid/columns/has-many-column' diff --git a/packages/bindx-ui/src/datagrid/columns/number-column.tsx b/packages/bindx-ui/src/datagrid/columns/number-column.tsx new file mode 100644 index 0000000..fcd2eac --- /dev/null +++ b/packages/bindx-ui/src/datagrid/columns/number-column.tsx @@ -0,0 +1,12 @@ +import type { ColumnRenderProps } from '@contember/bindx-dataview' +import { createColumn, numberColumnDef } from '@contember/bindx-dataview' +import { DataGridNumberFilterControls } from '#bindx-ui/datagrid/filters/number' + +function renderScalarDefault({ value }: ColumnRenderProps): React.ReactNode { + return value != null ? String(value) : '' +} + +export const NumberColumn = createColumn(numberColumnDef, { + renderCell: renderScalarDefault, + renderFilter: () => , +}) diff --git a/packages/bindx-ui/src/datagrid/columns/text-column.tsx b/packages/bindx-ui/src/datagrid/columns/text-column.tsx new file mode 100644 index 0000000..b59da0b --- /dev/null +++ b/packages/bindx-ui/src/datagrid/columns/text-column.tsx @@ -0,0 +1,12 @@ +import type { ColumnRenderProps } from '@contember/bindx-dataview' +import { createColumn, textColumnDef } from '@contember/bindx-dataview' +import { DataGridTextFilterInner } from '#bindx-ui/datagrid/filters/text' + +function renderScalarDefault({ value }: ColumnRenderProps): React.ReactNode { + return value != null ? String(value) : '' +} + +export const TextColumn = createColumn(textColumnDef, { + renderCell: renderScalarDefault, + renderFilter: () => , +}) diff --git a/packages/bindx-ui/src/datagrid/columns/uuid-column.tsx b/packages/bindx-ui/src/datagrid/columns/uuid-column.tsx new file mode 100644 index 0000000..a5275dd --- /dev/null +++ b/packages/bindx-ui/src/datagrid/columns/uuid-column.tsx @@ -0,0 +1,10 @@ +import type { ColumnRenderProps } from '@contember/bindx-dataview' +import { createColumn, uuidColumnDef } from '@contember/bindx-dataview' + +function renderScalarDefault({ value }: ColumnRenderProps): React.ReactNode { + return value != null ? String(value) : '' +} + +export const UuidColumn = createColumn(uuidColumnDef, { + renderCell: renderScalarDefault, +}) diff --git a/packages/bindx-ui/src/datagrid/datagrid.tsx b/packages/bindx-ui/src/datagrid/datagrid.tsx new file mode 100644 index 0000000..06140ca --- /dev/null +++ b/packages/bindx-ui/src/datagrid/datagrid.tsx @@ -0,0 +1,83 @@ +/** + * DataGrid — top-level component that wraps DataGrid with a pre-configured layout. + * + * Renders toolbar, table (auto from columns), optional named layouts, pagination, and empty state. + * The user provides column markers (and optional toolbar/layout markers) via a children render function: + * + * ```tsx + * + * {it => ( + * <> + * + * + * + * )} + * + * ``` + */ +import React, { type ReactElement, type ReactNode } from 'react' +import type { CommonEntity, EntityAccessor } from '@contember/bindx' +import { + DataGrid as DataGridCore, + type DataGridProps as DataGridCoreProps, + HasManyDataGrid as HasManyDataGridCore, + type HasManyDataGridProps as HasManyDataGridCoreProps, +} from '@contember/bindx-dataview' +import { DataGridLayout, type DataGridLayoutProps } from '#bindx-ui/datagrid/layout' + +export type DataGridProps = Record> = + & Omit, 'children'> + & DataGridLayoutProps + & { + /** Children render function: receives entity proxy `it`, returns column/toolbar/layout markers */ + children: (it: EntityAccessor>) => ReactNode + } + +export function DataGrid>({ + children, + stickyToolbar, + stickyPagination, + ...dataGridProps +}: DataGridProps): ReactElement { + return ( + + {it => ( + <> + {children(it)} + + + )} + + ) +} + +export type HasManyDataGridProps = + & Omit, 'children'> + & DataGridLayoutProps + & { + children: (it: EntityAccessor) => ReactNode + } + +export function HasManyDataGrid({ + children, + stickyToolbar, + stickyPagination, + ...props +}: HasManyDataGridProps): ReactElement { + return ( + + {it => ( + <> + {children(it)} + + + )} + + ) +} diff --git a/packages/bindx-ui/src/datagrid/default-grid.tsx b/packages/bindx-ui/src/datagrid/default-grid.tsx deleted file mode 100644 index 9a12af7..0000000 --- a/packages/bindx-ui/src/datagrid/default-grid.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/** - * DefaultDataGrid — top-level component that wraps DataGrid with a pre-configured layout. - * - * Renders toolbar, table (auto from columns), optional named layouts, pagination, and empty state. - * The user provides column markers (and optional toolbar/layout markers) via a children render function: - * - * ```tsx - * - * {it => ( - * <> - * - * - * - * )} - * - * ``` - */ -import React, { type ReactElement, type ReactNode } from 'react' -import type { CommonEntity, EntityAccessor } from '@contember/bindx' -import { - DataGrid, - type DataGridProps, - HasManyDataGrid, - type HasManyDataGridProps, -} from '@contember/bindx-dataview' -import { DefaultDataGridLayout, type DefaultDataGridLayoutProps } from './default-layout.js' - -export type DefaultDataGridProps = Record> = - & Omit, 'children'> - & DefaultDataGridLayoutProps - & { - /** Children render function: receives entity proxy `it`, returns column/toolbar/layout markers */ - children: (it: EntityAccessor>) => ReactNode - } - -export function DefaultDataGrid>({ - children, - stickyToolbar, - stickyPagination, - ...dataGridProps -}: DefaultDataGridProps): ReactElement { - return ( - - {it => ( - <> - {children(it)} - - - )} - - ) -} - -export type DefaultHasManyDataGridProps = - & Omit, 'children'> - & DefaultDataGridLayoutProps - & { - children: (it: EntityAccessor) => ReactNode - } - -export function DefaultHasManyDataGrid({ - children, - stickyToolbar, - stickyPagination, - ...props -}: DefaultHasManyDataGridProps): ReactElement { - return ( - - {it => ( - <> - {children(it)} - - - )} - - ) -} diff --git a/packages/bindx-ui/src/datagrid/elements.tsx b/packages/bindx-ui/src/datagrid/elements.tsx index 52d5dca..b9864a0 100644 --- a/packages/bindx-ui/src/datagrid/elements.tsx +++ b/packages/bindx-ui/src/datagrid/elements.tsx @@ -4,7 +4,7 @@ import { EyeIcon, EyeOffIcon } from 'lucide-react' import { Fragment, type ReactElement, type ReactNode } from 'react' import { type DataViewElementData, DataViewVisibilityTrigger, useDataViewContext, useDataViewElements } from '@contember/bindx-dataview' -import { useFieldLabelFormatter } from '../labels/index.js' +import { useFieldLabelFormatter } from '#bindx-ui/labels/index' import { dict } from '../dict.js' export interface DataGridToolbarVisibleElementsProps { diff --git a/packages/bindx-ui/src/datagrid/export.tsx b/packages/bindx-ui/src/datagrid/export.tsx index 74c2a44..a3afb93 100644 --- a/packages/bindx-ui/src/datagrid/export.tsx +++ b/packages/bindx-ui/src/datagrid/export.tsx @@ -4,7 +4,7 @@ import type { ReactElement } from 'react' import { DataViewExportTrigger } from '@contember/bindx-dataview' import { DownloadIcon } from 'lucide-react' -import { Button } from '../ui/button.js' +import { Button } from '#bindx-ui/ui/button' import { dict } from '../dict.js' export const DataGridAutoExport = (): ReactElement => { diff --git a/packages/bindx-ui/src/datagrid/filters/boolean.tsx b/packages/bindx-ui/src/datagrid/filters/boolean.tsx index eca4093..f79be37 100644 --- a/packages/bindx-ui/src/datagrid/filters/boolean.tsx +++ b/packages/bindx-ui/src/datagrid/filters/boolean.tsx @@ -8,16 +8,16 @@ import { DataViewBooleanFilterTrigger, DataViewNullFilterTrigger, } from '@contember/bindx-dataview' -import { useDefaultFieldLabel } from '../labels.js' -import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover.js' -import { Button } from '../../ui/button.js' +import { useDefaultFieldLabel } from '#bindx-ui/datagrid/labels' +import { Popover, PopoverContent, PopoverTrigger } from '#bindx-ui/ui/popover' +import { Button } from '#bindx-ui/ui/button' import { DataGridActiveFilterUI, DataGridFilterSelectTriggerUI, DataGridSingleFilterUI, -} from '../ui.js' -import { DataGridNullFilter } from './common.js' -import { DataGridFilterMobileHiding } from './mobile.js' +} from '#bindx-ui/datagrid/ui' +import { DataGridNullFilter } from '#bindx-ui/datagrid/filters/common' +import { DataGridFilterMobileHiding } from '#bindx-ui/datagrid/filters/mobile' import { dict } from '../../dict.js' export type DataGridBooleanFilterUIProps = diff --git a/packages/bindx-ui/src/datagrid/filters/common.tsx b/packages/bindx-ui/src/datagrid/filters/common.tsx index ca8e7b8..1baa112 100644 --- a/packages/bindx-ui/src/datagrid/filters/common.tsx +++ b/packages/bindx-ui/src/datagrid/filters/common.tsx @@ -3,7 +3,7 @@ */ import { type ReactElement, useCallback } from 'react' import { useDataViewFilterName, useDataViewNullFilter } from '@contember/bindx-dataview' -import { DataGridFilterSelectItemUI } from '../ui.js' +import { DataGridFilterSelectItemUI } from '#bindx-ui/datagrid/ui' import { dict } from '../../dict.js' export const DataGridNullFilter = ({ name }: { name?: string }): ReactElement => { diff --git a/packages/bindx-ui/src/datagrid/filters/date.tsx b/packages/bindx-ui/src/datagrid/filters/date.tsx index c735297..c2252fe 100644 --- a/packages/bindx-ui/src/datagrid/filters/date.tsx +++ b/packages/bindx-ui/src/datagrid/filters/date.tsx @@ -12,20 +12,20 @@ import { useDataViewFilter, useDataViewFilterName, } from '@contember/bindx-dataview' -import { useDefaultFieldLabel } from '../labels.js' +import { useDefaultFieldLabel } from '#bindx-ui/datagrid/labels' import type { DateFilterArtifact } from '@contember/bindx' import { XIcon } from 'lucide-react' -import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover.js' -import { Input } from '../../ui/input.js' -import { Button } from '../../ui/button.js' -import { Label } from '../../ui/label.js' +import { Popover, PopoverContent, PopoverTrigger } from '#bindx-ui/ui/popover' +import { Input } from '#bindx-ui/ui/input' +import { Button } from '#bindx-ui/ui/button' +import { Label } from '#bindx-ui/ui/label' import { DataGridActiveFilterUI, DataGridFilterSelectTriggerUI, DataGridSingleFilterUI, -} from '../ui.js' -import { DataGridNullFilter } from './common.js' -import { DataGridFilterMobileHiding } from './mobile.js' +} from '#bindx-ui/datagrid/ui' +import { DataGridNullFilter } from '#bindx-ui/datagrid/filters/common' +import { DataGridFilterMobileHiding } from '#bindx-ui/datagrid/filters/mobile' import { dict } from '../../dict.js' export interface DataGridPredefinedDateRange { diff --git a/packages/bindx-ui/src/datagrid/filters/defined.tsx b/packages/bindx-ui/src/datagrid/filters/defined.tsx index f8cfda8..a19f628 100644 --- a/packages/bindx-ui/src/datagrid/filters/defined.tsx +++ b/packages/bindx-ui/src/datagrid/filters/defined.tsx @@ -7,15 +7,15 @@ import { type DataViewIsDefinedFilterProps, DataViewNullFilterTrigger, } from '@contember/bindx-dataview' -import { useDefaultFieldLabel } from '../labels.js' +import { useDefaultFieldLabel } from '#bindx-ui/datagrid/labels' import { CheckIcon, XIcon } from 'lucide-react' -import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover.js' -import { Button } from '../../ui/button.js' +import { Popover, PopoverContent, PopoverTrigger } from '#bindx-ui/ui/popover' +import { Button } from '#bindx-ui/ui/button' import { DataGridFilterSelectTriggerUI, DataGridSingleFilterUI, -} from '../ui.js' -import { DataGridFilterMobileHiding } from './mobile.js' +} from '#bindx-ui/datagrid/ui' +import { DataGridFilterMobileHiding } from '#bindx-ui/datagrid/filters/mobile' import { dict } from '../../dict.js' export type DataGridIsDefinedFilterUIProps = diff --git a/packages/bindx-ui/src/datagrid/filters/enum.tsx b/packages/bindx-ui/src/datagrid/filters/enum.tsx index 9605140..2af1eba 100644 --- a/packages/bindx-ui/src/datagrid/filters/enum.tsx +++ b/packages/bindx-ui/src/datagrid/filters/enum.tsx @@ -12,17 +12,17 @@ import { useDataViewFilterName, type UseDataViewEnumFilterResult, } from '@contember/bindx-dataview' -import { useDefaultFieldLabel } from '../labels.js' -import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover.js' -import { Button } from '../../ui/button.js' +import { useDefaultFieldLabel } from '#bindx-ui/datagrid/labels' +import { Popover, PopoverContent, PopoverTrigger } from '#bindx-ui/ui/popover' +import { Button } from '#bindx-ui/ui/button' import { DataGridActiveFilterUI, DataGridFilterSelectItemUI, DataGridFilterSelectTriggerUI, DataGridSingleFilterUI, -} from '../ui.js' -import { DataGridNullFilter } from './common.js' -import { DataGridFilterMobileHiding } from './mobile.js' +} from '#bindx-ui/datagrid/ui' +import { DataGridNullFilter } from '#bindx-ui/datagrid/filters/common' +import { DataGridFilterMobileHiding } from '#bindx-ui/datagrid/filters/mobile' import { dict } from '../../dict.js' export type DataGridEnumFilterUIProps = diff --git a/packages/bindx-ui/src/datagrid/filters/index.ts b/packages/bindx-ui/src/datagrid/filters/index.ts index f5dd0b0..4dc4193 100644 --- a/packages/bindx-ui/src/datagrid/filters/index.ts +++ b/packages/bindx-ui/src/datagrid/filters/index.ts @@ -1,8 +1,8 @@ -export * from './boolean.js' -export * from './common.js' -export * from './date.js' -export * from './defined.js' -export * from './enum.js' -export * from './number.js' -export * from './relation.js' -export * from './text.js' +export * from '#bindx-ui/datagrid/filters/boolean' +export * from '#bindx-ui/datagrid/filters/common' +export * from '#bindx-ui/datagrid/filters/date' +export * from '#bindx-ui/datagrid/filters/defined' +export * from '#bindx-ui/datagrid/filters/enum' +export * from '#bindx-ui/datagrid/filters/number' +export * from '#bindx-ui/datagrid/filters/relation' +export * from '#bindx-ui/datagrid/filters/text' diff --git a/packages/bindx-ui/src/datagrid/filters/number.tsx b/packages/bindx-ui/src/datagrid/filters/number.tsx index 1e16f77..062ff9b 100644 --- a/packages/bindx-ui/src/datagrid/filters/number.tsx +++ b/packages/bindx-ui/src/datagrid/filters/number.tsx @@ -11,17 +11,17 @@ import { useDataViewFilter, useDataViewFilterName, } from '@contember/bindx-dataview' -import { useDefaultFieldLabel } from '../labels.js' +import { useDefaultFieldLabel } from '#bindx-ui/datagrid/labels' import type { NumberRangeFilterArtifact } from '@contember/bindx' -import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover.js' -import { Input } from '../../ui/input.js' +import { Popover, PopoverContent, PopoverTrigger } from '#bindx-ui/ui/popover' +import { Input } from '#bindx-ui/ui/input' import { DataGridActiveFilterUI, DataGridFilterSelectTriggerUI, DataGridSingleFilterUI, -} from '../ui.js' -import { DataGridNullFilter } from './common.js' -import { DataGridFilterMobileHiding } from './mobile.js' +} from '#bindx-ui/datagrid/ui' +import { DataGridNullFilter } from '#bindx-ui/datagrid/filters/common' +import { DataGridFilterMobileHiding } from '#bindx-ui/datagrid/filters/mobile' import { dict } from '../../dict.js' export type DataGridNumberFilterUIProps = diff --git a/packages/bindx-ui/src/datagrid/filters/relation.tsx b/packages/bindx-ui/src/datagrid/filters/relation.tsx index 9e504df..6cb78ee 100644 --- a/packages/bindx-ui/src/datagrid/filters/relation.tsx +++ b/packages/bindx-ui/src/datagrid/filters/relation.tsx @@ -11,8 +11,8 @@ import { type UseDataViewRelationFilterResult, } from '@contember/bindx-dataview' import type { FieldRef } from '@contember/bindx' -import { useDefaultFieldLabel } from '../labels.js' -import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover.js' +import { useDefaultFieldLabel } from '#bindx-ui/datagrid/labels' +import { Popover, PopoverContent, PopoverTrigger } from '#bindx-ui/ui/popover' import { DataGridActiveFilterUI, DataGridExcludeActionButtonUI, @@ -20,9 +20,9 @@ import { DataGridFilterSelectItemUI, DataGridFilterSelectTriggerUI, DataGridSingleFilterUI, -} from '../ui.js' -import { DataGridNullFilter } from './common.js' -import { DataGridFilterMobileHiding } from './mobile.js' +} from '#bindx-ui/datagrid/ui' +import { DataGridNullFilter } from '#bindx-ui/datagrid/filters/common' +import { DataGridFilterMobileHiding } from '#bindx-ui/datagrid/filters/mobile' import { dict } from '../../dict.js' // ============================================================================ diff --git a/packages/bindx-ui/src/datagrid/filters/text.tsx b/packages/bindx-ui/src/datagrid/filters/text.tsx index 42fadbf..42ab49a 100644 --- a/packages/bindx-ui/src/datagrid/filters/text.tsx +++ b/packages/bindx-ui/src/datagrid/filters/text.tsx @@ -17,15 +17,15 @@ import { type DataViewUnionTextFilterProps, QUERY_FILTER_NAME, } from '@contember/bindx-dataview' -import { useDefaultFieldLabel } from '../labels.js' +import { useDefaultFieldLabel } from '#bindx-ui/datagrid/labels' import { XIcon, MoreHorizontalIcon } from 'lucide-react' -import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover.js' -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../../ui/dropdown.js' -import { Button } from '../../ui/button.js' -import { InputBare, InputLike } from '../../ui/input.js' -import { DataGridActiveFilterUI } from '../ui.js' -import { DataGridNullFilter } from './common.js' -import { DataGridFilterMobileHiding } from './mobile.js' +import { Popover, PopoverContent, PopoverTrigger } from '#bindx-ui/ui/popover' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '#bindx-ui/ui/dropdown' +import { Button } from '#bindx-ui/ui/button' +import { InputBare, InputLike } from '#bindx-ui/ui/input' +import { DataGridActiveFilterUI } from '#bindx-ui/datagrid/ui' +import { DataGridNullFilter } from '#bindx-ui/datagrid/filters/common' +import { DataGridFilterMobileHiding } from '#bindx-ui/datagrid/filters/mobile' import { dict } from '../../dict.js' export type DataGridTextFilterProps = diff --git a/packages/bindx-ui/src/datagrid/grid.tsx b/packages/bindx-ui/src/datagrid/grid.tsx index 0953ad0..11d407a 100644 --- a/packages/bindx-ui/src/datagrid/grid.tsx +++ b/packages/bindx-ui/src/datagrid/grid.tsx @@ -3,11 +3,11 @@ */ // Re-export all styled primitives -export { DefaultDataGrid, type DefaultDataGridProps, DefaultHasManyDataGrid, type DefaultHasManyDataGridProps } from './default-grid.js' -export { DefaultDataGridLayout, type DefaultDataGridLayoutProps } from './default-layout.js' -export { DataGridToolbarUI, type DataGridToolbarUIProps } from './toolbar.js' -export { DataGridPaginationUI, type DataGridPaginationUIProps, DataGridPerPageSelector } from './pagination.js' -export { DataGridColumnHeaderUI, type DataGridColumnHeaderUIProps } from './column-header.js' +export { DataGrid, type DataGridProps, HasManyDataGrid, type HasManyDataGridProps } from '#bindx-ui/datagrid/datagrid' +export { DataGridLayout, type DataGridLayoutProps } from '#bindx-ui/datagrid/layout' +export { DataGridToolbarUI, type DataGridToolbarUIProps } from '#bindx-ui/datagrid/toolbar' +export { DataGridPaginationUI, type DataGridPaginationUIProps, DataGridPerPageSelector } from '#bindx-ui/datagrid/pagination' +export { DataGridColumnHeaderUI, type DataGridColumnHeaderUIProps } from '#bindx-ui/datagrid/column-header' export { DataGridContainer, DataGridTableWrapper, @@ -19,13 +19,13 @@ export { DataGridHeaderCell, DataGridCell, DataGridEmptyState, -} from './table.js' -export { DataGridAutoTable, type DataGridAutoTableProps } from './auto-table.js' -export { DataGridLoader, type DataGridLoaderProps } from './loader.js' -export { DataGridLayoutSwitcher } from './layout-switcher.js' -export { DataGridNoResults } from './empty.js' -export { DataGridAutoExport } from './export.js' -export { DataGridToolbarVisibleElements, type DataGridToolbarVisibleElementsProps } from './elements.js' +} from '#bindx-ui/datagrid/table' +export { DataGridAutoTable, type DataGridAutoTableProps } from '#bindx-ui/datagrid/auto-table' +export { DataGridLoader, type DataGridLoaderProps } from '#bindx-ui/datagrid/loader' +export { DataGridLayoutSwitcher } from '#bindx-ui/datagrid/layout-switcher' +export { DataGridNoResults } from '#bindx-ui/datagrid/empty' +export { DataGridAutoExport } from '#bindx-ui/datagrid/export' +export { DataGridToolbarVisibleElements, type DataGridToolbarVisibleElementsProps } from '#bindx-ui/datagrid/elements' export { DataGridToolbarWrapperUI, DataGridTooltipLabel, @@ -36,7 +36,7 @@ export { DataGridFilterSelectTriggerUI, DataGridFilterSelectItemUI, type DataGridFilterSelectItemProps, -} from './ui.js' +} from '#bindx-ui/datagrid/ui' // Labels export { @@ -44,19 +44,19 @@ export { DataViewHasOneLabel, DataViewHasManyLabel, useDefaultFieldLabel, -} from './labels.js' +} from '#bindx-ui/datagrid/labels' // Cells export { - DataGridEnumCell, - type DataGridEnumCellProps, - DataGridEnumListCell, - type DataGridEnumListCellProps, - DataGridHasOneCell, - type DataGridHasOneCellProps, - DataGridHasManyCell, - type DataGridHasManyCellProps, -} from './cells.js' + EnumCell, + type EnumCellProps, + EnumListCell, + type EnumListCellProps, + HasOneCell, + type HasOneCellProps, + HasManyCell, + type HasManyCellProps, +} from '#bindx-ui/datagrid/cells/index' // Tooltips export { @@ -66,7 +66,7 @@ export { type DataGridHasOneTooltipProps, DataGridHasManyTooltip, type DataGridHasManyTooltipProps, -} from './tooltips.js' +} from '#bindx-ui/datagrid/tooltips' // Filters export { @@ -106,21 +106,21 @@ export { DataGridRelationFilteredItemsList, type DataGridRelationFilteredItemsListProps, type RelationFilterItem, -} from './filters/index.js' +} from '#bindx-ui/datagrid/filters/index' // Styled columns (with inline filter UI) export { - DataGridTextColumn, - DataGridNumberColumn, - DataGridDateColumn, - DataGridDateTimeColumn, - DataGridBooleanColumn, - DataGridEnumColumn, - DataGridEnumListColumn, - DataGridUuidColumn, - DataGridIsDefinedColumn, - DataGridHasOneColumn, - DataGridHasManyColumn, + TextColumn, + NumberColumn, + DateColumn, + DateTimeColumn, + BooleanColumn, + EnumColumn, + EnumListColumn, + UuidColumn, + IsDefinedColumn, + HasOneColumn, + HasManyColumn, DataGridActionColumn, DataGridColumn, type DataGridTextColumnProps, @@ -136,4 +136,4 @@ export { type DataGridHasManyColumnProps, type DataGridActionColumnProps, type DataGridColumnProps, -} from './columns.js' +} from '#bindx-ui/datagrid/columns/index' diff --git a/packages/bindx-ui/src/datagrid/index.ts b/packages/bindx-ui/src/datagrid/index.ts index 3ee40dd..b2a156e 100644 --- a/packages/bindx-ui/src/datagrid/index.ts +++ b/packages/bindx-ui/src/datagrid/index.ts @@ -1 +1 @@ -export * from './grid.js' +export * from '#bindx-ui/datagrid/grid' diff --git a/packages/bindx-ui/src/datagrid/labels.tsx b/packages/bindx-ui/src/datagrid/labels.tsx index 0ef6100..ac4c286 100644 --- a/packages/bindx-ui/src/datagrid/labels.tsx +++ b/packages/bindx-ui/src/datagrid/labels.tsx @@ -9,7 +9,7 @@ import type { ReactElement, ReactNode } from 'react' import { FIELD_REF_META, type FieldRefMeta } from '@contember/bindx' import { useDataViewContext } from '@contember/bindx-dataview' import { useBindxContext } from '@contember/bindx-react' -import { useFieldLabelFormatter } from '../labels/index.js' +import { useFieldLabelFormatter } from '#bindx-ui/labels/index' /** Any ref that carries FIELD_REF_META (FieldRef, HasOneRef, HasManyRef) */ interface RefWithMeta { diff --git a/packages/bindx-ui/src/datagrid/default-layout.tsx b/packages/bindx-ui/src/datagrid/layout.tsx similarity index 68% rename from packages/bindx-ui/src/datagrid/default-layout.tsx rename to packages/bindx-ui/src/datagrid/layout.tsx index e664375..43b973e 100644 --- a/packages/bindx-ui/src/datagrid/default-layout.tsx +++ b/packages/bindx-ui/src/datagrid/layout.tsx @@ -1,5 +1,5 @@ /** - * Shared layout for DefaultDataGrid and DefaultHasManyDataGrid. + * Shared layout for DataGrid and HasManyDataGrid. * Renders toolbar, loader, table/custom layouts, pagination. */ import React, { type ReactElement } from 'react' @@ -10,22 +10,22 @@ import { DataViewNonEmpty, useDataViewContext, } from '@contember/bindx-dataview' -import { DataGridToolbarUI } from './toolbar.js' -import { DataGridLoader } from './loader.js' -import { DataGridPaginationUI } from './pagination.js' -import { DataGridContainer } from './table.js' -import { DataGridAutoTable } from './auto-table.js' -import { DataGridNoResults } from './empty.js' +import { DataGridToolbarUI } from '#bindx-ui/datagrid/toolbar' +import { DataGridLoader } from '#bindx-ui/datagrid/loader' +import { DataGridPaginationUI } from '#bindx-ui/datagrid/pagination' +import { DataGridContainer } from '#bindx-ui/datagrid/table' +import { DataGridAutoTable } from '#bindx-ui/datagrid/auto-table' +import { DataGridNoResults } from '#bindx-ui/datagrid/empty' -export interface DefaultDataGridLayoutProps { +export interface DataGridLayoutProps { stickyToolbar?: boolean stickyPagination?: boolean } -export function DefaultDataGridLayout({ +export function DataGridLayout({ stickyToolbar, stickyPagination, -}: DefaultDataGridLayoutProps): ReactElement { +}: DataGridLayoutProps): ReactElement { const { toolbarContent, layoutRenders } = useDataViewContext() return ( diff --git a/packages/bindx-ui/src/datagrid/loader.tsx b/packages/bindx-ui/src/datagrid/loader.tsx index 3538557..e327b28 100644 --- a/packages/bindx-ui/src/datagrid/loader.tsx +++ b/packages/bindx-ui/src/datagrid/loader.tsx @@ -3,7 +3,7 @@ */ import type { ReactElement, ReactNode } from 'react' import { DataViewLoaderState } from '@contember/bindx-dataview' -import { Loader } from '../ui/loader.js' +import { Loader } from '#bindx-ui/ui/loader' export interface DataGridLoaderProps { children: ReactNode diff --git a/packages/bindx-ui/src/datagrid/pagination.tsx b/packages/bindx-ui/src/datagrid/pagination.tsx index ea28c79..245cd69 100644 --- a/packages/bindx-ui/src/datagrid/pagination.tsx +++ b/packages/bindx-ui/src/datagrid/pagination.tsx @@ -8,7 +8,7 @@ import { DataViewSetItemsPerPageTrigger, } from '@contember/bindx-dataview' import { ChevronsLeftIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsRightIcon } from 'lucide-react' -import { Button } from '../ui/button.js' +import { Button } from '#bindx-ui/ui/button' import { dict, dictFormat } from '../dict.js' import { uic } from '../utils/uic.js' diff --git a/packages/bindx-ui/src/datagrid/toolbar.tsx b/packages/bindx-ui/src/datagrid/toolbar.tsx index 0125e33..bcea376 100644 --- a/packages/bindx-ui/src/datagrid/toolbar.tsx +++ b/packages/bindx-ui/src/datagrid/toolbar.tsx @@ -11,17 +11,17 @@ import { dataAttribute, } from '@contember/bindx-dataview' import { FilterIcon, RefreshCcwIcon, SettingsIcon } from 'lucide-react' -import { Button } from '../ui/button.js' -import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover.js' +import { Button } from '#bindx-ui/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '#bindx-ui/ui/popover' import { cn } from '../utils/cn.js' import { dict } from '../dict.js' -import { DataGridToolbarVisibleElements } from './elements.js' -import { DataGridAutoExport } from './export.js' -import { DataGridLayoutSwitcher } from './layout-switcher.js' -import { DataGridPerPageSelector } from './pagination.js' -import { DataGridToolbarWrapperUI } from './ui.js' -import { DataGridShowFiltersContext } from './filters/mobile.js' -import { DataGridTextFilterInner } from './filters/text.js' +import { DataGridToolbarVisibleElements } from '#bindx-ui/datagrid/elements' +import { DataGridAutoExport } from '#bindx-ui/datagrid/export' +import { DataGridLayoutSwitcher } from '#bindx-ui/datagrid/layout-switcher' +import { DataGridPerPageSelector } from '#bindx-ui/datagrid/pagination' +import { DataGridToolbarWrapperUI } from '#bindx-ui/datagrid/ui' +import { DataGridShowFiltersContext } from '#bindx-ui/datagrid/filters/mobile' +import { DataGridTextFilterInner } from '#bindx-ui/datagrid/filters/text' export interface DataGridToolbarUIProps { children?: ReactNode diff --git a/packages/bindx-ui/src/datagrid/tooltips.tsx b/packages/bindx-ui/src/datagrid/tooltips.tsx index 0c00b48..cf9378a 100644 --- a/packages/bindx-ui/src/datagrid/tooltips.tsx +++ b/packages/bindx-ui/src/datagrid/tooltips.tsx @@ -10,8 +10,8 @@ import { DataViewHasManyFilter, DataViewRelationFilterTrigger, } from '@contember/bindx-dataview' -import { Tooltip } from '../ui/tooltip.js' -import { DataGridExcludeActionButtonUI, DataGridFilterActionButtonUI } from './ui.js' +import { Tooltip } from '#bindx-ui/ui/tooltip' +import { DataGridExcludeActionButtonUI, DataGridFilterActionButtonUI } from '#bindx-ui/datagrid/ui' // ============================================================================ // Enum Field Tooltip diff --git a/packages/bindx-ui/src/datagrid/ui.tsx b/packages/bindx-ui/src/datagrid/ui.tsx index 3127b22..4f792c6 100644 --- a/packages/bindx-ui/src/datagrid/ui.tsx +++ b/packages/bindx-ui/src/datagrid/ui.tsx @@ -1,150 +1,16 @@ -/** - * DataGrid UI primitives — filter action buttons, active filter badges, etc. - */ -import { CheckSquareIcon, FilterIcon, FilterXIcon, PlusIcon, SquareIcon, XIcon } from 'lucide-react' -import { forwardRef, type ReactEventHandler, type ReactNode, useCallback } from 'react' -import { dict } from '../dict.js' -import { Button } from '../ui/button.js' -import { cn } from '../utils/cn.js' -import { uic } from '../utils/uic.js' - -export const DataGridTooltipLabel = uic('span', { - baseClass: 'cursor-pointer border-dashed border-b border-b-gray-400 hover:border-gray-800', -}) - -export const DataGridFilterActionButtonUI = forwardRef((props, ref) => { - return ( - - ) -}) -DataGridFilterActionButtonUI.displayName = 'DataGridFilterActionButtonUI' - -export const DataGridExcludeActionButtonUI = forwardRef((props, ref) => { - return ( - - ) -}) -DataGridExcludeActionButtonUI.displayName = 'DataGridExcludeActionButtonUI' - -export const DataGridActiveFilterUI = forwardRef(({ children, className, ...props }, ref) => { - return ( - - ) -}) -DataGridActiveFilterUI.displayName = 'DataGridActiveFilterUI' - -export const DataGridSingleFilterUI = forwardRef((props, ref) => { - return ( -
- ) -}) -DataGridSingleFilterUI.displayName = 'DataGridSingleFilterUI' - -export const DataGridFilterSelectTriggerUI = forwardRef(({ - children, - ...props -}, ref) => { - return ( - - ) -}) -DataGridFilterSelectTriggerUI.displayName = 'DataGridFilterSelectTriggerUI' - -export interface DataGridFilterSelectItemProps { - onInclude: () => void - onExclude: () => void - isIncluded: boolean - isExcluded: boolean - children: ReactNode -} - -export const DataGridFilterSelectItemUI = forwardRef(({ - children, - onExclude, - isExcluded, - onInclude, - isIncluded, - ...props -}, ref) => { - const include = useCallback(e => { - onInclude() - e.preventDefault() - }, [onInclude]) - const exclude = useCallback(e => { - onExclude() - e.preventDefault() - e.stopPropagation() - }, [onExclude]) - - return ( -
- - -
- ) -}) -DataGridFilterSelectItemUI.displayName = 'DataGridFilterSelectItemUI' - -export const DataGridToolbarWrapperUI = uic('div', { - baseClass: 'flex flex-col md:flex-row gap-2 md:items-end mb-4 items-stretch', - variants: { - sticky: { - true: 'sticky -top-4 z-50 bg-background border-b border-gray-200 py-4', - }, - }, -}) +// Re-export from split files for backward compatibility +export { + DataGridFilterActionButtonUI, + DataGridExcludeActionButtonUI, +} from '#bindx-ui/datagrid/ui/button-ui' + +export { + DataGridActiveFilterUI, + DataGridSingleFilterUI, + DataGridFilterSelectTriggerUI, + DataGridFilterSelectItemUI, + type DataGridFilterSelectItemProps, + DataGridToolbarWrapperUI, +} from '#bindx-ui/datagrid/ui/filter-ui' + +export { DataGridTooltipLabel } from '#bindx-ui/datagrid/ui/label-ui' diff --git a/packages/bindx-ui/src/datagrid/ui/button-ui.tsx b/packages/bindx-ui/src/datagrid/ui/button-ui.tsx new file mode 100644 index 0000000..e3410bf --- /dev/null +++ b/packages/bindx-ui/src/datagrid/ui/button-ui.tsx @@ -0,0 +1,36 @@ +import { FilterIcon, FilterXIcon } from 'lucide-react' +import { forwardRef } from 'react' +import { dict } from '../../dict.js' +import { Button } from '#bindx-ui/ui/button' + +export const DataGridFilterActionButtonUI = forwardRef((props, ref) => { + return ( + + ) +}) +DataGridFilterActionButtonUI.displayName = 'DataGridFilterActionButtonUI' + +export const DataGridExcludeActionButtonUI = forwardRef((props, ref) => { + return ( + + ) +}) +DataGridExcludeActionButtonUI.displayName = 'DataGridExcludeActionButtonUI' diff --git a/packages/bindx-ui/src/datagrid/ui/filter-ui.tsx b/packages/bindx-ui/src/datagrid/ui/filter-ui.tsx new file mode 100644 index 0000000..c2c2e40 --- /dev/null +++ b/packages/bindx-ui/src/datagrid/ui/filter-ui.tsx @@ -0,0 +1,110 @@ +import { CheckSquareIcon, FilterXIcon, PlusIcon, SquareIcon, XIcon } from 'lucide-react' +import { forwardRef, type ReactEventHandler, type ReactNode, useCallback } from 'react' +import { Button } from '#bindx-ui/ui/button' +import { cn } from '../../utils/cn.js' +import { uic } from '../../utils/uic.js' + +export const DataGridActiveFilterUI = forwardRef(({ children, className, ...props }, ref) => { + return ( + + ) +}) +DataGridActiveFilterUI.displayName = 'DataGridActiveFilterUI' + +export const DataGridSingleFilterUI = forwardRef((props, ref) => { + return ( +
+ ) +}) +DataGridSingleFilterUI.displayName = 'DataGridSingleFilterUI' + +export const DataGridFilterSelectTriggerUI = forwardRef(({ + children, + ...props +}, ref) => { + return ( + + ) +}) +DataGridFilterSelectTriggerUI.displayName = 'DataGridFilterSelectTriggerUI' + +export interface DataGridFilterSelectItemProps { + onInclude: () => void + onExclude: () => void + isIncluded: boolean + isExcluded: boolean + children: ReactNode +} + +export const DataGridFilterSelectItemUI = forwardRef(({ + children, + onExclude, + isExcluded, + onInclude, + isIncluded, + ...props +}, ref) => { + const include = useCallback(e => { + onInclude() + e.preventDefault() + }, [onInclude]) + const exclude = useCallback(e => { + onExclude() + e.preventDefault() + e.stopPropagation() + }, [onExclude]) + + return ( +
+ + +
+ ) +}) +DataGridFilterSelectItemUI.displayName = 'DataGridFilterSelectItemUI' + +export const DataGridToolbarWrapperUI = uic('div', { + baseClass: 'flex flex-col md:flex-row gap-2 md:items-end mb-4 items-stretch', + variants: { + sticky: { + true: 'sticky -top-4 z-50 bg-background border-b border-gray-200 py-4', + }, + }, +}) diff --git a/packages/bindx-ui/src/datagrid/ui/index.ts b/packages/bindx-ui/src/datagrid/ui/index.ts new file mode 100644 index 0000000..51995e4 --- /dev/null +++ b/packages/bindx-ui/src/datagrid/ui/index.ts @@ -0,0 +1,10 @@ +export { DataGridFilterActionButtonUI, DataGridExcludeActionButtonUI } from '#bindx-ui/datagrid/ui/button-ui' +export { + DataGridActiveFilterUI, + DataGridSingleFilterUI, + DataGridFilterSelectTriggerUI, + DataGridFilterSelectItemUI, + type DataGridFilterSelectItemProps, + DataGridToolbarWrapperUI, +} from '#bindx-ui/datagrid/ui/filter-ui' +export { DataGridTooltipLabel } from '#bindx-ui/datagrid/ui/label-ui' diff --git a/packages/bindx-ui/src/datagrid/ui/label-ui.tsx b/packages/bindx-ui/src/datagrid/ui/label-ui.tsx new file mode 100644 index 0000000..3e5a857 --- /dev/null +++ b/packages/bindx-ui/src/datagrid/ui/label-ui.tsx @@ -0,0 +1,5 @@ +import { uic } from '../../utils/uic.js' + +export const DataGridTooltipLabel = uic('span', { + baseClass: 'cursor-pointer border-dashed border-b border-b-gray-400 hover:border-gray-800', +}) diff --git a/packages/bindx-ui/src/defaults/BindxUIDefaults.tsx b/packages/bindx-ui/src/defaults/BindxUIDefaults.tsx new file mode 100644 index 0000000..b191c36 --- /dev/null +++ b/packages/bindx-ui/src/defaults/BindxUIDefaults.tsx @@ -0,0 +1,46 @@ +import { createContext, useContext, useMemo, type ReactNode } from 'react' + +/** + * Extensible map of component names to their default props. + * Users can extend this via declaration merging when they eject components: + * + * ```ts + * declare module '@contember/bindx-ui' { + * interface BindxUIDefaultsMap { + * MyCustomInput: Partial + * } + * } + * ``` + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface BindxUIDefaultsMap {} + +type DefaultsRecord = { + [K in keyof BindxUIDefaultsMap]?: BindxUIDefaultsMap[K] +} & Record> + +const BindxUIDefaultsContext = createContext({}) + +export interface BindxUIDefaultsProviderProps { + defaults: DefaultsRecord + children: ReactNode +} + +export function BindxUIDefaultsProvider({ defaults, children }: BindxUIDefaultsProviderProps): ReactNode { + const parent = useContext(BindxUIDefaultsContext) + + const merged = useMemo((): DefaultsRecord => { + const result: DefaultsRecord = { ...parent } + for (const key of Object.keys(defaults)) { + result[key] = { ...parent[key], ...defaults[key] } + } + return result + }, [parent, defaults]) + + return {children} +} + +export function useComponentDefaults>(componentName: string): Partial { + const defaults = useContext(BindxUIDefaultsContext) + return (defaults[componentName] ?? {}) as Partial +} diff --git a/packages/bindx-ui/src/defaults/index.ts b/packages/bindx-ui/src/defaults/index.ts new file mode 100644 index 0000000..490d05b --- /dev/null +++ b/packages/bindx-ui/src/defaults/index.ts @@ -0,0 +1 @@ +export { BindxUIDefaultsProvider, useComponentDefaults, type BindxUIDefaultsMap, type BindxUIDefaultsProviderProps } from '#bindx-ui/defaults/BindxUIDefaults' diff --git a/packages/bindx-ui/src/errors/index.ts b/packages/bindx-ui/src/errors/index.ts index d42f1a4..478eb3e 100644 --- a/packages/bindx-ui/src/errors/index.ts +++ b/packages/bindx-ui/src/errors/index.ts @@ -1 +1 @@ -export { useErrorFormatter } from './useErrorFormatter.js' +export { useErrorFormatter } from '#bindx-ui/errors/useErrorFormatter' diff --git a/packages/bindx-ui/src/form/checkbox-field.tsx b/packages/bindx-ui/src/form/checkbox-field.tsx new file mode 100644 index 0000000..c2eba19 --- /dev/null +++ b/packages/bindx-ui/src/form/checkbox-field.tsx @@ -0,0 +1,38 @@ +import { type ReactNode } from 'react' +import { CheckboxInput } from '#bindx-ui/ui/checkbox-input' +import { FormLabelUI } from '#bindx-ui/form/ui' +import { + FormCheckbox, + FormFieldScope, + FormLabel, +} from '@contember/bindx-form' +import type { FieldRef } from '@contember/bindx' +import { FormContainer, type FormContainerProps } from '#bindx-ui/form/container' +import { FormFieldLabel } from '#bindx-ui/form/label' + +export interface CheckboxFieldProps extends Omit { + readonly field: FieldRef + readonly required?: boolean + readonly inputProps?: Omit, 'defaultValue'> +} + +export const CheckboxField = ({ + field, + label, + description, + inputProps, + required, +}: CheckboxFieldProps): ReactNode => ( + + +
+ + + + + {label ?? } + +
+
+
+) diff --git a/packages/bindx-ui/src/form/container.tsx b/packages/bindx-ui/src/form/container.tsx index f0e4817..f7a3ed5 100644 --- a/packages/bindx-ui/src/form/container.tsx +++ b/packages/bindx-ui/src/form/container.tsx @@ -1,8 +1,8 @@ import { type ReactNode } from 'react' -import { FormContainerUI, FormDescriptionUI, FormErrorUI, FormLabelUI, FormLabelWrapperUI } from './ui.js' -import { useErrorFormatter } from '../errors/useErrorFormatter.js' +import { FormContainerUI, FormDescriptionUI, FormErrorUI, FormLabelUI, FormLabelWrapperUI } from '#bindx-ui/form/ui' +import { useErrorFormatter } from '#bindx-ui/errors/useErrorFormatter' import { FormError, FormFieldStateProvider, FormLabel, useFormFieldState } from '@contember/bindx-form' -import { FormFieldLabel } from './labels.js' +import { FormFieldLabel } from '#bindx-ui/form/label' import type { FieldError } from '@contember/bindx' export interface FormContainerProps { diff --git a/packages/bindx-ui/src/form/index.ts b/packages/bindx-ui/src/form/index.ts index 0710fa4..9f19d43 100644 --- a/packages/bindx-ui/src/form/index.ts +++ b/packages/bindx-ui/src/form/index.ts @@ -1,16 +1,10 @@ -export { FormContainer, type FormContainerProps } from './container.js' -export { - InputField, - TextareaField, - CheckboxField, - RadioEnumField, - type InputFieldProps, - type TextareaFieldProps, - type CheckboxFieldProps, - type RadioEnumFieldProps, -} from './inputs.js' -export { SelectEnumField, type SelectEnumFieldProps } from './select-enum-field.js' -export { FormFieldLabel } from './labels.js' +export { FormContainer, type FormContainerProps } from '#bindx-ui/form/container' +export { InputField, type InputFieldProps } from '#bindx-ui/form/input-field' +export { TextareaField, type TextareaFieldProps } from '#bindx-ui/form/textarea-field' +export { CheckboxField, type CheckboxFieldProps } from '#bindx-ui/form/checkbox-field' +export { RadioEnumField, type RadioEnumFieldProps } from '#bindx-ui/form/radio-enum-field' +export { SelectEnumField, type SelectEnumFieldProps } from '#bindx-ui/form/select-enum-field' +export { FormFieldLabel } from '#bindx-ui/form/label' export { FormLayout, FormDescriptionUI, @@ -18,4 +12,4 @@ export { FormLabelWrapperUI, FormLabelUI, FormContainerUI, -} from './ui.js' +} from '#bindx-ui/form/ui' diff --git a/packages/bindx-ui/src/form/input-field.tsx b/packages/bindx-ui/src/form/input-field.tsx new file mode 100644 index 0000000..0d76e9e --- /dev/null +++ b/packages/bindx-ui/src/form/input-field.tsx @@ -0,0 +1,34 @@ +import { type ComponentProps, type ReactNode } from 'react' +import { Input } from '#bindx-ui/ui/input' +import { + FormFieldScope, + FormInput, +} from '@contember/bindx-form' +import type { FieldRef } from '@contember/bindx' +import { FormContainer, type FormContainerProps } from '#bindx-ui/form/container' + +export interface InputFieldProps extends Omit { + readonly field: FieldRef + readonly required?: boolean + readonly inputProps?: ComponentProps + readonly formatValue?: (value: T | null) => string + readonly parseValue?: (value: string) => T | null +} + +export const InputField = ({ + field, + label, + description, + inputProps, + required, + parseValue, + formatValue, +}: InputFieldProps): ReactNode => ( + + + + + + + +) diff --git a/packages/bindx-ui/src/form/inputs.tsx b/packages/bindx-ui/src/form/inputs.tsx deleted file mode 100644 index 5b17206..0000000 --- a/packages/bindx-ui/src/form/inputs.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { type ComponentProps, type ReactNode, useMemo } from 'react' -import { CheckboxInput, Input, RadioInput } from '../ui/input.js' -import { TextareaAutosize } from '../ui/textarea.js' -import { FormLabelUI } from './ui.js' -import { - FormCheckbox, - FormFieldScope, - FormInput, - FormLabel, - FormRadioInput, - useFormFieldState, -} from '@contember/bindx-form' -import type { FieldRef } from '@contember/bindx' -import { FormContainer, type FormContainerProps } from './container.js' -import { useEnumOptionsFormatter } from '../labels/enumLabels.js' -import { FormFieldLabel } from './labels.js' - -export interface InputFieldProps extends Omit { - readonly field: FieldRef - readonly required?: boolean - readonly inputProps?: ComponentProps - readonly formatValue?: (value: T | null) => string - readonly parseValue?: (value: string) => T | null -} - -export const InputField = ({ - field, - label, - description, - inputProps, - required, - parseValue, - formatValue, -}: InputFieldProps): ReactNode => ( - - - - - - - -) - -export interface TextareaFieldProps extends Omit { - readonly field: FieldRef - readonly required?: boolean - readonly inputProps?: ComponentProps -} - -export const TextareaField = ({ - field, - label, - description, - inputProps, - required, -}: TextareaFieldProps): ReactNode => ( - - - - - - - -) - -export interface CheckboxFieldProps extends Omit { - readonly field: FieldRef - readonly required?: boolean - readonly inputProps?: Omit, 'defaultValue'> -} - -export const CheckboxField = ({ - field, - label, - description, - inputProps, - required, -}: CheckboxFieldProps): ReactNode => ( - - -
- - - - - {label ?? } - -
-
-
-) - -export interface RadioEnumFieldProps extends Omit { - readonly field: FieldRef - readonly required?: boolean - readonly options?: Record | Array<{ value: T | null; label: React.ReactNode }> - readonly orientation?: 'horizontal' | 'vertical' - readonly inputProps?: Omit, 'defaultValue'> -} - -export const RadioEnumField = ({ - field, - label, - description, - required, - ...rest -}: RadioEnumFieldProps): ReactNode => { - return ( - - - - - - ) -} - -interface RadioEnumFieldInnerProps { - readonly field: FieldRef - readonly options?: Record | Array<{ value: T | null; label: React.ReactNode }> - readonly orientation?: 'horizontal' | 'vertical' - readonly inputProps?: Omit, 'defaultValue'> - readonly required?: boolean -} - -const RadioEnumFieldInner = ({ - field, - inputProps, - required, - options, - orientation, -}: RadioEnumFieldInnerProps): ReactNode => { - const enumLabelsFormatter = useEnumOptionsFormatter() - const enumName = useFormFieldState()?.field?.enumName - const resolvedOptions = options ?? (enumName ? enumLabelsFormatter(enumName) : undefined) - - if (!resolvedOptions) { - throw new Error('RadioEnumField: options are required') - } - - const normalizedOptions = useMemo(() => { - return Array.isArray(resolvedOptions) - ? resolvedOptions - : Object.entries(resolvedOptions).map(([value, label]) => ({ value: value as T, label })) - }, [resolvedOptions]) - - return ( -
- {normalizedOptions.map(({ value, label }) => ( - - - - - {label} - - ))} -
- ) -} diff --git a/packages/bindx-ui/src/form/labels.tsx b/packages/bindx-ui/src/form/label.tsx similarity index 82% rename from packages/bindx-ui/src/form/labels.tsx rename to packages/bindx-ui/src/form/label.tsx index 0f294af..65fb3fa 100644 --- a/packages/bindx-ui/src/form/labels.tsx +++ b/packages/bindx-ui/src/form/label.tsx @@ -1,5 +1,5 @@ import { useFormFieldState } from '@contember/bindx-form' -import { useFieldLabelFormatter } from '../labels/fieldLabels.js' +import { useFieldLabelFormatter } from '#bindx-ui/labels/fieldLabels' import type { ReactNode } from 'react' export const FormFieldLabel = (): ReactNode => { diff --git a/packages/bindx-ui/src/form/radio-enum-field.tsx b/packages/bindx-ui/src/form/radio-enum-field.tsx new file mode 100644 index 0000000..1508ab3 --- /dev/null +++ b/packages/bindx-ui/src/form/radio-enum-field.tsx @@ -0,0 +1,78 @@ +import { type ReactNode, useMemo } from 'react' +import { RadioInput } from '#bindx-ui/ui/radio-input' +import { FormLabelUI } from '#bindx-ui/form/ui' +import { + FormFieldScope, + FormRadioInput, + useFormFieldState, +} from '@contember/bindx-form' +import type { FieldRef } from '@contember/bindx' +import { FormContainer, type FormContainerProps } from '#bindx-ui/form/container' +import { useEnumOptionsFormatter } from '#bindx-ui/labels/enumLabels' + +export interface RadioEnumFieldProps extends Omit { + readonly field: FieldRef + readonly required?: boolean + readonly options?: Record | Array<{ value: T | null; label: React.ReactNode }> + readonly orientation?: 'horizontal' | 'vertical' + readonly inputProps?: Omit, 'defaultValue'> +} + +export const RadioEnumField = ({ + field, + label, + description, + required, + ...rest +}: RadioEnumFieldProps): ReactNode => { + return ( + + + + + + ) +} + +interface RadioEnumFieldInnerProps { + readonly field: FieldRef + readonly options?: Record | Array<{ value: T | null; label: React.ReactNode }> + readonly orientation?: 'horizontal' | 'vertical' + readonly inputProps?: Omit, 'defaultValue'> + readonly required?: boolean +} + +const RadioEnumFieldInner = ({ + field, + inputProps, + required, + options, + orientation, +}: RadioEnumFieldInnerProps): ReactNode => { + const enumLabelsFormatter = useEnumOptionsFormatter() + const enumName = useFormFieldState()?.field?.enumName + const resolvedOptions = options ?? (enumName ? enumLabelsFormatter(enumName) : undefined) + + if (!resolvedOptions) { + throw new Error('RadioEnumField: options are required') + } + + const normalizedOptions = useMemo(() => { + return Array.isArray(resolvedOptions) + ? resolvedOptions + : Object.entries(resolvedOptions).map(([value, label]) => ({ value: value as T, label })) + }, [resolvedOptions]) + + return ( +
+ {normalizedOptions.map(({ value, label }) => ( + + + + + {label} + + ))} +
+ ) +} diff --git a/packages/bindx-ui/src/form/select-enum-field.tsx b/packages/bindx-ui/src/form/select-enum-field.tsx index e20c4c5..fa7831c 100644 --- a/packages/bindx-ui/src/form/select-enum-field.tsx +++ b/packages/bindx-ui/src/form/select-enum-field.tsx @@ -3,17 +3,17 @@ import type { FieldRef } from '@contember/bindx' import { FormFieldScope, FormInput, useFormFieldState } from '@contember/bindx-form' import { useField } from '@contember/bindx-react' import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react' -import { FormContainer, type FormContainerProps } from './container.js' -import { useEnumOptionsFormatter } from '../labels/enumLabels.js' -import { Popover, PopoverTrigger } from '../ui/popover.js' +import { FormContainer, type FormContainerProps } from '#bindx-ui/form/container' +import { useEnumOptionsFormatter } from '#bindx-ui/labels/enumLabels' +import { Popover, PopoverTrigger } from '#bindx-ui/ui/popover' import { SelectDefaultPlaceholderUI, SelectInputActionsUI, SelectInputUI, SelectInputWrapperUI, - SelectListItemUI, - SelectPopoverContent, -} from '../select/ui.js' +} from '#bindx-ui/select/input-ui' +import { SelectListItemUI } from '#bindx-ui/select/ui' +import { SelectPopoverContent } from '#bindx-ui/select/popover-ui' export interface SelectEnumFieldProps extends Omit { readonly field: FieldRef diff --git a/packages/bindx-ui/src/form/textarea-field.tsx b/packages/bindx-ui/src/form/textarea-field.tsx new file mode 100644 index 0000000..1c87d4f --- /dev/null +++ b/packages/bindx-ui/src/form/textarea-field.tsx @@ -0,0 +1,30 @@ +import { type ComponentProps, type ReactNode } from 'react' +import { TextareaAutosize } from '#bindx-ui/ui/textarea' +import { + FormFieldScope, + FormInput, +} from '@contember/bindx-form' +import type { FieldRef } from '@contember/bindx' +import { FormContainer, type FormContainerProps } from '#bindx-ui/form/container' + +export interface TextareaFieldProps extends Omit { + readonly field: FieldRef + readonly required?: boolean + readonly inputProps?: ComponentProps +} + +export const TextareaField = ({ + field, + label, + description, + inputProps, + required, +}: TextareaFieldProps): ReactNode => ( + + + + + + + +) diff --git a/packages/bindx-ui/src/form/ui.tsx b/packages/bindx-ui/src/form/ui.tsx index aaa0d4e..851f4b9 100644 --- a/packages/bindx-ui/src/form/ui.tsx +++ b/packages/bindx-ui/src/form/ui.tsx @@ -1,5 +1,5 @@ import { uic } from '../utils/uic.js' -import { Label } from '../ui/label.js' +import { Label } from '#bindx-ui/ui/label' export const FormLayout = uic('div', { baseClass: 'flex flex-col gap-2 mx-4 max-w-lg', diff --git a/packages/bindx-ui/src/index.ts b/packages/bindx-ui/src/index.ts index a3bd222..3ff47dc 100644 --- a/packages/bindx-ui/src/index.ts +++ b/packages/bindx-ui/src/index.ts @@ -13,22 +13,25 @@ export { Input, InputLike, InputBare, - CheckboxInput, - RadioInput, inputConfig, -} from './ui/input.js' -export { Label } from './ui/label.js' -export { Textarea, TextareaAutosize } from './ui/textarea.js' -export { Button, AnchorButton, buttonConfig } from './ui/button.js' -export { Overlay, type OverlayProps } from './ui/overlay.js' -export { Loader, LoaderIcon, type LoaderProps } from './ui/loader.js' -export { Progress } from './ui/progress.js' -export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } from './ui/popover.js' -export { Tooltip, type TooltipProps } from './ui/tooltip.js' +} from '#bindx-ui/ui/input' +export { CheckboxInput } from '#bindx-ui/ui/checkbox-input' +export { RadioInput } from '#bindx-ui/ui/radio-input' +export { Label } from '#bindx-ui/ui/label' +export { Textarea, TextareaAutosize } from '#bindx-ui/ui/textarea' +export { Button, AnchorButton, buttonConfig } from '#bindx-ui/ui/button' +export { Overlay, type OverlayProps } from '#bindx-ui/ui/overlay' +export { Loader, LoaderIcon, type LoaderProps } from '#bindx-ui/ui/loader' +export { Progress } from '#bindx-ui/ui/progress' +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } from '#bindx-ui/ui/popover' +export { Tooltip, type TooltipProps } from '#bindx-ui/ui/tooltip' export { - Sheet, SheetPortal, SheetOverlay, SheetTrigger, SheetClose, - SheetContent, SheetHeader, SheetBody, SheetFooter, SheetTitle, SheetDescription, -} from './ui/sheet.js' + Sheet, SheetTrigger, SheetClose, SheetContent, +} from '#bindx-ui/ui/sheet' +export { + SheetPortal, SheetOverlay, + SheetHeader, SheetBody, SheetFooter, SheetTitle, SheetDescription, +} from '#bindx-ui/ui/sheet-layout' // Form Components export { @@ -51,7 +54,7 @@ export { FormLabelWrapperUI, FormLabelUI, FormContainerUI, -} from './form/index.js' +} from '#bindx-ui/form/index' // Labels export { @@ -63,25 +66,25 @@ export { EnumOptionsFormatterProvider, type EnumOptionsFormatter, type EnumOptionsFormatterProviderProps, -} from './labels/index.js' +} from '#bindx-ui/labels/index' // Errors -export { useErrorFormatter } from './errors/index.js' +export { useErrorFormatter } from '#bindx-ui/errors/index' // Upload -export * from './upload/index.js' +export * from '#bindx-ui/upload/index' // DataGrid -export * from './datagrid/index.js' +export * from '#bindx-ui/datagrid/index' // Select -export * from './select/index.js' +export * from '#bindx-ui/select/index' // Repeater -export * from './repeater/index.js' +export * from '#bindx-ui/repeater/index' // Persist -export * from './persist/index.js' +export * from '#bindx-ui/persist/index' // Dict export { dict, dictFormat } from './dict.js' diff --git a/packages/bindx-ui/src/labels/index.ts b/packages/bindx-ui/src/labels/index.ts index 70a64c1..24e23c8 100644 --- a/packages/bindx-ui/src/labels/index.ts +++ b/packages/bindx-ui/src/labels/index.ts @@ -1,2 +1,2 @@ -export { useFieldLabelFormatter, FieldLabelFormatterProvider, type FieldLabelFormatter, type FieldLabelFormatterProviderProps } from './fieldLabels.js' -export { useEnumOptionsFormatter, EnumOptionsFormatterProvider, type EnumOptionsFormatter, type EnumOptionsFormatterProviderProps } from './enumLabels.js' +export { useFieldLabelFormatter, FieldLabelFormatterProvider, type FieldLabelFormatter, type FieldLabelFormatterProviderProps } from '#bindx-ui/labels/fieldLabels' +export { useEnumOptionsFormatter, EnumOptionsFormatterProvider, type EnumOptionsFormatter, type EnumOptionsFormatterProviderProps } from '#bindx-ui/labels/enumLabels' diff --git a/packages/bindx-ui/src/persist/index.ts b/packages/bindx-ui/src/persist/index.ts index 33e2e50..d27bd23 100644 --- a/packages/bindx-ui/src/persist/index.ts +++ b/packages/bindx-ui/src/persist/index.ts @@ -1 +1 @@ -export { PersistButton, type PersistButtonProps } from './persist-button.js' +export { PersistButton, type PersistButtonProps } from '#bindx-ui/persist/persist-button' diff --git a/packages/bindx-ui/src/persist/persist-button.tsx b/packages/bindx-ui/src/persist/persist-button.tsx index 4e90365..75b4998 100644 --- a/packages/bindx-ui/src/persist/persist-button.tsx +++ b/packages/bindx-ui/src/persist/persist-button.tsx @@ -1,7 +1,7 @@ import { usePersist } from '@contember/bindx-react' import { type ComponentProps, type ReactNode, useCallback } from 'react' -import { Button } from '../ui/button.js' -import { LoaderIcon } from '../ui/loader.js' +import { Button } from '#bindx-ui/ui/button' +import { LoaderIcon } from '#bindx-ui/ui/loader' import { cn } from '../utils/cn.js' import { dict } from '../dict.js' diff --git a/packages/bindx-ui/src/repeater/add-block-button.tsx b/packages/bindx-ui/src/repeater/add-block-button.tsx new file mode 100644 index 0000000..3dccfd7 --- /dev/null +++ b/packages/bindx-ui/src/repeater/add-block-button.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react' +import { PlusCircleIcon } from 'lucide-react' +import { Button } from '#bindx-ui/ui/button' +import { dict } from '../dict.js' + +export function AddBlockButton({ children, onClick }: { children?: ReactNode; onClick: () => void }): ReactNode { + return ( + + ) +} diff --git a/packages/bindx-ui/src/repeater/block-repeater-item.tsx b/packages/bindx-ui/src/repeater/block-repeater-item.tsx new file mode 100644 index 0000000..6747a7e --- /dev/null +++ b/packages/bindx-ui/src/repeater/block-repeater-item.tsx @@ -0,0 +1,132 @@ +import React, { useState, type ReactNode } from 'react' +import type { EntityAccessor, AnyBrand } from '@contember/bindx' +import type { BlockRepeaterItemInfo } from '@contember/bindx-repeater' +import { Trash2Icon, XIcon } from 'lucide-react' +import { Button } from '#bindx-ui/ui/button' +import { + Sheet, + SheetContent, + SheetHeader, + SheetBody, + SheetTitle, + SheetFooter, + SheetClose, +} from '#bindx-ui/ui/sheet' +import { dict } from '../dict.js' +import { + BlockRepeaterItemUI, + BlockRepeaterItemContentUI, + BlockRepeaterItemActionsUI, +} from '#bindx-ui/repeater/block-repeater-ui' +import type { BlockRenderDefinition } from '#bindx-ui/repeater/block-repeater' + +interface BlockItemProps, TBlockNames extends string> { + entity: EntityAccessor + info: BlockRepeaterItemInfo + blocks: Record> + showRemoveButton: boolean +} + +export function BlockItem, TBlockNames extends string>({ + entity, + info, + blocks, + showRemoveButton, +}: BlockItemProps): ReactNode { + return ( + + + + ) +} + +export function BlockItemContent, TBlockNames extends string>({ + entity, + info, + blocks, + showRemoveButton, +}: BlockItemProps): ReactNode { + const [editOpen, setEditOpen] = useState(entity.$isNew) + + const blockDef = info.blockType !== null + ? (blocks as Record>)[info.blockType] + : undefined + + if (!blockDef) return null + + const hasForm = blockDef.form !== undefined + + if (hasForm) { + return ( + <> +
setEditOpen(true)} + > + {showRemoveButton && ( + + + + )} + {blockDef.render(entity, info)} +
+ + e.preventDefault()}> + + {info.block?.label ?? info.blockType} +
+ + + + +
+
+ + {blockDef.form!(entity, info)} + + + + + + +
+
+ + ) + } + + return ( + + {showRemoveButton && ( + + + + )} + {blockDef.render(entity, info)} + + ) +} diff --git a/packages/bindx-ui/src/repeater/block-repeater-sortable.tsx b/packages/bindx-ui/src/repeater/block-repeater-sortable.tsx new file mode 100644 index 0000000..222d0a2 --- /dev/null +++ b/packages/bindx-ui/src/repeater/block-repeater-sortable.tsx @@ -0,0 +1,156 @@ +import { useState, type ReactNode } from 'react' +import type { EntityAccessor, HasManyAccessor, AnyBrand } from '@contember/bindx' +import type { + BlockRepeaterItemInfo, + BlockRepeaterItems, + BlockRepeaterMethods, +} from '@contember/bindx-repeater' +import { sortEntities, repairEntitiesOrder } from '@contember/bindx-repeater' +import { + DndContext, + closestCenter, + MouseSensor, + TouchSensor, + KeyboardSensor, + useSensor, + useSensors, + type DragEndEvent, + DragOverlay, +} from '@dnd-kit/core' +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { GripVerticalIcon } from 'lucide-react' +import { BlockRepeaterItemUI } from '#bindx-ui/repeater/block-repeater-ui' +import { BlockItemContent } from '#bindx-ui/repeater/block-repeater-item' +import type { BlockRenderDefinition } from '#bindx-ui/repeater/block-repeater' + +interface SortableBlockListProps, TBlockNames extends string> { + items: BlockRepeaterItems + methods: BlockRepeaterMethods + field: HasManyAccessor + sortableBy: string + blocks: Record> + showRemoveButton: boolean +} + +export function SortableBlockList, TBlockNames extends string>({ + items, + field, + sortableBy, + blocks, + showRemoveButton, +}: SortableBlockListProps): ReactNode { + const [activeId, setActiveId] = useState(null) + + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 5 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }), + useSensor(KeyboardSensor), + ) + + const entityIds: string[] = [] + items.map((entity) => { + entityIds.push(entity.id) + return null + }) + + const handleDragEnd = (event: DragEndEvent): void => { + setActiveId(null) + const { active, over } = event + if (!over || active.id === over.id) return + + const sorted = sortEntities(field.items, sortableBy) as EntityAccessor[] + const oldIndex = sorted.findIndex(e => e.id === active.id) + const newIndex = sorted.findIndex(e => e.id === over.id) + if (oldIndex === -1 || newIndex === -1) return + + const moved = [...sorted] + const [item] = moved.splice(oldIndex, 1) + moved.splice(newIndex, 0, item!) + repairEntitiesOrder(moved, sortableBy) + } + + return ( + setActiveId(String(e.active.id))} + onDragEnd={handleDragEnd} + onDragCancel={() => setActiveId(null)} + > + + {items.map((entity, info) => ( + + ))} + + + {activeId && items.map((entity, info) => { + if (entity.id !== activeId) return null + return ( + + + + ) + })} + + + ) +} + +interface SortableBlockItemProps, TBlockNames extends string> { + entity: EntityAccessor + info: BlockRepeaterItemInfo + blocks: Record> + showRemoveButton: boolean +} + +export function SortableBlockItem, TBlockNames extends string>({ + entity, + info, + blocks, + showRemoveButton, +}: SortableBlockItemProps): ReactNode { + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: entity.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : undefined, + } + + return ( + +
+ +
+ +
+
+
+ ) +} diff --git a/packages/bindx-ui/src/repeater/block-repeater-ui.tsx b/packages/bindx-ui/src/repeater/block-repeater-ui.tsx new file mode 100644 index 0000000..0562445 --- /dev/null +++ b/packages/bindx-ui/src/repeater/block-repeater-ui.tsx @@ -0,0 +1,13 @@ +import { uic } from '../utils/uic.js' + +export const BlockRepeaterItemUI = uic('div', { + baseClass: 'rounded-lg border border-gray-200 bg-white relative group/repeater-item shadow-sm', +}) + +export const BlockRepeaterItemContentUI = uic('div', { + baseClass: 'p-4', +}) + +export const BlockRepeaterItemActionsUI = uic('div', { + baseClass: 'absolute top-2 right-2 flex gap-1 opacity-0 group-hover/repeater-item:opacity-100 transition-opacity', +}) diff --git a/packages/bindx-ui/src/repeater/block-repeater.tsx b/packages/bindx-ui/src/repeater/block-repeater.tsx new file mode 100644 index 0000000..4fe6996 --- /dev/null +++ b/packages/bindx-ui/src/repeater/block-repeater.tsx @@ -0,0 +1,156 @@ +import React, { type ReactNode } from 'react' +import type { EntityAccessor, HasManyAccessor, AnyBrand } from '@contember/bindx' +import { withCollector } from '@contember/bindx-react' +import { + BlockRepeater as BlockRepeaterCore, + type BlockDefinition, + type BlockRepeaterItemInfo, + type BlockRepeaterItems, +} from '@contember/bindx-repeater' +import { + RepeaterWrapperUI, + RepeaterEmptyUI, +} from '#bindx-ui/repeater/repeater-ui' +import { dict } from '../dict.js' +import { BlockItem } from '#bindx-ui/repeater/block-repeater-item' +import { SortableBlockList } from '#bindx-ui/repeater/block-repeater-sortable' +import { AddBlockButton } from '#bindx-ui/repeater/add-block-button' + +/** + * Block definition with render and optional form for dual-mode. + * + * - `render` always required — used as inline content (no form) or preview (with form) + * - `form` optional — when present, render shows a clickable preview and form opens in a sheet + */ +export interface BlockRenderDefinition< + TEntity, + TSelected = TEntity, + TBrand extends AnyBrand = AnyBrand, + TEntityName extends string = string, + TSchema extends Record = Record, +> { + label?: React.ReactNode + render: ( + entity: EntityAccessor, + info: BlockRepeaterItemInfo, + ) => ReactNode + form?: ( + entity: EntityAccessor, + info: BlockRepeaterItemInfo, + ) => ReactNode +} + +export interface BlockRepeaterProps< + TEntity extends object = object, + TSelected = TEntity, + TBrand extends AnyBrand = AnyBrand, + TEntityName extends string = string, + TSchema extends Record = Record, + TBlockNames extends string = string, +> { + /** The has-many relation field */ + readonly field: HasManyAccessor + /** Name of the scalar field used to discriminate block types */ + readonly discriminationField: string + /** Optional field name for sorting — enables drag-and-drop when set */ + readonly sortableBy?: string + /** Section title */ + readonly title?: ReactNode + /** Whether to show the remove button on each item */ + readonly showRemoveButton?: boolean + /** Block definitions with render (and optional form) functions */ + readonly blocks: Record> +} + +export const BlockRepeater = withCollector(function BlockRepeater< + TEntity extends object, + TSelected, + TBrand extends AnyBrand, + TEntityName extends string, + TSchema extends Record, + TBlockNames extends string, +>({ + field, + discriminationField, + sortableBy, + title, + showRemoveButton = true, + blocks, +}: BlockRepeaterProps): ReactNode { + return ( + } + > + {(items, methods) => ( + + {title &&

{title}

} + + {methods.isEmpty && ( + {dict.repeater.empty} + )} + + {sortableBy ? ( + + ) : ( + + )} + +
+ {methods.blockList.map(b => ( + methods.addItem(b.name)}> + {b.label ?? b.name} + + ))} +
+
+ )} +
+ ) +}, (props) => ( + +)) + +interface PlainBlockListProps, TBlockNames extends string> { + items: BlockRepeaterItems + blocks: Record> + showRemoveButton: boolean +} + +function PlainBlockList, TBlockNames extends string>({ + items, + blocks, + showRemoveButton, +}: PlainBlockListProps): ReactNode { + return ( + <> + {items.map((entity, info) => ( + + ))} + + ) +} diff --git a/packages/bindx-ui/src/repeater/default-block-repeater.tsx b/packages/bindx-ui/src/repeater/default-block-repeater.tsx deleted file mode 100644 index aaeec42..0000000 --- a/packages/bindx-ui/src/repeater/default-block-repeater.tsx +++ /dev/null @@ -1,504 +0,0 @@ -import { useState, type ReactNode } from 'react' -import type { EntityAccessor, HasManyRef, AnyBrand } from '@contember/bindx' -import { HasMany, withCollector, useHasMany } from '@contember/bindx-react' -import { - BlockRepeater, - type BlockDefinition, - type BlockRepeaterItemInfo, - type BlockRepeaterItems, - type BlockRepeaterMethods, - sortEntities, - repairEntitiesOrder, -} from '@contember/bindx-repeater' -import { - DndContext, - closestCenter, - MouseSensor, - TouchSensor, - KeyboardSensor, - useSensor, - useSensors, - type DragEndEvent, - DragOverlay, -} from '@dnd-kit/core' -import { - SortableContext, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable' -import { CSS } from '@dnd-kit/utilities' -import { GripVerticalIcon, PlusCircleIcon, Trash2Icon, XIcon } from 'lucide-react' -import { Button } from '../ui/button.js' -import { - Sheet, - SheetContent, - SheetHeader, - SheetBody, - SheetTitle, - SheetFooter, - SheetClose, -} from '../ui/sheet.js' -import { - RepeaterWrapperUI, - RepeaterEmptyUI, - collectionItemInfo, -} from './default-repeater.js' -import { dict } from '../dict.js' -import { uic } from '../utils/uic.js' - -// ============================================================================ -// Types -// ============================================================================ - -/** - * Block definition with render and optional form for dual-mode. - * - * - `render` always required — used as inline content (no form) or preview (with form) - * - `form` optional — when present, render shows a clickable preview and form opens in a sheet - */ -export interface BlockRenderDefinition< - TEntity extends object, - TSelected = TEntity, - TBrand extends AnyBrand = AnyBrand, - TEntityName extends string = string, - TSchema extends Record = Record, -> { - label?: ReactNode - render: ( - entity: EntityAccessor, - info: BlockRepeaterItemInfo, - ) => ReactNode - form?: ( - entity: EntityAccessor, - info: BlockRepeaterItemInfo, - ) => ReactNode -} - -export interface DefaultBlockRepeaterProps< - TEntity extends object = object, - TSelected = TEntity, - TBrand extends AnyBrand = AnyBrand, - TEntityName extends string = string, - TSchema extends Record = Record, - TBlockNames extends string = string, -> { - /** The has-many relation field */ - readonly field: HasManyRef - /** Name of the scalar field used to discriminate block types */ - readonly discriminationField: string - /** Optional field name for sorting — enables drag-and-drop when set */ - readonly sortableBy?: string - /** Section title */ - readonly title?: ReactNode - /** Whether to show the remove button on each item */ - readonly showRemoveButton?: boolean - /** Block definitions with render (and optional form) functions */ - readonly blocks: Record> -} - -// ============================================================================ -// UI Components -// ============================================================================ - -const BlockRepeaterItemUI = uic('div', { - baseClass: 'rounded-lg border border-gray-200 bg-white relative group/repeater-item shadow-sm', -}) - -const BlockRepeaterItemContentUI = uic('div', { - baseClass: 'p-4', -}) - -const BlockRepeaterItemActionsUI = uic('div', { - baseClass: 'absolute top-2 right-2 flex gap-1 opacity-0 group-hover/repeater-item:opacity-100 transition-opacity', -}) - -// ============================================================================ -// DefaultBlockRepeater -// ============================================================================ - -export const DefaultBlockRepeater = withCollector(function DefaultBlockRepeater< - TEntity extends object, - TSelected, - TBrand extends AnyBrand, - TEntityName extends string, - TSchema extends Record, - TBlockNames extends string, ->({ - field, - discriminationField, - sortableBy, - title, - showRemoveButton = true, - blocks, -}: DefaultBlockRepeaterProps): ReactNode { - return ( - >} - > - {(items, methods) => ( - - {title &&

{title}

} - - {methods.isEmpty && ( - {dict.repeater.empty} - )} - - {sortableBy ? ( - - ) : ( - - )} - -
- {methods.blockList.map(b => ( - methods.addItem(b.name)}> - {b.label ?? b.name} - - ))} -
-
- )} -
- ) -}, (props) => ( - - {item => { - const blockCollectionInfo: BlockRepeaterItemInfo = { - ...collectionItemInfo, - blockType: null, - block: undefined, - } - return ( - <> - {Object.values>(props.blocks).map((blockDef, i) => { - const info = { ...blockCollectionInfo, index: i } - return ( - - {blockDef.render(item as EntityAccessor, info)} - {blockDef.form?.(item as EntityAccessor, info)} - - ) - })} - - ) - }} - -)) - -// ============================================================================ -// Non-sortable list (no DnD) -// ============================================================================ - -import React from 'react' - -interface PlainBlockListProps, TBlockNames extends string> { - items: BlockRepeaterItems - blocks: Record> - showRemoveButton: boolean -} - -function PlainBlockList, TBlockNames extends string>({ - items, - blocks, - showRemoveButton, -}: PlainBlockListProps): ReactNode { - return ( - <> - {items.map((entity, info) => ( - - ))} - - ) -} - -// ============================================================================ -// Sortable list (DnD) -// ============================================================================ - -interface SortableBlockListProps, TBlockNames extends string> { - items: BlockRepeaterItems - methods: BlockRepeaterMethods - field: HasManyRef - sortableBy: string - blocks: Record> - showRemoveButton: boolean -} - -function SortableBlockList, TBlockNames extends string>({ - items, - field, - sortableBy, - blocks, - showRemoveButton, -}: SortableBlockListProps): ReactNode { - const fieldAccessor = useHasMany(field) - const [activeId, setActiveId] = useState(null) - - const sensors = useSensors( - useSensor(MouseSensor, { activationConstraint: { distance: 5 } }), - useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }), - useSensor(KeyboardSensor), - ) - - // Collect entity IDs for SortableContext - const entityIds: string[] = [] - items.map((entity) => { - entityIds.push(entity.id) - return null - }) - - const handleDragEnd = (event: DragEndEvent): void => { - setActiveId(null) - const { active, over } = event - if (!over || active.id === over.id) return - - const sorted = sortEntities(fieldAccessor.items, sortableBy) as EntityAccessor[] - const oldIndex = sorted.findIndex(e => e.id === active.id) - const newIndex = sorted.findIndex(e => e.id === over.id) - if (oldIndex === -1 || newIndex === -1) return - - // Reorder: remove from old, insert at new - const moved = [...sorted] - const [item] = moved.splice(oldIndex, 1) - moved.splice(newIndex, 0, item!) - repairEntitiesOrder(moved, sortableBy) - } - - return ( - setActiveId(String(e.active.id))} - onDragEnd={handleDragEnd} - onDragCancel={() => setActiveId(null)} - > - - {items.map((entity, info) => ( - - ))} - - - {activeId && items.map((entity, info) => { - if (entity.id !== activeId) return null - return ( - - - - ) - })} - - - ) -} - -// ============================================================================ -// Sortable item wrapper -// ============================================================================ - -interface SortableBlockItemProps, TBlockNames extends string> { - entity: EntityAccessor - info: BlockRepeaterItemInfo - blocks: Record> - showRemoveButton: boolean -} - -function SortableBlockItem, TBlockNames extends string>({ - entity, - info, - blocks, - showRemoveButton, -}: SortableBlockItemProps): ReactNode { - const { - attributes, - listeners, - setNodeRef, - setActivatorNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: entity.id }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.4 : undefined, - } - - return ( - -
- -
- -
-
-
- ) -} - -// ============================================================================ -// Block item (plain, no sortable wrapper) -// ============================================================================ - -interface BlockItemProps, TBlockNames extends string> { - entity: EntityAccessor - info: BlockRepeaterItemInfo - blocks: Record> - showRemoveButton: boolean -} - -function BlockItem, TBlockNames extends string>({ - entity, - info, - blocks, - showRemoveButton, -}: BlockItemProps): ReactNode { - return ( - - - - ) -} - -// ============================================================================ -// Block item content with dual-mode (render/form) -// ============================================================================ - -function BlockItemContent, TBlockNames extends string>({ - entity, - info, - blocks, - showRemoveButton, -}: BlockItemProps): ReactNode { - const [editOpen, setEditOpen] = useState(entity.$isNew) - - const blockDef = info.blockType !== null - ? (blocks as Record>)[info.blockType] - : undefined - - if (!blockDef) return null - - const hasForm = blockDef.form !== undefined - - // Dual mode: render is preview, form opens in sheet - if (hasForm) { - return ( - <> -
setEditOpen(true)} - > - {showRemoveButton && ( - - - - )} - {blockDef.render(entity, info)} -
- - e.preventDefault()}> - - {info.block?.label ?? info.blockType} -
- - - - -
-
- - {blockDef.form!(entity, info)} - - - - - - -
-
- - ) - } - - // Inline mode: render is the full content - return ( - - {showRemoveButton && ( - - - - )} - {blockDef.render(entity, info)} - - ) -} - -// ============================================================================ -// Add block button -// ============================================================================ - -function AddBlockButton({ children, onClick }: { children?: ReactNode; onClick: () => void }): ReactNode { - return ( - - ) -} diff --git a/packages/bindx-ui/src/repeater/index.ts b/packages/bindx-ui/src/repeater/index.ts index 0bec08b..1e78805 100644 --- a/packages/bindx-ui/src/repeater/index.ts +++ b/packages/bindx-ui/src/repeater/index.ts @@ -1,14 +1,28 @@ export { - DefaultRepeater, - type DefaultRepeaterProps, + Repeater, + type RepeaterProps, + collectionItemInfo, +} from '#bindx-ui/repeater/repeater' + +export { RepeaterWrapperUI, RepeaterItemUI, RepeaterEmptyUI, RepeaterItemActionsUI, -} from './default-repeater.js' +} from '#bindx-ui/repeater/repeater-ui' export { - DefaultBlockRepeater, - type DefaultBlockRepeaterProps, + BlockRepeater, + type BlockRepeaterProps, type BlockRenderDefinition, -} from './default-block-repeater.js' +} from '#bindx-ui/repeater/block-repeater' + +export { + BlockRepeaterItemUI, + BlockRepeaterItemContentUI, + BlockRepeaterItemActionsUI, +} from '#bindx-ui/repeater/block-repeater-ui' + +export { BlockItem, BlockItemContent } from '#bindx-ui/repeater/block-repeater-item' +export { SortableBlockList, SortableBlockItem } from '#bindx-ui/repeater/block-repeater-sortable' +export { AddBlockButton } from '#bindx-ui/repeater/add-block-button' diff --git a/packages/bindx-ui/src/repeater/repeater-ui.tsx b/packages/bindx-ui/src/repeater/repeater-ui.tsx new file mode 100644 index 0000000..76adc63 --- /dev/null +++ b/packages/bindx-ui/src/repeater/repeater-ui.tsx @@ -0,0 +1,17 @@ +import { uic } from '../utils/uic.js' + +export const RepeaterWrapperUI = uic('div', { + baseClass: 'flex flex-col gap-2 relative bg-background mb-4', +}) + +export const RepeaterItemUI = uic('div', { + baseClass: 'rounded-sm border border-gray-200 bg-gray-50 p-4 relative group/repeater-item', +}) + +export const RepeaterEmptyUI = uic('div', { + baseClass: 'italic text-sm text-gray-600', +}) + +export const RepeaterItemActionsUI = uic('div', { + baseClass: 'absolute top-1 right-2 flex gap-2', +}) diff --git a/packages/bindx-ui/src/repeater/default-repeater.tsx b/packages/bindx-ui/src/repeater/repeater.tsx similarity index 66% rename from packages/bindx-ui/src/repeater/default-repeater.tsx rename to packages/bindx-ui/src/repeater/repeater.tsx index 6b9662a..c4ba502 100644 --- a/packages/bindx-ui/src/repeater/default-repeater.tsx +++ b/packages/bindx-ui/src/repeater/repeater.tsx @@ -1,37 +1,18 @@ import type { ReactNode } from 'react' import type { EntityAccessor, HasManyRef, AnyBrand } from '@contember/bindx' -import { HasMany, withCollector } from '@contember/bindx-react' -import { Repeater, type RepeaterItemInfo } from '@contember/bindx-repeater' +import { withCollector } from '@contember/bindx-react' +import { Repeater as RepeaterCore, type RepeaterItemInfo } from '@contember/bindx-repeater' import { PlusCircleIcon, Trash2Icon } from 'lucide-react' -import { Button } from '../ui/button.js' -import { uic } from '../utils/uic.js' +import { Button } from '#bindx-ui/ui/button' import { dict } from '../dict.js' +import { + RepeaterWrapperUI, + RepeaterItemUI, + RepeaterEmptyUI, + RepeaterItemActionsUI, +} from '#bindx-ui/repeater/repeater-ui' -// ============================================================================ -// UI Components -// ============================================================================ - -const RepeaterWrapperUI = uic('div', { - baseClass: 'flex flex-col gap-2 relative bg-background mb-4', -}) - -const RepeaterItemUI = uic('div', { - baseClass: 'rounded-sm border border-gray-200 bg-gray-50 p-4 relative group/repeater-item', -}) - -const RepeaterEmptyUI = uic('div', { - baseClass: 'italic text-sm text-gray-600', -}) - -const RepeaterItemActionsUI = uic('div', { - baseClass: 'absolute top-1 right-2 flex gap-2', -}) - -// ============================================================================ -// DefaultRepeater -// ============================================================================ - -export interface DefaultRepeaterProps< +export interface RepeaterProps< TEntity extends object = object, TSelected = TEntity, TBrand extends AnyBrand = AnyBrand, @@ -62,14 +43,14 @@ export interface DefaultRepeaterProps< * * @example * ```tsx - * + * * {(item, { remove }) => ( * * )} - * + * * ``` */ -export const DefaultRepeater = withCollector(function DefaultRepeater< +export const Repeater = withCollector(function Repeater< TEntity extends object, TSelected, TBrand extends AnyBrand, @@ -83,9 +64,9 @@ export const DefaultRepeater = withCollector(function DefaultRepeater< addButtonPosition = 'after', showRemoveButton = true, children, -}: DefaultRepeaterProps): ReactNode { +}: RepeaterProps): ReactNode { return ( - + {(items, { addItem, isEmpty }) => ( {title &&

{title}

} @@ -116,12 +97,12 @@ export const DefaultRepeater = withCollector(function DefaultRepeater< )}
)} -
+ ) }, (props) => ( - - {item => props.children(item, collectionItemInfo)} - + + {(items) => <>{items.map((entity, info) => props.children(entity, info))}} + )) function AddButton({ children, onClick }: { children?: ReactNode; onClick: () => void }): ReactNode { @@ -135,10 +116,6 @@ function AddButton({ children, onClick }: { children?: ReactNode; onClick: () => ) } -// ============================================================================ -// Exports for customization -// ============================================================================ - /** Mock item info for use in staticRender during selection collection. */ export const collectionItemInfo: RepeaterItemInfo = Object.freeze({ index: 0, @@ -148,10 +125,3 @@ export const collectionItemInfo: RepeaterItemInfo = Object.freeze({ moveUp: () => {}, moveDown: () => {}, }) - -export { - RepeaterWrapperUI, - RepeaterItemUI, - RepeaterEmptyUI, - RepeaterItemActionsUI, -} diff --git a/packages/bindx-ui/src/select/filter.tsx b/packages/bindx-ui/src/select/filter.tsx index 33bc625..0b93691 100644 --- a/packages/bindx-ui/src/select/filter.tsx +++ b/packages/bindx-ui/src/select/filter.tsx @@ -1,5 +1,5 @@ import { memo } from 'react' -import { Input } from '../ui/input.js' +import { Input } from '#bindx-ui/ui/input' import { DataViewHasFilterType, DataViewTextFilterInput, diff --git a/packages/bindx-ui/src/select/index.ts b/packages/bindx-ui/src/select/index.ts index 270587c..6660905 100644 --- a/packages/bindx-ui/src/select/index.ts +++ b/packages/bindx-ui/src/select/index.ts @@ -1,18 +1,22 @@ -export { SelectField, type SelectFieldProps } from './select-field.js' -export { MultiSelectField, type MultiSelectFieldProps } from './multi-select-field.js' -export { DefaultSelectDataView, type DefaultSelectDataViewProps } from './list.js' -export { SelectDefaultFilter } from './filter.js' -export { useOnHighlight } from './highlight.js' +export { SelectField, type SelectFieldProps } from '#bindx-ui/select/select-field' +export { MultiSelectField, type MultiSelectFieldProps } from '#bindx-ui/select/multi-select-field' +export { SelectDataView, type SelectDataViewProps } from '#bindx-ui/select/list' +export { SelectDefaultFilter } from '#bindx-ui/select/filter' +export { useOnHighlight } from '#bindx-ui/select/highlight' +export { SelectListItemUI } from '#bindx-ui/select/ui' export { SelectInputWrapperUI, SelectInputUI, SelectInputActionsUI, - SelectListItemUI, SelectDefaultPlaceholderUI, +} from '#bindx-ui/select/input-ui' +export { MultiSelectItemWrapperUI, MultiSelectItemUI, MultiSelectItemContentUI, MultiSelectItemRemoveButtonUI, +} from '#bindx-ui/select/multi-select-ui' +export { SelectPopoverContent, SelectCreateNewTrigger, -} from './ui.js' +} from '#bindx-ui/select/popover-ui' diff --git a/packages/bindx-ui/src/select/input-ui.tsx b/packages/bindx-ui/src/select/input-ui.tsx new file mode 100644 index 0000000..aa60122 --- /dev/null +++ b/packages/bindx-ui/src/select/input-ui.tsx @@ -0,0 +1,28 @@ +import { uic } from '../utils/uic.js' +import { dict } from '../dict.js' + +export const SelectInputWrapperUI = uic('div', { + baseClass: 'w-full max-w-md relative', +}) + +export const SelectInputUI = uic('button', { + baseClass: [ + 'flex gap-2 justify-between items-center', + 'w-full min-h-10', + 'px-2 py-1', + 'bg-background', + 'rounded-md border border-input ring-offset-background', + 'text-sm text-left', + 'cursor-pointer', + 'hover:border-gray-400', + 'placeholder:text-muted-foreground', + 'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', + 'disabled:cursor-not-allowed disabled:opacity-50', + ], +}) + +export const SelectInputActionsUI = uic('span', { + baseClass: 'flex gap-1 items-center', +}) + +export const SelectDefaultPlaceholderUI = (): React.ReactElement => {dict.select.placeholder} diff --git a/packages/bindx-ui/src/select/list.tsx b/packages/bindx-ui/src/select/list.tsx index ffe2c39..e87bef5 100644 --- a/packages/bindx-ui/src/select/list.tsx +++ b/packages/bindx-ui/src/select/list.tsx @@ -1,5 +1,5 @@ /** - * DefaultSelectDataView — pre-configured select options list with search, + * SelectDataView — pre-configured select options list with search, * infinite scroll, and keyboard navigation. */ @@ -7,7 +7,7 @@ import React, { type ReactNode } from 'react' import type { EntityAccessor, OrderDirection } from '@contember/bindx' import type { FieldRef } from '@contember/bindx' import { - SelectDataView, + SelectDataView as SelectDataViewCore, SelectOption, SelectItemTrigger, DataViewInfiniteLoadProvider, @@ -19,13 +19,13 @@ import { DataViewHighlightRow, useSelectHandleSelect, } from '@contember/bindx-dataview' -import { Loader } from '../ui/loader.js' -import { Button } from '../ui/button.js' +import { Loader } from '#bindx-ui/ui/loader' +import { Button } from '#bindx-ui/ui/button' import { ArrowBigDownDash } from 'lucide-react' -import { SelectDefaultFilter } from './filter.js' -import { SelectListItemUI } from './ui.js' +import { SelectDefaultFilter } from '#bindx-ui/select/filter' +import { SelectListItemUI } from '#bindx-ui/select/ui' -export interface DefaultSelectDataViewProps { +export interface SelectDataViewProps { /** Per-item render function */ children: (it: EntityAccessor) => ReactNode /** Field(s) to search across */ @@ -36,14 +36,14 @@ export interface DefaultSelectDataViewProps { filter?: Record } -export function DefaultSelectDataView({ +export function SelectDataView({ children, queryField, initialSorting, filter, -}: DefaultSelectDataViewProps): ReactNode { +}: SelectDataViewProps): ReactNode { return ( - {children} - + ) } diff --git a/packages/bindx-ui/src/select/multi-select-field.tsx b/packages/bindx-ui/src/select/multi-select-field.tsx index 5de7704..4142662 100644 --- a/packages/bindx-ui/src/select/multi-select-field.tsx +++ b/packages/bindx-ui/src/select/multi-select-field.tsx @@ -25,21 +25,23 @@ import type { FieldRef } from '@contember/bindx' import { HasMany, withCollector } from '@contember/bindx-react' import { MultiSelect, SelectEachValue, SelectPlaceholder } from '@contember/bindx-dataview' import { FormHasManyRelationScope } from '@contember/bindx-form' -import { FormContainer } from '../form/container.js' -import { Popover, PopoverTrigger } from '../ui/popover.js' +import { FormContainer } from '#bindx-ui/form/container' +import { Popover, PopoverTrigger } from '#bindx-ui/ui/popover' import { ChevronDownIcon } from 'lucide-react' -import { DefaultSelectDataView } from './list.js' +import { SelectDataView } from '#bindx-ui/select/list' import { - MultiSelectItemContentUI, - MultiSelectItemRemoveButtonUI, - MultiSelectItemUI, - MultiSelectItemWrapperUI, SelectDefaultPlaceholderUI, SelectInputActionsUI, SelectInputUI, SelectInputWrapperUI, - SelectPopoverContent, -} from './ui.js' +} from '#bindx-ui/select/input-ui' +import { + MultiSelectItemContentUI, + MultiSelectItemRemoveButtonUI, + MultiSelectItemUI, + MultiSelectItemWrapperUI, +} from '#bindx-ui/select/multi-select-ui' +import { SelectPopoverContent } from '#bindx-ui/select/popover-ui' /** Extract the target entity type from a HasManyRef */ type HasManyTarget = F extends HasManyRef ? TEntity : object @@ -128,13 +130,13 @@ export const MultiSelectField = withCollector(function MultiSelectField - {children as (it: EntityAccessor) => ReactNode} - + diff --git a/packages/bindx-ui/src/select/multi-select-ui.tsx b/packages/bindx-ui/src/select/multi-select-ui.tsx new file mode 100644 index 0000000..b4e8c48 --- /dev/null +++ b/packages/bindx-ui/src/select/multi-select-ui.tsx @@ -0,0 +1,29 @@ +import { uic } from '../utils/uic.js' +import { XIcon } from 'lucide-react' + +export const MultiSelectItemWrapperUI = uic('div', { + baseClass: 'flex flex-wrap gap-1 items-center justify-start', +}) + +export const MultiSelectItemUI = uic('span', { + baseClass: 'flex items-stretch border border-gray-200 rounded-sm hover:shadow-sm transition-all', +}) + +export const MultiSelectItemContentUI = uic('span', { + baseClass: 'rounded-l px-2 py-1 bg-background', +}) + +export const MultiSelectItemRemoveButtonUI = uic('span', { + baseClass: 'bg-gray-100 border-l border-gray-200 py-1 px-2 rounded-r text-black inline-flex items-center justify-center hover:bg-gray-300', + afterChildren: , + defaultProps: { + tabIndex: 0, + role: 'button', + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + ;(e.currentTarget as HTMLElement).click() + e.preventDefault() + } + }, + }, +}) diff --git a/packages/bindx-ui/src/select/popover-ui.tsx b/packages/bindx-ui/src/select/popover-ui.tsx new file mode 100644 index 0000000..be76197 --- /dev/null +++ b/packages/bindx-ui/src/select/popover-ui.tsx @@ -0,0 +1,19 @@ +import { forwardRef } from 'react' +import { uic } from '../utils/uic.js' +import { Button } from '#bindx-ui/ui/button' +import { PopoverContent } from '#bindx-ui/ui/popover' +import { PlusIcon } from 'lucide-react' + +export const SelectPopoverContent = uic(PopoverContent, { + baseClass: 'group w-[max(16rem,var(--radix-popover-trigger-width))]', + defaultProps: { + onWheel: (e: React.WheelEvent) => e.stopPropagation(), + }, +}) + +export const SelectCreateNewTrigger = forwardRef((props, ref) => ( + +)) +SelectCreateNewTrigger.displayName = 'SelectCreateNewTrigger' diff --git a/packages/bindx-ui/src/select/select-field.tsx b/packages/bindx-ui/src/select/select-field.tsx index 3624656..9accd4c 100644 --- a/packages/bindx-ui/src/select/select-field.tsx +++ b/packages/bindx-ui/src/select/select-field.tsx @@ -25,18 +25,18 @@ import { HasOne, withCollector } from '@contember/bindx-react' import { Select, SelectEachValue, SelectPlaceholder } from '@contember/bindx-dataview' import { useHasOne } from '@contember/bindx-react' import { FormHasOneRelationScope } from '@contember/bindx-form' -import { FormContainer } from '../form/container.js' -import { Popover, PopoverTrigger } from '../ui/popover.js' -import { Button } from '../ui/button.js' +import { FormContainer } from '#bindx-ui/form/container' +import { Popover, PopoverTrigger } from '#bindx-ui/ui/popover' +import { Button } from '#bindx-ui/ui/button' import { ChevronDownIcon, XIcon } from 'lucide-react' -import { DefaultSelectDataView } from './list.js' +import { SelectDataView } from '#bindx-ui/select/list' import { SelectDefaultPlaceholderUI, SelectInputActionsUI, SelectInputUI, SelectInputWrapperUI, - SelectPopoverContent, -} from './ui.js' +} from '#bindx-ui/select/input-ui' +import { SelectPopoverContent } from '#bindx-ui/select/popover-ui' /** Extract the target entity type from a HasOneRef */ type RelationTarget = F extends HasOneRef ? TEntity : object @@ -127,13 +127,13 @@ export const SelectField = withCollector(function SelectField - {children as (it: EntityAccessor) => ReactNode} - + diff --git a/packages/bindx-ui/src/select/ui.tsx b/packages/bindx-ui/src/select/ui.tsx index ea4503c..6a37b0c 100644 --- a/packages/bindx-ui/src/select/ui.tsx +++ b/packages/bindx-ui/src/select/ui.tsx @@ -1,33 +1,6 @@ +import { CheckIcon } from 'lucide-react' import { uic } from '../utils/uic.js' -import { Button } from '../ui/button.js' -import { PopoverContent } from '../ui/popover.js' -import { forwardRef } from 'react' -import { CheckIcon, PlusIcon, XIcon } from 'lucide-react' -import { dict } from '../dict.js' - -export const SelectInputWrapperUI = uic('div', { - baseClass: 'w-full max-w-md relative', -}) - -export const SelectInputUI = uic('button', { - baseClass: [ - 'flex gap-2 justify-between items-center', - 'w-full min-h-10', - 'px-2 py-1', - 'bg-background', - 'rounded-md border border-input ring-offset-background', - 'text-sm text-left', - 'cursor-pointer', - 'hover:border-gray-400', - 'placeholder:text-muted-foreground', - 'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', - 'disabled:cursor-not-allowed disabled:opacity-50', - ], -}) - -export const SelectInputActionsUI = uic('span', { - baseClass: 'flex gap-1 items-center', -}) +import { Button } from '#bindx-ui/ui/button' export const SelectListItemUI = uic(Button, { baseClass: 'w-full text-left justify-start gap-1 data-[highlighted]:bg-gray-200 data-[selected]:bg-gray-100 group relative min-h-8 h-auto', @@ -38,45 +11,22 @@ export const SelectListItemUI = uic(Button, { afterChildren: , }) -export const SelectDefaultPlaceholderUI = (): React.ReactElement => {dict.select.placeholder} - -export const MultiSelectItemWrapperUI = uic('div', { - baseClass: 'flex flex-wrap gap-1 items-center justify-start', -}) - -export const MultiSelectItemUI = uic('span', { - baseClass: 'flex items-stretch border border-gray-200 rounded-sm hover:shadow-sm transition-all', -}) - -export const MultiSelectItemContentUI = uic('span', { - baseClass: 'rounded-l px-2 py-1 bg-background', -}) - -export const MultiSelectItemRemoveButtonUI = uic('span', { - baseClass: 'bg-gray-100 border-l border-gray-200 py-1 px-2 rounded-r text-black inline-flex items-center justify-center hover:bg-gray-300', - afterChildren: , - defaultProps: { - tabIndex: 0, - role: 'button', - onKeyDown: (e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - ;(e.currentTarget as HTMLElement).click() - e.preventDefault() - } - }, - }, -}) - -export const SelectPopoverContent = uic(PopoverContent, { - baseClass: 'group w-[max(16rem,var(--radix-popover-trigger-width))]', - defaultProps: { - onWheel: (e: React.WheelEvent) => e.stopPropagation(), - }, -}) - -export const SelectCreateNewTrigger = forwardRef((props, ref) => ( - -)) -SelectCreateNewTrigger.displayName = 'SelectCreateNewTrigger' +// Re-export all split UI components for backward compatibility +export { + SelectInputWrapperUI, + SelectInputUI, + SelectInputActionsUI, + SelectDefaultPlaceholderUI, +} from '#bindx-ui/select/input-ui' + +export { + MultiSelectItemWrapperUI, + MultiSelectItemUI, + MultiSelectItemContentUI, + MultiSelectItemRemoveButtonUI, +} from '#bindx-ui/select/multi-select-ui' + +export { + SelectPopoverContent, + SelectCreateNewTrigger, +} from '#bindx-ui/select/popover-ui' diff --git a/packages/bindx-ui/src/ui/checkbox-input.tsx b/packages/bindx-ui/src/ui/checkbox-input.tsx new file mode 100644 index 0000000..a374616 --- /dev/null +++ b/packages/bindx-ui/src/ui/checkbox-input.tsx @@ -0,0 +1,9 @@ +import { uic } from '../utils/uic.js' + +export const CheckboxInput = uic('input', { + baseClass: 'w-4 h-4', + defaultProps: { + type: 'checkbox', + }, + displayName: 'CheckboxInput', +}) diff --git a/packages/bindx-ui/src/ui/index.ts b/packages/bindx-ui/src/ui/index.ts index 38b2644..ce3d85e 100644 --- a/packages/bindx-ui/src/ui/index.ts +++ b/packages/bindx-ui/src/ui/index.ts @@ -1,3 +1,5 @@ -export { Input, InputLike, InputBare, CheckboxInput, RadioInput, inputConfig } from './input.js' -export { Label } from './label.js' -export { Textarea, TextareaAutosize } from './textarea.js' +export { Input, InputLike, InputBare, inputConfig } from '#bindx-ui/ui/input' +export { CheckboxInput } from '#bindx-ui/ui/checkbox-input' +export { RadioInput } from '#bindx-ui/ui/radio-input' +export { Label } from '#bindx-ui/ui/label' +export { Textarea, TextareaAutosize } from '#bindx-ui/ui/textarea' diff --git a/packages/bindx-ui/src/ui/input.tsx b/packages/bindx-ui/src/ui/input.tsx index ce532e2..d74f634 100644 --- a/packages/bindx-ui/src/ui/input.tsx +++ b/packages/bindx-ui/src/ui/input.tsx @@ -1,5 +1,8 @@ import { uic, type UicConfig } from '../utils/uic.js' +export { CheckboxInput } from '#bindx-ui/ui/checkbox-input' +export { RadioInput } from '#bindx-ui/ui/radio-input' + export const inputConfig: UicConfig<{ inputSize: { default: string; sm: string; lg: string } variant: { default: string; ghost: string } @@ -50,19 +53,3 @@ export const InputBare = uic('input', { baseClass: 'w-full h-full focus-visible:outline-hidden', displayName: 'InputBare', }) - -export const CheckboxInput = uic('input', { - baseClass: 'w-4 h-4', - defaultProps: { - type: 'checkbox', - }, - displayName: 'CheckboxInput', -}) - -export const RadioInput = uic('input', { - baseClass: ` - appearance-none bg-background rounded-full w-4 h-4 ring-1 ring-gray-400 hover:ring-gray-600 grid place-items-center - before:rounded-full before:bg-gray-600 before:w-2 before:h-2 before:ring-2 before:ring-white before:content-[''] before:transform before:transition-all before:scale-0 checked:before:scale-100 - `, - displayName: 'RadioInput', -}) diff --git a/packages/bindx-ui/src/ui/loader.tsx b/packages/bindx-ui/src/ui/loader.tsx index 358ef9b..1fda1c4 100644 --- a/packages/bindx-ui/src/ui/loader.tsx +++ b/packages/bindx-ui/src/ui/loader.tsx @@ -1,6 +1,6 @@ import { Loader2Icon } from 'lucide-react' import { uic } from '../utils/uic.js' -import { Overlay, type OverlayProps } from './overlay.js' +import { Overlay, type OverlayProps } from '#bindx-ui/ui/overlay' export interface LoaderProps extends Omit { size?: 'sm' | 'md' | 'lg' diff --git a/packages/bindx-ui/src/ui/radio-input.tsx b/packages/bindx-ui/src/ui/radio-input.tsx new file mode 100644 index 0000000..e26e910 --- /dev/null +++ b/packages/bindx-ui/src/ui/radio-input.tsx @@ -0,0 +1,9 @@ +import { uic } from '../utils/uic.js' + +export const RadioInput = uic('input', { + baseClass: ` + appearance-none bg-background rounded-full w-4 h-4 ring-1 ring-gray-400 hover:ring-gray-600 grid place-items-center + before:rounded-full before:bg-gray-600 before:w-2 before:h-2 before:ring-2 before:ring-white before:content-[''] before:transform before:transition-all before:scale-0 checked:before:scale-100 + `, + displayName: 'RadioInput', +}) diff --git a/packages/bindx-ui/src/ui/sheet-layout.tsx b/packages/bindx-ui/src/ui/sheet-layout.tsx new file mode 100644 index 0000000..057a3ff --- /dev/null +++ b/packages/bindx-ui/src/ui/sheet-layout.tsx @@ -0,0 +1,68 @@ +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { cn } from '../utils/cn.js' + +const SheetPortal = DialogPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = 'SheetOverlay' + +function SheetHeader({ className, ...props }: React.HTMLAttributes): React.ReactNode { + return ( +
+ ) +} + +function SheetBody({ className, ...props }: React.HTMLAttributes): React.ReactNode { + return
+} + +function SheetFooter({ className, ...props }: React.HTMLAttributes): React.ReactNode { + return
+} + +const SheetTitle = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = 'SheetTitle' + +const SheetDescription = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = 'SheetDescription' + +export { + SheetPortal, + SheetOverlay, + SheetHeader, + SheetBody, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/packages/bindx-ui/src/ui/sheet.tsx b/packages/bindx-ui/src/ui/sheet.tsx index cf713ec..82b0a04 100644 --- a/packages/bindx-ui/src/ui/sheet.tsx +++ b/packages/bindx-ui/src/ui/sheet.tsx @@ -1,27 +1,11 @@ import * as React from 'react' import * as DialogPrimitive from '@radix-ui/react-dialog' -import { XIcon } from 'lucide-react' import { cn } from '../utils/cn.js' +import { SheetPortal, SheetOverlay } from '#bindx-ui/ui/sheet-layout' const Sheet = DialogPrimitive.Root const SheetTrigger = DialogPrimitive.Trigger const SheetClose = DialogPrimitive.Close -const SheetPortal = DialogPrimitive.Portal - -const SheetOverlay = React.forwardRef< - React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -SheetOverlay.displayName = 'SheetOverlay' const SheetContent = React.forwardRef< React.ComponentRef, @@ -47,54 +31,20 @@ const SheetContent = React.forwardRef< )) SheetContent.displayName = 'SheetContent' -function SheetHeader({ className, ...props }: React.HTMLAttributes): React.ReactNode { - return ( -
- ) -} - -function SheetBody({ className, ...props }: React.HTMLAttributes): React.ReactNode { - return
-} - -function SheetFooter({ className, ...props }: React.HTMLAttributes): React.ReactNode { - return
-} - -const SheetTitle = React.forwardRef< - React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -SheetTitle.displayName = 'SheetTitle' - -const SheetDescription = React.forwardRef< - React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -SheetDescription.displayName = 'SheetDescription' - export { Sheet, - SheetPortal, - SheetOverlay, SheetTrigger, SheetClose, SheetContent, +} + +// Re-export layout components for convenience +export { + SheetPortal, + SheetOverlay, SheetHeader, SheetBody, SheetFooter, SheetTitle, SheetDescription, -} +} from '#bindx-ui/ui/sheet-layout' diff --git a/packages/bindx-ui/src/upload/abort-button.tsx b/packages/bindx-ui/src/upload/abort-button.tsx new file mode 100644 index 0000000..011d116 --- /dev/null +++ b/packages/bindx-ui/src/upload/abort-button.tsx @@ -0,0 +1,23 @@ +import { type ReactNode } from 'react' +import { XIcon } from 'lucide-react' +import { useUploaderFileState } from '@contember/bindx-uploader' +import { Button } from '#bindx-ui/ui/button' + +export const AbortButton = (): ReactNode => { + const state = useUploaderFileState() + + const handleAbort = (): void => { + state.file.abortController.abort() + } + + return ( + + ) +} diff --git a/packages/bindx-ui/src/upload/dismiss-button.tsx b/packages/bindx-ui/src/upload/dismiss-button.tsx new file mode 100644 index 0000000..54f88e8 --- /dev/null +++ b/packages/bindx-ui/src/upload/dismiss-button.tsx @@ -0,0 +1,23 @@ +import { type ReactNode } from 'react' +import { XIcon } from 'lucide-react' +import { useUploaderFileState } from '@contember/bindx-uploader' +import { Button } from '#bindx-ui/ui/button' + +export const DismissButton = (): ReactNode => { + const state = useUploaderFileState() + + if (state.state !== 'success' && state.state !== 'error') { + return null + } + + return ( + + ) +} diff --git a/packages/bindx-ui/src/upload/dropzone-ui.tsx b/packages/bindx-ui/src/upload/dropzone-ui.tsx new file mode 100644 index 0000000..75e6011 --- /dev/null +++ b/packages/bindx-ui/src/upload/dropzone-ui.tsx @@ -0,0 +1,35 @@ +import { type ReactNode } from 'react' +import { Loader } from '#bindx-ui/ui/loader' +import { uic } from '../utils/uic.js' + +export const UploaderDropzoneWrapperUI = uic('div', { + baseClass: 'rounded-sm border border-gray-200 p-1 shadow-sm', + displayName: 'UploaderDropzoneWrapperUI', +}) + +export const UploaderDropzoneAreaUI = uic('div', { + baseClass: ` + flex flex-col gap-1 justify-center items-center py-6 border-dashed border-2 border-gray-300 rounded-md relative + transition-colors + hover:border-gray-400 hover:bg-gray-50 hover:cursor-pointer + data-[dropzone-accept]:border-green-500 data-[dropzone-accept]:bg-green-50 + data-[dropzone-reject]:border-red-500 data-[dropzone-reject]:bg-red-50 + `, + variants: { + size: { + square: 'h-40 w-40', + wide: 'w-full', + }, + }, + defaultVariants: { + size: 'wide', + }, + displayName: 'UploaderDropzoneAreaUI', +}) + +export const UploaderInactiveDropzoneUI = ({ children }: { children?: ReactNode }): ReactNode => ( +
+ + {children} +
+) diff --git a/packages/bindx-ui/src/upload/dropzone.tsx b/packages/bindx-ui/src/upload/dropzone.tsx index 06b8a41..701eb8b 100644 --- a/packages/bindx-ui/src/upload/dropzone.tsx +++ b/packages/bindx-ui/src/upload/dropzone.tsx @@ -5,13 +5,13 @@ import { UploaderDropzoneArea, useUploaderStateFiles, } from '@contember/bindx-uploader' -import { Button } from '../ui/button.js' +import { Button } from '#bindx-ui/ui/button' import { dict } from '../dict.js' import { UploaderDropzoneAreaUI, UploaderDropzoneWrapperUI, UploaderInactiveDropzoneUI, -} from './ui.js' +} from '#bindx-ui/upload/ui' export interface UploaderDropzoneProps { inactiveOnUpload?: boolean diff --git a/packages/bindx-ui/src/upload/index.ts b/packages/bindx-ui/src/upload/index.ts index 6d2ccf2..069a2b7 100644 --- a/packages/bindx-ui/src/upload/index.ts +++ b/packages/bindx-ui/src/upload/index.ts @@ -1,8 +1,12 @@ -// UI Primitives +// UI Primitives — dropzone export { UploaderDropzoneWrapperUI, UploaderDropzoneAreaUI, UploaderInactiveDropzoneUI, +} from '#bindx-ui/upload/dropzone-ui' + +// UI Primitives — progress +export { UploaderItemUI, UploaderFileProgressWrapperUI, UploaderFileProgressInfoUI, @@ -12,19 +16,29 @@ export { UploaderFileProgressSuccessUI, UploaderRepeaterItemsWrapperUI, UploaderRepeaterItemUI, -} from './ui.js' +} from '#bindx-ui/upload/progress-ui' // Dropzone -export { UploaderDropzone, type UploaderDropzoneProps } from './dropzone.js' +export { UploaderDropzone, type UploaderDropzoneProps } from '#bindx-ui/upload/dropzone' // Progress export { UploaderFileProgressUI, UploaderProgress, - AbortButton, - DismissButton, type UploaderFileProgressUIProps, -} from './progress.js' +} from '#bindx-ui/upload/progress' + +export { AbortButton } from '#bindx-ui/upload/abort-button' +export { DismissButton } from '#bindx-ui/upload/dismiss-button' + +// Progress states +export { + InitialProgress, + UploadingProgress, + FinalizingProgress, + ErrorProgress, + SuccessProgress, +} from '#bindx-ui/upload/progress-states' // Views -export * from './view/index.js' +export * from '#bindx-ui/upload/view/index' diff --git a/packages/bindx-ui/src/upload/progress-states.tsx b/packages/bindx-ui/src/upload/progress-states.tsx new file mode 100644 index 0000000..3f31a6a --- /dev/null +++ b/packages/bindx-ui/src/upload/progress-states.tsx @@ -0,0 +1,59 @@ +import { type ReactNode } from 'react' +import { CheckIcon } from 'lucide-react' +import { + UploaderError, + type UploaderFileStateInitial, + type UploaderFileStateUploading, + type UploaderFileStateFinalizing, + type UploaderFileStateError, + type UploaderFileStateSuccess, +} from '@contember/bindx-uploader' +import { dict } from '../dict.js' +import { UploaderFileProgressUI } from '#bindx-ui/upload/progress' +import { AbortButton } from '#bindx-ui/upload/abort-button' +import { DismissButton } from '#bindx-ui/upload/dismiss-button' +import { UploaderFileProgressErrorUI, UploaderFileProgressSuccessUI } from '#bindx-ui/upload/ui' + +export const InitialProgress = ({ state }: { state: UploaderFileStateInitial }): ReactNode => ( + } + /> +) + +export const UploadingProgress = ({ state }: { state: UploaderFileStateUploading }): ReactNode => ( + } + /> +) + +export const FinalizingProgress = ({ state }: { state: UploaderFileStateFinalizing }): ReactNode => ( + } + /> +) + +export const ErrorProgress = ({ state }: { state: UploaderFileStateError }): ReactNode => { + const errorMessage = state.error instanceof UploaderError + ? (state.error.options.endUserMessage ?? dict.uploader.uploadErrors[state.error.options.type] ?? dict.uploader.unknownError) + : dict.uploader.unknownError + + return ( + } + info={{errorMessage}} + /> + ) +} + +export const SuccessProgress = ({ state }: { state: UploaderFileStateSuccess }): ReactNode => ( + } + info={ {dict.uploader.done}} + /> +) diff --git a/packages/bindx-ui/src/upload/progress-ui.tsx b/packages/bindx-ui/src/upload/progress-ui.tsx new file mode 100644 index 0000000..7759cd5 --- /dev/null +++ b/packages/bindx-ui/src/upload/progress-ui.tsx @@ -0,0 +1,43 @@ +import { uic } from '../utils/uic.js' + +export const UploaderItemUI = uic('div', { + baseClass: 'rounded-sm border border-gray-200 p-1 shadow-sm bg-gray-100 flex gap-2 relative', + displayName: 'UploaderItemUI', +}) + +export const UploaderFileProgressWrapperUI = uic('div', { + baseClass: 'flex flex-col gap-2 p-3 border rounded-md bg-white', + displayName: 'UploaderFileProgressWrapperUI', +}) + +export const UploaderFileProgressInfoUI = uic('div', { + baseClass: 'flex items-center justify-between gap-4', + displayName: 'UploaderFileProgressInfoUI', +}) + +export const UploaderFileProgressFileNameUI = uic('span', { + baseClass: 'text-sm font-medium truncate', + displayName: 'UploaderFileProgressFileNameUI', +}) + +export const UploaderFileProgressActionsUI = uic('div', { + baseClass: 'flex gap-2 shrink-0', + displayName: 'UploaderFileProgressActionsUI', +}) + +export const UploaderFileProgressErrorUI = uic('div', { + baseClass: 'text-sm text-destructive', + displayName: 'UploaderFileProgressErrorUI', +}) + +export const UploaderFileProgressSuccessUI = uic('div', { + baseClass: 'text-sm text-green-600', + displayName: 'UploaderFileProgressSuccessUI', +}) + +export const UploaderRepeaterItemsWrapperUI = uic('div', { + baseClass: 'flex flex-wrap gap-4', + displayName: 'UploaderRepeaterItemsWrapperUI', +}) + +export const UploaderRepeaterItemUI = UploaderItemUI diff --git a/packages/bindx-ui/src/upload/progress.tsx b/packages/bindx-ui/src/upload/progress.tsx index b97e839..110accc 100644 --- a/packages/bindx-ui/src/upload/progress.tsx +++ b/packages/bindx-ui/src/upload/progress.tsx @@ -1,27 +1,22 @@ import { type ReactNode } from 'react' -import { XIcon, CheckIcon } from 'lucide-react' import { UploaderEachFile, UploaderFileStateSwitch, - useUploaderFileState, - UploaderError, - type UploaderFileStateInitial, - type UploaderFileStateUploading, - type UploaderFileStateFinalizing, - type UploaderFileStateError, - type UploaderFileStateSuccess, } from '@contember/bindx-uploader' -import { Progress } from '../ui/progress.js' -import { Button } from '../ui/button.js' -import { dict } from '../dict.js' +import { Progress } from '#bindx-ui/ui/progress' import { UploaderFileProgressWrapperUI, UploaderFileProgressInfoUI, UploaderFileProgressFileNameUI, UploaderFileProgressActionsUI, - UploaderFileProgressErrorUI, - UploaderFileProgressSuccessUI, -} from './ui.js' +} from '#bindx-ui/upload/ui' +import { + InitialProgress, + UploadingProgress, + FinalizingProgress, + SuccessProgress, + ErrorProgress, +} from '#bindx-ui/upload/progress-states' export interface UploaderFileProgressUIProps { file: File @@ -50,50 +45,6 @@ export const UploaderFileProgressUI = ({ ) -const InitialProgress = ({ state }: { state: UploaderFileStateInitial }): ReactNode => ( - } - /> -) - -const UploadingProgress = ({ state }: { state: UploaderFileStateUploading }): ReactNode => ( - } - /> -) - -const FinalizingProgress = ({ state }: { state: UploaderFileStateFinalizing }): ReactNode => ( - } - /> -) - -const ErrorProgress = ({ state }: { state: UploaderFileStateError }): ReactNode => { - const errorMessage = state.error instanceof UploaderError - ? (state.error.options.endUserMessage ?? dict.uploader.uploadErrors[state.error.options.type] ?? dict.uploader.unknownError) - : dict.uploader.unknownError - - return ( - } - info={{errorMessage}} - /> - ) -} - -const SuccessProgress = ({ state }: { state: UploaderFileStateSuccess }): ReactNode => ( - } - info={ {dict.uploader.done}} - /> -) - export const UploaderProgress = (): ReactNode => ( ( ) -export const AbortButton = (): ReactNode => { - const state = useUploaderFileState() - - const handleAbort = (): void => { - state.file.abortController.abort() - } - - return ( - - ) -} - -export const DismissButton = (): ReactNode => { - const state = useUploaderFileState() - - if (state.state !== 'success' && state.state !== 'error') { - return null - } - - return ( - - ) -} +// Re-export for convenience +export { AbortButton } from '#bindx-ui/upload/abort-button' +export { DismissButton } from '#bindx-ui/upload/dismiss-button' diff --git a/packages/bindx-ui/src/upload/ui.tsx b/packages/bindx-ui/src/upload/ui.tsx index 2b658dd..e9ea635 100644 --- a/packages/bindx-ui/src/upload/ui.tsx +++ b/packages/bindx-ui/src/upload/ui.tsx @@ -1,77 +1,18 @@ -import { type ReactNode } from 'react' -import { Loader } from '../ui/loader.js' -import { uic } from '../utils/uic.js' - -export const UploaderDropzoneWrapperUI = uic('div', { - baseClass: 'rounded-sm border border-gray-200 p-1 shadow-sm', - displayName: 'UploaderDropzoneWrapperUI', -}) - -export const UploaderDropzoneAreaUI = uic('div', { - baseClass: ` - flex flex-col gap-1 justify-center items-center py-6 border-dashed border-2 border-gray-300 rounded-md relative - transition-colors - hover:border-gray-400 hover:bg-gray-50 hover:cursor-pointer - data-[dropzone-accept]:border-green-500 data-[dropzone-accept]:bg-green-50 - data-[dropzone-reject]:border-red-500 data-[dropzone-reject]:bg-red-50 - `, - variants: { - size: { - square: 'h-40 w-40', - wide: 'w-full', - }, - }, - defaultVariants: { - size: 'wide', - }, - displayName: 'UploaderDropzoneAreaUI', -}) - -export const UploaderInactiveDropzoneUI = ({ children }: { children?: ReactNode }): ReactNode => ( -
- - {children} -
-) - -export const UploaderItemUI = uic('div', { - baseClass: 'rounded-sm border border-gray-200 p-1 shadow-sm bg-gray-100 flex gap-2 relative', - displayName: 'UploaderItemUI', -}) - -export const UploaderFileProgressWrapperUI = uic('div', { - baseClass: 'flex flex-col gap-2 p-3 border rounded-md bg-white', - displayName: 'UploaderFileProgressWrapperUI', -}) - -export const UploaderFileProgressInfoUI = uic('div', { - baseClass: 'flex items-center justify-between gap-4', - displayName: 'UploaderFileProgressInfoUI', -}) - -export const UploaderFileProgressFileNameUI = uic('span', { - baseClass: 'text-sm font-medium truncate', - displayName: 'UploaderFileProgressFileNameUI', -}) - -export const UploaderFileProgressActionsUI = uic('div', { - baseClass: 'flex gap-2 shrink-0', - displayName: 'UploaderFileProgressActionsUI', -}) - -export const UploaderFileProgressErrorUI = uic('div', { - baseClass: 'text-sm text-destructive', - displayName: 'UploaderFileProgressErrorUI', -}) - -export const UploaderFileProgressSuccessUI = uic('div', { - baseClass: 'text-sm text-green-600', - displayName: 'UploaderFileProgressSuccessUI', -}) - -export const UploaderRepeaterItemsWrapperUI = uic('div', { - baseClass: 'flex flex-wrap gap-4', - displayName: 'UploaderRepeaterItemsWrapperUI', -}) - -export const UploaderRepeaterItemUI = UploaderItemUI +// Re-export from split files +export { + UploaderDropzoneWrapperUI, + UploaderDropzoneAreaUI, + UploaderInactiveDropzoneUI, +} from '#bindx-ui/upload/dropzone-ui' + +export { + UploaderItemUI, + UploaderFileProgressWrapperUI, + UploaderFileProgressInfoUI, + UploaderFileProgressFileNameUI, + UploaderFileProgressActionsUI, + UploaderFileProgressErrorUI, + UploaderFileProgressSuccessUI, + UploaderRepeaterItemsWrapperUI, + UploaderRepeaterItemUI, +} from '#bindx-ui/upload/progress-ui' diff --git a/packages/bindx-ui/src/upload/view/actions.tsx b/packages/bindx-ui/src/upload/view/actions.tsx index 96b784f..90851ed 100644 --- a/packages/bindx-ui/src/upload/view/actions.tsx +++ b/packages/bindx-ui/src/upload/view/actions.tsx @@ -1,7 +1,7 @@ import { type ReactNode, type MouseEvent } from 'react' import { EditIcon, InfoIcon, TrashIcon } from 'lucide-react' -import { Button } from '../../ui/button.js' -import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover.js' +import { Button } from '#bindx-ui/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '#bindx-ui/ui/popover' export interface FileActionsProps { metadata?: ReactNode diff --git a/packages/bindx-ui/src/upload/view/any.tsx b/packages/bindx-ui/src/upload/view/any.tsx index b7fc23d..4617918 100644 --- a/packages/bindx-ui/src/upload/view/any.tsx +++ b/packages/bindx-ui/src/upload/view/any.tsx @@ -1,7 +1,7 @@ import { type ReactNode } from 'react' import { FileIcon } from 'lucide-react' -import { FileActions, type FileActionsProps } from './actions.js' -import { Metadata, type MetadataProps } from './metadata.js' +import { FileActions, type FileActionsProps } from '#bindx-ui/upload/view/actions' +import { Metadata, type MetadataProps } from '#bindx-ui/upload/view/metadata' export interface UploadedAnyViewProps extends Omit { url: string diff --git a/packages/bindx-ui/src/upload/view/audio-metadata.tsx b/packages/bindx-ui/src/upload/view/audio-metadata.tsx new file mode 100644 index 0000000..1432ff3 --- /dev/null +++ b/packages/bindx-ui/src/upload/view/audio-metadata.tsx @@ -0,0 +1,12 @@ +import { type ReactNode } from 'react' +import { Metadata, DurationMeta, type MetadataProps } from '#bindx-ui/upload/view/metadata-base' + +export interface AudioMetadataProps extends MetadataProps { + duration?: number | null +} + +export const AudioMetadata = ({ duration, ...props }: AudioMetadataProps): ReactNode => ( + + + +) diff --git a/packages/bindx-ui/src/upload/view/audio.tsx b/packages/bindx-ui/src/upload/view/audio.tsx index dfbca53..19bc3fa 100644 --- a/packages/bindx-ui/src/upload/view/audio.tsx +++ b/packages/bindx-ui/src/upload/view/audio.tsx @@ -1,6 +1,6 @@ import { type ReactNode } from 'react' -import { FileActions, type FileActionsProps } from './actions.js' -import { AudioMetadata, type AudioMetadataProps } from './metadata.js' +import { FileActions, type FileActionsProps } from '#bindx-ui/upload/view/actions' +import { AudioMetadata, type AudioMetadataProps } from '#bindx-ui/upload/view/metadata' export interface UploadedAudioViewProps extends Omit { url: string diff --git a/packages/bindx-ui/src/upload/view/image-metadata.tsx b/packages/bindx-ui/src/upload/view/image-metadata.tsx new file mode 100644 index 0000000..cfdb20a --- /dev/null +++ b/packages/bindx-ui/src/upload/view/image-metadata.tsx @@ -0,0 +1,13 @@ +import { type ReactNode } from 'react' +import { Metadata, DimensionsMeta, type MetadataProps } from '#bindx-ui/upload/view/metadata-base' + +export interface ImageMetadataProps extends MetadataProps { + width?: number | null + height?: number | null +} + +export const ImageMetadata = ({ width, height, ...props }: ImageMetadataProps): ReactNode => ( + + + +) diff --git a/packages/bindx-ui/src/upload/view/image.tsx b/packages/bindx-ui/src/upload/view/image.tsx index 1bc2b7b..7534e2e 100644 --- a/packages/bindx-ui/src/upload/view/image.tsx +++ b/packages/bindx-ui/src/upload/view/image.tsx @@ -1,8 +1,8 @@ import { type ReactNode, type MouseEvent } from 'react' import { TrashIcon, InfoIcon } from 'lucide-react' -import { Button } from '../../ui/button.js' -import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover.js' -import { ImageMetadata, type ImageMetadataProps } from './metadata.js' +import { Button } from '#bindx-ui/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '#bindx-ui/ui/popover' +import { ImageMetadata, type ImageMetadataProps } from '#bindx-ui/upload/view/metadata' export interface UploadedImageViewProps { url: string diff --git a/packages/bindx-ui/src/upload/view/index.ts b/packages/bindx-ui/src/upload/view/index.ts index dd48161..c2d112e 100644 --- a/packages/bindx-ui/src/upload/view/index.ts +++ b/packages/bindx-ui/src/upload/view/index.ts @@ -1,8 +1,8 @@ -export { UploadedImageView, type UploadedImageViewProps } from './image.js' -export { UploadedAudioView, type UploadedAudioViewProps } from './audio.js' -export { UploadedVideoView, type UploadedVideoViewProps } from './video.js' -export { UploadedAnyView, type UploadedAnyViewProps } from './any.js' -export { FileActions, type FileActionsProps } from './actions.js' +export { UploadedImageView, type UploadedImageViewProps } from '#bindx-ui/upload/view/image' +export { UploadedAudioView, type UploadedAudioViewProps } from '#bindx-ui/upload/view/audio' +export { UploadedVideoView, type UploadedVideoViewProps } from '#bindx-ui/upload/view/video' +export { UploadedAnyView, type UploadedAnyViewProps } from '#bindx-ui/upload/view/any' +export { FileActions, type FileActionsProps } from '#bindx-ui/upload/view/actions' export { Metadata, ImageMetadata, @@ -16,5 +16,5 @@ export { type VideoMetadataProps, type DimensionsMetaProps, type DurationMetaProps, -} from './metadata.js' -export { formatBytes, formatDuration, formatDate, truncateUrl } from './utils.js' +} from '#bindx-ui/upload/view/metadata' +export { formatBytes, formatDuration, formatDate, truncateUrl } from '#bindx-ui/upload/view/utils' diff --git a/packages/bindx-ui/src/upload/view/metadata-base.tsx b/packages/bindx-ui/src/upload/view/metadata-base.tsx new file mode 100644 index 0000000..ad49610 --- /dev/null +++ b/packages/bindx-ui/src/upload/view/metadata-base.tsx @@ -0,0 +1,96 @@ +import { type ReactNode } from 'react' +import { formatBytes, formatDate, formatDuration, truncateUrl } from '#bindx-ui/upload/view/utils' + +export interface MetadataProps { + url?: string | null + fileSize?: number | null + fileName?: string | null + fileType?: string | null + lastModified?: string | Date | null + children?: ReactNode +} + +export const Metadata = ({ + url, + fileSize, + fileName, + fileType, + lastModified, + children, +}: MetadataProps): ReactNode => ( +
+ {fileSize != null && fileSize > 0 && ( + <> + Size: + {formatBytes(fileSize)} + + )} + {fileType && ( + <> + Type: + {fileType} + + )} + {fileName && ( + <> + File name: + {fileName} + + )} + {children} + {lastModified && ( + <> + Date: + {formatDate(lastModified)} + + )} + {url && ( + <> + URL: + + + {truncateUrl(url)} + + + + )} +
+) + +export interface DimensionsMetaProps { + width?: number | null + height?: number | null +} + +export const DimensionsMeta = ({ width, height }: DimensionsMetaProps): ReactNode => { + if (!width || !height) { + return null + } + return ( + <> + Dimensions: + {width} x {height} px + + ) +} + +export interface DurationMetaProps { + duration?: number | null +} + +export const DurationMeta = ({ duration }: DurationMetaProps): ReactNode => { + if (!duration) { + return null + } + return ( + <> + Duration: + {formatDuration(duration)} + + ) +} diff --git a/packages/bindx-ui/src/upload/view/metadata.tsx b/packages/bindx-ui/src/upload/view/metadata.tsx index 1991150..1c6bcbf 100644 --- a/packages/bindx-ui/src/upload/view/metadata.tsx +++ b/packages/bindx-ui/src/upload/view/metadata.tsx @@ -1,130 +1,13 @@ -import { type ReactNode } from 'react' -import { formatBytes, formatDate, formatDuration, truncateUrl } from './utils.js' - -export interface MetadataProps { - url?: string | null - fileSize?: number | null - fileName?: string | null - fileType?: string | null - lastModified?: string | Date | null - children?: ReactNode -} - -export const Metadata = ({ - url, - fileSize, - fileName, - fileType, - lastModified, - children, -}: MetadataProps): ReactNode => ( -
- {fileSize != null && fileSize > 0 && ( - <> - Size: - {formatBytes(fileSize)} - - )} - {fileType && ( - <> - Type: - {fileType} - - )} - {fileName && ( - <> - File name: - {fileName} - - )} - {children} - {lastModified && ( - <> - Date: - {formatDate(lastModified)} - - )} - {url && ( - <> - URL: - - - {truncateUrl(url)} - - - - )} -
-) - -export interface DimensionsMetaProps { - width?: number | null - height?: number | null -} - -export const DimensionsMeta = ({ width, height }: DimensionsMetaProps): ReactNode => { - if (!width || !height) { - return null - } - return ( - <> - Dimensions: - {width} x {height} px - - ) -} - -export interface DurationMetaProps { - duration?: number | null -} - -export const DurationMeta = ({ duration }: DurationMetaProps): ReactNode => { - if (!duration) { - return null - } - return ( - <> - Duration: - {formatDuration(duration)} - - ) -} - -export interface ImageMetadataProps extends MetadataProps { - width?: number | null - height?: number | null -} - -export const ImageMetadata = ({ width, height, ...props }: ImageMetadataProps): ReactNode => ( - - - -) - -export interface AudioMetadataProps extends MetadataProps { - duration?: number | null -} - -export const AudioMetadata = ({ duration, ...props }: AudioMetadataProps): ReactNode => ( - - - -) - -export interface VideoMetadataProps extends MetadataProps { - width?: number | null - height?: number | null - duration?: number | null -} - -export const VideoMetadata = ({ width, height, duration, ...props }: VideoMetadataProps): ReactNode => ( - - - - -) +// Re-export from split files +export { + Metadata, + DimensionsMeta, + DurationMeta, + type MetadataProps, + type DimensionsMetaProps, + type DurationMetaProps, +} from '#bindx-ui/upload/view/metadata-base' + +export { ImageMetadata, type ImageMetadataProps } from '#bindx-ui/upload/view/image-metadata' +export { AudioMetadata, type AudioMetadataProps } from '#bindx-ui/upload/view/audio-metadata' +export { VideoMetadata, type VideoMetadataProps } from '#bindx-ui/upload/view/video-metadata' diff --git a/packages/bindx-ui/src/upload/view/video-metadata.tsx b/packages/bindx-ui/src/upload/view/video-metadata.tsx new file mode 100644 index 0000000..bb2b81f --- /dev/null +++ b/packages/bindx-ui/src/upload/view/video-metadata.tsx @@ -0,0 +1,15 @@ +import { type ReactNode } from 'react' +import { Metadata, DurationMeta, DimensionsMeta, type MetadataProps } from '#bindx-ui/upload/view/metadata-base' + +export interface VideoMetadataProps extends MetadataProps { + width?: number | null + height?: number | null + duration?: number | null +} + +export const VideoMetadata = ({ width, height, duration, ...props }: VideoMetadataProps): ReactNode => ( + + + + +) diff --git a/packages/bindx-ui/src/upload/view/video.tsx b/packages/bindx-ui/src/upload/view/video.tsx index 97d98af..285103e 100644 --- a/packages/bindx-ui/src/upload/view/video.tsx +++ b/packages/bindx-ui/src/upload/view/video.tsx @@ -1,6 +1,6 @@ import { type ReactNode } from 'react' -import { FileActions, type FileActionsProps } from './actions.js' -import { VideoMetadata, type VideoMetadataProps } from './metadata.js' +import { FileActions, type FileActionsProps } from '#bindx-ui/upload/view/actions' +import { VideoMetadata, type VideoMetadataProps } from '#bindx-ui/upload/view/metadata' export interface UploadedVideoViewProps extends Omit { url: string diff --git a/packages/bindx-ui/src/vite-plugin.ts b/packages/bindx-ui/src/vite-plugin.ts new file mode 100644 index 0000000..6f55e37 --- /dev/null +++ b/packages/bindx-ui/src/vite-plugin.ts @@ -0,0 +1,53 @@ +import { existsSync } from 'node:fs' +import { resolve, join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Plugin, ResolvedConfig } from 'vite' + +const BINDX_UI_PREFIX = '#bindx-ui/' +const EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js'] + +const packageSrcDir = resolve(dirname(fileURLToPath(import.meta.url))) + +export interface BindxUIPluginOptions { + /** Directory where local component overrides live. Default: './src/ui' */ + dir?: string +} + +export function bindxUI(options: BindxUIPluginOptions = {}): Plugin { + let resolvedDir: string + + return { + name: 'bindx-ui-override', + enforce: 'pre', + + configResolved(resolvedConfig: ResolvedConfig): void { + resolvedDir = resolve(resolvedConfig.root, options.dir ?? './src/ui') + }, + + resolveId(source: string): string | null { + if (!source.startsWith(BINDX_UI_PREFIX)) { + return null + } + + const componentPath = source.slice(BINDX_UI_PREFIX.length) + + // Check for local override first + for (const ext of EXTENSIONS) { + const localPath = join(resolvedDir, componentPath + ext) + if (existsSync(localPath)) { + return localPath + } + } + + // Fallback: resolve directly to the source file in the package + for (const ext of EXTENSIONS) { + const packagePath = join(packageSrcDir, componentPath + ext) + if (existsSync(packagePath)) { + return packagePath + } + } + + return null + }, + } +} diff --git a/packages/bindx-ui/tsconfig.json b/packages/bindx-ui/tsconfig.json index 6d72fe1..b7c81d0 100644 --- a/packages/bindx-ui/tsconfig.json +++ b/packages/bindx-ui/tsconfig.json @@ -3,7 +3,10 @@ "compilerOptions": { "outDir": "./dist/types", "rootDir": "./src", - "composite": true + "composite": true, + "paths": { + "#bindx-ui/*": ["./src/*"] + } }, "include": ["src/**/*"], "references": [ diff --git a/packages/example/pages/block-repeater.tsx b/packages/example/pages/block-repeater.tsx index e84701c..32f3c64 100644 --- a/packages/example/pages/block-repeater.tsx +++ b/packages/example/pages/block-repeater.tsx @@ -4,7 +4,7 @@ import { BlockRepeater } from '@contember/bindx-repeater' import { Uploader, createImageFileType } from '@contember/bindx-uploader' import { InputField, - DefaultBlockRepeater, + BlockRepeater as StyledBlockRepeater, UploaderDropzone, UploaderProgress, UploadedImageView, @@ -28,7 +28,7 @@ export function HeadlessBlockRepeaterPage({ id }: { id: string }): ReactNode { notFound={

Article not found

} > {article => ( -
+

Headless BlockRepeater

( -
+
{info.blockType} @@ -57,15 +57,18 @@ export function HeadlessBlockRepeaterPage({ id }: { id: string }): ReactNode { className="text-xs text-gray-400 hover:text-gray-600 disabled:opacity-30" disabled={info.isFirst} onClick={info.moveUp} + data-testid="move-up" >Up
@@ -85,6 +88,7 @@ export function HeadlessBlockRepeaterPage({ id }: { id: string }): ReactNode { key={b.name} className="text-sm px-3 py-1 border rounded hover:bg-gray-50" onClick={() => methods.addItem(b.name)} + data-testid={`add-block-${b.name}`} > + {b.label?.toString() ?? b.name} @@ -100,7 +104,7 @@ export function HeadlessBlockRepeaterPage({ id }: { id: string }): ReactNode { } /** - * Styled DefaultBlockRepeater — inline mode (DnD enabled). + * Styled BlockRepeater — inline mode (DnD enabled). * Uses Entity JSX for data binding. */ export function StyledBlockRepeaterPage({ id }: { id: string }): ReactNode { @@ -114,7 +118,7 @@ export function StyledBlockRepeaterPage({ id }: { id: string }): ReactNode { {article => (

Inline mode (DnD + inline editing)

-

Dual mode (preview + sheet edit)

Click a block to edit in a side sheet. Drag the handle to reorder.

- - {it => ( <> - - - - + + + + {author => author.name.value ?? '\u2014'} - - + + {tag => tag.name.value ?? ''} - + {item => ( @@ -105,7 +105,7 @@ export function DataGridPage(): ReactElement { )} - +
) diff --git a/packages/example/pages/hasmany-datagrid.tsx b/packages/example/pages/hasmany-datagrid.tsx index 8b6bb35..7dfbf58 100644 --- a/packages/example/pages/hasmany-datagrid.tsx +++ b/packages/example/pages/hasmany-datagrid.tsx @@ -1,9 +1,9 @@ import type { ReactElement, ReactNode } from 'react' import { DataGridToolbarContent } from '@contember/bindx-dataview' import { - DefaultHasManyDataGrid, - DataGridTextColumn, - DataGridDateColumn, + HasManyDataGrid, + TextColumn, + DateColumn, DataGridTextFilter, FieldLabelFormatterProvider, } from '@contember/bindx-ui' @@ -26,7 +26,7 @@ function labelFormatter(entityName: string, fieldName: string): ReactNode | null * DataGrid for a has-many relation (Author → Articles). * * Demonstrates: - * - Entity JSX wrapping a DefaultHasManyDataGrid + * - Entity JSX wrapping a HasManyDataGrid * - Nested relation data grid */ export function HasManyDataGridPage({ id }: { id: string }): ReactElement { @@ -38,23 +38,23 @@ export function HasManyDataGridPage({ id }: { id: string }): ReactElement { Author:

- {it => ( <> - - - + + + )} - +
)} diff --git a/packages/example/ui-overrides/ui/button.tsx b/packages/example/ui-overrides/ui/button.tsx new file mode 100644 index 0000000..2a20f2c --- /dev/null +++ b/packages/example/ui-overrides/ui/button.tsx @@ -0,0 +1,32 @@ +import { uic, uiconfig } from '@contember/bindx-ui/utils' + +export const buttonConfig = uiconfig({ + baseClass: 'inline-flex items-center justify-center rounded-md text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 transition-all', + variants: { + variant: { + default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90', + outline: 'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary border border-input text-secondary-foreground shadow-xs hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + xs: 'h-6 rounded-sm px-2 text-xs', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, +}) + +export const Button = uic('button', { + ...buttonConfig, + baseClass: buttonConfig.baseClass + ' border-4 border-red-500', +}) +export const AnchorButton = uic('a', buttonConfig) diff --git a/packages/example/vite.config.ts b/packages/example/vite.config.ts index 18c8257..857f8f3 100644 --- a/packages/example/vite.config.ts +++ b/packages/example/vite.config.ts @@ -2,9 +2,10 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import path from 'path' +import { bindxUI } from '../bindx-ui/src/vite-plugin.js' export default defineConfig({ - plugins: [tailwindcss(), react()], + plugins: [tailwindcss(), react(), bindxUI({ dir: './ui-overrides' })], root: __dirname, server: { port: 15180, diff --git a/tests/browser/blockRepeater.test.ts b/tests/browser/blockRepeater.test.ts new file mode 100644 index 0000000..85b63ad --- /dev/null +++ b/tests/browser/blockRepeater.test.ts @@ -0,0 +1,66 @@ +import { test, expect, describe } from 'bun:test' +import { browserTest, el, tid, waitFor, evalJs } from './browser.js' + +const headless = '[data-testid="headless-block-repeater"]' +const itemSel = `${headless} [data-testid^="block-item-"]` + +function blockCount(): number { + return parseInt(evalJs(`document.querySelectorAll('${itemSel}').length`), 10) || 0 +} + +function firstBlockMoveUpDisabled(): boolean { + return evalJs(`document.querySelector('${itemSel} [data-testid="move-up"]')?.disabled`) === 'true' +} + +browserTest('Block Repeater', () => { + describe('initial state', () => { + test('headless repeater loads', () => { + waitFor(() => el(`${tid('headless-block-repeater')}`).exists, { timeout: 15_000 }) + expect(el(`${tid('headless-block-repeater')}`).exists).toBe(true) + }, 20_000) + + test('add block buttons are visible', () => { + expect(el('add-block-text').exists).toBe(true) + expect(el('add-block-image').exists).toBe(true) + }) + }) + + describe('adding blocks', () => { + test('add text block increases count', () => { + waitFor(() => el('add-block-text').exists) + const initial = blockCount() + el('add-block-text').click() + waitFor(() => blockCount() > initial) + expect(blockCount()).toBe(initial + 1) + }) + + test('add image block increases count', () => { + const initial = blockCount() + el('add-block-image').click() + waitFor(() => blockCount() > initial) + expect(blockCount()).toBe(initial + 1) + }) + }) + + describe('block operations', () => { + test('first block has move-up disabled', () => { + waitFor(() => blockCount() >= 2) + expect(firstBlockMoveUpDisabled()).toBe(true) + }) + + test('remove a block decreases count', () => { + const initial = blockCount() + // Click first remove button via JS to avoid strict-mode multi-match + evalJs(`document.querySelector('${itemSel} [data-testid="remove-block"]').click()`) + waitFor(() => blockCount() === initial - 1) + expect(blockCount()).toBe(initial - 1) + }) + }) + + describe('styled block repeater', () => { + test('styled repeater renders', () => { + expect(el(`${tid('section-block-repeater')}`).text).toContain('Inline mode') + expect(el(`${tid('section-block-repeater')}`).text).toContain('Dual mode') + }) + }) +}, 'block-repeater') diff --git a/tests/browser/browser.ts b/tests/browser/browser.ts index d438abf..193f36d 100644 --- a/tests/browser/browser.ts +++ b/tests/browser/browser.ts @@ -148,6 +148,10 @@ export function browserTest(name: string, fn: () => void, hash?: string): void { }) } +export function evalJs(js: string): string { + return exec(`agent-browser eval ${q(js)}`) +} + export function screenshot(path?: string): string { const target = path ?? `/tmp/browser-test-${Date.now()}.png` exec(`agent-browser screenshot ${target}`) diff --git a/tests/browser/buttonOverride.test.ts b/tests/browser/buttonOverride.test.ts new file mode 100644 index 0000000..927aab8 --- /dev/null +++ b/tests/browser/buttonOverride.test.ts @@ -0,0 +1,17 @@ +import { test, expect } from 'bun:test' +import { browserTest, el, waitFor, evalJs } from './browser.js' + +browserTest('Button Override via Vite Plugin', () => { + test('page loads with article editor', () => { + waitFor(() => el('article-editor').exists) + expect(el('article-save-button').exists).toBe(true) + }) + + test('save button uses overridden Button with border-red-500', () => { + // PersistButton (from @contember/bindx-ui) imports Button via #bindx-ui/ui/button + // The Vite plugin resolves this to our local override with border-red-500 + const className = evalJs(`document.querySelector('[data-testid="article-save-button"]')?.className`) + expect(className).toContain('border-red-500') + expect(className).toContain('border-4') + }) +}, 'article-editor') diff --git a/tests/browser/dataGrid.test.ts b/tests/browser/dataGrid.test.ts index 5eb55d9..192b51c 100644 --- a/tests/browser/dataGrid.test.ts +++ b/tests/browser/dataGrid.test.ts @@ -6,7 +6,7 @@ const scope = tid('datagrid-example') browserTest('DataGrid', () => { describe('styled rendering', () => { test('table structure renders', () => { - waitFor(() => el('datagrid-example').exists) + waitFor(() => el(`${scope} ${tid('datagrid-table')}`).exists) expect(el(`${scope} ${tid('datagrid-table')}`).exists).toBe(true) expect(el(`${scope} ${tid('datagrid-header')}`).exists).toBe(true) expect(el(`${scope} ${tid('datagrid-body')}`).exists).toBe(true) diff --git a/tests/repeater/block-repeater.test.tsx b/tests/repeater/block-repeater.test.tsx index 35a49d2..0249b15 100644 --- a/tests/repeater/block-repeater.test.tsx +++ b/tests/repeater/block-repeater.test.tsx @@ -508,7 +508,7 @@ describe('BlockRepeater', () => { }) test('selection collection includes discrimination field', async () => { - const { BlockRepeaterWithMeta } = await import('@contember/bindx-repeater') + const { BlockRepeater: BlockRepeaterWithMeta } = await import('@contember/bindx-repeater') const { SelectionScope } = await import('@contember/bindx') const { createCollectorProxy } = await import('@contember/bindx-react') @@ -558,4 +558,61 @@ describe('BlockRepeater', () => { const contentField = blocksField!.nested!.fields.get('content') expect(contentField).toBeDefined() }) + + test('selection collection discovers render/form functions on blocks', async () => { + const { BlockRepeater: BR } = await import('@contember/bindx-repeater') + const { SelectionScope } = await import('@contember/bindx') + const { createCollectorProxy, collectSelection } = await import('@contember/bindx-react') + + const scope = new SelectionScope() + const proxy = createCollectorProxy(scope) + + const getSelection = (BR as any).getSelection as ( + props: any, + collectNested: (children: any) => any, + ) => any + + const result = getSelection( + { + field: proxy.blocks, + discriminationField: 'type', + blocks: { + text: { + label: 'Text', + render: (entity: any) => { + void entity.content + return null + }, + }, + image: { + label: 'Image', + render: (entity: any) => { + void entity.imageUrl + return null + }, + form: (entity: any) => { + void entity.imageCaption + return null + }, + }, + }, + // No children — blocks have render/form functions + }, + (jsx: any) => collectSelection(jsx), + ) + + expect(result).toBeNull() + + const selection = scope.toSelectionMeta() + const blocksField = selection.fields.get('blocks') + expect(blocksField).toBeDefined() + + // Fields from render functions should be collected + expect(blocksField!.nested!.fields.has('content')).toBe(true) + expect(blocksField!.nested!.fields.has('imageUrl')).toBe(true) + expect(blocksField!.nested!.fields.has('imageCaption')).toBe(true) + + // Discrimination field should be added + expect(blocksField!.nested!.fields.has('type')).toBe(true) + }) }) diff --git a/tests/unit/bindx-ui/cli-backport.test.ts b/tests/unit/bindx-ui/cli-backport.test.ts new file mode 100644 index 0000000..8316ff3 --- /dev/null +++ b/tests/unit/bindx-ui/cli-backport.test.ts @@ -0,0 +1,142 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { threeWayMerge } from '../../../packages/bindx-ui/src/cli/merge.js' +import { generateAgentPrompt } from '../../../packages/bindx-ui/src/cli/agent-prompt.js' +import { loadMetadata, saveMetadata, type EjectedEntry } from '../../../packages/bindx-ui/src/cli/metadata.js' +import { getGitRef, getGitPath } from '../../../packages/bindx-ui/src/cli/git.js' + +describe('Three-Way Merge', () => { + test('clean merge — upstream adds line, local adds different line', () => { + const base = 'line 1\nline 2\nline 3\n' + const local = 'line 1\nlocal addition\nline 2\nline 3\n' + const upstream = 'line 1\nline 2\nline 3\nupstream addition\n' + + const result = threeWayMerge(local, base, upstream) + expect(result.status).toBe('clean') + expect(result.content).toContain('local addition') + expect(result.content).toContain('upstream addition') + expect(result.conflictCount).toBe(0) + }) + + test('conflict — both modify same line', () => { + const base = 'line 1\noriginal line\nline 3\n' + const local = 'line 1\nlocal change\nline 3\n' + const upstream = 'line 1\nupstream change\nline 3\n' + + const result = threeWayMerge(local, base, upstream) + expect(result.status).toBe('conflict') + expect(result.conflictCount).toBeGreaterThan(0) + expect(result.content).toContain('<<<<<<<') + }) + + test('no changes — all identical', () => { + const content = 'same content\n' + const result = threeWayMerge(content, content, content) + expect(result.status).toBe('clean') + expect(result.content).toBe(content) + }) + + test('only upstream changed', () => { + const base = 'original\n' + const local = 'original\n' + const upstream = 'updated\n' + + const result = threeWayMerge(local, base, upstream) + expect(result.status).toBe('clean') + expect(result.content).toBe('updated\n') + }) + + test('only local changed', () => { + const base = 'original\n' + const local = 'modified locally\n' + const upstream = 'original\n' + + const result = threeWayMerge(local, base, upstream) + expect(result.status).toBe('clean') + expect(result.content).toBe('modified locally\n') + }) +}) + +describe('Agent Prompt', () => { + test('generates prompt with all fields', () => { + const prompt = generateAgentPrompt({ + componentPath: 'form/text-input', + ejectVersion: '0.1.0', + currentVersion: '0.2.0', + localDiff: '-original\n+modified', + upstreamDiff: '-original\n+updated', + localContent: 'const TextInput = () => ', + upstreamContent: 'const TextInput = () => ', + localFilePath: '/project/src/ui/form/text-input.tsx', + }) + + expect(prompt).toContain('form/text-input') + expect(prompt).toContain('@contember/bindx-ui@0.1.0') + expect(prompt).toContain('@0.2.0') + expect(prompt).toContain('-original\n+modified') + expect(prompt).toContain('-original\n+updated') + expect(prompt).toContain('const TextInput') + expect(prompt).toContain('bindx-ui backport --sync form/text-input') + }) +}) + +describe('Git Helpers', () => { + test('getGitRef returns a valid commit hash', () => { + const ref = getGitRef() + expect(ref).toMatch(/^[0-9a-f]{40}$/) + }) + + test('getGitPath resolves package file to git-relative path', () => { + const absolutePath = join(process.cwd(), 'packages/bindx-ui/src/cli/git.ts') + const gitPath = getGitPath(absolutePath) + expect(gitPath).toBe('packages/bindx-ui/src/cli/git.ts') + }) +}) + +describe('Metadata with git fields', () => { + let targetDir: string + + beforeEach(() => { + targetDir = mkdtempSync(join(tmpdir(), 'bindx-meta-test-')) + }) + + afterEach(() => { + rmSync(targetDir, { recursive: true, force: true }) + }) + + test('saves and loads gitRef and gitPath', () => { + const entry: EjectedEntry = { + path: 'ui/button', + version: '0.1.0', + originalHash: 'abc123', + gitRef: 'deadbeef1234567890abcdef1234567890abcdef', + gitPath: 'packages/bindx-ui/src/ui/button.tsx', + } + + saveMetadata(targetDir, { ejected: { 'ui/button': entry } }) + const loaded = loadMetadata(targetDir) + + expect(loaded.ejected['ui/button']?.gitRef).toBe(entry.gitRef) + expect(loaded.ejected['ui/button']?.gitPath).toBe(entry.gitPath) + }) + + test('handles legacy metadata without git fields', () => { + const legacyJson = JSON.stringify({ + ejected: { + 'ui/button': { + path: 'ui/button', + version: '0.1.0', + originalHash: 'abc123', + }, + }, + }) + writeFileSync(join(targetDir, '.bindx-ui.json'), legacyJson, 'utf-8') + + const loaded = loadMetadata(targetDir) + expect(loaded.ejected['ui/button']?.gitRef).toBeUndefined() + expect(loaded.ejected['ui/button']?.gitPath).toBeUndefined() + expect(loaded.ejected['ui/button']?.path).toBe('ui/button') + }) +}) diff --git a/tests/unit/bindx-ui/cli-eject.test.ts b/tests/unit/bindx-ui/cli-eject.test.ts new file mode 100644 index 0000000..ea36cca --- /dev/null +++ b/tests/unit/bindx-ui/cli-eject.test.ts @@ -0,0 +1,70 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { mkdtempSync, existsSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { eject } from '../../../packages/bindx-ui/src/cli/eject.js' +import { restore } from '../../../packages/bindx-ui/src/cli/restore.js' +import { loadMetadata } from '../../../packages/bindx-ui/src/cli/metadata.js' + +describe('CLI Eject/Restore', () => { + let targetDir: string + + beforeEach(() => { + targetDir = mkdtempSync(join(tmpdir(), 'bindx-ui-test-')) + }) + + afterEach(() => { + rmSync(targetDir, { recursive: true, force: true }) + }) + + test('ejects a component to target directory', () => { + eject('ui/button', targetDir) + + const ejectedPath = join(targetDir, 'ui/button.tsx') + expect(existsSync(ejectedPath)).toBe(true) + + const content = readFileSync(ejectedPath, 'utf-8') + expect(content).toContain('// Ejected from @contember/bindx-ui@') + expect(content).toContain('ui/button') + }) + + test('creates metadata after eject', () => { + eject('ui/button', targetDir) + + const metadata = loadMetadata(targetDir) + expect(metadata.ejected['ui/button']).toBeDefined() + expect(metadata.ejected['ui/button']?.path).toBe('ui/button') + expect(metadata.ejected['ui/button']?.version).toBeDefined() + expect(metadata.ejected['ui/button']?.originalHash).toBeDefined() + }) + + test('skips already ejected component', () => { + eject('ui/button', targetDir) + const firstContent = readFileSync(join(targetDir, 'ui/button.tsx'), 'utf-8') + + // Second eject should skip + eject('ui/button', targetDir) + const secondContent = readFileSync(join(targetDir, 'ui/button.tsx'), 'utf-8') + expect(secondContent).toBe(firstContent) + }) + + test('ejects folder glob', () => { + eject('ui/*', targetDir) + + // Should have ejected multiple ui components + const metadata = loadMetadata(targetDir) + const uiComponents = Object.keys(metadata.ejected).filter(k => k.startsWith('ui/')) + expect(uiComponents.length).toBeGreaterThan(1) + }) + + test('restore removes ejected component', () => { + eject('ui/button', targetDir) + expect(existsSync(join(targetDir, 'ui/button.tsx'))).toBe(true) + + restore('ui/button', targetDir) + expect(existsSync(join(targetDir, 'ui/button.tsx'))).toBe(false) + + const metadata = loadMetadata(targetDir) + expect(metadata.ejected['ui/button']).toBeUndefined() + }) +}) diff --git a/tests/unit/bindx-ui/cli-registry.test.ts b/tests/unit/bindx-ui/cli-registry.test.ts new file mode 100644 index 0000000..eda5dee --- /dev/null +++ b/tests/unit/bindx-ui/cli-registry.test.ts @@ -0,0 +1,59 @@ +import { describe, test, expect } from 'bun:test' +import { discoverComponents } from '../../../packages/bindx-ui/src/cli/registry.js' + +describe('CLI Registry', () => { + test('discovers components from all component folders', () => { + const components = discoverComponents() + + expect(components.length).toBeGreaterThan(0) + + // Verify components come from expected folders + const folders = new Set(components.map(c => c.path.split('/')[0])) + expect(folders.has('ui')).toBe(true) + expect(folders.has('form')).toBe(true) + expect(folders.has('datagrid')).toBe(true) + expect(folders.has('select')).toBe(true) + + // Should NOT include cli, utils, defaults + expect(folders.has('cli')).toBe(false) + expect(folders.has('utils')).toBe(false) + expect(folders.has('defaults')).toBe(false) + }) + + test('does not include index files', () => { + const components = discoverComponents() + const indexComponents = components.filter(c => c.path.endsWith('/index')) + expect(indexComponents).toHaveLength(0) + }) + + test('component paths match expected format', () => { + const components = discoverComponents() + + for (const component of components) { + // Path should be like 'ui/button', 'form/container', 'datagrid/filters/text' + expect(component.path).toMatch(/^[a-z]+\/[a-z]/) + // No file extensions in path + expect(component.path).not.toMatch(/\.(tsx?|jsx?)$/) + // Source path should be absolute and exist + expect(component.sourcePath).toMatch(/^\//) + } + }) + + test('includes specific known components', () => { + const components = discoverComponents() + const paths = components.map(c => c.path) + + expect(paths).toContain('ui/button') + expect(paths).toContain('form/container') + expect(paths).toContain('form/input-field') + expect(paths).toContain('datagrid/columns/text-column') + expect(paths).toContain('select/select-field') + }) + + test('components are sorted alphabetically', () => { + const components = discoverComponents() + const paths = components.map(c => c.path) + const sorted = [...paths].sort() + expect(paths).toEqual(sorted) + }) +}) diff --git a/tests/unit/bindx-ui/defaults.test.tsx b/tests/unit/bindx-ui/defaults.test.tsx new file mode 100644 index 0000000..a83854c --- /dev/null +++ b/tests/unit/bindx-ui/defaults.test.tsx @@ -0,0 +1,76 @@ +import { describe, test, expect } from 'bun:test' +import { renderHook } from '@testing-library/react' +import { type ReactNode } from 'react' +import { + BindxUIDefaultsProvider, + useComponentDefaults, +} from '../../../packages/bindx-ui/src/defaults/BindxUIDefaults.js' + +describe('BindxUIDefaults', () => { + test('returns empty object when no provider', () => { + const { result } = renderHook(() => useComponentDefaults('Button')) + expect(result.current).toEqual({}) + }) + + test('returns defaults from provider', () => { + const wrapper = ({ children }: { children: ReactNode }): ReactNode => ( + + {children} + + ) + + const { result } = renderHook(() => useComponentDefaults<{ size: string; variant: string }>('Button'), { + wrapper, + }) + + expect(result.current).toEqual({ size: 'sm', variant: 'outline' }) + }) + + test('returns empty object for unregistered component', () => { + const wrapper = ({ children }: { children: ReactNode }): ReactNode => ( + + {children} + + ) + + const { result } = renderHook(() => useComponentDefaults('Input'), { wrapper }) + expect(result.current).toEqual({}) + }) + + test('nested providers merge defaults', () => { + const wrapper = ({ children }: { children: ReactNode }): ReactNode => ( + + + {children} + + + ) + + const { result: buttonResult } = renderHook( + () => useComponentDefaults<{ size: string; variant: string }>('Button'), + { wrapper }, + ) + // Inner provider merges with outer: size from outer + variant from inner + expect(buttonResult.current).toEqual({ size: 'sm', variant: 'outline' }) + + const { result: inputResult } = renderHook( + () => useComponentDefaults<{ type: string }>('Input'), + { wrapper }, + ) + // Input only defined in outer, should propagate through + expect(inputResult.current).toEqual({ type: 'text' }) + }) + + test('nested provider overrides parent for same key', () => { + const wrapper = ({ children }: { children: ReactNode }): ReactNode => ( + + + {children} + + + ) + + const { result } = renderHook(() => useComponentDefaults<{ size: string }>('Button'), { wrapper }) + expect(result.current).toEqual({ size: 'sm' }) + }) +}) diff --git a/tests/unit/bindx-ui/vite-plugin.test.ts b/tests/unit/bindx-ui/vite-plugin.test.ts new file mode 100644 index 0000000..e5300e9 --- /dev/null +++ b/tests/unit/bindx-ui/vite-plugin.test.ts @@ -0,0 +1,78 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { bindxUI } from '../../../packages/bindx-ui/src/vite-plugin.js' +import type { Plugin, ResolvedConfig } from 'vite' + +describe('Vite Plugin', () => { + let targetDir: string + let plugin: Plugin + + beforeEach(() => { + targetDir = mkdtempSync(join(tmpdir(), 'bindx-ui-vite-test-')) + plugin = bindxUI({ dir: targetDir }) + + // Simulate Vite's configResolved hook + const configResolved = plugin.configResolved as (config: ResolvedConfig) => void + configResolved({ root: '/' } as ResolvedConfig) + }) + + afterEach(() => { + rmSync(targetDir, { recursive: true, force: true }) + }) + + test('ignores non-#bindx-ui imports', () => { + const resolveId = plugin.resolveId as (source: string) => string | null + expect(resolveId('react')).toBeNull() + expect(resolveId('@contember/bindx')).toBeNull() + expect(resolveId('./relative/path')).toBeNull() + }) + + test('resolves to local file when override exists', () => { + // Create a local override + mkdirSync(join(targetDir, 'ui'), { recursive: true }) + writeFileSync(join(targetDir, 'ui/button.tsx'), 'export const Button = () => null') + + const resolveId = plugin.resolveId as (source: string) => string | null + const result = resolveId('#bindx-ui/ui/button') + + expect(result).toBe(join(targetDir, 'ui/button.tsx')) + }) + + test('resolves to package fallback when no local override', () => { + const resolveId = plugin.resolveId as (source: string) => string | null + const result = resolveId('#bindx-ui/ui/button') + + // Plugin resolves directly to package source file + expect(result).toMatch(/packages\/bindx-ui\/src\/ui\/button\.tsx$/) + }) + + test('checks multiple extensions', () => { + mkdirSync(join(targetDir, 'form'), { recursive: true }) + writeFileSync(join(targetDir, 'form/container.ts'), 'export {}') + + const resolveId = plugin.resolveId as (source: string) => string | null + const result = resolveId('#bindx-ui/form/container') + + expect(result).toBe(join(targetDir, 'form/container.ts')) + }) + + test('handles nested paths like datagrid/filters/text', () => { + mkdirSync(join(targetDir, 'datagrid/filters'), { recursive: true }) + writeFileSync(join(targetDir, 'datagrid/filters/text.tsx'), 'export {}') + + const resolveId = plugin.resolveId as (source: string) => string | null + const result = resolveId('#bindx-ui/datagrid/filters/text') + + expect(result).toBe(join(targetDir, 'datagrid/filters/text.tsx')) + }) + + test('plugin has enforce: pre', () => { + expect(plugin.enforce).toBe('pre') + }) + + test('plugin name is bindx-ui-override', () => { + expect(plugin.name).toBe('bindx-ui-override') + }) +})