@open-mercato/ui 0.6.5-develop.5337.1.534b781eac → 0.6.5
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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/ai/AiChat.js.map +1 -1
- package/dist/ai/records/ActivityCard.js +1 -6
- package/dist/ai/records/ActivityCard.js.map +2 -2
- package/dist/ai/records/DealCard.js +2 -23
- package/dist/ai/records/DealCard.js.map +2 -2
- package/dist/ai/records/ProductCard.js +2 -17
- package/dist/ai/records/ProductCard.js.map +2 -2
- package/dist/ai/useAiChat.js.map +1 -1
- package/dist/backend/AppShell.js +15 -8
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js.map +1 -1
- package/dist/backend/DataTable.js +10 -9
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/ProfileDropdown.js +62 -33
- package/dist/backend/ProfileDropdown.js.map +2 -2
- package/dist/backend/charts/KpiCard.js +22 -14
- package/dist/backend/charts/KpiCard.js.map +2 -2
- package/dist/backend/charts/Sparkline.js +75 -0
- package/dist/backend/charts/Sparkline.js.map +7 -0
- package/dist/backend/charts/index.js +4 -1
- package/dist/backend/charts/index.js.map +2 -2
- package/dist/backend/dashboard/DashboardScreen.js +1 -2
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/dist/backend/injection/InjectionSpot.js +23 -6
- package/dist/backend/injection/InjectionSpot.js.map +2 -2
- package/dist/backend/injection/useGuardedMutation.js.map +1 -1
- package/dist/backend/utils/optimisticLock.js.map +1 -1
- package/dist/primitives/avatar.js +2 -2
- package/dist/primitives/avatar.js.map +2 -2
- package/dist/primitives/pagination.js +3 -3
- package/dist/primitives/pagination.js.map +2 -2
- package/dist/utils/format.js +27 -0
- package/dist/utils/format.js.map +7 -0
- package/package.json +9 -6
- package/src/ai/AiChat.tsx +1 -1
- package/src/ai/__tests__/AiChat.task-plan.test.tsx +1 -1
- package/src/ai/records/ActivityCard.tsx +1 -7
- package/src/ai/records/DealCard.tsx +2 -26
- package/src/ai/records/ProductCard.tsx +2 -19
- package/src/ai/useAiChat.ts +1 -1
- package/src/backend/AppShell.tsx +18 -8
- package/src/backend/CrudForm.tsx +1 -1
- package/src/backend/DataTable.tsx +18 -11
- package/src/backend/ProfileDropdown.tsx +35 -3
- package/src/backend/__tests__/AppShell.test.tsx +61 -1
- package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +52 -0
- package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +99 -0
- package/src/backend/__tests__/ProfileDropdown.test.tsx +59 -0
- package/src/backend/__tests__/lazy-heavy-libraries.test.ts +8 -0
- package/src/backend/charts/KpiCard.tsx +22 -12
- package/src/backend/charts/Sparkline.tsx +87 -0
- package/src/backend/charts/index.ts +2 -1
- package/src/backend/dashboard/DashboardScreen.tsx +1 -2
- package/src/backend/injection/InjectionSpot.tsx +27 -7
- package/src/backend/injection/__tests__/InjectionSpot.remount.test.tsx +140 -0
- package/src/backend/injection/useGuardedMutation.ts +1 -1
- package/src/backend/utils/optimisticLock.ts +1 -1
- package/src/primitives/__tests__/alert.test.tsx +16 -0
- package/src/primitives/__tests__/avatar.test.tsx +36 -1
- package/src/primitives/__tests__/pagination.test.tsx +31 -0
- package/src/primitives/avatar.tsx +8 -2
- package/src/primitives/pagination.tsx +3 -3
- package/src/utils/__tests__/format.test.ts +58 -0
- package/src/utils/format.ts +40 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/primitives/pagination.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport {\n ChevronLeft,\n ChevronRight,\n ChevronsLeft,\n ChevronsRight,\n} from 'lucide-react'\nimport { cva, type VariantProps } from 'class-variance-authority'\n\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n CompactSelectTrigger,\n Select,\n SelectContent,\n SelectItem,\n SelectValue,\n} from './compact-select'\n\n/**\n * Page navigation primitive per Figma `Pagination Group [1.1]` (DS Open\n * Mercato componentSet `199985:4135`).\n *\n * Layout (Figma `Basic` variant):\n *\n * [Left] \"Page 2 of 16\"\n * [Center] \u23EE \u25C0 [1][2]\u2026[N-1][N] \u25B6 \u23ED\n * [Right] \"7 / page\" CompactSelect\n *\n * Figma's three boolean variant props are mapped 1:1 to React props:\n *\n * \uD83E\uDD47 First / Last \u2192 `showFirstLast` (default true)\n * \u23ED\uFE0F Next / Previous \u2192 `showPrevNext` (default true)\n * \uD83E\uDDEA Advanced \u2192 `showInfo` + `showPageSize` (default both true)\n *\n * The numeric page list uses the same ellipsis algorithm Material /\n * MUI / shadcn ship: `boundaryCount` pages at each end (default 1),\n * `siblingCount` pages on either side of the current page (default 1),\n * collapse the rest into `\u2026` placeholders.\n *\n * ```tsx\n * const [page, setPage] = React.useState(1)\n * const [pageSize, setPageSize] = React.useState(25)\n * <Pagination\n * page={page}\n * pageSize={pageSize}\n * total={items.length}\n * onPageChange={setPage}\n * onPageSizeChange={setPageSize}\n * />\n *\n * // Compact (no first/last, no page-size select)\n * <Pagination\n * page={page}\n * pageSize={20}\n * total={120}\n * onPageChange={setPage}\n * showFirstLast={false}\n * showPageSize={false}\n * />\n * ```\n */\n\n/**\n * Build a stable list of pages + ellipsis placeholders that fits a\n * pagination row of `boundaryCount + 2 + siblingCount * 2 + 1` cells.\n * Returns `number` entries (1-indexed) and `'ellipsis-left'` /\n * `'ellipsis-right'` placeholders that the renderer paints as `\u2026`.\n *\n * Exported for tests + consumer reuse (e.g. a server-side rendered\n * pagination indicator that needs the same shape).\n */\nexport function buildPaginationItems(\n page: number,\n totalPages: number,\n siblingCount: number,\n boundaryCount: number,\n): Array<number | 'ellipsis-left' | 'ellipsis-right'> {\n if (totalPages <= 0) return []\n // When everything fits, just list every page.\n const totalSlots = boundaryCount * 2 + siblingCount * 2 + 3\n if (totalPages <= totalSlots) {\n return Array.from({ length: totalPages }, (_, i) => i + 1)\n }\n const startPages = Array.from({ length: boundaryCount }, (_, i) => i + 1)\n const endPages = Array.from(\n { length: boundaryCount },\n (_, i) => totalPages - boundaryCount + 1 + i,\n )\n const siblingStart = Math.max(\n Math.min(page - siblingCount, totalPages - boundaryCount - siblingCount * 2 - 1),\n boundaryCount + 2,\n )\n const siblingEnd = Math.min(\n Math.max(page + siblingCount, boundaryCount + siblingCount * 2 + 2),\n endPages.length > 0 ? endPages[0] - 2 : totalPages - 1,\n )\n const middle: Array<number> = []\n for (let i = siblingStart; i <= siblingEnd; i += 1) middle.push(i)\n\n // Bridge between the start boundary and the sibling window. The\n // ellipsis is only worth showing when \u22652 page numbers fall between\n // them; if exactly 1 page is missing, render that single number\n // instead of \"\u2026\" (cleaner UX \u2014 same width, no information loss).\n const result: Array<number | 'ellipsis-left' | 'ellipsis-right'> = []\n for (const p of startPages) result.push(p)\n if (siblingStart > boundaryCount + 2) {\n result.push('ellipsis-left')\n } else if (siblingStart === boundaryCount + 2) {\n result.push(boundaryCount + 1)\n }\n for (const p of middle) result.push(p)\n const firstEnd = endPages[0] ?? totalPages\n if (siblingEnd < firstEnd - 2) {\n result.push('ellipsis-right')\n } else if (siblingEnd === firstEnd - 2) {\n result.push(firstEnd - 1)\n }\n for (const p of endPages) result.push(p)\n return result\n}\n\nconst cellVariants = cva(\n 'inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-medium outline-none transition-colors tabular-nums ' +\n 'focus-visible:shadow-focus ' +\n 'disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n selected: {\n true: 'bg-muted text-foreground',\n false: 'bg-background text-foreground hover:bg-muted/40',\n },\n },\n defaultVariants: { selected: false },\n },\n)\n\nconst navButtonVariants = cva(\n 'inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors ' +\n 'hover:bg-muted/40 hover:text-foreground ' +\n 'focus-visible:shadow-focus ' +\n 'disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground',\n)\n\nexport type PaginationProps = React.HTMLAttributes<HTMLDivElement> & {\n /** Current 1-indexed page. */\n page: number\n /** Items per page. */\n pageSize: number\n /** Total item count. Used to derive `Math.ceil(total / pageSize)` pages. */\n total: number\n /** Called when the user changes page. */\n onPageChange: (next: number) => void\n /** Called when the user changes page size. Optional \u2014 when omitted,\n * the \"X / page\" select is hidden. */\n onPageSizeChange?: (next: number) => void\n /** Page size options for the select. Default `[10, 25, 50, 100]`. */\n pageSizeOptions?: readonly number[]\n /** Show the left \"Page X of Y\" indicator. Default `true`. */\n showInfo?: boolean\n /** Show the right \"X / page\" select. Default `true` when\n * `onPageSizeChange` is provided; ignored otherwise. */\n showPageSize?: boolean\n /** Show \u23EE / \u23ED first / last buttons. Default `true`. */\n showFirstLast?: boolean\n /** Show \u25C0 / \u25B6 prev / next buttons. Default `true`. */\n showPrevNext?: boolean\n /** Pages on either side of the current page in the page list. Default `1`. */\n siblingCount?: number\n /** Pages pinned at each end of the page list. Default `1`. */\n boundaryCount?: number\n /** Block all interactions. Default `false`. */\n disabled?: boolean\n /** ARIA label for the navigation landmark. Default `\"Pagination\"`. */\n 'aria-label'?: string\n /** Format the \"Page X of Y\" label. */\n formatPageInfo?: (page: number, totalPages: number) => string\n /** Format the \"X / page\" label. */\n formatPageSizeLabel?: (pageSize: number) => string\n}\n\nexport const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(\n (\n {\n className,\n page,\n pageSize,\n total,\n onPageChange,\n onPageSizeChange,\n pageSizeOptions = [10, 25, 50, 100],\n showInfo = true,\n showPageSize: showPageSizeProp,\n showFirstLast = true,\n showPrevNext = true,\n siblingCount = 1,\n boundaryCount = 1,\n disabled = false,\n formatPageInfo,\n formatPageSizeLabel,\n ...props\n },\n ref,\n ) => {\n const t = useT()\n const resolvedFormatPageInfo =\n formatPageInfo ??\n ((p: number, total: number) =>\n t('ui.pagination.info.pageOf', 'Page {page} of {total}', { page: p, total }))\n const resolvedFormatPageSizeLabel =\n formatPageSizeLabel ??\n ((size: number) =>\n t('ui.pagination.itemsPerPage.label', '{size} / page', { size }))\n const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)))\n const safePage = Math.min(Math.max(1, page), totalPages)\n const items = React.useMemo(\n () => buildPaginationItems(safePage, totalPages, siblingCount, boundaryCount),\n [safePage, totalPages, siblingCount, boundaryCount],\n )\n const showPageSize = showPageSizeProp ?? Boolean(onPageSizeChange)\n\n const goTo = React.useCallback(\n (next: number) => {\n if (disabled) return\n const bounded = Math.min(Math.max(1, next), totalPages)\n if (bounded !== safePage) onPageChange(bounded)\n },\n [disabled, onPageChange, safePage, totalPages],\n )\n\n return (\n <nav\n ref={ref}\n data-slot=\"pagination\"\n aria-label={props['aria-label'] ?? t('ui.pagination.landmark.ariaLabel', 'Pagination')}\n className={cn('flex w-full items-center justify-between gap-6', className)}\n {...props}\n >\n {showInfo ? (\n <div\n data-slot=\"pagination-info\"\n className=\"shrink-0 text-sm text-muted-foreground tabular-nums\"\n >\n {resolvedFormatPageInfo(safePage, totalPages)}\n </div>\n ) : (\n <div />\n )}\n\n <div\n data-slot=\"pagination-controls\"\n className=\"inline-flex items-center gap-2\"\n >\n {showFirstLast ? (\n <button\n type=\"button\"\n data-slot=\"pagination-first\"\n aria-label={t('ui.pagination.first.ariaLabel', 'First page')}\n disabled={disabled || safePage <= 1}\n onClick={() => goTo(1)}\n className={cn(navButtonVariants())}\n >\n <ChevronsLeft aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n {showPrevNext ? (\n <button\n type=\"button\"\n data-slot=\"pagination-prev\"\n aria-label={t('ui.pagination.previous.ariaLabel', 'Previous page')}\n disabled={disabled || safePage <= 1}\n onClick={() => goTo(safePage - 1)}\n className={cn(navButtonVariants())}\n >\n <ChevronLeft aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n\n <ol\n data-slot=\"pagination-pages\"\n className=\"inline-flex items-center gap-2 list-none\"\n >\n {items.map((entry, index) => {\n if (entry === 'ellipsis-left' || entry === 'ellipsis-right') {\n return (\n <li\n key={`${entry}-${index}`}\n data-slot=\"pagination-ellipsis\"\n aria-hidden=\"true\"\n className=\"inline-flex size-8 items-center justify-center text-sm text-muted-foreground\"\n >\n \u2026\n </li>\n )\n }\n const selected = entry === safePage\n return (\n <li key={entry}>\n <button\n type=\"button\"\n data-slot=\"pagination-page\"\n data-state={selected ? 'on' : 'off'}\n aria-current={selected ? 'page' : undefined}\n aria-label={\n selected\n ? t('ui.pagination.page.currentAriaLabel', 'Page {page}, current page', { page: entry })\n : t('ui.pagination.page.goToAriaLabel', 'Go to page {page}', { page: entry })\n }\n disabled={disabled}\n onClick={() => goTo(entry)}\n className={cn(cellVariants({ selected }))}\n >\n {entry}\n </button>\n </li>\n )\n })}\n </ol>\n\n {showPrevNext ? (\n <button\n type=\"button\"\n data-slot=\"pagination-next\"\n aria-label={t('ui.pagination.next.ariaLabel', 'Next page')}\n disabled={disabled || safePage >= totalPages}\n onClick={() => goTo(safePage + 1)}\n className={cn(navButtonVariants())}\n >\n <ChevronRight aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n {showFirstLast ? (\n <button\n type=\"button\"\n data-slot=\"pagination-last\"\n aria-label={t('ui.pagination.last.ariaLabel', 'Last page')}\n disabled={disabled || safePage >= totalPages}\n onClick={() => goTo(totalPages)}\n className={cn(navButtonVariants())}\n >\n <ChevronsRight aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n </div>\n\n {showPageSize && onPageSizeChange ? (\n <div data-slot=\"pagination-page-size\" className=\"shrink-0\">\n <Select\n value={String(pageSize)}\n onValueChange={(next) => onPageSizeChange(Number(next))}\n disabled={disabled}\n >\n <CompactSelectTrigger aria-label={t('ui.pagination.itemsPerPage.ariaLabel', 'Items per page')}>\n <SelectValue />\n </CompactSelectTrigger>\n <SelectContent>\n {pageSizeOptions.map((option) => (\n <SelectItem key={option} value={String(option)}>\n {resolvedFormatPageSizeLabel(option)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n ) : (\n <div />\n )}\n </nav>\n )\n },\n)\nPagination.displayName = 'Pagination'\n\nexport { cellVariants as paginationCellVariants, navButtonVariants as paginationNavVariants }\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport {\n ChevronLeft,\n ChevronRight,\n ChevronsLeft,\n ChevronsRight,\n} from 'lucide-react'\nimport { cva, type VariantProps } from 'class-variance-authority'\n\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n CompactSelectTrigger,\n Select,\n SelectContent,\n SelectItem,\n SelectValue,\n} from './compact-select'\n\n/**\n * Page navigation primitive per Figma `Pagination Group [1.1]` (DS Open\n * Mercato componentSet `199985:4135`).\n *\n * Layout (Figma `Basic` variant):\n *\n * [Left] \"Page 2 of 16\"\n * [Center] \u23EE \u25C0 [1][2]\u2026[N-1][N] \u25B6 \u23ED\n * [Right] \"7 / page\" CompactSelect\n *\n * Figma's three boolean variant props are mapped 1:1 to React props:\n *\n * \uD83E\uDD47 First / Last \u2192 `showFirstLast` (default true)\n * \u23ED\uFE0F Next / Previous \u2192 `showPrevNext` (default true)\n * \uD83E\uDDEA Advanced \u2192 `showInfo` + `showPageSize` (default both true)\n *\n * The numeric page list uses the same ellipsis algorithm Material /\n * MUI / shadcn ship: `boundaryCount` pages at each end (default 1),\n * `siblingCount` pages on either side of the current page (default 1),\n * collapse the rest into `\u2026` placeholders.\n *\n * ```tsx\n * const [page, setPage] = React.useState(1)\n * const [pageSize, setPageSize] = React.useState(25)\n * <Pagination\n * page={page}\n * pageSize={pageSize}\n * total={items.length}\n * onPageChange={setPage}\n * onPageSizeChange={setPageSize}\n * />\n *\n * // Compact (no first/last, no page-size select)\n * <Pagination\n * page={page}\n * pageSize={20}\n * total={120}\n * onPageChange={setPage}\n * showFirstLast={false}\n * showPageSize={false}\n * />\n * ```\n */\n\n/**\n * Build a stable list of pages + ellipsis placeholders that fits a\n * pagination row of `boundaryCount + 2 + siblingCount * 2 + 1` cells.\n * Returns `number` entries (1-indexed) and `'ellipsis-left'` /\n * `'ellipsis-right'` placeholders that the renderer paints as `\u2026`.\n *\n * Exported for tests + consumer reuse (e.g. a server-side rendered\n * pagination indicator that needs the same shape).\n */\nexport function buildPaginationItems(\n page: number,\n totalPages: number,\n siblingCount: number,\n boundaryCount: number,\n): Array<number | 'ellipsis-left' | 'ellipsis-right'> {\n if (totalPages <= 0) return []\n // When everything fits, just list every page.\n const totalSlots = boundaryCount * 2 + siblingCount * 2 + 3\n if (totalPages <= totalSlots) {\n return Array.from({ length: totalPages }, (_, i) => i + 1)\n }\n const startPages = Array.from({ length: boundaryCount }, (_, i) => i + 1)\n const endPages = Array.from(\n { length: boundaryCount },\n (_, i) => totalPages - boundaryCount + 1 + i,\n )\n const siblingStart = Math.max(\n Math.min(page - siblingCount, totalPages - boundaryCount - siblingCount * 2 - 1),\n boundaryCount + 2,\n )\n const siblingEnd = Math.min(\n Math.max(page + siblingCount, boundaryCount + siblingCount * 2 + 2),\n endPages.length > 0 ? endPages[0] - 2 : totalPages - 1,\n )\n const middle: Array<number> = []\n for (let i = siblingStart; i <= siblingEnd; i += 1) middle.push(i)\n\n // Bridge between the start boundary and the sibling window. The\n // ellipsis is only worth showing when \u22652 page numbers fall between\n // them; if exactly 1 page is missing, render that single number\n // instead of \"\u2026\" (cleaner UX \u2014 same width, no information loss).\n const result: Array<number | 'ellipsis-left' | 'ellipsis-right'> = []\n for (const p of startPages) result.push(p)\n if (siblingStart > boundaryCount + 2) {\n result.push('ellipsis-left')\n } else if (siblingStart === boundaryCount + 2) {\n result.push(boundaryCount + 1)\n }\n for (const p of middle) result.push(p)\n const firstEnd = endPages[0] ?? totalPages\n if (siblingEnd < firstEnd - 2) {\n result.push('ellipsis-right')\n } else if (siblingEnd === firstEnd - 2) {\n result.push(firstEnd - 1)\n }\n for (const p of endPages) result.push(p)\n return result\n}\n\nconst cellVariants = cva(\n 'inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-medium outline-none transition-colors tabular-nums ' +\n 'focus-visible:shadow-focus ' +\n 'disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n selected: {\n true: 'bg-muted text-foreground',\n false: 'bg-background text-foreground hover:bg-muted/40',\n },\n },\n defaultVariants: { selected: false },\n },\n)\n\nconst navButtonVariants = cva(\n 'inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors ' +\n 'hover:bg-muted/40 hover:text-foreground ' +\n 'focus-visible:shadow-focus ' +\n 'disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground',\n)\n\nexport type PaginationProps = React.HTMLAttributes<HTMLDivElement> & {\n /** Current 1-indexed page. */\n page: number\n /** Items per page. */\n pageSize: number\n /** Total item count. Used to derive `Math.ceil(total / pageSize)` pages. */\n total: number\n /** Called when the user changes page. */\n onPageChange: (next: number) => void\n /** Called when the user changes page size. Optional \u2014 when omitted,\n * the \"X / page\" select is hidden. */\n onPageSizeChange?: (next: number) => void\n /** Page size options for the select. Default `[10, 25, 50, 100]`. */\n pageSizeOptions?: readonly number[]\n /** Show the left \"Page X of Y\" indicator. Default `true`. */\n showInfo?: boolean\n /** Show the right \"X / page\" select. Default `true` when\n * `onPageSizeChange` is provided; ignored otherwise. */\n showPageSize?: boolean\n /** Show \u23EE / \u23ED first / last buttons. Default `true`. */\n showFirstLast?: boolean\n /** Show \u25C0 / \u25B6 prev / next buttons. Default `true`. */\n showPrevNext?: boolean\n /** Pages on either side of the current page in the page list. Default `1`. */\n siblingCount?: number\n /** Pages pinned at each end of the page list. Default `1`. */\n boundaryCount?: number\n /** Block all interactions. Default `false`. */\n disabled?: boolean\n /** ARIA label for the navigation landmark. Default `\"Pagination\"`. */\n 'aria-label'?: string\n /** Format the \"Page X of Y\" label. */\n formatPageInfo?: (page: number, totalPages: number) => string\n /** Format the \"X / page\" label. */\n formatPageSizeLabel?: (pageSize: number) => string\n}\n\nexport const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>(\n (\n {\n className,\n page,\n pageSize,\n total,\n onPageChange,\n onPageSizeChange,\n pageSizeOptions = [10, 25, 50, 100],\n showInfo = true,\n showPageSize: showPageSizeProp,\n showFirstLast = true,\n showPrevNext = true,\n siblingCount = 1,\n boundaryCount = 1,\n disabled = false,\n formatPageInfo,\n formatPageSizeLabel,\n ...props\n },\n ref,\n ) => {\n const t = useT()\n const resolvedFormatPageInfo =\n formatPageInfo ??\n ((p: number, total: number) =>\n t('ui.pagination.info.pageOf', 'Page {page} of {total}', { page: p, total }))\n const resolvedFormatPageSizeLabel =\n formatPageSizeLabel ??\n ((size: number) =>\n t('ui.pagination.itemsPerPage.label', '{size} / page', { size }))\n const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)))\n const safePage = Math.min(Math.max(1, page), totalPages)\n const items = React.useMemo(\n () => buildPaginationItems(safePage, totalPages, siblingCount, boundaryCount),\n [safePage, totalPages, siblingCount, boundaryCount],\n )\n const showPageSize = showPageSizeProp ?? Boolean(onPageSizeChange)\n\n const goTo = React.useCallback(\n (next: number) => {\n if (disabled) return\n const bounded = Math.min(Math.max(1, next), totalPages)\n if (bounded !== safePage) onPageChange(bounded)\n },\n [disabled, onPageChange, safePage, totalPages],\n )\n\n return (\n <nav\n ref={ref}\n data-slot=\"pagination\"\n aria-label={props['aria-label'] ?? t('ui.pagination.landmark.ariaLabel', 'Pagination')}\n className={cn('flex w-full flex-wrap items-center justify-between gap-x-6 gap-y-2', className)}\n {...props}\n >\n {showInfo ? (\n <div\n data-slot=\"pagination-info\"\n className=\"shrink-0 text-sm text-muted-foreground tabular-nums\"\n >\n {resolvedFormatPageInfo(safePage, totalPages)}\n </div>\n ) : (\n <div />\n )}\n\n <div\n data-slot=\"pagination-controls\"\n className=\"flex flex-wrap items-center justify-center gap-2\"\n >\n {showFirstLast ? (\n <button\n type=\"button\"\n data-slot=\"pagination-first\"\n aria-label={t('ui.pagination.first.ariaLabel', 'First page')}\n disabled={disabled || safePage <= 1}\n onClick={() => goTo(1)}\n className={cn(navButtonVariants())}\n >\n <ChevronsLeft aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n {showPrevNext ? (\n <button\n type=\"button\"\n data-slot=\"pagination-prev\"\n aria-label={t('ui.pagination.previous.ariaLabel', 'Previous page')}\n disabled={disabled || safePage <= 1}\n onClick={() => goTo(safePage - 1)}\n className={cn(navButtonVariants())}\n >\n <ChevronLeft aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n\n <ol\n data-slot=\"pagination-pages\"\n className=\"flex flex-wrap items-center justify-center gap-2 list-none\"\n >\n {items.map((entry, index) => {\n if (entry === 'ellipsis-left' || entry === 'ellipsis-right') {\n return (\n <li\n key={`${entry}-${index}`}\n data-slot=\"pagination-ellipsis\"\n aria-hidden=\"true\"\n className=\"inline-flex size-8 items-center justify-center text-sm text-muted-foreground\"\n >\n \u2026\n </li>\n )\n }\n const selected = entry === safePage\n return (\n <li key={entry}>\n <button\n type=\"button\"\n data-slot=\"pagination-page\"\n data-state={selected ? 'on' : 'off'}\n aria-current={selected ? 'page' : undefined}\n aria-label={\n selected\n ? t('ui.pagination.page.currentAriaLabel', 'Page {page}, current page', { page: entry })\n : t('ui.pagination.page.goToAriaLabel', 'Go to page {page}', { page: entry })\n }\n disabled={disabled}\n onClick={() => goTo(entry)}\n className={cn(cellVariants({ selected }))}\n >\n {entry}\n </button>\n </li>\n )\n })}\n </ol>\n\n {showPrevNext ? (\n <button\n type=\"button\"\n data-slot=\"pagination-next\"\n aria-label={t('ui.pagination.next.ariaLabel', 'Next page')}\n disabled={disabled || safePage >= totalPages}\n onClick={() => goTo(safePage + 1)}\n className={cn(navButtonVariants())}\n >\n <ChevronRight aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n {showFirstLast ? (\n <button\n type=\"button\"\n data-slot=\"pagination-last\"\n aria-label={t('ui.pagination.last.ariaLabel', 'Last page')}\n disabled={disabled || safePage >= totalPages}\n onClick={() => goTo(totalPages)}\n className={cn(navButtonVariants())}\n >\n <ChevronsRight aria-hidden=\"true\" className=\"size-5\" />\n </button>\n ) : null}\n </div>\n\n {showPageSize && onPageSizeChange ? (\n <div data-slot=\"pagination-page-size\" className=\"shrink-0\">\n <Select\n value={String(pageSize)}\n onValueChange={(next) => onPageSizeChange(Number(next))}\n disabled={disabled}\n >\n <CompactSelectTrigger aria-label={t('ui.pagination.itemsPerPage.ariaLabel', 'Items per page')}>\n <SelectValue />\n </CompactSelectTrigger>\n <SelectContent>\n {pageSizeOptions.map((option) => (\n <SelectItem key={option} value={String(option)}>\n {resolvedFormatPageSizeLabel(option)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n ) : (\n <div />\n )}\n </nav>\n )\n },\n)\nPagination.displayName = 'Pagination'\n\nexport { cellVariants as paginationCellVariants, navButtonVariants as paginationNavVariants }\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["total"]
|
|
7
7
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
function formatCurrency(value, currency) {
|
|
2
|
+
if (value === null || value === void 0 || value === "") return null;
|
|
3
|
+
const numeric = typeof value === "number" ? value : Number(value);
|
|
4
|
+
if (!Number.isFinite(numeric)) {
|
|
5
|
+
return typeof value === "string" ? value : null;
|
|
6
|
+
}
|
|
7
|
+
const code = currency && currency.length === 3 ? currency.toUpperCase() : void 0;
|
|
8
|
+
try {
|
|
9
|
+
if (code) {
|
|
10
|
+
return new Intl.NumberFormat(void 0, { style: "currency", currency: code }).format(numeric);
|
|
11
|
+
}
|
|
12
|
+
} catch {
|
|
13
|
+
}
|
|
14
|
+
const formatted = new Intl.NumberFormat().format(numeric);
|
|
15
|
+
return code ? `${formatted} ${code}` : formatted;
|
|
16
|
+
}
|
|
17
|
+
function formatDate(value) {
|
|
18
|
+
if (!value) return null;
|
|
19
|
+
const date = new Date(value);
|
|
20
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
21
|
+
return date.toLocaleDateString(void 0, { year: "numeric", month: "short", day: "numeric" });
|
|
22
|
+
}
|
|
23
|
+
export {
|
|
24
|
+
formatCurrency,
|
|
25
|
+
formatDate
|
|
26
|
+
};
|
|
27
|
+
//# sourceMappingURL=format.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/utils/format.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Format a monetary value with an optional ISO-4217 currency code.\n *\n * Returns `null` for empty input, the original string for non-numeric input,\n * and falls back to a plain number format (optionally suffixed with the code)\n * when the currency code is missing or rejected by `Intl.NumberFormat`.\n */\nexport function formatCurrency(\n value: string | number | null | undefined,\n currency?: string | null,\n): string | null {\n if (value === null || value === undefined || value === '') return null\n const numeric = typeof value === 'number' ? value : Number(value)\n if (!Number.isFinite(numeric)) {\n return typeof value === 'string' ? value : null\n }\n const code = currency && currency.length === 3 ? currency.toUpperCase() : undefined\n try {\n if (code) {\n return new Intl.NumberFormat(undefined, { style: 'currency', currency: code }).format(numeric)\n }\n } catch {\n // fall through to plain number formatting\n }\n const formatted = new Intl.NumberFormat().format(numeric)\n return code ? `${formatted} ${code}` : formatted\n}\n\n/**\n * Format an ISO date string as a localized short date (e.g. `Jun 9, 2026`).\n *\n * Returns `null` for empty input and echoes the original value back when it is\n * not a parseable date.\n */\nexport function formatDate(value: string | null | undefined): string | null {\n if (!value) return null\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return value\n return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })\n}\n"],
|
|
5
|
+
"mappings": "AAOO,SAAS,eACd,OACA,UACe;AACf,MAAI,UAAU,QAAQ,UAAU,UAAa,UAAU,GAAI,QAAO;AAClE,QAAM,UAAU,OAAO,UAAU,WAAW,QAAQ,OAAO,KAAK;AAChE,MAAI,CAAC,OAAO,SAAS,OAAO,GAAG;AAC7B,WAAO,OAAO,UAAU,WAAW,QAAQ;AAAA,EAC7C;AACA,QAAM,OAAO,YAAY,SAAS,WAAW,IAAI,SAAS,YAAY,IAAI;AAC1E,MAAI;AACF,QAAI,MAAM;AACR,aAAO,IAAI,KAAK,aAAa,QAAW,EAAE,OAAO,YAAY,UAAU,KAAK,CAAC,EAAE,OAAO,OAAO;AAAA,IAC/F;AAAA,EACF,QAAQ;AAAA,EAER;AACA,QAAM,YAAY,IAAI,KAAK,aAAa,EAAE,OAAO,OAAO;AACxD,SAAO,OAAO,GAAG,SAAS,IAAI,IAAI,KAAK;AACzC;AAQO,SAAS,WAAW,OAAiD;AAC1E,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,OAAO,IAAI,KAAK,KAAK;AAC3B,MAAI,OAAO,MAAM,KAAK,QAAQ,CAAC,EAAG,QAAO;AACzC,SAAO,KAAK,mBAAmB,QAAW,EAAE,MAAM,WAAW,OAAO,SAAS,KAAK,UAAU,CAAC;AAC/F;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.5
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -74,6 +74,10 @@
|
|
|
74
74
|
"types": "./src/backend/forms/index.ts",
|
|
75
75
|
"default": "./dist/backend/forms/index.js"
|
|
76
76
|
},
|
|
77
|
+
"./backend/markdown": {
|
|
78
|
+
"types": "./src/backend/markdown/index.ts",
|
|
79
|
+
"default": "./dist/backend/markdown/index.js"
|
|
80
|
+
},
|
|
77
81
|
"./backend/messages": {
|
|
78
82
|
"types": "./src/backend/messages/index.ts",
|
|
79
83
|
"default": "./dist/backend/messages/index.js"
|
|
@@ -131,7 +135,7 @@
|
|
|
131
135
|
"@dnd-kit/core": "^6.3.1",
|
|
132
136
|
"@dnd-kit/sortable": "^10.0.0",
|
|
133
137
|
"@dnd-kit/utilities": "^3.2.2",
|
|
134
|
-
"@mdxeditor/editor": "^4.0.
|
|
138
|
+
"@mdxeditor/editor": "^4.0.3",
|
|
135
139
|
"@radix-ui/react-accordion": "^1.2.13",
|
|
136
140
|
"@radix-ui/react-dialog": "^1.1.16",
|
|
137
141
|
"@radix-ui/react-popover": "^1.1.16",
|
|
@@ -150,13 +154,13 @@
|
|
|
150
154
|
"remark-gfm": "^4.0.1"
|
|
151
155
|
},
|
|
152
156
|
"peerDependencies": {
|
|
153
|
-
"@open-mercato/shared": "0.6.5
|
|
157
|
+
"@open-mercato/shared": "0.6.5",
|
|
154
158
|
"react": ">=18.0.0",
|
|
155
159
|
"react-dom": ">=18.0.0",
|
|
156
160
|
"react-is": ">=18.0.0"
|
|
157
161
|
},
|
|
158
162
|
"devDependencies": {
|
|
159
|
-
"@open-mercato/shared": "0.6.5
|
|
163
|
+
"@open-mercato/shared": "0.6.5",
|
|
160
164
|
"@testing-library/dom": "^10.4.1",
|
|
161
165
|
"@testing-library/jest-dom": "^6.9.1",
|
|
162
166
|
"@testing-library/react": "^16.3.1",
|
|
@@ -177,6 +181,5 @@
|
|
|
177
181
|
"type": "git",
|
|
178
182
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
179
183
|
"directory": "packages/ui"
|
|
180
|
-
}
|
|
181
|
-
"stableVersion": "0.6.4"
|
|
184
|
+
}
|
|
182
185
|
}
|
package/src/ai/AiChat.tsx
CHANGED
|
@@ -432,7 +432,7 @@ function formatToolCaption(call: AiChatToolCallSnapshot): string {
|
|
|
432
432
|
|
|
433
433
|
/**
|
|
434
434
|
* Visible agent task plan rendered above raw tool-call rows. Phase 1
|
|
435
|
-
* implementation of spec `.ai/specs/2026-05-13-ai-chat-visible-task-plan.md`:
|
|
435
|
+
* implementation of spec `.ai/specs/implemented/2026-05-13-ai-chat-visible-task-plan.md`:
|
|
436
436
|
* compact, fixed-height rows with icon + label + status badge so the
|
|
437
437
|
* panel does not jump while streaming. Source of truth is the
|
|
438
438
|
* client-local `taskPlan` array on the assistant message.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Acceptance tests for the visible agent task plan
|
|
7
|
-
* (spec `.ai/specs/2026-05-13-ai-chat-visible-task-plan.md`).
|
|
7
|
+
* (spec `.ai/specs/implemented/2026-05-13-ai-chat-visible-task-plan.md`).
|
|
8
8
|
*
|
|
9
9
|
* Asserts that:
|
|
10
10
|
* - `useAiChat` merges `data-agent-task-plan` snapshots and
|
|
@@ -15,13 +15,7 @@ import type { LucideIcon } from 'lucide-react'
|
|
|
15
15
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
16
16
|
import { KeyValueList, RecordCardShell, TagRow, statusToTagVariant } from './RecordCardShell'
|
|
17
17
|
import type { ActivityRecordPayload } from './types'
|
|
18
|
-
|
|
19
|
-
function formatDate(value: string | null | undefined): string | null {
|
|
20
|
-
if (!value) return null
|
|
21
|
-
const d = new Date(value)
|
|
22
|
-
if (Number.isNaN(d.getTime())) return value
|
|
23
|
-
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
24
|
-
}
|
|
18
|
+
import { formatDate } from '../../utils/format'
|
|
25
19
|
|
|
26
20
|
function pickActivityIcon(type: string | null | undefined): LucideIcon {
|
|
27
21
|
if (!type) return ActivityIcon
|
|
@@ -5,31 +5,7 @@ import { Briefcase, Building2, CalendarDays, CircleDollarSign, User } from 'luci
|
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import { KeyValueList, RecordCardShell, TagRow, statusToTagVariant } from './RecordCardShell'
|
|
7
7
|
import type { DealRecordPayload } from './types'
|
|
8
|
-
|
|
9
|
-
function formatAmount(amount: string | number | null | undefined, currency?: string | null): string | null {
|
|
10
|
-
if (amount === null || amount === undefined || amount === '') return null
|
|
11
|
-
const value = typeof amount === 'number' ? amount : Number(amount)
|
|
12
|
-
if (!Number.isFinite(value)) {
|
|
13
|
-
return typeof amount === 'string' ? amount : null
|
|
14
|
-
}
|
|
15
|
-
const code = currency && currency.length === 3 ? currency.toUpperCase() : undefined
|
|
16
|
-
try {
|
|
17
|
-
if (code) {
|
|
18
|
-
return new Intl.NumberFormat(undefined, { style: 'currency', currency: code }).format(value)
|
|
19
|
-
}
|
|
20
|
-
} catch {
|
|
21
|
-
// fall through to fallback
|
|
22
|
-
}
|
|
23
|
-
const formatted = new Intl.NumberFormat().format(value)
|
|
24
|
-
return code ? `${formatted} ${code}` : formatted
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function formatDate(value: string | null | undefined): string | null {
|
|
28
|
-
if (!value) return null
|
|
29
|
-
const d = new Date(value)
|
|
30
|
-
if (Number.isNaN(d.getTime())) return value
|
|
31
|
-
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
32
|
-
}
|
|
8
|
+
import { formatCurrency, formatDate } from '../../utils/format'
|
|
33
9
|
|
|
34
10
|
export interface DealCardProps extends DealRecordPayload {}
|
|
35
11
|
|
|
@@ -39,7 +15,7 @@ export function DealCard(props: DealCardProps) {
|
|
|
39
15
|
? { label: props.status, variant: statusToTagVariant(props.status) }
|
|
40
16
|
: null
|
|
41
17
|
const stage = props.stage && props.stage !== props.status ? props.stage : null
|
|
42
|
-
const amount =
|
|
18
|
+
const amount = formatCurrency(props.amount, props.currency)
|
|
43
19
|
const closeDate = formatDate(props.closeDate)
|
|
44
20
|
|
|
45
21
|
const items = [
|
|
@@ -5,24 +5,7 @@ import { Package, Tag as TagIcon } from 'lucide-react'
|
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import { KeyValueList, RecordCardShell, TagRow, statusToTagVariant } from './RecordCardShell'
|
|
7
7
|
import type { ProductRecordPayload } from './types'
|
|
8
|
-
|
|
9
|
-
function formatPrice(price: string | number | null | undefined, currency?: string | null): string | null {
|
|
10
|
-
if (price === null || price === undefined || price === '') return null
|
|
11
|
-
const value = typeof price === 'number' ? price : Number(price)
|
|
12
|
-
if (!Number.isFinite(value)) {
|
|
13
|
-
return typeof price === 'string' ? price : null
|
|
14
|
-
}
|
|
15
|
-
const code = currency && currency.length === 3 ? currency.toUpperCase() : undefined
|
|
16
|
-
try {
|
|
17
|
-
if (code) {
|
|
18
|
-
return new Intl.NumberFormat(undefined, { style: 'currency', currency: code }).format(value)
|
|
19
|
-
}
|
|
20
|
-
} catch {
|
|
21
|
-
// fall through
|
|
22
|
-
}
|
|
23
|
-
const formatted = new Intl.NumberFormat().format(value)
|
|
24
|
-
return code ? `${formatted} ${code}` : formatted
|
|
25
|
-
}
|
|
8
|
+
import { formatCurrency } from '../../utils/format'
|
|
26
9
|
|
|
27
10
|
export interface ProductCardProps extends ProductRecordPayload {}
|
|
28
11
|
|
|
@@ -31,7 +14,7 @@ export function ProductCard(props: ProductCardProps) {
|
|
|
31
14
|
const status = props.status
|
|
32
15
|
? { label: props.status, variant: statusToTagVariant(props.status) }
|
|
33
16
|
: null
|
|
34
|
-
const price =
|
|
17
|
+
const price = formatCurrency(props.price, props.currency)
|
|
35
18
|
|
|
36
19
|
const leading = props.imageUrl ? (
|
|
37
20
|
<div className="relative size-12 overflow-hidden rounded-md border border-border bg-muted">
|
package/src/ai/useAiChat.ts
CHANGED
|
@@ -44,7 +44,7 @@ export interface AiChatMessageUiPart {
|
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* Snapshot of a single task in the visible agent task plan
|
|
47
|
-
* (spec `.ai/specs/2026-05-13-ai-chat-visible-task-plan.md`). Tasks are
|
|
47
|
+
* (spec `.ai/specs/implemented/2026-05-13-ai-chat-visible-task-plan.md`). Tasks are
|
|
48
48
|
* streamed as additive `data-agent-task-plan` / `data-agent-task-update`
|
|
49
49
|
* SSE chunks and rendered above raw tool-call rows in `<AiChat>`.
|
|
50
50
|
*
|
package/src/backend/AppShell.tsx
CHANGED
|
@@ -150,6 +150,11 @@ function resolveInjectedMenuLabel(
|
|
|
150
150
|
return item.label ?? item.id
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
function shouldBypassLogoOptimization(src?: string | null): boolean {
|
|
154
|
+
const value = src ?? ''
|
|
155
|
+
return /^https?:\/\//.test(value) || /^\/api\/attachments\/(?:image|file)\//.test(value)
|
|
156
|
+
}
|
|
157
|
+
|
|
153
158
|
function mergeSidebarItemsWithInjected(
|
|
154
159
|
items: SidebarItem[],
|
|
155
160
|
injectedItems: InjectionMenuItem[],
|
|
@@ -444,6 +449,11 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
444
449
|
const { items: topbarInjectedMenuItems } = useInjectedMenuItems('menu:topbar:actions')
|
|
445
450
|
useEventBridge() // SSE DOM Event Bridge — singleton SSE connection for real-time server events
|
|
446
451
|
const resolvedProductName = productName ?? t('appShell.productName')
|
|
452
|
+
const resolvedLogo = chromePayload?.brand?.logo?.src ? chromePayload.brand.logo : logo
|
|
453
|
+
const resolvedBrandName = chromePayload?.brand?.logo?.src
|
|
454
|
+
? chromePayload.brand.name ?? resolvedProductName
|
|
455
|
+
: resolvedProductName
|
|
456
|
+
const resolvedLogoBypassesOptimization = shouldBypassLogoOptimization(resolvedLogo?.src)
|
|
447
457
|
const [mobileOpen, setMobileOpen] = React.useState(false)
|
|
448
458
|
// When the mobile drawer opens on a settings/profile route, it follows the
|
|
449
459
|
// section sidebar by default. Set to 'main' to force-show the main nav even
|
|
@@ -700,8 +710,8 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
700
710
|
className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
|
|
701
711
|
aria-label={t('appShell.goToDashboard')}
|
|
702
712
|
>
|
|
703
|
-
<Image src={
|
|
704
|
-
{!compact && <span className="truncate text-sm font-medium text-foreground">{
|
|
713
|
+
<Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={40} height={40} className="rounded-full shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
|
|
714
|
+
{!compact && <span className="truncate text-sm font-medium text-foreground">{resolvedBrandName}</span>}
|
|
705
715
|
</Link>
|
|
706
716
|
</div>
|
|
707
717
|
)}
|
|
@@ -833,8 +843,8 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
833
843
|
className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
|
|
834
844
|
aria-label={t('appShell.goToDashboard')}
|
|
835
845
|
>
|
|
836
|
-
<Image src={
|
|
837
|
-
{!compact && <span className="truncate text-sm font-medium text-foreground">{
|
|
846
|
+
<Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={40} height={40} className="rounded-full shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
|
|
847
|
+
{!compact && <span className="truncate text-sm font-medium text-foreground">{resolvedBrandName}</span>}
|
|
838
848
|
</Link>
|
|
839
849
|
</div>
|
|
840
850
|
) : null}
|
|
@@ -899,8 +909,8 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
899
909
|
className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
|
|
900
910
|
aria-label={t('appShell.goToDashboard')}
|
|
901
911
|
>
|
|
902
|
-
<Image src={
|
|
903
|
-
{!compact && <span className="truncate text-sm font-medium text-foreground">{
|
|
912
|
+
<Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={40} height={40} className="rounded-full shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
|
|
913
|
+
{!compact && <span className="truncate text-sm font-medium text-foreground">{resolvedBrandName}</span>}
|
|
904
914
|
</Link>
|
|
905
915
|
</div>
|
|
906
916
|
)}
|
|
@@ -1392,8 +1402,8 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
1392
1402
|
<aside className="absolute left-0 top-0 flex h-full w-[280px] max-w-[85vw] flex-col bg-background border-r shadow-lg overflow-hidden">
|
|
1393
1403
|
<div className="shrink-0 flex items-center justify-between gap-2 border-b px-4 py-3">
|
|
1394
1404
|
<Link href="/backend" className="flex items-center gap-2 min-w-0 text-sm font-semibold" onClick={() => setMobileOpen(false)} aria-label={t('appShell.goToDashboard')}>
|
|
1395
|
-
<Image src={
|
|
1396
|
-
<span className="truncate">{
|
|
1405
|
+
<Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={28} height={28} className="rounded shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
|
|
1406
|
+
<span className="truncate">{resolvedBrandName}</span>
|
|
1397
1407
|
</Link>
|
|
1398
1408
|
<IconButton variant="ghost" size="sm" onClick={() => setMobileOpen(false)} aria-label={t('appShell.closeMenu')}>
|
|
1399
1409
|
<X className="size-4" />
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -310,7 +310,7 @@ export type CrudFormProps<TValues extends Record<string, unknown>> = {
|
|
|
310
310
|
// When true, shows Delete button whenever onDelete is provided, even without an id
|
|
311
311
|
deleteVisible?: boolean
|
|
312
312
|
/**
|
|
313
|
-
* OSS opt-in optimistic locking (spec: .ai/specs/2026-05-25-oss-optimistic-locking.md).
|
|
313
|
+
* OSS opt-in optimistic locking (spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md).
|
|
314
314
|
*
|
|
315
315
|
* When set to a non-empty ISO-8601 string (typically `record.updatedAt`
|
|
316
316
|
* from the API response), the form auto-injects the
|
|
@@ -264,6 +264,8 @@ export type DataTableProps<T> = {
|
|
|
264
264
|
extensionTableId?: string
|
|
265
265
|
stickyFirstColumn?: boolean
|
|
266
266
|
stickyActionsColumn?: boolean
|
|
267
|
+
/** Horizontal alignment of the row-actions (kebab) column header + cell. Defaults to 'right'. */
|
|
268
|
+
actionsColumnAlign?: 'right' | 'center'
|
|
267
269
|
virtualized?: boolean
|
|
268
270
|
virtualizedMaxHeight?: number | string
|
|
269
271
|
virtualizedOverscan?: number
|
|
@@ -272,7 +274,7 @@ export type DataTableProps<T> = {
|
|
|
272
274
|
* or the legacy flat `AdvancedFilterState` shape as a backward-compatibility
|
|
273
275
|
* bridge. The bridge is provided for one minor version; legacy callers SHOULD
|
|
274
276
|
* migrate to the tree shape — see the spec
|
|
275
|
-
* `.ai/specs/2026-05-10-crm-list-filter-redesign.md` "Migration & Backward
|
|
277
|
+
* `.ai/specs/implemented/2026-05-10-crm-list-filter-redesign.md` "Migration & Backward
|
|
276
278
|
* Compatibility" section and `RELEASE_NOTES.md`.
|
|
277
279
|
*
|
|
278
280
|
* When the legacy flat shape is detected, DataTable converts it to a tree via
|
|
@@ -365,10 +367,14 @@ const EMPTY_FILTER_VALUES: FilterValues = Object.freeze({}) as FilterValues
|
|
|
365
367
|
// sticky right-0 → shadow falls to the LEFT (use `before:` + `-left-2` + `to-l`)
|
|
366
368
|
// sticky left-0 → shadow falls to the RIGHT (use `after:` + `-right-2` + `to-r`)
|
|
367
369
|
// `foreground/8` matches the `--shadow-md` token opacity (8%) and is theme-aware.
|
|
370
|
+
// Column pinning (and these shadows) is md-and-up only: below `md` the pinned
|
|
371
|
+
// first column + actions column can be wider than the whole viewport, which
|
|
372
|
+
// leaves the scrollable middle columns no visible window at all — narrow
|
|
373
|
+
// screens fall back to plain horizontal scroll so every column stays reachable.
|
|
368
374
|
const STICKY_RIGHT_SHADOW_CLASS =
|
|
369
|
-
'before:absolute before:inset-y-0 before:-left-2 before:w-2 before:bg-gradient-to-l before:from-foreground/8 before:to-transparent before:pointer-events-none'
|
|
375
|
+
'md:before:absolute md:before:inset-y-0 md:before:-left-2 md:before:w-2 md:before:bg-gradient-to-l md:before:from-foreground/8 md:before:to-transparent md:before:pointer-events-none'
|
|
370
376
|
const STICKY_LEFT_SHADOW_CLASS =
|
|
371
|
-
'after:absolute after:inset-y-0 after:-right-2 after:w-2 after:bg-gradient-to-r after:from-foreground/8 after:to-transparent after:pointer-events-none'
|
|
377
|
+
'md:after:absolute md:after:inset-y-0 md:after:-right-2 md:after:w-2 md:after:bg-gradient-to-r md:after:from-foreground/8 md:after:to-transparent md:after:pointer-events-none'
|
|
372
378
|
|
|
373
379
|
type BulkActionExecuteResult = {
|
|
374
380
|
ok: boolean
|
|
@@ -1000,6 +1006,7 @@ export function DataTable<T>({
|
|
|
1000
1006
|
extensionTableId: extensionTableIdProp,
|
|
1001
1007
|
stickyFirstColumn = false,
|
|
1002
1008
|
stickyActionsColumn = false,
|
|
1009
|
+
actionsColumnAlign = 'right',
|
|
1003
1010
|
virtualized = false,
|
|
1004
1011
|
virtualizedMaxHeight,
|
|
1005
1012
|
virtualizedOverscan = 10,
|
|
@@ -1023,7 +1030,7 @@ export function DataTable<T>({
|
|
|
1023
1030
|
// (`triggerRef`, `externalPopover`, `onApplyTree`, `onTriggerClick`) are
|
|
1024
1031
|
// undefined for legacy callers — they were added in this PR and didn't exist
|
|
1025
1032
|
// in the legacy contract.
|
|
1026
|
-
// See spec `.ai/specs/2026-05-10-crm-list-filter-redesign.md` ("Migration &
|
|
1033
|
+
// See spec `.ai/specs/implemented/2026-05-10-crm-list-filter-redesign.md` ("Migration &
|
|
1027
1034
|
// Backward Compatibility") and BACKWARD_COMPATIBILITY.md §3.
|
|
1028
1035
|
type AdvancedFilterNormalized = {
|
|
1029
1036
|
fields?: AdvancedFilterFieldDef[]
|
|
@@ -2705,12 +2712,12 @@ export function DataTable<T>({
|
|
|
2705
2712
|
const columnMeta = (header.column.columnDef as any)?.meta
|
|
2706
2713
|
const priority = resolvePriority(header.column)
|
|
2707
2714
|
const isFirstDataColumn = headerIndex === 0
|
|
2708
|
-
const stickyClass = stickyFirstColumn && isFirstDataColumn ? ` sticky left-0 z-10 bg-background ${STICKY_LEFT_SHADOW_CLASS}` : ''
|
|
2715
|
+
const stickyClass = stickyFirstColumn && isFirstDataColumn ? ` md:sticky md:left-0 md:z-10 md:bg-background ${STICKY_LEFT_SHADOW_CLASS}` : ''
|
|
2709
2716
|
const headerCellContent = header.isPlaceholder ? null : (
|
|
2710
2717
|
<Button
|
|
2711
2718
|
variant="ghost"
|
|
2712
2719
|
type="button"
|
|
2713
|
-
className={`h-auto p-0 font-medium ${sortable && header.column.getCanSort?.() ? 'cursor-pointer select-none' : ''}`}
|
|
2720
|
+
className={`h-auto p-0 has-[>svg]:px-0 font-medium ${sortable && header.column.getCanSort?.() ? 'cursor-pointer select-none' : ''}`}
|
|
2714
2721
|
onClick={() => sortable && header.column.toggleSorting?.(header.column.getIsSorted() === 'asc')}
|
|
2715
2722
|
>
|
|
2716
2723
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
@@ -2737,8 +2744,8 @@ export function DataTable<T>({
|
|
|
2737
2744
|
{rowActions || injectedRowActions.length > 0 ? (
|
|
2738
2745
|
<TableHead
|
|
2739
2746
|
className={cn(
|
|
2740
|
-
'w-0 text-right',
|
|
2741
|
-
stickyActionsColumn && `sticky right-0 z-20 bg-background ${STICKY_RIGHT_SHADOW_CLASS}`,
|
|
2747
|
+
actionsColumnAlign === 'center' ? 'w-0 text-center' : 'w-0 text-right',
|
|
2748
|
+
stickyActionsColumn && `md:sticky md:right-0 md:z-20 md:bg-background ${STICKY_RIGHT_SHADOW_CLASS}`,
|
|
2742
2749
|
)}
|
|
2743
2750
|
>
|
|
2744
2751
|
{t('ui.dataTable.actionsColumn', 'Actions')}
|
|
@@ -2860,7 +2867,7 @@ export function DataTable<T>({
|
|
|
2860
2867
|
) : content
|
|
2861
2868
|
|
|
2862
2869
|
return (
|
|
2863
|
-
<TableCell key={cell.id} className={responsiveClass(priority, columnMeta?.hidden) + (isStickyCell ? ` sticky left-0 z-10 bg-background ${STICKY_LEFT_SHADOW_CLASS}` : '')}>
|
|
2870
|
+
<TableCell key={cell.id} className={responsiveClass(priority, columnMeta?.hidden) + (isStickyCell ? ` md:sticky md:left-0 md:z-10 md:bg-background ${STICKY_LEFT_SHADOW_CLASS}` : '')}>
|
|
2864
2871
|
{wrappedContent}
|
|
2865
2872
|
</TableCell>
|
|
2866
2873
|
)
|
|
@@ -2868,8 +2875,8 @@ export function DataTable<T>({
|
|
|
2868
2875
|
{rowActions || injectedRowActions.length > 0 ? (
|
|
2869
2876
|
<TableCell
|
|
2870
2877
|
className={cn(
|
|
2871
|
-
'text-right whitespace-nowrap',
|
|
2872
|
-
stickyActionsColumn && `sticky right-0 z-10 bg-background ${STICKY_RIGHT_SHADOW_CLASS}`,
|
|
2878
|
+
actionsColumnAlign === 'center' ? 'text-center whitespace-nowrap' : 'text-right whitespace-nowrap',
|
|
2879
|
+
stickyActionsColumn && `md:sticky md:right-0 md:z-10 md:bg-background ${STICKY_RIGHT_SHADOW_CLASS}`,
|
|
2873
2880
|
)}
|
|
2874
2881
|
data-actions-cell
|
|
2875
2882
|
>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
import * as React from 'react'
|
|
3
|
+
import { createPortal } from 'react-dom'
|
|
3
4
|
import Link from 'next/link'
|
|
4
5
|
import { User, LogOut, Bell, Moon, Sun, Globe, Key, Check, ChevronRight } from 'lucide-react'
|
|
5
6
|
import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -21,6 +22,8 @@ export type ProfileDropdownProps = {
|
|
|
21
22
|
notificationsHref?: string
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect
|
|
26
|
+
|
|
24
27
|
const localeLabels: Record<Locale, string> = {
|
|
25
28
|
en: 'English',
|
|
26
29
|
de: 'Deutsch',
|
|
@@ -42,12 +45,39 @@ export function ProfileDropdown({
|
|
|
42
45
|
const [mounted, setMounted] = React.useState(false)
|
|
43
46
|
const buttonRef = React.useRef<HTMLButtonElement>(null)
|
|
44
47
|
const menuRef = React.useRef<HTMLDivElement>(null)
|
|
48
|
+
const [menuPosition, setMenuPosition] = React.useState<{ top: number; right: number } | null>(null)
|
|
45
49
|
const { items: injectedItems } = useInjectedMenuItems('menu:topbar:profile-dropdown')
|
|
46
50
|
|
|
47
51
|
React.useEffect(() => {
|
|
48
52
|
setMounted(true)
|
|
49
53
|
}, [])
|
|
50
54
|
|
|
55
|
+
// Position the portaled menu under the trigger. Rendering the menu in a body
|
|
56
|
+
// portal keeps it out of the sticky header's backdrop-blur stacking context so
|
|
57
|
+
// it always paints above page content such as the DataTable sticky Actions column.
|
|
58
|
+
useIsomorphicLayoutEffect(() => {
|
|
59
|
+
if (!open) {
|
|
60
|
+
setMenuPosition(null)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
const updatePosition = () => {
|
|
64
|
+
const trigger = buttonRef.current
|
|
65
|
+
if (!trigger) return
|
|
66
|
+
const rect = trigger.getBoundingClientRect()
|
|
67
|
+
setMenuPosition({
|
|
68
|
+
top: rect.bottom + 8,
|
|
69
|
+
right: Math.max(0, window.innerWidth - rect.right),
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
updatePosition()
|
|
73
|
+
window.addEventListener('resize', updatePosition)
|
|
74
|
+
window.addEventListener('scroll', updatePosition, true)
|
|
75
|
+
return () => {
|
|
76
|
+
window.removeEventListener('resize', updatePosition)
|
|
77
|
+
window.removeEventListener('scroll', updatePosition, true)
|
|
78
|
+
}
|
|
79
|
+
}, [open])
|
|
80
|
+
|
|
51
81
|
const isDark = resolvedTheme === 'dark'
|
|
52
82
|
|
|
53
83
|
// Close on click outside
|
|
@@ -336,10 +366,11 @@ export function ProfileDropdown({
|
|
|
336
366
|
<User className="size-4" />
|
|
337
367
|
</IconButton>
|
|
338
368
|
|
|
339
|
-
{open && (
|
|
369
|
+
{open && menuPosition && typeof document !== 'undefined' && createPortal(
|
|
340
370
|
<div
|
|
341
371
|
ref={menuRef}
|
|
342
|
-
className="
|
|
372
|
+
className="fixed z-popover w-64 overflow-hidden rounded-lg border bg-popover p-0 shadow-lg"
|
|
373
|
+
style={{ top: menuPosition.top, right: menuPosition.right }}
|
|
343
374
|
role="menu"
|
|
344
375
|
data-testid="profile-dropdown"
|
|
345
376
|
>
|
|
@@ -384,7 +415,8 @@ export function ProfileDropdown({
|
|
|
384
415
|
context={injectionContext}
|
|
385
416
|
/>
|
|
386
417
|
</div>
|
|
387
|
-
</div
|
|
418
|
+
</div>,
|
|
419
|
+
document.body,
|
|
388
420
|
)}
|
|
389
421
|
</div>
|
|
390
422
|
)
|