@entifix/react-controls 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/i18n/catalog/en.d.ts.map +1 -1
  2. package/dist/i18n/catalog/en.js +3 -0
  3. package/dist/i18n/catalog/en.js.map +1 -1
  4. package/dist/i18n/catalog/es.d.ts +4 -1
  5. package/dist/i18n/catalog/es.d.ts.map +1 -1
  6. package/dist/i18n/catalog/es.js +4 -1
  7. package/dist/i18n/catalog/es.js.map +1 -1
  8. package/dist/i18n/catalog/index.d.ts +6 -0
  9. package/dist/i18n/catalog/index.d.ts.map +1 -1
  10. package/dist/preferences/use-viewport-mode.d.ts +0 -1
  11. package/dist/preferences/use-viewport-mode.d.ts.map +1 -1
  12. package/dist/preferences/use-viewport-mode.js +0 -1
  13. package/dist/preferences/use-viewport-mode.js.map +1 -1
  14. package/dist/ui/actions/action-surfaces.d.ts +3 -3
  15. package/dist/ui/actions/action-surfaces.js +4 -4
  16. package/dist/ui/actions/action-surfaces.js.map +1 -1
  17. package/dist/ui/actions/render-link.d.ts +8 -4
  18. package/dist/ui/actions/render-link.d.ts.map +1 -1
  19. package/dist/ui/actions/render-link.js.map +1 -1
  20. package/dist/ui/atoms/field-control/field-control.d.ts +1 -1
  21. package/dist/ui/atoms/field-control/field-control.js +1 -1
  22. package/dist/ui/atoms/field-control/field-control.js.map +1 -1
  23. package/dist/ui/molecules/bulk-action-bar/bulk-action-bar.d.ts +2 -2
  24. package/dist/ui/molecules/bulk-action-bar/bulk-action-bar.js.map +1 -1
  25. package/dist/ui/molecules/bulk-result/bulk-result.js.map +1 -1
  26. package/dist/ui/molecules/loading-boundary/loading-boundary.d.ts +1 -1
  27. package/dist/ui/molecules/loading-boundary/loading-boundary.js +1 -1
  28. package/dist/ui/molecules/loading-boundary/loading-boundary.js.map +1 -1
  29. package/dist/ui/molecules/pagination/index.d.ts +2 -0
  30. package/dist/ui/molecules/pagination/index.d.ts.map +1 -1
  31. package/dist/ui/molecules/pagination/index.js +2 -0
  32. package/dist/ui/molecules/pagination/index.js.map +1 -1
  33. package/dist/ui/molecules/pagination/page-math.d.ts +23 -0
  34. package/dist/ui/molecules/pagination/page-math.d.ts.map +1 -0
  35. package/dist/ui/molecules/pagination/page-math.js +47 -0
  36. package/dist/ui/molecules/pagination/page-math.js.map +1 -0
  37. package/dist/ui/molecules/pagination/pagination-links.d.ts +33 -0
  38. package/dist/ui/molecules/pagination/pagination-links.d.ts.map +1 -0
  39. package/dist/ui/molecules/pagination/pagination-links.js +94 -0
  40. package/dist/ui/molecules/pagination/pagination-links.js.map +1 -0
  41. package/dist/ui/molecules/pagination/pagination.d.ts +6 -1
  42. package/dist/ui/molecules/pagination/pagination.d.ts.map +1 -1
  43. package/dist/ui/molecules/pagination/pagination.js +8 -2
  44. package/dist/ui/molecules/pagination/pagination.js.map +1 -1
  45. package/dist/ui/organisms/entity-detail-grid/entity-detail-grid.d.ts +2 -2
  46. package/dist/ui/organisms/entity-detail-grid/entity-detail-grid.js +2 -2
  47. package/dist/ui/organisms/entity-detail-grid/entity-detail-grid.js.map +1 -1
  48. package/dist/ui/organisms/entity-form/entity-form.js +4 -4
  49. package/dist/ui/organisms/entity-form/entity-form.js.map +1 -1
  50. package/dist/ui/organisms/entity-form/entity-form.types.d.ts +2 -2
  51. package/dist/ui/organisms/entity-form/entity-form.types.js.map +1 -1
  52. package/dist/ui/organisms/entity-table/entity-table-tree.d.ts +33 -0
  53. package/dist/ui/organisms/entity-table/entity-table-tree.d.ts.map +1 -0
  54. package/dist/ui/organisms/entity-table/entity-table-tree.js +80 -0
  55. package/dist/ui/organisms/entity-table/entity-table-tree.js.map +1 -0
  56. package/dist/ui/organisms/entity-table/entity-table.d.ts.map +1 -1
  57. package/dist/ui/organisms/entity-table/entity-table.js +41 -9
  58. package/dist/ui/organisms/entity-table/entity-table.js.map +1 -1
  59. package/dist/ui/organisms/wizard/wizard.d.ts +1 -2
  60. package/dist/ui/organisms/wizard/wizard.d.ts.map +1 -1
  61. package/dist/ui/organisms/wizard/wizard.js +2 -3
  62. package/dist/ui/organisms/wizard/wizard.js.map +1 -1
  63. package/dist/ui/organisms/wizard/wizard.types.d.ts +2 -2
  64. package/dist/ui/organisms/wizard/wizard.types.js.map +1 -1
  65. package/dist/ui/utils/active-section/active-section.d.ts +1 -1
  66. package/dist/ui/utils/active-section/active-section.js +1 -1
  67. package/dist/ui/utils/active-section/active-section.js.map +1 -1
  68. package/dist/ui/utils/keep-section/keep-section.d.ts +1 -1
  69. package/dist/ui/utils/keep-section/keep-section.js +1 -1
  70. package/dist/ui/utils/keep-section/keep-section.js.map +1 -1
  71. package/package.json +5 -5
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../src/ui/molecules/bulk-result/bulk-result.tsx"],"sourcesContent":["'use client';\n\nimport {\n type BulkOutcome,\n type EntityId,\n failedIds,\n succeededCount,\n} from '@entifix/core';\n\nimport { useT, useTranslateKey } from '../../../i18n/index.js';\nimport { Button } from '../../atoms/button/index.js';\nimport { cn } from '../../utils/cn.js';\n\nexport interface BulkResultProps {\n outcomes: readonly BulkOutcome[];\n /**\n * How a failed row is named to the user. An id is not an answer to \"which\n * ones failed\" — the caller holds the rows and can render the same label the\n * table shows. Falls back to the id when the row is no longer on the page.\n */\n labelFor?: (id: EntityId) => string | undefined;\n /** Re-runs the failures **only**. Absent hides the affordance. */\n onRetryFailed?: (ids: EntityId[]) => void;\n onDismiss: () => void;\n busy?: boolean;\n className?: string;\n}\n\n/**\n * What a bulk action actually did, per row.\n *\n * The requirement this exists for: 40 selected, 3 fail. A single notice lies\n * whichever way it is written — as a failure it hides the 37 rows that were\n * written, and as a success it hides the 3 that were not, which is worse\n * because the user walks away believing the work is done. So both counts are\n * always stated, and every failure is named with its own reason.\n *\n * `role=\"status\"`, not `role=\"alert\"`: a partial failure is an outcome to read,\n * not an interruption, and `alert` on a list this long is hostile. The region\n * is `tabIndex={-1}` so the caller can move focus to it once the run ends,\n * which is the only reliable way a keyboard user learns the result arrived.\n */\nexport function BulkResult({\n outcomes,\n labelFor,\n onRetryFailed,\n onDismiss,\n busy = false,\n className,\n}: BulkResultProps) {\n const t = useT();\n const translateKey = useTranslateKey();\n\n const failed = outcomes.filter(outcome => !outcome.ok);\n const succeeded = succeededCount(outcomes);\n\n return (\n <div\n role=\"status\"\n tabIndex={-1}\n aria-label={t('bulk.resultLabel')}\n data-testid=\"bulk-result\"\n className={cn(\n 'flex flex-col gap-2xs rounded-lg border border-border bg-surface-elevated px-s py-2xs',\n className,\n )}\n >\n <div className=\"flex flex-wrap items-center gap-2xs\">\n <span className=\"text-step-sm text-content\">\n {t('bulk.succeeded', { count: succeeded })}\n </span>\n {failed.length > 0 && (\n <span className=\"text-step-sm text-danger\">\n {t('bulk.failed', { count: failed.length })}\n </span>\n )}\n {failed.length > 0 && onRetryFailed && (\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n disabled={busy}\n onClick={() => onRetryFailed(failedIds(outcomes))}\n >\n {t('bulk.retryFailed')}\n </Button>\n )}\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n disabled={busy}\n onClick={onDismiss}\n >\n {t('bulk.dismiss')}\n </Button>\n </div>\n\n {failed.length > 0 && (\n <ul className=\"flex flex-col gap-3xs\">\n {failed.map(outcome => (\n <li\n key={String(outcome.id)}\n className=\"text-step-sm text-content-muted\"\n >\n <span className=\"text-content\">\n {labelFor?.(outcome.id) ?? String(outcome.id)}\n </span>\n {' — '}\n {/* The reason is a *code*, resolved through the shared `errors`\n catalog — the same vocabulary a service's `{ error, code }`\n body is rendered from, so a new failure reason is\n translatable rather than English, and `@r10c/i18n-check`\n fails the build on one the catalog lacks. `unexpected` is the\n browser-side fallback the catalog already carries. */}\n {translateKey(`errors:${outcome.code ?? 'unexpected'}`)}\n </li>\n ))}\n </ul>\n )}\n </div>\n );\n}\n"],"names":["failedIds","succeededCount","useT","useTranslateKey","Button","cn","BulkResult","outcomes","labelFor","onRetryFailed","onDismiss","busy","className","t","translateKey","failed","filter","outcome","ok","succeeded","div","role","tabIndex","aria-label","data-testid","span","count","length","type","variant","size","disabled","onClick","ul","map","li","id","String","code"],"mappings":"AAAA;;AAEA,SAGEA,SAAS,EACTC,cAAc,QACT,gBAAgB;AAEvB,SAASC,IAAI,EAAEC,eAAe,QAAQ,yBAAyB;AAC/D,SAASC,MAAM,QAAQ,8BAA8B;AACrD,SAASC,EAAE,QAAQ,oBAAoB;AAiBvC;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASC,WAAW,EACzBC,QAAQ,EACRC,QAAQ,EACRC,aAAa,EACbC,SAAS,EACTC,OAAO,KAAK,EACZC,SAAS,EACO;IAChB,MAAMC,IAAIX;IACV,MAAMY,eAAeX;IAErB,MAAMY,SAASR,SAASS,MAAM,CAACC,CAAAA,UAAW,CAACA,QAAQC,EAAE;IACrD,MAAMC,YAAYlB,eAAeM;IAEjC,qBACE,MAACa;QACCC,MAAK;QACLC,UAAU,CAAC;QACXC,cAAYV,EAAE;QACdW,eAAY;QACZZ,WAAWP,GACT,yFACAO;;0BAGF,MAACQ;gBAAIR,WAAU;;kCACb,KAACa;wBAAKb,WAAU;kCACbC,EAAE,kBAAkB;4BAAEa,OAAOP;wBAAU;;oBAEzCJ,OAAOY,MAAM,GAAG,mBACf,KAACF;wBAAKb,WAAU;kCACbC,EAAE,eAAe;4BAAEa,OAAOX,OAAOY,MAAM;wBAAC;;oBAG5CZ,OAAOY,MAAM,GAAG,KAAKlB,+BACpB,KAACL;wBACCwB,MAAK;wBACLC,SAAQ;wBACRC,MAAK;wBACLC,UAAUpB;wBACVqB,SAAS,IAAMvB,cAAcT,UAAUO;kCAEtCM,EAAE;;kCAGP,KAACT;wBACCwB,MAAK;wBACLC,SAAQ;wBACRC,MAAK;wBACLC,UAAUpB;wBACVqB,SAAStB;kCAERG,EAAE;;;;YAINE,OAAOY,MAAM,GAAG,mBACf,KAACM;gBAAGrB,WAAU;0BACXG,OAAOmB,GAAG,CAACjB,CAAAA;8BAegBA;yCAd1B,MAACkB;wBAECvB,WAAU;;0CAEV,KAACa;gCAAKb,WAAU;kDACbJ,4BAAAA,SAAWS,QAAQmB,EAAE,oBAAKC,OAAOpB,QAAQmB,EAAE;;4BAE7C;4BAOAtB,aAAa,CAAC,OAAO,GAAEG,gBAAAA,QAAQqB,IAAI,YAAZrB,gBAAgB,cAAc;;uBAbjDoB,OAAOpB,QAAQmB,EAAE;;;;;AAoBpC"}
1
+ {"version":3,"sources":["../../../../src/ui/molecules/bulk-result/bulk-result.tsx"],"sourcesContent":["'use client';\n\nimport {\n type BulkOutcome,\n type EntityId,\n failedIds,\n succeededCount,\n} from '@entifix/core';\n\nimport { useT, useTranslateKey } from '../../../i18n/index.js';\nimport { Button } from '../../atoms/button/index.js';\nimport { cn } from '../../utils/cn.js';\n\nexport interface BulkResultProps {\n outcomes: readonly BulkOutcome[];\n /**\n * How a failed row is named to the user. An id is not an answer to \"which\n * ones failed\" — the caller holds the rows and can render the same label the\n * table shows. Falls back to the id when the row is no longer on the page.\n */\n labelFor?: (id: EntityId) => string | undefined;\n /** Re-runs the failures **only**. Absent hides the affordance. */\n onRetryFailed?: (ids: EntityId[]) => void;\n onDismiss: () => void;\n busy?: boolean;\n className?: string;\n}\n\n/**\n * What a bulk action actually did, per row.\n *\n * The requirement this exists for: 40 selected, 3 fail. A single notice lies\n * whichever way it is written — as a failure it hides the 37 rows that were\n * written, and as a success it hides the 3 that were not, which is worse\n * because the user walks away believing the work is done. So both counts are\n * always stated, and every failure is named with its own reason.\n *\n * `role=\"status\"`, not `role=\"alert\"`: a partial failure is an outcome to read,\n * not an interruption, and `alert` on a list this long is hostile. The region\n * is `tabIndex={-1}` so the caller can move focus to it once the run ends,\n * which is the only reliable way a keyboard user learns the result arrived.\n */\nexport function BulkResult({\n outcomes,\n labelFor,\n onRetryFailed,\n onDismiss,\n busy = false,\n className,\n}: BulkResultProps) {\n const t = useT();\n const translateKey = useTranslateKey();\n\n const failed = outcomes.filter(outcome => !outcome.ok);\n const succeeded = succeededCount(outcomes);\n\n return (\n <div\n role=\"status\"\n tabIndex={-1}\n aria-label={t('bulk.resultLabel')}\n data-testid=\"bulk-result\"\n className={cn(\n 'flex flex-col gap-2xs rounded-lg border border-border bg-surface-elevated px-s py-2xs',\n className,\n )}\n >\n <div className=\"flex flex-wrap items-center gap-2xs\">\n <span className=\"text-step-sm text-content\">\n {t('bulk.succeeded', { count: succeeded })}\n </span>\n {failed.length > 0 && (\n <span className=\"text-step-sm text-danger\">\n {t('bulk.failed', { count: failed.length })}\n </span>\n )}\n {failed.length > 0 && onRetryFailed && (\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n disabled={busy}\n onClick={() => onRetryFailed(failedIds(outcomes))}\n >\n {t('bulk.retryFailed')}\n </Button>\n )}\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n disabled={busy}\n onClick={onDismiss}\n >\n {t('bulk.dismiss')}\n </Button>\n </div>\n\n {failed.length > 0 && (\n <ul className=\"flex flex-col gap-3xs\">\n {failed.map(outcome => (\n <li\n key={String(outcome.id)}\n className=\"text-step-sm text-content-muted\"\n >\n <span className=\"text-content\">\n {labelFor?.(outcome.id) ?? String(outcome.id)}\n </span>\n {' — '}\n {/* The reason is a *code*, resolved through the shared `errors`\n catalog — the same vocabulary a service's `{ error, code }`\n body is rendered from, so a new failure reason is\n translatable rather than English, and a host's catalog\n check fails the build on one the catalog lacks. `unexpected` is the\n browser-side fallback the catalog already carries. */}\n {translateKey(`errors:${outcome.code ?? 'unexpected'}`)}\n </li>\n ))}\n </ul>\n )}\n </div>\n );\n}\n"],"names":["failedIds","succeededCount","useT","useTranslateKey","Button","cn","BulkResult","outcomes","labelFor","onRetryFailed","onDismiss","busy","className","t","translateKey","failed","filter","outcome","ok","succeeded","div","role","tabIndex","aria-label","data-testid","span","count","length","type","variant","size","disabled","onClick","ul","map","li","id","String","code"],"mappings":"AAAA;;AAEA,SAGEA,SAAS,EACTC,cAAc,QACT,gBAAgB;AAEvB,SAASC,IAAI,EAAEC,eAAe,QAAQ,yBAAyB;AAC/D,SAASC,MAAM,QAAQ,8BAA8B;AACrD,SAASC,EAAE,QAAQ,oBAAoB;AAiBvC;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASC,WAAW,EACzBC,QAAQ,EACRC,QAAQ,EACRC,aAAa,EACbC,SAAS,EACTC,OAAO,KAAK,EACZC,SAAS,EACO;IAChB,MAAMC,IAAIX;IACV,MAAMY,eAAeX;IAErB,MAAMY,SAASR,SAASS,MAAM,CAACC,CAAAA,UAAW,CAACA,QAAQC,EAAE;IACrD,MAAMC,YAAYlB,eAAeM;IAEjC,qBACE,MAACa;QACCC,MAAK;QACLC,UAAU,CAAC;QACXC,cAAYV,EAAE;QACdW,eAAY;QACZZ,WAAWP,GACT,yFACAO;;0BAGF,MAACQ;gBAAIR,WAAU;;kCACb,KAACa;wBAAKb,WAAU;kCACbC,EAAE,kBAAkB;4BAAEa,OAAOP;wBAAU;;oBAEzCJ,OAAOY,MAAM,GAAG,mBACf,KAACF;wBAAKb,WAAU;kCACbC,EAAE,eAAe;4BAAEa,OAAOX,OAAOY,MAAM;wBAAC;;oBAG5CZ,OAAOY,MAAM,GAAG,KAAKlB,+BACpB,KAACL;wBACCwB,MAAK;wBACLC,SAAQ;wBACRC,MAAK;wBACLC,UAAUpB;wBACVqB,SAAS,IAAMvB,cAAcT,UAAUO;kCAEtCM,EAAE;;kCAGP,KAACT;wBACCwB,MAAK;wBACLC,SAAQ;wBACRC,MAAK;wBACLC,UAAUpB;wBACVqB,SAAStB;kCAERG,EAAE;;;;YAINE,OAAOY,MAAM,GAAG,mBACf,KAACM;gBAAGrB,WAAU;0BACXG,OAAOmB,GAAG,CAACjB,CAAAA;8BAegBA;yCAd1B,MAACkB;wBAECvB,WAAU;;0CAEV,KAACa;gCAAKb,WAAU;kDACbJ,4BAAAA,SAAWS,QAAQmB,EAAE,oBAAKC,OAAOpB,QAAQmB,EAAE;;4BAE7C;4BAOAtB,aAAa,CAAC,OAAO,GAAEG,gBAAAA,QAAQqB,IAAI,YAAZrB,gBAAgB,cAAc;;uBAbjDoB,OAAOpB,QAAQmB,EAAE;;;;;AAoBpC"}
@@ -20,7 +20,7 @@ export interface LoadingBoundaryProps {
20
20
  * a non-sighted caller is told nothing at all while a region is pending.
21
21
  *
22
22
  * It is not an error boundary and does not catch anything. Rendering actions
23
- * became asynchronous when affordances moved behind `$metadata` (ADR 0026);
23
+ * became asynchronous when affordances moved behind `$metadata`;
24
24
  * this is where that wait is shown.
25
25
  */
26
26
  export declare function LoadingBoundary({ isLoading, children, fallback, lines, className, label, }: LoadingBoundaryProps): import("react").JSX.Element;
@@ -11,7 +11,7 @@ import { cn } from '../../utils/cn.js';
11
11
  * a non-sighted caller is told nothing at all while a region is pending.
12
12
  *
13
13
  * It is not an error boundary and does not catch anything. Rendering actions
14
- * became asynchronous when affordances moved behind `$metadata` (ADR 0026);
14
+ * became asynchronous when affordances moved behind `$metadata`;
15
15
  * this is where that wait is shown.
16
16
  */ export function LoadingBoundary({ isLoading, children, fallback, lines = 3, className, label }) {
17
17
  if (!isLoading) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../src/ui/molecules/loading-boundary/loading-boundary.tsx"],"sourcesContent":["import type { ReactNode } from 'react';\n\nimport { Skeleton, SkeletonText } from '../../atoms/skeleton/index.js';\nimport { cn } from '../../utils/cn.js';\n\nexport interface LoadingBoundaryProps {\n isLoading: boolean;\n children: ReactNode;\n /** What to show while loading. Defaults to a shape-matched skeleton. */\n fallback?: ReactNode;\n /** Lines for the default text fallback. `0` renders a single block instead. */\n lines?: number;\n className?: string;\n /** Announced to assistive tech; a catalog key resolved by the caller. */\n label?: string;\n}\n\n/**\n * Holds a region's shape while its content is still arriving.\n *\n * Presentational and copy-free: it renders a skeleton, not the word \"Loading\",\n * because a placeholder that matches the real geometry causes no layout shift\n * when the content lands and needs no translation. The optional {@link label} is\n * for screen readers only — the visual skeleton is `aria-hidden`, so without it\n * a non-sighted caller is told nothing at all while a region is pending.\n *\n * It is not an error boundary and does not catch anything. Rendering actions\n * became asynchronous when affordances moved behind `$metadata` (ADR 0026);\n * this is where that wait is shown.\n */\nexport function LoadingBoundary({\n isLoading,\n children,\n fallback,\n lines = 3,\n className,\n label,\n}: LoadingBoundaryProps) {\n if (!isLoading) {\n return <>{children}</>;\n }\n\n return (\n <div\n data-testid=\"loading-boundary\"\n className={cn(className)}\n role=\"status\"\n aria-busy=\"true\"\n aria-live=\"polite\"\n >\n {label && <span className=\"sr-only\">{label}</span>}\n {fallback ??\n (lines === 0 ? (\n <Skeleton className=\"h-8 w-full\" />\n ) : (\n <SkeletonText lines={lines} />\n ))}\n </div>\n );\n}\n"],"names":["Skeleton","SkeletonText","cn","LoadingBoundary","isLoading","children","fallback","lines","className","label","div","data-testid","role","aria-busy","aria-live","span"],"mappings":";AAEA,SAASA,QAAQ,EAAEC,YAAY,QAAQ,gCAAgC;AACvE,SAASC,EAAE,QAAQ,oBAAoB;AAcvC;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,gBAAgB,EAC9BC,SAAS,EACTC,QAAQ,EACRC,QAAQ,EACRC,QAAQ,CAAC,EACTC,SAAS,EACTC,KAAK,EACgB;IACrB,IAAI,CAACL,WAAW;QACd,qBAAO;sBAAGC;;IACZ;IAEA,qBACE,MAACK;QACCC,eAAY;QACZH,WAAWN,GAAGM;QACdI,MAAK;QACLC,aAAU;QACVC,aAAU;;YAETL,uBAAS,KAACM;gBAAKP,WAAU;0BAAWC;;YACpCH,mBAAAA,WACEC,UAAU,kBACT,KAACP;gBAASQ,WAAU;+BAEpB,KAACP;gBAAaM,OAAOA;;;;AAI/B"}
1
+ {"version":3,"sources":["../../../../src/ui/molecules/loading-boundary/loading-boundary.tsx"],"sourcesContent":["import type { ReactNode } from 'react';\n\nimport { Skeleton, SkeletonText } from '../../atoms/skeleton/index.js';\nimport { cn } from '../../utils/cn.js';\n\nexport interface LoadingBoundaryProps {\n isLoading: boolean;\n children: ReactNode;\n /** What to show while loading. Defaults to a shape-matched skeleton. */\n fallback?: ReactNode;\n /** Lines for the default text fallback. `0` renders a single block instead. */\n lines?: number;\n className?: string;\n /** Announced to assistive tech; a catalog key resolved by the caller. */\n label?: string;\n}\n\n/**\n * Holds a region's shape while its content is still arriving.\n *\n * Presentational and copy-free: it renders a skeleton, not the word \"Loading\",\n * because a placeholder that matches the real geometry causes no layout shift\n * when the content lands and needs no translation. The optional {@link label} is\n * for screen readers only — the visual skeleton is `aria-hidden`, so without it\n * a non-sighted caller is told nothing at all while a region is pending.\n *\n * It is not an error boundary and does not catch anything. Rendering actions\n * became asynchronous when affordances moved behind `$metadata`;\n * this is where that wait is shown.\n */\nexport function LoadingBoundary({\n isLoading,\n children,\n fallback,\n lines = 3,\n className,\n label,\n}: LoadingBoundaryProps) {\n if (!isLoading) {\n return <>{children}</>;\n }\n\n return (\n <div\n data-testid=\"loading-boundary\"\n className={cn(className)}\n role=\"status\"\n aria-busy=\"true\"\n aria-live=\"polite\"\n >\n {label && <span className=\"sr-only\">{label}</span>}\n {fallback ??\n (lines === 0 ? (\n <Skeleton className=\"h-8 w-full\" />\n ) : (\n <SkeletonText lines={lines} />\n ))}\n </div>\n );\n}\n"],"names":["Skeleton","SkeletonText","cn","LoadingBoundary","isLoading","children","fallback","lines","className","label","div","data-testid","role","aria-busy","aria-live","span"],"mappings":";AAEA,SAASA,QAAQ,EAAEC,YAAY,QAAQ,gCAAgC;AACvE,SAASC,EAAE,QAAQ,oBAAoB;AAcvC;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,gBAAgB,EAC9BC,SAAS,EACTC,QAAQ,EACRC,QAAQ,EACRC,QAAQ,CAAC,EACTC,SAAS,EACTC,KAAK,EACgB;IACrB,IAAI,CAACL,WAAW;QACd,qBAAO;sBAAGC;;IACZ;IAEA,qBACE,MAACK;QACCC,eAAY;QACZH,WAAWN,GAAGM;QACdI,MAAK;QACLC,aAAU;QACVC,aAAU;;YAETL,uBAAS,KAACM;gBAAKP,WAAU;0BAAWC;;YACpCH,mBAAAA,WACEC,UAAU,kBACT,KAACP;gBAASQ,WAAU;+BAEpB,KAACP;gBAAaM,OAAOA;;;;AAI/B"}
@@ -1,2 +1,4 @@
1
+ export * from './page-math.js';
1
2
  export * from './pagination.js';
3
+ export * from './pagination-links.js';
2
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/ui/molecules/pagination/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/ui/molecules/pagination/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,uBAAuB,CAAC"}
@@ -1,3 +1,5 @@
1
+ export * from './page-math.js';
1
2
  export * from './pagination.js';
3
+ export * from './pagination-links.js';
2
4
 
3
5
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../src/ui/molecules/pagination/index.ts"],"sourcesContent":["export * from './pagination.js';\n"],"names":[],"mappings":"AAAA,cAAc,kBAAkB"}
1
+ {"version":3,"sources":["../../../../src/ui/molecules/pagination/index.ts"],"sourcesContent":["export * from './page-math.js';\nexport * from './pagination.js';\nexport * from './pagination-links.js';\n"],"names":[],"mappings":"AAAA,cAAc,iBAAiB;AAC/B,cAAc,kBAAkB;AAChC,cAAc,wBAAwB"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * How many pages a listing spans. An empty listing is still one page: "page 1
3
+ * of 0" reads as broken.
4
+ */
5
+ export declare function pageCount(totalItems: number, pageSize: number): number;
6
+ /** A page number to link to, or a gap standing for the pages between two. */
7
+ export type PageWindowEntry = {
8
+ readonly kind: 'page';
9
+ readonly page: number;
10
+ } | {
11
+ readonly kind: 'gap';
12
+ readonly after: number;
13
+ };
14
+ /**
15
+ * The page numbers worth linking from `current`: the first, the last, and
16
+ * `radius` either side of the current one, with a gap wherever pages are
17
+ * skipped.
18
+ *
19
+ * A gap replaces two or more pages, never one: `1 … 3` hides a single page
20
+ * behind a glyph that is as wide as its number, so the page is shown instead.
21
+ */
22
+ export declare function pageWindow(current: number, total: number, radius?: number): PageWindowEntry[];
23
+ //# sourceMappingURL=page-math.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-math.d.ts","sourceRoot":"","sources":["../../../../src/ui/molecules/pagination/page-math.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEtE;AAED,6EAA6E;AAC7E,MAAM,MAAM,eAAe,GACvB;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAChD;IAAE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAErD;;;;;;;GAOG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,MAAM,SAAI,GACT,eAAe,EAAE,CAkBnB"}
@@ -0,0 +1,47 @@
1
+ /**
2
+ * How many pages a listing spans. An empty listing is still one page: "page 1
3
+ * of 0" reads as broken.
4
+ */ export function pageCount(totalItems, pageSize) {
5
+ return Math.max(1, Math.ceil(totalItems / pageSize));
6
+ }
7
+ /**
8
+ * The page numbers worth linking from `current`: the first, the last, and
9
+ * `radius` either side of the current one, with a gap wherever pages are
10
+ * skipped.
11
+ *
12
+ * A gap replaces two or more pages, never one: `1 … 3` hides a single page
13
+ * behind a glyph that is as wide as its number, so the page is shown instead.
14
+ */ export function pageWindow(current, total, radius = 1) {
15
+ const wanted = new Set([
16
+ 1,
17
+ total
18
+ ]);
19
+ for(let page = current - radius; page <= current + radius; page += 1){
20
+ if (page >= 1 && page <= total) wanted.add(page);
21
+ }
22
+ const pages = [
23
+ ...wanted
24
+ ].sort((a, b)=>a - b);
25
+ const entries = [];
26
+ pages.forEach((page, index)=>{
27
+ const previous = pages[index - 1];
28
+ if (previous !== undefined && page - previous === 2) {
29
+ entries.push({
30
+ kind: 'page',
31
+ page: previous + 1
32
+ });
33
+ } else if (previous !== undefined && page - previous > 2) {
34
+ entries.push({
35
+ kind: 'gap',
36
+ after: previous
37
+ });
38
+ }
39
+ entries.push({
40
+ kind: 'page',
41
+ page
42
+ });
43
+ });
44
+ return entries;
45
+ }
46
+
47
+ //# sourceMappingURL=page-math.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../src/ui/molecules/pagination/page-math.ts"],"sourcesContent":["/**\n * How many pages a listing spans. An empty listing is still one page: \"page 1\n * of 0\" reads as broken.\n */\nexport function pageCount(totalItems: number, pageSize: number): number {\n return Math.max(1, Math.ceil(totalItems / pageSize));\n}\n\n/** A page number to link to, or a gap standing for the pages between two. */\nexport type PageWindowEntry =\n | { readonly kind: 'page'; readonly page: number }\n | { readonly kind: 'gap'; readonly after: number };\n\n/**\n * The page numbers worth linking from `current`: the first, the last, and\n * `radius` either side of the current one, with a gap wherever pages are\n * skipped.\n *\n * A gap replaces two or more pages, never one: `1 … 3` hides a single page\n * behind a glyph that is as wide as its number, so the page is shown instead.\n */\nexport function pageWindow(\n current: number,\n total: number,\n radius = 1,\n): PageWindowEntry[] {\n const wanted = new Set<number>([1, total]);\n for (let page = current - radius; page <= current + radius; page += 1) {\n if (page >= 1 && page <= total) wanted.add(page);\n }\n const pages = [...wanted].sort((a, b) => a - b);\n\n const entries: PageWindowEntry[] = [];\n pages.forEach((page, index) => {\n const previous = pages[index - 1];\n if (previous !== undefined && page - previous === 2) {\n entries.push({ kind: 'page', page: previous + 1 });\n } else if (previous !== undefined && page - previous > 2) {\n entries.push({ kind: 'gap', after: previous });\n }\n entries.push({ kind: 'page', page });\n });\n return entries;\n}\n"],"names":["pageCount","totalItems","pageSize","Math","max","ceil","pageWindow","current","total","radius","wanted","Set","page","add","pages","sort","a","b","entries","forEach","index","previous","undefined","push","kind","after"],"mappings":"AAAA;;;CAGC,GACD,OAAO,SAASA,UAAUC,UAAkB,EAAEC,QAAgB;IAC5D,OAAOC,KAAKC,GAAG,CAAC,GAAGD,KAAKE,IAAI,CAACJ,aAAaC;AAC5C;AAOA;;;;;;;CAOC,GACD,OAAO,SAASI,WACdC,OAAe,EACfC,KAAa,EACbC,SAAS,CAAC;IAEV,MAAMC,SAAS,IAAIC,IAAY;QAAC;QAAGH;KAAM;IACzC,IAAK,IAAII,OAAOL,UAAUE,QAAQG,QAAQL,UAAUE,QAAQG,QAAQ,EAAG;QACrE,IAAIA,QAAQ,KAAKA,QAAQJ,OAAOE,OAAOG,GAAG,CAACD;IAC7C;IACA,MAAME,QAAQ;WAAIJ;KAAO,CAACK,IAAI,CAAC,CAACC,GAAGC,IAAMD,IAAIC;IAE7C,MAAMC,UAA6B,EAAE;IACrCJ,MAAMK,OAAO,CAAC,CAACP,MAAMQ;QACnB,MAAMC,WAAWP,KAAK,CAACM,QAAQ,EAAE;QACjC,IAAIC,aAAaC,aAAaV,OAAOS,aAAa,GAAG;YACnDH,QAAQK,IAAI,CAAC;gBAAEC,MAAM;gBAAQZ,MAAMS,WAAW;YAAE;QAClD,OAAO,IAAIA,aAAaC,aAAaV,OAAOS,WAAW,GAAG;YACxDH,QAAQK,IAAI,CAAC;gBAAEC,MAAM;gBAAOC,OAAOJ;YAAS;QAC9C;QACAH,QAAQK,IAAI,CAAC;YAAEC,MAAM;YAAQZ;QAAK;IACpC;IACA,OAAOM;AACT"}
@@ -0,0 +1,33 @@
1
+ import type { Translate } from '../../../i18n/index.js';
2
+ import { type RenderLink } from '../../actions/render-link.js';
3
+ export interface PaginationLinksProps {
4
+ currentPage: number;
5
+ pageSize: number;
6
+ totalItems: number;
7
+ /** The URL of a page. Page 1 is the host's to spell, usually without `?page`. */
8
+ hrefFor: (page: number) => string;
9
+ /**
10
+ * Resolves this control's keys (`pagination.*`). A server component has no
11
+ * context to read a translator from, so the host hands one over: in a Next
12
+ * host, the server translator for the controls' namespace.
13
+ */
14
+ t: Translate;
15
+ /** Defaults to a plain anchor; a host passes its own link component. */
16
+ renderLink?: RenderLink;
17
+ /** How many page numbers either side of the current one. Defaults to 1. */
18
+ radius?: number;
19
+ className?: string;
20
+ }
21
+ /**
22
+ * {@link Pagination} as links, and a **server** component.
23
+ *
24
+ * Every page is an address: a crawler follows it, a visitor middle-clicks it,
25
+ * and the page works with JavaScript off. `Pagination` steps through a
26
+ * callback and carries a `'use client'`, which a server-rendered listing
27
+ * cannot use without turning its pager into a client boundary for no gain.
28
+ *
29
+ * Previous and Next stay in the layout at the edges, as text marked
30
+ * `aria-disabled` rather than as links that go nowhere.
31
+ */
32
+ export declare function PaginationLinks({ currentPage, pageSize, totalItems, hrefFor, t, renderLink, radius, className, }: PaginationLinksProps): import("react").JSX.Element;
33
+ //# sourceMappingURL=pagination-links.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pagination-links.d.ts","sourceRoot":"","sources":["../../../../src/ui/molecules/pagination/pagination-links.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,EAAgB,KAAK,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAS7E,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,iFAAiF;IACjF,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IAClC;;;;OAIG;IACH,CAAC,EAAE,SAAS,CAAC;IACb,wEAAwE;IACxE,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,EAC9B,WAAW,EACX,QAAQ,EACR,UAAU,EACV,OAAO,EACP,CAAC,EACD,UAAyB,EACzB,MAAM,EACN,SAAS,GACV,EAAE,oBAAoB,+BA2EtB"}
@@ -0,0 +1,94 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { renderAnchor } from '../../actions/render-link.js';
3
+ import { button } from '../../atoms/button/button-recipe.js';
4
+ import { Text } from '../../atoms/text/index.js';
5
+ import { cn } from '../../utils/cn.js';
6
+ import { pageCount, pageWindow } from './page-math.js';
7
+ /** A glyph, not copy: it reads the same in every locale, and it is aria-hidden. */ const GAP = '…';
8
+ /**
9
+ * {@link Pagination} as links, and a **server** component.
10
+ *
11
+ * Every page is an address: a crawler follows it, a visitor middle-clicks it,
12
+ * and the page works with JavaScript off. `Pagination` steps through a
13
+ * callback and carries a `'use client'`, which a server-rendered listing
14
+ * cannot use without turning its pager into a client boundary for no gain.
15
+ *
16
+ * Previous and Next stay in the layout at the edges, as text marked
17
+ * `aria-disabled` rather than as links that go nowhere.
18
+ */ export function PaginationLinks({ currentPage, pageSize, totalItems, hrefFor, t, renderLink = renderAnchor, radius, className }) {
19
+ const totalPages = pageCount(totalItems, pageSize);
20
+ const step = button({
21
+ variant: 'secondary',
22
+ size: 'sm'
23
+ });
24
+ const number = button({
25
+ variant: 'ghost',
26
+ size: 'sm'
27
+ });
28
+ const unavailable = cn(step, 'pointer-events-none opacity-50');
29
+ const edge = (page, label, enabled)=>enabled ? renderLink({
30
+ href: hrefFor(page),
31
+ className: step,
32
+ children: label,
33
+ intent: {
34
+ kind: 'page',
35
+ page
36
+ }
37
+ }) : /*#__PURE__*/ _jsx("span", {
38
+ "aria-disabled": "true",
39
+ className: unavailable,
40
+ children: label
41
+ });
42
+ return /*#__PURE__*/ _jsxs("nav", {
43
+ "aria-label": t('pagination.label'),
44
+ className: cn('flex flex-wrap items-center justify-between gap-2xs', className),
45
+ children: [
46
+ /*#__PURE__*/ _jsx(Text, {
47
+ step: -1,
48
+ tone: "muted",
49
+ children: t('pagination.summary', {
50
+ current: currentPage,
51
+ total: totalPages,
52
+ items: totalItems
53
+ })
54
+ }),
55
+ /*#__PURE__*/ _jsxs("ol", {
56
+ className: "flex flex-wrap items-center gap-3xs",
57
+ children: [
58
+ /*#__PURE__*/ _jsx("li", {
59
+ children: edge(currentPage - 1, t('pagination.previous'), currentPage > 1)
60
+ }),
61
+ pageWindow(currentPage, totalPages, radius).map((entry)=>entry.kind === 'gap' ? /*#__PURE__*/ _jsx("li", {
62
+ "aria-hidden": "true",
63
+ children: /*#__PURE__*/ _jsx(Text, {
64
+ tone: "muted",
65
+ children: GAP
66
+ })
67
+ }, `gap-${entry.after}`) : /*#__PURE__*/ _jsx("li", {
68
+ children: entry.page === currentPage ? /*#__PURE__*/ _jsx("span", {
69
+ "aria-current": "page",
70
+ className: button({
71
+ variant: 'primary',
72
+ size: 'sm'
73
+ }),
74
+ children: entry.page
75
+ }) : renderLink({
76
+ href: hrefFor(entry.page),
77
+ className: number,
78
+ children: String(entry.page),
79
+ intent: {
80
+ kind: 'page',
81
+ page: entry.page
82
+ }
83
+ })
84
+ }, entry.page)),
85
+ /*#__PURE__*/ _jsx("li", {
86
+ children: edge(currentPage + 1, t('pagination.next'), currentPage < totalPages)
87
+ })
88
+ ]
89
+ })
90
+ ]
91
+ });
92
+ }
93
+
94
+ //# sourceMappingURL=pagination-links.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../src/ui/molecules/pagination/pagination-links.tsx"],"sourcesContent":["import type { Translate } from '../../../i18n/index.js';\nimport { renderAnchor, type RenderLink } from '../../actions/render-link.js';\nimport { button } from '../../atoms/button/button-recipe.js';\nimport { Text } from '../../atoms/text/index.js';\nimport { cn } from '../../utils/cn.js';\nimport { pageCount, pageWindow } from './page-math.js';\n\n/** A glyph, not copy: it reads the same in every locale, and it is aria-hidden. */\nconst GAP = '…';\n\nexport interface PaginationLinksProps {\n currentPage: number;\n pageSize: number;\n totalItems: number;\n /** The URL of a page. Page 1 is the host's to spell, usually without `?page`. */\n hrefFor: (page: number) => string;\n /**\n * Resolves this control's keys (`pagination.*`). A server component has no\n * context to read a translator from, so the host hands one over: in a Next\n * host, the server translator for the controls' namespace.\n */\n t: Translate;\n /** Defaults to a plain anchor; a host passes its own link component. */\n renderLink?: RenderLink;\n /** How many page numbers either side of the current one. Defaults to 1. */\n radius?: number;\n className?: string;\n}\n\n/**\n * {@link Pagination} as links, and a **server** component.\n *\n * Every page is an address: a crawler follows it, a visitor middle-clicks it,\n * and the page works with JavaScript off. `Pagination` steps through a\n * callback and carries a `'use client'`, which a server-rendered listing\n * cannot use without turning its pager into a client boundary for no gain.\n *\n * Previous and Next stay in the layout at the edges, as text marked\n * `aria-disabled` rather than as links that go nowhere.\n */\nexport function PaginationLinks({\n currentPage,\n pageSize,\n totalItems,\n hrefFor,\n t,\n renderLink = renderAnchor,\n radius,\n className,\n}: PaginationLinksProps) {\n const totalPages = pageCount(totalItems, pageSize);\n const step = button({ variant: 'secondary', size: 'sm' });\n const number = button({ variant: 'ghost', size: 'sm' });\n const unavailable = cn(step, 'pointer-events-none opacity-50');\n\n const edge = (page: number, label: string, enabled: boolean) =>\n enabled ? (\n renderLink({\n href: hrefFor(page),\n className: step,\n children: label,\n intent: { kind: 'page', page },\n })\n ) : (\n <span aria-disabled=\"true\" className={unavailable}>\n {label}\n </span>\n );\n\n return (\n <nav\n aria-label={t('pagination.label')}\n className={cn(\n 'flex flex-wrap items-center justify-between gap-2xs',\n className,\n )}\n >\n <Text step={-1} tone=\"muted\">\n {t('pagination.summary', {\n current: currentPage,\n total: totalPages,\n items: totalItems,\n })}\n </Text>\n\n <ol className=\"flex flex-wrap items-center gap-3xs\">\n <li>\n {edge(currentPage - 1, t('pagination.previous'), currentPage > 1)}\n </li>\n {pageWindow(currentPage, totalPages, radius).map(entry =>\n entry.kind === 'gap' ? (\n <li key={`gap-${entry.after}`} aria-hidden=\"true\">\n <Text tone=\"muted\">{GAP}</Text>\n </li>\n ) : (\n <li key={entry.page}>\n {entry.page === currentPage ? (\n <span\n aria-current=\"page\"\n className={button({ variant: 'primary', size: 'sm' })}\n >\n {entry.page}\n </span>\n ) : (\n renderLink({\n href: hrefFor(entry.page),\n className: number,\n children: String(entry.page),\n intent: { kind: 'page', page: entry.page },\n })\n )}\n </li>\n ),\n )}\n <li>\n {edge(\n currentPage + 1,\n t('pagination.next'),\n currentPage < totalPages,\n )}\n </li>\n </ol>\n </nav>\n );\n}\n"],"names":["renderAnchor","button","Text","cn","pageCount","pageWindow","GAP","PaginationLinks","currentPage","pageSize","totalItems","hrefFor","t","renderLink","radius","className","totalPages","step","variant","size","number","unavailable","edge","page","label","enabled","href","children","intent","kind","span","aria-disabled","nav","aria-label","tone","current","total","items","ol","li","map","entry","aria-hidden","after","aria-current","String"],"mappings":";AACA,SAASA,YAAY,QAAyB,+BAA+B;AAC7E,SAASC,MAAM,QAAQ,sCAAsC;AAC7D,SAASC,IAAI,QAAQ,4BAA4B;AACjD,SAASC,EAAE,QAAQ,oBAAoB;AACvC,SAASC,SAAS,EAAEC,UAAU,QAAQ,iBAAiB;AAEvD,iFAAiF,GACjF,MAAMC,MAAM;AAqBZ;;;;;;;;;;CAUC,GACD,OAAO,SAASC,gBAAgB,EAC9BC,WAAW,EACXC,QAAQ,EACRC,UAAU,EACVC,OAAO,EACPC,CAAC,EACDC,aAAab,YAAY,EACzBc,MAAM,EACNC,SAAS,EACY;IACrB,MAAMC,aAAaZ,UAAUM,YAAYD;IACzC,MAAMQ,OAAOhB,OAAO;QAAEiB,SAAS;QAAaC,MAAM;IAAK;IACvD,MAAMC,SAASnB,OAAO;QAAEiB,SAAS;QAASC,MAAM;IAAK;IACrD,MAAME,cAAclB,GAAGc,MAAM;IAE7B,MAAMK,OAAO,CAACC,MAAcC,OAAeC,UACzCA,UACEZ,WAAW;YACTa,MAAMf,QAAQY;YACdR,WAAWE;YACXU,UAAUH;YACVI,QAAQ;gBAAEC,MAAM;gBAAQN;YAAK;QAC/B,mBAEA,KAACO;YAAKC,iBAAc;YAAOhB,WAAWM;sBACnCG;;IAIP,qBACE,MAACQ;QACCC,cAAYrB,EAAE;QACdG,WAAWZ,GACT,uDACAY;;0BAGF,KAACb;gBAAKe,MAAM,CAAC;gBAAGiB,MAAK;0BAClBtB,EAAE,sBAAsB;oBACvBuB,SAAS3B;oBACT4B,OAAOpB;oBACPqB,OAAO3B;gBACT;;0BAGF,MAAC4B;gBAAGvB,WAAU;;kCACZ,KAACwB;kCACEjB,KAAKd,cAAc,GAAGI,EAAE,wBAAwBJ,cAAc;;oBAEhEH,WAAWG,aAAaQ,YAAYF,QAAQ0B,GAAG,CAACC,CAAAA,QAC/CA,MAAMZ,IAAI,KAAK,sBACb,KAACU;4BAA8BG,eAAY;sCACzC,cAAA,KAACxC;gCAAKgC,MAAK;0CAAS5B;;2BADb,CAAC,IAAI,EAAEmC,MAAME,KAAK,EAAE,kBAI7B,KAACJ;sCACEE,MAAMlB,IAAI,KAAKf,4BACd,KAACsB;gCACCc,gBAAa;gCACb7B,WAAWd,OAAO;oCAAEiB,SAAS;oCAAWC,MAAM;gCAAK;0CAElDsB,MAAMlB,IAAI;iCAGbV,WAAW;gCACTa,MAAMf,QAAQ8B,MAAMlB,IAAI;gCACxBR,WAAWK;gCACXO,UAAUkB,OAAOJ,MAAMlB,IAAI;gCAC3BK,QAAQ;oCAAEC,MAAM;oCAAQN,MAAMkB,MAAMlB,IAAI;gCAAC;4BAC3C;2BAdKkB,MAAMlB,IAAI;kCAmBvB,KAACgB;kCACEjB,KACCd,cAAc,GACdI,EAAE,oBACFJ,cAAcQ;;;;;;AAM1B"}
@@ -8,6 +8,11 @@ export interface PaginationProps {
8
8
  pageSizeOptions?: readonly number[];
9
9
  className?: string;
10
10
  }
11
- /** Page stepper plus an optional page-size selector. */
11
+ /**
12
+ * Page stepper plus an optional page-size selector, driven by callbacks.
13
+ *
14
+ * For a listing whose pages are addresses (server-rendered, crawlable, working
15
+ * without JavaScript) use {@link PaginationLinks} instead.
16
+ */
12
17
  export declare function Pagination({ currentPage, pageSize, totalItems, onPageChange, onPageSizeChange, pageSizeOptions, className, }: PaginationProps): import("react").JSX.Element;
13
18
  //# sourceMappingURL=pagination.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pagination.d.ts","sourceRoot":"","sources":["../../../../src/ui/molecules/pagination/pagination.tsx"],"names":[],"mappings":"AAQA,eAAO,MAAM,yBAAyB,4BAA6B,CAAC;AAEpE,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C,eAAe,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wDAAwD;AACxD,wBAAgB,UAAU,CAAC,EACzB,WAAW,EACX,QAAQ,EACR,UAAU,EACV,YAAY,EACZ,gBAAgB,EAChB,eAA2C,EAC3C,SAAS,GACV,EAAE,eAAe,+BAwDjB"}
1
+ {"version":3,"file":"pagination.d.ts","sourceRoot":"","sources":["../../../../src/ui/molecules/pagination/pagination.tsx"],"names":[],"mappings":"AASA,eAAO,MAAM,yBAAyB,4BAA6B,CAAC;AAEpE,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C,eAAe,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,EACzB,WAAW,EACX,QAAQ,EACR,UAAU,EACV,YAAY,EACZ,gBAAgB,EAChB,eAA2C,EAC3C,SAAS,GACV,EAAE,eAAe,+BAwDjB"}
@@ -5,15 +5,21 @@ import { Button } from '../../atoms/button/index.js';
5
5
  import { Select } from '../../atoms/field/index.js';
6
6
  import { Text } from '../../atoms/text/index.js';
7
7
  import { cn } from '../../utils/cn.js';
8
+ import { pageCount } from './page-math.js';
8
9
  export const DEFAULT_PAGE_SIZE_OPTIONS = [
9
10
  10,
10
11
  25,
11
12
  50,
12
13
  100
13
14
  ];
14
- /** Page stepper plus an optional page-size selector. */ export function Pagination({ currentPage, pageSize, totalItems, onPageChange, onPageSizeChange, pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, className }) {
15
+ /**
16
+ * Page stepper plus an optional page-size selector, driven by callbacks.
17
+ *
18
+ * For a listing whose pages are addresses (server-rendered, crawlable, working
19
+ * without JavaScript) use {@link PaginationLinks} instead.
20
+ */ export function Pagination({ currentPage, pageSize, totalItems, onPageChange, onPageSizeChange, pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, className }) {
15
21
  const t = useT();
16
- const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
22
+ const totalPages = pageCount(totalItems, pageSize);
17
23
  return /*#__PURE__*/ _jsxs("div", {
18
24
  className: cn('flex flex-wrap items-center justify-between gap-2xs', className),
19
25
  children: [
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../src/ui/molecules/pagination/pagination.tsx"],"sourcesContent":["'use client';\n\nimport { useT } from '../../../i18n/index.js';\nimport { Button } from '../../atoms/button/index.js';\nimport { Select } from '../../atoms/field/index.js';\nimport { Text } from '../../atoms/text/index.js';\nimport { cn } from '../../utils/cn.js';\n\nexport const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const;\n\nexport interface PaginationProps {\n currentPage: number;\n pageSize: number;\n totalItems: number;\n onPageChange: (page: number) => void;\n onPageSizeChange?: (pageSize: number) => void;\n pageSizeOptions?: readonly number[];\n className?: string;\n}\n\n/** Page stepper plus an optional page-size selector. */\nexport function Pagination({\n currentPage,\n pageSize,\n totalItems,\n onPageChange,\n onPageSizeChange,\n pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,\n className,\n}: PaginationProps) {\n const t = useT();\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n\n return (\n <div\n className={cn(\n 'flex flex-wrap items-center justify-between gap-2xs',\n className,\n )}\n >\n <Text step={-1} tone=\"muted\">\n {t('pagination.summary', {\n current: currentPage,\n total: totalPages,\n items: totalItems,\n })}\n </Text>\n\n <div className=\"flex items-center gap-2xs\">\n {onPageSizeChange && (\n <label className=\"flex items-center gap-3xs text-step-sm text-content-muted\">\n {t('pagination.rows')}\n <Select\n value={pageSize}\n onChange={event => onPageSizeChange(Number(event.target.value))}\n >\n {pageSizeOptions.map(option => (\n <option key={option} value={option}>\n {option}\n </option>\n ))}\n </Select>\n </label>\n )}\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n disabled={currentPage <= 1}\n onClick={() => onPageChange(currentPage - 1)}\n >\n {t('pagination.previous')}\n </Button>\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n disabled={currentPage >= totalPages}\n onClick={() => onPageChange(currentPage + 1)}\n >\n {t('pagination.next')}\n </Button>\n </div>\n </div>\n );\n}\n"],"names":["useT","Button","Select","Text","cn","DEFAULT_PAGE_SIZE_OPTIONS","Pagination","currentPage","pageSize","totalItems","onPageChange","onPageSizeChange","pageSizeOptions","className","t","totalPages","Math","max","ceil","div","step","tone","current","total","items","label","value","onChange","event","Number","target","map","option","type","variant","size","disabled","onClick"],"mappings":"AAAA;;AAEA,SAASA,IAAI,QAAQ,yBAAyB;AAC9C,SAASC,MAAM,QAAQ,8BAA8B;AACrD,SAASC,MAAM,QAAQ,6BAA6B;AACpD,SAASC,IAAI,QAAQ,4BAA4B;AACjD,SAASC,EAAE,QAAQ,oBAAoB;AAEvC,OAAO,MAAMC,4BAA4B;IAAC;IAAI;IAAI;IAAI;CAAI,CAAU;AAYpE,sDAAsD,GACtD,OAAO,SAASC,WAAW,EACzBC,WAAW,EACXC,QAAQ,EACRC,UAAU,EACVC,YAAY,EACZC,gBAAgB,EAChBC,kBAAkBP,yBAAyB,EAC3CQ,SAAS,EACO;IAChB,MAAMC,IAAId;IACV,MAAMe,aAAaC,KAAKC,GAAG,CAAC,GAAGD,KAAKE,IAAI,CAACT,aAAaD;IAEtD,qBACE,MAACW;QACCN,WAAWT,GACT,uDACAS;;0BAGF,KAACV;gBAAKiB,MAAM,CAAC;gBAAGC,MAAK;0BAClBP,EAAE,sBAAsB;oBACvBQ,SAASf;oBACTgB,OAAOR;oBACPS,OAAOf;gBACT;;0BAGF,MAACU;gBAAIN,WAAU;;oBACZF,kCACC,MAACc;wBAAMZ,WAAU;;4BACdC,EAAE;0CACH,KAACZ;gCACCwB,OAAOlB;gCACPmB,UAAUC,CAAAA,QAASjB,iBAAiBkB,OAAOD,MAAME,MAAM,CAACJ,KAAK;0CAE5Dd,gBAAgBmB,GAAG,CAACC,CAAAA,uBACnB,KAACA;wCAAoBN,OAAOM;kDACzBA;uCADUA;;;;kCAOrB,KAAC/B;wBACCgC,MAAK;wBACLC,SAAQ;wBACRC,MAAK;wBACLC,UAAU7B,eAAe;wBACzB8B,SAAS,IAAM3B,aAAaH,cAAc;kCAEzCO,EAAE;;kCAEL,KAACb;wBACCgC,MAAK;wBACLC,SAAQ;wBACRC,MAAK;wBACLC,UAAU7B,eAAeQ;wBACzBsB,SAAS,IAAM3B,aAAaH,cAAc;kCAEzCO,EAAE;;;;;;AAKb"}
1
+ {"version":3,"sources":["../../../../src/ui/molecules/pagination/pagination.tsx"],"sourcesContent":["'use client';\n\nimport { useT } from '../../../i18n/index.js';\nimport { Button } from '../../atoms/button/index.js';\nimport { Select } from '../../atoms/field/index.js';\nimport { Text } from '../../atoms/text/index.js';\nimport { cn } from '../../utils/cn.js';\nimport { pageCount } from './page-math.js';\n\nexport const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const;\n\nexport interface PaginationProps {\n currentPage: number;\n pageSize: number;\n totalItems: number;\n onPageChange: (page: number) => void;\n onPageSizeChange?: (pageSize: number) => void;\n pageSizeOptions?: readonly number[];\n className?: string;\n}\n\n/**\n * Page stepper plus an optional page-size selector, driven by callbacks.\n *\n * For a listing whose pages are addresses (server-rendered, crawlable, working\n * without JavaScript) use {@link PaginationLinks} instead.\n */\nexport function Pagination({\n currentPage,\n pageSize,\n totalItems,\n onPageChange,\n onPageSizeChange,\n pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,\n className,\n}: PaginationProps) {\n const t = useT();\n const totalPages = pageCount(totalItems, pageSize);\n\n return (\n <div\n className={cn(\n 'flex flex-wrap items-center justify-between gap-2xs',\n className,\n )}\n >\n <Text step={-1} tone=\"muted\">\n {t('pagination.summary', {\n current: currentPage,\n total: totalPages,\n items: totalItems,\n })}\n </Text>\n\n <div className=\"flex items-center gap-2xs\">\n {onPageSizeChange && (\n <label className=\"flex items-center gap-3xs text-step-sm text-content-muted\">\n {t('pagination.rows')}\n <Select\n value={pageSize}\n onChange={event => onPageSizeChange(Number(event.target.value))}\n >\n {pageSizeOptions.map(option => (\n <option key={option} value={option}>\n {option}\n </option>\n ))}\n </Select>\n </label>\n )}\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n disabled={currentPage <= 1}\n onClick={() => onPageChange(currentPage - 1)}\n >\n {t('pagination.previous')}\n </Button>\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n disabled={currentPage >= totalPages}\n onClick={() => onPageChange(currentPage + 1)}\n >\n {t('pagination.next')}\n </Button>\n </div>\n </div>\n );\n}\n"],"names":["useT","Button","Select","Text","cn","pageCount","DEFAULT_PAGE_SIZE_OPTIONS","Pagination","currentPage","pageSize","totalItems","onPageChange","onPageSizeChange","pageSizeOptions","className","t","totalPages","div","step","tone","current","total","items","label","value","onChange","event","Number","target","map","option","type","variant","size","disabled","onClick"],"mappings":"AAAA;;AAEA,SAASA,IAAI,QAAQ,yBAAyB;AAC9C,SAASC,MAAM,QAAQ,8BAA8B;AACrD,SAASC,MAAM,QAAQ,6BAA6B;AACpD,SAASC,IAAI,QAAQ,4BAA4B;AACjD,SAASC,EAAE,QAAQ,oBAAoB;AACvC,SAASC,SAAS,QAAQ,iBAAiB;AAE3C,OAAO,MAAMC,4BAA4B;IAAC;IAAI;IAAI;IAAI;CAAI,CAAU;AAYpE;;;;;CAKC,GACD,OAAO,SAASC,WAAW,EACzBC,WAAW,EACXC,QAAQ,EACRC,UAAU,EACVC,YAAY,EACZC,gBAAgB,EAChBC,kBAAkBP,yBAAyB,EAC3CQ,SAAS,EACO;IAChB,MAAMC,IAAIf;IACV,MAAMgB,aAAaX,UAAUK,YAAYD;IAEzC,qBACE,MAACQ;QACCH,WAAWV,GACT,uDACAU;;0BAGF,KAACX;gBAAKe,MAAM,CAAC;gBAAGC,MAAK;0BAClBJ,EAAE,sBAAsB;oBACvBK,SAASZ;oBACTa,OAAOL;oBACPM,OAAOZ;gBACT;;0BAGF,MAACO;gBAAIH,WAAU;;oBACZF,kCACC,MAACW;wBAAMT,WAAU;;4BACdC,EAAE;0CACH,KAACb;gCACCsB,OAAOf;gCACPgB,UAAUC,CAAAA,QAASd,iBAAiBe,OAAOD,MAAME,MAAM,CAACJ,KAAK;0CAE5DX,gBAAgBgB,GAAG,CAACC,CAAAA,uBACnB,KAACA;wCAAoBN,OAAOM;kDACzBA;uCADUA;;;;kCAOrB,KAAC7B;wBACC8B,MAAK;wBACLC,SAAQ;wBACRC,MAAK;wBACLC,UAAU1B,eAAe;wBACzB2B,SAAS,IAAMxB,aAAaH,cAAc;kCAEzCO,EAAE;;kCAEL,KAACd;wBACC8B,MAAK;wBACLC,SAAQ;wBACRC,MAAK;wBACLC,UAAU1B,eAAeQ;wBACzBmB,SAAS,IAAMxB,aAAaH,cAAc;kCAEzCO,EAAE;;;;;;AAKb"}
@@ -4,7 +4,7 @@ import type { EntityDetailGridProps } from './entity-detail-grid.types.js';
4
4
  *
5
5
  * A `composition` — an order and its lines — where the rows have no life outside
6
6
  * the master and go out in the same write
7
- * ([ADR 0034](../../../../../../../docs/adr/0034-composition-metadata.md)). Not
7
+ *. Not
8
8
  * a `linkCollection`, which is association to records that exist on their own
9
9
  * and save separately; that editor is still #26.
10
10
  *
@@ -37,7 +37,7 @@ import type { EntityDetailGridProps } from './entity-detail-grid.types.js';
37
37
  * **one** summary above it, which is the only `role="alert"` in the component.
38
38
  * A live region per cell would announce the whole grid on every keystroke, and
39
39
  * a message rendered only in the cell is unreachable when the failing row is
40
- * scrolled out — which is the case #110 names.
40
+ * scrolled out — the case a long grid always meets.
41
41
  */
42
42
  export declare function EntityDetailGrid({ descriptor, rows, onRowsChange, errors, editing, isLoading, skeleton, footer, className, }: EntityDetailGridProps): import("react").JSX.Element;
43
43
  //# sourceMappingURL=entity-detail-grid.d.ts.map
@@ -16,7 +16,7 @@ import { cn } from '../../utils/cn.js';
16
16
  *
17
17
  * A `composition` — an order and its lines — where the rows have no life outside
18
18
  * the master and go out in the same write
19
- * ([ADR 0034](../../../../../../../docs/adr/0034-composition-metadata.md)). Not
19
+ *. Not
20
20
  * a `linkCollection`, which is association to records that exist on their own
21
21
  * and save separately; that editor is still #26.
22
22
  *
@@ -49,7 +49,7 @@ import { cn } from '../../utils/cn.js';
49
49
  * **one** summary above it, which is the only `role="alert"` in the component.
50
50
  * A live region per cell would announce the whole grid on every keystroke, and
51
51
  * a message rendered only in the cell is unreachable when the failing row is
52
- * scrolled out — which is the case #110 names.
52
+ * scrolled out — the case a long grid always meets.
53
53
  */ export function EntityDetailGrid({ descriptor, rows, onRowsChange, errors, editing = false, isLoading = false, skeleton = true, footer, className }) {
54
54
  var _columns_;
55
55
  const t = useT();
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../src/ui/organisms/entity-detail-grid/entity-detail-grid.tsx"],"sourcesContent":["'use client';\n\nimport {\n describeChildColumns,\n editableChildColumns,\n type EntityFieldDescriptor,\n type EntityRowDraft,\n newRowKey,\n parseRowFieldPath,\n ROW_KEY,\n rowFieldPath,\n} from '@entifix/core';\nimport { type KeyboardEvent, useId, useMemo } from 'react';\n\nimport { useLocalizedDescriptors, useT } from '../../../i18n/index.js';\nimport { Button } from '../../atoms/button/index.js';\nimport { FieldControl } from '../../atoms/field-control/index.js';\nimport { Skeleton } from '../../atoms/skeleton/index.js';\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeaderCell,\n TableMessageRow,\n TableRow,\n} from '../../atoms/table/index.js';\nimport { Stack } from '../../molecules/stack/index.js';\nimport { cn } from '../../utils/cn.js';\nimport type { EntityDetailGridProps } from './entity-detail-grid.types.js';\n\n/** Rows the placeholder stands in for. Enough to read as a grid, not a page. */\nconst SKELETON_ROW_COUNT = 3;\n\n/**\n * The rows a record **owns**, edited in place.\n *\n * A `composition` — an order and its lines — where the rows have no life outside\n * the master and go out in the same write\n * ([ADR 0034](../../../../../../../docs/adr/0034-composition-metadata.md)). Not\n * a `linkCollection`, which is association to records that exist on their own\n * and save separately; that editor is still #26.\n *\n * ## Why this is not `EntityTable`\n *\n * `EntityTable` is a *server* listing: pages, RSQL filtering and sorting,\n * column personalization, a selection model and a bulk bar. A detail grid has\n * none of those and cannot have them — its rows are local, unsaved, and\n * unqueryable by construction, since a collection member is never `filterable`.\n * Sharing the organism would mean every one of those features growing a\n * \"detail\" branch. It shares the `Table` **atoms** instead, which is the level\n * the two actually have in common.\n *\n * ## Inline cells, and why Enter is free\n *\n * Each cell is a {@link FieldControl}, the same descriptor→input map the form\n * uses, so a child's `enum` gets a select and its `date` a date input with no\n * code here. `Tab` walks the row natively and crosses into the next; `Enter` on\n * the last row appends and focuses the new row's first cell.\n *\n * That last binding is available only because `EntityForm` renders a `<Card>`\n * with a `<button onClick>` Save rather than a `<form>` — inside a real form,\n * `Enter` in a text input submits, and appending a row would fight the browser\n * for it. Wrapping the form in a `<form>` element later would break this, which\n * is the one thing worth knowing before doing it.\n *\n * ## Errors\n *\n * A cell carries `aria-invalid` and points at its own message; the grid carries\n * **one** summary above it, which is the only `role=\"alert\"` in the component.\n * A live region per cell would announce the whole grid on every keystroke, and\n * a message rendered only in the cell is unreachable when the failing row is\n * scrolled out — which is the case #110 names.\n */\nexport function EntityDetailGrid({\n descriptor,\n rows,\n onRowsChange,\n errors,\n editing = false,\n isLoading = false,\n skeleton = true,\n footer,\n className,\n}: EntityDetailGridProps) {\n const t = useT();\n const gridId = useId();\n\n const described = useMemo(\n () =>\n descriptor.childType === undefined\n ? []\n : editableChildColumns(describeChildColumns(descriptor.childType)),\n [descriptor.childType],\n );\n const columns = useLocalizedDescriptors(described);\n\n // Only the messages that name *this* member. The form hands over its whole\n // error map so a record with two owned collections needs no bookkeeping at\n // the call site, and a grid that filtered nothing would show the other's.\n const rowErrors = useMemo(\n () => readRowErrors(descriptor.name, errors),\n [descriptor.name, errors],\n );\n\n const showSkeleton = isLoading && skeleton !== false;\n const customSkeleton = typeof skeleton === 'boolean' ? undefined : skeleton;\n // One more than the columns: every row carries its remove control.\n const columnCount = columns.length + (editing ? 1 : 0);\n\n const setRow = (index: number, member: string, value: string) => {\n onRowsChange(\n rows.map((row, position) =>\n position === index ? { ...row, [member]: value } : row,\n ),\n );\n };\n\n const firstColumn = columns[0]?.name;\n\n const appendRow = () => {\n onRowsChange([...rows, seedEmptyRow(columns)]);\n focusCell(gridId, rows.length, firstColumn);\n };\n\n const removeRow = (index: number) => {\n onRowsChange(rows.filter((_row, position) => position !== index));\n // Focus the row that slid into this position, or the one above it when the\n // removed row was last — never nothing, which drops the user to the page.\n focusCell(gridId, Math.min(index, rows.length - 2), firstColumn);\n };\n\n /**\n * `Enter` anywhere in the **last row** appends the next one.\n *\n * Bound to the row rather than to its final cell, which is what it looked\n * like it should be: the last *column* is whatever the child declared last,\n * and a read-only member there renders a disabled input that never receives a\n * key at all — so the binding would exist and be unreachable, on exactly the\n * entities that have a computed column. The row is the unit the user is\n * finishing anyway.\n *\n * Only from a text input: a `<select>` opens on Enter and a checkbox toggles\n * on Space, so taking the key from those would break a control's own\n * behaviour to add a row the user did not ask for. A read-only column needs\n * no test of its own — its input is `disabled`, so it never takes focus and\n * never raises a key here at all.\n */\n const onLastRowKeyDown = (event: KeyboardEvent<HTMLTableRowElement>) => {\n if (event.key !== 'Enter') return;\n if (!(event.target instanceof HTMLInputElement)) return;\n event.preventDefault();\n appendRow();\n };\n\n return (\n <Stack gap=\"2xs\" className={className}>\n <div className=\"flex items-center justify-between gap-s\">\n <span className=\"text-step-sm font-medium text-content-muted\">\n {descriptor.label}\n </span>\n {editing && (\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n onClick={appendRow}\n disabled={isLoading}\n >\n {t('detail.addRow')}\n </Button>\n )}\n </div>\n\n {editing && rowErrors.size > 0 && (\n <div role=\"alert\" className=\"text-step-sm text-danger\">\n {t('detail.errorSummary', { count: rowErrors.size })}\n </div>\n )}\n\n <Table\n aria-label={descriptor.label}\n aria-busy={isLoading || undefined}\n aria-rowcount={rows.length}\n >\n <TableHead>\n <TableRow>\n {columns.map(column => (\n <TableHeaderCell\n key={column.name}\n id={`${gridId}-${column.name}`}\n >\n {column.label}\n </TableHeaderCell>\n ))}\n {editing && (\n <TableHeaderCell align=\"end\">\n <span className=\"sr-only\">{t('detail.rowActions')}</span>\n </TableHeaderCell>\n )}\n </TableRow>\n </TableHead>\n\n <TableBody>\n {showSkeleton\n ? (customSkeleton ?? (\n <SkeletonRows\n columns={columns.length}\n extra={editing ? 1 : 0}\n />\n ))\n : rows.map((row, index) => (\n <TableRow\n key={row[ROW_KEY]}\n onKeyDown={\n editing && index === rows.length - 1\n ? onLastRowKeyDown\n : undefined\n }\n >\n {columns.map(column => {\n const error = rowErrors.get(\n rowFieldPath(descriptor.name, index, column.name),\n );\n const cellId = `${gridId}-${index}-${column.name}`;\n return (\n <TableCell key={column.name}>\n {editing ? (\n <FieldControl\n descriptor={column}\n value={row[column.name] ?? ''}\n id={cellId}\n aria-labelledby={`${gridId}-${column.name}`}\n aria-invalid={error !== undefined || undefined}\n aria-describedby={\n error === undefined\n ? undefined\n : `${cellId}-error`\n }\n onChange={next => setRow(index, column.name, next)}\n />\n ) : (\n <span>{row[column.name] ?? ''}</span>\n )}\n {editing && error !== undefined && (\n <span\n id={`${cellId}-error`}\n className=\"mt-3xs block text-step-sm text-danger\"\n >\n {error}\n </span>\n )}\n </TableCell>\n );\n })}\n {editing && (\n <TableCell align=\"end\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => removeRow(index)}\n >\n {t('detail.removeRow', { row: index + 1 })}\n </Button>\n </TableCell>\n )}\n </TableRow>\n ))}\n\n {!showSkeleton && rows.length === 0 && (\n <TableMessageRow colSpan={Math.max(columnCount, 1)}>\n {t('detail.empty')}\n </TableMessageRow>\n )}\n </TableBody>\n </Table>\n\n {footer !== undefined && !showSkeleton && (\n <div className={cn('flex justify-end text-step-sm text-content')}>\n {footer(rows)}\n </div>\n )}\n </Stack>\n );\n}\n\n/** The placeholder, shaped like the grid it stands in for. */\nfunction SkeletonRows({ columns, extra }: { columns: number; extra: number }) {\n return (\n <>\n {Array.from({ length: SKELETON_ROW_COUNT }, (_row, index) => (\n <TableRow key={index}>\n {Array.from({ length: columns + extra }, (_cell, cellIndex) => (\n <TableCell key={cellIndex}>\n <Skeleton className=\"h-8 w-full\" />\n </TableCell>\n ))}\n </TableRow>\n ))}\n </>\n );\n}\n\n/**\n * A blank row, seeded with every column the grid edits.\n *\n * Every member is present and empty rather than absent, for the reason a form's\n * seed carries every key: a control handed `undefined` flips from controlled to\n * uncontrolled the moment the user types into it.\n */\nfunction seedEmptyRow(\n columns: readonly EntityFieldDescriptor[],\n): EntityRowDraft {\n const row: EntityRowDraft = { [ROW_KEY]: newRowKey() };\n for (const column of columns) row[column.name] = '';\n return row;\n}\n\n/**\n * Moves focus into a cell after the list changed.\n *\n * By element id rather than through a ref into the `<tbody>`, because the row\n * being focused does not exist yet when this is called — it is the row the\n * caller has just asked React to render. Deferring to a microtask lets that\n * render commit first; `getElementById` then finds the input the same way the\n * cell's own `aria-describedby` does.\n *\n * A row index that names nothing simply focuses nothing, which is the right\n * outcome for removing the only row: there is no cell to land in, and stealing\n * focus somewhere arbitrary is worse than leaving it where the user put it.\n */\nfunction focusCell(gridId: string, index: number, column?: string): void {\n if (column === undefined || index < 0) return;\n\n queueMicrotask(() => {\n document.getElementById(`${gridId}-${index}-${column}`)?.focus();\n });\n}\n\n/** The messages naming a row of `member`, keyed by their full path. */\nfunction readRowErrors(\n member: string,\n errors: Record<string, string> | undefined,\n): Map<string, string> {\n const found = new Map<string, string>();\n if (errors === undefined) return found;\n\n for (const [path, message] of Object.entries(errors)) {\n if (parseRowFieldPath(path)?.member === member) found.set(path, message);\n }\n return found;\n}\n"],"names":["describeChildColumns","editableChildColumns","newRowKey","parseRowFieldPath","ROW_KEY","rowFieldPath","useId","useMemo","useLocalizedDescriptors","useT","Button","FieldControl","Skeleton","Table","TableBody","TableCell","TableHead","TableHeaderCell","TableMessageRow","TableRow","Stack","cn","SKELETON_ROW_COUNT","EntityDetailGrid","descriptor","rows","onRowsChange","errors","editing","isLoading","skeleton","footer","className","columns","t","gridId","described","childType","undefined","rowErrors","readRowErrors","name","showSkeleton","customSkeleton","columnCount","length","setRow","index","member","value","map","row","position","firstColumn","appendRow","seedEmptyRow","focusCell","removeRow","filter","_row","Math","min","onLastRowKeyDown","event","key","target","HTMLInputElement","preventDefault","gap","div","span","label","type","variant","size","onClick","disabled","role","count","aria-label","aria-busy","aria-rowcount","column","id","align","SkeletonRows","extra","onKeyDown","error","get","cellId","aria-labelledby","aria-invalid","aria-describedby","onChange","next","colSpan","max","Array","from","_cell","cellIndex","queueMicrotask","document","getElementById","focus","found","Map","path","message","Object","entries","set"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../src/ui/organisms/entity-detail-grid/entity-detail-grid.tsx"],"sourcesContent":["'use client';\n\nimport {\n describeChildColumns,\n editableChildColumns,\n type EntityFieldDescriptor,\n type EntityRowDraft,\n newRowKey,\n parseRowFieldPath,\n ROW_KEY,\n rowFieldPath,\n} from '@entifix/core';\nimport { type KeyboardEvent, useId, useMemo } from 'react';\n\nimport { useLocalizedDescriptors, useT } from '../../../i18n/index.js';\nimport { Button } from '../../atoms/button/index.js';\nimport { FieldControl } from '../../atoms/field-control/index.js';\nimport { Skeleton } from '../../atoms/skeleton/index.js';\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeaderCell,\n TableMessageRow,\n TableRow,\n} from '../../atoms/table/index.js';\nimport { Stack } from '../../molecules/stack/index.js';\nimport { cn } from '../../utils/cn.js';\nimport type { EntityDetailGridProps } from './entity-detail-grid.types.js';\n\n/** Rows the placeholder stands in for. Enough to read as a grid, not a page. */\nconst SKELETON_ROW_COUNT = 3;\n\n/**\n * The rows a record **owns**, edited in place.\n *\n * A `composition` — an order and its lines — where the rows have no life outside\n * the master and go out in the same write\n *. Not\n * a `linkCollection`, which is association to records that exist on their own\n * and save separately; that editor is still #26.\n *\n * ## Why this is not `EntityTable`\n *\n * `EntityTable` is a *server* listing: pages, RSQL filtering and sorting,\n * column personalization, a selection model and a bulk bar. A detail grid has\n * none of those and cannot have them — its rows are local, unsaved, and\n * unqueryable by construction, since a collection member is never `filterable`.\n * Sharing the organism would mean every one of those features growing a\n * \"detail\" branch. It shares the `Table` **atoms** instead, which is the level\n * the two actually have in common.\n *\n * ## Inline cells, and why Enter is free\n *\n * Each cell is a {@link FieldControl}, the same descriptor→input map the form\n * uses, so a child's `enum` gets a select and its `date` a date input with no\n * code here. `Tab` walks the row natively and crosses into the next; `Enter` on\n * the last row appends and focuses the new row's first cell.\n *\n * That last binding is available only because `EntityForm` renders a `<Card>`\n * with a `<button onClick>` Save rather than a `<form>` — inside a real form,\n * `Enter` in a text input submits, and appending a row would fight the browser\n * for it. Wrapping the form in a `<form>` element later would break this, which\n * is the one thing worth knowing before doing it.\n *\n * ## Errors\n *\n * A cell carries `aria-invalid` and points at its own message; the grid carries\n * **one** summary above it, which is the only `role=\"alert\"` in the component.\n * A live region per cell would announce the whole grid on every keystroke, and\n * a message rendered only in the cell is unreachable when the failing row is\n * scrolled out — the case a long grid always meets.\n */\nexport function EntityDetailGrid({\n descriptor,\n rows,\n onRowsChange,\n errors,\n editing = false,\n isLoading = false,\n skeleton = true,\n footer,\n className,\n}: EntityDetailGridProps) {\n const t = useT();\n const gridId = useId();\n\n const described = useMemo(\n () =>\n descriptor.childType === undefined\n ? []\n : editableChildColumns(describeChildColumns(descriptor.childType)),\n [descriptor.childType],\n );\n const columns = useLocalizedDescriptors(described);\n\n // Only the messages that name *this* member. The form hands over its whole\n // error map so a record with two owned collections needs no bookkeeping at\n // the call site, and a grid that filtered nothing would show the other's.\n const rowErrors = useMemo(\n () => readRowErrors(descriptor.name, errors),\n [descriptor.name, errors],\n );\n\n const showSkeleton = isLoading && skeleton !== false;\n const customSkeleton = typeof skeleton === 'boolean' ? undefined : skeleton;\n // One more than the columns: every row carries its remove control.\n const columnCount = columns.length + (editing ? 1 : 0);\n\n const setRow = (index: number, member: string, value: string) => {\n onRowsChange(\n rows.map((row, position) =>\n position === index ? { ...row, [member]: value } : row,\n ),\n );\n };\n\n const firstColumn = columns[0]?.name;\n\n const appendRow = () => {\n onRowsChange([...rows, seedEmptyRow(columns)]);\n focusCell(gridId, rows.length, firstColumn);\n };\n\n const removeRow = (index: number) => {\n onRowsChange(rows.filter((_row, position) => position !== index));\n // Focus the row that slid into this position, or the one above it when the\n // removed row was last — never nothing, which drops the user to the page.\n focusCell(gridId, Math.min(index, rows.length - 2), firstColumn);\n };\n\n /**\n * `Enter` anywhere in the **last row** appends the next one.\n *\n * Bound to the row rather than to its final cell, which is what it looked\n * like it should be: the last *column* is whatever the child declared last,\n * and a read-only member there renders a disabled input that never receives a\n * key at all — so the binding would exist and be unreachable, on exactly the\n * entities that have a computed column. The row is the unit the user is\n * finishing anyway.\n *\n * Only from a text input: a `<select>` opens on Enter and a checkbox toggles\n * on Space, so taking the key from those would break a control's own\n * behaviour to add a row the user did not ask for. A read-only column needs\n * no test of its own — its input is `disabled`, so it never takes focus and\n * never raises a key here at all.\n */\n const onLastRowKeyDown = (event: KeyboardEvent<HTMLTableRowElement>) => {\n if (event.key !== 'Enter') return;\n if (!(event.target instanceof HTMLInputElement)) return;\n event.preventDefault();\n appendRow();\n };\n\n return (\n <Stack gap=\"2xs\" className={className}>\n <div className=\"flex items-center justify-between gap-s\">\n <span className=\"text-step-sm font-medium text-content-muted\">\n {descriptor.label}\n </span>\n {editing && (\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n onClick={appendRow}\n disabled={isLoading}\n >\n {t('detail.addRow')}\n </Button>\n )}\n </div>\n\n {editing && rowErrors.size > 0 && (\n <div role=\"alert\" className=\"text-step-sm text-danger\">\n {t('detail.errorSummary', { count: rowErrors.size })}\n </div>\n )}\n\n <Table\n aria-label={descriptor.label}\n aria-busy={isLoading || undefined}\n aria-rowcount={rows.length}\n >\n <TableHead>\n <TableRow>\n {columns.map(column => (\n <TableHeaderCell\n key={column.name}\n id={`${gridId}-${column.name}`}\n >\n {column.label}\n </TableHeaderCell>\n ))}\n {editing && (\n <TableHeaderCell align=\"end\">\n <span className=\"sr-only\">{t('detail.rowActions')}</span>\n </TableHeaderCell>\n )}\n </TableRow>\n </TableHead>\n\n <TableBody>\n {showSkeleton\n ? (customSkeleton ?? (\n <SkeletonRows\n columns={columns.length}\n extra={editing ? 1 : 0}\n />\n ))\n : rows.map((row, index) => (\n <TableRow\n key={row[ROW_KEY]}\n onKeyDown={\n editing && index === rows.length - 1\n ? onLastRowKeyDown\n : undefined\n }\n >\n {columns.map(column => {\n const error = rowErrors.get(\n rowFieldPath(descriptor.name, index, column.name),\n );\n const cellId = `${gridId}-${index}-${column.name}`;\n return (\n <TableCell key={column.name}>\n {editing ? (\n <FieldControl\n descriptor={column}\n value={row[column.name] ?? ''}\n id={cellId}\n aria-labelledby={`${gridId}-${column.name}`}\n aria-invalid={error !== undefined || undefined}\n aria-describedby={\n error === undefined\n ? undefined\n : `${cellId}-error`\n }\n onChange={next => setRow(index, column.name, next)}\n />\n ) : (\n <span>{row[column.name] ?? ''}</span>\n )}\n {editing && error !== undefined && (\n <span\n id={`${cellId}-error`}\n className=\"mt-3xs block text-step-sm text-danger\"\n >\n {error}\n </span>\n )}\n </TableCell>\n );\n })}\n {editing && (\n <TableCell align=\"end\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => removeRow(index)}\n >\n {t('detail.removeRow', { row: index + 1 })}\n </Button>\n </TableCell>\n )}\n </TableRow>\n ))}\n\n {!showSkeleton && rows.length === 0 && (\n <TableMessageRow colSpan={Math.max(columnCount, 1)}>\n {t('detail.empty')}\n </TableMessageRow>\n )}\n </TableBody>\n </Table>\n\n {footer !== undefined && !showSkeleton && (\n <div className={cn('flex justify-end text-step-sm text-content')}>\n {footer(rows)}\n </div>\n )}\n </Stack>\n );\n}\n\n/** The placeholder, shaped like the grid it stands in for. */\nfunction SkeletonRows({ columns, extra }: { columns: number; extra: number }) {\n return (\n <>\n {Array.from({ length: SKELETON_ROW_COUNT }, (_row, index) => (\n <TableRow key={index}>\n {Array.from({ length: columns + extra }, (_cell, cellIndex) => (\n <TableCell key={cellIndex}>\n <Skeleton className=\"h-8 w-full\" />\n </TableCell>\n ))}\n </TableRow>\n ))}\n </>\n );\n}\n\n/**\n * A blank row, seeded with every column the grid edits.\n *\n * Every member is present and empty rather than absent, for the reason a form's\n * seed carries every key: a control handed `undefined` flips from controlled to\n * uncontrolled the moment the user types into it.\n */\nfunction seedEmptyRow(\n columns: readonly EntityFieldDescriptor[],\n): EntityRowDraft {\n const row: EntityRowDraft = { [ROW_KEY]: newRowKey() };\n for (const column of columns) row[column.name] = '';\n return row;\n}\n\n/**\n * Moves focus into a cell after the list changed.\n *\n * By element id rather than through a ref into the `<tbody>`, because the row\n * being focused does not exist yet when this is called — it is the row the\n * caller has just asked React to render. Deferring to a microtask lets that\n * render commit first; `getElementById` then finds the input the same way the\n * cell's own `aria-describedby` does.\n *\n * A row index that names nothing simply focuses nothing, which is the right\n * outcome for removing the only row: there is no cell to land in, and stealing\n * focus somewhere arbitrary is worse than leaving it where the user put it.\n */\nfunction focusCell(gridId: string, index: number, column?: string): void {\n if (column === undefined || index < 0) return;\n\n queueMicrotask(() => {\n document.getElementById(`${gridId}-${index}-${column}`)?.focus();\n });\n}\n\n/** The messages naming a row of `member`, keyed by their full path. */\nfunction readRowErrors(\n member: string,\n errors: Record<string, string> | undefined,\n): Map<string, string> {\n const found = new Map<string, string>();\n if (errors === undefined) return found;\n\n for (const [path, message] of Object.entries(errors)) {\n if (parseRowFieldPath(path)?.member === member) found.set(path, message);\n }\n return found;\n}\n"],"names":["describeChildColumns","editableChildColumns","newRowKey","parseRowFieldPath","ROW_KEY","rowFieldPath","useId","useMemo","useLocalizedDescriptors","useT","Button","FieldControl","Skeleton","Table","TableBody","TableCell","TableHead","TableHeaderCell","TableMessageRow","TableRow","Stack","cn","SKELETON_ROW_COUNT","EntityDetailGrid","descriptor","rows","onRowsChange","errors","editing","isLoading","skeleton","footer","className","columns","t","gridId","described","childType","undefined","rowErrors","readRowErrors","name","showSkeleton","customSkeleton","columnCount","length","setRow","index","member","value","map","row","position","firstColumn","appendRow","seedEmptyRow","focusCell","removeRow","filter","_row","Math","min","onLastRowKeyDown","event","key","target","HTMLInputElement","preventDefault","gap","div","span","label","type","variant","size","onClick","disabled","role","count","aria-label","aria-busy","aria-rowcount","column","id","align","SkeletonRows","extra","onKeyDown","error","get","cellId","aria-labelledby","aria-invalid","aria-describedby","onChange","next","colSpan","max","Array","from","_cell","cellIndex","queueMicrotask","document","getElementById","focus","found","Map","path","message","Object","entries","set"],"mappings":"AAAA;;;AAEA,SACEA,oBAAoB,EACpBC,oBAAoB,EAGpBC,SAAS,EACTC,iBAAiB,EACjBC,OAAO,EACPC,YAAY,QACP,gBAAgB;AACvB,SAA6BC,KAAK,EAAEC,OAAO,QAAQ,QAAQ;AAE3D,SAASC,uBAAuB,EAAEC,IAAI,QAAQ,yBAAyB;AACvE,SAASC,MAAM,QAAQ,8BAA8B;AACrD,SAASC,YAAY,QAAQ,qCAAqC;AAClE,SAASC,QAAQ,QAAQ,gCAAgC;AACzD,SACEC,KAAK,EACLC,SAAS,EACTC,SAAS,EACTC,SAAS,EACTC,eAAe,EACfC,eAAe,EACfC,QAAQ,QACH,6BAA6B;AACpC,SAASC,KAAK,QAAQ,iCAAiC;AACvD,SAASC,EAAE,QAAQ,oBAAoB;AAGvC,8EAA8E,GAC9E,MAAMC,qBAAqB;AAE3B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuCC,GACD,OAAO,SAASC,iBAAiB,EAC/BC,UAAU,EACVC,IAAI,EACJC,YAAY,EACZC,MAAM,EACNC,UAAU,KAAK,EACfC,YAAY,KAAK,EACjBC,WAAW,IAAI,EACfC,MAAM,EACNC,SAAS,EACa;QAkCFC;IAjCpB,MAAMC,IAAIzB;IACV,MAAM0B,SAAS7B;IAEf,MAAM8B,YAAY7B,QAChB,IACEiB,WAAWa,SAAS,KAAKC,YACrB,EAAE,GACFrC,qBAAqBD,qBAAqBwB,WAAWa,SAAS,IACpE;QAACb,WAAWa,SAAS;KAAC;IAExB,MAAMJ,UAAUzB,wBAAwB4B;IAExC,2EAA2E;IAC3E,2EAA2E;IAC3E,0EAA0E;IAC1E,MAAMG,YAAYhC,QAChB,IAAMiC,cAAchB,WAAWiB,IAAI,EAAEd,SACrC;QAACH,WAAWiB,IAAI;QAAEd;KAAO;IAG3B,MAAMe,eAAeb,aAAaC,aAAa;IAC/C,MAAMa,iBAAiB,OAAOb,aAAa,YAAYQ,YAAYR;IACnE,mEAAmE;IACnE,MAAMc,cAAcX,QAAQY,MAAM,GAAIjB,CAAAA,UAAU,IAAI,CAAA;IAEpD,MAAMkB,SAAS,CAACC,OAAeC,QAAgBC;QAC7CvB,aACED,KAAKyB,GAAG,CAAC,CAACC,KAAKC,WACbA,aAAaL,QAAQ,aAAKI;gBAAK,CAACH,OAAO,EAAEC;iBAAUE;IAGzD;IAEA,MAAME,eAAcpB,YAAAA,OAAO,CAAC,EAAE,qBAAVA,UAAYQ,IAAI;IAEpC,MAAMa,YAAY;QAChB5B,aAAa;eAAID;YAAM8B,aAAatB;SAAS;QAC7CuB,UAAUrB,QAAQV,KAAKoB,MAAM,EAAEQ;IACjC;IAEA,MAAMI,YAAY,CAACV;QACjBrB,aAAaD,KAAKiC,MAAM,CAAC,CAACC,MAAMP,WAAaA,aAAaL;QAC1D,2EAA2E;QAC3E,0EAA0E;QAC1ES,UAAUrB,QAAQyB,KAAKC,GAAG,CAACd,OAAOtB,KAAKoB,MAAM,GAAG,IAAIQ;IACtD;IAEA;;;;;;;;;;;;;;;GAeC,GACD,MAAMS,mBAAmB,CAACC;QACxB,IAAIA,MAAMC,GAAG,KAAK,SAAS;QAC3B,IAAI,CAAED,CAAAA,MAAME,MAAM,YAAYC,gBAAe,GAAI;QACjDH,MAAMI,cAAc;QACpBb;IACF;IAEA,qBACE,MAAClC;QAAMgD,KAAI;QAAMpC,WAAWA;;0BAC1B,MAACqC;gBAAIrC,WAAU;;kCACb,KAACsC;wBAAKtC,WAAU;kCACbR,WAAW+C,KAAK;;oBAElB3C,yBACC,KAAClB;wBACC8D,MAAK;wBACLC,SAAQ;wBACRC,MAAK;wBACLC,SAASrB;wBACTsB,UAAU/C;kCAETK,EAAE;;;;YAKRN,WAAWW,UAAUmC,IAAI,GAAG,mBAC3B,KAACL;gBAAIQ,MAAK;gBAAQ7C,WAAU;0BACzBE,EAAE,uBAAuB;oBAAE4C,OAAOvC,UAAUmC,IAAI;gBAAC;;0BAItD,MAAC7D;gBACCkE,cAAYvD,WAAW+C,KAAK;gBAC5BS,aAAWnD,aAAaS;gBACxB2C,iBAAexD,KAAKoB,MAAM;;kCAE1B,KAAC7B;kCACC,cAAA,MAACG;;gCACEc,QAAQiB,GAAG,CAACgC,CAAAA,uBACX,KAACjE;wCAECkE,IAAI,GAAGhD,OAAO,CAAC,EAAE+C,OAAOzC,IAAI,EAAE;kDAE7ByC,OAAOX,KAAK;uCAHRW,OAAOzC,IAAI;gCAMnBb,yBACC,KAACX;oCAAgBmE,OAAM;8CACrB,cAAA,KAACd;wCAAKtC,WAAU;kDAAWE,EAAE;;;;;;kCAMrC,MAACpB;;4BACE4B,eACIC,yBAAAA,+BACC,KAAC0C;gCACCpD,SAASA,QAAQY,MAAM;gCACvByC,OAAO1D,UAAU,IAAI;iCAGzBH,KAAKyB,GAAG,CAAC,CAACC,KAAKJ,sBACb,MAAC5B;oCAECoE,WACE3D,WAAWmB,UAAUtB,KAAKoB,MAAM,GAAG,IAC/BiB,mBACAxB;;wCAGLL,QAAQiB,GAAG,CAACgC,CAAAA;gDAUI/B,kBAYFA;4CArBb,MAAMqC,QAAQjD,UAAUkD,GAAG,CACzBpF,aAAamB,WAAWiB,IAAI,EAAEM,OAAOmC,OAAOzC,IAAI;4CAElD,MAAMiD,SAAS,GAAGvD,OAAO,CAAC,EAAEY,MAAM,CAAC,EAAEmC,OAAOzC,IAAI,EAAE;4CAClD,qBACE,MAAC1B;;oDACEa,wBACC,KAACjB;wDACCa,YAAY0D;wDACZjC,KAAK,GAAEE,mBAAAA,GAAG,CAAC+B,OAAOzC,IAAI,CAAC,YAAhBU,mBAAoB;wDAC3BgC,IAAIO;wDACJC,mBAAiB,GAAGxD,OAAO,CAAC,EAAE+C,OAAOzC,IAAI,EAAE;wDAC3CmD,gBAAcJ,UAAUlD,aAAaA;wDACrCuD,oBACEL,UAAUlD,YACNA,YACA,GAAGoD,OAAO,MAAM,CAAC;wDAEvBI,UAAUC,CAAAA,OAAQjD,OAAOC,OAAOmC,OAAOzC,IAAI,EAAEsD;uEAG/C,KAACzB;mEAAMnB,oBAAAA,GAAG,CAAC+B,OAAOzC,IAAI,CAAC,YAAhBU,oBAAoB;;oDAE5BvB,WAAW4D,UAAUlD,2BACpB,KAACgC;wDACCa,IAAI,GAAGO,OAAO,MAAM,CAAC;wDACrB1D,WAAU;kEAETwD;;;+CAvBSN,OAAOzC,IAAI;wCA4B/B;wCACCb,yBACC,KAACb;4CAAUqE,OAAM;sDACf,cAAA,KAAC1E;gDACC8D,MAAK;gDACLC,SAAQ;gDACRC,MAAK;gDACLC,SAAS,IAAMlB,UAAUV;0DAExBb,EAAE,oBAAoB;oDAAEiB,KAAKJ,QAAQ;gDAAE;;;;mCAlDzCI,GAAG,CAAC/C,QAAQ;4BAyDxB,CAACsC,gBAAgBjB,KAAKoB,MAAM,KAAK,mBAChC,KAAC3B;gCAAgB8E,SAASpC,KAAKqC,GAAG,CAACrD,aAAa;0CAC7CV,EAAE;;;;;;YAMVH,WAAWO,aAAa,CAACI,8BACxB,KAAC2B;gBAAIrC,WAAWX,GAAG;0BAChBU,OAAON;;;;AAKlB;AAEA,4DAA4D,GAC5D,SAAS4D,aAAa,EAAEpD,OAAO,EAAEqD,KAAK,EAAsC;IAC1E,qBACE;kBACGY,MAAMC,IAAI,CAAC;YAAEtD,QAAQvB;QAAmB,GAAG,CAACqC,MAAMZ,sBACjD,KAAC5B;0BACE+E,MAAMC,IAAI,CAAC;oBAAEtD,QAAQZ,UAAUqD;gBAAM,GAAG,CAACc,OAAOC,0BAC/C,KAACtF;kCACC,cAAA,KAACH;4BAASoB,WAAU;;uBADNqE;eAFLtD;;AAUvB;AAEA;;;;;;CAMC,GACD,SAASQ,aACPtB,OAAyC;IAEzC,MAAMkB,MAAsB;QAAE,CAAC/C,QAAQ,EAAEF;IAAY;IACrD,KAAK,MAAMgF,UAAUjD,QAASkB,GAAG,CAAC+B,OAAOzC,IAAI,CAAC,GAAG;IACjD,OAAOU;AACT;AAEA;;;;;;;;;;;;CAYC,GACD,SAASK,UAAUrB,MAAc,EAAEY,KAAa,EAAEmC,MAAe;IAC/D,IAAIA,WAAW5C,aAAaS,QAAQ,GAAG;IAEvCuD,eAAe;YACbC;SAAAA,2BAAAA,SAASC,cAAc,CAAC,GAAGrE,OAAO,CAAC,EAAEY,MAAM,CAAC,EAAEmC,QAAQ,sBAAtDqB,yBAAyDE,KAAK;IAChE;AACF;AAEA,qEAAqE,GACrE,SAASjE,cACPQ,MAAc,EACdrB,MAA0C;IAE1C,MAAM+E,QAAQ,IAAIC;IAClB,IAAIhF,WAAWW,WAAW,OAAOoE;IAEjC,KAAK,MAAM,CAACE,MAAMC,QAAQ,IAAIC,OAAOC,OAAO,CAACpF,QAAS;YAChDxB;QAAJ,IAAIA,EAAAA,qBAAAA,kBAAkByG,0BAAlBzG,mBAAyB6C,MAAM,MAAKA,QAAQ0D,MAAMM,GAAG,CAACJ,MAAMC;IAClE;IACA,OAAOH;AACT"}
@@ -27,8 +27,8 @@ import { resolveEntityFormFields } from './use-entity-form-fields.js';
27
27
  * have to be a typed relation: when the target lives in **another slice's
28
28
  * store**, an `EntityLink` would be an illegal edge and a storage-layer join
29
29
  * across a store boundary, so the member is a plain id and the relation exists
30
- * only in the operator's head (ADR 0022, and `ProductSpecification.brandId` is
31
- * the live case). Nothing about the editor cares which of the two it is —
30
+ * only in the operator's head — a cross-store reference is an id, never a
31
+ * link. Nothing about the editor cares which of the two it is —
32
32
  * `EntityLinkInput` writes `String(target.id)` into the draft either way, and
33
33
  * the *only* place `link`-ness still matters is `applyEntityLinks` at submit,
34
34
  * which skips a non-`link` descriptor and so leaves the id as the truth.
@@ -408,7 +408,7 @@ linkSources) {
408
408
  }
409
409
  const owned = sourced.filter((field)=>field.type === 'composition').map((field)=>field.name);
410
410
  if (owned.length > 0) {
411
- throw new EntifixLogicError(`EntityForm was given a link source for an owned collection: ${owned.join(', ')}. ` + 'A `composition` is not a reference — its rows have no life outside this ' + 'record and are never picked from existing ones, so there is nothing for a ' + 'picker to look up. The detail grid that edits them is #122.', undefined, {
411
+ throw new EntifixLogicError(`EntityForm was given a link source for an owned collection: ${owned.join(', ')}. ` + 'A `composition` is not a reference — its rows have no life outside this ' + 'record and are never picked from existing ones, so there is nothing for a ' + 'picker to look up. The detail grid is what edits them.', undefined, {
412
412
  fields: owned
413
413
  });
414
414
  }
@@ -432,7 +432,7 @@ linkSources) {
432
432
  descriptor: field
433
433
  });
434
434
  // Members with no editor of their own here: a to-one `link` whose picker was
435
- // never wired, a `linkCollection` (#26) and a `composition` (#122). All three
435
+ // never wired, a `linkCollection` (#26) and a `composition`. All three
436
436
  // fall back to the read display, which is honest — a disabled text box
437
437
  // holding `[object Object]` is not.
438
438
  const isRelation = field.type === 'link' || field.type === 'linkCollection' || field.type === 'composition';