{"_id":"@astrapi69/entity-kit","_rev":"6-8b54c1ec5bbe26504c02f08fe6bba868","name":"@astrapi69/entity-kit","dist-tags":{"latest":"0.3.1"},"versions":{"0.1.0":{"name":"@astrapi69/entity-kit","version":"0.1.0","keywords":["react","headless","beaninfo","entity","descriptor","tanstack-table","typescript","components"],"author":{"name":"Asterios Raptis"},"license":"MIT","_id":"@astrapi69/entity-kit@0.1.0","maintainers":[{"name":"astrapi69","email":"asterios.raptis@gmx.net"}],"dist":{"shasum":"eceb6f55e441f210c808f484298528fb043d5c2f","tarball":"https://registry.npmjs.org/@astrapi69/entity-kit/-/entity-kit-0.1.0.tgz","fileCount":15,"integrity":"sha512-9ca0MbbD6nfgmXy2oqkVX7YuBxXgpFD5mNvQMt4wt1oKaMfuCcbOrZTCNjUAxLVAn6IhtY2h5j3/bK6GmeDQ5A==","signatures":[{"sig":"MEQCIEZpQMtAGfZ/jwo/XQMysk97YlDUKkizV69q1HTpVxQAAiAHWPK+cNj6Qg/FYLgKpalRXQz0bDbDpw1Zx+4XXdVSQw==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":243535},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./styles":"./styles/defaults.css","./styles/*":"./styles/*"},"gitHead":"15ea886cd20946530110d071f9db77eca30171b6","scripts":{"dev":"tsup --watch","lint":"eslint src/","test":"vitest run","build":"tsup","lint:fix":"eslint src/ --fix","typecheck":"tsc --noEmit","test:watch":"vitest","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build"},"_npmUser":{"name":"astrapi69","email":"asterios.raptis@gmx.net"},"_npmVersion":"11.12.1","description":"Headless, type-safe React component library implementing the BeanInfo pattern. Any object type describes itself via an EntityDescriptor and generic components render it.","directories":{},"sideEffects":["**/*.css"],"_nodeVersion":"24.15.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.3.5","jsdom":"^25.0.1","react":"^18.3.1","eslint":"^9.39.4","vitest":"^2.1.8","react-dom":"^18.3.1","@eslint/js":"^9.39.4","typescript":"^5.6.3","@types/react":"^18.3.12","@types/react-dom":"^18.3.1","typescript-eslint":"^8.60.1","@vitest/coverage-v8":"^2.1.9","@tanstack/react-table":"^8.20.5","@testing-library/react":"^16.0.1","@testing-library/jest-dom":"^6.5.0","eslint-plugin-react-hooks":"^5.2.0"},"peerDependencies":{"react":"^18","react-dom":"^18","@tanstack/react-table":"^8"},"_npmOperationalInternal":{"tmp":"tmp/entity-kit_0.1.0_1780859147188_0.07384261072808496","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@astrapi69/entity-kit","version":"0.1.1","keywords":["react","headless","beaninfo","entity","descriptor","tanstack-table","typescript","components"],"author":{"name":"Asterios Raptis"},"license":"MIT","_id":"@astrapi69/entity-kit@0.1.1","maintainers":[{"name":"astrapi69","email":"asterios.raptis@gmx.net"}],"dist":{"shasum":"47ae474e306bd1e79e7a663ec5fc99cf8bd74bbe","tarball":"https://registry.npmjs.org/@astrapi69/entity-kit/-/entity-kit-0.1.1.tgz","fileCount":15,"integrity":"sha512-8Jc1uNy7LUrNz0kbHGc8mFPB79eqV90sRDil2gCEQoM2iazcZ/LIXUvV17TiIofX7TI5qNzvaovx0qV9O+Vucg==","signatures":[{"sig":"MEQCIBS8EtKK3SOtGC66gIzcX7a6uzEEZvQBVyjwl9oTz2WJAiBilPrGFARjP1NuW3h8TAcp5hJSgwUjBlTpVaCgX/350g==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":254878},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./styles":"./styles/defaults.css","./styles/*":"./styles/*"},"gitHead":"8abd7d04f78f018fce02a5508534eea78fc3fc80","scripts":{"dev":"tsup --watch","lint":"eslint src/","test":"vitest run","build":"tsup","lint:fix":"eslint src/ --fix","typecheck":"tsc --noEmit","test:watch":"vitest","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build"},"_npmUser":{"name":"astrapi69","email":"asterios.raptis@gmx.net"},"_npmVersion":"11.12.1","description":"Headless, type-safe React component library implementing the BeanInfo pattern. Any object type describes itself via an EntityDescriptor and generic components render it.","directories":{},"sideEffects":["**/*.css"],"_nodeVersion":"24.15.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.1","jsdom":"^29.1.1","react":"^19.2.7","eslint":"^10.4.1","vitest":"^4.1.8","react-dom":"^19.2.7","@eslint/js":"^10.0.1","typescript":"^6.0.3","@types/react":"^19.2.17","@types/react-dom":"^19.2.3","typescript-eslint":"^8.60.1","@vitest/coverage-v8":"^4.1.8","@tanstack/react-table":"^8.21.3","@testing-library/react":"^16.3.2","@testing-library/jest-dom":"^6.9.1","eslint-plugin-react-hooks":"^7.1.1"},"peerDependencies":{"react":"^18 || ^19","react-dom":"^18 || ^19","@tanstack/react-table":"^8"},"_npmOperationalInternal":{"tmp":"tmp/entity-kit_0.1.1_1780908592893_0.09280992958644907","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@astrapi69/entity-kit","version":"0.2.0","keywords":["react","headless","beaninfo","entity","descriptor","tanstack-table","typescript","components"],"author":{"name":"Asterios Raptis"},"license":"MIT","_id":"@astrapi69/entity-kit@0.2.0","maintainers":[{"name":"astrapi69","email":"asterios.raptis@gmx.net"}],"dist":{"shasum":"9a484e09090ae580e1e0204c1f59e16926bcc39d","tarball":"https://registry.npmjs.org/@astrapi69/entity-kit/-/entity-kit-0.2.0.tgz","fileCount":15,"integrity":"sha512-Gvkfm0GObebqItEw4eYhhRBhRE7h3JZwMpAD3PXpJ1UeEh9PbUIPqNqB4ngViWq8Ig/KStnxqV/7V9uV5hyT2w==","signatures":[{"sig":"MEQCIFyFS2bk1cxOVOyD62bdMUrUwidDl43o76B3HkuNx7s/AiAUEPXesW2Kc798liNhV7XpB2JCjAYGV4USrFaO+xfr4w==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":264508},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./styles":"./styles/defaults.css","./styles/*":"./styles/*","./package.json":"./package.json"},"gitHead":"1369712db5117dd454b5b61a5ab2046611865a18","scripts":{"dev":"tsup --watch","lint":"eslint src/","test":"vitest run","build":"tsup","lint:fix":"eslint src/ --fix","typecheck":"tsc --noEmit","test:watch":"vitest","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build"},"_npmUser":{"name":"astrapi69","email":"asterios.raptis@gmx.net"},"_npmVersion":"11.12.1","description":"Headless, type-safe React component library implementing the BeanInfo pattern. Any object type describes itself via an EntityDescriptor and generic components render it.","directories":{},"sideEffects":["**/*.css"],"_nodeVersion":"24.15.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.1","jsdom":"^29.1.1","react":"^19.2.7","eslint":"^10.4.1","vitest":"^4.1.8","react-dom":"^19.2.7","@eslint/js":"^10.0.1","typescript":"^6.0.3","@types/react":"^19.2.17","@types/react-dom":"^19.2.3","typescript-eslint":"^8.60.1","@vitest/coverage-v8":"^4.1.8","@tanstack/react-table":"^8.21.3","@testing-library/react":"^16.3.2","@testing-library/jest-dom":"^6.9.1","eslint-plugin-react-hooks":"^7.1.1"},"peerDependencies":{"react":"^18 || ^19","react-dom":"^18 || ^19","@tanstack/react-table":"^8"},"_npmOperationalInternal":{"tmp":"tmp/entity-kit_0.2.0_1780910346117_0.9216220736267462","host":"s3://npm-registry-packages-npm-production"}},"0.2.1":{"name":"@astrapi69/entity-kit","version":"0.2.1","keywords":["react","headless","beaninfo","entity","descriptor","tanstack-table","typescript","components"],"author":{"name":"Asterios Raptis"},"license":"MIT","_id":"@astrapi69/entity-kit@0.2.1","maintainers":[{"name":"astrapi69","email":"asterios.raptis@gmx.net"}],"dist":{"shasum":"10bbc0505c3cc0628b07e7db35b81051b6ffbfa6","tarball":"https://registry.npmjs.org/@astrapi69/entity-kit/-/entity-kit-0.2.1.tgz","fileCount":15,"integrity":"sha512-DOn7KP+68ZH4++r2A4930WmwwU0uT7oTn5TZZBT33/tTBSFgIOh/i5HYH5r3QEHUPdW/j/1h1UmfxSkpajrUXw==","signatures":[{"sig":"MEYCIQDIohDNlYCjYViFdHnYu24AP9jN7sIBwu1mvBTNOydEOAIhAIILgxVvc0sXKoJD3xap0WLjibJJvXCmkHhONxz3zzbI","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":268642},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./styles":"./styles/defaults.css","./styles/*":"./styles/*","./package.json":"./package.json"},"gitHead":"8ffe9ecdb6e2a666982a551bede058a00ddfde2b","scripts":{"dev":"tsup --watch","lint":"eslint src/","test":"vitest run","build":"tsup","lint:fix":"eslint src/ --fix","typecheck":"tsc --noEmit","test:watch":"vitest","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build"},"_npmUser":{"name":"astrapi69","email":"asterios.raptis@gmx.net"},"_npmVersion":"11.12.1","description":"Headless, type-safe React component library implementing the BeanInfo pattern. Any object type describes itself via an EntityDescriptor and generic components render it.","directories":{},"sideEffects":["**/*.css"],"_nodeVersion":"24.15.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.1","jsdom":"^29.1.1","react":"^19.2.7","eslint":"^10.4.1","vitest":"^4.1.8","react-dom":"^19.2.7","@eslint/js":"^10.0.1","typescript":"^6.0.3","@types/react":"^19.2.17","@types/react-dom":"^19.2.3","typescript-eslint":"^8.60.1","@vitest/coverage-v8":"^4.1.8","@tanstack/react-table":"^8.21.3","@testing-library/react":"^16.3.2","@testing-library/jest-dom":"^6.9.1","eslint-plugin-react-hooks":"^7.1.1"},"peerDependencies":{"react":"^18 || ^19","react-dom":"^18 || ^19","@tanstack/react-table":"^8"},"_npmOperationalInternal":{"tmp":"tmp/entity-kit_0.2.1_1780910783500_0.452327094669309","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@astrapi69/entity-kit","version":"0.3.0","keywords":["react","headless","beaninfo","entity","descriptor","tanstack-table","typescript","components"],"author":{"name":"Asterios Raptis"},"license":"MIT","_id":"@astrapi69/entity-kit@0.3.0","maintainers":[{"name":"astrapi69","email":"asterios.raptis@gmx.net"}],"dist":{"shasum":"30b335713f2dc5b75d5d50f3eed6b762ea9af740","tarball":"https://registry.npmjs.org/@astrapi69/entity-kit/-/entity-kit-0.3.0.tgz","fileCount":15,"integrity":"sha512-c57EhBfWY5YnRmZVUmxE0GWG7QcPowf4aPWzzmwIjhl9u36a0mC24uWvbsmIJ0EH7PZBYdxs4jdu/ZIerpY5JA==","signatures":[{"sig":"MEYCIQCZkfQSYIpfbvh6/br7Jw3RlAlf7RmEVYshUf11gB3OPQIhALQluZ+hkAbsqy0YlchH3NEehd7KbQyNr23sGFSeZXtV","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":224110},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./styles":"./styles/defaults.css","./styles/*":"./styles/*","./package.json":"./package.json"},"gitHead":"4bb3a84a73049d629eb0ecf93fef6983990e149f","scripts":{"dev":"tsup --watch","lint":"eslint src/","test":"vitest run","build":"tsup","lint:fix":"eslint src/ --fix","typecheck":"tsc --noEmit","test:watch":"vitest","test:coverage":"vitest run --coverage","prepublishOnly":"npm run build"},"_npmUser":{"name":"astrapi69","email":"asterios.raptis@gmx.net"},"_npmVersion":"11.12.1","description":"Headless, type-safe React component library implementing the BeanInfo pattern. Any object type describes itself via an EntityDescriptor and generic components render it.","directories":{},"sideEffects":["**/*.css"],"_nodeVersion":"24.15.0","dependencies":{"@astrapi69/entity-kit-core":"^0.1.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.1","jsdom":"^29.1.1","react":"^19.2.7","eslint":"^10.4.1","vitest":"^4.1.8","react-dom":"^19.2.7","@eslint/js":"^10.0.1","typescript":"^6.0.3","@types/react":"^19.2.17","@types/react-dom":"^19.2.3","typescript-eslint":"^8.60.1","@vitest/coverage-v8":"^4.1.8","@tanstack/react-table":"^8.21.3","@testing-library/react":"^16.3.2","@testing-library/jest-dom":"^6.9.1","eslint-plugin-react-hooks":"^7.1.1"},"peerDependencies":{"react":"^18 || ^19","react-dom":"^18 || ^19","@tanstack/react-table":"^8"},"_npmOperationalInternal":{"tmp":"tmp/entity-kit_0.3.0_1781004324070_0.49836163809251","host":"s3://npm-registry-packages-npm-production"}},"0.3.1":{"name":"@astrapi69/entity-kit","version":"0.3.1","description":"Headless, type-safe React component library implementing the BeanInfo pattern. Any object type describes itself via an EntityDescriptor and generic components render it.","license":"MIT","author":{"name":"Asterios Raptis"},"type":"module","sideEffects":["**/*.css"],"main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./styles":"./styles/defaults.css","./styles/*":"./styles/*","./package.json":"./package.json"},"scripts":{"build":"tsup","dev":"tsup --watch","test":"vitest run","test:watch":"vitest","test:coverage":"vitest run --coverage","typecheck":"tsc --noEmit","lint":"eslint src/","lint:fix":"eslint src/ --fix","prepublishOnly":"npm run build"},"keywords":["react","headless","beaninfo","entity","descriptor","tanstack-table","typescript","components"],"peerDependencies":{"@tanstack/react-table":"^8","react":"^18 || ^19","react-dom":"^18 || ^19"},"devDependencies":{"@eslint/js":"^10.0.1","@tanstack/react-table":"^8.21.3","@testing-library/jest-dom":"^6.9.1","@testing-library/react":"^16.3.2","@types/react":"^19.2.17","@types/react-dom":"^19.2.3","@vitest/coverage-v8":"^4.1.8","eslint":"^10.4.1","eslint-plugin-react-hooks":"^7.1.1","jsdom":"^29.1.1","react":"^19.2.7","react-dom":"^19.2.7","tsup":"^8.5.1","typescript":"^6.0.3","typescript-eslint":"^8.60.1","vitest":"^4.1.8"},"publishConfig":{"access":"public"},"dependencies":{"@astrapi69/entity-kit-core":"^0.1.0"},"gitHead":"6cc66a38cd4b6de5afa3505577449f3c40f401ad","_id":"@astrapi69/entity-kit@0.3.1","_nodeVersion":"24.15.0","_npmVersion":"11.12.1","dist":{"integrity":"sha512-qGN0K7at6c/G4pdUEnNrCUhDrCeQeNT0qEZLI25d45cJMl4iEnZ2nNvavBWj8OiR0ysjigMaCgRHWDqXYuK/SA==","shasum":"743222899c4d2d3fa6205043d5bb07a4befef180","tarball":"https://registry.npmjs.org/@astrapi69/entity-kit/-/entity-kit-0.3.1.tgz","fileCount":15,"unpackedSize":225948,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIG8W6Z7yYTaZqJ+Ft1eYxbhsIefKTyRexP0CQuLHWel+AiAnAH5XZC+tG52sjLkz4127EYh6BCcBJlsB2vQ2ZzjWgQ=="}]},"_npmUser":{"name":"astrapi69","email":"asterios.raptis@gmx.net"},"directories":{},"maintainers":[{"name":"astrapi69","email":"asterios.raptis@gmx.net"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/entity-kit_0.3.1_1781004923709_0.8766820520451364"},"_hasShrinkwrap":false}},"time":{"created":"2026-06-07T19:05:47.007Z","modified":"2026-06-09T11:35:24.034Z","0.1.0":"2026-06-07T19:05:47.343Z","0.1.1":"2026-06-08T08:49:53.022Z","0.2.0":"2026-06-08T09:19:06.293Z","0.2.1":"2026-06-08T09:26:23.639Z","0.3.0":"2026-06-09T11:25:24.252Z","0.3.1":"2026-06-09T11:35:23.938Z"},"author":{"name":"Asterios Raptis"},"license":"MIT","keywords":["react","headless","beaninfo","entity","descriptor","tanstack-table","typescript","components"],"description":"Headless, type-safe React component library implementing the BeanInfo pattern. Any object type describes itself via an EntityDescriptor and generic components render it.","maintainers":[{"name":"astrapi69","email":"asterios.raptis@gmx.net"}],"readme":"# @astrapi69/entity-kit\n\n> Headless, type-safe React components built on the **BeanInfo pattern** — works\n> with **any** styling approach.\n\nAny object type — a book, a profile, a comment, an article — describes itself\nonce via an **`EntityDescriptor`**, and generic components (list, tile grid,\ndetail view, trash, search) render it **without ever knowing the concrete\ntype**. Styling is fully delegated to your app: use the optional CSS custom\nproperties, **or** pass a `classNames` prop to wire up Tailwind, CSS Modules or\nany CSS framework.\n\n- 🧬 **BeanInfo for React** — one descriptor drives every view of an entity.\n- 🪶 **Headless-first** — components render fine with **no stylesheet at all**.\n- 🎨 **Any styling approach** — CSS variables, Tailwind, CSS Modules or CSS-in-JS\n  via the `classNames` prop. Zero coupling to any of them.\n- 🔒 **Type-safe** — strict TypeScript, generic over your entity type `T`. Every\n  `classNames` interface is exported for full autocomplete.\n- 📊 **TanStack Table** under the hood for sorting, filtering and pagination.\n- 🚫 **No business logic, no fetching.** It renders what the descriptor says.\n\n## The BeanInfo pattern\n\nIn Java's BeanInfo, a class ships metadata describing its own properties so\ngeneric tooling can introspect and render it. `entity-kit` brings the same idea\nto React: instead of writing a bespoke `<BookList>`, `<BookCard>` and\n`<BookDetail>`, you write **one** `EntityDescriptor<Book>` and reuse the generic\nviews. Add a `Profile`? Write `EntityDescriptor<Profile>` — the views are\nalready done.\n\n## Install\n\n```bash\nnpm install @astrapi69/entity-kit\n```\n\nPeer dependencies (you provide these):\n\n```bash\nnpm install react react-dom @tanstack/react-table\n```\n\n| Peer                    | Version |\n| ----------------------- | ------- |\n| `react`                 | `^18 \\|\\| ^19` |\n| `react-dom`             | `^18 \\|\\| ^19` |\n| `@tanstack/react-table` | `^8`           |\n\n### Packages: `entity-kit` and `entity-kit-core`\n\nAll types, utilities (search, sort, `resolveLabel`, `generateTestId`, …), the\ndescriptor registry and the CSS design tokens live in\n[`@astrapi69/entity-kit-core`](https://www.npmjs.com/package/@astrapi69/entity-kit-core)\n— a **framework-agnostic** package with zero dependencies. `@astrapi69/entity-kit`\ndepends on it and adds the React components.\n\n- You don't install core yourself — it comes in automatically as a dependency.\n- `@astrapi69/entity-kit` **re-exports everything from core**, so existing\n  imports are unchanged: `import { EntityDescriptor, descriptorRegistry } from\n  \"@astrapi69/entity-kit\"` keeps working. You can equally import those from\n  `@astrapi69/entity-kit-core` directly — both resolve to the same definitions.\n  (In `@astrapi69/entity-kit` the descriptor types are pre-bound to React's\n  `ReactNode` for `icon`/`render`/`thumbnail`; from core you supply the node\n  type yourself, e.g. `EntityDescriptor<Book, ReactNode>`.)\n- `@astrapi69/entity-kit/styles` re-exports the core stylesheet, so theming is\n  unchanged. Apps building a **Vue / Svelte / Angular** binding can depend on\n  `@astrapi69/entity-kit-core` alone for the same descriptors, utilities and\n  tokens — without pulling in React.\n\n## Quick example\n\n**1. Describe your entity once:**\n\n```tsx\nimport type { EntityDescriptor } from \"@astrapi69/entity-kit\";\n\ninterface Book {\n  id: string;\n  title: string;\n  author: string;\n  year: number;\n  deleted: boolean;\n  deletedOn?: string;\n}\n\nconst bookDescriptor: EntityDescriptor<Book> = {\n  entityName: \"book\",\n  getId: (b) => b.id,\n  displayName: (b) => b.title,\n  shortDescription: (b) => `by ${b.author} (${b.year})`,\n  icon: <BookIcon />,\n  thumbnail: (b) => <img alt={b.title} src={`/covers/${b.id}.jpg`} />,\n  listFields: [\n    { key: \"title\", label: \"Title\", sortable: true },\n    { key: \"author\", label: \"Author\", sortable: true },\n    { key: \"year\", label: \"Year\", sortable: true },\n  ],\n  detailFields: [\n    { key: \"title\", label: \"Title\" },\n    { key: \"author\", label: \"Author\" },\n    { key: \"year\", label: \"Year\", render: (b) => <strong>{b.year}</strong> },\n  ],\n  searchableFields: [\"title\", \"author\"],\n  isDeleted: (b) => b.deleted,\n  deletedAt: (b) => b.deletedOn ?? null,\n  actions: [\n    { id: \"edit\", label: \"Edit\" },\n    { id: \"delete\", label: \"Delete\", variant: \"danger\", isAvailable: (b) => !b.deleted },\n  ],\n};\n```\n\n**2. Render it with any generic view:**\n\n```tsx\nimport { EntityTileView } from \"@astrapi69/entity-kit\";\nimport \"@astrapi69/entity-kit/styles\"; // optional default theme\n\nfunction Library({ books }: { books: Book[] }) {\n  return (\n    <EntityTileView\n      items={books}\n      descriptor={bookDescriptor}\n      onAction={(actionId, book) => {\n        // The library reports the action; you act on it.\n        if (actionId === \"edit\") openEditor(book);\n        if (actionId === \"delete\") softDelete(book);\n      }}\n    />\n  );\n}\n```\n\nSwap `EntityTileView` for `EntityListView`, `EntityDetailView` or\n`EntityTrashView` — same descriptor, different presentation.\n\n### Switching views\n\n```tsx\nimport { useViewMode, EntityViewSwitcher, EntityListView, EntityTileView } from \"@astrapi69/entity-kit\";\n\nfunction Browser({ books }: { books: Book[] }) {\n  const { mode, setMode } = useViewMode(\"tile\");\n  return (\n    <>\n      <EntityViewSwitcher mode={mode} onChange={setMode} />\n      {mode === \"list\" && <EntityListView items={books} descriptor={bookDescriptor} />}\n      {mode === \"tile\" && <EntityTileView items={books} descriptor={bookDescriptor} />}\n    </>\n  );\n}\n```\n\n## Internationalization (i18n)\n\nEvery `label` — on a `FieldDescriptor` or an `ActionDescriptor` — accepts either\na plain string **or** a factory `() => string`. The factory is resolved at\nrender time, so labels follow the active locale and re-render when it changes.\nWire it to whatever i18n library you use:\n\n```tsx\nimport { useTranslation } from \"react-i18next\";\nimport type { EntityDescriptor } from \"@astrapi69/entity-kit\";\n\n// `t` from i18next, `$t` from vue-i18n-style setups, etc. — any () => string.\nfunction makeBookDescriptor(t: (key: string) => string): EntityDescriptor<Book> {\n  return {\n    entityName: \"book\",\n    getId: (b) => b.id,\n    displayName: (b) => b.title,\n    shortDescription: (b) => b.author,\n    icon: <BookIcon />,\n    listFields: [\n      { key: \"title\", label: () => t(\"book.title\"), sortable: true },\n      { key: \"author\", label: () => t(\"book.author\") },\n    ],\n    detailFields: [{ key: \"title\", label: () => t(\"book.title\") }],\n    searchableFields: [\"title\", \"author\"],\n    isDeleted: (b) => b.deleted,\n    actions: [\n      { id: \"edit\", label: () => t(\"actions.edit\") },\n      { id: \"delete\", label: () => t(\"actions.delete\"), variant: \"danger\" },\n    ],\n  };\n}\n\nfunction Library({ books }: { books: Book[] }) {\n  const { t } = useTranslation();\n  // Rebuild when the language changes so the label factories re-resolve.\n  const descriptor = useMemo(() => makeBookDescriptor(t), [t]);\n  return <EntityListView items={books} descriptor={descriptor} />;\n}\n```\n\nPlain strings still work everywhere — use the factory form only for labels you\ntranslate.\n\n## Styling guide\n\nThis is the heart of the library: **it works with any styling approach without\ncoupling to any of them.** Every component renders semantic class names by\ndefault (`entity-tile`, `entity-tile__title`, …) and **also** accepts an\noptional `classNames` prop. Each `classNames` slot, when provided, **replaces**\nthe default class for that slot:\n\n```tsx\n<element className={classNames?.title ?? \"entity-tile__title\"} />\n```\n\nPick whichever of the four approaches below matches your app.\n\n---\n\n### 1. CSS Variables (default, zero-config)\n\nThe simplest path. Import the stylesheet, **do not** pass `classNames`, and\noverride design tokens in your own global CSS.\n\n**Import:**\n\n```tsx\nimport \"@astrapi69/entity-kit/styles\";          // all components + dark mode\n// …or cherry-pick:\nimport \"@astrapi69/entity-kit/styles/entity-tile.css\";\nimport \"@astrapi69/entity-kit/styles/entity-list.css\";\n```\n\n**`classNames` prop:** none — the components use their semantic defaults.\n\n**Override tokens** (`--entity-{component}-{property}`) anywhere in your CSS:\n\n```css\n:root {\n  /* Global */\n  --entity-font-family: \"Inter\", sans-serif;\n  --entity-radius: 0.75rem;\n\n  /* Tile */\n  --entity-tile-bg: #fffdf7;\n  --entity-tile-min-width: 260px;\n  --entity-tile-title-color: #2a1a00;\n\n  /* Actions */\n  --entity-actions-danger-bg: #e11d48;\n}\n```\n\n```tsx\nfunction Library({ books }: { books: Book[] }) {\n  return <EntityTileView items={books} descriptor={bookDescriptor} onAction={onAction} />;\n}\n```\n\n**Dark mode** ships with the default theme. Activate it by setting\n`data-theme=\"dark\"` on any ancestor (or `<html>`):\n\n```html\n<html data-theme=\"dark\">\n```\n\n```css\n/* The bundled defaults.css already remaps every token under: */\n[data-theme=\"dark\"] {\n  --entity-tile-bg: #1c1c1e;\n  --entity-tile-title-color: #f0f0f0;\n  /* …and so on. Override these to customise your dark palette. */\n}\n```\n\n<details>\n<summary><strong>All available CSS variables, grouped by component</strong></summary>\n\n```css\n/* Global tokens (shared) */\n--entity-font-family\n--entity-radius\n--entity-disabled-opacity\n\n/* List (--entity-list-*) */\n--entity-list-color            --entity-list-bg               --entity-list-font-size\n--entity-list-head-bg          --entity-list-header-align     --entity-list-header-weight\n--entity-list-header-color     --entity-list-border           --entity-list-row-border\n--entity-list-row-hover-bg     --entity-list-cell-padding     --entity-list-cell-valign\n--entity-list-actions-width    --entity-list-actions-align    --entity-list-sort-gap\n--entity-list-sort-indicator-color\n--entity-list-pagination-justify --entity-list-pagination-gap --entity-list-pagination-padding\n--entity-list-page-button-padding --entity-list-page-button-bg --entity-list-page-button-color\n--entity-list-page-button-border  --entity-list-page-status-color --entity-list-page-status-size\n\n/* Tile (--entity-tile-*) */\n--entity-tile-min-width        --entity-tile-gap              --entity-tile-card-gap\n--entity-tile-bg               --entity-tile-color            --entity-tile-border\n--entity-tile-radius           --entity-tile-padding          --entity-tile-shadow\n--entity-tile-body-gap         --entity-tile-thumb-ratio      --entity-tile-thumb-bg\n--entity-tile-thumb-radius     --entity-tile-thumb-fit        --entity-tile-icon-size\n--entity-tile-title-size       --entity-tile-title-weight     --entity-tile-title-color\n--entity-tile-subtitle-size    --entity-tile-subtitle-color\n\n/* Detail (--entity-detail-*) */\n--entity-detail-bg             --entity-detail-color          --entity-detail-border\n--entity-detail-radius         --entity-detail-padding        --entity-detail-header-gap\n--entity-detail-header-padding --entity-detail-header-border  --entity-detail-header-margin\n--entity-detail-icon-size      --entity-detail-title-size     --entity-detail-title-weight\n--entity-detail-subtitle-size  --entity-detail-subtitle-color --entity-detail-fields-columns\n--entity-detail-fields-gap     --entity-detail-label-weight   --entity-detail-label-color\n--entity-detail-value-color    --entity-detail-footer-margin  --entity-detail-footer-padding\n--entity-detail-footer-border\n\n/* Actions (--entity-actions-*) */\n--entity-actions-gap           --entity-actions-icon-gap      --entity-actions-padding\n--entity-actions-font-size     --entity-actions-color         --entity-actions-bg\n--entity-actions-border        --entity-actions-hover-bg      --entity-actions-danger-color\n--entity-actions-danger-bg     --entity-actions-danger-border --entity-actions-danger-hover-bg\n\n/* Trash (--entity-trash-*) */\n--entity-trash-bg              --entity-trash-head-bg         --entity-trash-color\n--entity-trash-cell-color\n\n/* Search (--entity-search-*) */\n--entity-search-gap            --entity-search-padding        --entity-search-color\n--entity-search-bg             --entity-search-border         --entity-search-placeholder-color\n--entity-search-icon-color     --entity-search-clear-size     --entity-search-clear-color\n--entity-search-clear-bg       --entity-search-clear-hover-bg\n\n/* View switcher (--entity-switcher-*) */\n--entity-switcher-gap          --entity-switcher-padding      --entity-switcher-bg\n--entity-switcher-button-padding --entity-switcher-font-size  --entity-switcher-color\n--entity-switcher-button-bg    --entity-switcher-active-color --entity-switcher-active-bg\n--entity-switcher-active-shadow --entity-switcher-icon-gap\n\n/* Empty state (--entity-empty-*) */\n--entity-empty-gap             --entity-empty-padding         --entity-empty-color\n--entity-empty-icon-size       --entity-empty-icon-opacity    --entity-empty-title-size\n--entity-empty-title-weight    --entity-empty-title-color     --entity-empty-description-size\n```\n\n</details>\n\n**Caveats:** because tokens cascade, set them on `:root` (or a scoping\ncontainer) — not on the component element, which the library controls.\n\n---\n\n### 2. Tailwind CSS\n\n**Do _not_ import `defaults.css`.** Pass Tailwind utilities through the\n`classNames` prop. The default semantic classes are dropped for every slot you\noverride, so there is nothing to fight with.\n\n**`classNames` prop:** an object of Tailwind utility strings, one per slot.\n\n**`EntityTileView` — responsive grid, hover states, dark mode via `dark:`:**\n\n```tsx\nimport { EntityTileView } from \"@astrapi69/entity-kit\";\n// No defaults.css import.\n\n<EntityTileView\n  items={books}\n  descriptor={bookDescriptor}\n  onAction={onAction}\n  classNames={{\n    grid: \"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4\",\n    tile: \"flex flex-col gap-2 rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition hover:shadow-md dark:border-gray-700 dark:bg-gray-800\",\n    body: \"flex flex-col gap-2\",\n    thumbnail: \"aspect-video overflow-hidden rounded bg-gray-100 dark:bg-gray-700\",\n    title: \"text-base font-semibold text-gray-900 dark:text-gray-50\",\n    subtitle: \"text-sm text-gray-500 dark:text-gray-400\",\n    actions: \"mt-auto flex flex-wrap gap-2\",\n    actionButton: \"rounded px-2.5 py-1 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600\",\n    dangerActionButton: \"rounded px-2.5 py-1 text-sm bg-rose-600 text-white hover:bg-rose-700\",\n  }}\n/>\n```\n\n**`EntityListView` — table styling:**\n\n```tsx\n<EntityListView\n  items={books}\n  descriptor={bookDescriptor}\n  onAction={onAction}\n  classNames={{\n    root: \"w-full\",\n    table: \"w-full border-collapse text-sm\",\n    head: \"bg-gray-50 dark:bg-gray-800\",\n    header: \"px-3 py-2 text-left font-semibold text-gray-600 dark:text-gray-300\",\n    sortButton: \"inline-flex items-center gap-1 hover:text-gray-900 dark:hover:text-white\",\n    row: \"border-b border-gray-100 hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-gray-800/50\",\n    cell: \"px-3 py-2 align-middle\",\n    actionsCell: \"px-3 py-2 text-right whitespace-nowrap\",\n    actionButton: \"rounded px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 dark:bg-gray-700\",\n    pagination: \"flex items-center justify-end gap-3 px-3 py-2\",\n    pageButton: \"rounded border border-gray-300 px-2.5 py-1 disabled:opacity-50 dark:border-gray-600\",\n    pageStatus: \"text-sm text-gray-500\",\n  }}\n/>\n```\n\n**`EntityTrashView` — danger-coloured restore / delete:** trash forwards its\n`list` slots to the inner list view. Restore is a default-variant button;\npermanent delete is a `danger` variant, so it picks up `dangerActionButton`.\n\n```tsx\n<EntityTrashView\n  items={books}\n  descriptor={bookDescriptor}\n  onAction={onAction}\n  classNames={{\n    container: \"rounded-lg border border-gray-200 dark:border-gray-700\",\n    list: {\n      table: \"w-full border-collapse text-sm\",\n      cell: \"px-3 py-2 text-gray-500 dark:text-gray-400\",\n      actionsCell: \"px-3 py-2 text-right\",\n      // Restore (default variant):\n      actionButton: \"rounded px-2 py-1 text-xs bg-emerald-600 text-white hover:bg-emerald-700\",\n      // Permanent delete (danger variant):\n      dangerActionButton: \"rounded px-2 py-1 text-xs bg-rose-600 text-white hover:bg-rose-700\",\n    },\n  }}\n/>\n```\n\n**Caveats:**\n\n- Don't import `defaults.css` — you don't need it and the cascade could clash.\n- For dark mode, use Tailwind's `dark:` variant in your slot strings (above) —\n  the `[data-theme=\"dark\"]` tokens only apply to the bundled stylesheet.\n- Tailwind only generates classes it can see; keep these strings in files\n  covered by your `content` globs (not behind runtime string concatenation).\n\n---\n\n### 3. CSS Modules\n\n**Do _not_ import `defaults.css`.** Create a `.module.css` file, import it, and\npass the scoped class names through `classNames`.\n\n**`classNames` prop:** values come from your imported `styles` object.\n\n**`Library.module.css`:**\n\n```css\n.grid {\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));\n  gap: 1rem;\n}\n.tile {\n  display: flex;\n  flex-direction: column;\n  gap: 0.5rem;\n  border: 1px solid #e5e7eb;\n  border-radius: 0.5rem;\n  padding: 1rem;\n  background: #fff;\n}\n.title {\n  margin: 0;\n  font-size: 1rem;\n  font-weight: 600;\n}\n.subtitle {\n  margin: 0;\n  font-size: 0.875rem;\n  color: #6b7280;\n}\n\n/* Dark mode: target the global theme attribute from inside a module with :global */\n:global([data-theme=\"dark\"]) .tile {\n  background: #1f2937;\n  border-color: #374151;\n}\n```\n\n**`Library.tsx`:**\n\n```tsx\nimport { EntityTileView } from \"@astrapi69/entity-kit\";\nimport styles from \"./Library.module.css\";\n\nfunction Library({ books }: { books: Book[] }) {\n  return (\n    <EntityTileView\n      items={books}\n      descriptor={bookDescriptor}\n      onAction={onAction}\n      classNames={{\n        grid: styles.grid,\n        tile: styles.tile,\n        title: styles.title,\n        subtitle: styles.subtitle,\n      }}\n    />\n  );\n}\n```\n\n**Caveats:** module class names are hashed at build time — always reference them\nthrough the imported object (`styles.grid`), never as string literals. Use\n`:global([data-theme=\"dark\"])` to react to a global dark-mode attribute from\nwithin a scoped module.\n\n---\n\n### 4. Styled-components / Emotion (CSS-in-JS)\n\n> ⚠️ **Least recommended.** CSS-in-JS adds runtime style generation on every\n> render. Prefer one of the zero-runtime approaches above unless you already\n> have a CSS-in-JS codebase. Because the components take **class name strings**,\n> the cleanest integration is to read the generated class off a styled element.\n\n**Do _not_ import `defaults.css`.** Create styled wrappers and pass their\ngenerated class names (styled-components/Emotion expose them via the\n`.toString()` / `css` APIs):\n\n```tsx\nimport styled from \"styled-components\";\nimport { EntityTileView } from \"@astrapi69/entity-kit\";\n\nconst Grid = styled.ul`\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));\n  gap: 1rem;\n`;\nconst Tile = styled.li`\n  border: 1px solid ${(p) => p.theme.border};\n  border-radius: 0.5rem;\n  padding: 1rem;\n  background: ${(p) => p.theme.surface};\n`;\n\nfunction Library({ books }: { books: Book[] }) {\n  return (\n    <EntityTileView\n      items={books}\n      descriptor={bookDescriptor}\n      onAction={onAction}\n      // styled() components stringify to their generated class name:\n      classNames={{ grid: `${Grid}`, tile: `${Tile}` }}\n    />\n  );\n}\n```\n\nWith **Emotion** you can use the `css` helper and pass the resulting class:\n\n```tsx\nimport { css } from \"@emotion/css\";\n\nconst grid = css`display: grid; gap: 1rem;`;\n<EntityTileView /* … */ classNames={{ grid }} />;\n```\n\n**Dark mode** flows through your `ThemeProvider` — read `props.theme` inside the\nstyled definitions as usual.\n\n**Caveats:** the styled component itself is not rendered (only its class name is\nused), so component-level props/variants on the wrapper won't apply — keep the\nstyling purely declarative. Expect runtime overhead versus the other options.\n\n---\n\n### Comparison\n\n| Approach      | Import `defaults.css`? | `classNames` prop? | Dark mode               | Runtime cost | Recommended for                |\n| ------------- | ---------------------- | ------------------ | ----------------------- | ------------ | ------------------------------ |\n| CSS Variables | **Yes**                | No                 | `[data-theme]`          | Zero         | Apps without a CSS framework   |\n| Tailwind      | No                     | **Yes**            | `dark:` prefix          | Zero         | Tailwind projects              |\n| CSS Modules   | No                     | **Yes**            | `:global([data-theme])` | Zero         | Scoped styling                 |\n| CSS-in-JS     | No                     | **Yes**            | `ThemeProvider`         | Runtime      | Existing CSS-in-JS codebases   |\n\n## Testing guide\n\nThe descriptor pattern makes testing unusually clean: the object-to-UI mapping\nis just pure functions, and the components are generic, so each layer is tested\nin isolation. There are three levels — pick the one that matches what you own.\n\n---\n\n### 1. Library-level (inside entity-kit itself)\n\nFor contributors. The generic components are tested against a **mock\ndescriptor** with minimal fields — no real entity type required. (entity-kit\nships 85 such tests; this documents the pattern.)\n\nA minimal mock descriptor is enough to exercise any component:\n\n```tsx\nimport type { EntityDescriptor } from \"@astrapi69/entity-kit\";\n\ninterface Widget {\n  id: string;\n  name: string;\n  archived: boolean;\n}\n\nconst widgets: Widget[] = [\n  { id: \"1\", name: \"Alpha\", archived: false },\n  { id: \"2\", name: \"Beta\", archived: false },\n];\n\nconst widgetDescriptor: EntityDescriptor<Widget> = {\n  entityName: \"widget\",\n  getId: (w) => w.id,\n  displayName: (w) => w.name,\n  shortDescription: () => \"\",\n  icon: \"🔧\",\n  listFields: [{ key: \"name\", label: \"Name\" }],\n  detailFields: [{ key: \"name\", label: \"Name\" }],\n  searchableFields: [\"name\"],\n  isDeleted: (w) => w.archived,\n  actions: [{ id: \"edit\", label: \"Edit\" }],\n};\n```\n\n```tsx\nimport { fireEvent, render, screen } from \"@testing-library/react\";\nimport { describe, expect, it, vi } from \"vitest\";\nimport { EntityTileView } from \"@astrapi69/entity-kit\";\n\ndescribe(\"EntityTileView\", () => {\n  it(\"applies the semantic default classes\", () => {\n    const { container } = render(\n      <EntityTileView items={widgets} descriptor={widgetDescriptor} />,\n    );\n    expect(container.querySelector(\".entity-tile-grid\")).toBeInTheDocument();\n    expect(container.querySelector(\".entity-tile__title\")).toBeInTheDocument();\n  });\n\n  it(\"lets custom classNames override the defaults\", () => {\n    const { container } = render(\n      <EntityTileView\n        items={widgets}\n        descriptor={widgetDescriptor}\n        classNames={{ grid: \"my-grid\", title: \"my-title\" }}\n      />,\n    );\n    expect(container.querySelector(\".my-grid\")).toBeInTheDocument();\n    // The default it replaced must be gone:\n    expect(container.querySelector(\".entity-tile-grid\")).not.toBeInTheDocument();\n  });\n\n  it(\"fires onAction with the action id and item\", () => {\n    const onAction = vi.fn();\n    render(\n      <EntityTileView\n        items={widgets}\n        descriptor={widgetDescriptor}\n        onAction={onAction}\n      />,\n    );\n    fireEvent.click(screen.getAllByText(\"Edit\")[0]);\n    expect(onAction).toHaveBeenCalledWith(\"edit\", widgets[0]);\n  });\n\n  it(\"renders the empty state for an empty items array\", () => {\n    render(<EntityTileView items={[]} descriptor={widgetDescriptor} />);\n    expect(screen.getByText(\"No items\")).toBeInTheDocument();\n  });\n});\n```\n\n**Gotcha:** when asserting that a `classNames` slot dropped its default, query\nthe exact default class (e.g. `.entity-tile-grid`) — overriding one slot does\nnot change the others, so the un-overridden defaults are still present.\n\n---\n\n### 2. Descriptor-level (inside the consuming app)\n\nThe highest-value tests you'll write. An `EntityDescriptor` is **pure\nfunctions** — no React, no DOM, no rendering. Test the object-to-UI mapping\ndirectly; these run instantly.\n\n```ts\n// descriptors/bookDescriptor.ts\nimport type { EntityDescriptor } from \"@astrapi69/entity-kit\";\n\nexport interface Book {\n  id: string;\n  title: string;\n  author: string;\n  year: number;\n  deleted: boolean;\n  deletedOn?: string;\n}\n\nexport const bookDescriptor: EntityDescriptor<Book> = {\n  entityName: \"book\",\n  getId: (b) => b.id,\n  displayName: (b) => b.title,\n  shortDescription: (b) => `by ${b.author} (${b.year})`,\n  icon: \"📚\",\n  listFields: [\n    { key: \"title\", label: \"Title\", sortable: true },\n    { key: \"author\", label: \"Author\", sortable: true },\n    { key: \"year\", label: \"Year\", sortable: true },\n  ],\n  detailFields: [\n    { key: \"title\", label: \"Title\" },\n    { key: \"author\", label: \"Author\" },\n    { key: \"year\", label: \"Year\" },\n  ],\n  searchableFields: [\"title\", \"author\"],\n  isDeleted: (b) => b.deleted,\n  deletedAt: (b) => b.deletedOn ?? null,\n  actions: [\n    { id: \"edit\", label: \"Edit\" },\n    { id: \"delete\", label: \"Delete\", variant: \"danger\", isAvailable: (b) => !b.deleted },\n    { id: \"restore\", label: \"Restore\", isAvailable: (b) => b.deleted },\n  ],\n};\n```\n\n```ts\n// descriptors/bookDescriptor.test.ts  — no React, no DOM\nimport { describe, expect, it } from \"vitest\";\nimport { bookDescriptor, type Book } from \"./bookDescriptor\";\n\nconst active: Book = {\n  id: \"1\", title: \"Dune\", author: \"Herbert\", year: 1965, deleted: false,\n};\nconst trashed: Book = {\n  id: \"2\", title: \"Old Draft\", author: \"Herbert\", year: 1960,\n  deleted: true, deletedOn: \"2026-01-15T10:00:00.000Z\",\n};\n\ndescribe(\"bookDescriptor\", () => {\n  it(\"maps an item to its display strings\", () => {\n    expect(bookDescriptor.getId(active)).toBe(\"1\");\n    expect(bookDescriptor.displayName(active)).toBe(\"Dune\");\n    expect(bookDescriptor.shortDescription(active)).toBe(\"by Herbert (1965)\");\n  });\n\n  it(\"detects soft-deleted items\", () => {\n    expect(bookDescriptor.isDeleted(active)).toBe(false);\n    expect(bookDescriptor.isDeleted(trashed)).toBe(true);\n    expect(bookDescriptor.deletedAt?.(trashed)).toBe(\"2026-01-15T10:00:00.000Z\");\n    expect(bookDescriptor.deletedAt?.(active)).toBeNull();\n  });\n\n  it(\"filters actions by availability\", () => {\n    const availableIds = (item: Book) =>\n      bookDescriptor.actions\n        .filter((a) => a.isAvailable?.(item) ?? true)\n        .map((a) => a.id);\n\n    expect(availableIds(active)).toEqual([\"edit\", \"delete\"]);\n    expect(availableIds(trashed)).toEqual([\"edit\", \"restore\"]);\n  });\n\n  it(\"declares complete list and detail fields\", () => {\n    expect(bookDescriptor.listFields.map((f) => f.key)).toEqual([\n      \"title\", \"author\", \"year\",\n    ]);\n    expect(bookDescriptor.detailFields.map((f) => f.key)).toContain(\"year\");\n    expect(bookDescriptor.searchableFields).toEqual([\"title\", \"author\"]);\n  });\n});\n```\n\n**Why this matters:** a bug in `displayName`, `getId`, `isDeleted` or an\naction's `isAvailable` is a bug in *every* view at once. Catching it here — with\nzero rendering — is the cheapest test you can write, so write one for every new\ndescriptor.\n\n---\n\n### 3. Integration-level (inside the consuming app)\n\nTests a page/view that composes entity-kit with a descriptor and your app\nlogic. Render with mock data, assert items appear, and verify that `onAction`\ntriggers the right behaviour (navigation, API calls). **Mock the API client** so\nthe view is tested in isolation from the network.\n\n```tsx\n// Dashboard.tsx\nimport { useEffect, useState } from \"react\";\nimport { useNavigate } from \"react-router-dom\";\nimport { EntityTileView } from \"@astrapi69/entity-kit\";\nimport { bookApi } from \"./api/bookApi\";\nimport { bookDescriptor, type Book } from \"./descriptors/bookDescriptor\";\n\nexport function Dashboard() {\n  const [books, setBooks] = useState<Book[]>([]);\n  const navigate = useNavigate();\n\n  useEffect(() => {\n    bookApi.list().then(setBooks);\n  }, []);\n\n  return (\n    <EntityTileView\n      items={books}\n      descriptor={bookDescriptor}\n      onAction={(action, book) => {\n        if (action === \"edit\") navigate(`/books/${book.id}/edit`);\n        if (action === \"delete\") {\n          bookApi.remove(book.id).then(() =>\n            setBooks((prev) => prev.filter((b) => b.id !== book.id)),\n          );\n        }\n      }}\n    />\n  );\n}\n```\n\n```tsx\n// Dashboard.test.tsx\nimport { fireEvent, render, screen, waitFor } from \"@testing-library/react\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\nimport { Dashboard } from \"./Dashboard\";\nimport { bookApi } from \"./api/bookApi\";\n\n// Mock the API client — the view is isolated from the network.\nvi.mock(\"./api/bookApi\", () => ({\n  bookApi: { list: vi.fn(), remove: vi.fn() },\n}));\n\n// Mock router navigation so we can assert on it.\nconst navigate = vi.fn();\nvi.mock(\"react-router-dom\", () => ({ useNavigate: () => navigate }));\n\nconst books = [\n  { id: \"1\", title: \"Dune\", author: \"Herbert\", year: 1965, deleted: false },\n  { id: \"2\", title: \"Neuromancer\", author: \"Gibson\", year: 1984, deleted: false },\n];\n\nbeforeEach(() => {\n  vi.mocked(bookApi.list).mockResolvedValue(books);\n  vi.mocked(bookApi.remove).mockResolvedValue(undefined);\n  navigate.mockClear();\n});\n\ndescribe(\"Dashboard\", () => {\n  it(\"renders the fetched books as tiles\", async () => {\n    render(<Dashboard />);\n    // Prefer the stable data-testid (entityName-getId) over label text:\n    expect(await screen.findByTestId(\"book-1\")).toBeInTheDocument();\n    expect(screen.getByTestId(\"book-2\")).toBeInTheDocument();\n  });\n\n  it(\"navigates to the editor when Edit is activated\", async () => {\n    render(<Dashboard />);\n    await screen.findByTestId(\"book-1\");\n    fireEvent.click(screen.getByTestId(\"book-1-edit\"));\n    expect(navigate).toHaveBeenCalledWith(\"/books/1/edit\");\n  });\n\n  it(\"calls the API and drops the tile when Delete is activated\", async () => {\n    render(<Dashboard />);\n    await screen.findByTestId(\"book-1\");\n    fireEvent.click(screen.getByTestId(\"book-1-delete\"));\n    expect(bookApi.remove).toHaveBeenCalledWith(\"1\");\n    await waitFor(() =>\n      expect(screen.queryByTestId(\"book-1\")).not.toBeInTheDocument(),\n    );\n  });\n});\n```\n\nEvery row/tile, action button, search input and view toggle carries a stable\n`data-testid` derived from the descriptor (scheme in\n[Test ids](#test-ids-data-testid)). Selecting by it keeps tests resilient to\nlabel/i18n/styling changes, and the **same ids power Playwright E2E**:\n\n```ts\n// books.e2e.ts (Playwright)\nawait page.getByTestId(\"book-search\").fill(\"dune\");\nawait page.getByTestId(\"book-1\").click();\nawait page.getByTestId(\"book-1-delete\").click();\nawait page.getByTestId(\"view-tile\").click();\n```\n\n**Caveat:** keep integration tests focused on *composition and wiring* — that\ndata flows in and actions flow out. The generic rendering (columns, sorting,\nempty states) is already covered library-side, and the mapping is covered\ndescriptor-side; don't re-test those here.\n\n---\n\n### Choosing a level\n\n| Level       | What it tests        | Dependencies            | Speed   | When to write                    |\n| ----------- | -------------------- | ----------------------- | ------- | -------------------------------- |\n| Library     | Generic components   | None (mock data)        | Fast    | When contributing to entity-kit  |\n| Descriptor  | Object-to-UI mapping | None (pure functions)   | Instant | For every new EntityDescriptor   |\n| Integration | Page composition     | entity-kit + descriptor | Medium  | For every page using entity-kit  |\n| E2E         | Full user flow       | Everything (real browser) | Slow  | For critical user journeys       |\n\n### Testing with different CSS frameworks\n\nThe `classNames` tests are **framework-agnostic**. They assert that a custom\nclass string *replaces* the semantic default for a slot — and that assertion\nholds no matter where the string comes from:\n\n```tsx\n// The same test shape works for any approach:\nclassNames={{ grid: \"grid grid-cols-3 gap-4\" }}   // Tailwind utilities\nclassNames={{ grid: styles.grid }}                // CSS Modules (a hashed name)\nclassNames={{ grid: \"my-grid\" }}                  // plain semantic class\n```\n\nBecause every slot is just a `string`, a test that checks\n`container.querySelector(\".my-grid\")` is present and `.entity-tile-grid` is\nabsent verifies the override mechanism itself — independent of Tailwind, CSS\nModules, or any other string-based class system. You never need to run a real\nCSS pipeline to test that your classes are wired through correctly.\n\n## API reference\n\n### Types\n\n| Export                | Description                                                                |\n| --------------------- | ------------------------------------------------------------------------- |\n| `EntityDescriptor<T>` | The self-description of an entity type. Drives all views.                 |\n| `FieldDescriptor<T>`  | A single field: `key`, `label`, optional `render`, `sortable`, `visible`. |\n| `ActionDescriptor<T>` | A single action: `id`, `label`, `icon?`, `variant?`, `isAvailable?`.      |\n| `ActionVariant`       | `\"default\" \\| \"danger\"`.                                                  |\n\n`label` (on both `FieldDescriptor` and `ActionDescriptor`) is\n`string | (() => string)` — pass a factory for i18n (see\n[Internationalization](#internationalization-i18n)).\n\n**`EntityDescriptor<T>` members.** Only five are required; the rest are optional\nand default sensibly when omitted:\n\n| Member               | Required? | Default when omitted     |\n| -------------------- | --------- | ------------------------ |\n| `entityName`         | **yes**   | —                        |\n| `getId(item)`        | **yes**   | —                        |\n| `displayName(item)`  | **yes**   | —                        |\n| `listFields`         | **yes**   | —                        |\n| `isDeleted(item)`    | **yes**   | —                        |\n| `shortDescription(item)` | no    | `() => \"\"`               |\n| `icon`               | no        | nothing rendered         |\n| `thumbnail(item)`    | no        | falls back to `icon`     |\n| `detailFields`       | no        | `[]`                     |\n| `searchableFields`   | no        | `[]`                     |\n| `deletedAt(item)`    | no        | trash hides the column   |\n| `actions`            | no        | `[]`                     |\n\nA minimal descriptor is therefore just the five required members:\n\n```tsx\nconst minimalBook: EntityDescriptor<Book> = {\n  entityName: \"book\",\n  getId: (b) => b.id,\n  displayName: (b) => b.title,\n  listFields: [{ key: \"title\", label: \"Title\" }],\n  isDeleted: (b) => b.deleted,\n};\n```\n\n### ClassNames interfaces\n\nEvery component accepts an optional `classNames` prop typed by one of these\n(all exported for autocomplete). Each field is an optional class string for one\nvisual slot; an omitted slot falls back to its semantic default class.\n\n| Interface                 | Component            | Key slots                                                                 |\n| ------------------------- | ------------------- | ------------------------------------------------------------------------- |\n| `TileClassNames`          | `EntityTileView`    | `grid`, `tile`, `body`, `thumbnail`, `title`, `subtitle`, `actions`, `actionButton`, `dangerActionButton` |\n| `ListClassNames`          | `EntityListView`    | `root`, `table`, `head`, `header`, `sortButton`, `row`, `cell`, `actionsCell`, `actionButton`, `dangerActionButton`, `pagination`, `pageButton`, `pageStatus` |\n| `DetailClassNames`        | `EntityDetailView`  | `container`, `header`, `title`, `subtitle`, `fields`, `field`, `label`, `value`, `footer`, `actions`, `actionButton`, `dangerActionButton` |\n| `TrashClassNames`         | `EntityTrashView`   | `container`, `list` (a `ListClassNames` forwarded to the inner list)      |\n| `SearchClassNames`        | `EntitySearchBar`   | `container`, `input`, `icon`, `clearButton`                               |\n| `ViewSwitcherClassNames`  | `EntityViewSwitcher`| `group`, `button`, `activeButton`, `icon`, `label`                        |\n| `EmptyStateClassNames`    | `EntityEmptyState`  | `container`, `icon`, `title`, `description`, `action`                     |\n| `ActionsClassNames`       | `EntityActions`     | `actions`, `actionButton`, `dangerActionButton`, `actionIcon`, `actionLabel` |\n\n### Components\n\n| Component            | Renders                                                                   | `classNames`             |\n| -------------------- | ------------------------------------------------------------------------- | ------------------------ |\n| `EntityListView`     | Sortable, filterable, paginated table from `listFields` (TanStack Table). | `ListClassNames`         |\n| `EntityTileView`     | Responsive grid of cards with thumbnail, name, description, actions.      | `TileClassNames`         |\n| `EntityDetailView`   | Label/value pairs from `detailFields`, honouring custom renderers.        | `DetailClassNames`       |\n| `EntityTrashView`    | `isDeleted` items as a list with restore/delete; `prefiltered` skips the filter. | `TrashClassNames`        |\n| `EntitySearchBar`    | Search input filtering over `searchableFields` via `useEntitySearch`.     | `SearchClassNames`       |\n| `EntityViewSwitcher` | Toggle buttons for list / tile / detail modes.                            | `ViewSwitcherClassNames` |\n| `EntityEmptyState`   | Placeholder for empty collections.                                        | `EmptyStateClassNames`   |\n| `EntityActions`      | The per-item action menu (used internally; exported for custom layouts).  | `ActionsClassNames`      |\n\nAll view components take `items: T[]` and `descriptor: EntityDescriptor<T>`,\nreport user intent through an `onAction(actionId, item)` callback, and accept a\n`classNames` prop. They never mutate or fetch data.\n\n`EntityTrashView` emits the action ids `RESTORE_ACTION_ID` (`\"restore\"`) and\n`PERMANENT_DELETE_ACTION_ID` (`\"permanentDelete\"`), both exported as constants.\nPass **`prefiltered`** when `items` are already the trashed set (filtered\nserver-side) to skip the internal `isDeleted` filter; the deletion-timestamp\ncolumn auto-hides when the descriptor has no `deletedAt`:\n\n```tsx\n// `trashedBooks` already came back filtered from the API:\n<EntityTrashView items={trashedBooks} descriptor={bookDescriptor} prefiltered onAction={onAction} />\n```\n\n`EntitySearchBar` works controlled (pass `query`) or uncontrolled, and reports\nthe filtered list through `onResults(results)` and text through\n`onQueryChange(query)`.\n\n### Test ids (`data-testid`)\n\nEvery interactive element carries a stable `data-testid` derived from the\ndescriptor — built from `entityName` and `getId(item)`, so it survives\nlabel/i18n/styling changes. Ideal for Playwright/Cypress and integration tests:\n\n| Element              | `data-testid`                              |\n| -------------------- | ------------------------------------------ |\n| List row / tile      | `{entityName}-{getId(item)}`               |\n| Action button        | `{entityName}-{getId(item)}-{action.id}`   |\n| Search input         | `{entityName}-search`                      |\n| View switcher toggle | `view-{mode}` (`view-list`, `view-tile`, …) |\n\n### Hooks\n\n| Hook                                        | Returns                                                                                   |\n| ------------------------------------------- | ----------------------------------------------------------------------------------------- |\n| `useEntityList(items, descriptor, opts?)`   | `{ table, columns, rows, query, setQuery, sorting, setSorting }` — wraps TanStack Table.   |\n| `useEntitySearch(items, descriptor, query)` | Items filtered by `query` over `searchableFields`.                                        |\n| `useViewMode(defaultMode?)`                 | `{ mode, setMode, cycle }` — toggles `\"list\" \\| \"tile\" \\| \"detail\"`.                      |\n\n### Registry\n\nRe-exported from `@astrapi69/entity-kit-core` (importable from either package).\n\n| Export               | Description                                                          |\n| -------------------- | ------------------------------------------------------------------- |\n| `DescriptorRegistry` | Map-based registry (methods below).                                 |\n| `descriptorRegistry` | A shared default `DescriptorRegistry` instance.                     |\n\n| Method                 | Behavior                                                          |\n| ---------------------- | ----------------------------------------------------------------- |\n| `register(descriptor)` | Register under `descriptor.entityName`; re-registering overwrites. |\n| `get<T>(name)`         | Return the descriptor, or **throw** if none is registered.        |\n| `tryGet<T>(name)`      | Return the descriptor, or `undefined` if none is registered.      |\n| `has(name)`            | Whether a descriptor is registered under `name`.                  |\n| `list()`               | All registered descriptors, in insertion order.                   |\n| `names()`              | All registered entity names, in insertion order.                  |\n| `unregister(name)`     | Remove one; returns `true` if something was removed.              |\n| `clear()`              | Remove all registered descriptors.                                |\n\n```tsx\nimport { descriptorRegistry } from \"@astrapi69/entity-kit\";\n\ndescriptorRegistry.register(bookDescriptor);\ndescriptorRegistry.register(profileDescriptor);\n\nconst book = descriptorRegistry.get<Book>(\"book\");     // throws if \"book\" is unknown\nconst maybe = descriptorRegistry.tryGet<Book>(\"book\"); // undefined if unknown\n```\n\n> **Changed in 0.3.0.** `get()` now **throws** on an unknown name (it returned\n> `undefined` in ≤ 0.2.x). Use `tryGet()` for the `undefined`-returning lookup.\n\n## Migrating to 0.3.0\n\n0.3.0 moves all types, utilities, the registry and the design tokens into the\nnew framework-agnostic [`@astrapi69/entity-kit-core`](#packages-entity-kit-and-entity-kit-core)\npackage; `@astrapi69/entity-kit` re-exports them and keeps the React components.\n**It installs automatically and existing imports are unchanged** — including\n`@astrapi69/entity-kit/styles`.\n\nOne behavioral change to be aware of:\n\n- **`descriptorRegistry.get()` now throws** on an unknown name instead of\n  returning `undefined`. If you relied on the old behavior, switch to the new\n  **`tryGet()`** (see [Registry](#registry)).\n\n## Migrating from 0.1.x\n\n**0.2.0 is fully backwards-compatible — no code changes required.** Every change\nis additive; existing descriptors and components keep working unchanged. What's\nnew and opt-in:\n\n- **i18n labels** — `FieldDescriptor.label` / `ActionDescriptor.label` now accept\n  `() => string` in addition to `string`. Existing string labels are untouched.\n  See [Internationalization](#internationalization-i18n).\n- **Optional descriptor fields** — `shortDescription`, `detailFields`,\n  `searchableFields`, `actions` (and `icon`) are now optional with sensible\n  defaults. Descriptors that already set them are unaffected; new ones can omit\n  them. See the [member table](#api-reference).\n- **`prefiltered` on `EntityTrashView`** — opt in when your app already filtered\n  to the trashed set.\n- **`data-testid` attributes** — added to rows, tiles, action buttons, the\n  search input and view toggles. Purely additive; safe to start selecting by\n  them. See [Test ids](#test-ids-data-testid).\n- **`./package.json` export** — added to the package `exports` map for bundlers\n  that resolve it.\n\nBumping `react` to 19 is also supported (peer range is `^18 || ^19`), but not\nrequired — 18 keeps working.\n\n## Design rules\n\nThis library deliberately does **not**:\n\n- export anything as a default — **named exports only**;\n- fetch data or call APIs — it receives data, it does not fetch it;\n- contain business logic — it renders what the descriptor says, nothing more;\n- ship hardcoded colors, fonts or spacing — everything is a CSS variable or a\n  `classNames` slot;\n- require styles to function — components are headless-first, and **every**\n  component accepts a `classNames` prop.\n\n## License\n\n[MIT](./LICENSE) © Asterios Raptis\n","readmeFilename":"README.md"}