@cosmicdrift/kumiko-renderer 0.398.0 → 0.399.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.
@@ -13,6 +13,7 @@ export function resolveEntityFacetSpecs(fields, featureName, entityName, transla
13
13
  options: propertyOf(rawDef, "options"),
14
14
  entity: propertyOf(rawDef, "entity"),
15
15
  labelField: propertyOf(rawDef, "labelField"),
16
+ optionsQuery: propertyOf(rawDef, "optionsQuery"),
16
17
  };
17
18
  if (def.filterable !== true)
18
19
  continue;
@@ -57,6 +58,7 @@ export function resolveEntityFacetSpecs(fields, featureName, entityName, transla
57
58
  refEntity: target.entityName,
58
59
  refFeature: target.featureName,
59
60
  labelField: typeof def.labelField === "string" ? def.labelField : "id",
61
+ ...(typeof def.optionsQuery === "string" && { optionsQuery: def.optionsQuery }),
60
62
  },
61
63
  });
62
64
  }
@@ -25,7 +25,7 @@ import { isDefaultFilterActive, isListNarrowed, useListEmptyContent, } from "./l
25
25
  import { buildEntityFilterFacets, buildFacetCountRequests, buildFilterPayload, mergeReferenceFacetOptions, normalizeDefaultFilters, } from "./list-facets.js";
26
26
  import { useNav } from "./nav.js";
27
27
  import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
28
- import { ReferenceFacetBridges } from "./reference-facet-bridge.js";
28
+ import { ReferenceFacetBridges, withReferenceFacetState, } from "./reference-facet-bridge.js";
29
29
  import { navigateWithReturnTo, useReturnHost } from "./return-to.js";
30
30
  import { buildClientActionRowAction, buildDefaultEditRowAction, buildDrawerRowAction, buildProjectionToolbarActions, buildWriteHandlerRowAction, findRowClickAction, navigateActionSearchParams, runDrawerRowAction, } from "./row-actions.js";
31
31
  import { findEditScreenFor } from "./screen-access.js";
@@ -154,7 +154,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
154
154
  await refreshListAfterWrite();
155
155
  }, [refreshFacetCounts, refreshListAfterWrite]);
156
156
  const [entityReferenceFacetOptions, setEntityReferenceFacetOptions] = useState({});
157
- const handleEntityFacetOptions = useCallback((field, options) => setEntityReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
157
+ const handleEntityFacetOptions = useCallback((field, state) => setEntityReferenceFacetOptions((prev) => withReferenceFacetState(prev, field, state)), []);
158
158
  const [facetCounts, setFacetCounts] = useState({});
159
159
  const handleFacetCount = useCallback((key, count) => setFacetCounts((prev) => (prev[key] === count ? prev : { ...prev, [key]: count })), []);
160
160
  const filterFacets = useMemo(() => buildEntityFilterFacets(mergeReferenceFacetOptions(entityFacetSpecs, entityReferenceFacetOptions), screen.facets, effectiveTranslate, facetCounts), [entityFacetSpecs, entityReferenceFacetOptions, screen.facets, effectiveTranslate, facetCounts]);
@@ -395,7 +395,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
395
395
  // arrives async; until it is there RenderList renders the table without a
396
396
  // pager (the pager has its own total>0 guard).
397
397
  const pager = listPager({ usePager, page: listRows.page, limit, urlState });
398
- return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(ListGroupPageBridges, { ...listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, onOptions: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount, refreshNonce: facetCountRefreshNonce }), _jsx(RenderList, { screen: screen, entity: entity, rows: listRows.rows, featureName: featureName, searchable: searchable, narrowed: isListNarrowed(urlState.q, urlState.filters, defaultFilters), defaultFilterActive: isDefaultFilterActive(urlState.q, urlState.filters, defaultFilters), ...(screen.description !== undefined && {
398
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(ListGroupPageBridges, { ...listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, selected: urlState.filters, onState: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount, refreshNonce: facetCountRefreshNonce }), _jsx(RenderList, { screen: screen, entity: entity, rows: listRows.rows, featureName: featureName, searchable: searchable, narrowed: isListNarrowed(urlState.q, urlState.filters, defaultFilters), defaultFilterActive: isDefaultFilterActive(urlState.q, urlState.filters, defaultFilters), ...(screen.description !== undefined && {
399
399
  description: effectiveTranslate(screen.description),
400
400
  }), ...(emptyContent !== undefined && { emptyContent }), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
401
401
  ? { screenPadding: false, chromeless: true }
@@ -1,6 +1,7 @@
1
1
  import type { EntityListFacetConfig, ListFacetSpec } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import type { Translate } from "../headless/index.ts";
3
3
  import type { DataTableFacet, DataTableFacetChip } from "../primitives.tsx";
4
+ import type { ReferenceFacetState } from "./reference-facet-bridge.tsx";
4
5
  export type ResolvedFacetSpec = {
5
6
  readonly field: string;
6
7
  readonly type: "select" | "boolean" | "reference";
@@ -17,12 +18,14 @@ export type ResolvedFacetSpec = {
17
18
  readonly refEntity: string;
18
19
  readonly refFeature: string;
19
20
  readonly labelField: string;
21
+ /** Author handler that serves the options instead of the entity list. */
22
+ readonly optionsQuery?: string;
20
23
  };
24
+ /** Reference facets only: set once the bridge resolved it. Makes the facet searchable. */
25
+ readonly onSearchChange?: (query: string) => void;
26
+ readonly searching?: boolean;
21
27
  };
22
- export declare function mergeReferenceFacetOptions(specs: readonly ResolvedFacetSpec[], optionsByField: Readonly<Record<string, readonly {
23
- readonly value: string;
24
- readonly label: string;
25
- }[]>>): ResolvedFacetSpec[];
28
+ export declare function mergeReferenceFacetOptions(specs: readonly ResolvedFacetSpec[], stateByField: Readonly<Record<string, ReferenceFacetState>>): ResolvedFacetSpec[];
26
29
  export declare function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[];
27
30
  type FacetConfigByField = Readonly<Record<string, EntityListFacetConfig | false>>;
28
31
  export declare function buildFacetChips(spec: ResolvedFacetSpec, config: EntityListFacetConfig, translate: Translate): DataTableFacetChip[];
@@ -4,13 +4,30 @@ import { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
4
4
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
5
5
  // Fills in a "reference" spec's initially-empty `options` from the
6
6
  // field-keyed options ReferenceFacetBridges resolved, shared by every caller.
7
- export function mergeReferenceFacetOptions(specs, optionsByField) {
8
- return specs.map((spec) => spec.reference !== undefined
9
- ? { ...spec, options: optionsByField[spec.field] ?? spec.options }
10
- : spec);
7
+ export function mergeReferenceFacetOptions(specs, stateByField) {
8
+ return specs.map((spec) => {
9
+ const state = stateByField[spec.field];
10
+ return spec.reference !== undefined && state !== undefined
11
+ ? {
12
+ ...spec,
13
+ options: state.options,
14
+ onSearchChange: state.onSearchChange,
15
+ searching: state.searching,
16
+ }
17
+ : spec;
18
+ });
19
+ }
20
+ function facetBase(spec) {
21
+ return {
22
+ field: spec.field,
23
+ label: spec.label,
24
+ options: spec.options,
25
+ ...(spec.onSearchChange !== undefined && { onSearchChange: spec.onSearchChange }),
26
+ ...(spec.searching !== undefined && { searching: spec.searching }),
27
+ };
11
28
  }
12
29
  export function buildFilterFacets(specs) {
13
- return specs.map((spec) => ({ field: spec.field, label: spec.label, options: spec.options }));
30
+ return specs.map(facetBase);
14
31
  }
15
32
  // Chips are the field's own options plus the declared extra choices
16
33
  // ("Running" bundle, "All"), in the order the config's `position` asks for.
@@ -39,7 +56,7 @@ export function buildEntityFilterFacets(specs, configs, translate, counts) {
39
56
  const config = configs?.[spec.field];
40
57
  if (config === false)
41
58
  continue;
42
- const base = { field: spec.field, label: spec.label, options: spec.options };
59
+ const base = facetBase(spec);
43
60
  if (config?.display !== "chips") {
44
61
  out.push(base);
45
62
  continue;
@@ -108,7 +108,7 @@ export function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
108
108
  limit: query.limit,
109
109
  urlState: query.urlState,
110
110
  });
111
- return (_jsxs(_Fragment, { children: [refetchErrorBanner, actions.toolbarGateQueries, _jsx(ListGroupPageBridges, { ...data.listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: query.facetSpecs, onOptions: query.handleProjectionFacetOptions }), renderProjectionList({
111
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, actions.toolbarGateQueries, _jsx(ListGroupPageBridges, { ...data.listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: query.facetSpecs, selected: query.urlState.filters, onState: query.handleProjectionFacetOptions }), renderProjectionList({
112
112
  schema,
113
113
  screen,
114
114
  translate,
@@ -4,7 +4,19 @@ export type ReferenceFacetOption = {
4
4
  readonly value: string;
5
5
  readonly label: string;
6
6
  };
7
- export declare function ReferenceFacetBridges({ specs, onOptions, }: {
7
+ /** What a bridge publishes for its facet: the options for the current search term plus the
8
+ * handle that changes the term. */
9
+ export type ReferenceFacetState = {
10
+ readonly options: readonly ReferenceFacetOption[];
11
+ readonly onSearchChange: (query: string) => void;
12
+ readonly searching: boolean;
13
+ };
14
+ /** Reducer step for the callers' state: same state keeps the previous object, so an unchanged
15
+ * bridge does not re-render the list. */
16
+ export declare function withReferenceFacetState(previous: Readonly<Record<string, ReferenceFacetState>>, field: string, state: ReferenceFacetState): Readonly<Record<string, ReferenceFacetState>>;
17
+ export declare function ReferenceFacetBridges({ specs, selected, onState, }: {
8
18
  readonly specs: readonly ResolvedFacetSpec[];
9
- readonly onOptions: (field: string, options: readonly ReferenceFacetOption[]) => void;
19
+ /** The facet selections of the list (field to chosen ids), so chosen values keep their label. */
20
+ readonly selected: Readonly<Record<string, readonly string[]>>;
21
+ readonly onState: (field: string, state: ReferenceFacetState) => void;
10
22
  }): ReactNode;
@@ -1,29 +1,48 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
2
  // One bridge component per reference facet, mirroring ReferenceLookupBridge —
3
3
  // facet count varies per screen, so the hook can't be called in a loop.
4
- import { useEffect } from "react";
5
- import { rendererWarn } from "../dev-console.js";
6
- import { REFERENCE_LIST_LOOKUP_LIMIT } from "../hooks/reference-limits.js";
7
- import { useReferenceLookup } from "../hooks/use-reference-lookup.js";
8
- function ReferenceFacetOptionsBridge({ field, refEntity, refFeature, labelField, onOptions, }) {
9
- const lookup = useReferenceLookup(refFeature, refEntity, labelField);
4
+ import { useCallback, useEffect, useMemo, useState } from "react";
5
+ import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
6
+ import { useQuery } from "../hooks/use-query.js";
7
+ import { referenceOptionSource, storedReferenceRowsPayload, withUnlistedSelectedRows, } from "../hooks/use-reference-lookup.js";
8
+ /** Reducer step for the callers' state: same state keeps the previous object, so an unchanged
9
+ * bridge does not re-render the list. */
10
+ export function withReferenceFacetState(previous, field, state) {
11
+ const before = previous[field];
12
+ const unchanged = before?.options === state.options &&
13
+ before.searching === state.searching &&
14
+ before.onSearchChange === state.onSearchChange;
15
+ return unchanged ? previous : { ...previous, [field]: state };
16
+ }
17
+ function ReferenceFacetOptionsBridge({ field, refEntity, refFeature, labelField, optionsQuery, selected, onState, }) {
18
+ const { queryQn, labelKey } = referenceOptionSource({
19
+ refLabelField: labelField,
20
+ ...(optionsQuery !== undefined && { refOptionsQuery: optionsQuery }),
21
+ }, refFeature, refEntity);
22
+ const [searchTerm, setSearchTerm] = useState("");
23
+ const payload = useMemo(() => searchTerm === ""
24
+ ? { limit: REFERENCE_COMBOBOX_LIMIT }
25
+ : { limit: REFERENCE_COMBOBOX_LIMIT, search: searchTerm }, [searchTerm]);
26
+ const listed = useQuery(queryQn, payload);
27
+ // The first page can miss a chosen value; without its row the chip would show the raw id.
28
+ const chosen = useQuery(queryQn, storedReferenceRowsPayload(optionsQuery !== undefined ? { refOptionsQuery: optionsQuery } : {}, selected, REFERENCE_COMBOBOX_LIMIT), { enabled: selected.length > 0 });
29
+ const onSearchChange = useCallback((query) => setSearchTerm(query), []);
30
+ const options = useMemo(() => withUnlistedSelectedRows(listed.data?.rows ?? [], chosen.data?.rows ?? []).map((row) => {
31
+ const value = String(row["id"] ?? "");
32
+ return { value, label: String(row[labelKey] ?? value) };
33
+ }), [listed.data, chosen.data, labelKey]);
34
+ const searching = listed.loading;
10
35
  useEffect(() => {
11
- if (lookup.loading)
12
- return;
13
- // The lookup is one capped list fetch; a full page means the facet may be
14
- // missing values the user wants to filter by.
15
- if (lookup.map.size >= REFERENCE_LIST_LOOKUP_LIMIT) {
16
- rendererWarn(`[kumiko] reference facet "${field}" shows only the first ${REFERENCE_LIST_LOOKUP_LIMIT} ${refEntity} rows; further values cannot be filtered by`);
17
- }
18
- onOptions(field, [...lookup.map.entries()].map(([value, label]) => ({ value, label })));
19
- }, [lookup.loading, lookup.map, field, refEntity, onOptions]);
36
+ onState(field, { options, onSearchChange, searching });
37
+ }, [field, options, onSearchChange, searching, onState]);
20
38
  return null;
21
39
  }
22
40
  // One invisible bridge per "reference" facet spec, each publishing its
23
- // resolved options back via `onOptions` once loaded.
24
- export function ReferenceFacetBridges({ specs, onOptions, }) {
41
+ // resolved options back via `onState` once loaded.
42
+ export function ReferenceFacetBridges({ specs, selected, onState, }) {
25
43
  const referenceSpecs = specs.filter((spec) => spec.reference !== undefined);
26
44
  if (referenceSpecs.length === 0)
27
45
  return null;
28
- return (_jsx(_Fragment, { children: referenceSpecs.map((spec) => (_jsx(ReferenceFacetOptionsBridge, { field: spec.field, refEntity: spec.reference?.refEntity ?? "", refFeature: spec.reference?.refFeature ?? "", labelField: spec.reference?.labelField ?? "id", onOptions: onOptions }, spec.field))) }));
46
+ return (_jsx(_Fragment, { children: referenceSpecs.map((spec) => (_jsx(ReferenceFacetOptionsBridge, { field: spec.field, refEntity: spec.reference?.refEntity ?? "", refFeature: spec.reference?.refFeature ?? "", labelField: spec.reference?.labelField ?? "id", optionsQuery: spec.reference?.optionsQuery, selected: selected[spec.field] ?? NO_SELECTION, onState: onState }, spec.field))) }));
29
47
  }
48
+ const NO_SELECTION = [];
@@ -2,7 +2,7 @@ import type { ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-framewo
2
2
  import type { Translate } from "../headless/index.ts";
3
3
  import { type ListUrlStateApi } from "../hooks/use-list-url-state.ts";
4
4
  import type { synthesizeProjectionEntity } from "./projection-list-shim.ts";
5
- import type { ReferenceFacetOption } from "./reference-facet-bridge.tsx";
5
+ import { type ReferenceFacetState } from "./reference-facet-bridge.tsx";
6
6
  type ProjectionListQueryInput = {
7
7
  readonly screen: ProjectionListScreenDefinition;
8
8
  readonly featureName: string;
@@ -19,7 +19,7 @@ export declare function useProjectionListQuery(input: ProjectionListQueryInput):
19
19
  limit: number;
20
20
  usePager: boolean;
21
21
  facetSpecs: import("./list-facets.ts").ResolvedFacetSpec[];
22
- handleProjectionFacetOptions: (field: string, options: readonly ReferenceFacetOption[]) => void;
22
+ handleProjectionFacetOptions: (field: string, state: ReferenceFacetState) => void;
23
23
  resolvedFacetSpecs: import("./list-facets.ts").ResolvedFacetSpec[];
24
24
  dateRangeFacets: import("../data-table-facet-types.ts").DataTableDateRangeFacet[];
25
25
  handleDateRangeChange: (field: string, bound: "from" | "to", value: string) => void;
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useMemo, useState } from "react";
2
2
  import { useListUrlState } from "../hooks/use-list-url-state.js";
3
3
  import { buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "./list-facets.js";
4
+ import { withReferenceFacetState } from "./reference-facet-bridge.js";
4
5
  import { buildListQueryPayload } from "./screen-shared.js";
5
6
  import { useProjectionDateRange } from "./use-projection-date-range.js";
6
7
  // Same predicate that makes a header clickable: a stale link onto a
@@ -30,7 +31,7 @@ export function useProjectionListQuery(input) {
30
31
  // buildFilterFacets/buildFilterPayload.
31
32
  const facetSpecs = useMemo(() => resolveProjectionFacetSpecs(screen.facets, effectiveTranslate, featureName), [screen.facets, effectiveTranslate, featureName]);
32
33
  const [projectionReferenceFacetOptions, setProjectionReferenceFacetOptions] = useState({});
33
- const handleProjectionFacetOptions = useCallback((field, options) => setProjectionReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
34
+ const handleProjectionFacetOptions = useCallback((field, state) => setProjectionReferenceFacetOptions((prev) => withReferenceFacetState(prev, field, state)), []);
34
35
  const resolvedFacetSpecs = useMemo(() => mergeReferenceFacetOptions(facetSpecs, projectionReferenceFacetOptions), [facetSpecs, projectionReferenceFacetOptions]);
35
36
  const filterPayload = useMemo(() => buildFilterPayload(urlState.filters, (field) => facetSpecs.find((spec) => spec.field === field)?.type), [urlState.filters, facetSpecs]);
36
37
  const { dateRangePayload, dateRangeFacets, handleDateRangeChange } = useProjectionDateRange(screen.facets, effectiveTranslate, urlState);
@@ -8,7 +8,7 @@ import { useListBulk } from "../app/list-bulk-actions.js";
8
8
  import { buildFilterFacets, buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "../app/list-facets.js";
9
9
  import { useNav } from "../app/nav.js";
10
10
  import { SYNTHESIZED_FIELD_PII_REASON } from "../app/projection-list-shim.js";
11
- import { ReferenceFacetBridges, } from "../app/reference-facet-bridge.js";
11
+ import { ReferenceFacetBridges, withReferenceFacetState, } from "../app/reference-facet-bridge.js";
12
12
  import { navigateWithReturnTo, useReturnHost } from "../app/return-to.js";
13
13
  import { buildDefaultEditRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, findRowClickAction, runDrawerRowAction, runProjectionRowNavigate, } from "../app/row-actions.js";
14
14
  import { findEditScreenFor } from "../app/screen-access.js";
@@ -142,7 +142,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
142
142
  const { search, filters, sort, setSearch, setFilters, setSort, listTable, groupField } = useRelatedListState(section, nav.route?.screenId);
143
143
  const facetSpecs = useMemo(() => resolveProjectionFacetSpecs(section.facets, effectiveTranslate, featureName), [section.facets, effectiveTranslate, featureName]);
144
144
  const [referenceFacetOptions, setReferenceFacetOptions] = useState({});
145
- const handleFacetOptions = useCallback((field, options) => setReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
145
+ const handleFacetOptions = useCallback((field, state) => setReferenceFacetOptions((prev) => withReferenceFacetState(prev, field, state)), []);
146
146
  const resolvedFacetSpecs = useMemo(() => mergeReferenceFacetOptions(facetSpecs, referenceFacetOptions), [facetSpecs, referenceFacetOptions]);
147
147
  const filterPayload = useMemo(() => buildFilterPayload(filters, (field) => resolvedFacetSpecs.find((spec) => spec.field === field)?.type), [filters, resolvedFacetSpecs]);
148
148
  const filterFacets = useMemo(() => buildFilterFacets(resolvedFacetSpecs), [resolvedFacetSpecs]);
@@ -332,7 +332,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
332
332
  // `renderer` stays DOM-free. Stacked (non-tabs) sections keep their own
333
333
  // Section card and document-flow height since they render a visible title
334
334
  // and aren't confined to a tab panel.
335
- const bridges = _jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleFacetOptions });
335
+ const bridges = (_jsx(ReferenceFacetBridges, { specs: facetSpecs, selected: filters, onState: handleFacetOptions }));
336
336
  if (embedded === true) {
337
337
  const countSuffix = listRows.hasData
338
338
  ? ` · ${aggregates.serverGroups?.totalCount ?? listRows.rows.length}`
@@ -10,7 +10,7 @@ function renderTabbedGroupsSection(rc, section, sectionKey) {
10
10
  // group cards (Card-in-Card) — the boot-validator rejects that
11
11
  // combination in tabs mode, so `sectionActionsEl` is always
12
12
  // undefined here at runtime.
13
- return rc.wrapWizardStep(sectionKey, _jsx(Card, { options: { framed: false }, children: groupsGrid }, sectionKey));
13
+ return rc.wrapWizardStep(sectionKey, _jsx(Card, { options: { framed: false }, ...(section.description !== undefined && { slots: { subtitle: section.description } }), children: groupsGrid }, sectionKey));
14
14
  }
15
15
  function renderTabbedFieldsSection(rc, section, sectionKey) {
16
16
  if (section.groups !== undefined)
@@ -16,6 +16,11 @@ export type DataTableFacet = {
16
16
  readonly showCounts?: boolean;
17
17
  /** Chips only: hide chips whose `count` is 0 (unless pressed). */
18
18
  readonly hideEmpty?: boolean;
19
+ /** Present: the options come from a server-side search (reference facets), so the dropdown
20
+ * shows a search field and reports the typed term instead of filtering `options` itself. */
21
+ readonly onSearchChange?: (query: string) => void;
22
+ /** The search is loading; the option list may still show the previous term's rows. */
23
+ readonly searching?: boolean;
19
24
  };
20
25
  export type DataTableFacetChip = {
21
26
  readonly id: string;
@@ -242,6 +242,14 @@ export const kumikoDefaultTranslations = {
242
242
  "kumiko.nav.expand": "Expand",
243
243
  "kumiko.nav.collapse": "Collapse",
244
244
  "kumiko.nav.search": "Search navigation…",
245
+ "kumiko.notifications.label": "Notifications",
246
+ "kumiko.notifications.labelUnread": "Notifications, {count} unread",
247
+ "kumiko.notifications.title": "Notifications",
248
+ "kumiko.notifications.empty": "No notifications yet.",
249
+ "kumiko.notifications.markAllRead": "Mark all as read",
250
+ "kumiko.notifications.loadMore": "Load more",
251
+ "kumiko.notifications.unread": "Unread",
252
+ "kumiko.notifications.loadFailed": "Notifications could not be loaded.",
245
253
  "kumiko.nav.language": "Language",
246
254
  "kumiko.workspace.switch": "Switch workspace",
247
255
  // Workspace-Switcher fallback label when activeId points at no visible workspace.
@@ -0,0 +1,5 @@
1
+ import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { type ReactNode } from "react";
3
+ export declare function NotificationBell({ screens, }: {
4
+ readonly screens: readonly ScreenDefinition[];
5
+ }): ReactNode;
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Bell } from "lucide-react";
3
+ import { Popover as PopoverPrimitive } from "radix-ui";
4
+ import { useState } from "react";
5
+ import { lastSegment, useLocale, useNav, useTranslation } from "../../index.js";
6
+ import { useRelativeTimeFormatter } from "../app/relative-time.js";
7
+ import { cn } from "../lib/cn.js";
8
+ import { useInAppInbox } from "./use-in-app-inbox.js";
9
+ const MAX_BADGE_COUNT = 99;
10
+ const triggerClass = "relative inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-input " +
11
+ "bg-background text-foreground shadow-xs transition-colors hover:bg-accent " +
12
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
13
+ const panelClass = "z-50 flex max-h-[28rem] w-[min(22rem,calc(100vw-1.5rem))] flex-col overflow-hidden rounded-md border " +
14
+ "bg-popover text-popover-foreground shadow-md";
15
+ export function NotificationBell({ screens, }) {
16
+ const t = useTranslation();
17
+ const nav = useNav();
18
+ const locale = useLocale().locale();
19
+ const formatRelative = useRelativeTimeFormatter(locale);
20
+ const [open, setOpen] = useState(false);
21
+ const inbox = useInAppInbox(open);
22
+ const badge = inbox.unreadCount > MAX_BADGE_COUNT ? `${MAX_BADGE_COUNT}+` : String(inbox.unreadCount);
23
+ const label = inbox.unreadCount > 0
24
+ ? t("kumiko.notifications.labelUnread", { count: badge })
25
+ : t("kumiko.notifications.label");
26
+ const openRow = async (row) => {
27
+ if (!row.isRead)
28
+ await inbox.markRead(row.id);
29
+ const target = row.target;
30
+ if (target === null)
31
+ return;
32
+ if (!screens.some((screen) => screen.id === target.screenId))
33
+ return;
34
+ const screenId = lastSegment(target.screenId);
35
+ setOpen(false);
36
+ nav.navigate(target.entityId === undefined ? { screenId } : { screenId, entityId: target.entityId });
37
+ };
38
+ return (_jsxs(PopoverPrimitive.Root, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverPrimitive.Trigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": label, "data-kumiko-layout": "notification-bell", className: triggerClass, children: [_jsx(Bell, { className: "size-4", "aria-hidden": "true" }), inbox.unreadCount > 0 && (_jsx("span", { "data-kumiko-layout": "notification-bell-badge", className: "absolute -right-1.5 -top-1.5 flex min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-4 text-destructive-foreground", children: badge }))] }) }), _jsx(PopoverPrimitive.Portal, { children: _jsxs(PopoverPrimitive.Content, { align: "end", sideOffset: 6, className: panelClass, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-border px-3 py-2", children: [_jsx("h2", { className: "text-sm font-semibold", children: t("kumiko.notifications.title") }), inbox.unreadCount > 0 && (_jsx("button", { type: "button", className: "text-xs text-primary hover:underline", onClick: () => void inbox.markAllRead(), children: t("kumiko.notifications.markAllRead") }))] }), _jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto", children: [inbox.failed && (_jsx("p", { role: "alert", className: "px-3 py-4 text-sm text-destructive", children: t("kumiko.notifications.loadFailed") })), !inbox.failed && !inbox.loading && inbox.rows.length === 0 && (_jsx("p", { className: "px-3 py-6 text-center text-sm text-muted-foreground", children: t("kumiko.notifications.empty") })), _jsx("ul", { children: inbox.rows.map((row) => (_jsx("li", { children: _jsxs("button", { type: "button", "data-unread": row.isRead ? undefined : "true", className: "flex w-full items-start gap-2 border-b border-border px-3 py-2 text-left hover:bg-accent focus-visible:bg-accent focus-visible:outline-none", onClick: () => void openRow(row), children: [_jsx("span", { "aria-hidden": "true", className: cn("mt-1.5 size-2 shrink-0 rounded-full", row.isRead ? "bg-transparent" : "bg-primary") }), !row.isRead && (_jsx("span", { className: "sr-only", children: t("kumiko.notifications.unread") })), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: cn("block truncate text-sm", !row.isRead && "font-semibold"), children: row.title }), row.body !== null && (_jsx("span", { className: "block truncate text-xs text-muted-foreground", children: row.body })), _jsx("span", { className: "block text-xs text-muted-foreground", children: formatRelative(Date.parse(row.createdAt)) })] })] }) }, row.id))) }), inbox.hasMore && (_jsx("button", { type: "button", className: "w-full px-3 py-2 text-sm text-primary hover:bg-accent", onClick: () => void inbox.loadMore(), children: t("kumiko.notifications.loadMore") }))] })] }) })] }));
39
+ }
@@ -7,6 +7,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
7
7
  // The `data-kumiko-layout="shell-header"` marker drives `--shell-header-height`
8
8
  // in styles.css (:has() selector) — the single source for the header height
9
9
  // that Drawer's `belowHeader` prop also reads.
10
+ import { IN_APP_NOTIFICATION_FEATURE } from "@cosmicdrift/kumiko-types/in-app-notification-types";
10
11
  import { MoreHorizontal } from "lucide-react";
11
12
  import { Fragment, useEffect, useId, useMemo, useRef, useState, } from "react";
12
13
  import { resolveNavigation } from "../../headless/index.js";
@@ -17,6 +18,7 @@ import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbP
17
18
  import { SidebarTrigger } from "../ui/sidebar.js";
18
19
  import { HeaderOverflowMenuContext, headerOverflowMenuItemClass } from "./header-overflow-menu.js";
19
20
  import { buildNavRegistrySliceForApp } from "./nav-tree.js";
21
+ import { NotificationBell } from "./notification-bell.js";
20
22
  import { usePageHeaderSlot } from "./page-header-slot.js";
21
23
  import { activeNavLabel, resolveDetailBreadcrumb, screenTitleKey, } from "./shell-breadcrumb.js";
22
24
  import { SHELL_HEADER_CONTROLS_CLASSES } from "./uniform-bar-controls.js";
@@ -35,6 +37,7 @@ export function ShellHeader({ schema, user, headerActions, allowedNavQns, }) {
35
37
  return resolveNavigation({ source, ...(user !== undefined && { user }) });
36
38
  }, [appSchema, user, allowedNavQns]);
37
39
  const allScreens = useMemo(() => appSchema.features.flatMap((f) => f.screens), [appSchema.features]);
40
+ const hasInAppChannel = appSchema.features.some((feature) => feature.featureName === IN_APP_NOTIFICATION_FEATURE);
38
41
  const slot = usePageHeaderSlot();
39
42
  const standInScreenId = slot?.breadcrumbScreenId;
40
43
  const screenId = standInScreenId ?? nav.route?.screenId;
@@ -78,7 +81,7 @@ export function ShellHeader({ schema, user, headerActions, allowedNavQns, }) {
78
81
  e.preventDefault();
79
82
  nav.navigate({ screenId });
80
83
  }, children: crumb.label })) : isLast ? (_jsx("h1", { "aria-current": "page", title: titleOverride ?? crumb.label, className: "truncate text-lg font-semibold text-foreground", children: titleOverride ?? crumb.label })) : (_jsx(BreadcrumbPage, { className: "whitespace-nowrap text-foreground-secondary", children: crumb.label })) })] }, screenId ?? crumb.label));
81
- }) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn(SHELL_HEADER_CONTROLS_CLASSES, "flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
84
+ }) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn(SHELL_HEADER_CONTROLS_CLASSES, "flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), user !== undefined && hasInAppChannel && _jsx(NotificationBell, { screens: allScreens }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
82
85
  }
83
86
  // A hand-rolled menu instead of Radix DropdownMenu: app headerActions are opaque
84
87
  // nodes with their own popovers, and the panel stays mounted while closed so their
@@ -0,0 +1,15 @@
1
+ import { type InAppInboxRow } from "@cosmicdrift/kumiko-types/in-app-notification-types";
2
+ export declare const IN_APP_INBOX_PAGE_SIZE = 20;
3
+ export type InAppInbox = {
4
+ readonly unreadCount: number;
5
+ readonly rows: readonly InAppInboxRow[];
6
+ readonly hasMore: boolean;
7
+ readonly loading: boolean;
8
+ readonly failed: boolean;
9
+ readonly loadMore: () => Promise<void>;
10
+ readonly markRead: (id: number) => Promise<void>;
11
+ readonly markAllRead: () => Promise<void>;
12
+ };
13
+ /** Headless state of the notification bell: the unread count always, the list only while `open`.
14
+ * The delivered signal refetches both; no polling. */
15
+ export declare function useInAppInbox(open: boolean): InAppInbox;
@@ -0,0 +1,61 @@
1
+ import { IN_APP_NOTIFICATION_DELIVERED_EVENT, IN_APP_NOTIFICATION_QUERIES, IN_APP_NOTIFICATION_WRITES, } from "@cosmicdrift/kumiko-types/in-app-notification-types";
2
+ import { useCallback, useEffect, useState } from "react";
3
+ import { useDispatcher } from "../../context/dispatcher-context.js";
4
+ import { useMutation } from "../../hooks/use-mutation.js";
5
+ import { useQuery } from "../../hooks/use-query.js";
6
+ import { useLiveEvents } from "../../sse/live-events.js";
7
+ export const IN_APP_INBOX_PAGE_SIZE = 20;
8
+ /** Headless state of the notification bell: the unread count always, the list only while `open`.
9
+ * The delivered signal refetches both; no polling. */
10
+ export function useInAppInbox(open) {
11
+ const dispatcher = useDispatcher();
12
+ const subscribe = useLiveEvents();
13
+ const count = useQuery(IN_APP_NOTIFICATION_QUERIES.unreadCount, {});
14
+ const firstPage = useQuery(IN_APP_NOTIFICATION_QUERIES.inbox, { limit: IN_APP_INBOX_PAGE_SIZE }, { enabled: open });
15
+ const [olderRows, setOlderRows] = useState([]);
16
+ const [olderCursor, setOlderCursor] = useState(undefined);
17
+ const markReadWrite = useMutation(IN_APP_NOTIFICATION_WRITES.markRead);
18
+ const markAllReadWrite = useMutation(IN_APP_NOTIFICATION_WRITES.markAllRead);
19
+ const refetchCount = count.refetch;
20
+ const refetchFirstPage = firstPage.refetch;
21
+ const refetchAll = useCallback(async () => {
22
+ setOlderRows([]);
23
+ setOlderCursor(undefined);
24
+ await Promise.all([refetchCount(), open ? refetchFirstPage() : Promise.resolve()]);
25
+ }, [refetchCount, refetchFirstPage, open]);
26
+ useEffect(() => subscribe(IN_APP_NOTIFICATION_DELIVERED_EVENT, () => void refetchAll()), [subscribe, refetchAll]);
27
+ const firstRows = firstPage.data?.rows ?? [];
28
+ const nextCursor = olderCursor === undefined ? (firstPage.data?.nextCursor ?? null) : olderCursor;
29
+ const loadMore = useCallback(async () => {
30
+ // skip: no older page left
31
+ if (nextCursor === null)
32
+ return;
33
+ const result = await dispatcher.query(IN_APP_NOTIFICATION_QUERIES.inbox, {
34
+ limit: IN_APP_INBOX_PAGE_SIZE,
35
+ cursor: nextCursor,
36
+ });
37
+ // skip: keep the loaded rows, the next click retries
38
+ if (!result.isSuccess)
39
+ return;
40
+ setOlderRows((previous) => [...previous, ...result.data.rows]);
41
+ setOlderCursor(result.data.nextCursor);
42
+ }, [dispatcher, nextCursor]);
43
+ const markRead = useCallback(async (id) => {
44
+ await markReadWrite.mutate({ id });
45
+ await refetchAll();
46
+ }, [markReadWrite.mutate, refetchAll]);
47
+ const markAllRead = useCallback(async () => {
48
+ await markAllReadWrite.mutate({});
49
+ await refetchAll();
50
+ }, [markAllReadWrite.mutate, refetchAll]);
51
+ return {
52
+ unreadCount: count.data?.count ?? 0,
53
+ rows: [...firstRows, ...olderRows],
54
+ hasMore: nextCursor !== null,
55
+ loading: firstPage.loading,
56
+ failed: firstPage.error !== null,
57
+ loadMore,
58
+ markRead,
59
+ markAllRead,
60
+ };
61
+ }
@@ -6,6 +6,7 @@ import { Button as UiButton } from "../ui/button.js";
6
6
  import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger, } from "./dropdown-menu.js";
7
7
  import "../react-css-custom-properties.js";
8
8
  import { DefaultButton } from "./button.js";
9
+ import { FacetSearchFilter } from "./facet-search-filter.js";
9
10
  // ---- DataTable (shadcn: Table) ----
10
11
  function sameValueSet(a, b) {
11
12
  return a.length === b.length && a.every((value) => b.includes(value));
@@ -26,6 +27,9 @@ export function FacetFilter({ facet, selected, onChange, dense = false, }) {
26
27
  if (facet.chips !== undefined) {
27
28
  return _jsx(FacetChips, { facet: facet, selected: selected, onChange: onChange });
28
29
  }
30
+ if (facet.onSearchChange !== undefined) {
31
+ return (_jsx(FacetSearchFilter, { facet: facet, onSearchChange: facet.onSearchChange, selected: selected, onChange: onChange, dense: dense }));
32
+ }
29
33
  return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(UiButton, { variant: "outline", size: "sm", className: cn("h-9", dense && "border-border-strong px-2.5"), "data-testid": `facet-${facet.field}`, children: [facet.label, selected.length > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-1 rounded-sm px-1 font-normal tabular-nums", children: selected.length })), _jsx(ChevronDown, { className: "text-muted-foreground" })] }) }), _jsx(DropdownMenuContent, { align: "start", className: "max-h-[var(--radix-dropdown-menu-content-available-height)] w-44 overflow-y-auto", children: facet.options.map((opt) => (_jsx(DropdownMenuCheckboxItem, { checked: selected.includes(opt.value), onCheckedChange: (checked) => toggle(opt.value, checked), onSelect: (e) => e.preventDefault(), "data-testid": `facet-${facet.field}-${opt.value}`, children: opt.label }, opt.value))) })] }));
30
34
  }
31
35
  // Time-range filter: two native <input type="date"> instead of a
@@ -0,0 +1,9 @@
1
+ import { type ReactNode } from "react";
2
+ import { type DataTableFacet } from "../../index.ts";
3
+ export declare function FacetSearchFilter({ facet, onSearchChange, selected, onChange, dense, }: {
4
+ facet: DataTableFacet;
5
+ onSearchChange: (query: string) => void;
6
+ selected: readonly string[];
7
+ onChange: (field: string, values: readonly string[]) => void;
8
+ dense: boolean;
9
+ }): ReactNode;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Facet dropdown with a server-side search field, for reference facets whose option list is a
3
+ // capped page. A Radix DropdownMenu would swallow the typed keys for its typeahead, so this is a
4
+ // Popover + cmdk list like ComboboxInput, with the facet's own trigger button.
5
+ import { Command } from "cmdk";
6
+ import { Check, ChevronDown, Loader2 } from "lucide-react";
7
+ import { Popover as PopoverPrimitive } from "radix-ui";
8
+ import { useEffect, useState } from "react";
9
+ import { REFERENCE_SEARCH_DEBOUNCE_MS, useTranslation } from "../../index.js";
10
+ import { cn } from "../lib/cn.js";
11
+ import { Badge } from "../ui/badge.js";
12
+ import { Button as UiButton } from "../ui/button.js";
13
+ export function FacetSearchFilter({ facet, onSearchChange, selected, onChange, dense, }) {
14
+ const t = useTranslation();
15
+ const [open, setOpen] = useState(false);
16
+ const [searchTerm, setSearchTerm] = useState("");
17
+ useEffect(() => {
18
+ const timer = setTimeout(() => onSearchChange(searchTerm), REFERENCE_SEARCH_DEBOUNCE_MS);
19
+ return () => clearTimeout(timer);
20
+ }, [searchTerm, onSearchChange]);
21
+ useEffect(() => {
22
+ if (!open && searchTerm !== "")
23
+ setSearchTerm("");
24
+ }, [open, searchTerm]);
25
+ const toggle = (value) => {
26
+ onChange(facet.field, selected.includes(value) ? selected.filter((v) => v !== value) : [...selected, value]);
27
+ };
28
+ return (_jsxs(PopoverPrimitive.Root, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverPrimitive.Trigger, { asChild: true, children: _jsxs(UiButton, { variant: "outline", size: "sm", className: cn("h-9", dense && "border-border-strong px-2.5"), "data-testid": `facet-${facet.field}`, children: [facet.label, selected.length > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-1 rounded-sm px-1 font-normal tabular-nums", children: selected.length })), _jsx(ChevronDown, { className: "text-muted-foreground" })] }) }), _jsx(PopoverPrimitive.Portal, { children: _jsx(PopoverPrimitive.Content, { align: "start", sideOffset: 4, className: "z-50 w-64 rounded-md border bg-popover p-0 text-popover-foreground shadow-md outline-none", children: _jsxs(Command, { shouldFilter: false, children: [_jsxs("div", { className: "relative", children: [_jsx(Command.Input, { value: searchTerm, onValueChange: setSearchTerm, placeholder: t("kumiko.combobox.search-placeholder"), "data-testid": `facet-${facet.field}-search`, className: "flex h-9 w-full border-0 border-b border-border bg-transparent px-3 py-1 text-sm outline-none placeholder:text-muted-foreground" }), facet.searching === true && (_jsx(Loader2, { className: "absolute right-2 top-2.5 h-4 w-4 animate-spin text-muted-foreground" }))] }), _jsxs(Command.List, { className: "max-h-64 overflow-y-auto p-1", children: [_jsx(Command.Empty, { className: "py-3 text-center text-sm text-muted-foreground", children: facet.searching === true
29
+ ? t("kumiko.combobox.loading")
30
+ : t("kumiko.combobox.empty") }), facet.options.map((opt) => (_jsxs(Command.Item, { value: opt.value, onSelect: () => toggle(opt.value), "data-testid": `facet-${facet.field}-${opt.value}`, className: "relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground", children: [selected.includes(opt.value) && (_jsx("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(Check, { className: "h-4 w-4" }) })), opt.label] }, opt.value)))] })] }) }) })] }));
31
+ }
@@ -78,10 +78,21 @@ export function createEventSourceLiveEvents(options = {}) {
78
78
  // addEventListener(entityName, ...) gate below only filters which frames
79
79
  // arrive at all, not which subscriber a given frame is for. Without this
80
80
  // filter, an `invoice` subscriber would also fire on every `user` frame.
81
- const handleEvent = (raw) => {
81
+ // Frames without an aggregateType (channel-in-app:event:delivered) are named after their
82
+ // event type, so the frame name is the subscription key and the signal is synthesized.
83
+ const handleEvent = (raw, frameName) => {
82
84
  let parsed;
83
85
  try {
84
- parsed = JSON.parse(raw);
86
+ const body = JSON.parse(raw);
87
+ const aggregateType = propertyOf(body, "aggregateType");
88
+ parsed =
89
+ typeof aggregateType === "string"
90
+ ? body
91
+ : {
92
+ aggregateType: frameName,
93
+ eventType: frameName,
94
+ createdAt: Temporal.Instant.fromEpochMilliseconds(now()).toString(),
95
+ };
85
96
  }
86
97
  catch {
87
98
  // skip: malformed SSE payload, drop it rather than crash all subscribers
@@ -109,7 +120,7 @@ export function createEventSourceLiveEvents(options = {}) {
109
120
  connection.addEventListener(entityName, (e) => {
110
121
  const data = propertyOf(e, "data");
111
122
  if (typeof data === "string")
112
- handleEvent(data);
123
+ handleEvent(data, entityName);
113
124
  });
114
125
  wiredEntities.add(entityName);
115
126
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.398.0",
3
+ "version": "0.399.0",
4
4
  "description": "React renderer for Kumiko screens. The root export holds the platform-agnostic logic (primitives contract, hooks, KumikoScreen, navigation and SSE abstractions); ./headless the DOM-free UI logic, ./live the HTTP dispatcher, ./web the browser bindings (HTML primitives, history navigation, EventSource live events, createKumikoApp).",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -48,8 +48,8 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@cosmicdrift/kumiko-framework": "0.398.0",
52
- "@cosmicdrift/kumiko-types": "0.398.0",
51
+ "@cosmicdrift/kumiko-framework": "0.399.0",
52
+ "@cosmicdrift/kumiko-types": "0.399.0",
53
53
  "@types/qrcode": "^1.5.5",
54
54
  "class-variance-authority": "^0.7.1",
55
55
  "clsx": "^2.1.1",
@@ -79,8 +79,8 @@
79
79
  }
80
80
  },
81
81
  "devDependencies": {
82
- "@cosmicdrift/kumiko-locale-de": "0.398.0",
83
- "@cosmicdrift/kumiko-testing": "0.398.0",
82
+ "@cosmicdrift/kumiko-locale-de": "0.399.0",
83
+ "@cosmicdrift/kumiko-testing": "0.399.0",
84
84
  "@tailwindcss/cli": "^4.3.3",
85
85
  "@testing-library/react": "^16.3.3",
86
86
  "@testing-library/user-event": "^14.6.8",
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.399.0",
4
+ "type": "improvement",
5
+ "title": "Notification bell in the shell header",
6
+ "detail": "With the channel-in-app feature in the schema and a signed-in user, ShellHeader renders a bell with an unread badge (99+ from 100) and a popover list with title, body, relative time, mark all read and load more. A click marks the entry read and opens its target screen when the schema has it. The delivered signal refetches the count and the open list; there is no polling. SSE frames without an aggregateType now reach subscribers keyed by the frame name. New keys `kumiko.notifications.*` in the default bundle and in all four locale packages."
7
+ },
8
+ {
9
+ "version": "0.399.0",
10
+ "type": "fix",
11
+ "title": "Tab description is rendered on groups tabs",
12
+ "detail": "The `description` of a section with tabbed groups was dropped; only fields and relatedList tabs showed it. The outer card of a groups tab now renders it as its subtitle, also when a relatedList tab has no rows."
13
+ },
14
+ {
15
+ "version": "0.399.0",
16
+ "type": "improvement",
17
+ "title": "Searchable reference facets",
18
+ "detail": "A reference facet in entityList, projectionList and relatedList loads a page of options and a search field narrows them on the server (300 ms debounce), so values beyond the first page can be filtered by. Chosen values keep their label from a separate by-id request, also after a reload from the URL. A reference field with `optionsQuery` uses that handler for the facet. The 200-row warning is gone; `DataTableFacet` gets optional `onSearchChange` and `searching`."
19
+ },
2
20
  {
3
21
  "version": "0.398.0",
4
22
  "type": "fix",