@drhomet/mithaq 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSES/openstatus-time-picker.txt +21 -0
- package/README.md +161 -0
- package/dist/asas/build.d.ts +10 -2
- package/dist/asas/build.js +69 -22
- package/dist/asas/cli/mithaq.js +35 -3
- package/dist/asas/cli/upgrade.d.ts +22 -9
- package/dist/asas/cli/upgrade.js +187 -78
- package/dist/asas/http/fetch.d.ts +21 -3
- package/dist/asas/http/fetch.js +123 -60
- package/dist/asas/http/orpc.d.ts +5 -3
- package/dist/asas/http/orpc.js +126 -108
- package/dist/asas/runner/bulk.d.ts +1 -1
- package/dist/asas/runner/bulk.js +16 -11
- package/dist/asas/runner/errors.d.ts +5 -2
- package/dist/asas/runner/errors.js +18 -2
- package/dist/asas/runner/read.d.ts +6 -2
- package/dist/asas/runner/read.js +28 -6
- package/dist/asas/runner/steps.d.ts +22 -4
- package/dist/asas/runner/steps.js +15 -9
- package/dist/asas/runner/store.d.ts +4 -0
- package/dist/asas/runner/store.js +21 -0
- package/dist/asas/runner/tenant.d.ts +2 -2
- package/dist/asas/runner/tenant.js +78 -16
- package/dist/asas/runner/write.d.ts +173 -11
- package/dist/asas/runner/write.js +274 -46
- package/dist/auth/better-auth/access.d.ts +14 -0
- package/dist/auth/better-auth/access.js +32 -0
- package/dist/auth/better-auth/generate.d.ts +3 -1
- package/dist/auth/better-auth/generate.js +26 -8
- package/dist/auth/better-auth/index.d.ts +2 -0
- package/dist/auth/better-auth/tenant.d.ts +11 -3
- package/dist/auth/better-auth/tenant.js +10 -2
- package/dist/auth/local/index.js +5 -3
- package/dist/base/fields.d.ts +3 -2
- package/dist/base/fields.js +3 -2
- package/dist/base/index.d.ts +2 -1
- package/dist/base/json-schema.d.ts +7 -0
- package/dist/base/json-schema.js +35 -0
- package/dist/base/kinds.d.ts +21 -0
- package/dist/base/kinds.js +46 -1
- package/dist/base/rules.d.ts +4 -4
- package/dist/base/rules.js +4 -3
- package/dist/drizzle/index.d.ts +9 -139
- package/dist/drizzle/index.js +143 -24
- package/dist/drizzle/kinds.d.ts +1 -0
- package/dist/drizzle/kinds.js +3 -1
- package/dist/drizzle/migrate.d.ts +26 -0
- package/dist/drizzle/migrate.js +69 -26
- package/dist/drizzle/rules.d.ts +1 -1
- package/dist/index.d.ts +11 -21
- package/dist/index.js +8 -18
- package/dist/mithaq/define.d.ts +62 -15
- package/dist/mithaq/define.js +23 -9
- package/dist/mithaq/generate-messages.js +5 -4
- package/dist/mithaq/language.d.ts +43 -4
- package/dist/mithaq/message-file.d.ts +13 -0
- package/dist/mithaq/message-file.js +29 -0
- package/dist/mithaq/messages.js +5 -0
- package/dist/mithaq/resolve.js +70 -10
- package/dist/mithaq/types.d.ts +29 -1
- package/dist/rbac/index.d.ts +2 -2
- package/dist/rbac/index.js +1 -1
- package/dist/rich-text/index.d.ts +1 -0
- package/dist/rich-text/index.js +1 -0
- package/dist/rich-text/rich-text.d.ts +2 -55
- package/dist/rich-text/rich-text.js +2 -125
- package/dist/rich-text/value.d.ts +55 -0
- package/dist/rich-text/value.js +126 -0
- package/dist/screen/client.d.ts +5 -2
- package/dist/screen/client.js +5 -2
- package/dist/screen/generate.js +48 -6
- package/dist/screen/index.d.ts +18 -1
- package/dist/screen/index.js +24 -6
- package/dist/screen/react.d.ts +2 -0
- package/dist/storage/index.d.ts +2 -3
- package/dist/storage/index.js +1 -2
- package/dist/storage/storage.d.ts +2 -0
- package/dist/storage/storage.js +9 -2
- package/dist/targets.d.ts +14 -0
- package/dist/targets.js +16 -0
- package/dist/vite.d.ts +7 -0
- package/dist/vite.js +81 -0
- package/dist/zod/index.d.ts +12 -3
- package/dist/zod/index.js +6 -5
- package/dist/zod/kinds.d.ts +3 -1
- package/dist/zod/kinds.js +11 -2
- package/dist/zod/rules.d.ts +1 -1
- package/docs/packages/mithaq/README.md +962 -0
- package/docs/packages/mithaq/src/asas/cli/README.md +252 -0
- package/docs/packages/mithaq/src/asas/runner/README.md +472 -0
- package/docs/packages/mithaq/src/drizzle/README.md +39 -0
- package/docs/packages/mithaq-admin/README.md +312 -0
- package/docs/templates/asas/README.md +1056 -0
- package/package.json +37 -8
- package/skills/add-field/SKILL.md +27 -0
- package/skills/add-kayan/SKILL.md +26 -0
- package/skills/eject-admin-piece/SKILL.md +28 -0
- package/skills/upgrade-mithaq/SKILL.md +35 -0
- package/r/mithaq-cells.json +0 -20
- package/r/mithaq-controls.json +0 -37
- package/r/mithaq-directional-icon.json +0 -15
- package/r/mithaq-expected-updated-at.json +0 -15
- package/r/mithaq-field-shell.json +0 -18
- package/r/mithaq-filter-operators.json +0 -15
- package/r/mithaq-form.json +0 -33
- package/r/mithaq-list-columns.json +0 -23
- package/r/mithaq-list-filters.json +0 -24
- package/r/mithaq-list-pagination.json +0 -24
- package/r/mithaq-list-query.json +0 -18
- package/r/mithaq-list-selection.json +0 -19
- package/r/mithaq-list.json +0 -33
- package/r/mithaq-nav.json +0 -24
- package/r/mithaq-nodes.json +0 -34
- package/r/mithaq-relationship-cell.json +0 -20
- package/r/mithaq-relationship-control.json +0 -20
- package/r/mithaq-rich-text.json +0 -40
- package/r/mithaq-screens-host.json +0 -96
- package/r/mithaq-storage.json +0 -44
- package/r/mithaq-upload.json +0 -29
- package/r/registry.json +0 -550
package/r/mithaq-list.json
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "mithaq-list",
|
|
4
|
-
"title": "Mithaq list",
|
|
5
|
-
"description": "The list screen over a kayan's find, with search, filters, columns, sort, page and limit driven by one callback and every destination a prop, each row opening its record and carrying a menu of Edit, Delete and the list node's row buttons, a row button offered where its when holds over that row, and the toolbar and bulk buttons.",
|
|
6
|
-
"registryDependencies": [
|
|
7
|
-
"button",
|
|
8
|
-
"card",
|
|
9
|
-
"dropdown-menu",
|
|
10
|
-
"input",
|
|
11
|
-
"table",
|
|
12
|
-
"@mithaq/mithaq-cells",
|
|
13
|
-
"@mithaq/mithaq-controls",
|
|
14
|
-
"@mithaq/mithaq-filter-operators",
|
|
15
|
-
"@mithaq/mithaq-form",
|
|
16
|
-
"@mithaq/mithaq-list-columns",
|
|
17
|
-
"@mithaq/mithaq-list-filters",
|
|
18
|
-
"@mithaq/mithaq-list-pagination",
|
|
19
|
-
"@mithaq/mithaq-list-query",
|
|
20
|
-
"@mithaq/mithaq-list-selection",
|
|
21
|
-
"@mithaq/mithaq-nodes",
|
|
22
|
-
"@mithaq/mithaq-relationship-cell"
|
|
23
|
-
],
|
|
24
|
-
"files": [
|
|
25
|
-
{
|
|
26
|
-
"path": "registry/list.tsx",
|
|
27
|
-
"content": "import { useEffect, useId, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactElement, type ReactNode } from 'react'\nimport { ArrowDown, ArrowUp, ChevronDown, MoreHorizontal, Plus, Search as SearchIcon, X } from 'lucide-react'\nimport type { ButtonClient, ClientField, ClientKayan, Row } from 'mithaq/admin'\nimport { useScreen } from 'mithaq/admin/react'\nimport { Button, buttonVariants } from '@/components/ui/button'\nimport { Card, CardContent } from '@/components/ui/card'\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'\nimport { Input } from '@/components/ui/input'\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'\nimport { NodeButton, OwnButton, drawsKind, isUntitled, listOf, nodesOf, pageButtonOf, refOf, rowTitle, rowWarning, scopeOf, shown as holds, type ListNode, type PageButton, type ScreenPage } from './nodes.tsx'\nimport { cellFor, cells } from './cells.tsx'\nimport { counted, descriptionOf, labelFor, operationLabelFor, refusalKeyOf, titleOf, type Copy, type FindApi, type Translate } from './controls.tsx'\nimport { DeleteButton, Form, OperationButton, type FormProps } from './form.tsx'\nimport { useRelationshipTitles } from './relationship-cell.tsx'\nimport { filterPathsOf } from './filter-operators.ts'\nimport { Filters } from './list-filters.tsx'\nimport { Columns, columnCandidates } from './list-columns.tsx'\nimport { Pagination } from './list-pagination.tsx'\nimport { SelectAll, SelectRow, writableFields } from './list-selection.tsx'\nimport {\n canSort,\n columnsFrom,\n columnsTo,\n refined,\n remember,\n remembered,\n searchableFields,\n type ColumnChoice,\n type FilterWhere,\n type ListQuery,\n} from './list-query.ts'\n\n/** One page, as the managed reader answers it. */\nexport type Page = {\n docs: readonly Record<string, unknown>[]\n hasNextPage: boolean\n hasPrevPage: boolean\n limit: number\n nextPage: number | null\n page: number\n prevPage: number | null\n totalDocs: number\n totalPages: number\n}\n\n/** The six options this screen drives `find` and the URL with. */\nexport type Query = ListQuery\n\nexport type ListProps = {\n kayan: ClientKayan\n page: Page\n query: Query\n t: Translate\n locale: string\n /**\n * Which locales exist, from the app: a localized field is offered to the filter panel as its locale paths.\n */\n locales?: readonly string[]\n /** Every kayan of the build, so a relationship's hop can name the target's fields. */\n client?: readonly ClientKayan[]\n /**\n * The typed client's reads. A relationship column names the rows it points at with one read per target for the whole page, and the filter panel's relationship value input pages its target with it.\n */\n api?: FindApi\n /**\n * Whether this actor may read one field, off `GET /api/access`. Chrome only: the read masks a denied column and refuses a denied filter whatever this says.\n */\n reads?: (kayan: string, field: string) => boolean\n /**\n * Whether this actor may create a row of this kayan, off the same report. Chrome only: the create is authorized server-side. Undeclared is allowed.\n */\n creates?: boolean\n /**\n * This actor's answer per operation name, off the same report. A button in the list node's `toolbar`, `bulk` or `row` is drawn where it allows that operation, and the selection column where it allows a bulk button. Chrome only; an absent key is denied.\n */\n operations?: Readonly<Record<string, boolean>>\n /** Whether this actor may write one field on an update, for a bulk update's boxes. */\n writes?: (field: string) => boolean\n /**\n * The access report a list button's `when` reads as `$user`, cut to the screen's kayan.\n */\n access?: unknown\n /**\n * What running one of the list's buttons costs the host: the call and the re-read after it. With none, no button is drawn, as there would be nothing for it to do.\n */\n run?: (operation: string, input: Record<string, unknown>) => Promise<unknown>\n /** Where the next query goes. Navigation, as a callback. */\n onQuery: (query: Query) => void\n /** Where one row goes. Navigation, as a prop, so no router is imported. */\n href: (id: string) => string\n /** Where a new row is written. */\n newHref: string\n /**\n * A screen's list page, where this list draws one: its list node names the columns, filters and search, and the components it names by path are drawn from here.\n */\n screen?: ScreenPage\n /** The list node this list draws, where it is not the page's own: a list of another kayan. */\n node?: ListNode\n /**\n * How a list of another kayan edits its rows in place, below the table: one row at a time, in the row's own form over the list's columns, a create being the same form with the page's row already in its relationship. Every write is the listed kayan's own and saves apart from the page's form. A row still opens its own page through `href`.\n */\n edit?: Pick<FormProps, 'client' | 'writes'> & {\n /** What every row it adds holds whatever the form sends: the relationship to the page's row. */\n prefill?: Readonly<Record<string, unknown>>\n }\n}\n\n/**\n * Which columns a list shows, in order: the URL's list when it carries one, else the list node's `columns`, else every field. Never a field this actor may not read.\n */\nexport const columnsOf = ({\n kayan,\n cols,\n reads,\n opens,\n}: {\n kayan: ClientKayan\n cols?: readonly string[]\n reads?: (kayan: string, field: string) => boolean\n /** The columns a page's list node opens with, which is every field where there is no node. */\n opens?: readonly string[]\n}): readonly ColumnChoice[] => {\n const isDefault = (_kayan: ClientKayan, field: ClientField) => !opens || opens.includes(field.name)\n const candidates = columnCandidates(kayan, reads)\n const offered = new Set(candidates.map((field) => field.name))\n const fromUrl = columnsFrom(cols).filter((one) => offered.has(one.name))\n const named = new Set(fromUrl.map((one) => one.name))\n const rest = candidates.filter((field) => !named.has(field.name))\n if (fromUrl.length) {\n // A column the URL did not name keeps the declaration's answer, so a field added to a kayan is not hidden from old links.\n return [...fromUrl, ...rest.map((field) => ({ name: field.name, active: isDefault(kayan, field) }))]\n }\n return candidates.map((field) => ({ name: field.name, active: isDefault(kayan, field) }))\n}\n\n/**\n * The query a column change asks for. It keeps the page it is on, since it changes what a row shows and never which rows match.\n */\nexport const withColumns = (query: Query, columns: readonly ColumnChoice[]): Query => ({\n ...query,\n cols: columnsTo(columns),\n})\n\n// Pinned cells are opaque (the card's fill with the head's or hovered row's tint mixed in), because a translucent one\n// shows the cells that scroll beneath it.\nconst headFill = 'bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))]'\nconst pinnedHead = `sticky z-10 ${headFill}`\nconst pinned = 'sticky z-10 bg-card group-hover/row:bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))]'\n// A shadow on the row menu column's start edge while the table runs on under it. The head cell keeps its rule underneath\n// (`[&_th]:shadow-[inset_0_-1px_0_var(--border)]` on the head row), so the head's own takes `!` and carries the rule too.\nconst edge = 'shadow-[-10px_0_10px_-6px_color-mix(in_oklab,var(--foreground)_35%,transparent)] rtl:shadow-[10px_0_10px_-6px_color-mix(in_oklab,var(--foreground)_35%,transparent)]'\nconst edgeHead = 'shadow-[inset_0_-1px_0_var(--border),-10px_0_10px_-6px_color-mix(in_oklab,var(--foreground)_35%,transparent)]! rtl:shadow-[inset_0_-1px_0_var(--border),10px_0_10px_-6px_color-mix(in_oklab,var(--foreground)_35%,transparent)]!'\n// Exactly a touch target wide, so the first column pins right after it; on a phone as tall, so the box's 44 pixel layer\n// stays inside its own row.\nconst selection = 'w-11 min-w-11 p-0 max-sm:h-11 [&>*]:mx-auto [&>[role=checkbox]]:translate-y-0'\n\nconst nextSort = (name: string, sort: string | undefined) =>\n sort === name ? `-${name}` : sort === `-${name}` ? undefined : name\n\nfunction Search({\n fields,\n locale,\n onSearch,\n t,\n value,\n}: {\n value: string | undefined\n /** The labels of the fields a search runs over, which is empty for a kayan with none. */\n fields: readonly string[]\n locale: string\n onSearch: (term: string | undefined) => void\n t: Translate\n}): ReactElement | null {\n const [text, setText] = useState(value ?? '')\n useEffect(() => setText(value ?? ''), [value])\n useEffect(() => {\n if (text === (value ?? '')) return\n const timer = setTimeout(() => onSearch(text.trim() === '' ? undefined : text), 300)\n return () => clearTimeout(timer)\n }, [onSearch, text, value])\n // No searchable field, no box. After the hooks, which must run on every render or on none.\n if (!fields.length) return null\n const label = t('admin.search', 'Search') ?? 'Search'\n const placeholder = new Intl.ListFormat(locale, { type: 'disjunction' }).format(fields)\n return (\n <span className=\"flex w-full items-center gap-1 lg:max-w-sm\">\n <span className=\"relative w-full\">\n <SearchIcon className=\"text-muted-foreground absolute start-3 top-1/2 size-4 -translate-y-1/2\" aria-hidden=\"true\" />\n <Input\n type=\"search\"\n className=\"ps-9 max-sm:h-11\"\n placeholder={placeholder}\n aria-label={label}\n value={text}\n onChange={(event) => setText(event.target.value)}\n />\n </span>\n {text ? (\n <Button type=\"button\" variant=\"outline\" size=\"icon-sm\" className=\"max-sm:size-11\" onClick={() => setText('')} aria-label={t('admin.clearSearch', 'Clear search') ?? 'Clear search'}>\n <X />\n </Button>\n ) : null}\n </span>\n )\n}\n\nexport function List({\n access,\n api,\n client,\n creates,\n edit,\n href,\n kayan,\n locale,\n locales,\n newHref,\n onQuery,\n operations,\n page,\n query,\n reads,\n run,\n screen,\n t,\n writes,\n ...props\n}: ListProps): ReactElement {\n const copy: Copy = { t, locale }\n const { maxLimit } = useScreen()\n const node = props.node ?? listOf(screen)\n /** The row being edited in place, `null` for a row being added, and nothing while no form is open. */\n const [editing, setEditing] = useState<Row | null | undefined>(undefined)\n const edits = edit && node\n ? { ...edit, client: { ...edit.client, create: (input: Record<string, unknown>) => edit.client.create({ ...input, ...edit.prefill }) } }\n : undefined\n const Heading = node?.of ? 'h2' : 'h1'\n const Empty = refOf({ screen, place: 'empty', path: node?.empty })\n /** The project's items in each row's menu, and the dialog one of them opened, which outlives the menu. */\n const Menu = refOf({ screen, place: 'menu', path: node?.menu })\n const [over, setOver] = useState<ReactNode>(null)\n const cellOf = new Map(node?.columns.map((column) => [column.field, column.cell]))\n const searched = searchableFields({ kayan, search: node?.search, client })\n const columns = columnsOf({ kayan, cols: query.cols, reads, opens: node?.columns.map((column) => column.field) })\n const active = columns.flatMap((column) => kayan.fields.find((field) => field.name === column.name && column.active) ?? [])\n const titled = active.find((field) => field.name === kayan.useAsTitle)\n const shown = titled ? [titled, ...active.filter((field) => field !== titled)] : active\n // One read per target for the whole page, so a relationship column is not one read per cell. Over the shown columns\n // alone, so a relationship the viewer has switched off costs no read.\n const titles = useRelationshipTitles({ fields: shown, docs: page.docs, context: { api, client, locale } })\n const paths = filterPathsOf(kayan, { client, locales, reads, ...(node?.filters && { filters: node.filters }) })\n // The first shown cell carries the row's link, so a hidden or unreadable title leaves a row reachable.\n const linked = shown[0]\n const refine = (next: Partial<Query>) => onQuery(refined(query, next))\n const filtered = Boolean(query.where || query.search)\n /**\n * Whether the person has opened or closed the filter panel, and nothing until they do. Until then it is open on a list whose URL filters, read on every render so a `where` that arrives after mount opens it.\n */\n const [filtering, setFiltering] = useState<boolean | undefined>(undefined)\n const open = filtering ?? Boolean(query.where)\n const filtersId = useId()\n /** The kayan's plural, which the bulk dialog and its result name the ticked rows by. */\n const plural = titleOf({ kayan, copy, many: true })\n const about = descriptionOf(kayan, copy)\n\n /**\n * The list node's buttons this actor may run, drawn only where the host said what running one costs and the report allows its operation by name.\n */\n const allowed = (declared: readonly ButtonClient[] | undefined) =>\n run ? (declared ?? []).flatMap((node) => {\n const operation = pageButtonOf({ kayan, node, allowed: operations })\n return operation ? [{ node, operation }] : []\n }) : []\n /** What a toolbar or bulk button's `when` reads: no values and no row, and the report as `$user`. */\n const scope = scopeOf({ values: {}, record: null, access, kayan })\n const toolbar = allowed(node?.toolbar).filter((one) => holds(one.node, scope))\n // A bulk button's boxes are the row's fields it names, cut to those this actor could write; its call is the ticked\n // rows' `where` around them.\n const bulkButtons = allowed(node?.bulk).filter((one) => holds(one.node, scope)).map((one) => ({\n ...one,\n operation: { ...one.operation, fields: writableFields(kayan, writes).filter((field) => one.node.fields?.includes(field.name)) },\n }))\n const rowButtons = allowed(node?.row)\n const editsRows = edits !== undefined && operations?.update === true\n const deletes = run !== undefined && operations?.delete === true && kayan.operations.some((one) => one.name === 'delete')\n const [deleting, setDeleting] = useState<Row | undefined>(undefined)\n const [asking, setAsking] = useState<{ row: Row; operation: PageButton } | undefined>(undefined)\n const [running, setRunning] = useState(false)\n const table = useRef<HTMLTableElement>(null)\n // Whether the table runs past its frame's end, so the frame fades there. The value is the width of the row menu's pinned\n // column, which the fade sits before, and `false` where nothing runs past. `scrollLeft` is negative on a right-to-left page.\n const [more, setMore] = useState<number | false>(false)\n useEffect(() => {\n const grid = table.current\n const frame = grid?.parentElement\n if (!grid || !frame) return\n const check = () => setMore(frame.scrollWidth - frame.clientWidth - Math.abs(frame.scrollLeft) > 1 && (grid.querySelector('thead th:last-child')?.getBoundingClientRect().width ?? 0))\n check()\n frame.addEventListener('scroll', check, { passive: true })\n const resized = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(check)\n resized?.observe(frame)\n resized?.observe(grid)\n return () => {\n frame.removeEventListener('scroll', check)\n resized?.disconnect()\n }\n }, [])\n /**\n * The name of a row's menu button, naming the row by its title so a reader going down the column hears each row's own; a row with no title, or with the title column hidden, keeps the shared name.\n */\n const rowActionsLabel = (row: Row) => {\n const name = titled && rowTitle({ kayan, row, locale })\n // Bidi-isolated, so a name in the other direction does not reorder the sentence.\n return name ? (t('admin.rowActionsFor', 'Actions for {name}') ?? 'Actions for {name}').replace('{name}', () => `\\u2068${name}\\u2069`) : (t('admin.rowActions', 'Row actions') ?? 'Row actions')\n }\n const openHint = useId()\n /**\n * Opens a row by clicking its own link, so the host routes it as it routes every other link and the list imports no router.\n */\n const openRow = (id: string) =>\n [...(table.current?.querySelectorAll<HTMLAnchorElement>('a[data-row-link]') ?? [])].find((one) => one.dataset.rowLink === id)?.click()\n const runOnRow = async (operation: string, input: Record<string, unknown>, refuse: (thrown: unknown) => void = setRefused) => {\n setRunning(true)\n setRefused(undefined)\n try {\n await run!(operation, input)\n return true\n } catch (thrown) {\n refuse(thrown)\n return false\n } finally {\n setRunning(false)\n }\n }\n /**\n * Whether no column the list opens with is one the viewer may read, which the projection says by leaving the field out. Such a list draws no rows, nor anything that acts on them.\n */\n const unreadable = !(node?.columns.map((column) => column.field) ?? kayan.fields.map((field) => field.name)).some((name) => kayan.fields.some((field) => field.name === name))\n const picks = bulkButtons.length > 0 && !unreadable\n /**\n * The refusal the last run answered with, drawn above the table, so a refused button that closed does not leave the page reading as changed.\n */\n const [refused, setRefused] = useState<unknown>(undefined)\n const refusal = refusalKeyOf(refused)\n const button = (one: PageButton, inputOf: (values: Record<string, unknown>) => Record<string, unknown>, key: string, extra: { description?: string; disabled?: boolean; onRan?: (written: unknown) => void } = {}) => (\n <ListButton key={key} operation={one} kayan={kayan} client={client} api={api} copy={copy} screen={screen} run={run!} inputOf={inputOf} onRefused={setRefused} {...extra} />\n )\n const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set())\n /**\n * How many rows the last bulk changed, announced in the count's live region after the ticks drop. A new object per bulk restarts its six seconds; it clears after them or when a row is ticked again.\n */\n const [done, setDone] = useState<{ count: number } | undefined>(undefined)\n useEffect(() => {\n if (!done) return\n const timer = setTimeout(() => setDone(undefined), 6000)\n return () => clearTimeout(timer)\n }, [done])\n const rowIds = page.docs.map((row) => String(row.id ?? ''))\n /**\n * The ticked rows still on the page. A page that moved under the selection leaves ids that are no longer on screen, and a delete of rows nobody can see is the one thing this must not send.\n */\n const ticked = rowIds.filter((one) => one && selected.has(one))\n useEffect(() => {\n if (ticked.length) setDone(undefined)\n }, [ticked.length])\n /** A counted sentence about the ticked rows, capitalised though the kayan's plural may be lower case. */\n const aboutTicked = (key: string, count: number, fallback: string) => {\n const said = counted({ t, locale, key, count, fallback }).replace('{plural}', () => plural)\n return said.charAt(0).toLocaleUpperCase(locale) + said.slice(1)\n }\n\n // Per-viewer limit and columns are remembered in `localStorage`, read once and only where the URL named neither.\n // Read in an effect: it does not exist on the server, and reading it during render would mismatch hydration.\n useEffect(() => {\n if (query.limit !== undefined || query.cols !== undefined) return\n const held = remembered(kayan.name, maxLimit)\n if (held.limit === undefined && held.cols === undefined) return\n onQuery({ ...query, ...held })\n // The kayan alone: running again on the query would undo a viewer's own change back to what was remembered.\n }, [kayan.name])\n useEffect(() => {\n if (query.limit === undefined && query.cols === undefined) return\n remember(kayan.name, { ...(query.limit !== undefined && { limit: query.limit }), ...(query.cols !== undefined && { cols: query.cols }) })\n }, [kayan.name, query.cols, query.limit])\n\n return (\n <section className={`flex w-full min-w-0 flex-1 flex-col gap-4 ${ticked.length || done ? 'max-sm:pb-[calc(5rem+env(safe-area-inset-bottom))]' : ''}`}>\n {/* `flex-wrap`: on a phone the title and the button share 320 pixels. `min-w-0` on the section lets the list shrink\n inside the grid or flex row that holds it, whose items otherwise keep the width of their widest row. */}\n <header className=\"flex flex-wrap items-start justify-between gap-4\">\n <div className=\"grid min-w-0 gap-1\">\n {/* A title node on the screen's own list page is the page's one h1, so the list keeps only its sentence; a list\n of another kayan keeps its heading. */}\n {screen && !node?.of && drawsKind({ nodes: nodesOf(screen.page), kind: 'title', scope }) ? null : <Heading className={node?.of ? 'text-lg font-semibold' : 'text-2xl font-bold tracking-tight'}>{titleOf({ kayan, copy, many: true })}</Heading>}\n {about ? <p className=\"text-muted-foreground text-sm\">{about}</p> : null}\n </div>\n {creates === false ? null : edits ? (\n // A list of another kayan adds a row in place, below the table.\n <Button type=\"button\" className=\"max-sm:h-11\" onClick={() => setEditing(null)}>\n <Plus className=\"me-2 h-4 w-4\" />\n {t(`${kayan.name}.new`, t('admin.new', 'New'))}\n </Button>\n ) : (\n // `render` keeps the destination an anchor; `nativeButton={false}` tells base-ui the rendered element is not\n // a button.\n <Button nativeButton={false} className=\"max-sm:h-11\" render={<a href={newHref} />}>\n <Plus className=\"me-2 h-4 w-4\" />\n {t(`${kayan.name}.new`, t('admin.new', 'New'))}\n </Button>\n )}\n </header>\n\n <Card>\n <CardContent>\n <div className=\"w-full space-y-4\">\n <div className=\"flex flex-col justify-between gap-2 lg:flex-row lg:items-center\">\n <div className=\"flex flex-1 flex-col gap-2 lg:flex-row lg:items-center\">\n <Search\n value={query.search}\n fields={searched.flatMap((path) => {\n // A path across a relationship is named by both labels (\"Course Title\"), so the far field is not\n // mistaken for the list's own, in the locale's order.\n const [own, far] = path.split('.')\n const near = kayan.fields.find((one) => one.name === own)\n const there = far === undefined ? kayan : client?.find((one) => one.name === near?.relationTo)\n const field = there?.fields.find((one) => one.name === (far ?? own))\n if (!there || !field) return []\n const label = labelFor({ kayan: there, field, copy })\n if (far === undefined || !near) return [label]\n return [(t('admin.searchThrough', '{relation} {field}') ?? '{relation} {field}').replace('{relation}', () => labelFor({ kayan, field: near, copy })).replace('{field}', () => label)]\n })}\n locale={locale}\n t={t}\n onSearch={(search) => refine({ search })}\n />\n </div>\n <div className=\"flex flex-wrap items-center gap-2\">\n {refusal ? (\n <p role=\"alert\" className=\"rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive\">\n {t(refusal, refusal)}\n </p>\n ) : null}\n {toolbar.map((one, at) => button(one.operation, (values) => values, `toolbar-${at}`, { description: t('admin.toolbarWarning', 'This runs on no row of the list.') ?? 'This runs on no row of the list.' }))}\n {/* Both stay mounted, the buttons hidden until a row is ticked: a live region must exist before its first change\n to be announced, and a dialog a button opened must outlive a read that took the ticked rows off the page. */}\n {picks ? (\n // On a phone the count and bulk buttons are a bar fixed to the bottom while a row is ticked, so ticking\n // does not push the table down under the finger. It stands on the form's own bar where a record page draws one.\n <div\n data-mithaq-bulk-bar=\"\"\n role=\"region\"\n aria-label={t('admin.bulkBar', 'Ticked rows') ?? 'Ticked rows'}\n className={ticked.length || done ? 'contents max-sm:bg-background max-sm:fixed max-sm:inset-x-0 max-sm:bottom-[var(--action-bar-height,0px)] max-sm:z-30 max-sm:flex max-sm:flex-nowrap max-sm:items-center max-sm:gap-2 max-sm:overflow-x-auto max-sm:border-t max-sm:p-3 max-sm:pb-[max(0.75rem,env(safe-area-inset-bottom))] max-sm:[&_button]:shrink-0 max-sm:[&>span]:shrink-0' : 'contents'}\n >\n {/* `aria-live`: the count changes as boxes are ticked and nothing else announces it. */}\n <span className={ticked.length || done ? 'text-muted-foreground text-sm' : 'sr-only'} aria-live=\"polite\" data-mithaq-selected={ticked.length || undefined} data-mithaq-done={!ticked.length && done ? done.count : undefined}>\n {ticked.length\n ? (t('admin.selected', '{count} selected') ?? '{count} selected').replace('{count}', String(ticked.length))\n : done ? aboutTicked('admin.bulkDone', done.count, '{plural} done: {count}.') : ''}\n </span>\n <span hidden={ticked.length === 0} className={ticked.length ? 'contents' : undefined}>\n {bulkButtons.map((one, at) =>\n button(\n one.operation,\n // Only the ticked rows still on the page, with the boxes' values as the data written over each.\n (values) => ({ where: { id: { in: ticked } }, ...(one.operation.fields.length > 0 && { data: values }) }),\n `bulk-${at}`,\n {\n // A where of no id is a call nobody ticked, which a dialog left open over rows that left the\n // page would confirm, so it is disabled.\n disabled: ticked.length === 0,\n description: aboutTicked('admin.bulkWarning', ticked.length, '{plural} you ticked: {count}. This applies to each of them.'),\n // The ticked ids name rows the host's re-read is about to drop, so the selection is cleared\n // and what the bulk did is said, less the rows it named as left.\n onRan: (written) => {\n setDone({ count: ticked.length - ((written as { errors?: readonly unknown[] } | null)?.errors?.length ?? 0) })\n setSelected(new Set())\n },\n },\n ),\n )}\n </span>\n </div>\n ) : null}\n {paths.length ? (\n <Button\n type=\"button\"\n variant={open || query.where ? 'secondary' : 'outline'}\n className=\"max-sm:h-11\"\n aria-controls={open ? filtersId : undefined}\n aria-expanded={open}\n onClick={() => setFiltering(!open)}\n >\n {t('admin.filters', 'Filters') ?? 'Filters'}\n <ChevronDown className={open ? 'rotate-180' : undefined} aria-hidden=\"true\" />\n </Button>\n ) : null}\n {unreadable ? null : (\n <Columns\n kayan={kayan}\n columns={columns}\n t={t}\n locale={locale}\n onChange={(next) => onQuery(withColumns(query, next))}\n />\n )}\n </div>\n </div>\n\n {open ? (\n <div id={filtersId}>\n <Filters\n paths={paths}\n value={query.where}\n t={t}\n locale={locale}\n client={client}\n api={api}\n onChange={(where: FilterWhere | undefined) => {\n // The person is at work in the panel, so it stays, even where the last filter goes.\n setFiltering(true)\n refine({ where })\n }}\n />\n </div>\n ) : null}\n\n {/* Held to the screen below the page's header so it scrolls both ways and the head row can stay (a printed page\n holds every row). It isolates, so pinned cells never paint over the page's sticky header or take its clicks. */}\n <div className=\"bg-card relative isolate overflow-hidden rounded-lg border [&>div:first-child]:max-h-[calc(100dvh-var(--header-height,0px))] print:[&>div:first-child]:max-h-none\">\n {unreadable ? null : (\n <Table ref={table}>\n {/* The head rule is each head cell's own inset shadow, since a collapsed border scrolls away from a sticky\n head; the row's own `border-b` is dropped so both do not draw. */}\n <TableHeader className={`sticky top-0 z-20 ${headFill} [&_tr]:border-b-0 [&_th]:shadow-[inset_0_-1px_0_var(--border)]`}>\n <TableRow>\n {picks ? (\n <TableHead scope=\"col\" className={`${pinnedHead} start-0 ${selection}`}>\n <SelectAll ids={rowIds} selected={selected} onSelect={setSelected} t={t} />\n </TableHead>\n ) : null}\n {shown.map((field) => {\n // A heading is a button only where the read would take the sort: the server answers 400 for a\n // localized field, a set, a join and a screen's `sortable: false`.\n const sortable = canSort(field)\n // The URL's sort, else the list node's default, which is the order the page was read in.\n const sort = query.sort ?? node?.sort\n const direction = sort === field.name ? 'ascending' : sort === `-${field.name}` ? 'descending' : 'none'\n return (\n <TableHead key={field.name} scope=\"col\" aria-sort={sortable ? direction : undefined} className={field === linked ? `${pinnedHead} ${picks ? 'start-11' : 'start-0'}` : undefined}>\n {sortable ? (\n // One arrow, on the sorted column only. `aria-hidden` on the glyph and `aria-sort` on the\n // cell keep the answer readable without it.\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"-ms-2 h-8 px-2 max-sm:h-11 max-sm:min-w-11\"\n onClick={() => refine({ sort: nextSort(field.name, query.sort) })}\n >\n {labelFor({ kayan, field, copy })}\n {direction === 'ascending' ? (\n <ArrowUp className=\"size-4\" aria-hidden=\"true\" />\n ) : direction === 'descending' ? (\n <ArrowDown className=\"size-4\" aria-hidden=\"true\" />\n ) : null}\n </Button>\n ) : (\n labelFor({ kayan, field, copy })\n )}\n </TableHead>\n )\n })}\n <TableHead scope=\"col\" className={`${pinnedHead} end-0 w-0 ${more === false ? '' : edgeHead}`}>\n <span className=\"sr-only\">{t('admin.actions', 'Actions')}</span>\n </TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {page.docs.map((row, index) => {\n const id = String(row.id ?? '')\n // The list node's row buttons whose `when` holds over this row. One a project draws with its own\n // component stays in the cell, since a menu item cannot be one.\n const offered = rowButtons.filter((one) => holds(one.node, scopeOf({ values: row, record: row, access, kayan })))\n const own = offered.filter((one) => refOf({ screen, place: 'button', path: one.operation.component }))\n const menu = offered.filter((one) => !own.includes(one))\n // `#` is the host's word for a kayan with no page that draws a row, so such a row is no link and\n // no tab stop, and its menu offers neither Edit nor View.\n const to = href(id)\n const opens = to !== '#'\n const edits = editsRows || opens\n return (\n // The whole row opens the record, and so does Enter on the row. A click on anything inside that acts\n // on its own, or on the menu or a dialog it opened (React hands those up through its portal), opens nothing.\n <TableRow\n key={String(row.id ?? index)}\n tabIndex={opens ? 0 : undefined}\n data-row={id}\n aria-describedby={opens ? openHint : undefined}\n // The ring every focusable control draws, inset so the table's frame does not clip it.\n className={opens ? 'group/row cursor-pointer outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset' : 'group/row'}\n onClick={(event: MouseEvent<HTMLTableRowElement>) => {\n const target = event.target as Element\n if (!opens || !event.currentTarget.contains(target) || target.closest('a, button, input, select, textarea, label, [role=\"checkbox\"], [role=\"menuitem\"]')) return\n openRow(id)\n }}\n onKeyDown={(event: KeyboardEvent<HTMLTableRowElement>) => {\n if (!opens || event.key !== 'Enter' || event.target !== event.currentTarget) return\n event.preventDefault()\n openRow(id)\n }}\n >\n {picks ? (\n <TableCell className={`${pinned} start-0 ${selection}`}>\n <SelectRow id={id} selected={selected} onSelect={setSelected} t={t} />\n </TableCell>\n ) : null}\n {shown.map((field) => {\n const Ref = refOf({ screen, place: 'cell', path: cellOf.get(field.name) ?? field.cell })\n // An email cell is a mail link, and a link cannot sit in the row's link (`<a>` in `<a>` is a\n // hydration error), so the linked column draws its text. Upload draws no anchor.\n const drawn = cellFor(field, cellOf.get(field.name))\n const Cell = drawn === cells.email && field === linked && opens ? cells.text! : drawn\n const untitled = field.name === kayan.useAsTitle && isUntitled({ kayan, row, locale })\n const cell = untitled ? (\n <span className=\"text-muted-foreground\">{t('admin.untitled', 'Untitled') ?? 'Untitled'}</span>\n ) : Ref && screen ? (\n <Ref field={field} value={row[field.name]} row={row} />\n ) : (\n <Cell kayan={kayan} field={field} value={row[field.name]} t={t} locale={locale} titles={titles} />\n )\n return (\n <TableCell key={field.name} className={field === linked ? `${pinned} ${picks ? 'start-11' : 'start-0'}` : undefined}>\n {field === linked && opens ? (\n // A real link, so it opens in a new tab and lists for a reader, but out of the tab\n // order since the row is the stop. An untitled row's name holds the visible text.\n <a href={to} data-row-link={id} tabIndex={-1} aria-label={untitled ? (t('admin.openUntitled', 'Untitled, open') ?? 'Untitled, open') : undefined} className=\"font-medium hover:underline\">\n {cell}\n </a>\n ) : (\n cell\n )}\n </TableCell>\n )\n })}\n <TableCell className={`${pinned} end-0 w-0 ${more === false ? '' : edge}`}>\n <div className=\"flex items-center justify-end gap-2\">\n {own.map((one, at) => button(one.operation, (values) => ({ ...values, id: row.id }), `row-${at}`, { description: rowWarning({ kayan, row, operation: one.operation.name, copy }) }))}\n {edits || menu.length || deletes || Menu ? (\n <DropdownMenu>\n <DropdownMenuTrigger\n data-row-menu={id}\n aria-label={rowActionsLabel(row)}\n className={buttonVariants({ variant: 'ghost', size: 'icon-sm', className: 'max-sm:size-11' })}\n >\n <MoreHorizontal className=\"size-4\" aria-hidden=\"true\" />\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"w-40\">\n {/* View where the report lets this actor update no row of the kayan, since the row's\n page is then only read. */}\n {edits ? (\n operations && operations.update !== true ? (\n <DropdownMenuItem data-row-action=\"view\" className=\"max-sm:min-h-11\" onClick={() => openRow(id)}>\n {t('admin.view', 'View') ?? 'View'}\n </DropdownMenuItem>\n ) : (\n <DropdownMenuItem data-row-action=\"edit\" className=\"max-sm:min-h-11\" onClick={() => (editsRows ? setEditing(row) : openRow(id))}>\n {t('admin.edit', 'Edit') ?? 'Edit'}\n </DropdownMenuItem>\n )\n ) : null}\n {menu.map((one) => (\n <DropdownMenuItem\n key={one.operation.name}\n data-row-action={one.operation.name}\n className=\"max-sm:min-h-11\"\n disabled={running}\n onClick={() => {\n // A button that asks for values or carries a confirm opens its dialog over the\n // row; any other runs.\n if (one.operation.fields?.length || one.operation.confirm) return setAsking({ row, operation: one.operation })\n void runOnRow(one.operation.name, { id: row.id })\n }}\n >\n {operationLabelFor({ kayan, operation: one.operation, copy })}\n </DropdownMenuItem>\n ))}\n {Menu ? <Menu row={row} open={setOver} /> : null}\n {deletes ? (\n <DropdownMenuItem data-row-action=\"delete\" variant=\"destructive\" className=\"max-sm:min-h-11\" disabled={running} onClick={() => setDeleting(row)}>\n {t('admin.delete', 'Delete') ?? 'Delete'}\n </DropdownMenuItem>\n ) : null}\n </DropdownMenuContent>\n </DropdownMenu>\n ) : null}\n </div>\n </TableCell>\n </TableRow>\n )\n })}\n </TableBody>\n </Table>\n )}\n {/* The table can be wider than a phone, so a sentence in a cell spanning it would be cut off at the frame's\n edge. */}\n {page.docs.length === 0 ? (\n <div data-list-message=\"\" className=\"text-muted-foreground flex min-h-24 items-center justify-center p-4 text-center text-sm\">\n {filtered ? (\n <span className=\"flex flex-col items-center gap-2\">\n {t('admin.noMatches', 'No rows match this filter.') ?? 'No rows match this filter.'}\n <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"max-sm:h-11\" onClick={() => refine({ search: undefined, where: undefined })}>\n {t('admin.clearFilters', 'Clear filters') ?? 'Clear filters'}\n </Button>\n </span>\n ) : Empty && screen ? (\n <Empty page={screen.page} />\n ) : (\n (t(`${kayan.name}.none`) ?? t('admin.empty', 'Nothing here yet.') ?? 'Nothing here yet.')\n )}\n </div>\n ) : unreadable ? (\n // Rows with no column to draw would read as records with nothing in them, so the list says what it\n // cannot show, and whom to ask\n <div data-list-message=\"\" className=\"text-muted-foreground flex min-h-24 items-center justify-center p-4 text-center text-sm\">\n {t('admin.noColumns', 'Your role can\\'t see any column of this list. Ask an administrator of your organization for access.')}\n </div>\n ) : null}\n {more === false ? null : <div data-scroll-cue=\"\" aria-hidden className=\"from-card pointer-events-none absolute inset-y-0 w-10 bg-linear-to-l to-transparent rtl:bg-linear-to-r\" style={{ insetInlineEnd: more }} />}\n </div>\n\n <span id={openHint} hidden>\n {t('admin.openRow', 'Press Enter to open this row.') ?? 'Press Enter to open this row.'}\n </span>\n\n {/* Drawn once below the table rather than inside the menu, which closes as its item is pressed and would\n take a dialog inside it along. */}\n {deletes ? (\n <DeleteButton\n busy={running}\n copy={copy}\n kayan={kayan}\n label={t('admin.delete', 'Delete') ?? 'Delete'}\n open={deleting !== undefined}\n onOpenChange={(open) => (open ? undefined : setDeleting(undefined))}\n row={deleting}\n onDelete={(refuse) => runOnRow('delete', { id: deleting?.id }, refuse)}\n />\n ) : null}\n {asking ? (\n <OperationButton\n api={api}\n busy={running}\n copy={copy}\n description={rowWarning({ kayan, row: asking.row, operation: asking.operation.name, copy })}\n inputOf={(values) => ({ ...values, id: asking.row.id })}\n kayan={kayan}\n kayans={client}\n operation={asking.operation}\n open\n onOpenChange={(open) => (open ? undefined : setAsking(undefined))}\n run={(input, refuse) => runOnRow(asking.operation.name, input, refuse)}\n />\n ) : null}\n {over}\n\n {unreadable ? null : (\n <Pagination\n page={page}\n t={t}\n locale={locale}\n onPage={(next) => onQuery({ ...query, page: next })}\n onLimit={(limit) => refine({ limit })}\n />\n )}\n\n {edits && editing !== undefined ? (\n <div data-mithaq-inline-edit={editing ? String(editing.id ?? '') : 'new'} className=\"bg-muted/30 rounded-md border p-3\">\n <Form\n kayan={kayan}\n screen={screen && { ...screen, kayan }}\n nodes={node!.columns}\n client={edits.client}\n writes={edits.writes}\n value={editing}\n t={t}\n locale={locale}\n locales={locales}\n kayans={client}\n api={api}\n onDone={() => setEditing(undefined)}\n onCancel={() => setEditing(undefined)}\n />\n </div>\n ) : null}\n </div>\n </CardContent>\n </Card>\n </section>\n )\n}\n\nfunction ListButton({\n api,\n client,\n copy,\n description,\n disabled,\n inputOf,\n kayan,\n onRan,\n onRefused,\n operation,\n run,\n screen,\n}: {\n api?: FindApi\n client?: readonly ClientKayan[]\n copy: Copy\n description?: string\n disabled?: boolean\n inputOf: (values: Record<string, unknown>) => Record<string, unknown>\n kayan: ClientKayan\n onRan?: (written: unknown) => void\n onRefused: (thrown: unknown) => void\n operation: PageButton\n run: (operation: string, input: Record<string, unknown>) => Promise<unknown>\n screen?: ScreenPage\n}): ReactElement {\n const [busy, setBusy] = useState(false)\n const call = async (input: Record<string, unknown>, refuse: (thrown: unknown) => void = onRefused) => {\n // Every path ends here, so a button that ignores `busy` sends nothing too.\n if (disabled) return false\n setBusy(true)\n onRefused(undefined)\n try {\n onRan?.(await run(operation.name, input))\n return true\n } catch (thrown) {\n refuse(thrown)\n return false\n } finally {\n setBusy(false)\n }\n }\n const Own = refOf({ screen, place: 'button', path: operation.component })\n if (Own && screen) {\n return <OwnButton Own={Own} operation={operation} busy={busy || disabled === true} copy={copy} call={async (input: Row) => void (await call(inputOf(input)))} />\n }\n if (!operation.fields?.length) return <NodeButton node={operation} copy={copy} busy={busy || disabled === true} description={description} run={() => void call(inputOf({}))} />\n return (\n <OperationButton\n api={api}\n busy={busy || disabled === true}\n copy={copy}\n description={description}\n inputOf={inputOf}\n kayan={kayan}\n kayans={client}\n operation={operation}\n run={call}\n />\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 97acf94d409745118e2ec5a6f107c4ef0ff08f4ec9202ba97fb24f8408de7ddf\n",
|
|
28
|
-
"type": "registry:component",
|
|
29
|
-
"target": "@components/mithaq/list.tsx"
|
|
30
|
-
}
|
|
31
|
-
],
|
|
32
|
-
"type": "registry:component"
|
|
33
|
-
}
|
package/r/mithaq-nav.json
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "mithaq-nav",
|
|
4
|
-
"title": "Mithaq nav",
|
|
5
|
-
"description": "The admin's app shell: the inset sidebar with the organization switcher, the kayan index the access report allows, the user menu, and the header with the sidebar trigger and the theme toggle.",
|
|
6
|
-
"registryDependencies": [
|
|
7
|
-
"avatar",
|
|
8
|
-
"button",
|
|
9
|
-
"dropdown-menu",
|
|
10
|
-
"separator",
|
|
11
|
-
"sheet",
|
|
12
|
-
"sidebar",
|
|
13
|
-
"@mithaq/mithaq-controls"
|
|
14
|
-
],
|
|
15
|
-
"files": [
|
|
16
|
-
{
|
|
17
|
-
"path": "registry/nav.tsx",
|
|
18
|
-
"content": "import { createContext, useContext, useEffect, useMemo, useState, type ComponentProps, type CSSProperties, type ReactNode } from 'react'\nimport {\n BookOpen,\n Building2,\n CalendarDays,\n Check,\n ChevronsUpDown,\n ClipboardList,\n Folder,\n GalleryVerticalEnd,\n HelpCircle,\n Languages,\n LayoutGrid,\n LogOut,\n Mail,\n Monitor,\n Moon,\n MoreHorizontal,\n PlusCircle,\n Search,\n Settings2,\n Share2,\n ShieldCheck,\n Sun,\n Table2,\n Trash2,\n Users,\n type LucideIcon,\n} from 'lucide-react'\nimport { Moon02Icon, Sun03Icon } from '@hugeicons/core-free-icons'\nimport { HugeiconsIcon } from '@hugeicons/react'\nimport type { ClientKayan } from 'mithaq/admin'\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar'\nimport { Button, buttonVariants } from '@/components/ui/button'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport { Separator } from '@/components/ui/separator'\nimport { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'\nimport {\n Sidebar,\n SidebarContent,\n SidebarFooter,\n SidebarGroup,\n SidebarGroupContent,\n SidebarGroupLabel,\n SidebarHeader,\n SidebarInset,\n SidebarMenu,\n SidebarMenuAction,\n SidebarMenuButton,\n SidebarMenuItem,\n SidebarProvider,\n SidebarTrigger,\n useSidebar,\n} from '@/components/ui/sidebar'\nimport { titleOf, type Translate } from './controls.tsx'\n\n/**\n * As much of the access report as this screen reads: one `read` per kayan, keyed by its name.\n * `operations` is what a screen gates a control on: an effect is allowed where any one of its\n * operations is, so gate a button on `operations.archive`, never on the effect.\n */\nexport type AccessReport = {\n readonly collections?: Readonly<\n Record<\n string,\n { readonly read?: boolean; readonly operations?: Readonly<Record<string, boolean>> } | undefined\n >\n >\n}\n\n/**\n * The two keys of a kayan this screen reads: its name, and whether it is a global, which picks the\n * singular over the plural. A shell handed only these server-renders no field names or labels.\n */\nexport type NavKayan = Pick<ClientKayan, 'name' | 'single'>\n\n/**\n * One lucide icon per common kayan name, `Table2` for every other kayan. An app gives its own\n * kayan an icon by editing this map.\n */\nexport const navIcons: Record<string, LucideIcon> = {\n courses: BookOpen,\n sessions: CalendarDays,\n enrollments: ClipboardList,\n settings: Settings2,\n user: Users,\n localUsers: Users,\n member: Users,\n organization: Building2,\n invitation: Mail,\n roles: ShieldCheck,\n}\n\n/** The icon a kayan is listed under. `Object.hasOwn` so a kayan named for a key of `Object.prototype` reads nothing off it. */\nexport const iconOf = (name: string): LucideIcon => (Object.hasOwn(navIcons, name) ? navIcons[name]! : Table2)\n\n/** One entry of the kayan index: what it is called, where it goes and what it is drawn with. */\nexport type NavEntry = { kayan: NavKayan; label: string; href: string; Icon: LucideIcon }\n\n/**\n * What the header's `<h1>` says: the kayan's plural on its list, its singular on a row of it, the\n * shell's own heading anywhere else. A row is matched by the rule `NavMain` marks an entry active by.\n */\nexport const titleFor = ({\n entries,\n copy,\n pathname,\n}: {\n entries: readonly NavEntry[]\n copy: { t: Translate; locale: string }\n pathname?: string\n}): string | undefined => {\n const row = pathname === undefined ? undefined : entries.find((one) => pathname.startsWith(`${one.href}/`))\n if (row) return titleOf({ kayan: row.kayan, copy })\n const listed = entries.find((one) => one.href === pathname)\n return listed ? listed.label : copy.t('admin.nav', 'Admin')\n}\n\n/** The kayan index: the kayans this request's access report lets the viewer read. */\nexport const navEntries = ({\n client,\n access,\n copy,\n href,\n}: {\n client: readonly NavKayan[]\n access: AccessReport\n copy: { t: Translate; locale: string }\n href: (kayan: NavKayan) => string\n}): NavEntry[] =>\n client\n // `=== true` so a kayan the report has no entry for is denied, including one named for a key\n // of `Object.prototype`.\n .filter((kayan) => access.collections?.[kayan.name]?.read === true)\n .map((kayan) => ({\n kayan,\n label: titleOf({ kayan, copy, many: !kayan.single }),\n href: href(kayan),\n Icon: iconOf(kayan.name),\n }))\n\n/** The values the theme toggle offers. */\nexport type Theme = 'dark' | 'light' | 'system'\n\n/** The themes the user menu offers unless the project passes its own to `NavUserMenu`. */\nexport const defaultThemes: readonly Theme[] = ['light', 'dark', 'system']\n\ntype ThemeProviderProps = {\n children: ReactNode\n defaultTheme?: Theme\n storageKey?: string\n}\n\ntype ThemeProviderState = {\n theme: Theme\n setTheme: (theme: Theme) => void\n}\n\nconst initialState: ThemeProviderState = {\n theme: 'system',\n setTheme: () => null,\n}\n\n/** `null` outside a provider, so a menu that offers the choice draws nothing where nothing would keep it. */\nconst ThemeProviderContext = createContext<ThemeProviderState | null>(null)\n\n/**\n * Keeps the theme in `localStorage` and puts `light` or `dark` on the document element. The stored\n * theme is read in the first browser render and not in an effect: a change after mount re-renders\n * consumers, and inside an unhydrated Suspense boundary that makes React drop the server's HTML.\n */\nexport function ThemeProvider({\n children,\n defaultTheme = 'system',\n storageKey = 'vite-ui-theme',\n}: ThemeProviderProps) {\n const [theme, setTheme] = useState<Theme>(() => {\n if (typeof window === 'undefined') return defaultTheme\n const stored = localStorage.getItem(storageKey)\n // A stray value under this key would hydrate mismatched against the server's default.\n return stored === 'dark' || stored === 'light' || stored === 'system' ? stored : defaultTheme\n })\n\n useEffect(() => {\n const root = window.document.documentElement\n root.classList.remove('light', 'dark')\n\n if (theme === 'system') {\n const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'\n root.classList.add(systemTheme)\n return\n }\n\n root.classList.add(theme)\n }, [theme])\n\n const value = useMemo(\n () => ({\n theme,\n setTheme: (newTheme: Theme) => {\n localStorage.setItem(storageKey, newTheme)\n setTheme(newTheme)\n },\n }),\n [theme, storageKey],\n )\n\n return <ThemeProviderContext.Provider value={value}>{children}</ThemeProviderContext.Provider>\n}\n\n/** The theme and its setter, or `null` where no `ThemeProvider` is above. */\nexport const useTheme = () => useContext(ThemeProviderContext)\n\n/** A theme toggle button whose menu offers light, dark and system. */\nexport function ModeToggle({ t }: { t: Translate }) {\n const { setTheme } = useTheme() ?? initialState\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger className={buttonVariants({ variant: 'outline', size: 'icon' })}>\n <HugeiconsIcon\n icon={Sun03Icon}\n className=\"h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0\"\n />\n <HugeiconsIcon\n icon={Moon02Icon}\n className=\"absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100\"\n />\n <span className=\"sr-only\">{t('admin.theme', 'Toggle theme')}</span>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"max-sm:**:data-[slot$=item]:min-h-11\">\n <DropdownMenuItem onClick={() => setTheme('light')}>{t('admin.themeLight', 'Light')}</DropdownMenuItem>\n <DropdownMenuItem onClick={() => setTheme('dark')}>{t('admin.themeDark', 'Dark')}</DropdownMenuItem>\n <DropdownMenuItem onClick={() => setTheme('system')}>\n {t('admin.themeSystem', 'System')}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n )\n}\n\n/**\n * A language's own name for itself (`ar` reads `العربية`), whatever the viewer reads in. It is the\n * same in every locale, so it carries no copy key. `of` throws a `RangeError` on a malformed tag,\n * so the tag itself is the fallback.\n */\nexport const endonym = (locale: string): string => {\n try {\n return new Intl.DisplayNames([locale], { type: 'language' }).of(locale) ?? locale\n } catch {\n return locale\n }\n}\n\n/** A radio row: a 44 pixel target on a phone, and the tick on the inline end in right-to-left pages, where the primitive draws it at the right. */\nconst radioRow = 'max-sm:min-h-11 rtl:pr-3 rtl:pl-8 rtl:[&>[data-slot=dropdown-menu-radio-item-indicator]]:right-auto rtl:[&>[data-slot=dropdown-menu-radio-item-indicator]]:left-2'\n\n/** One row of the user menu's locale group: the locale, what it calls itself, and whether it is the one being read in. */\nexport const localeEntries = (\n locales: readonly string[],\n current: string,\n): readonly { locale: string; label: string; active: boolean }[] =>\n locales.map((one) => ({ locale: one, label: endonym(one), active: one === current }))\n\n/** What the shell is told about the viewer, which is the two strings the user menu draws. */\nexport type NavUser = {\n name: string\n email: string\n /** The other screens the host links for this viewer, each a whole address; the user menu draws a link to each. */\n screens?: readonly { name: string; path: string }[]\n}\n\n/**\n * One organization the switcher lists. Two may share a name, so each carries a slug, which the\n * switcher shows beside a name that repeats.\n */\nexport type NavOrganization = { id: string; name: string; slug?: string }\n\nexport type ShellProps = {\n /** Every kayan and global the app chose to hand down, as little of each as the two keys of `NavKayan`. */\n client: readonly NavKayan[]\n access: AccessReport\n t: Translate\n locale: string\n /** Where an entry goes. A prop so the shell imports no router. */\n href: (kayan: NavKayan) => string\n /** The path the viewer is on, so the entry for it is marked current. A prop for the same reason `href` is one. */\n pathname?: string\n /** Which side the sidebar is on. */\n side?: 'left' | 'right'\n user: NavUser\n organizations: readonly NavOrganization[]\n activeOrganizationId?: string | null\n /** What switching the active organization means, which is the app's business because it is the auth arm's. */\n onSwitchOrganization: (id: string) => void\n onSignOut: () => void\n /** Where the host's permissions screen is. No row is drawn without it. */\n permissionsHref?: string\n /** Which locales the host reads in, so the user menu can offer them. A prop because mithaq resolves no locale. */\n locales?: readonly string[]\n /** What choosing a locale means. Without it the menu has no locale group. */\n onLocale?: (locale: string) => void\n children?: ReactNode\n}\n\nexport function Shell({\n access,\n activeOrganizationId,\n children,\n client,\n href,\n locale,\n locales,\n onLocale,\n onSignOut,\n onSwitchOrganization,\n organizations,\n pathname,\n permissionsHref,\n side = 'left',\n t,\n user,\n}: ShellProps) {\n const entries = navEntries({ client, access, copy: { t, locale }, href })\n return (\n <SidebarProvider\n style={\n {\n '--sidebar-width': 'calc(var(--spacing) * 72)',\n '--header-height': 'calc(var(--spacing) * 12)',\n } as CSSProperties\n }\n >\n <AppSidebar\n activeOrganizationId={activeOrganizationId}\n entries={entries}\n locale={locale}\n locales={locales}\n onLocale={onLocale}\n onSignOut={onSignOut}\n onSwitchOrganization={onSwitchOrganization}\n organizations={organizations}\n pathname={pathname}\n permissionsHref={permissionsHref}\n side={side}\n t={t}\n user={user}\n variant=\"inset\"\n />\n {/* `min-w-0`: the inset is a flex item beside the sidebar, and a table wider than a phone\n would widen the whole document. */}\n <SidebarInset className=\"min-w-0\">\n <SiteHeader t={t} title={titleFor({ entries, copy: { t, locale }, pathname })} />\n <div className=\"flex flex-1 flex-col px-4 pb-4 lg:px-6 lg:pb-6\">\n {/* `min-w-0`: a flex child whose minimum is its content pushes the sidebar off the page\n instead of scrolling a wide table. */}\n <div className=\"@container/main flex min-w-0 flex-1 flex-col gap-2\">{children}</div>\n </div>\n </SidebarInset>\n </SidebarProvider>\n )\n}\n\n/**\n * The sidebar: organization switcher, kayan index and user menu. It takes `side` and no `dir`,\n * since the direction is already on the document.\n */\nexport function AppSidebar({\n activeOrganizationId,\n entries,\n locale,\n locales,\n onLocale,\n onSignOut,\n onSwitchOrganization,\n organizations,\n pathname,\n permissionsHref,\n t,\n user,\n ...props\n}: {\n activeOrganizationId?: string | null\n entries: readonly NavEntry[]\n locale?: string\n locales?: readonly string[]\n onLocale?: (locale: string) => void\n onSignOut: () => void\n onSwitchOrganization: (id: string) => void\n organizations: readonly NavOrganization[]\n pathname?: string\n permissionsHref?: string\n t: Translate\n user: NavUser\n} & ComponentProps<typeof Sidebar>) {\n return (\n <NavSidebar {...props} t={t}>\n {/* One navigation landmark around the whole sidebar. A box of its own: the sidebar's props\n reach no element on a phone, where it is a sheet. */}\n <nav aria-label={t('admin.navigation', 'Navigation')} className=\"flex min-h-0 flex-1 flex-col\">\n <SidebarHeader>\n <OrganizationSwitcher\n activeOrganizationId={activeOrganizationId}\n onSwitch={onSwitchOrganization}\n organizations={organizations}\n t={t}\n />\n </SidebarHeader>\n <SidebarContent>\n <NavMain entries={entries} pathname={pathname} t={t} />\n <NavDocuments t={t} />\n <NavSecondary className=\"mt-auto\" permissionsHref={permissionsHref} t={t} />\n </SidebarContent>\n <SidebarFooter>\n <NavUserMenu locale={locale} locales={locales} onLocale={onLocale} onSignOut={onSignOut} t={t} user={user} />\n </SidebarFooter>\n </nav>\n </NavSidebar>\n )\n}\n\n/**\n * The kayan index as one sidebar group. An entry is current when the path is its own or a segment\n * under it, so no two entries are current at once. `render` and not `asChild`: base-ui composes a\n * button into an anchor through `useRender`.\n */\nexport function NavMain({ entries, pathname, t }: { entries: readonly NavEntry[]; pathname?: string; t: Translate }) {\n return (\n <SidebarGroup>\n <SidebarGroupContent className=\"flex flex-col gap-2\">\n {/* Quick Create and Inbox are placeholders: this admin has no destination for either. */}\n <SidebarMenu>\n <SidebarMenuItem className=\"flex items-center gap-2\">\n <SidebarMenuButton\n tooltip={t('admin.quickCreate', 'Quick Create')}\n className=\"bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear\"\n >\n <PlusCircle />\n <span>{t('admin.quickCreate', 'Quick Create')}</span>\n </SidebarMenuButton>\n <Button size=\"icon\" className=\"size-8 group-data-[collapsible=icon]:opacity-0\" variant=\"outline\">\n <Mail />\n <span className=\"sr-only\">{t('admin.inbox', 'Inbox')}</span>\n </Button>\n </SidebarMenuItem>\n </SidebarMenu>\n <SidebarMenu>\n {entries.map((entry) => {\n const isActive = pathname !== undefined && (pathname === entry.href || pathname.startsWith(`${entry.href}/`))\n return (\n <SidebarMenuItem key={entry.kayan.name}>\n <SidebarMenuButton\n isActive={isActive}\n render={\n <a href={entry.href}>\n <entry.Icon />\n <span>{entry.label}</span>\n </a>\n }\n />\n </SidebarMenuItem>\n )\n })}\n </SidebarMenu>\n </SidebarGroupContent>\n </SidebarGroup>\n )\n}\n\n/**\n * The documents group. `items` defaults to none, so only the label and the \"More\" row draw; the\n * row markup stays for an app that passes its own documents.\n */\nexport function NavDocuments({\n items = [],\n t,\n}: {\n items?: readonly { name: string; url: string; icon: LucideIcon }[]\n t: Translate\n}) {\n const { isMobile } = useSidebar()\n return (\n <SidebarGroup className=\"group-data-[collapsible=icon]:hidden\">\n <SidebarGroupLabel>{t('admin.documents', 'Documents')}</SidebarGroupLabel>\n <SidebarMenu>\n {items.map((item) => (\n <SidebarMenuItem key={item.name}>\n <SidebarMenuButton render={<a href={item.url} />}>\n <item.icon />\n <span>{item.name}</span>\n </SidebarMenuButton>\n <DropdownMenu>\n <DropdownMenuTrigger\n render={(props) => (\n <SidebarMenuAction showOnHover className=\"data-[state=open]:bg-accent rounded-sm\" {...props}>\n <MoreHorizontal />\n <span className=\"sr-only\">{t('admin.more', 'More')}</span>\n </SidebarMenuAction>\n )}\n />\n <DropdownMenuContent\n className=\"w-24 rounded-lg\"\n side={isMobile ? 'bottom' : 'right'}\n align={isMobile ? 'end' : 'start'}\n >\n <DropdownMenuItem>\n <Folder className=\"me-2 h-4 w-4\" />\n <span>{t('admin.open', 'Open')}</span>\n </DropdownMenuItem>\n <DropdownMenuItem>\n <Share2 className=\"me-2 h-4 w-4\" />\n <span>{t('admin.share', 'Share')}</span>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant=\"destructive\">\n <Trash2 className=\"me-2 h-4 w-4\" />\n <span>{t('admin.trash', 'Move to Trash')}</span>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </SidebarMenuItem>\n ))}\n <SidebarMenuItem>\n <SidebarMenuButton className=\"text-sidebar-foreground/70\">\n <MoreHorizontal className=\"text-sidebar-foreground/70\" />\n <span>{t('admin.more', 'More')}</span>\n </SidebarMenuButton>\n </SidebarMenuItem>\n </SidebarMenu>\n </SidebarGroup>\n )\n}\n\n/**\n * The rows at the foot of the sidebar: a permissions row where the host gives `permissionsHref`,\n * then Get Help and Search, which point at `#`. Their copy keys are quoted literals at the call\n * so the copy sweep can see them.\n */\nexport function NavSecondary({\n className,\n permissionsHref,\n t,\n}: {\n className?: string\n permissionsHref?: string\n t: Translate\n}) {\n return (\n <SidebarGroup className={className}>\n <SidebarGroupContent>\n <SidebarMenu>\n {permissionsHref ? (\n <SidebarMenuItem>\n <SidebarMenuButton render={<a href={permissionsHref} />}>\n <ShieldCheck />\n <span>{t('admin.permissions', 'Permissions')}</span>\n </SidebarMenuButton>\n </SidebarMenuItem>\n ) : null}\n <SidebarMenuItem>\n <SidebarMenuButton render={<a href=\"#\" />}>\n <HelpCircle />\n <span>{t('admin.getHelp', 'Get Help')}</span>\n </SidebarMenuButton>\n </SidebarMenuItem>\n <SidebarMenuItem>\n <SidebarMenuButton render={<a href=\"#\" />}>\n <Search />\n <span>{t('admin.search', 'Search')}</span>\n </SidebarMenuButton>\n </SidebarMenuItem>\n </SidebarMenu>\n </SidebarGroupContent>\n </SidebarGroup>\n )\n}\n\nconst initialsOf = (name: string) =>\n name\n .split(/\\s+/)\n .filter(Boolean)\n .slice(0, 2)\n .map((word) => word[0]!.toUpperCase())\n .join('') || '?'\n\n/**\n * The organization switcher: a dropdown listing every organization as a radio item, the active\n * one checked.\n */\nexport function OrganizationSwitcher({\n activeOrganizationId,\n onSwitch,\n organizations,\n t,\n}: {\n activeOrganizationId?: string | null\n onSwitch: (id: string) => void\n organizations: readonly NavOrganization[]\n t: Translate\n}) {\n const { isMobile } = useSidebar()\n const active = organizations.find((one) => one.id === activeOrganizationId) ?? organizations[0]\n /** An organization's name, with its slug beside it where another listed shares the name. */\n const named = (one: NavOrganization) =>\n one.slug && organizations.some((other) => other.id !== one.id && other.name === one.name) ? (\n <>\n {one.name} <span dir=\"ltr\" data-slug=\"\" className=\"text-muted-foreground text-xs font-normal\">{one.slug}</span>\n </>\n ) : (\n one.name\n )\n const badge = (\n <>\n <div className=\"flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground\">\n <GalleryVerticalEnd className=\"size-4\" />\n </div>\n <div className=\"grid flex-1 text-start text-sm leading-tight\">\n <span className=\"truncate font-semibold text-foreground\">\n {active ? named(active) : t('admin.noOrganization', 'No organization')}\n </span>\n <span className=\"truncate text-xs\">\n {active ? t('admin.organization', 'Organization') : ''}\n </span>\n </div>\n </>\n )\n // One organization or none is nothing to switch between, so it is drawn as a plain name.\n if (organizations.length < 2) {\n return (\n <SidebarMenu>\n <SidebarMenuItem data-organization=\"\">\n <div className=\"flex h-12 items-center gap-2 p-2\">{badge}</div>\n </SidebarMenuItem>\n </SidebarMenu>\n )\n }\n return (\n <SidebarMenu>\n <SidebarMenuItem>\n <DropdownMenu>\n <DropdownMenuTrigger\n render={(props, { open }) => (\n <SidebarMenuButton\n size=\"lg\"\n data-state={open ? 'open' : 'closed'}\n className=\"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground\"\n {...props}\n >\n {badge}\n <ChevronsUpDown className=\"ms-auto\" />\n </SidebarMenuButton>\n )}\n />\n <DropdownMenuContent\n className=\"min-w-56 rounded-lg max-sm:**:data-[slot$=item]:min-h-11\"\n align=\"start\"\n side={isMobile ? 'bottom' : 'right'}\n sideOffset={4}\n >\n <DropdownMenuGroup>\n <DropdownMenuLabel className=\"text-xs text-muted-foreground\">\n {t('admin.organizations', 'Organizations')}\n </DropdownMenuLabel>\n {/* Radio items, so the current organization is `aria-checked`; `closeOnClick`\n because a radio item does not close the menu by itself. */}\n <DropdownMenuRadioGroup value={active?.id} onValueChange={onSwitch}>\n {organizations.map((one) => (\n <DropdownMenuRadioItem key={one.id} value={one.id} closeOnClick className={radioRow}>\n {named(one)}\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n </SidebarMenuItem>\n </SidebarMenu>\n )\n}\n\n/**\n * The user menu: avatar, name and email, a link to each of the viewer's other screens, a locale\n * group, a theme group and sign-out. The locale and theme groups draw only where they can work.\n */\nexport function NavUserMenu({\n locale,\n locales,\n onLocale,\n onSignOut,\n t,\n themes = defaultThemes,\n user,\n}: {\n locale?: string\n locales?: readonly string[]\n onLocale?: (locale: string) => void\n onSignOut: () => void\n t: Translate\n /**\n * Which themes the menu offers: `defaultThemes` unless given, none for `[]`. Drawn only where a\n * `ThemeProvider` is above. Rows keep the order light, dark, system.\n */\n themes?: readonly Theme[]\n user: NavUser\n}) {\n const { isMobile } = useSidebar()\n const provided = useTheme()\n const screens = user.screens\n return (\n <SidebarMenu>\n <SidebarMenuItem>\n <DropdownMenu>\n <DropdownMenuTrigger\n render={(props, { open }) => (\n <SidebarMenuButton\n data-state={open ? 'open' : 'closed'}\n size=\"lg\"\n className=\"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground\"\n {...props}\n >\n <Avatar className=\"h-8 w-8 rounded-lg grayscale\">\n <AvatarFallback className=\"rounded-lg\">{initialsOf(user.name)}</AvatarFallback>\n </Avatar>\n <div className=\"grid flex-1 text-start text-sm leading-tight\">\n <span className=\"truncate font-medium\">{user.name}</span>\n <span className=\"text-muted-foreground truncate text-xs\">{user.email}</span>\n </div>\n <ChevronsUpDown className=\"ms-auto size-4\" />\n </SidebarMenuButton>\n )}\n />\n <DropdownMenuContent\n className=\"min-w-56 rounded-lg w-56 max-sm:**:data-[slot$=item]:min-h-11\"\n side={isMobile ? 'bottom' : 'top'}\n align=\"end\"\n sideOffset={4}\n >\n <DropdownMenuGroup>\n <DropdownMenuLabel className=\"p-0 font-normal\">\n <div className=\"flex items-center gap-2 px-1 py-1.5 text-start text-sm\">\n <Avatar className=\"h-8 w-8 rounded-lg\">\n <AvatarFallback className=\"rounded-lg\">{initialsOf(user.name)}</AvatarFallback>\n </Avatar>\n <div className=\"grid flex-1 text-start text-sm leading-tight\">\n <span className=\"truncate font-medium\">{user.name}</span>\n <span className=\"text-muted-foreground truncate text-xs\">{user.email}</span>\n </div>\n </div>\n </DropdownMenuLabel>\n </DropdownMenuGroup>\n {screens && screens.length > 0 ? (\n <>\n <DropdownMenuSeparator />\n <DropdownMenuGroup>\n {screens.map((one) => (\n <DropdownMenuItem key={one.name} className=\"max-sm:min-h-11\" render={<a href={one.path} />}>\n <LayoutGrid className=\"me-2 h-4 w-4\" />\n {t(`screens.${one.name}`, one.name) ?? one.name}\n </DropdownMenuItem>\n ))}\n </DropdownMenuGroup>\n </>\n ) : null}\n {locales && locales.length > 0 && onLocale ? (\n <>\n <DropdownMenuSeparator />\n <DropdownMenuGroup>\n {localeEntries(locales, locale ?? '').map((entry) => (\n <DropdownMenuItem key={entry.locale} className=\"max-sm:min-h-11\" onClick={() => onLocale(entry.locale)}>\n <Languages className=\"me-2 h-4 w-4\" />\n {entry.label}\n {entry.active ? <Check className=\"ms-auto h-4 w-4\" /> : null}\n </DropdownMenuItem>\n ))}\n </DropdownMenuGroup>\n </>\n ) : null}\n {provided && themes.length > 0 ? (\n <>\n <DropdownMenuSeparator />\n <DropdownMenuGroup>\n <DropdownMenuLabel className=\"text-xs text-muted-foreground\">{t('admin.theme', 'Toggle theme')}</DropdownMenuLabel>\n <DropdownMenuRadioGroup value={provided.theme} onValueChange={(next) => provided.setTheme(next as Theme)}>\n {(\n [\n ['light', Sun, t('admin.themeLight', 'Light')],\n ['dark', Moon, t('admin.themeDark', 'Dark')],\n ['system', Monitor, t('admin.themeSystem', 'System')],\n ] as const\n )\n .filter(([value]) => themes.includes(value))\n .map(([value, Icon, label]) => (\n <DropdownMenuRadioItem key={value} value={value} closeOnClick className={radioRow}>\n <Icon />\n {label}\n </DropdownMenuRadioItem>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuGroup>\n </>\n ) : null}\n <DropdownMenuSeparator />\n <DropdownMenuItem className=\"max-sm:min-h-11\" onClick={onSignOut}>\n <LogOut className=\"me-2 h-4 w-4\" />\n {t('admin.signOut', 'Sign out')}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </SidebarMenuItem>\n </SidebarMenu>\n )\n}\n\n/**\n * `Sidebar`, except that on a phone its sheet is titled and described in the page's language;\n * the primitive's sheet is English only, which a screen reader announces on an Arabic page.\n * It has no close button: the overlay and Escape close it.\n */\nexport function NavSidebar({ children, side = 'left', t, ...props }: ComponentProps<typeof Sidebar> & { t: Translate }) {\n const { isMobile, openMobile, setOpenMobile } = useSidebar()\n if (!isMobile) return <Sidebar {...props} side={side} collapsible=\"offcanvas\">{children}</Sidebar>\n return (\n <Sheet open={openMobile} onOpenChange={setOpenMobile}>\n <SheetContent\n side={side}\n showCloseButton={false}\n data-sidebar=\"sidebar\"\n data-slot=\"sidebar\"\n data-mobile=\"true\"\n className=\"bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0\"\n style={{ '--sidebar-width': '18rem' } as CSSProperties}\n >\n <SheetHeader className=\"sr-only\">\n <SheetTitle>{t('admin.navigation', 'Navigation')}</SheetTitle>\n <SheetDescription>{t('admin.navigationAbout', 'The pages you can open.')}</SheetDescription>\n </SheetHeader>\n <div className=\"flex h-full w-full flex-col\">{children}</div>\n </SheetContent>\n </Sheet>\n )\n}\n\n/**\n * The header: sidebar trigger, the screen's title and the theme toggle. The trigger's\n * `aria-label` is its only name, so an Arabic page carries no English word for it.\n */\nexport function SiteHeader({ t, title }: { t: Translate; title?: string }) {\n return (\n <header className=\"flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)\">\n <div className=\"flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6\">\n <SidebarTrigger aria-label={t('admin.toggleSidebar', 'Toggle Sidebar')} className=\"-ms-1\" />\n <Separator orientation=\"vertical\" className=\"mx-2 data-[orientation=vertical]:h-4\" />\n <h1 className=\"text-base font-medium\">{title ?? t('admin.nav', 'Admin')}</h1>\n <div className=\"ms-auto flex items-center gap-2\">\n <ModeToggle t={t} />\n </div>\n </div>\n </header>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 b1ad44964867b0d65635652733a4821b190a720b1e283fc2f4aab3f82993effa\n",
|
|
19
|
-
"type": "registry:component",
|
|
20
|
-
"target": "@components/mithaq/nav.tsx"
|
|
21
|
-
}
|
|
22
|
-
],
|
|
23
|
-
"type": "registry:component"
|
|
24
|
-
}
|
package/r/mithaq-nodes.json
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
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
|
-
}
|