@drhomet/mithaq 0.0.0-stage → 0.1.1
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/LICENSE +22 -0
- package/LICENSES/payload.txt +22 -0
- package/LICENSES/ponytail.txt +21 -0
- package/dist/asas/actor.d.ts +96 -0
- package/dist/asas/actor.js +114 -0
- package/dist/asas/build.d.ts +231 -0
- package/dist/asas/build.js +787 -0
- package/dist/asas/cli/mithaq.d.ts +2 -0
- package/dist/asas/cli/mithaq.js +145 -0
- package/dist/asas/cli/upgrade.d.ts +20 -0
- package/dist/asas/cli/upgrade.js +147 -0
- package/dist/asas/http/fetch.d.ts +23 -0
- package/dist/asas/http/fetch.js +115 -0
- package/dist/asas/http/orpc.d.ts +43 -0
- package/dist/asas/http/orpc.js +135 -0
- package/dist/asas/runner/bulk.d.ts +20 -0
- package/dist/asas/runner/bulk.js +191 -0
- package/dist/asas/runner/errors.d.ts +60 -0
- package/dist/asas/runner/errors.js +59 -0
- package/dist/asas/runner/read.d.ts +68 -0
- package/dist/asas/runner/read.js +579 -0
- package/dist/asas/runner/runtime.d.ts +21 -0
- package/dist/asas/runner/runtime.js +8 -0
- package/dist/asas/runner/steps.d.ts +400 -0
- package/dist/asas/runner/steps.js +271 -0
- package/dist/asas/runner/tenant.d.ts +54 -0
- package/dist/asas/runner/tenant.js +213 -0
- package/dist/asas/runner/write.d.ts +444 -0
- package/dist/asas/runner/write.js +791 -0
- package/dist/auth/better-auth/access.d.ts +15 -0
- package/dist/auth/better-auth/access.js +71 -0
- package/dist/auth/better-auth/generate.d.ts +29 -0
- package/dist/auth/better-auth/generate.js +310 -0
- package/dist/auth/better-auth/index.d.ts +92 -0
- package/dist/auth/better-auth/index.js +81 -0
- package/dist/auth/better-auth/tenant.d.ts +18 -0
- package/dist/auth/better-auth/tenant.js +26 -0
- package/dist/auth/local/index.d.ts +285 -0
- package/dist/auth/local/index.js +736 -0
- package/dist/base/decimal.d.ts +9 -0
- package/dist/base/decimal.js +16 -0
- package/dist/base/fields.d.ts +123 -0
- package/dist/base/fields.js +51 -0
- package/dist/base/index.d.ts +46 -0
- package/dist/base/index.js +5 -0
- package/dist/base/kinds.d.ts +54 -0
- package/dist/base/kinds.js +78 -0
- package/dist/base/rules.d.ts +73 -0
- package/dist/base/rules.js +80 -0
- package/dist/drizzle/index.d.ts +438 -0
- package/dist/drizzle/index.js +832 -0
- package/dist/drizzle/kinds.d.ts +27 -0
- package/dist/drizzle/kinds.js +41 -0
- package/dist/drizzle/migrate.d.ts +133 -0
- package/dist/drizzle/migrate.js +1285 -0
- package/dist/drizzle/proxy/index.d.ts +1 -0
- package/dist/drizzle/proxy/index.js +1 -0
- package/dist/drizzle/proxy/node-postgres.d.ts +1 -0
- package/dist/drizzle/proxy/node-postgres.js +1 -0
- package/dist/drizzle/proxy/pg-core.d.ts +1 -0
- package/dist/drizzle/proxy/pg-core.js +1 -0
- package/dist/drizzle/rules.d.ts +336 -0
- package/dist/drizzle/rules.js +44 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +36 -0
- package/dist/mithaq/define.d.ts +288 -0
- package/dist/mithaq/define.js +43 -0
- package/dist/mithaq/generate-messages.d.ts +38 -0
- package/dist/mithaq/generate-messages.js +234 -0
- package/dist/mithaq/language.d.ts +335 -0
- package/dist/mithaq/language.js +76 -0
- package/dist/mithaq/lock.d.ts +71 -0
- package/dist/mithaq/lock.js +329 -0
- package/dist/mithaq/messages.d.ts +55 -0
- package/dist/mithaq/messages.js +106 -0
- package/dist/mithaq/refusal.d.ts +6 -0
- package/dist/mithaq/refusal.js +6 -0
- package/dist/mithaq/resolve.d.ts +10 -0
- package/dist/mithaq/resolve.js +954 -0
- package/dist/mithaq/rows.d.ts +87 -0
- package/dist/mithaq/rows.js +174 -0
- package/dist/mithaq/rukn.d.ts +72 -0
- package/dist/mithaq/rukn.js +15 -0
- package/dist/mithaq/standard-schema.d.ts +38 -0
- package/dist/mithaq/standard-schema.js +2 -0
- package/dist/mithaq/types.d.ts +358 -0
- package/dist/mithaq/types.js +15 -0
- package/dist/mithaq/user.d.ts +19 -0
- package/dist/mithaq/user.js +16 -0
- package/dist/mithaq/where.d.ts +32 -0
- package/dist/mithaq/where.js +87 -0
- package/dist/rbac/index.d.ts +105 -0
- package/dist/rbac/index.js +182 -0
- package/dist/rbac/steps.d.ts +22 -0
- package/dist/rbac/steps.js +161 -0
- package/dist/rich-text/index.d.ts +27 -0
- package/dist/rich-text/index.js +58 -0
- package/dist/rich-text/rich-text.d.ts +159 -0
- package/dist/rich-text/rich-text.js +372 -0
- package/dist/screen/client.d.ts +153 -0
- package/dist/screen/client.js +68 -0
- package/dist/screen/generate.d.ts +14 -0
- package/dist/screen/generate.js +226 -0
- package/dist/screen/index.d.ts +479 -0
- package/dist/screen/index.js +645 -0
- package/dist/screen/react.d.ts +95 -0
- package/dist/screen/react.js +58 -0
- package/dist/screen/studio.d.ts +24 -0
- package/dist/screen/studio.js +68 -0
- package/dist/service/index.d.ts +63 -0
- package/dist/service/index.js +15 -0
- package/dist/storage/allowed-type.d.ts +5 -0
- package/dist/storage/allowed-type.js +7 -0
- package/dist/storage/index.d.ts +43 -0
- package/dist/storage/index.js +44 -0
- package/dist/storage/storage.d.ts +384 -0
- package/dist/storage/storage.js +405 -0
- package/dist/versions/index.d.ts +37 -0
- package/dist/versions/index.js +8 -0
- package/dist/versions/versions.d.ts +104 -0
- package/dist/versions/versions.js +160 -0
- package/dist/zod/index.d.ts +184 -0
- package/dist/zod/index.js +121 -0
- package/dist/zod/kinds.d.ts +34 -0
- package/dist/zod/kinds.js +49 -0
- package/dist/zod/rules.d.ts +117 -0
- package/dist/zod/rules.js +67 -0
- package/package.json +63 -4
- package/r/mithaq-cells.json +20 -0
- package/r/mithaq-controls.json +37 -0
- package/r/mithaq-directional-icon.json +15 -0
- package/r/mithaq-expected-updated-at.json +15 -0
- package/r/mithaq-field-shell.json +18 -0
- package/r/mithaq-filter-operators.json +15 -0
- package/r/mithaq-form.json +33 -0
- package/r/mithaq-list-columns.json +23 -0
- package/r/mithaq-list-filters.json +24 -0
- package/r/mithaq-list-pagination.json +24 -0
- package/r/mithaq-list-query.json +18 -0
- package/r/mithaq-list-selection.json +19 -0
- package/r/mithaq-list.json +33 -0
- package/r/mithaq-nav.json +24 -0
- package/r/mithaq-nodes.json +34 -0
- package/r/mithaq-relationship-cell.json +20 -0
- package/r/mithaq-relationship-control.json +20 -0
- package/r/mithaq-rich-text.json +40 -0
- package/r/mithaq-screens-host.json +96 -0
- package/r/mithaq-storage.json +44 -0
- package/r/mithaq-upload.json +29 -0
- package/r/registry.json +550 -0
- package/README.md +0 -3
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "mithaq-nodes",
|
|
4
|
+
"title": "Mithaq nodes",
|
|
5
|
+
"description": "The node tree both forms draw, a screen's page and the admin's layout alike, the frame a record opens in over the list, the adapter from a screen's kayan, and one props type per place a screen names a component.",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"accordion",
|
|
8
|
+
"alert-dialog",
|
|
9
|
+
"badge",
|
|
10
|
+
"button",
|
|
11
|
+
"card",
|
|
12
|
+
"collapsible",
|
|
13
|
+
"dialog",
|
|
14
|
+
"drawer",
|
|
15
|
+
"resizable",
|
|
16
|
+
"scroll-area",
|
|
17
|
+
"select",
|
|
18
|
+
"separator",
|
|
19
|
+
"sheet",
|
|
20
|
+
"tabs",
|
|
21
|
+
"@mithaq/mithaq-controls",
|
|
22
|
+
"@mithaq/mithaq-field-shell",
|
|
23
|
+
"@mithaq/mithaq-relationship-control"
|
|
24
|
+
],
|
|
25
|
+
"files": [
|
|
26
|
+
{
|
|
27
|
+
"path": "registry/nodes.tsx",
|
|
28
|
+
"content": "import { Fragment, use, useMemo, useRef, useState, type ComponentType, type Key, type ReactElement, type ReactNode, type RefObject } from 'react'\nimport { ChevronDown, XIcon } from 'lucide-react'\nimport { kindOf, matchesWhere, regionsIn } from 'mithaq/admin'\nimport { ScreenContext, useLocale, useScreen } from 'mithaq/admin/react'\nimport type {\n ButtonClient,\n ButtonProps,\n CellProps as ScreenCellProps,\n ClientField,\n ClientKayan,\n ComponentProps,\n ControlProps as ScreenControlProps,\n EmptyProps,\n MenuProps,\n FrameClient,\n LayoutProps,\n LeafClient,\n NodeClient,\n NodeKind,\n NodeOf,\n PageClient,\n PageProps,\n ProviderProps,\n Row,\n RegionsClient,\n ShellProps,\n Translate,\n} from 'mithaq/admin'\nimport { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'\nimport { Accordion, AccordionHeader, AccordionItem, AccordionPanel, AccordionTrigger } from '@/components/ui/accordion'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'\nimport { Dialog, DialogClose, DialogContent, DialogTitle } from '@/components/ui/dialog'\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer'\nimport { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'\nimport { ScrollArea } from '@/components/ui/scroll-area'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport { Separator } from '@/components/ui/separator'\nimport { Sheet, SheetClose, SheetContent, SheetTitle } from '@/components/ui/sheet'\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'\nimport {\n besideLabel,\n controlFor,\n controls,\n descriptionFor,\n labelFor,\n newTitleOf,\n placeholderFor,\n titleOf,\n type ControlProps,\n type Copy,\n type FindApi,\n type FoundPage,\n} from './controls.tsx'\nimport { FieldShell, describedBy } from './field-shell.tsx'\nimport { idsOf, nameOfRow } from './relationship-control.tsx'\nimport { titleKey, useRelationshipTitles } from './relationship-cell.tsx'\n\n/**\n * What each place a screen names a component hands it, from `mithaq/admin`, with a React node where\n * the props carry one. Everything else a component reads through the hooks `mithaq/admin/react`\n * exports.\n */\nexport type Places = {\n provider: ComponentType<ProviderProps<ReactNode>>\n shell: ComponentType<ShellProps<readonly string[], ReactNode>>\n page: ComponentType<PageProps>\n layout: ComponentType<LayoutProps<readonly string[], ReactNode>>\n component: ComponentType<ComponentProps>\n control: ComponentType<ScreenControlProps>\n cell: ComponentType<ScreenCellProps>\n empty: ComponentType<EmptyProps>\n menu: ComponentType<MenuProps<ReactNode>>\n button: ComponentType<ButtonProps>\n}\n\n/** The components a screen names by path, keyed by the path `screen.json` prints. */\nexport type Refs = Readonly<Record<string, ComponentType<never>>>\n\n/** A kayan's page declared as one tree, rather than drawn whole by one component. */\nexport type TreePage = Extract<PageClient, { page: FrameClient }>\n\n/** What a form or a list draws a screen's page with, beside the adapted kayan. */\nexport type ScreenPage = {\n refs: Refs\n /** The screen's own kayan, which is what a component named by path is handed. */\n kayan: ClientKayan\n page: TreePage\n}\n\n/** A component the page names by path, typed for the place it sits in. */\nexport const refOf = <Place extends keyof Places>({\n screen,\n place: _place,\n path,\n}: {\n screen: Pick<ScreenPage, 'refs'> | undefined\n place: Place\n path: string | undefined\n}) =>\n (screen && path && Object.hasOwn(screen.refs, path) ? screen.refs[path] : undefined) as Places[Place] | undefined\n\nexport type ListNode = NodeOf<'list'>['list']\n/** A node of the kind it is, once `kindOf` has said which. */\nconst ofKind = <Kind extends NodeKind,>(node: NodeClient, kind: Kind) => (kindOf(node) === kind ? (node as NodeOf<Kind>) : undefined)\n/** What a node holds, off its kind's entry in the one map. */\nconst inside = (node: NodeClient): readonly NodeClient[] => (nodeMap[kindOf(node)!].children as (node: NodeClient) => readonly NodeClient[])(node)\n/** The first node some nodes hold at any depth that `is` picks. */\nconst first = <Found,>(nodes: readonly NodeClient[], is: (node: NodeClient) => Found | undefined): Found | undefined => {\n for (const node of nodes) {\n const found = is(node) ?? first(inside(node), is)\n if (found !== undefined) return found\n }\n return undefined\n}\n/** The first list node of some nodes, wherever it sits, that lists the page's own kayan. */\nexport const listIn = (nodes: readonly NodeClient[]): ListNode | undefined =>\n first(nodes, (node) => {\n const list = ofKind(node, 'list')?.list\n return list && !list.of ? list : undefined\n })\n/** Every list node some nodes hold that lists another kayan scoped to the page's row, wherever each sits. */\nexport const relatedIn = (nodes: readonly NodeClient[], scope?: Row): ListNode[] =>\n nodes.flatMap((node) => {\n // A node its `when` hides is not drawn, so nothing under it is read either: the read would only be\n // refused. A tab that hides every node it holds is the tab strip's to leave out, and its nodes are\n // each hidden here.\n if (scope && !shown(node, scope)) return []\n const list = ofKind(node, 'list')?.list\n return list ? (list.of ? [list] : []) : relatedIn(inside(node), scope)\n })\n/** The first form node, wherever it sits: a page that holds one is drawn by a form. */\nconst formNodeIn = (nodes: readonly NodeClient[]): NodeOf<'form'> | undefined => first(nodes, (node) => ofKind(node, 'form'))\n/** The first form node's nodes, wherever it sits. */\nexport const formIn = (nodes: readonly NodeClient[]): NodeClient[] | undefined => formNodeIn(nodes)?.form\n/**\n * Whether a page is drawn as the row's values rather than a form: it holds no form, or the viewer may update the row\n * nowhere, where boxes refusing every keystroke would only pretend. A project component's form decides itself.\n */\nexport const readsAsValues = ({\n page,\n value,\n operations,\n}: {\n page: TreePage\n value: Row | null | undefined\n operations: Readonly<Record<string, boolean>>\n}): boolean => {\n const form = formNodeIn(nodesOf(page))\n return !form || (!!value && operations.update !== true && !form.component)\n}\n/** Whether some nodes draw a node of this kind over the scope: the node and every node holding it must be shown. */\nexport const drawsKind = ({ nodes, kind, scope }: { nodes: readonly NodeClient[]; kind: NodeKind; scope: Row }): boolean =>\n nodes.some((node) => shown(node, scope) && (kindOf(node) === kind || drawsKind({ nodes: inside(node), kind, scope })))\n/** Every node of a page's tree, region by region, from its shell down. */\nexport const nodesOf = (page: TreePage): NodeClient[] => regionsIn(page.page).flatMap(([, nodes]) => nodes)\n/** The list node a screen's page draws, which is what a list page holds exactly one of. */\nexport const listOf = (screen: { page: TreePage } | undefined): ListNode | undefined => screen && listIn(nodesOf(screen.page))\n\n/**\n * Whether a nav entry is the page the viewer is on: its kayan is the page's own, on every page of that\n * kayan. Known on the server, so the first paint marks it.\n */\nexport const currentOf = (entry: { kayan?: string }, kayan: string | undefined): 'page' | undefined =>\n entry.kayan !== undefined && entry.kayan === kayan ? 'page' : undefined\n\n/**\n * What one row is called by its kayan's own title field, in the viewer's\n * locale, or nothing where the kayan declares none, the row holds none, or\n * the title is a relationship that has to be read first; never the row's id.\n */\nexport const rowTitle = ({ kayan, row, locale }: { kayan: ClientKayan; row: Row; locale: string }): string | undefined => {\n const field = kayan.fields.find((one) => one.name === kayan.useAsTitle && one.name !== 'id')\n const held = field ? row[field.name] : undefined\n if (!field || field.kind === 'relationship' || held === null || held === undefined || held === '') return undefined\n const name = nameOfRow({ row, title: field.name, locale, kind: field.kind })\n return name && name !== String(row.id ?? '') ? name : undefined\n}\n\n/**\n * Whether a row's title is empty: the kayan is titled by a field the reader may read, and its value (in the viewer's\n * locale if localized) is nothing or blank. A draft may hold no title, and a snapshot of one may lack the key.\n */\nexport const isUntitled = ({ kayan, row, locale }: { kayan: ClientKayan; row: Row; locale: string }): boolean => {\n const field = kayan.fields.find((one) => one.name === kayan.useAsTitle && one.name !== 'id')\n if (!field || field.kind === 'relationship') return false\n const held = row[field.name]\n const text = held !== null && typeof held === 'object' && !(held instanceof Date) ? (held as Row)[locale] : held\n return text === null || text === undefined || (typeof text === 'string' && text.trim() === '')\n}\n\n/**\n * What a button's dialog says it runs on: the row's title (or \"this row\"), in the operation's `.warning` sentence.\n * The title is isolated with U+2068/U+2069 so an email or digits in an Arabic sentence keep their order.\n */\nexport const rowWarning = ({ kayan, row, operation, copy: { t, locale } }: { kayan: ClientKayan; row: Row; operation?: string; copy: Copy }): string => {\n const name = rowTitle({ kayan, row, locale })\n if (!name) return t('admin.rowWarning', 'This applies to this row.') ?? 'This applies to this row.'\n const sentence = (operation && t(`${kayan.name}.operations.${operation}.warning`)) || (t('admin.rowNamedWarning', 'This applies to {name}.') ?? 'This applies to {name}.')\n return sentence.replace('{name}', () => `\\u2068${name}\\u2069`)\n}\n\n/**\n * A page's title read across its titling relationship: the related row's title, or the kind's name until it answers,\n * on every server render, and for a row the viewer may not read.\n */\nfunction RelatedTitle({ Heading, field, row, locale, fallback }: { Heading: 'h1' | 'h2'; field: ClientField; row: Row; locale: string; fallback: string }): ReactElement {\n const state = use(ScreenContext)\n const target = field.relationTo ?? ''\n const api = useMemo<FindApi | undefined>(() => (state ? { [target]: { find: (input) => state.client({ kayan: target, operation: 'find', input }) as Promise<FoundPage> } } : undefined), [state?.client, target])\n const titles = useRelationshipTitles({ fields: [field], docs: [row], context: { api, client: Object.values(state?.screen.kayan ?? {}), locale } })\n const id = idsOf(row[field.name])[0]\n const name = id === undefined ? undefined : titles.get(titleKey(target, id))\n const title = typeof name === 'string' && name !== id ? name : fallback\n return <Heading data-node=\"title\" title={title} className=\"text-2xl font-bold tracking-tight\">{title}</Heading>\n}\n\n/** The screen's nav as the host provides it: one link per entry under the screen's path. */\nexport function NavLinks(): ReactElement {\n const screen = useScreen()\n const copy = useLocale()\n const current = use(ScreenContext)?.kayan?.name\n return (\n <ul className=\"flex flex-col gap-1\">\n {screen.nav.map((entry) => (\n <li key={`${entry.page}${entry.path}`}>\n <a href={`${screen.path}${entry.path}`} aria-current={currentOf(entry, current)} className=\"text-sm\">\n {entry.label?.[copy.locale] ?? titleOf({ kayan: { name: entry.kayan ?? entry.page }, copy, many: !screen.kayan[entry.kayan ?? '']?.single })}\n </a>\n </li>\n ))}\n </ul>\n )\n}\n\n/**\n * A button's `confirm`, asked in the app's own alert dialog in the page's locale rather than the\n * browser's. `ask` answers whether the person said yes, at once for a button that carries no\n * confirm, and `dialog` is the question, drawn beside the button that asks it.\n */\nexport const useConfirm = ({\n confirm,\n confirmLabel,\n description,\n copy: { t, locale },\n}: {\n confirm: ButtonClient['confirm']\n confirmLabel: string\n /** What the caller says the button runs on, such as a bulk's count of ticked rows, before the confirm's own body. */\n description?: string\n copy: Copy\n}) => {\n const [answer, setAnswer] = useState<(yes: boolean) => void>()\n const settle = (yes: boolean) => {\n answer?.(yes)\n setAnswer(undefined)\n }\n const heading = confirm?.title[locale]\n const body = [description, confirm?.body[locale]].filter(Boolean).join(' ')\n const onConfirm = () => settle(true)\n const onCancel = () => settle(false)\n const ask = () => (confirm ? new Promise<boolean>((resolve) => setAnswer(() => resolve)) : Promise.resolve(true))\n const dialog = confirm ? (\n <AlertDialog open={answer !== undefined} onOpenChange={(open: boolean) => (open ? undefined : onCancel())}>\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>{heading}</AlertDialogTitle>\n <AlertDialogDescription>{body}</AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter className=\"max-sm:[&_button]:h-11\">\n <AlertDialogCancel>{t('admin.cancel', 'Cancel') ?? 'Cancel'}</AlertDialogCancel>\n <AlertDialogAction data-button=\"confirm-operation\" onClick={onConfirm}>\n {confirmLabel}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n ) : null\n return { ask, dialog }\n}\n\n/**\n * A project's own button component, handed a run that asks the button's\n * confirm first, so no component can run an operation past its question.\n */\nexport function OwnButton({\n Own,\n busy,\n call,\n copy,\n operation,\n}: {\n Own: Places['button']\n busy: boolean\n call: (input: Row) => Promise<void>\n copy: Copy\n operation: Pick<PageButton, 'name' | 'label' | 'confirm'>\n}): ReactElement {\n const { ask, dialog } = useConfirm({ confirm: operation.confirm, confirmLabel: operation.label[copy.locale] ?? operation.name, copy })\n return (\n <>\n <Own operation={operation.name} busy={busy} run={async (input: Row) => void ((await ask()) && (await call(input)))} />\n {dialog}\n </>\n )\n}\n\n/**\n * A button that runs its operation where its node sits, after its confirm.\n * It is what a node draws when it asks for no values: a form draws a dialog\n * for an operation that does.\n */\nexport function NodeButton({ node, copy, busy, run, description }: { node: Pick<ButtonClient, 'label' | 'confirm'>; copy: Copy; busy: boolean; run: () => void; description?: string }): ReactElement {\n const label = node.label[copy.locale]\n const { ask, dialog } = useConfirm({ confirm: node.confirm, confirmLabel: label ?? '', description, copy })\n return (\n <>\n <Button type=\"button\" variant=\"outline\" className=\"max-sm:h-11\" disabled={busy} onClick={() => void ask().then((yes) => yes && run())}>\n {label}\n </Button>\n {dialog}\n </>\n )\n}\n\n/**\n * A bulk update's box for a yes-or-no field, which has three answers where a checkbox has two: leave\n * each row's value as it is, set it, or clear it. The two values read in the field's own words, and\n * leaving it as it is sends nothing.\n */\nexport function BulkChoice({\n copy: { t },\n describedBy,\n error,\n field,\n id,\n kayan,\n onChange,\n readOnly,\n value,\n}: {\n copy: Copy\n describedBy?: string\n /** A refusal of this box, which marks the trigger invalid as every control marks itself. */\n error?: string\n field: ClientField\n id: string\n kayan: ClientKayan\n onChange: (value: boolean | undefined) => void\n readOnly: boolean\n value: unknown\n}): ReactElement {\n const word = (held: boolean) => t(`${kayan.name}.${field.name}.${held}`) ?? (held ? t('admin.yes', 'Yes') : t('admin.no', 'No')) ?? String(held)\n const keep = t('admin.leaveAsIs', 'Leave as is') ?? 'Leave as is'\n const current = typeof value === 'boolean' ? String(value) : 'keep'\n return (\n <Select value={current} onValueChange={(next: string | null) => onChange(next === 'true' ? true : next === 'false' ? false : undefined)} disabled={readOnly}>\n <SelectTrigger id={id} className=\"w-full\" aria-describedby={describedBy} aria-invalid={error ? true : undefined}>\n <SelectValue>{current === 'keep' ? keep : word(current === 'true')}</SelectValue>\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"keep\">{keep}</SelectItem>\n <SelectItem value=\"true\">{word(true)}</SelectItem>\n <SelectItem value=\"false\">{word(false)}</SelectItem>\n </SelectContent>\n </Select>\n )\n}\n\n/** One button a page draws where its node sits: the operation, and what the node says about it. */\nexport type PageButton = ClientKayan['operations'][number] & Pick<ButtonClient, 'label' | 'component' | 'confirm'>\n\n/**\n * The button a node draws for this actor, when the kayan declares its\n * operation and the report allows it by its own name, with the fields its\n * dialog asks for cut to the ones the node names.\n */\nexport const pageButtonOf = ({\n kayan,\n node,\n allowed,\n}: {\n kayan: ClientKayan\n node: ButtonClient\n allowed: Readonly<Record<string, boolean>> | undefined\n}): PageButton | undefined => {\n const operation = kayan.operations.find((one) => one.name === node.button)\n if (!operation || allowed?.[operation.name] !== true) return undefined\n const fields = operation.fields?.filter((one) => !node.fields || node.fields.includes(one.name))\n return { ...operation, ...(fields && { fields }), label: node.label, ...(node.component && { component: node.component }), ...(node.confirm && { confirm: node.confirm }) }\n}\n\n/** A named group's label and description, resolved the way a field's are. */\nexport const groupCopy = ({\n kayan,\n name,\n group,\n copy: { t, locale },\n}: {\n kayan: ClientKayan\n name: string\n group: { label?: Readonly<Record<string, string>>; description?: Readonly<Record<string, string>> }\n copy: Copy\n}) => ({\n label: (typeof group.label === 'object' ? group.label[locale] : undefined) ?? t(`${kayan.name}.layout.${name}`, name) ?? name,\n description:\n (typeof group.description === 'object' ? group.description[locale] : undefined)\n ?? t(`${kayan.name}.layout.${name}.description`),\n})\n\n/** One node of a screen's page. */\nexport type Node = NodeClient\n\n/** Every field name a tree mentions, at any depth, of the leaves `keep` keeps. */\nexport const leavesOf = (nodes: readonly Node[], keep: (leaf: LeafClient) => boolean = () => true): readonly string[] =>\n nodes.flatMap((node): readonly string[] => {\n const leaf = ofKind(node, 'field')\n return leaf ? (keep(leaf) ? [leaf.field] : []) : leavesOf(inside(node), keep)\n })\n\n/**\n * One tab strip. `keepMounted` renders every panel, hiding the inactive ones, so a field on a tab nobody opened\n * still holds its value; base-ui unmounts a hidden panel otherwise.\n */\nfunction TabGroup({\n line,\n tabs,\n}: {\n line: boolean\n tabs: readonly { key: string; label: string; description?: string; invalid: number; invalidLabel: string; body: ReactNode }[]\n}): ReactElement {\n return (\n <Tabs defaultValue={tabs[0]?.key} data-node=\"tabs\" className={line ? 'gap-4' : 'overflow-hidden rounded-lg border'}>\n <TabsList variant={line ? 'line' : 'default'} className={line ? 'w-full justify-start max-sm:min-h-12' : 'm-1 max-sm:min-h-12'}>\n {tabs.map((tab) => (\n <TabsTrigger key={tab.key} value={tab.key} className={line ? 'flex-none max-sm:min-h-11' : 'max-sm:min-h-11'}>\n {tab.label}\n {/* How many of this tab's fields the refusal named, so a save refused over a field on a tab\n nobody opened says so on the tab. */}\n {tab.invalid > 0 ? (\n <Badge variant=\"destructive\" aria-label={tab.invalidLabel} title={tab.invalidLabel} data-mithaq-invalid={tab.invalid}>\n {tab.invalid}\n </Badge>\n ) : null}\n </TabsTrigger>\n ))}\n </TabsList>\n {tabs.map((tab) => (\n <TabsContent key={tab.key} value={tab.key} keepMounted className={line ? 'flex flex-col gap-4' : 'flex flex-col gap-4 p-4'}>\n {tab.description ? <p className=\"text-sm text-muted-foreground\">{tab.description}</p> : null}\n {tab.body}\n </TabsContent>\n ))}\n </Tabs>\n )\n}\n\n/** What a tree is drawn with: the form's own answer for each leaf and component, and the frame each layout is. */\nexport type Draw = {\n kayan: ClientKayan\n copy: Copy\n /** The refusal per field, which a tab counts over the fields it holds. */\n issues: Readonly<Record<string, string>>\n control: (leaf: string | LeafClient) => ReactNode\n component: (path: string, index: number) => ReactNode\n /** A layout nested in a region, handed its own regions drawn. */\n layout: (args: { path: string; regions: Record<string, ReactNode>; index: number }) => ReactNode\n /** A list node, the page's own or one of another kayan, and the component that draws it in place of the registry's list. */\n list?: (args: { node: ListNode; index: number; component?: string }) => ReactNode\n /** A button node, which only what holds a row to run it over draws. */\n button?: (node: ButtonClient, index: number) => ReactNode\n /** The derived nav, which the host holds. */\n nav?: () => ReactNode\n /** What a form node draws around what it holds. */\n form?: (children: ReactNode) => ReactNode\n /** The form's controls, which only a form draws: where the row stands, its last save and its buttons. */\n controls?: () => ReactNode\n /**\n * What a node's `when` reads: the form's values by field name, the row the\n * page draws as `record` and the access report as `$user`, which `scopeOf`\n * puts together.\n */\n scope: Row\n /**\n * Whether the nodes drawn sit inside a card: a section, an accordion, a pill\n * tab strip, or the card a run of loose fields is drawn on. A tab strip reads\n * it to pick the pill strip or the line bar.\n */\n inCard?: boolean\n /**\n * Whether the page lists the kayan's own rows, where its title names the kayan by its plural:\n * \"Courses\" over the list, \"Course\" over one of them. A list of another kayan on a record page\n * leaves it the singular.\n */\n many?: boolean\n /**\n * Whether the page opens over its list in a dialog, a sheet or a drawer, where its title is an h2\n * so the list's h1 behind it stays the page's one.\n */\n framed?: boolean\n}\n\n/**\n * What a `when` reads, from the values a form holds, the row the page draws and the actor's access\n * report, and the kind of each field of the kayan, which a decimal is compared by. No `when` can name\n * `$kinds`: the build refuses a key that is no field and not `$user`.\n */\nexport const scopeOf = ({ values, record, access, kayan }: { values: Row; record: Row | null | undefined; access: unknown; kayan: ClientKayan }): Row =>\n ({ ...values, record: record ?? null, $user: access ?? null, $kinds: Object.fromEntries(kayan.fields.map((field) => [field.name, field.kind])) })\n\n/**\n * Whether a node is drawn: it carries no `when`, or its `when` holds over the scope, a dotted key\n * read one segment at a time. It decides what is shown and nothing else: the server still refuses\n * what the actor may not do.\n */\nexport const shown = (node: NodeClient, scope: Row) =>\n typeof node !== 'object' || !node.when\n || matchesWhere({\n where: node.when,\n valueOf: (key) => key.split('.').reduce<unknown>((value, part) => (value && typeof value === 'object' ? (value as Row)[part] : undefined), scope),\n // A field is named alone, as `record.<field>`, or with a locale after it.\n kindOf: (key) => {\n const [head, next] = key.split('.')\n return (scope.$kinds as Record<string, string> | undefined)?.[head === 'record' ? next! : head!]\n },\n })\n\n/**\n * Every kind of node, how it draws and what it holds: the one map every walk of a tree goes through.\n * TypeScript refuses it with a kind left out. The set is closed: a project draws its own thing with a\n * `component` node, and changes how a kind draws by editing its copy of this map.\n */\nexport type NodeMap = {\n [Kind in NodeKind]: {\n /** The node drawn; `inRow` when it sits in a row, where a section takes its width. */\n draw: (args: { node: NodeOf<Kind>; draw: Draw; index: number; inRow: boolean }) => ReactNode\n /** The nodes it holds, which a walk descends into. */\n children: (node: NodeOf<Kind>) => readonly NodeClient[]\n }\n}\n\nconst none = () => []\n\n/**\n * How each node kind is drawn. A list is drawn by the host that holds its rows, and by nothing where no host hands one.\n * Every node carries its kind as `data-node`, so a served page says what it drew.\n */\nexport const nodeMap: NodeMap = {\n layout: {\n draw: ({ node, draw, index }) => <div key={`layout-${index}`} data-node=\"layout\" className=\"contents\">{draw.layout({ path: node.layout, regions: regionsOf(node, draw), index })}</div>,\n children: (node) => regionsIn(node).flatMap(([, nodes]) => nodes),\n },\n field: { draw: ({ node, draw }) => draw.control(node), children: none },\n form: {\n draw: ({ node, draw, index }) => {\n // A form node that names a component is drawn by it in place of its leaves, and the form still\n // holds, checks and saves the values.\n const held = node.component ? draw.component(node.component, index) : carded(node.form, draw)\n return <div key={`form-${index}`} data-node=\"form\" className=\"flex flex-col gap-4\">{draw.form ? draw.form(held) : held}</div>\n },\n children: (node) => node.form,\n },\n list: {\n draw: ({ node, draw, index }) => <div key={`list-${index}`} data-node=\"list\" className=\"contents\">{draw.list?.({ node: node.list, index, component: node.component })}</div>,\n children: none,\n },\n button: { draw: ({ node, draw, index }) => <div key={`button-${index}`} data-node=\"button\" className=\"contents\">{draw.button?.(node, index)}</div>, children: none },\n title: {\n // The whole title is its tooltip too, as a header may cut it short on a phone. The title is the row\n // as read, not the value being typed, so it changes once a save lands.\n draw: ({ draw: { kayan, copy, many, framed, form, scope }, index }) => {\n const Heading = framed ? 'h2' : 'h1'\n const record = scope.record as Row | null | undefined\n const field = kayan.fields.find((one) => one.name === kayan.useAsTitle && one.name !== 'id')\n // A global with no saved row yet is still its one page, so it keeps its name.\n const fallback = !many && form && !record && !kayan.single ? newTitleOf({ kayan, copy }) : titleOf({ kayan, copy, many })\n if (!many && record && field?.kind === 'relationship') return <RelatedTitle key={`title-${index}`} Heading={Heading} field={field} row={record} locale={copy.locale} fallback={fallback} />\n const untitled = !many && !!record && isUntitled({ kayan, row: record, locale: copy.locale })\n const title = untitled ? (copy.t('admin.untitled', 'Untitled') ?? 'Untitled') : (!many && record ? rowTitle({ kayan, row: record, locale: copy.locale }) : undefined) ?? fallback\n return <Heading key={`title-${index}`} data-node=\"title\" title={title} className=\"text-2xl font-bold tracking-tight\">{untitled ? <span className=\"text-muted-foreground\">{title}</span> : title}</Heading>\n },\n children: none,\n },\n nav: {\n // Nothing in a frame: the list's own shell behind it already draws the nav.\n draw: ({ draw, index }) => !draw.framed && <nav key={`nav-${index}`} data-node=\"nav\" aria-label={draw.copy.t('admin.navigation', 'Navigation')}>{draw.nav?.()}</nav>,\n children: none,\n },\n // Drawn only by a form, which hands its controls; off a form page the node draws nothing.\n controls: {\n draw: ({ draw, index }) => draw.controls && <div key={`controls-${index}`} data-node=\"controls\" className=\"contents\">{draw.controls()}</div>,\n children: none,\n },\n component: {\n draw: ({ node, draw, index }) => <div key={`component-${index}`} data-node=\"component\" className=\"contents\">{draw.component(node.component, index)}</div>,\n children: none,\n },\n row: {\n draw: ({ node, draw, index }) =>\n node.resizable ? (\n <ResizablePanelGroup key={`row-${index}`} data-node=\"row\" orientation=\"horizontal\" className=\"rounded-lg border\">\n {drawNodes({ nodes: node.row, draw }).flatMap((child, at) => [\n ...(at ? [<ResizableHandle key={`handle-${at}`} withHandle />] : []),\n <ResizablePanel key={`panel-${at}`} className=\"p-2\">{child}</ResizablePanel>,\n ])}\n </ResizablePanelGroup>\n ) : (\n // `min-w-40` on a field alone so it wraps to its own line on a phone before it is too narrow to\n // show its value: `flex-1` alone starts every field at no width, so the row never wraps. A button\n // or a short select beside it is not forced to 160 pixels.\n <div key={`row-${index}`} data-node=\"row\" className=\"flex flex-wrap items-start gap-4 [&>[data-control]]:min-w-40 [&>[data-field-value]]:min-w-40 [&>*]:flex-1\">\n {drawNodes({ nodes: node.row, draw, inRow: true })}\n </div>\n ),\n children: (node) => node.row,\n },\n separator: { draw: ({ index }) => <Separator key={`separator-${index}`} data-node=\"separator\" />, children: none },\n tabs: {\n draw: ({ node, draw, index }) => (\n <TabGroup\n key={`tabs-${index}`}\n line={!draw.inCard}\n // A tab whose every node its `when` hides is left out, so no actor opens a tab that holds\n // nothing it may see.\n tabs={node.tabs.filter((tab) => tab.fields.some((one) => shown(one, draw.scope))).map((tab) => {\n // Counted over the fields this tab holds at any depth, so a\n // refused field inside a row inside a tab is counted on the tab.\n const invalid = leavesOf(tab.fields).filter((name) => draw.issues[name] !== undefined).length\n return {\n key: tab.tab,\n ...groupCopy({ kayan: draw.kayan, name: tab.tab, group: tab, copy: draw.copy }),\n invalid,\n invalidLabel: (\n draw.copy.t('admin.invalidFields', 'Fields needing attention: {count}') ?? 'Fields needing attention: {count}'\n ).replace('{count}', String(invalid)),\n body: <div className=\"flex flex-col gap-4\">{carded(tab.fields, draw)}</div>,\n }\n })}\n />\n ),\n children: (node) => node.tabs.flatMap((tab) => tab.fields),\n },\n accordion: {\n draw: ({ node, draw, index }) => (\n <Accordion data-layout-accordion=\"true\" data-node=\"accordion\" key={`accordion-${index}`} defaultValue={node.accordion[0] ? [node.accordion[0].item] : []} keepMounted className=\"overflow-hidden rounded-lg border\">\n {node.accordion.map((item) => {\n const { label, description } = groupCopy({ kayan: draw.kayan, name: item.item, group: item, copy: draw.copy })\n return <AccordionItem key={item.item} value={item.item} className=\"border-b last:border-b-0\">\n <AccordionHeader><AccordionTrigger>{label}<ChevronDown aria-hidden=\"true\" className=\"size-4\" /></AccordionTrigger></AccordionHeader>\n <AccordionPanel className=\"flex flex-col gap-4 p-4\">\n {description ? <p className=\"text-sm text-muted-foreground\">{description}</p> : null}\n {drawNodes({ nodes: item.fields, draw: { ...draw, inCard: true } })}\n </AccordionPanel>\n </AccordionItem>\n })}\n </Accordion>\n ),\n children: (node) => node.accordion.flatMap((item) => item.fields),\n },\n section: {\n draw: ({ node, draw, inRow }) => {\n const { label, description } = groupCopy({ kayan: draw.kayan, name: node.section, group: node, copy: draw.copy })\n const body = <CardContent className=\"flex flex-col gap-4\">{drawNodes({ nodes: node.fields, draw: { ...draw, inCard: true } })}</CardContent>\n // A section is a card that opens and closes. `keepMounted` on the panel so a field inside a closed\n // section still renders and a refusal naming it is counted.\n return (\n <Collapsible key={node.section} data-node=\"section\" defaultOpen={node.collapsed !== true} style={inRow ? { flexGrow: node.width ?? 1, flexBasis: 0 } : undefined}>\n <Card className=\"overflow-visible rounded-lg\">\n <CardHeader>\n <CardTitle>\n <h2 className=\"text-base font-medium\">\n <CollapsibleTrigger className=\"group flex w-full cursor-pointer items-center justify-between gap-2 text-start max-sm:min-h-11\">\n {label}\n <ChevronDown\n aria-hidden=\"true\"\n className=\"text-muted-foreground size-4 transition-transform group-data-[panel-open]:rotate-180\"\n />\n </CollapsibleTrigger>\n </h2>\n </CardTitle>\n {description ? <CardDescription>{description}</CardDescription> : null}\n </CardHeader>\n <CollapsibleContent keepMounted>\n {node.scroll ? <ScrollArea style={{ height: node.scroll }}>{body}</ScrollArea> : body}\n </CollapsibleContent>\n </Card>\n </Collapsible>\n )\n },\n children: (node) => node.fields,\n },\n}\n\n/**\n * A form's nodes, with each run of fields that sits in no section drawn on a card, so a dark page\n * reads as cards over the inset and not as boxes on black. A section, tabs and a component are drawn\n * as they are.\n */\nconst loose = new Set(['field', 'row', 'separator'])\nexport const carded = (nodes: readonly Node[], draw: Draw): ReactNode[] => {\n const drawn: ReactNode[] = []\n let run: Node[] = []\n const flush = () => {\n const held = run.filter((one) => shown(one, draw.scope) && !blank(one, draw.kayan))\n // A separator divides what a card holds, so a card holding nothing else is not drawn.\n if (held.some((one) => kindOf(one) !== 'separator')) {\n drawn.push(\n <Card key={`card-${drawn.length}`} className=\"overflow-visible rounded-lg\">\n <CardContent className=\"flex flex-col gap-4\">{drawNodes({ nodes: held, draw: { ...draw, inCard: true } })}</CardContent>\n </Card>,\n )\n }\n run = []\n }\n for (const one of nodes) {\n if (loose.has(kindOf(one)!)) {\n run.push(one)\n continue\n }\n flush()\n drawn.push(<Fragment key={`node-${drawn.length}`}>{drawNodes({ nodes: [one], draw })}</Fragment>)\n }\n flush()\n return drawn\n}\n\n/**\n * Whether a node would draw nothing but empty frames: a leaf naming a field the projection does not\n * hold, which is a field the viewer may not read, or a row, section, tab strip or accordion holding\n * only such leaves. Such a group is not drawn, rather than drawn as an empty card.\n */\nconst blank = (node: Node, kayan: ClientKayan): boolean => {\n const kind = kindOf(node)\n if (kind === 'field') return !kayan.fields.some((field) => field.name === (typeof node === 'string' ? node : (node as LeafClient).field))\n return ['row', 'section', 'tabs', 'accordion'].includes(kind!) && inside(node).every((one) => blank(one, kayan))\n}\n\n/** One tree, one node at a time, each through its kind's entry in the one map. */\nexport const drawNodes = ({ nodes, draw, inRow = false }: { nodes: readonly Node[]; draw: Draw; inRow?: boolean }): ReactNode[] =>\n nodes.map((node, index) =>\n !shown(node, draw.scope) || blank(node, draw.kayan)\n ? null\n : (nodeMap[kindOf(node)!].draw as (args: { node: Node; draw: Draw; index: number; inRow: boolean }) => ReactNode)({ node, draw, index, inRow }),\n )\n\n/**\n * Whether a node draws anything: shown, not blank, a button its page draws (an operation the report\n * allows), and a group holding at least one such node. A danger zone of buttons the viewer may not\n * run is no card at all.\n */\nconst draws = (node: Node, draw: Draw): boolean => {\n if (!shown(node, draw.scope) || blank(node, draw.kayan)) return false\n const kind = kindOf(node)\n if (kind === 'button') return (draw.button?.(node as ButtonClient, 0) ?? null) !== null\n return !['row', 'section', 'tabs', 'accordion'].includes(kind!) || inside(node).some((one) => draws(one, draw))\n}\n\n/**\n * A shell's or a layout's regions, each drawn from its nodes, which is what its\n * file is handed. A region none of whose nodes is drawn is handed as nothing,\n * so a layout leaves out its frame, a danger zone's heading among them.\n */\nexport const regionsOf = (frame: RegionsClient, draw: Draw): Record<string, ReactNode> =>\n Object.fromEntries(regionsIn(frame).map(([region, nodes]) => [region, nodes.some((node) => draws(node, draw)) ? drawNodes({ nodes, draw }) : undefined]))\n\n/**\n * The shell or the layout a page names, handed its regions drawn and the page\n * it frames. A name this app has no component for is said where the frame\n * would have been rather than drawn as a blank.\n */\nexport const frameOf = ({\n screen,\n t,\n path,\n regions,\n key,\n}: {\n screen: ScreenPage\n t: Translate\n path: string\n regions: Record<string, ReactNode>\n key?: Key\n}): ReactNode => {\n const Frame = refOf({ screen, place: 'layout', path })\n if (Frame) return <Frame key={key} regions={regions} page={screen.page} />\n const missing = t('admin.missingLayout', 'No component is registered for layout \"{layout}\".') ?? 'No component is registered for layout \"{layout}\".'\n return <p key={key} data-missing-layout={path} className=\"text-muted-foreground text-sm\">{missing.replace('{layout}', path)}</p>\n}\n\n/**\n * A page's whole tree: its shell handed each region drawn, or, where the page\n * opens over the list and so inside the list's own shell, its regions alone.\n */\nexport const pageOf = (screen: ScreenPage, draw: Draw): ReactNode => {\n const drawn = { ...draw, many: listOf(screen) !== undefined }\n if (!screen.page.mode) return frameOf({ screen, t: draw.copy.t, path: screen.page.page.shell, regions: regionsOf(screen.page.page, drawn) })\n // Over the list the page draws only itself, and a nav node at any depth draws nothing there.\n return regionsIn(screen.page.page).map(([region, nodes]) => <Fragment key={region}>{drawNodes({ nodes, draw: { ...drawn, framed: true } })}</Fragment>)\n}\n\n/**\n * One field's control in its shell, as the form draws a leaf. The leaf's copy, width and read-only win over the field's;\n * a component named by path gets the props its place hands it, any other name is a registry control.\n */\nexport function LeafControl({\n field,\n kayan,\n leaf,\n screen,\n copy,\n error,\n invalidLocales,\n onBlur,\n ...rest\n}: Omit<ControlProps, 'id' | 't' | 'locale' | 'placeholder' | 'describedBy' | 'client'> & {\n leaf?: LeafClient\n screen?: ScreenPage\n copy: Copy\n /** The locales of a localized field a refusal named by its path, `title.ar`, whose boxes alone are marked. */\n invalidLocales?: readonly string[]\n onBlur?: () => void\n kayans?: readonly ClientKayan[]\n api?: FindApi\n}): ReactElement {\n const { kayans, ...props } = rest\n const { locale } = copy\n const chosen = leaf?.component ?? field.component\n const Ref = refOf({ screen, place: 'control', path: chosen })\n const Control = (leaf?.component && controls[leaf.component]) || controlFor(field)\n const inputId = `${kayan.name}-${field.name}`\n const description = leaf?.description?.[locale] ?? descriptionFor({ kayan, field, copy })\n const readOnly = props.readOnly || leaf?.readOnly === true\n const placeholder = leaf?.placeholder?.[locale] ?? placeholderFor({ kayan, field, copy })\n // A localized field is one box per locale under one label, the viewer's own first and carrying the\n // field's id; a change to one box writes the whole object, which is how the store takes it.\n if (field.rules.localized === true && !Ref) {\n const held = (props.value && typeof props.value === 'object' ? props.value : {}) as Record<string, unknown>\n const names = new Intl.DisplayNames([locale], { type: 'language' })\n const order = [locale, ...(copy.locales ?? []).filter((one) => one !== locale)]\n // Marks the locales a refusal's paths name; before any save, the empty boxes of a required field;\n // any other refusal marks every box.\n const blank = (one: string) => held[one] === undefined || held[one] === null || held[one] === ''\n const marked = (one: string) =>\n !error ? false\n : invalidLocales?.length ? invalidLocales.includes(one)\n : field.rules.required === true && order.some(blank) ? blank(one)\n : true\n const label = labelFor({ kayan, field, copy })\n return (\n <FieldShell id={inputId} label={label} description={description} error={error} onBlur={onBlur} control={chosen} width={leaf?.width ?? field.width}>\n <div className=\"grid gap-2\">\n {order.map((one) => (\n <div key={one} className=\"grid gap-1\">\n {/* The viewer's own box is named by the field's label, every other box by the label and its locale. */}\n {one === locale ? (\n <span id={`${inputId}-${one}-locale`} className=\"text-muted-foreground text-xs\">{names.of(one)}</span>\n ) : (\n <label id={`${inputId}-${one}-locale`} htmlFor={`${inputId}-${one}`} className=\"text-muted-foreground text-xs\">\n <span className=\"sr-only\">{label} </span>\n {names.of(one)}\n </label>\n )}\n <Control\n {...props}\n field={field}\n kayan={kayan}\n client={kayans}\n t={copy.t}\n locale={locale}\n locales={copy.locales}\n id={one === locale ? inputId : `${inputId}-${one}`}\n value={held[one]}\n onChange={(next) => props.onChange({ ...held, [one]: next })}\n readOnly={readOnly}\n error={marked(one) ? error : undefined}\n placeholder={placeholder}\n describedBy={[`${inputId}-${one}-locale`, describedBy({ id: inputId, description, error })].filter(Boolean).join(' ')}\n />\n </div>\n ))}\n </div>\n </FieldShell>\n )\n }\n return (\n <FieldShell\n id={inputId}\n label={labelFor({ kayan, field, copy })}\n description={description}\n error={error}\n onBlur={onBlur}\n beside={!Ref && besideLabel(Control)}\n control={chosen}\n width={leaf?.width ?? field.width}\n >\n {Ref && screen ? (\n <Ref field={field} value={props.value} onChange={props.onChange} readOnly={readOnly} issue={error ?? null} />\n ) : (\n <Control\n {...props}\n field={field}\n kayan={kayan}\n client={kayans}\n t={copy.t}\n locale={locale}\n locales={copy.locales}\n id={inputId}\n readOnly={readOnly}\n error={error}\n placeholder={placeholder}\n describedBy={describedBy({ id: inputId, description, error })}\n />\n )}\n </FieldShell>\n )\n}\n\n/**\n * A sheet's or a dialog's close button, an icon named in the viewer's language. The sheet or dialog\n * that draws it passes `showCloseButton={false}`, so the primitive's English one is not drawn beside it.\n */\nexport function CloseButton({ Close, label }: { Close: typeof SheetClose | typeof DialogClose; label: string }): ReactElement {\n return (\n <Close render={<Button variant=\"ghost\" size=\"icon-sm\" className=\"absolute top-4 end-4 max-sm:top-2 max-sm:end-2 max-sm:size-11\" />}>\n <XIcon aria-hidden=\"true\" />\n <span className=\"sr-only\">{label}</span>\n </Close>\n )\n}\n\n/**\n * The open popups of shadcn's four modals. The sidebar's mobile sheet is a sheet whose popup the\n * sidebar renames `data-slot=\"sidebar\"`; the desktop sidebar has the same slot and is never `data-open`.\n */\nconst openModal = '[data-slot=dialog-content][data-open],[data-slot=alert-dialog-content][data-open],[data-slot=sheet-content][data-open],[data-slot=sidebar][data-open],[data-slot=drawer-popup][data-open]'\n\n/**\n * Makes everything outside the open modal `inert`, as `showModal()` does, so Tab never reaches the page behind it:\n * Base UI's focus guards act only on focus events, which an unfocused document never receives.\n */\nexport const holdFocus = (body: HTMLElement): (() => void) => {\n /** Everything this made inert given back, which is all it ever clears. */\n const release = () => {\n for (const one of body.querySelectorAll('[data-mithaq-inert]')) {\n one.removeAttribute('inert')\n one.removeAttribute('data-mithaq-inert')\n }\n }\n // A live region, such as a list's count of ticked rows, keeps speaking while a modal is open: an\n // element holding one is walked into rather than made inert whole.\n const shut = (node: Element) => {\n if (node.matches('[aria-live], script') || node.hasAttribute('inert')) return\n if (node.querySelector('[aria-live]')) {\n for (const child of node.children) shut(child)\n return\n }\n node.setAttribute('inert', '')\n node.setAttribute('data-mithaq-inert', '')\n }\n const sync = () => {\n release()\n const open = [...body.querySelectorAll(openModal)].at(-1)\n if (!open) return\n for (const child of body.children) if (child.hasAttribute('data-base-ui-inert') && !child.contains(open)) shut(child)\n }\n const observer = new MutationObserver(sync)\n observer.observe(body, { childList: true, subtree: true, attributeFilter: ['data-open', 'data-base-ui-inert'] })\n sync()\n // The cleanup gives the page back whole even when the frame unmounts under an open modal.\n return () => {\n observer.disconnect()\n release()\n }\n}\n\n/**\n * Focus on open: the first form field, or the popup itself on a touch so the phone's keyboard stays down.\n * A route-opened frame gets no interaction type, so on a coarse pointer anything but the keyboard counts as touch.\n */\nexport const initialFocusIn = (popup: RefObject<HTMLElement | null>) => (type: string) =>\n type === 'touch' || (type !== 'keyboard' && globalThis.matchMedia?.('(pointer: coarse)').matches)\n ? popup.current\n : popup.current?.querySelector<HTMLElement>('form :is(input:not([type=hidden]), textarea, select, [contenteditable=true], [role=combobox]):not(:disabled, [aria-hidden=true], [tabindex=\"-1\"])') ?? true\n\n/**\n * Where a create or an edit opens over the list, when its page names a mode:\n * shadcn's dialog, sheet or drawer, open while it is drawn, and closing it is\n * the host's navigation back to the list.\n */\nexport function RecordFrame({\n mode,\n title,\n closeLabel,\n onClose,\n children,\n}: {\n mode: 'dialog' | 'drawer' | 'sheet'\n title: string\n /** What a screen reader names the close button by, in the viewer's language. */\n closeLabel: string\n onClose: () => void\n children?: ReactNode\n}): ReactElement {\n const change = (open: boolean) => {\n if (!open) onClose()\n }\n const popup = useRef<HTMLDivElement>(null)\n const frame =\n // The title is for a screen reader alone, as the page draws its own title node inside. The close\n // button is this file's own, as the primitive's is named in English.\n mode === 'sheet' ? <Sheet open onOpenChange={change}><SheetContent ref={popup} showCloseButton={false} initialFocus={initialFocusIn(popup)} className=\"gap-4 overflow-y-auto p-4 data-[side=left]:max-sm:w-full data-[side=right]:max-sm:w-full\"><SheetTitle className=\"sr-only\">{title}</SheetTitle>{children}<CloseButton Close={SheetClose} label={closeLabel} /></SheetContent></Sheet>\n : mode === 'drawer' ? <Drawer open onOpenChange={change}><DrawerContent><DrawerTitle className=\"sr-only\">{title}</DrawerTitle><div className=\"flex min-h-0 flex-col gap-4 overflow-y-auto p-4\">{children}</div></DrawerContent></Drawer>\n : <Dialog open onOpenChange={change}><DialogContent ref={popup} showCloseButton={false} initialFocus={initialFocusIn(popup)}><DialogTitle className=\"sr-only\">{title}</DialogTitle>{children}<CloseButton Close={DialogClose} label={closeLabel} /></DialogContent></Dialog>\n // A server render draws nothing of the popup, which is a portal, so the mode is said here.\n return (\n <>\n <span hidden data-record-mode={mode} />\n {frame}\n </>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 d6e35be14e1300296cbe3b2d1330322351a5f45e94e6ae3340b8b6f5728cbc0f\n",
|
|
29
|
+
"type": "registry:component",
|
|
30
|
+
"target": "@components/mithaq/nodes.tsx"
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"type": "registry:component"
|
|
34
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "mithaq-relationship-cell",
|
|
4
|
+
"title": "Mithaq relationship cell",
|
|
5
|
+
"description": "Every relationship id on a page named by one read per target, in three states: loading, the related row's title, and the id where the read did not answer.",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"@mithaq/mithaq-cells",
|
|
8
|
+
"@mithaq/mithaq-controls",
|
|
9
|
+
"@mithaq/mithaq-relationship-control"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "registry/relationship-cell.tsx",
|
|
14
|
+
"content": "import { use, useEffect, useState, type ReactNode } from 'react'\nimport type { ClientField, ClientKayan } from 'mithaq/admin'\nimport { ScreenContext } from 'mithaq/admin/react'\nimport type { CellProps } from './cells.tsx'\nimport { isForbidden, listed, type FindApi } from './controls.tsx'\nimport { findByIds, idsOf, nameOfRow, titleFieldOf } from './relationship-control.tsx'\n\n/**\n * What the table knows about the rows its relationship columns point at, keyed by target and id.\n * `null` is loading, a string is the row's title, `false` is a row the read did not answer for\n * (the access answer), an `Error` is a failed read, and an absent key was never asked for.\n */\nexport type RelationshipTitles = ReadonlyMap<string, string | Record<string, unknown> | null | false | Error>\n\nexport const titleKey = (target: string, id: string): string => `${target}:${id}`\n\n/** Where the same map holds the row itself, for a cell that draws more than a title. */\nexport const rowKey = (target: string, id: string): string => `row:${target}:${id}`\n\n/** Every id one page points at, grouped by the kayan it points into and counted once each, so a page costs one read per target. */\nexport const relationshipIdsOf = (\n fields: readonly ClientField[],\n docs: readonly Record<string, unknown>[],\n): ReadonlyMap<string, readonly string[]> => {\n const byTarget = new Map<string, Set<string>>()\n for (const field of fields) {\n if ((field.kind !== 'relationship' && field.kind !== 'upload') || !field.relationTo) continue\n const held = byTarget.get(field.relationTo) ?? new Set<string>()\n for (const row of docs) for (const id of idsOf(row[field.name])) held.add(id)\n if (held.size) byTarget.set(field.relationTo, held)\n }\n return new Map([...byTarget].map(([target, ids]) => [target, [...ids]]))\n}\n\n/**\n * One read per target per page, keyed on the grouped ids so an unchanged page asks for nothing.\n * Cells read as loading until the browser's effect answers; with no `api` or screen, ids settle\n * as rows the viewer cannot see. No cell renders the raw id.\n */\nexport const useRelationshipTitles = ({\n fields,\n docs,\n context = {},\n}: {\n fields: readonly ClientField[]\n docs: readonly Record<string, unknown>[]\n context?: { api?: FindApi; client?: readonly ClientKayan[]; locale?: string }\n}): RelationshipTitles => {\n const { api, client, locale } = context\n const maxLimit = use(ScreenContext)?.screen.maxLimit\n const groups = relationshipIdsOf(fields, docs)\n const asked = JSON.stringify([...groups])\n const [titles, setTitles] = useState<RelationshipTitles>(() => new Map())\n\n useEffect(() => {\n let live = true\n const answers = new Map<string, string | Record<string, unknown> | null | false | Error>()\n // A target with no reader or no known read cap settles as `false` now, rather than loading on a read that is never made.\n for (const [target, ids] of groups) {\n for (const id of ids) {\n const asks = api?.[target] && maxLimit !== undefined ? null : false\n answers.set(titleKey(target, id), asks)\n answers.set(rowKey(target, id), asks)\n }\n }\n if (!answers.size) return\n setTitles(new Map(answers))\n void Promise.all(\n [...groups].map(async ([target, ids]) => {\n const reads = api?.[target]\n if (!reads || maxLimit === undefined) return\n const kayan = client?.find((one) => one.name === target)\n const title = titleFieldOf(kayan)\n const kind = kayan?.fields.find((one) => one.name === title)?.kind\n // A forbidden read is the same answer as a missing row; any other failure is held as its Error. Neither may reject: that would leave every cell loading.\n const found = await findByIds({ reads, ids, maxLimit }).catch((thrown: unknown) => (isForbidden(thrown) ? [] : thrown instanceof Error ? thrown : new Error(String(thrown))))\n if (found instanceof Error) {\n for (const id of ids) {\n answers.set(titleKey(target, id), found)\n answers.set(rowKey(target, id), found)\n }\n return\n }\n const byId = new Map(found.map((row) => [String(row.id ?? ''), row]))\n // An id the answer lacks is a row this actor may not read, so `false`, never left `null`.\n for (const id of ids) {\n const row = byId.get(id)\n answers.set(titleKey(target, id), row ? nameOfRow({ row, title, locale, kind }) : false)\n answers.set(rowKey(target, id), row ?? false)\n }\n }),\n ).then(() => {\n if (live) setTitles(new Map(answers))\n })\n return () => {\n live = false\n }\n // `groups` is rebuilt every render; `asked` is its content.\n }, [api, asked, locale])\n\n return titles\n}\n\nconst totalToShow = 3\n\nexport function RelationshipCell({ field, locale, t, titles, value }: CellProps): ReactNode {\n const target = field.relationTo ?? ''\n const ids = idsOf(value)\n if (!ids.length) return ''\n const names = ids.slice(0, totalToShow).map((id) => {\n const found = titles?.get(titleKey(target, id))\n if (found === null || found === undefined) return t('admin.loading', 'Loading…') ?? 'Loading…'\n if (found instanceof Error) return '—'\n // A row not answered for reads as unseeable, never as the raw id the column holds.\n return typeof found === 'string' ? found : (t('admin.cannotSee', 'You can\\'t see this') ?? 'You can\\'t see this')\n })\n const rest = ids.length - names.length\n return (\n <span>\n {listed(names, locale)}\n {rest > 0\n ? ` ${(t('admin.andMore', 'and {count} more') ?? 'and {count} more').replace('{count}', String(rest))}`\n : ''}\n </span>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 9f18a8922be32e3a3671aed05e4f723d7e31d040d0c9ca341418aeaf6ab15cc4\n",
|
|
15
|
+
"type": "registry:component",
|
|
16
|
+
"target": "@components/mithaq/relationship-cell.tsx"
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"type": "registry:component"
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "mithaq-relationship-control",
|
|
4
|
+
"title": "Mithaq relationship control",
|
|
5
|
+
"description": "The picker for a relationship field: a debounced search over the target kayan's own find, paged on scroll, with the selected row hydrated by one read.",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"combobox",
|
|
8
|
+
"spinner",
|
|
9
|
+
"@mithaq/mithaq-controls"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "registry/relationship-control.tsx",
|
|
14
|
+
"content": "import { createContext, use, useEffect, useRef, useState, type ReactElement, type ReactNode } from 'react'\nimport type { ClientKayan } from 'mithaq/admin'\nimport { ScreenContext } from 'mithaq/admin/react'\nimport {\n Combobox,\n ComboboxChip,\n ComboboxChips,\n ComboboxChipsInput,\n ComboboxContent,\n ComboboxInput,\n ComboboxItem,\n ComboboxList,\n} from '@/components/ui/combobox'\nimport { Spinner } from '@/components/ui/spinner'\nimport { ariaRequired, changeOf, comboboxPhone, formatDate, isForbidden, type ControlProps, type FindApi } from './controls.tsx'\n\n/**\n * A chip's remove button is 24 pixels and a phone's finger needs 44: its hit area is widened by an\n * inset over the chip, which the box around it keeps at 22 pixels.\n */\nconst chipRemovePhone = 'max-sm:[&_[data-slot=combobox-chip-remove]]:relative max-sm:[&_[data-slot=combobox-chip-remove]]:after:absolute max-sm:[&_[data-slot=combobox-chip-remove]]:after:-inset-[11px]'\n\n/**\n * What a row of the target is shown as, and what a search runs over. `??` rather than `||` because\n * `useAsTitle` is either a field's name or absent, and `buildAsas` refuses a name that is not a field\n * of the kayan.\n */\nexport const titleFieldOf = (target?: ClientKayan): string => target?.useAsTitle ?? 'id'\n\n/**\n * Every row of `ids` the target answers for, `maxLimit` ids at a time, so a set past the cap is answered in full.\n * `maxLimit` is the build's own, as the server refuses a limit past it rather than clamping.\n */\nexport const findByIds = async ({ reads, ids, maxLimit }: { reads: NonNullable<FindApi[string]>; ids: readonly string[]; maxLimit: number }) => {\n const docs: Record<string, unknown>[] = []\n for (let at = 0; at < ids.length; at += maxLimit) {\n const piece = ids.slice(at, at + maxLimit)\n docs.push(...(await reads.find({ where: { id: { in: piece } }, limit: piece.length })).docs)\n }\n return docs\n}\n\n/**\n * Whether a term typed here can be turned into a condition at all: `like` takes text and email and\n * nothing else, so a target whose title is its `id`, a uuid, offers a list to scroll and no search.\n * Without this the first keystroke on such a picker is a 400.\n */\nexport const searchableTitle = (target: ClientKayan | undefined, title: string): boolean => {\n const kind = target?.fields.find((field) => field.name === title)?.kind\n return kind === 'text' || kind === 'email'\n}\n\n/**\n * Whether the title is a localized field: one value per locale, which no sort can name and a `like`\n * names by one locale, `name.ar`.\n */\nexport const localizedTitle = (target: ClientKayan | undefined, title: string): boolean =>\n target?.fields.find((field) => field.name === title)?.rules.localized === true\n\n/**\n * What one row of the target reads as: the title field's value, and the row's own id where the target\n * declares no title or the read masked the column.\n */\nexport const nameOfRow = ({ row, title, locale, kind }: { row: Record<string, unknown>; title: string; locale?: string; kind?: string }): string => {\n const held = row[title]\n // A localized title is read in the viewer's locale and in no other: a row short of that locale shows\n // its id, never another locale's words. A date is drawn in the format its column draws it in rather\n // than as the wire's ISO string.\n const shown = held !== null && typeof held === 'object' && !(held instanceof Date) ? (held as Record<string, unknown>)[locale ?? ''] : held\n if (shown === null || shown === undefined || shown === '') return String(row.id ?? '')\n const date = kind === 'datetime' || kind === 'date' ? formatDate({ value: shown, locale: locale ?? '', day: kind === 'date' }) : undefined\n return date ?? String(shown)\n}\n\n/**\n * How a project names a picker's rows beyond their title: a hook, given the target kayan's name and the rows held,\n * that returns a namer or `undefined` to keep the title. A hook because the name may need a read of its own.\n */\nexport type UseRowLabel = (target: string, rows: readonly Record<string, unknown>[]) => ((row: Record<string, unknown>) => string | undefined) | undefined\nexport const RowLabelContext = createContext<UseRowLabel>(() => undefined)\n\n/** How many rows one page of the picker holds. */\nconst perPage = 10\n\n/**\n * The ids one value holds: none, the one a many-to-one field carries, or the set a `hasMany` field\n * carries, in the order the store answered with.\n */\nexport const idsOf = (value: unknown): readonly string[] =>\n value === null || value === undefined ? [] : Array.isArray(value) ? value.map(String) : [String(value)]\n\n/** One row of the target as the combobox's own item: base-ui reads a label off the shape `{ value, label }` without being told how. */\ntype Option = { value: string; label: string }\n\n/**\n * The picker, and what each row of its list is drawn as beside its title where the caller draws more\n * than the title, such as the upload control's thumbnail.\n */\nexport function RelationshipControl(props: ControlProps & { drawRow?: (row: Record<string, unknown>, label: string) => ReactNode }): ReactElement {\n const { api, client, describedBy, drawRow, error, field, id, locale, placeholder, readOnly, t, value } = props\n const change = changeOf(props)\n const target = client?.find((one) => one.name === field.relationTo)\n const title = titleFieldOf(target)\n const searchable = searchableTitle(target, title)\n const localized = localizedTitle(target, title)\n const reads = target && api ? api[target.name] : undefined\n // The most rows one read asks for: the build's `maxLimit`, off the screen. A form drawn with no\n // screen has no such number, so it holds to the picker's own page of ten.\n const most = use(ScreenContext)?.screen.maxLimit ?? perPage\n /** Every id this field is holding, which is at most one unless the field said otherwise. */\n const held = idsOf(value)\n const many = field.hasMany === true\n\n const [open, setOpen] = useState(false)\n const [term, setTerm] = useState('')\n /** The term a query has been run for, which is the typed one 300 ms later. */\n const [searched, setSearched] = useState('')\n const [rows, setRows] = useState<readonly Record<string, unknown>[]>([])\n /** The page a scroll to the bottom loads next, off the answer's own `nextPage`. */\n const [next, setNext] = useState<number | null>(null)\n const [loading, setLoading] = useState(false)\n /**\n * Whether the last page read came back refused rather than empty, so the list does not say \"no rows\n * match this filter\" for a target this actor may not read at all.\n */\n const [refused, setRefused] = useState(false)\n /**\n * The chosen rows by id: one picked off a page needs no read, one the field was seeded with is read once.\n * A map, since a `hasMany` field holds a set and a many-to-one field a set of one.\n */\n const [chosen, setChosen] = useState<ReadonlyMap<string, Record<string, unknown>>>(() => new Map())\n\n /**\n * What the popup is anchored on where the box is a chip list: the list itself and not the input\n * inside it, which shrinks to the width the typed term needs, so a listbox that followed it would\n * hang off the middle of the field.\n */\n const chipsRef = useRef<HTMLDivElement | null>(null)\n\n /**\n * Held ids the read settled without a row: `unseen` when refused or left out (as is every id of a target the screen\n * does not cross), `failed` on a 500 or lost network, which says nothing about access.\n */\n const [settled, setSettled] = useState<ReadonlyMap<string, 'unseen' | 'failed'>>(() => new Map())\n\n const titleKind = target?.fields.find((one) => one.name === title)?.kind\n const named = use(RowLabelContext)(field.relationTo ?? '', [...rows, ...chosen.values()])\n const nameOf = (row: Record<string, unknown>) => named?.(row) ?? nameOfRow({ row, title, locale, kind: titleKind })\n const rowOf = (held: string) => chosen.get(held) ?? rows.find((one) => String(one.id ?? '') === held)\n const stateOf = (held: string) => (reads ? settled.get(held) : 'unseen')\n /**\n * What one held id reads as: the row's own title where something named it, a row this viewer cannot\n * see where the read settled without it, a dash where the read failed, and loading until then. Never\n * the raw id, which reads as data and is none of the viewer's.\n */\n const labelOf = (held: string) => {\n const row = rowOf(held)\n if (row) return nameOf(row)\n const state = stateOf(held)\n if (state === 'failed') return '—'\n return state === 'unseen'\n ? (t('admin.cannotSee', 'You can\\'t see this') ?? 'You can\\'t see this')\n : (t('admin.loading', 'Loading…') ?? 'Loading…')\n }\n /**\n * A set whose every row the viewer cannot see says so once, in place of one chip for each. The chips\n * are not drawn but are still the field's value, so `pick` refuses a shorter set while this holds:\n * a row can be added, never taken off.\n */\n const allRefused = many && held.length > 0 && held.every((one) => !rowOf(one) && stateOf(one) === 'unseen')\n\n /**\n * One page of the target, which is the whole query. The `not_in` keeps every chosen row out of the\n * list; an empty list of ids is no special case, because the store writes `not_in []` as `true`.\n */\n const queryFor = (page: number, search: string) => ({\n limit: Math.min(perPage, most),\n page,\n ...(!localized && { sort: title }),\n where: {\n id: { not_in: held },\n ...(searchable && search ? { [localized ? `${title}.${locale}` : title]: { like: search } } : {}),\n },\n })\n /**\n * The ids the query above is keyed on, as one string: `held` is rebuilt on every render, so an effect\n * watching the array would read every page again on every keystroke.\n */\n const excluded = held.join(',')\n\n useEffect(() => {\n if (term === searched) return\n const timer = setTimeout(() => setSearched(term), 300)\n return () => clearTimeout(timer)\n }, [searched, term])\n\n // Page one, on open and on every term the debounce settles.\n useEffect(() => {\n if (!open || !reads) return\n let live = true\n setLoading(true)\n void reads\n .find(queryFor(1, searched))\n .then((found) => {\n if (!live) return\n setRefused(false)\n setRows(found.docs)\n setNext(found.hasNextPage ? found.nextPage : null)\n })\n // A read the server refuses leaves the list as it was rather than rejecting into nothing, and\n // the list says it was refused rather than that nothing matched.\n .catch(() => {\n if (live) setRefused(true)\n })\n .finally(() => {\n if (live) setLoading(false)\n })\n return () => {\n live = false\n }\n }, [excluded, locale, open, reads, searched, title])\n\n /**\n * The chosen rows, for every id the option list does not already hold: one read, in pieces the read\n * cap admits (`findByIds`). A row picked off a page is never asked for, as picking it puts the row\n * in `chosen`.\n */\n const asked = useRef(new Set<string>())\n useEffect(() => {\n if (!reads) return\n // Once per id, whatever the answer: an id the target does not answer for would otherwise be asked\n // for again on every page the list loads, as the loaded rows are watched.\n const missing = held.filter(\n (one) => !chosen.has(one) && !rows.some((row) => String(row.id ?? '') === one) && !asked.current.has(one),\n )\n if (!missing.length) return\n for (const one of missing) asked.current.add(one)\n // No cleanup drops the answer: each id is asked for once, so an answer dropped because the effect\n // ran again would never be asked for again, and the box would show the id for good.\n void findByIds({ reads, ids: missing, maxLimit: most })\n .then((docs) => {\n const found = new Set(docs.map((row) => String(row.id ?? '')))\n setSettled((already) => new Map([...already, ...missing.filter((one) => !found.has(one)).map((one) => [one, 'unseen'] as const)]))\n if (!docs.length) return\n setChosen((already) => new Map([...already, ...docs.map((row) => [String(row.id ?? ''), row] as const)]))\n })\n // A refusal is the same answer as a row not returned: the viewer cannot see it. A failure says\n // only that the read did not answer.\n .catch((thrown: unknown) => {\n const state = isForbidden(thrown) ? 'unseen' : 'failed'\n setSettled((already) => new Map([...already, ...missing.map((one) => [one, state] as const)]))\n })\n }, [chosen, excluded, reads, rows])\n\n const selected = held.map((one) => ({ value: one, label: labelOf(one) }))\n const searchLabel = t('admin.search', 'Search') ?? 'Search'\n const options: Option[] = rows.map((row) => ({ value: String(row.id ?? ''), label: nameOf(row) }))\n\n /**\n * What a pick reports. Picked rows are remembered, so hydration does not re-ask for names the list showed.\n * A `hasMany` field reports an array, never `null`: a `[]` seed against `null` would read as a change forever.\n */\n const pick = (next: readonly Option[]) => {\n // A set drawn as one note cannot be shortened: Backspace in the empty box takes the last chip off,\n // and it would drop an id nobody sees.\n if (allRefused && next.length < held.length) return\n const picked = next.flatMap((one) => {\n const row = rows.find((each) => String(each.id ?? '') === one.value)\n return row ? [[one.value, row] as const] : []\n })\n if (picked.length) setChosen((already) => new Map([...already, ...picked]))\n setTerm('')\n const ids = next.map((one) => one.value)\n change(many ? ids : (ids[0] ?? null))\n }\n\n /**\n * Everything both boxes hand the root, written once. `filter={null}` because the filtering is the\n * target kayan's `find` and not a pass over the page; `items` is the page as it arrived.\n */\n const root = {\n items: options,\n filter: null,\n // The first row is highlighted as the list opens, so Enter picks a row rather than nothing. The\n // cast: base-ui's root types `autoHighlight` as a boolean while the combobox behind it reads\n // `'always'`, and `true` alone highlights nothing until a term is typed.\n autoHighlight: 'always' as unknown as boolean,\n open,\n onOpenChange: setOpen,\n disabled: readOnly,\n // The items are objects, so equal ids in two objects have to be told to match: the default is `Object.is`.\n isItemEqualToValue: (one: Option, other: Option) => one.value === other.value,\n // The typed term alone. base-ui writes the selected row's label into the input when the selection\n // changes and when the list opens, and each of those calls this too; taking them as a search term\n // would read the target for a name it is already showing.\n onInputValueChange: (next: string, details: { reason: string }) =>\n details.reason === 'input-change' && setTerm(next),\n }\n\n /** The listbox, which is the same page of rows whichever box is above it. */\n const list = (\n <ComboboxContent anchor={many ? chipsRef : undefined}>\n <ComboboxList\n onScroll={(event) => {\n const list = event.currentTarget\n if (loading || next === null || !reads) return\n if (list.scrollTop + list.clientHeight < list.scrollHeight - 8) return\n const page = next\n setLoading(true)\n void reads\n .find(queryFor(page, searched))\n .then((found) => {\n setRows((already) => [...already, ...found.docs])\n setNext(found.hasNextPage ? found.nextPage : null)\n })\n .catch(() => {})\n .finally(() => setLoading(false))\n }}\n >\n {options.map((option) => {\n const row = drawRow && rows.find((one) => String(one.id ?? '') === option.value)\n return (\n <ComboboxItem key={option.value} value={option}>\n {row ? drawRow(row, option.label) : option.label}\n </ComboboxItem>\n )\n })}\n </ComboboxList>\n {loading ? (\n <p className=\"flex items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground\">\n <Spinner />\n {t('admin.loading', 'Loading…') ?? 'Loading…'}\n </p>\n ) : null}\n {!loading && rows.length === 0 ? (\n <p className=\"px-3 py-1.5 text-sm text-muted-foreground\">\n {refused\n ? (t('admin.refused', 'That list could not be read.') ?? 'That list could not be read.')\n : (t('admin.noMatches', 'No rows match this filter.') ?? 'No rows match this filter.')}\n </p>\n ) : null}\n </ComboboxContent>\n )\n\n // A set: one chip per chosen row, rendered in the order `value` holds because base-ui removes a\n // chip by its position among them.\n if (many) {\n return (\n <Combobox {...root} multiple value={selected} onValueChange={pick}>\n <ComboboxChips ref={chipsRef} aria-invalid={error ? true : undefined}>\n {allRefused ? (\n <span className=\"text-muted-foreground px-1 text-sm\">{selected[0]!.label}</span>\n ) : selected.map((one) => (\n <ComboboxChip key={one.value} className={chipRemovePhone} removeLabel={t('admin.remove', 'Remove')} showRemove={!readOnly}>\n {one.label}\n </ComboboxChip>\n ))}\n <ComboboxChipsInput\n id={id}\n // A hint is for an empty box: beside a chip it reads as a value nobody chose, so the box keeps\n // its name in `aria-label` and says nothing once a row is held.\n placeholder={selected.length ? undefined : (placeholder ?? searchLabel)}\n disabled={readOnly}\n readOnly={!searchable}\n aria-label={searchLabel}\n aria-describedby={describedBy}\n aria-invalid={error ? true : undefined}\n aria-required={ariaRequired(field)}\n />\n </ComboboxChips>\n {list}\n </Combobox>\n )\n }\n\n return (\n // The keyboard model (roles, aria wiring, arrows, Enter, Escape) is base-ui's, so none is here.\n <Combobox {...root} value={selected[0] ?? null} onValueChange={(next) => pick(next ? [next] : [])}>\n <ComboboxInput\n id={id}\n className={comboboxPhone}\n placeholder={placeholder ?? searchLabel}\n disabled={readOnly}\n // A target whose title cannot take `like` offers a list and no search, so the box takes no term\n // and still takes the focus, the arrows and Enter.\n readOnly={!searchable}\n aria-label={searchLabel}\n aria-describedby={describedBy}\n aria-invalid={error ? true : undefined}\n aria-required={ariaRequired(field)}\n // The two icon-only buttons of the addon are named here because base-ui names neither.\n clearLabel={t('admin.clear', 'Clear')}\n triggerLabel={t('admin.showOptions', 'Show options')}\n showClear={held.length > 0 && !readOnly}\n />\n {list}\n </Combobox>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 379037afe9749f23307c507f39a782a9fa4e232e83f63894be6f9aef24f76cb5\n",
|
|
15
|
+
"type": "registry:component",
|
|
16
|
+
"target": "@components/mithaq/relationship-control.tsx"
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"type": "registry:component"
|
|
20
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "mithaq-rich-text",
|
|
4
|
+
"title": "Mithaq rich text",
|
|
5
|
+
"description": "The richText kind's control and read view: a Lexical editor with history, block formats, text formats, links, alignment, a divider and the / menu, written in the page's direction, and its viewer, both over the nodes and picks a screen provides, and both drawing a stored value whose node they cannot mount as its words, read-only, with the reason in the viewer's language; and the node list itself, which the server reads with too.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"lexical@0.52.0",
|
|
8
|
+
"@lexical/extension@0.52.0",
|
|
9
|
+
"@lexical/link@0.52.0",
|
|
10
|
+
"@lexical/list@0.52.0",
|
|
11
|
+
"@lexical/react@0.52.0",
|
|
12
|
+
"@lexical/rich-text@0.52.0",
|
|
13
|
+
"@lexical/selection@0.52.0",
|
|
14
|
+
"@lexical/utils@0.52.0"
|
|
15
|
+
],
|
|
16
|
+
"registryDependencies": [
|
|
17
|
+
"button",
|
|
18
|
+
"button-group",
|
|
19
|
+
"direction",
|
|
20
|
+
"dropdown-menu",
|
|
21
|
+
"input",
|
|
22
|
+
"toggle",
|
|
23
|
+
"@mithaq/mithaq-controls"
|
|
24
|
+
],
|
|
25
|
+
"files": [
|
|
26
|
+
{
|
|
27
|
+
"path": "registry/rich-text.tsx",
|
|
28
|
+
"content": "import { createContext, use, useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type JSX, type KeyboardEvent as ReactKeyboardEvent, type ReactElement, type ReactNode } from 'react'\nimport { createPortal } from 'react-dom'\nimport { LexicalComposer } from '@lexical/react/LexicalComposer'\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'\nimport { ContentEditable } from '@lexical/react/LexicalContentEditable'\nimport { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'\nimport { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'\nimport { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'\nimport { ListPlugin } from '@lexical/react/LexicalListPlugin'\nimport { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin'\nimport { HorizontalRulePlugin } from '@lexical/react/LexicalHorizontalRulePlugin'\nimport { HorizontalRuleNode as HorizontalRuleView, INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'\nimport { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'\nimport { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'\nimport { LexicalTypeaheadMenuPlugin, MenuOption, useBasicTypeaheadTriggerMatch } from '@lexical/react/LexicalTypeaheadMenuPlugin'\nimport { $isLinkNode, $toggleLink, TOGGLE_LINK_COMMAND } from '@lexical/link'\nimport { Input } from '@/components/ui/input'\nimport { $isListNode, INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, ListNode } from '@lexical/list'\nimport { $createHeadingNode, $createQuoteNode, $isHeadingNode, $isQuoteNode, type HeadingTagType } from '@lexical/rich-text'\nimport { $isAtNodeEnd, $setBlocksType } from '@lexical/selection'\nimport { $dfs, $findMatchingParent, $getNearestBlockElementAncestorOrThrow, $getNearestNodeOfType, mergeRegister } from '@lexical/utils'\nimport {\n $createParagraphNode,\n $getRoot,\n createEditor,\n $getSelection,\n $isElementNode,\n $isLineBreakNode,\n $isNodeSelection,\n $isRangeSelection,\n $isTextNode,\n BLUR_COMMAND,\n CAN_REDO_COMMAND,\n CAN_UNDO_COMMAND,\n COMMAND_PRIORITY_CRITICAL,\n COMMAND_PRIORITY_HIGH,\n COMMAND_PRIORITY_LOW,\n FORMAT_ELEMENT_COMMAND,\n FORMAT_TEXT_COMMAND,\n HISTORY_MERGE_TAG,\n INDENT_CONTENT_COMMAND,\n KEY_ESCAPE_COMMAND,\n OUTDENT_CONTENT_COMMAND,\n REDO_COMMAND,\n SELECTION_CHANGE_COMMAND,\n UNDO_COMMAND,\n type BaseSelection,\n type ElementFormatType,\n type EditorState,\n type ElementNode,\n type Klass,\n type LexicalEditor,\n type LexicalNode,\n type RangeSelection,\n type TextFormatType,\n type TextNode,\n} from 'lexical'\nimport {\n CheckIcon,\n PencilIcon,\n TrashIcon,\n XIcon,\n AlignCenterIcon,\n AlignJustifyIcon,\n AlignLeftIcon,\n AlignRightIcon,\n BoldIcon,\n ChevronDownIcon,\n CodeIcon,\n EraserIcon,\n Heading1Icon,\n Heading2Icon,\n Heading3Icon,\n IndentDecreaseIcon,\n IndentIncreaseIcon,\n ItalicIcon,\n LinkIcon,\n ListIcon,\n ListOrderedIcon,\n ListTodoIcon,\n MinusIcon,\n QuoteIcon,\n RedoIcon,\n StrikethroughIcon,\n TextIcon,\n UnderlineIcon,\n UndoIcon,\n} from 'lucide-react'\nimport { useDirection } from '@/components/ui/direction'\nimport { Button } from '@/components/ui/button'\nimport { ButtonGroup } from '@/components/ui/button-group'\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'\nimport { Toggle } from '@/components/ui/toggle'\nimport { cn } from '@/lib/utils'\nimport { ariaRequired, changeOf, type ControlProps, type Translate } from './controls.tsx'\nimport { richTextBounds, safeUrl, typesOf, unknownType } from 'mithaq/rich-text'\nimport { defaultNodes } from './rich-text-nodes.ts'\n\n/** One node of Lexical's serialized editor state. */\nexport type RichTextNode = { type: string; children?: RichTextNode[]; text?: string; [key: string]: unknown }\n/** What a `richText` field holds. */\nexport type RichTextValue = { root: RichTextNode & { type: 'root'; children: RichTextNode[] }; [key: string]: unknown }\nexport type RichTextDirection = 'ltr' | 'rtl'\n\n/** One empty paragraph, with no direction of its own so the block takes the one its first letter gives it. */\nconst emptyValue = (): RichTextValue => ({\n root: {\n children: [{ children: [], direction: null, format: '', indent: 0, textFormat: 0, textStyle: '', type: 'paragraph', version: 1 }],\n direction: null,\n format: '',\n indent: 0,\n type: 'root',\n version: 1,\n },\n})\n\nconst valueOf = (value: unknown): RichTextValue => {\n const held = value && typeof value === 'object' && (value as RichTextValue).root ? (value as RichTextValue) : undefined\n return held?.root.children?.length ? held : emptyValue()\n}\n\n/** Whether the editor holds nothing at all: only empty paragraphs, which the control reports as `null`. */\nconst blank = (value: RichTextValue) => value.root.children.every((node) => node.type === 'paragraph' && !node.children?.length)\n\nconst theme = {\n paragraph: 'mb-2',\n heading: {\n h1: 'mb-3 text-2xl font-semibold',\n h2: 'mb-3 text-xl font-semibold',\n h3: 'mb-2 text-lg font-semibold',\n },\n list: {\n nested: { listitem: 'list-none before:hidden after:hidden' },\n ol: 'ms-6 list-decimal',\n ul: 'ms-6 list-disc',\n checklist: 'ms-2 list-none',\n listitem: 'mb-1',\n listitemChecked:\n 'relative mb-1 list-none px-7 line-through opacity-70 before:absolute before:start-1 before:top-1 before:size-4 before:rounded before:border before:border-primary before:bg-primary after:absolute after:start-[9px] after:top-[5px] after:block after:h-2 after:w-1 after:rotate-45 after:border-b-2 after:border-e-2 after:border-white after:content-[\"\"]',\n listitemUnchecked:\n 'relative mb-1 list-none px-7 before:absolute before:start-1 before:top-1 before:size-4 before:rounded before:border before:border-primary before:content-[\"\"]',\n },\n quote: 'mb-2 border-s-2 ps-3 text-muted-foreground',\n link: 'text-primary underline underline-offset-4',\n hr: 'my-3 border-t',\n text: {\n bold: 'font-semibold',\n italic: 'italic',\n underline: 'underline underline-offset-2',\n strikethrough: 'line-through',\n underlineStrikethrough: 'underline line-through',\n code: 'rounded bg-muted px-1 font-mono text-[0.9em]',\n },\n}\n\n/** What a toolbar or a slash entry runs with: the editor, and whether the entry is the block the selection is in. */\ntype Selected = { editor: LexicalEditor; isActive: boolean }\n\n/**\n * One entry of the `/` menu, and of the block menu where `block: true`: the block type it makes,\n * its words in each locale, its icon, and what it does. A project's own node brings its own pick beside `defaultPicks`.\n */\nexport type RichTextPick = {\n key: string\n label: Readonly<Record<string, string>>\n Icon: ComponentType<{ className?: string }>\n keywords?: readonly string[]\n block?: true\n onSelect: (selected: Selected) => void\n}\n\nconst pickLabel = (pick: RichTextPick, locale: string) => pick.label[locale] ?? pick.label.en ?? pick.key\n\ntype Toolbar = { activeEditor: LexicalEditor; blockType: string; setBlockType: (blockType: string) => void }\n\n/** Runs `callback` on every selection change and once on mount. */\nfunction useSelectionChange(activeEditor: LexicalEditor, callback: (selection: BaseSelection) => void) {\n const latest = useRef(callback)\n latest.current = callback\n useEffect(\n () =>\n activeEditor.registerCommand(\n SELECTION_CHANGE_COMMAND,\n () => {\n const selection = $getSelection()\n if (selection) latest.current(selection)\n return false\n },\n COMMAND_PRIORITY_CRITICAL,\n ),\n [activeEditor],\n )\n useEffect(() => {\n activeEditor.getEditorState().read(() => {\n const selection = $getSelection()\n if (selection) latest.current(selection)\n })\n }, [activeEditor])\n}\n\nfunction getSelectedNode(selection: RangeSelection): TextNode | ElementNode {\n const anchorNode = selection.anchor.getNode()\n const focusNode = selection.focus.getNode()\n if (anchorNode === focusNode) return anchorNode\n return selection.isBackward()\n ? ($isAtNodeEnd(selection.focus) ? anchorNode : focusNode)\n : ($isAtNodeEnd(selection.anchor) ? anchorNode : focusNode)\n}\n\nconst toParagraph = (editor: LexicalEditor) =>\n editor.update(() => {\n const selection = $getSelection()\n if ($isRangeSelection(selection)) $setBlocksType(selection, () => $createParagraphNode())\n })\n\nconst toHeading = (editor: LexicalEditor, tag: HeadingTagType) =>\n editor.update(() => {\n const selection = $getSelection()\n if ($isRangeSelection(selection)) $setBlocksType(selection, () => $createHeadingNode(tag))\n })\n\nconst listOf = (command: typeof INSERT_UNORDERED_LIST_COMMAND) => ({ editor, isActive }: Selected) =>\n isActive ? toParagraph(editor) : editor.dispatchCommand(command, undefined)\n\nfunction HistoryButtons({ activeEditor, t }: { activeEditor: LexicalEditor; t: Translate }) {\n const [canUndo, setCanUndo] = useState(false)\n const [canRedo, setCanRedo] = useState(false)\n useEffect(\n () =>\n mergeRegister(\n activeEditor.registerCommand<boolean>(CAN_UNDO_COMMAND, (payload) => (setCanUndo(payload), false), COMMAND_PRIORITY_CRITICAL),\n activeEditor.registerCommand<boolean>(CAN_REDO_COMMAND, (payload) => (setCanRedo(payload), false), COMMAND_PRIORITY_CRITICAL),\n ),\n [activeEditor],\n )\n const undo = t('admin.richText.undo', 'Undo') ?? 'Undo'\n const redo = t('admin.richText.redo', 'Redo') ?? 'Redo'\n return (\n <ButtonGroup>\n <Button type=\"button\" variant=\"outline\" size=\"icon-sm\" className=\"max-sm:size-11\" title={undo} aria-label={undo} disabled={!canUndo} onClick={() => activeEditor.dispatchCommand(UNDO_COMMAND, undefined)}>\n <UndoIcon className=\"size-4 rtl:-scale-x-100\" />\n </Button>\n <Button type=\"button\" variant=\"outline\" size=\"icon-sm\" className=\"max-sm:size-11\" title={redo} aria-label={redo} disabled={!canRedo} onClick={() => activeEditor.dispatchCommand(REDO_COMMAND, undefined)}>\n <RedoIcon className=\"size-4 rtl:-scale-x-100\" />\n </Button>\n </ButtonGroup>\n )\n}\n\nconst nearestBlock = (anchorNode: LexicalNode) =>\n $isElementNode(anchorNode) && !anchorNode.isInline() ? anchorNode : $findMatchingParent(anchorNode, (node) => $isElementNode(node) && !node.isInline())\n\nfunction BlockDropdown({ toolbar, t, locale, picks }: { toolbar: Toolbar; t: Translate; locale: string; picks: readonly RichTextPick[] }) {\n const { activeEditor, blockType, setBlockType } = toolbar\n useSelectionChange(activeEditor, (selection) => {\n if (!$isRangeSelection(selection)) return\n const anchorNode = selection.anchor.getNode()\n const element = nearestBlock(anchorNode) ?? anchorNode.getTopLevelElementOrThrow()\n if (activeEditor.getElementByKey(element.getKey()) === null) return\n if ($isListNode(element)) {\n const parent = $getNearestNodeOfType<ListNode>(anchorNode, ListNode)\n setBlockType((parent ?? element).getListType())\n } else {\n setBlockType($isHeadingNode(element) ? element.getTag() : element.getType())\n }\n })\n const blocks = picks.filter((pick) => pick.block)\n const current = blocks.find((entry) => entry.key === blockType) ?? blocks[0]\n if (!current) return null\n // The accessible name contains the visible words, so a voice control can say them to press the button.\n const name = (t('admin.richText.blockFormatCurrent', 'Block format: {block}') ?? 'Block format: {block}').replace('{block}', () => pickLabel(current, locale))\n return (\n <DropdownMenu>\n <DropdownMenuTrigger render={<Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"gap-1 px-2 max-sm:h-11\" title={name} aria-label={name} />}>\n <current.Icon className=\"size-4\" />\n <span className=\"text-sm\">{pickLabel(current, locale)}</span>\n <ChevronDownIcon className=\"size-3\" />\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n {blocks.map((pick) => (\n <DropdownMenuItem key={pick.key} onClick={() => pick.onSelect({ editor: activeEditor, isActive: pick.key === blockType })}>\n <div className=\"flex items-center gap-1 font-normal\">\n <pick.Icon className=\"size-4\" />\n {pickLabel(pick, locale)}\n </div>\n </DropdownMenuItem>\n ))}\n </DropdownMenuContent>\n </DropdownMenu>\n )\n}\n\nconst formats = [\n { format: 'bold', Icon: BoldIcon, label: (t: Translate) => t('admin.richText.bold', 'Bold') },\n { format: 'italic', Icon: ItalicIcon, label: (t: Translate) => t('admin.richText.italic', 'Italic') },\n { format: 'underline', Icon: UnderlineIcon, label: (t: Translate) => t('admin.richText.underline', 'Underline') },\n { format: 'strikethrough', Icon: StrikethroughIcon, label: (t: Translate) => t('admin.richText.strikethrough', 'Strikethrough') },\n { format: 'code', Icon: CodeIcon, label: (t: Translate) => t('admin.richText.code', 'Code') },\n] as const\nfunction FormatButtons({ t }: { t: Translate }) {\n const [editor] = useLexicalComposerContext()\n const [active, setActive] = useState<readonly string[]>([])\n useSelectionChange(editor, (selection) => {\n if ($isRangeSelection(selection)) setActive(formats.filter(({ format }) => selection.hasFormat(format)).map(({ format }) => format))\n })\n return (\n <ButtonGroup>\n {formats.map(({ format, Icon, label }) => {\n const name = label(t) ?? format\n return (\n <Toggle key={format} variant=\"outline\" size=\"sm\" className=\"max-sm:h-11 max-sm:min-w-11\" title={name} aria-label={name} pressed={active.includes(format)} onPressedChange={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, format as TextFormatType)}>\n <Icon className=\"size-4\" />\n </Toggle>\n )\n })}\n </ButtonGroup>\n )\n}\n\nfunction ClearFormatting({ t }: { t: Translate }) {\n const [editor] = useLexicalComposerContext()\n const clear = useCallback(() => {\n editor.update(() => {\n const selection = $getSelection()\n if (!$isRangeSelection(selection)) return\n const { anchor, focus } = selection\n const nodes = selection.getNodes()\n const extractedNodes = selection.extract()\n if (anchor.key === focus.key && anchor.offset === focus.offset) return\n nodes.forEach((node, idx) => {\n // The first and last node are split by the selection, so text outside\n // it keeps its format.\n if ($isTextNode(node)) {\n let textNode = node\n if (idx === 0 && anchor.offset !== 0) textNode = textNode.splitText(anchor.offset)[1] || textNode\n if (idx === nodes.length - 1) textNode = textNode.splitText(focus.offset)[0] || textNode\n const extractedTextNode = extractedNodes[0]\n if (nodes.length === 1 && $isTextNode(extractedTextNode)) textNode = extractedTextNode\n if (textNode.__style !== '') textNode.setStyle('')\n if (textNode.__format !== 0) {\n textNode.setFormat(0)\n $getNearestBlockElementAncestorOrThrow(textNode).setFormat('')\n }\n } else if ($isHeadingNode(node) || $isQuoteNode(node)) {\n node.replace($createParagraphNode(), true)\n }\n })\n })\n }, [editor])\n const name = t('admin.richText.clearFormatting', 'Clear formatting') ?? 'Clear formatting'\n return (\n <Button type=\"button\" variant=\"outline\" size=\"icon-sm\" className=\"max-sm:size-11\" title={name} aria-label={name} onClick={clear}>\n <EraserIcon className=\"size-4\" />\n </Button>\n )\n}\n\nfunction LinkButton({ t, setLinkEditMode }: { t: Translate; setLinkEditMode: (editing: boolean) => void }) {\n const [editor] = useLexicalComposerContext()\n const [isLink, setIsLink] = useState(false)\n useSelectionChange(editor, (selection) => {\n if (!$isRangeSelection(selection)) return\n const node = getSelectedNode(selection)\n setIsLink($isLinkNode(node.getParent()) || $isLinkNode(node))\n })\n const name = isLink ? (t('admin.richText.removeLink', 'Remove link') ?? 'Remove link') : (t('admin.richText.addLink', 'Add link') ?? 'Add link')\n return (\n <Toggle variant=\"outline\" size=\"sm\" className=\"max-sm:h-11 max-sm:min-w-11\" title={name} aria-label={name} pressed={isLink} onPressedChange={() => {\n setLinkEditMode(!isLink)\n // `LinkPlugin`'s own check refuses the placeholder, so it is made past it; `dropPlaceholders` removes it once the link editor is no longer open on it.\n if (isLink) editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)\n else editor.update(() => $toggleLink(newLink))\n }}>\n <LinkIcon className=\"size-4\" />\n </Toggle>\n )\n}\n\nconst alignments = [\n { format: 'left', Icon: AlignLeftIcon, label: (t: Translate) => t('admin.richText.alignLeft', 'Align left') },\n { format: 'center', Icon: AlignCenterIcon, label: (t: Translate) => t('admin.richText.alignCenter', 'Align center') },\n { format: 'right', Icon: AlignRightIcon, label: (t: Translate) => t('admin.richText.alignRight', 'Align right') },\n { format: 'justify', Icon: AlignJustifyIcon, label: (t: Translate) => t('admin.richText.justify', 'Justify') },\n] as const\nfunction AlignButtons({ t }: { t: Translate }) {\n const [editor] = useLexicalComposerContext()\n const [current, setCurrent] = useState<ElementFormatType>('')\n useSelectionChange(editor, (selection) => {\n if (!$isRangeSelection(selection)) return\n const node = getSelectedNode(selection)\n const parent = node.getParent()\n // A link is inline, so a link's alignment is its paragraph's.\n const block = $isLinkNode(parent) ? $findMatchingParent(node, (one) => $isElementNode(one) && !one.isInline()) : undefined\n setCurrent($isElementNode(block) ? block.getFormatType() : $isElementNode(node) ? node.getFormatType() : (parent?.getFormatType() ?? ''))\n })\n const outdent = t('admin.richText.outdent', 'Outdent') ?? 'Outdent'\n const indent = t('admin.richText.indent', 'Indent') ?? 'Indent'\n return (\n <>\n <ButtonGroup>\n {alignments.map(({ format, Icon, label }) => {\n const name = label(t) ?? format\n return (\n <Toggle key={format} variant=\"outline\" size=\"sm\" className=\"max-sm:h-11 max-sm:min-w-11\" title={name} aria-label={name} pressed={current === format} onPressedChange={() => (setCurrent(format), editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, format))}>\n <Icon className=\"size-4\" />\n </Toggle>\n )\n })}\n </ButtonGroup>\n <ButtonGroup>\n <Button type=\"button\" variant=\"outline\" size=\"icon-sm\" className=\"max-sm:size-11\" title={outdent} aria-label={outdent} onClick={() => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)}>\n <IndentDecreaseIcon className=\"size-4 rtl:-scale-x-100\" />\n </Button>\n <Button type=\"button\" variant=\"outline\" size=\"icon-sm\" className=\"max-sm:size-11\" title={indent} aria-label={indent} onClick={() => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)}>\n <IndentIncreaseIcon className=\"size-4 rtl:-scale-x-100\" />\n </Button>\n </ButtonGroup>\n </>\n )\n}\n\n/** The address a new link starts with: the link editor opens on it so it is typed over, and no save may hold it. */\nconst newLink = 'https://'\n\n/** Unwraps every link still holding the placeholder (except the selected one when `keepSelected`) back into its words, so a saved value holds none. */\nconst dropPlaceholders = (editor: LexicalEditor, keepSelected: boolean) =>\n editor.update(() => {\n const selection = $getSelection()\n const kept = keepSelected && $isRangeSelection(selection) ? $findMatchingParent(getSelectedNode(selection), $isLinkNode) : null\n for (const { node } of $dfs()) {\n if (!$isLinkNode(node) || node.getURL() !== newLink || node.is(kept)) continue\n for (const child of node.getChildren()) node.insertBefore(child)\n node.remove()\n }\n })\n\nconst holdsPlaceholder = (state: EditorState) => state.read(() => $dfs().some(({ node }) => $isLinkNode(node) && node.getURL() === newLink))\n\n/** Whether the focus is in the link editor's own box, where a placeholder is being typed over. */\nconst typingLink = () => document.activeElement?.classList.contains('link-input') === true\n\nconst VERTICAL_GAP = 10\nconst HORIZONTAL_OFFSET = 5\n\nfunction setFloatingElemPositionForLinkEditor(targetRect: DOMRect | null, floatingElem: HTMLElement, anchorElem: HTMLElement): void {\n const scrollerElem = anchorElem.parentElement\n if (targetRect === null || !scrollerElem) {\n floatingElem.style.opacity = '0'\n floatingElem.style.transform = 'translate(-10000px, -10000px)'\n return\n }\n const floatingElemRect = floatingElem.getBoundingClientRect()\n const anchorElementRect = anchorElem.getBoundingClientRect()\n const editorScrollerRect = scrollerElem.getBoundingClientRect()\n let top = targetRect.top - VERTICAL_GAP\n let left = targetRect.left - HORIZONTAL_OFFSET\n if (top < editorScrollerRect.top) top += floatingElemRect.height + targetRect.height + VERTICAL_GAP * 2\n if (left + floatingElemRect.width > editorScrollerRect.right) left = editorScrollerRect.right - floatingElemRect.width - HORIZONTAL_OFFSET\n // A right-to-left page puts the bubble's start on the right, which the\n // physical offset above would push off the edge.\n if (left < editorScrollerRect.left) left = editorScrollerRect.left\n top -= anchorElementRect.top\n left -= anchorElementRect.left\n floatingElem.style.opacity = '1'\n floatingElem.style.transform = `translate(${left}px, ${top}px)`\n}\n\nfunction FloatingLinkEditor({ editor, isLink, setIsLink, anchorElem, isLinkEditMode, setIsLinkEditMode, t }: {\n editor: LexicalEditor\n isLink: boolean\n setIsLink: (isLink: boolean) => void\n anchorElem: HTMLElement\n isLinkEditMode: boolean\n setIsLinkEditMode: (editing: boolean) => void\n t: Translate\n}): JSX.Element {\n const editorRef = useRef<HTMLDivElement | null>(null)\n const inputRef = useRef<HTMLInputElement>(null)\n const [linkUrl, setLinkUrl] = useState('')\n const [editedLinkUrl, setEditedLinkUrl] = useState(newLink)\n // False until the address is edited, so the placeholder it opens on is not flagged invalid.\n const [edited, setEdited] = useState(false)\n const [lastSelection, setLastSelection] = useState<BaseSelection | null>(null)\n\n const $updateLinkEditor = useCallback(() => {\n const selection = $getSelection()\n if ($isRangeSelection(selection)) {\n const node = getSelectedNode(selection)\n const linkParent = $findMatchingParent(node, $isLinkNode)\n if (linkParent) setLinkUrl(linkParent.getURL())\n else if ($isLinkNode(node)) setLinkUrl(node.getURL())\n else setLinkUrl('')\n if (isLinkEditMode) setEditedLinkUrl(linkUrl)\n }\n const editorElem = editorRef.current\n const nativeSelection = window.getSelection()\n const activeElement = document.activeElement\n if (editorElem === null) return\n const rootElement = editor.getRootElement()\n if (selection !== null && nativeSelection !== null && rootElement !== null && rootElement.contains(nativeSelection.anchorNode) && editor.isEditable()) {\n const domRect: DOMRect | undefined = nativeSelection.focusNode?.parentElement?.getBoundingClientRect()\n if (domRect) {\n domRect.y += 40\n setFloatingElemPositionForLinkEditor(domRect, editorElem, anchorElem)\n }\n setLastSelection(selection)\n } else if (!activeElement || !activeElement.classList.contains('link-input')) {\n if (rootElement !== null) setFloatingElemPositionForLinkEditor(null, editorElem, anchorElem)\n setLastSelection(null)\n setIsLinkEditMode(false)\n setLinkUrl('')\n }\n return true\n }, [anchorElem, editor, setIsLinkEditMode, isLinkEditMode, linkUrl])\n\n useEffect(() => {\n const scrollerElem = anchorElem.parentElement\n const update = () => editor.getEditorState().read(() => void $updateLinkEditor())\n window.addEventListener('resize', update)\n scrollerElem?.addEventListener('scroll', update)\n return () => {\n window.removeEventListener('resize', update)\n scrollerElem?.removeEventListener('scroll', update)\n }\n }, [anchorElem.parentElement, editor, $updateLinkEditor])\n\n useEffect(\n () =>\n mergeRegister(\n editor.registerUpdateListener(({ editorState }) => editorState.read(() => void $updateLinkEditor())),\n editor.registerCommand(SELECTION_CHANGE_COMMAND, () => ($updateLinkEditor(), true), COMMAND_PRIORITY_LOW),\n editor.registerCommand(KEY_ESCAPE_COMMAND, () => (isLink ? (setIsLink(false), true) : false), COMMAND_PRIORITY_HIGH),\n ),\n [editor, $updateLinkEditor, setIsLink, isLink],\n )\n\n useEffect(() => {\n editor.getEditorState().read(() => void $updateLinkEditor())\n }, [editor, $updateLinkEditor])\n\n useEffect(() => {\n if (isLinkEditMode && inputRef.current) {\n inputRef.current.focus()\n setIsLink(true)\n }\n }, [isLinkEditMode, isLink])\n\n useEffect(() => setEdited(false), [isLinkEditMode])\n // Every address written passes `safeUrl`, the check the server makes of a write.\n const allowed = safeUrl(editedLinkUrl) && editedLinkUrl.trim() !== newLink\n const handleLinkSubmission = () => {\n if (lastSelection === null || !allowed) return\n if (linkUrl !== '') editor.dispatchCommand(TOGGLE_LINK_COMMAND, editedLinkUrl.trim())\n setEditedLinkUrl(newLink)\n setIsLinkEditMode(false)\n }\n const cancel = () => {\n if (linkUrl === newLink) editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)\n setIsLinkEditMode(false)\n setIsLink(false)\n }\n const address = t('admin.richText.linkAddress', 'Link address') ?? 'Link address'\n const cancelling = t('admin.richText.cancelLink', 'Cancel') ?? 'Cancel'\n const saving = t('admin.richText.saveLink', 'Save link') ?? 'Save link'\n const editing = t('admin.richText.editLink', 'Edit link') ?? 'Edit link'\n const removing = t('admin.richText.removeLink', 'Remove link') ?? 'Remove link'\n return (\n <div ref={editorRef} data-link-editor className=\"absolute top-0 left-0 z-20 w-full max-w-sm rounded-md opacity-0 shadow-md\">\n {!isLink ? null : isLinkEditMode ? (\n <div className=\"bg-popover flex items-center gap-2 rounded-md border p-1 ps-2\">\n <Input\n ref={inputRef}\n dir=\"ltr\"\n type=\"url\"\n aria-label={address}\n aria-invalid={edited && !allowed ? true : undefined}\n value={editedLinkUrl}\n onChange={(event) => (setEdited(true), setEditedLinkUrl(event.target.value))}\n onBlur={() => setTimeout(() => {\n if (editor.getRootElement()?.contains(document.activeElement) || editorRef.current?.contains(document.activeElement)) return\n dropPlaceholders(editor, false)\n setIsLinkEditMode(false)\n })}\n onKeyDown={(event) => {\n if (event.key === 'Enter') {\n event.preventDefault()\n handleLinkSubmission()\n } else if (event.key === 'Escape') {\n event.preventDefault()\n cancel()\n }\n }}\n className=\"link-input flex-grow\"\n />\n <Button type=\"button\" size=\"icon\" variant=\"ghost\" title={cancelling} aria-label={cancelling} onClick={cancel} className=\"shrink-0\">\n <XIcon className=\"size-4\" />\n </Button>\n <Button type=\"button\" size=\"icon\" disabled={!allowed} title={saving} aria-label={saving} onClick={handleLinkSubmission} className=\"shrink-0\">\n <CheckIcon className=\"size-4\" />\n </Button>\n </div>\n ) : (\n <div className=\"bg-popover flex items-center justify-between gap-2 rounded-md border p-1 ps-2\">\n <a href={safeUrl(linkUrl) ? linkUrl : undefined} target=\"_blank\" rel=\"noopener noreferrer\" dir=\"ltr\" className=\"overflow-hidden text-sm text-ellipsis whitespace-nowrap\">\n {linkUrl}\n </a>\n <div className=\"flex\">\n <Button type=\"button\" size=\"icon\" variant=\"ghost\" title={editing} aria-label={editing} onClick={() => (setEditedLinkUrl(linkUrl), setIsLinkEditMode(true))}>\n <PencilIcon className=\"size-4\" />\n </Button>\n <Button type=\"button\" size=\"icon\" variant=\"destructive\" title={removing} aria-label={removing} onClick={() => editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)}>\n <TrashIcon className=\"size-4\" />\n </Button>\n </div>\n </div>\n )}\n </div>\n )\n}\n\n/** Draws the link editor over the anchor while the selection sits in one link. */\nfunction FloatingLinkEditorPlugin({ anchorElem, isLinkEditMode, setIsLinkEditMode, t }: {\n anchorElem: HTMLDivElement | null\n isLinkEditMode: boolean\n setIsLinkEditMode: (editing: boolean) => void\n t: Translate\n}): JSX.Element | null {\n const [editor] = useLexicalComposerContext()\n const [activeEditor, setActiveEditor] = useState(editor)\n const [isLink, setIsLink] = useState(false)\n useEffect(() => {\n const $updateToolbar = () => {\n const selection = $getSelection()\n if ($isRangeSelection(selection)) {\n const focusLinkNode = $findMatchingParent(getSelectedNode(selection), $isLinkNode)\n if (!focusLinkNode) {\n setIsLink(false)\n return\n }\n const badNode = selection.getNodes().filter((node) => !$isLineBreakNode(node)).find((node) => {\n const linkNode = $findMatchingParent(node, $isLinkNode)\n return !focusLinkNode.is(linkNode) || (linkNode && !linkNode.is(focusLinkNode))\n })\n setIsLink(!badNode)\n } else if ($isNodeSelection(selection)) {\n const node = selection.getNodes()[0]\n setIsLink(!!node && ($isLinkNode(node.getParent()) || $isLinkNode(node)))\n }\n }\n // A placeholder is dropped once the link editor is no longer open on it, so a click elsewhere or a save never stores one.\n const stale = (editorState: EditorState) =>\n editorState.read(() => {\n const selection = $getSelection()\n const inside = $isRangeSelection(selection) ? $findMatchingParent(getSelectedNode(selection), $isLinkNode) : null\n return $dfs().some(({ node }) => $isLinkNode(node) && node.getURL() === newLink && !node.is(inside))\n })\n return mergeRegister(\n editor.registerUpdateListener(({ editorState }) => {\n editorState.read(() => $updateToolbar())\n if (stale(editorState)) dropPlaceholders(editor, true)\n }),\n editor.registerCommand(SELECTION_CHANGE_COMMAND, (_payload, newEditor) => ($updateToolbar(), setActiveEditor(newEditor), false), COMMAND_PRIORITY_CRITICAL),\n editor.registerCommand(BLUR_COMMAND, () => (setTimeout(() => typingLink() || dropPlaceholders(editor, false)), false), COMMAND_PRIORITY_LOW),\n )\n }, [editor])\n if (!anchorElem) return null\n return createPortal(\n <FloatingLinkEditor editor={activeEditor} isLink={isLink} anchorElem={anchorElem} setIsLink={setIsLink} isLinkEditMode={isLinkEditMode} setIsLinkEditMode={setIsLinkEditMode} t={t} />,\n anchorElem,\n )\n}\n\nconst headingPick = (tag: HeadingTagType, Icon: RichTextPick['Icon'], level: string): RichTextPick => ({\n key: tag, label: { en: `Heading ${level}`, ar: `عنوان ${level}` }, Icon, keywords: ['heading', 'header', tag], block: true, onSelect: ({ editor }) => toHeading(editor, tag),\n})\nexport const defaultPicks: readonly RichTextPick[] = [\n { key: 'paragraph', label: { en: 'Paragraph', ar: 'فقرة' }, Icon: TextIcon, keywords: ['normal', 'paragraph', 'p', 'text'], block: true, onSelect: ({ editor }) => toParagraph(editor) },\n headingPick('h1', Heading1Icon, '1'),\n headingPick('h2', Heading2Icon, '2'),\n headingPick('h3', Heading3Icon, '3'),\n { key: 'number', label: { en: 'Numbered list', ar: 'قائمة مرقمة' }, Icon: ListOrderedIcon, keywords: ['numbered list', 'ordered list', 'ol'], block: true, onSelect: listOf(INSERT_ORDERED_LIST_COMMAND) },\n { key: 'bullet', label: { en: 'Bulleted list', ar: 'قائمة نقطية' }, Icon: ListIcon, keywords: ['bulleted list', 'unordered list', 'ul'], block: true, onSelect: listOf(INSERT_UNORDERED_LIST_COMMAND) },\n { key: 'check', label: { en: 'Check list', ar: 'قائمة مهام' }, Icon: ListTodoIcon, keywords: ['check list', 'todo list'], block: true, onSelect: listOf(INSERT_CHECK_LIST_COMMAND) },\n {\n key: 'quote',\n label: { en: 'Quote', ar: 'اقتباس' },\n Icon: QuoteIcon,\n keywords: ['block quote'],\n block: true,\n onSelect: ({ editor, isActive }) => (isActive ? undefined : editor.update(() => $setBlocksType($getSelection(), () => $createQuoteNode()))),\n },\n { key: 'horizontalRule', label: { en: 'Divider', ar: 'خط فاصل' }, Icon: MinusIcon, keywords: ['horizontal rule', 'divider', 'hr'], onSelect: ({ editor }) => editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined) },\n]\n\n/** The React subclasses of nodes that draw React, each of the same type as the DOM-free class in the node list and extending it. */\nexport const defaultViews: readonly Klass<LexicalNode>[] = [HorizontalRuleView]\n\n/** A node list as the browser draws it: each class swapped for its React subclass where one extends it. */\nexport const viewsOf = (nodes: readonly Klass<LexicalNode>[], views: readonly Klass<LexicalNode>[] = defaultViews) =>\n nodes.map((node) => views.find((view) => view.prototype instanceof node) ?? node)\n\n/**\n * The nodes and picks every editor and viewer under it draws with, provided once by a screen from\n * the project's own list so the editor, viewer and server read the same nodes. The registry's own where none is provided.\n */\nexport const RichTextContext = createContext<{\n nodes: readonly Klass<LexicalNode>[]\n picks: readonly RichTextPick[]\n /** What a project's own node mounts inside every editor beside its nodes, handed the field's control props (for example the upload node's dialog). */\n Plugin?: ComponentType<ControlProps>\n}>({ nodes: viewsOf(defaultNodes), picks: defaultPicks })\n\nclass PickerOption extends MenuOption {\n item: RichTextPick\n title: string\n constructor(item: RichTextPick, title: string) {\n super(item.key)\n this.item = item\n this.title = title\n }\n}\n\nfunction PickerMenu({ options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }: {\n options: PickerOption[]\n selectedIndex: number | null\n selectOptionAndCleanUp: (option: PickerOption) => void\n setHighlightedIndex: (index: number) => void\n}) {\n const itemRefs = useRef<(HTMLButtonElement | null)[]>([])\n useEffect(() => {\n if (selectedIndex !== null) itemRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest', behavior: 'auto' })\n }, [selectedIndex])\n return (\n <div className=\"border-border bg-popover text-popover-foreground h-min min-w-48 overflow-hidden rounded-lg border p-1 shadow-xl\" role=\"listbox\">\n {options.map((option, index) => {\n const { Icon } = option.item\n return (\n <button\n key={option.key}\n ref={(element) => {\n itemRefs.current[index] = element\n }}\n type=\"button\"\n role=\"option\"\n aria-selected={selectedIndex === index}\n className={cn('flex w-full items-center gap-2 rounded-md px-3 py-2 text-start text-sm outline-none', selectedIndex === index ? 'bg-accent' : 'hover:bg-muted')}\n onClick={() => selectOptionAndCleanUp(option)}\n onMouseDown={(event) => event.preventDefault()}\n onMouseEnter={() => setHighlightedIndex(index)}\n >\n <span className=\"text-muted-foreground flex size-5 items-center justify-center\"><Icon className=\"size-4\" /></span>\n <span className=\"flex-1\">{option.title}</span>\n </button>\n )\n })}\n </div>\n )\n}\n\nfunction SlashMenu({ locale, picks }: { locale: string; picks: readonly RichTextPick[] }): JSX.Element {\n const [editor] = useLexicalComposerContext()\n const [queryString, setQueryString] = useState<string | null>(null)\n const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', { minLength: 0 })\n const options = useMemo(() => {\n const all = picks.map((item) => new PickerOption(item, pickLabel(item, locale)))\n if (!queryString) return all\n // The typed word is compared as text, never as a pattern: a lone bracket would throw in a RegExp.\n const word = queryString.toLowerCase()\n return all.filter((option) => option.title.toLowerCase().includes(word) || (option.item.keywords ?? []).some((keyword) => keyword.includes(word)))\n }, [queryString, locale, picks])\n const onSelectOption = useCallback(\n (selected: PickerOption, nodeToRemove: TextNode | null, closeMenu: () => void) => {\n editor.update(() => {\n nodeToRemove?.remove()\n selected.item.onSelect({ editor, isActive: false })\n closeMenu()\n })\n },\n [editor],\n )\n return (\n <LexicalTypeaheadMenuPlugin<PickerOption>\n onQueryChange={setQueryString}\n onSelectOption={onSelectOption}\n triggerFn={checkForTriggerMatch}\n options={options}\n menuRenderFn={(anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) => {\n if (!anchorElementRef.current || !options.length) return null\n const anchorRect = anchorElementRef.current.getBoundingClientRect()\n return createPortal(\n <div className=\"fixed z-[1000]\" style={{ left: anchorRect.left, top: anchorRect.bottom + 4 }}>\n <PickerMenu options={options} selectedIndex={selectedIndex} selectOptionAndCleanUp={selectOptionAndCleanUp} setHighlightedIndex={setHighlightedIndex} />\n </div>,\n document.body,\n )\n }}\n />\n )\n}\n\n/**\n * Clears the direction of the root and every block, so Lexical writes `dir=\"auto\"` per block and\n * each follows its first strong letter. Written as a history merge, so it is no change the form would save.\n */\nfunction DirectionPlugin() {\n const [editor] = useLexicalComposerContext()\n useEffect(() => {\n editor.update(() => {\n const root = $getRoot()\n root.setDirection(null)\n for (const block of root.getChildren()) if ($isElementNode(block) && block.getDirection() !== null) block.setDirection(null)\n }, { tag: HISTORY_MERGE_TAG })\n }, [editor])\n return null\n}\n\nfunction ToolbarRow({ t, locale, picks, setLinkEditMode, direction }: { t: Translate; locale: string; picks: readonly RichTextPick[]; setLinkEditMode: (editing: boolean) => void; direction: RichTextDirection }) {\n const [editor] = useLexicalComposerContext()\n const bar = useRef<HTMLDivElement>(null)\n const [blockType, setBlockType] = useState('paragraph')\n const [activeEditor, setActiveEditor] = useState(editor)\n useEffect(\n () => activeEditor.registerCommand(SELECTION_CHANGE_COMMAND, (_payload, next) => (setActiveEditor(next), false), COMMAND_PRIORITY_CRITICAL),\n [activeEditor],\n )\n // One tab stop for the whole toolbar: the button that last held focus, or the first enabled one, is tabbable. A button that changes state hands the stop on, so it never lands on a disabled one.\n useEffect(() => {\n const row = bar.current!\n let last: Element | null = null\n const enabled = () => [...row.querySelectorAll('button:not(:disabled)')] as HTMLElement[]\n const sync = () => {\n const all = [...row.querySelectorAll('button')] as HTMLElement[]\n // A button disabled under focus (Undo, once nothing is left to undo) drops focus to the page: hand it to the nearest button that still takes it.\n if (last instanceof HTMLButtonElement && last.disabled && (document.activeElement === last || document.activeElement === document.body)) {\n const at = all.indexOf(last)\n all.slice(at + 1).concat(all.slice(0, at).reverse()).find((one) => !(one as HTMLButtonElement).disabled)?.focus()\n }\n const keep = enabled().find((one) => one === last) ?? enabled()[0]\n for (const one of all) one.tabIndex = one === keep ? 0 : -1\n }\n const focused = (event: FocusEvent) => ((last = event.target as Element), sync())\n // Focus leaving the row forgets the button, so a later change cannot pull focus back in; a button disabled under focus is left to the hand-off above.\n const left = (event: FocusEvent) => {\n if (!row.contains(event.relatedTarget as Node | null) && !(event.target as HTMLButtonElement).disabled) last = null\n }\n sync()\n const watch = new MutationObserver(sync)\n watch.observe(row, { subtree: true, childList: true, attributeFilter: ['disabled'] })\n row.addEventListener('focusin', focused)\n row.addEventListener('focusout', left)\n return () => (watch.disconnect(), row.removeEventListener('focusin', focused), row.removeEventListener('focusout', left))\n }, [])\n const move = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n // Alt+Left, Cmd+Arrow and Ctrl+Home are the browser's own, not the toolbar's.\n if (event.altKey || event.ctrlKey || event.metaKey) return\n const items = [...event.currentTarget.querySelectorAll('button:not(:disabled)')] as HTMLElement[]\n const at = items.indexOf(document.activeElement as HTMLElement)\n // A menu's own items sit in a portal, outside this row, so only a button of the row moves focus.\n if (at < 0) return\n const step = { ArrowRight: direction === 'rtl' ? -1 : 1, ArrowLeft: direction === 'rtl' ? 1 : -1 }[event.key]\n const to = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : step === undefined ? undefined : (at + step + items.length) % items.length\n if (to === undefined) return\n event.preventDefault()\n items[to]!.focus()\n }\n return (\n // Sticky under the page's header, so no ancestor may scroll or clip (the editor box uses overflow-clip). The tint is opaque so scrolling text never shows through.\n // On a phone it is one sideways-scrolling line, with contained width so it does not widen the page.\n <div ref={bar} role=\"toolbar\" aria-label={t('admin.richText.toolbar', 'Formatting')} aria-orientation=\"horizontal\" onKeyDown={move} className=\"border-border bg-[color-mix(in_oklab,var(--muted)_20%,var(--background))] sticky top-[var(--header-bar-height,var(--header-height,0px))] z-9 flex flex-wrap items-center gap-1.5 border-b px-2 py-2 max-sm:flex-nowrap max-sm:overflow-x-auto max-sm:contain-inline-size max-sm:[&>*]:shrink-0\">\n <HistoryButtons activeEditor={activeEditor} t={t} />\n <BlockDropdown toolbar={{ activeEditor, blockType, setBlockType }} t={t} locale={locale} picks={picks} />\n <FormatButtons t={t} />\n <LinkButton t={t} setLinkEditMode={setLinkEditMode} />\n <ClearFormatting t={t} />\n <AlignButtons t={t} />\n </div>\n )\n}\n\nconst configOf = (namespace: string, nodes: readonly Klass<LexicalNode>[], value: RichTextValue, editable: boolean) => ({\n namespace,\n editable,\n editorState: JSON.stringify(value),\n nodes: [...nodes],\n onError(error: Error) {\n throw error\n },\n theme,\n})\n\n/**\n * What stops these nodes drawing a stored value, or nothing: the first node type the value holds\n * that no class of the list registers, or `''` where it nests past the server's bound or Lexical's own parse refuses it.\n */\nexport const unreadable = (value: RichTextValue, nodes: readonly Klass<LexicalNode>[]): string | undefined => {\n const type = unknownType({ node: value.root, types: typesOf(nodes) })\n if (type !== undefined) return type\n try {\n createEditor({ nodes: [...nodes], onError: (error) => { throw error } }).parseEditorState(JSON.stringify(value))\n return undefined\n } catch {\n return ''\n }\n}\n\n/**\n * One node of a value no editor here can mount, drawn as its words; a node no class registers is\n * marked by its type. Stops at the server's depth bound with an ellipsis rather than overflowing the stack.\n */\nfunction Walked({ node, types, inline, depth = richTextBounds.depth }: { node: RichTextNode; types: ReadonlySet<string>; inline: boolean; depth?: number }): ReactNode {\n if (depth <= 0) return '…'\n if (!node || typeof node !== 'object') return null\n if (node.type === 'linebreak') return <br />\n if (typeof node.text === 'string' && types.has(node.type)) return node.text\n const children = Array.isArray(node.children) ? node.children : []\n const runs = children.some((child) => typeof child?.text === 'string')\n const drawn = children.map((child, at) => <Walked key={at} node={child} types={types} inline={runs} depth={depth - 1} />)\n if (!types.has(node.type)) {\n return (\n <span data-unknown-node={node.type} className=\"border-destructive text-destructive rounded border border-dashed px-1\">\n {node.type}{typeof node.text === 'string' ? `: ${node.text}` : null}{drawn.length ? <> {drawn}</> : null}\n </span>\n )\n }\n return inline ? <span>{drawn}</span> : <div className=\"mb-2\">{drawn}</div>\n}\n\n/** A stored value this editor cannot mount, drawn read-only by `Walked`. It reports no change, so the field stays out of every save and nothing it holds is lost. */\nfunction Unreadable({ value, nodes, problem, direction, t, id, describedBy }: {\n value: RichTextValue\n nodes: readonly Klass<LexicalNode>[]\n problem: string\n direction: RichTextDirection\n t: Translate\n id?: string\n describedBy?: string\n}): ReactElement {\n const said = problem\n ? (t('admin.richText.unknownNode', 'This text holds a \"{type}\" node this editor cannot draw, so it is shown as its words and cannot be changed here.') ?? '').replace('{type}', () => problem)\n : (t('admin.richText.unreadable', 'This editor cannot read this text, so it is shown as its words and cannot be changed here.') ?? '')\n return (\n <div dir={direction} data-rich-text-view={id ?? ''} data-rich-text-unreadable={problem} className=\"grid gap-2\">\n <p role=\"alert\" className=\"text-destructive text-sm\">{said}</p>\n <div id={id} aria-describedby={describedBy} className=\"text-sm leading-5\">\n {(Array.isArray(value.root.children) ? value.root.children : []).map((node, at) => <Walked key={at} node={node} types={typesOf(nodes)} inline={false} />)}\n </div>\n </div>\n )\n}\n\n/**\n * The editor, over the screen's nodes and picks (`RichTextContext`) unless handed its own.\n * Uncontrolled past its first value, as Lexical is. A stored value it cannot mount is drawn by `Unreadable` and reports nothing.\n */\nexport function RichTextEditor({ value, onChange, direction, t, locale, id, placeholder, describedBy, invalid, required, nodes, picks, control }: {\n value: unknown\n onChange: (value: RichTextValue) => void\n direction: RichTextDirection\n t: Translate\n locale: string\n id?: string\n placeholder?: string\n describedBy?: string\n invalid?: boolean\n required?: boolean\n nodes?: readonly Klass<LexicalNode>[]\n picks?: readonly RichTextPick[]\n /** The field's control props, which the screen's `Plugin` is handed. */\n control?: ControlProps\n}): ReactElement {\n const provided = use(RichTextContext)\n const { Plugin } = provided\n const drawn = nodes ?? provided.nodes\n const [held] = useState(() => valueOf(value))\n const [problem] = useState(() => unreadable(held, drawn))\n // Built only over a value these nodes read, so a value nested past the bound never reaches `JSON.stringify` or Lexical.\n const [initial] = useState(() => (problem === undefined ? configOf('MithaqRichText', drawn, held, true) : undefined))\n const [isLinkEditMode, setIsLinkEditMode] = useState(false)\n const [anchor, setAnchor] = useState<HTMLDivElement | null>(null)\n if (problem !== undefined || !initial) return <Unreadable value={held} nodes={drawn} problem={problem ?? ''} direction={direction} t={t} id={id} describedBy={describedBy} />\n return (\n <div className=\"border-input bg-background overflow-clip rounded-lg border shadow-xs\" dir={direction} data-rich-text={id}>\n <LexicalComposer initialConfig={initial}>\n <ToolbarRow t={t} locale={locale} picks={picks ?? provided.picks} setLinkEditMode={setIsLinkEditMode} direction={direction} />\n <div className=\"relative\" ref={setAnchor}>\n <RichTextPlugin\n contentEditable={\n <ContentEditable\n id={id}\n // Named by the field's label, since a `for` cannot reach a div.\n aria-labelledby={id ? `${id}-label` : undefined}\n aria-describedby={describedBy}\n aria-invalid={invalid ? true : undefined}\n aria-required={required ? true : undefined}\n aria-placeholder={placeholder ?? ''}\n className=\"min-h-40 px-4 py-3 text-sm leading-6 outline-none\"\n placeholder={<div className=\"text-muted-foreground pointer-events-none absolute start-0 top-0 overflow-hidden px-4 py-3 text-sm text-ellipsis select-none\">{placeholder ?? ''}</div>}\n />\n }\n ErrorBoundary={LexicalErrorBoundary}\n />\n </div>\n {/* A value holding an unconfirmed link is never handed on, so an autosave while the link editor is open sends no placeholder the server refuses. */}\n <OnChangePlugin ignoreSelectionChange onChange={(state) => holdsPlaceholder(state) || onChange(state.toJSON() as unknown as RichTextValue)} />\n <HistoryPlugin />\n <ListPlugin />\n <CheckListPlugin />\n <LinkPlugin validateUrl={safeUrl} />\n <FloatingLinkEditorPlugin anchorElem={anchor} isLinkEditMode={isLinkEditMode} setIsLinkEditMode={setIsLinkEditMode} t={t} />\n <HorizontalRulePlugin />\n <SlashMenu locale={locale} picks={picks ?? provided.picks} />\n <DirectionPlugin />\n {Plugin && control ? <Plugin {...control} /> : null}\n </LexicalComposer>\n </div>\n )\n}\n\n/**\n * The read view: the same nodes, never editable, remounted when the value changes. A link is drawn\n * through Lexical's `sanitizeUrl`, which answers `about:blank` for unsupported schemes, so a\n * `javascript:` or `data:` address that reached a column past the server's check is never followed.\n */\nexport function RichTextViewer({ value, direction, t, className, id, describedBy, nodes }: {\n value: unknown\n direction: RichTextDirection\n t: Translate\n className?: string\n id?: string\n describedBy?: string\n nodes?: readonly Klass<LexicalNode>[]\n}): ReactElement {\n const drawn = nodes ?? use(RichTextContext).nodes\n const held = useMemo(() => valueOf(value), [value])\n // Asked before serializing, so a value nested past the bound never reaches `JSON.stringify` or Lexical.\n const problem = useMemo(() => unreadable(held, drawn), [held, drawn])\n if (problem !== undefined) return <Unreadable value={held} nodes={drawn} problem={problem} direction={direction} t={t} id={id} describedBy={describedBy} />\n const serialized = JSON.stringify(held)\n return (\n <div dir={direction} data-rich-text-view={id ?? ''}>\n <LexicalComposer key={serialized} initialConfig={configOf('MithaqRichTextViewer', drawn, held, false)}>\n <RichTextPlugin\n contentEditable={<ContentEditable id={id} aria-describedby={describedBy} className={cn('text-sm leading-5 outline-none [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_p:last-child]:mb-0', className)} />}\n ErrorBoundary={LexicalErrorBoundary}\n />\n <ListPlugin />\n <CheckListPlugin />\n <LinkPlugin validateUrl={safeUrl} />\n <DirectionPlugin />\n </LexicalComposer>\n </div>\n )\n}\n\n/** The `richText` kind's control: the editor, or the viewer where the field is read only. An editor holding nothing but empty paragraphs reports `null`. */\nexport function RichTextControl(props: ControlProps): ReactElement {\n const { describedBy, error, id, locale, placeholder, readOnly, t, value } = props\n const direction = useDirection() === 'rtl' ? 'rtl' : 'ltr'\n const change = changeOf(props)\n if (readOnly) return <RichTextViewer value={value} direction={direction} t={t} id={id} describedBy={describedBy} />\n return (\n <RichTextEditor\n value={value}\n onChange={(next) => change(blank(next) ? null : next)}\n direction={direction}\n t={t}\n locale={locale ?? 'en'}\n id={id}\n placeholder={placeholder}\n describedBy={describedBy}\n invalid={!!error}\n required={ariaRequired(props.field) === true}\n control={props}\n />\n )\n}\n\n/** A rich text value as a record page reads it: the viewer, over the page's direction, and nothing where it holds none. */\nexport function RichTextValueView({ value, t }: { value: unknown; t: Translate }): ReactNode {\n const direction = useDirection() === 'rtl' ? 'rtl' : 'ltr'\n return value ? <RichTextViewer value={value} direction={direction} t={t} /> : null\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 0709e5fd4e8b0eaeda128c63d9bb3d4bd885ef9f053b7374a40c0223e7aee874\n",
|
|
29
|
+
"type": "registry:component",
|
|
30
|
+
"target": "@components/mithaq/rich-text.tsx"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"path": "registry/rich-text-nodes.ts",
|
|
34
|
+
"content": "export { defaultNodes } from 'mithaq/rich-text'\n\n// A project's own list spreads this, in a file both the editor and the server import:\n//\n// export const richTextNodes = [...defaultNodes, CalloutNode]\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 12555780fdbc7bc6b283e1c56c498b12265f7c417359a0d4eff71d8207bdb3cd\n",
|
|
35
|
+
"type": "registry:lib",
|
|
36
|
+
"target": "@components/mithaq/rich-text-nodes.ts"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"type": "registry:component"
|
|
40
|
+
}
|