@mintplayer/ng-spark 22.22.0 → 22.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +181 -0
  2. package/fesm2022/mintplayer-ng-spark-attribute-description.mjs +100 -47
  3. package/fesm2022/mintplayer-ng-spark-attribute-description.mjs.map +1 -1
  4. package/fesm2022/mintplayer-ng-spark-client-operations.mjs +308 -329
  5. package/fesm2022/mintplayer-ng-spark-client-operations.mjs.map +1 -1
  6. package/fesm2022/mintplayer-ng-spark-column-filter.mjs +175 -135
  7. package/fesm2022/mintplayer-ng-spark-column-filter.mjs.map +1 -1
  8. package/fesm2022/mintplayer-ng-spark-grid.mjs +2259 -1117
  9. package/fesm2022/mintplayer-ng-spark-grid.mjs.map +1 -1
  10. package/fesm2022/mintplayer-ng-spark-history.mjs +611 -0
  11. package/fesm2022/mintplayer-ng-spark-history.mjs.map +1 -0
  12. package/fesm2022/mintplayer-ng-spark-icon.mjs +64 -27
  13. package/fesm2022/mintplayer-ng-spark-icon.mjs.map +1 -1
  14. package/fesm2022/mintplayer-ng-spark-models.mjs +365 -753
  15. package/fesm2022/mintplayer-ng-spark-models.mjs.map +1 -1
  16. package/fesm2022/mintplayer-ng-spark-moderation.mjs +1212 -0
  17. package/fesm2022/mintplayer-ng-spark-moderation.mjs.map +1 -0
  18. package/fesm2022/mintplayer-ng-spark-panels.mjs +52 -0
  19. package/fesm2022/mintplayer-ng-spark-panels.mjs.map +1 -0
  20. package/fesm2022/mintplayer-ng-spark-pipes.mjs +1122 -765
  21. package/fesm2022/mintplayer-ng-spark-pipes.mjs.map +1 -1
  22. package/fesm2022/mintplayer-ng-spark-po-create.mjs +273 -169
  23. package/fesm2022/mintplayer-ng-spark-po-create.mjs.map +1 -1
  24. package/fesm2022/mintplayer-ng-spark-po-detail.mjs +786 -410
  25. package/fesm2022/mintplayer-ng-spark-po-detail.mjs.map +1 -1
  26. package/fesm2022/mintplayer-ng-spark-po-edit.mjs +292 -284
  27. package/fesm2022/mintplayer-ng-spark-po-edit.mjs.map +1 -1
  28. package/fesm2022/mintplayer-ng-spark-po-form.mjs +2187 -1225
  29. package/fesm2022/mintplayer-ng-spark-po-form.mjs.map +1 -1
  30. package/fesm2022/mintplayer-ng-spark-query-list.mjs +516 -303
  31. package/fesm2022/mintplayer-ng-spark-query-list.mjs.map +1 -1
  32. package/fesm2022/mintplayer-ng-spark-renderers.mjs +17 -55
  33. package/fesm2022/mintplayer-ng-spark-renderers.mjs.map +1 -1
  34. package/fesm2022/mintplayer-ng-spark-retry-action-modal.mjs +216 -177
  35. package/fesm2022/mintplayer-ng-spark-retry-action-modal.mjs.map +1 -1
  36. package/fesm2022/mintplayer-ng-spark-routes.mjs +24 -28
  37. package/fesm2022/mintplayer-ng-spark-routes.mjs.map +1 -1
  38. package/fesm2022/mintplayer-ng-spark-services.mjs +625 -640
  39. package/fesm2022/mintplayer-ng-spark-services.mjs.map +1 -1
  40. package/fesm2022/mintplayer-ng-spark-shell.mjs +1013 -397
  41. package/fesm2022/mintplayer-ng-spark-shell.mjs.map +1 -1
  42. package/fesm2022/mintplayer-ng-spark-soft-delete.mjs +294 -0
  43. package/fesm2022/mintplayer-ng-spark-soft-delete.mjs.map +1 -0
  44. package/fesm2022/mintplayer-ng-spark.mjs +13 -50
  45. package/fesm2022/mintplayer-ng-spark.mjs.map +1 -1
  46. package/package.json +17 -1
  47. package/types/mintplayer-ng-spark-attribute-description.d.ts +25 -18
  48. package/types/mintplayer-ng-spark-client-operations.d.ts +133 -160
  49. package/types/mintplayer-ng-spark-column-filter.d.ts +73 -55
  50. package/types/mintplayer-ng-spark-grid.d.ts +885 -448
  51. package/types/mintplayer-ng-spark-history.d.ts +223 -0
  52. package/types/mintplayer-ng-spark-icon.d.ts +15 -13
  53. package/types/mintplayer-ng-spark-models.d.ts +624 -573
  54. package/types/mintplayer-ng-spark-moderation.d.ts +402 -0
  55. package/types/mintplayer-ng-spark-panels.d.ts +108 -0
  56. package/types/mintplayer-ng-spark-pipes.d.ts +120 -149
  57. package/types/mintplayer-ng-spark-po-create.d.ts +46 -37
  58. package/types/mintplayer-ng-spark-po-detail.d.ts +177 -117
  59. package/types/mintplayer-ng-spark-po-edit.d.ts +64 -65
  60. package/types/mintplayer-ng-spark-po-form.d.ts +507 -371
  61. package/types/mintplayer-ng-spark-query-list.d.ts +118 -82
  62. package/types/mintplayer-ng-spark-renderers.d.ts +79 -85
  63. package/types/mintplayer-ng-spark-retry-action-modal.d.ts +31 -34
  64. package/types/mintplayer-ng-spark-routes.d.ts +7 -11
  65. package/types/mintplayer-ng-spark-services.d.ts +377 -261
  66. package/types/mintplayer-ng-spark-shell.d.ts +209 -128
  67. package/types/mintplayer-ng-spark-soft-delete.d.ts +90 -0
  68. package/types/mintplayer-ng-spark.d.ts +7 -12
@@ -1 +1 @@
1
- {"version":3,"file":"mintplayer-ng-spark-client-operations.mjs","sources":["../../client-operations/src/operations.ts","../../client-operations/src/handlers.token.ts","../../client-operations/src/dispatcher.service.ts","../../client-operations/src/notification.service.ts","../../client-operations/src/toast-container.component.ts","../../client-operations/src/attribute-refresh.service.ts","../../client-operations/src/query-refresh.service.ts","../../client-operations/src/provide.ts","../../client-operations/mintplayer-ng-spark-client-operations.ts"],"sourcesContent":["// Wire types matching MintPlayer.Spark.Abstractions.ClientOperations on the server.\n// Discriminator is the `type` field. Unknown operation types are silently dropped\n// by the dispatcher (forward-compat: new types can land server-side without updating\n// older clients).\n\nimport type { PersistentObject } from '@mintplayer/ng-spark/models';\n\nexport enum NotificationKind {\n Info = 0,\n Success = 1,\n Warning = 2,\n Error = 3,\n}\n\nexport interface NavigateOperation {\n type: 'navigate';\n objectTypeId?: string;\n id?: string;\n routeName?: string;\n}\n\nexport interface NotifyOperation {\n type: 'notify';\n message: string;\n kind: NotificationKind;\n durationMs?: number;\n}\n\nexport interface RefreshAttributeOperation {\n type: 'refreshAttribute';\n objectTypeId: string;\n id: string;\n attributeName: string;\n value?: unknown;\n\n /**\n * Nested rows of an `AsDetail` attribute. A loaded AsDetail attribute carries its rows in\n * `object` / `objects` and leaves `value` null, so a patch limited to `value` could never\n * refresh a detail grid -- it always sent null, and a null over a null repaints nothing.\n *\n * An empty `objects` array means the grid is now empty, and is distinct from omitting it.\n */\n object?: unknown;\n objects?: unknown[];\n}\n\nexport interface RefreshQueryOperation {\n type: 'refreshQuery';\n queryId: string;\n}\n\nexport type DisableTarget =\n | { kind: 'persistentObject'; objectTypeId: string; id: string }\n | { kind: 'query'; queryId: string }\n | { kind: 'currentResponse' }\n | { kind: 'session' };\n\nexport interface DisableActionOperation {\n type: 'disableAction';\n actionName: string;\n target: DisableTarget;\n}\n\nexport interface RetryOperation {\n type: 'retry';\n step: number;\n title: string;\n options: string[];\n defaultOption?: string | null;\n persistentObject?: PersistentObject | null;\n message?: string | null;\n}\n\n/**\n * Discriminated union of known operation types, plus an open shape for unknown\n * future operations. Handlers should narrow via the `type` discriminator before\n * accessing fields specific to their operation type.\n */\nexport type ClientOperation =\n | NavigateOperation\n | NotifyOperation\n | RefreshAttributeOperation\n | RefreshQueryOperation\n | DisableActionOperation\n | RetryOperation\n | { type: string; [key: string]: unknown };\n\n/**\n * Wire envelope returned by every action endpoint. `result` carries the primary\n * payload (the PersistentObject for a Create, the QueryResult for an Execute,\n * etc.); `operations` carries the side-effects the frontend dispatches.\n */\nexport interface ClientOperationEnvelope<T = unknown> {\n result: T | null;\n operations: ClientOperation[];\n}\n","import { InjectionToken } from '@angular/core';\nimport type { ClientOperation } from './operations';\n\n/**\n * A handler for a specific operation type. Receives the operation and\n * executes the side-effect (e.g. show a toast, navigate, refresh a query).\n * Handlers should `as`-narrow the operation to the type they registered for.\n */\nexport type ClientOperationHandler = (operation: ClientOperation) => void;\n\n/**\n * One entry in the multi-provider registration. Apps can register custom\n * handlers alongside the built-in ones to extend the operation set with\n * app-specific operation types.\n */\nexport interface ClientOperationHandlerRegistration {\n type: string;\n handler: ClientOperationHandler;\n}\n\n/**\n * Multi-provider token. `provideSparkClientOperations()` registers the\n * built-in handlers; apps can add their own with additional `multi: true`\n * providers using this token.\n */\nexport const SPARK_CLIENT_OPERATION_HANDLERS = new InjectionToken<readonly ClientOperationHandlerRegistration[]>(\n 'SPARK_CLIENT_OPERATION_HANDLERS',\n);\n","import { Injectable, inject } from '@angular/core';\nimport type { ClientOperation } from './operations';\nimport { SPARK_CLIENT_OPERATION_HANDLERS, type ClientOperationHandler } from './handlers.token';\n\n/**\n * Routes received operations to registered handlers. Unknown operation types\n * (no matching registration) are silently dropped — this is the forward-compat\n * contract that lets new operation types ship server-side without coordinated\n * client updates.\n *\n * Last-registered-wins on duplicate `type` values, matching standard\n * Angular multi-provider override semantics.\n *\n * R2-H19 — security contract for handler authors:\n * The dispatcher treats handler resolution as allow-list-by-type (unknown\n * types drop). It does NOT validate the *content* of each operation. Handlers\n * that act on URL-shaped fields (navigate, redirect, openWindow) MUST run\n * the value through `sanitizeReturnUrl` from `@mintplayer/ng-spark-auth/models`\n * (or an equivalent same-origin check) before acting on it. Otherwise a\n * single attribute-echo XSS or a single mid-channel byte flip on a non-TLS\n * path lets the server drive client navigation to an attacker host. The\n * built-in `notify` handler renders via Angular interpolation (escaped) so\n * it's safe to pass through, but anything more powerful must validate.\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkClientOperationDispatcher {\n private readonly handlerMap: ReadonlyMap<string, ClientOperationHandler>;\n\n constructor() {\n const registrations = inject(SPARK_CLIENT_OPERATION_HANDLERS, { optional: true }) ?? [];\n const map = new Map<string, ClientOperationHandler>();\n for (const { type, handler } of registrations) {\n map.set(type, handler);\n }\n this.handlerMap = map;\n }\n\n dispatch(operations: readonly ClientOperation[] | null | undefined): void {\n if (!operations || operations.length === 0) return;\n for (const operation of operations) {\n const handler = this.handlerMap.get(operation.type);\n if (handler) {\n handler(operation);\n }\n // Unknown types: silently dropped (forward-compat).\n }\n }\n}\n","import { Injectable, signal } from '@angular/core';\nimport { NotificationKind } from './operations';\n\nexport interface SparkToast {\n id: string;\n message: string;\n kind: NotificationKind;\n durationMs: number;\n}\n\nconst DEFAULT_DURATION_MS = 4000;\n\n/**\n * Holds the active toasts as a signal. The `<spark-toast-container>` component\n * renders them; the built-in `notify` operation handler pushes new toasts here.\n *\n * Auto-dismissal: each toast schedules its own removal after `durationMs`. Pass\n * `0` to make a toast sticky (manual dismissal only).\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkNotificationService {\n private readonly _toasts = signal<readonly SparkToast[]>([]);\n readonly toasts = this._toasts.asReadonly();\n\n show(message: string, kind: NotificationKind = NotificationKind.Info, durationMs?: number): void {\n const id = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;\n const effectiveDuration = durationMs ?? DEFAULT_DURATION_MS;\n const toast: SparkToast = { id, message, kind, durationMs: effectiveDuration };\n this._toasts.update(toasts => [...toasts, toast]);\n\n if (effectiveDuration > 0) {\n setTimeout(() => this.dismiss(id), effectiveDuration);\n }\n }\n\n dismiss(id: string): void {\n this._toasts.update(toasts => toasts.filter(t => t.id !== id));\n }\n\n clear(): void {\n this._toasts.set([]);\n }\n}\n","import { ChangeDetectionStrategy, Component, inject } from '@angular/core';\nimport { SparkNotificationService } from './notification.service';\nimport { NotificationKind } from './operations';\n\n@Component({\n selector: 'spark-toast-container',\n standalone: true,\n template: `\n <div class=\"spark-toast-container\">\n @for (toast of notifications.toasts(); track toast.id) {\n <div\n class=\"spark-toast\"\n [class.spark-toast--info]=\"toast.kind === Kind.Info\"\n [class.spark-toast--success]=\"toast.kind === Kind.Success\"\n [class.spark-toast--warning]=\"toast.kind === Kind.Warning\"\n [class.spark-toast--error]=\"toast.kind === Kind.Error\"\n (click)=\"notifications.dismiss(toast.id)\"\n >\n {{ toast.message }}\n </div>\n }\n </div>\n `,\n styles: [`\n .spark-toast-container {\n position: fixed;\n top: 1rem;\n right: 1rem;\n z-index: 9999;\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n pointer-events: none;\n }\n .spark-toast {\n padding: 0.75rem 1rem;\n border-radius: 4px;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);\n cursor: pointer;\n min-width: 220px;\n max-width: 400px;\n color: white;\n font-size: 0.95rem;\n pointer-events: auto;\n animation: spark-toast-in 0.18s ease-out;\n }\n .spark-toast--info { background: #0d6efd; }\n .spark-toast--success { background: #198754; }\n .spark-toast--warning { background: #ffc107; color: #000; }\n .spark-toast--error { background: #dc3545; }\n @keyframes spark-toast-in {\n from { opacity: 0; transform: translateX(8px); }\n to { opacity: 1; transform: translateX(0); }\n }\n `],\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SparkToastContainerComponent {\n protected readonly notifications = inject(SparkNotificationService);\n protected readonly Kind = NotificationKind;\n}\n","import { Injectable, signal } from '@angular/core';\n\n/**\n * Carries a server-issued `refreshAttribute` to whichever detail page is showing that object.\n *\n * The same broadcast shape as {@link SparkQueryRefreshService}, and for the same reason: detail\n * pages come and go behind `@if` and lazy routes, and nothing in ng-spark holds a component\n * reference. A page reads {@link tokenFor} in an effect and applies {@link patchesFor} when it\n * changes.\n *\n * Until this existed the operation was declared on the wire and handled by nobody: the server\n * computed the new values, serialised them, sent them, and the dispatcher dropped them silently\n * because unknown types are ignored. `IClientAccessor.RefreshAttribute` was inert in every Spark\n * application. It is what made the Coverage home page's Resync button look dead — it recomputed\n * the account and repository counts correctly on every click and none of them ever arrived.\n *\n * The operation carries the new value, so this is a patch and not a re-fetch: no round trip, and\n * no dependence on an index that may still be stale from the write that triggered it.\n */\n/**\n * The parts of an attribute a server patch may carry.\n *\n * Three fields rather than one value because a loaded attribute does not keep every shape in\n * `value`: an `AsDetail` attribute puts its nested rows in `object` / `objects` and leaves\n * `value` null. A patch limited to `value` was therefore incapable of refreshing a detail grid,\n * and did so silently -- see {@link SparkAttributeRefreshService}.\n *\n * Only the keys the server sent are present, and each is applied on its own. An `objects: []`\n * means the grid is now empty, which is why emptiness has to be distinguishable from absence.\n */\nexport interface SparkAttributePatch {\n value?: unknown;\n object?: unknown;\n objects?: unknown[];\n}\n\n@Injectable({ providedIn: 'root' })\nexport class SparkAttributeRefreshService {\n private readonly tokens = signal<Record<string, number>>({});\n private readonly patches = signal<Record<string, Record<string, SparkAttributePatch>>>({});\n\n /**\n * Objects are keyed by type AND id: the same id can exist under two types, and a detail page\n * only ever wants the attributes meant for the object it is showing.\n */\n private static keyFor(objectTypeId: string, id: string): string {\n return `${objectTypeId}${id}`;\n }\n\n /** Bumped every time the server patches an attribute of this object. */\n tokenFor(objectTypeId: string | undefined | null, id: string | undefined | null): number {\n if (!objectTypeId || !id) return 0;\n return this.tokens()[SparkAttributeRefreshService.keyFor(objectTypeId, id)] ?? 0;\n }\n\n /**\n * The newest value the server sent for each patched attribute of this object.\n *\n * Cumulative rather than drained on read: a detail page that mounts after the operation arrived\n * still sees the value, and two pages showing the same object both get it. Values are overwritten\n * by later patches, so this holds one entry per attribute, not a history.\n */\n patchesFor(objectTypeId: string | undefined | null, id: string | undefined | null): Record<string, SparkAttributePatch> {\n if (!objectTypeId || !id) return {};\n return this.patches()[SparkAttributeRefreshService.keyFor(objectTypeId, id)] ?? {};\n }\n\n /**\n * Record a new value for one attribute and wake whoever is showing the object.\n *\n * Callers patching several attributes for one user action should do so in one synchronous run:\n * the signals coalesce within a tick into a single effect run, and patches split across an await\n * become one run each.\n */\n request(objectTypeId: string, id: string, attributeName: string, patch: SparkAttributePatch): void {\n const key = SparkAttributeRefreshService.keyFor(objectTypeId, id);\n this.patches.update(current => ({\n ...current,\n [key]: { ...(current[key] ?? {}), [attributeName]: patch },\n }));\n this.tokens.update(current => ({ ...current, [key]: (current[key] ?? 0) + 1 }));\n }\n}\n","import { Injectable, signal } from '@angular/core';\n\n/**\n * Carries a server-issued `refreshQuery` to whichever grids are showing that query.\n *\n * A broadcast signal rather than a registry of component handles: grids come and go behind\n * `@if` and lazy routes, and nothing else in ng-spark holds a component reference. A grid\n * reads {@link tokenFor} in an effect and re-fetches when it changes, which is the same\n * declarative shape as the `reloadToken` input a host would use.\n *\n * Until this existed the server could emit the operation and the dispatcher dropped it: only\n * `notify` was registered, and unknown types are ignored silently — so `refreshOnCompleted`\n * on the server had no effect on any grid the action did not happen to be hosted in.\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkQueryRefreshService {\n private readonly tokens = signal<Record<string, number>>({});\n\n /** Bumped every time the server asks for this query to refresh. */\n tokenFor(queryId: string | undefined): number {\n if (!queryId) return 0;\n return this.tokens()[queryId] ?? 0;\n }\n\n /**\n * Ask every grid showing `queryId` to re-fetch.\n *\n * Matched on the EXACT string a grid passes to {@link tokenFor} -- whichever of id or alias\n * its `queryId` input holds. A caller that knows only the other form will not reach it.\n *\n * Callers bumping several keys for one user action should do so in one synchronous run: the\n * signal coalesces bumps within a tick into a single effect run, and bumps split across an\n * await become one re-fetch each.\n */\n request(queryId: string): void {\n this.tokens.update(current => ({ ...current, [queryId]: (current[queryId] ?? 0) + 1 }));\n }\n}\n","import { type EnvironmentProviders, inject, makeEnvironmentProviders } from '@angular/core';\nimport { Router } from '@angular/router';\nimport type {\n ClientOperation,\n DisableActionOperation,\n NavigateOperation,\n NotifyOperation,\n RefreshAttributeOperation,\n RefreshQueryOperation,\n} from './operations';\nimport { SPARK_CLIENT_OPERATION_HANDLERS } from './handlers.token';\nimport { SparkAttributeRefreshService } from './attribute-refresh.service';\nimport { SparkNotificationService } from './notification.service';\nimport { SparkQueryRefreshService } from './query-refresh.service';\n\n/**\n * Registers the built-in client-operation handlers: `notify`, `refreshQuery`,\n * `refreshAttribute` and `navigate`. Apps add this once in their bootstrap providers.\n *\n * Unregistered operation types are dropped SILENTLY by the dispatcher, which is why\n * `refreshQuery` did nothing at all for as long as it went unhandled — the server emitted\n * it, nothing listened, and no error said so.\n *\n * That happened twice more before anyone noticed. `refreshAttribute` and `navigate` were both\n * declared wire types with no handler anywhere in the repository: every\n * `IClientAccessor.RefreshAttribute` and `Navigate` call in every Spark application was\n * computed, serialised, sent and discarded. Both are registered below. The remaining gap is\n * `disableAction`, registered purely to log so that it stays visible rather than invisible.\n *\n * The lesson worth keeping: a wire type in `operations.ts` is not a feature. Adding one without\n * a handler here produces a server API that appears to work and does nothing.\n *\n * To register custom operation types alongside the built-ins, add additional\n * `multi: true` providers using <see cref=\"SPARK_CLIENT_OPERATION_HANDLERS\" />.\n */\nexport function provideSparkClientOperations(): EnvironmentProviders {\n return makeEnvironmentProviders([\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const notifications = inject(SparkNotificationService);\n return {\n type: 'notify',\n handler: (operation: ClientOperation) => {\n const notify = operation as NotifyOperation;\n notifications.show(notify.message, notify.kind, notify.durationMs);\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const refresh = inject(SparkQueryRefreshService);\n return {\n type: 'refreshQuery',\n handler: (operation: ClientOperation) => {\n refresh.request((operation as RefreshQueryOperation).queryId);\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const attributes = inject(SparkAttributeRefreshService);\n return {\n type: 'refreshAttribute',\n handler: (operation: ClientOperation) => {\n const patch = operation as RefreshAttributeOperation;\n // Forwarded field by field, not as one value: an AsDetail attribute's rows\n // travel in object/objects and its value is null, so collapsing them here\n // is what made a detail-grid refresh impossible to express.\n attributes.request(patch.objectTypeId, patch.id, patch.attributeName, {\n value: patch.value,\n object: patch.object,\n objects: patch.objects,\n });\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const router = inject(Router);\n return {\n type: 'navigate',\n handler: (operation: ClientOperation) => {\n const navigate = operation as NavigateOperation;\n\n if (navigate.routeName) {\n void router.navigateByUrl(\n navigate.routeName.startsWith('/') ? navigate.routeName : `/${navigate.routeName}`);\n return;\n }\n\n // `/po/{type}/{id}` is the convention the grid's row links already assume,\n // and the detail route resolves a type by id as well as by alias, so the\n // operation's objectTypeId can be used verbatim.\n if (navigate.objectTypeId && navigate.id) {\n void router.navigate(['/po', navigate.objectTypeId, navigate.id]);\n return;\n }\n\n console.warn('[spark] navigate operation carried neither routeName nor objectTypeId+id; ignored.');\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => ({\n type: 'disableAction',\n handler: (operation: ClientOperation) => {\n // Deliberately a no-op with a warning, not silence. The server's\n // IClientAccessor.DisableQueryActions presumes a client that honours it;\n // nothing renders the disabled state yet, and a silently dropped operation\n // reads as \"the server did not send it\" when debugging.\n const disable = operation as DisableActionOperation;\n console.warn(\n `[spark] disableAction('${disable.actionName}') is not implemented by this client; the action stays enabled.`);\n },\n }),\n multi: true,\n },\n ]);\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"mintplayer-ng-spark-client-operations.mjs","names":[],"sources":["../../client-operations/src/operations.ts","../../client-operations/src/handlers.token.ts","../../client-operations/src/dispatcher.service.ts","../../client-operations/src/notification.service.ts","../../client-operations/src/toast-container.component.ts","../../client-operations/src/attribute-refresh.service.ts","../../client-operations/src/query-refresh.service.ts","../../client-operations/src/provide.ts"],"sourcesContent":["// Wire types matching MintPlayer.Spark.Abstractions.ClientOperations on the server.\n// Discriminator is the `type` field. Unknown operation types are silently dropped\n// by the dispatcher (forward-compat: new types can land server-side without updating\n// older clients).\n\nimport type { PersistentObject } from '@mintplayer/ng-spark/models';\n\nexport enum NotificationKind {\n Info = 0,\n Success = 1,\n Warning = 2,\n Error = 3,\n}\n\nexport interface NavigateOperation {\n type: 'navigate';\n objectTypeId?: string;\n id?: string;\n routeName?: string;\n}\n\nexport interface NotifyOperation {\n type: 'notify';\n message: string;\n kind: NotificationKind;\n durationMs?: number;\n}\n\nexport interface RefreshAttributeOperation {\n type: 'refreshAttribute';\n objectTypeId: string;\n id: string;\n attributeName: string;\n value?: unknown;\n\n /**\n * Nested rows of an `AsDetail` attribute. A loaded AsDetail attribute carries its rows in\n * `object` / `objects` and leaves `value` null, so a patch limited to `value` could never\n * refresh a detail grid -- it always sent null, and a null over a null repaints nothing.\n *\n * An empty `objects` array means the grid is now empty, and is distinct from omitting it.\n */\n object?: unknown;\n objects?: unknown[];\n}\n\nexport interface RefreshQueryOperation {\n type: 'refreshQuery';\n queryId: string;\n}\n\n// `disableAction` is gone (#460, D13): the server no longer emits it. Disabled actions arrive on\n// `PersistentObject.disabledActions` / `QueryResult.disabledActions`, and a submitted disabled action\n// is refused with 403.\n\nexport interface RetryOperation {\n type: 'retry';\n step: number;\n title: string;\n options: string[];\n defaultOption?: string | null;\n persistentObject?: PersistentObject | null;\n message?: string | null;\n}\n\n/**\n * Discriminated union of known operation types, plus an open shape for unknown\n * future operations. Handlers should narrow via the `type` discriminator before\n * accessing fields specific to their operation type.\n */\nexport type ClientOperation =\n | NavigateOperation\n | NotifyOperation\n | RefreshAttributeOperation\n | RefreshQueryOperation\n | RetryOperation\n | { type: string; [key: string]: unknown };\n\n/**\n * Wire envelope returned by every action endpoint. `result` carries the primary\n * payload (the PersistentObject for a Create, the QueryResult for an Execute,\n * etc.); `operations` carries the side-effects the frontend dispatches.\n */\nexport interface ClientOperationEnvelope<T = unknown> {\n result: T | null;\n operations: ClientOperation[];\n}\n","import { InjectionToken } from '@angular/core';\nimport type { ClientOperation } from './operations';\n\n/**\n * A handler for a specific operation type. Receives the operation and\n * executes the side-effect (e.g. show a toast, navigate, refresh a query).\n * Handlers should `as`-narrow the operation to the type they registered for.\n */\nexport type ClientOperationHandler = (operation: ClientOperation) => void;\n\n/**\n * One entry in the multi-provider registration. Apps can register custom\n * handlers alongside the built-in ones to extend the operation set with\n * app-specific operation types.\n */\nexport interface ClientOperationHandlerRegistration {\n type: string;\n handler: ClientOperationHandler;\n}\n\n/**\n * Multi-provider token. `provideSparkClientOperations()` registers the\n * built-in handlers; apps can add their own with additional `multi: true`\n * providers using this token.\n */\nexport const SPARK_CLIENT_OPERATION_HANDLERS = new InjectionToken<readonly ClientOperationHandlerRegistration[]>(\n 'SPARK_CLIENT_OPERATION_HANDLERS',\n);\n","import { Injectable, inject } from '@angular/core';\nimport type { ClientOperation } from './operations';\nimport { SPARK_CLIENT_OPERATION_HANDLERS, type ClientOperationHandler } from './handlers.token';\n\n/**\n * Routes received operations to registered handlers. Unknown operation types\n * (no matching registration) are silently dropped — this is the forward-compat\n * contract that lets new operation types ship server-side without coordinated\n * client updates.\n *\n * Last-registered-wins on duplicate `type` values, matching standard\n * Angular multi-provider override semantics.\n *\n * R2-H19 — security contract for handler authors:\n * The dispatcher treats handler resolution as allow-list-by-type (unknown\n * types drop). It does NOT validate the *content* of each operation. Handlers\n * that act on URL-shaped fields (navigate, redirect, openWindow) MUST run\n * the value through `sanitizeReturnUrl` from `@mintplayer/ng-spark-auth/models`\n * (or an equivalent same-origin check) before acting on it. Otherwise a\n * single attribute-echo XSS or a single mid-channel byte flip on a non-TLS\n * path lets the server drive client navigation to an attacker host. The\n * built-in `notify` handler renders via Angular interpolation (escaped) so\n * it's safe to pass through, but anything more powerful must validate.\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkClientOperationDispatcher {\n private readonly handlerMap: ReadonlyMap<string, ClientOperationHandler>;\n\n constructor() {\n const registrations = inject(SPARK_CLIENT_OPERATION_HANDLERS, { optional: true }) ?? [];\n const map = new Map<string, ClientOperationHandler>();\n for (const { type, handler } of registrations) {\n map.set(type, handler);\n }\n this.handlerMap = map;\n }\n\n dispatch(operations: readonly ClientOperation[] | null | undefined): void {\n if (!operations || operations.length === 0) return;\n for (const operation of operations) {\n const handler = this.handlerMap.get(operation.type);\n if (handler) {\n handler(operation);\n }\n // Unknown types: silently dropped (forward-compat).\n }\n }\n}\n","import { Injectable, signal } from '@angular/core';\nimport { NotificationKind } from './operations';\n\nexport interface SparkToast {\n id: string;\n message: string;\n kind: NotificationKind;\n durationMs: number;\n}\n\nconst DEFAULT_DURATION_MS = 4000;\n\n/**\n * Holds the active toasts as a signal. The `<spark-toast-container>` component\n * renders them; the built-in `notify` operation handler pushes new toasts here.\n *\n * Auto-dismissal: each toast schedules its own removal after `durationMs`. Pass\n * `0` to make a toast sticky (manual dismissal only).\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkNotificationService {\n private readonly _toasts = signal<readonly SparkToast[]>([]);\n readonly toasts = this._toasts.asReadonly();\n\n show(message: string, kind: NotificationKind = NotificationKind.Info, durationMs?: number): void {\n const id = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;\n const effectiveDuration = durationMs ?? DEFAULT_DURATION_MS;\n const toast: SparkToast = { id, message, kind, durationMs: effectiveDuration };\n this._toasts.update(toasts => [...toasts, toast]);\n\n if (effectiveDuration > 0) {\n setTimeout(() => this.dismiss(id), effectiveDuration);\n }\n }\n\n dismiss(id: string): void {\n this._toasts.update(toasts => toasts.filter(t => t.id !== id));\n }\n\n clear(): void {\n this._toasts.set([]);\n }\n}\n","import { ChangeDetectionStrategy, Component, inject } from '@angular/core';\nimport { SparkNotificationService } from './notification.service';\nimport { NotificationKind } from './operations';\n\n@Component({\n selector: 'spark-toast-container',\n standalone: true,\n template: `\n <div class=\"spark-toast-container\">\n @for (toast of notifications.toasts(); track toast.id) {\n <div\n class=\"spark-toast\"\n [class.spark-toast--info]=\"toast.kind === Kind.Info\"\n [class.spark-toast--success]=\"toast.kind === Kind.Success\"\n [class.spark-toast--warning]=\"toast.kind === Kind.Warning\"\n [class.spark-toast--error]=\"toast.kind === Kind.Error\"\n (click)=\"notifications.dismiss(toast.id)\"\n >\n {{ toast.message }}\n </div>\n }\n </div>\n `,\n styles: [`\n .spark-toast-container {\n position: fixed;\n top: 1rem;\n right: 1rem;\n z-index: 9999;\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n pointer-events: none;\n }\n .spark-toast {\n padding: 0.75rem 1rem;\n border-radius: 4px;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);\n cursor: pointer;\n min-width: 220px;\n max-width: 400px;\n color: white;\n font-size: 0.95rem;\n pointer-events: auto;\n animation: spark-toast-in 0.18s ease-out;\n }\n .spark-toast--info { background: #0d6efd; }\n .spark-toast--success { background: #198754; }\n .spark-toast--warning { background: #ffc107; color: #000; }\n .spark-toast--error { background: #dc3545; }\n @keyframes spark-toast-in {\n from { opacity: 0; transform: translateX(8px); }\n to { opacity: 1; transform: translateX(0); }\n }\n `],\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SparkToastContainerComponent {\n protected readonly notifications = inject(SparkNotificationService);\n protected readonly Kind = NotificationKind;\n}\n","import { Injectable, signal } from '@angular/core';\n\n/**\n * Carries a server-issued `refreshAttribute` to whichever detail page is showing that object.\n *\n * The same broadcast shape as {@link SparkQueryRefreshService}, and for the same reason: detail\n * pages come and go behind `@if` and lazy routes, and nothing in ng-spark holds a component\n * reference. A page reads {@link tokenFor} in an effect and applies {@link patchesFor} when it\n * changes.\n *\n * Until this existed the operation was declared on the wire and handled by nobody: the server\n * computed the new values, serialised them, sent them, and the dispatcher dropped them silently\n * because unknown types are ignored. `IClientAccessor.RefreshAttribute` was inert in every Spark\n * application. It is what made the Coverage home page's Resync button look dead — it recomputed\n * the account and repository counts correctly on every click and none of them ever arrived.\n *\n * The operation carries the new value, so this is a patch and not a re-fetch: no round trip, and\n * no dependence on an index that may still be stale from the write that triggered it.\n */\n/**\n * The parts of an attribute a server patch may carry.\n *\n * Three fields rather than one value because a loaded attribute does not keep every shape in\n * `value`: an `AsDetail` attribute puts its nested rows in `object` / `objects` and leaves\n * `value` null. A patch limited to `value` was therefore incapable of refreshing a detail grid,\n * and did so silently -- see {@link SparkAttributeRefreshService}.\n *\n * Only the keys the server sent are present, and each is applied on its own. An `objects: []`\n * means the grid is now empty, which is why emptiness has to be distinguishable from absence.\n */\nexport interface SparkAttributePatch {\n value?: unknown;\n object?: unknown;\n objects?: unknown[];\n}\n\n@Injectable({ providedIn: 'root' })\nexport class SparkAttributeRefreshService {\n private readonly tokens = signal<Record<string, number>>({});\n private readonly patches = signal<Record<string, Record<string, SparkAttributePatch>>>({});\n\n /**\n * Objects are keyed by type AND id: the same id can exist under two types, and a detail page\n * only ever wants the attributes meant for the object it is showing.\n */\n private static keyFor(objectTypeId: string, id: string): string {\n return `${objectTypeId}${id}`;\n }\n\n /** Bumped every time the server patches an attribute of this object. */\n tokenFor(objectTypeId: string | undefined | null, id: string | undefined | null): number {\n if (!objectTypeId || !id) return 0;\n return this.tokens()[SparkAttributeRefreshService.keyFor(objectTypeId, id)] ?? 0;\n }\n\n /**\n * The newest value the server sent for each patched attribute of this object.\n *\n * Cumulative rather than drained on read: a detail page that mounts after the operation arrived\n * still sees the value, and two pages showing the same object both get it. Values are overwritten\n * by later patches, so this holds one entry per attribute, not a history.\n */\n patchesFor(objectTypeId: string | undefined | null, id: string | undefined | null): Record<string, SparkAttributePatch> {\n if (!objectTypeId || !id) return {};\n return this.patches()[SparkAttributeRefreshService.keyFor(objectTypeId, id)] ?? {};\n }\n\n /**\n * Record a new value for one attribute and wake whoever is showing the object.\n *\n * Callers patching several attributes for one user action should do so in one synchronous run:\n * the signals coalesce within a tick into a single effect run, and patches split across an await\n * become one run each.\n */\n request(objectTypeId: string, id: string, attributeName: string, patch: SparkAttributePatch): void {\n const key = SparkAttributeRefreshService.keyFor(objectTypeId, id);\n this.patches.update(current => ({\n ...current,\n [key]: { ...(current[key] ?? {}), [attributeName]: patch },\n }));\n this.tokens.update(current => ({ ...current, [key]: (current[key] ?? 0) + 1 }));\n }\n}\n","import { Injectable, signal } from '@angular/core';\n\n/**\n * Carries a server-issued `refreshQuery` to whichever grids are showing that query.\n *\n * A broadcast signal rather than a registry of component handles: grids come and go behind\n * `@if` and lazy routes, and nothing else in ng-spark holds a component reference. A grid\n * reads {@link tokenFor} in an effect and re-fetches when it changes, which is the same\n * declarative shape as the `reloadToken` input a host would use.\n *\n * Until this existed the server could emit the operation and the dispatcher dropped it: only\n * `notify` was registered, and unknown types are ignored silently — so `refreshOnCompleted`\n * on the server had no effect on any grid the action did not happen to be hosted in.\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkQueryRefreshService {\n private readonly tokens = signal<Record<string, number>>({});\n\n /** Bumped every time the server asks for this query to refresh. */\n tokenFor(queryId: string | undefined): number {\n if (!queryId) return 0;\n return this.tokens()[queryId] ?? 0;\n }\n\n /**\n * Ask every grid showing `queryId` to re-fetch.\n *\n * Matched on the EXACT string a grid passes to {@link tokenFor} -- whichever of id or alias\n * its `queryId` input holds. A caller that knows only the other form will not reach it.\n *\n * Callers bumping several keys for one user action should do so in one synchronous run: the\n * signal coalesces bumps within a tick into a single effect run, and bumps split across an\n * await become one re-fetch each.\n */\n request(queryId: string): void {\n this.tokens.update(current => ({ ...current, [queryId]: (current[queryId] ?? 0) + 1 }));\n }\n}\n","import { type EnvironmentProviders, inject, makeEnvironmentProviders } from '@angular/core';\nimport { Router } from '@angular/router';\nimport type {\n ClientOperation,\n NavigateOperation,\n NotifyOperation,\n RefreshAttributeOperation,\n RefreshQueryOperation,\n} from './operations';\nimport { SPARK_CLIENT_OPERATION_HANDLERS } from './handlers.token';\nimport { SparkAttributeRefreshService } from './attribute-refresh.service';\nimport { SparkNotificationService } from './notification.service';\nimport { SparkQueryRefreshService } from './query-refresh.service';\n\n/**\n * Registers the built-in client-operation handlers: `notify`, `refreshQuery`,\n * `refreshAttribute` and `navigate`. Apps add this once in their bootstrap providers.\n *\n * Unregistered operation types are dropped SILENTLY by the dispatcher, which is why\n * `refreshQuery` did nothing at all for as long as it went unhandled — the server emitted\n * it, nothing listened, and no error said so.\n *\n * That happened twice more before anyone noticed. `refreshAttribute` and `navigate` were both\n * declared wire types with no handler anywhere in the repository: every\n * `IClientAccessor.RefreshAttribute` and `Navigate` call in every Spark application was\n * computed, serialised, sent and discarded. Both are registered below. The last gap,\n * `disableAction`, was closed by deleting it (#460, D13): disabled actions travel on the object's\n * or result's `disabledActions` and are enforced by the server at submit.\n *\n * The lesson worth keeping: a wire type in `operations.ts` is not a feature. Adding one without\n * a handler here produces a server API that appears to work and does nothing.\n *\n * To register custom operation types alongside the built-ins, add additional\n * `multi: true` providers using <see cref=\"SPARK_CLIENT_OPERATION_HANDLERS\" />.\n */\nexport function provideSparkClientOperations(): EnvironmentProviders {\n return makeEnvironmentProviders([\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const notifications = inject(SparkNotificationService);\n return {\n type: 'notify',\n handler: (operation: ClientOperation) => {\n const notify = operation as NotifyOperation;\n notifications.show(notify.message, notify.kind, notify.durationMs);\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const refresh = inject(SparkQueryRefreshService);\n return {\n type: 'refreshQuery',\n handler: (operation: ClientOperation) => {\n refresh.request((operation as RefreshQueryOperation).queryId);\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const attributes = inject(SparkAttributeRefreshService);\n return {\n type: 'refreshAttribute',\n handler: (operation: ClientOperation) => {\n const patch = operation as RefreshAttributeOperation;\n // Forwarded field by field, not as one value: an AsDetail attribute's rows\n // travel in object/objects and its value is null, so collapsing them here\n // is what made a detail-grid refresh impossible to express.\n attributes.request(patch.objectTypeId, patch.id, patch.attributeName, {\n value: patch.value,\n object: patch.object,\n objects: patch.objects,\n });\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const router = inject(Router);\n return {\n type: 'navigate',\n handler: (operation: ClientOperation) => {\n const navigate = operation as NavigateOperation;\n\n if (navigate.routeName) {\n void router.navigateByUrl(\n navigate.routeName.startsWith('/') ? navigate.routeName : `/${navigate.routeName}`);\n return;\n }\n\n // `/po/{type}/{id}` is the convention the grid's row links already assume,\n // and the detail route resolves a type by id as well as by alias, so the\n // operation's objectTypeId can be used verbatim.\n if (navigate.objectTypeId && navigate.id) {\n void router.navigate(['/po', navigate.objectTypeId, navigate.id]);\n return;\n }\n\n console.warn('[spark] navigate operation carried neither routeName nor objectTypeId+id; ignored.');\n },\n };\n },\n multi: true,\n },\n ]);\n}\n"],"mappings":"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"}
@@ -1,138 +1,178 @@
1
- import * as i0 from '@angular/core';
2
- import { input, computed, signal, ChangeDetectionStrategy, Component } from '@angular/core';
3
- import { BsCheckboxComponent } from '@mintplayer/ng-bootstrap/checkbox';
4
- import { SparkIconComponent } from '@mintplayer/ng-spark/icon';
5
-
6
- /**
7
- * The contents of one column's filter panel (#431).
8
- *
9
- * Spark supplies its own panel on **every** filterable column rather than using the datatable's
10
- * built-in one. Two reasons, and the first forces the second:
11
- *
12
- * - A `canListDistincts: false` column has no built-in mode that fits. `FilterMode` is
13
- * `'values' | 'comparison'` and there is no `contains` operator, so a free-text filter cannot be
14
- * expressed — and resolving `null` from the distinct source is not an escape either, because that
15
- * means "fall back to the local pass", which is disabled for a `[fetch]`-bound grid. Such a column
16
- * would render the built-in panel showing its "no values" string: a dead panel.
17
- * - Since some columns must nest regardless, mixing would put two visually different panels in one
18
- * grid.
19
- *
20
- * What the datatable still owns, and this component must not reimplement: the async distinct source
21
- * and its debounce, request cancellation, the matching/remaining bucketing, the change event, the
22
- * document-root overlay, the focus trap and Escape. This is markup over {@link FilterContext}.
23
- *
24
- * ⚠️ **Styling is ours.** The panel is portalled out of the grid's subtree, so Bootstrap's
25
- * `.form-control` and `.btn` do not reach it — they are only styled inside `bs-*` components. Every
26
- * visual here is explicit.
27
- */
28
- class SparkColumnFilterPanelComponent {
29
- column = input.required(/* @ts-ignore */
30
- ...(ngDevMode ? [{ debugName: "column" }] : /* istanbul ignore next */ []));
31
- /** The datatable's bucketed list, or null while nothing has loaded. */
32
- values = input(null, /* @ts-ignore */
33
- ...(ngDevMode ? [{ debugName: "values" }] : /* istanbul ignore next */ []));
34
- ctx = input.required(/* @ts-ignore */
35
- ...(ngDevMode ? [{ debugName: "ctx" }] : /* istanbul ignore next */ []));
36
- /**
37
- * Optional relabelling for a column with a custom cell renderer.
38
- *
39
- * A renderer is a component that paints arbitrary DOM, and a distinct value has no row, so it
40
- * cannot be instantiated to produce text. The label therefore defaults to the server-computed cell
41
- * text — a status rendered as a coloured pill lists `Active`, a rating rendered as stars lists `3`.
42
- * This hook is the escape for the case where that is too raw: a pure function, no DOM, no row.
43
- */
44
- filterLabel = input(null, /* @ts-ignore */
45
- ...(ngDevMode ? [{ debugName: "filterLabel" }] : /* istanbul ignore next */ []));
46
- /** Whether this column offers a value list at all, or only free text. */
47
- listsDistincts = computed(() => this.column().canListDistincts !== false, /* @ts-ignore */
48
- ...(ngDevMode ? [{ debugName: "listsDistincts" }] : /* istanbul ignore next */ []));
49
- /** Selected values, by value. Identity is the value, never the label — two rows may share text. */
50
- selected = signal([], /* @ts-ignore */
51
- ...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
52
- inverse = signal(false, /* @ts-ignore */
53
- ...(ngDevMode ? [{ debugName: "inverse" }] : /* istanbul ignore next */ []));
54
- freeText = signal('', /* @ts-ignore */
55
- ...(ngDevMode ? [{ debugName: "freeText" }] : /* istanbul ignore next */ []));
56
- hasSelection = computed(() => this.selected().length > 0, /* @ts-ignore */
57
- ...(ngDevMode ? [{ debugName: "hasSelection" }] : /* istanbul ignore next */ []));
58
- matching = computed(() => this.values()?.matching ?? [], /* @ts-ignore */
59
- ...(ngDevMode ? [{ debugName: "matching" }] : /* istanbul ignore next */ []));
60
- remaining = computed(() => this.values()?.remaining ?? [], /* @ts-ignore */
61
- ...(ngDevMode ? [{ debugName: "remaining" }] : /* istanbul ignore next */ []));
62
- hasMore = computed(() => this.values()?.hasMore === true, /* @ts-ignore */
63
- ...(ngDevMode ? [{ debugName: "hasMore" }] : /* istanbul ignore next */ []));
64
- /**
65
- * Whether to show the empty state.
66
- *
67
- * Covers three states on purpose: nothing loaded yet, nothing matched, and the distinct request
68
- * failed. The datatable swallows a rejected promise into a null list with no error, so the third
69
- * is not distinguishable here - and the server already refuses to distinguish "no values" from
70
- * "you may not list them".
71
- */
72
- isEmpty = computed(() => !this.matching().length && !this.remaining().length, /* @ts-ignore */
73
- ...(ngDevMode ? [{ debugName: "isEmpty" }] : /* istanbul ignore next */ []));
74
- labelFor(value) {
75
- const relabel = this.filterLabel();
76
- return relabel ? relabel(value.value) : value.label;
77
- }
78
- isChecked(value) {
79
- return this.selected().some(v => sameValue(v.value, value.value));
80
- }
81
- toggle(value, checked) {
82
- const next = checked
83
- ? [...this.selected(), value]
84
- : this.selected().filter(v => !sameValue(v.value, value.value));
85
- this.selected.set(next);
86
- this.ctx().apply(next, this.inverse());
87
- }
88
- toggleInverse() {
89
- // Not a flag on the wire: the grid moves the selected values between `includes` and `excludes`.
90
- // Applying immediately keeps the panel's state and the grid's in step without an Apply button.
91
- this.inverse.update(v => !v);
92
- this.ctx().apply(this.selected(), this.inverse());
93
- }
94
- onSearch(term) {
95
- // Debounced and cancelled by the datatable; re-queried only when it cannot answer locally.
96
- this.ctx().search(term);
97
- }
98
- /**
99
- * Applies the free-text filter of a column whose values may not be enumerated.
100
- *
101
- * It goes through the same `apply` as a checkbox list, carrying one synthetic entry, so the grid
102
- * has exactly one event shape to translate and no second wire form to maintain.
103
- */
104
- applyFreeText() {
105
- const text = this.freeText().trim();
106
- const next = text ? [{ value: text, label: text }] : [];
107
- this.selected.set(next);
108
- this.ctx().apply(next, this.inverse());
109
- }
110
- clear() {
111
- this.selected.set([]);
112
- this.inverse.set(false);
113
- this.freeText.set('');
114
- this.ctx().clear();
115
- }
116
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: SparkColumnFilterPanelComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
117
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.3", type: SparkColumnFilterPanelComponent, isStandalone: true, selector: "spark-column-filter-panel", inputs: { column: { classPropertyName: "column", publicName: "column", isSignal: true, isRequired: true, transformFunction: null }, values: { classPropertyName: "values", publicName: "values", isSignal: true, isRequired: false, transformFunction: null }, ctx: { classPropertyName: "ctx", publicName: "ctx", isSignal: true, isRequired: true, transformFunction: null }, filterLabel: { classPropertyName: "filterLabel", publicName: "filterLabel", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<!--\n \u26A0\uFE0F DOM ORDER IS THE FOCUS ORDER. The overlay resolves initial focus by querying\n `.filter-search, .filter-operand` and falls back to the first tabbable element, so the search box\n is first AND carries that class deliberately. The built-in panel aims focus past its own Clear\n button for the same reason: opening onto a disabled-or-destructive control is the wrong place to\n land, and Clear is disabled exactly when the panel is freshly opened.\n\n No role=\"dialog\" and no aria-label here: the overlay's own `.filter-panel` wrapper already carries\n both, and a second dialog role nested inside the first is worse than none.\n-->\n<div class=\"spark-filter-panel\">\n @if (listsDistincts()) {\n <div class=\"spark-filter-search\">\n <spark-icon name=\"search\" />\n <input\n class=\"filter-search\"\n type=\"search\"\n [attr.aria-label]=\"'Search values'\"\n (input)=\"onSearch($any($event.target).value)\" />\n </div>\n\n <div class=\"spark-filter-body\">\n <!--\n Not a checkbox: it does not select a value, it changes what the selection means.\n aria-pressed is written for both states so the off state is announced too.\n -->\n <button\n type=\"button\"\n class=\"spark-filter-inverse\"\n [class.is-on]=\"inverse()\"\n [attr.aria-pressed]=\"inverse()\"\n [attr.aria-label]=\"'Exclude the selected values'\"\n (click)=\"toggleInverse()\">\n \u2260\n </button>\n\n <div class=\"spark-filter-values\" role=\"group\" [attr.aria-label]=\"'Values of ' + column().name\">\n <!-- `values()` is already narrowed by the search term; re-filtering here would double-apply it. -->\n @for (value of matching(); track value.value) {\n <bs-checkbox\n class=\"spark-filter-value\"\n [isToggled]=\"isChecked(value)\"\n (isToggledChange)=\"toggle(value, $event === true)\">\n {{ labelFor(value) }}\n </bs-checkbox>\n }\n\n <!--\n Values that were present when the filter was first applied but are not any more. Dimmed\n and still selectable, so a selection can be widened without clearing it first. The\n datatable derives this bucket by diffing its own snapshot; the server always sends it empty.\n -->\n @for (value of remaining(); track value.value) {\n <bs-checkbox\n class=\"spark-filter-value is-remaining\"\n [isToggled]=\"isChecked(value)\"\n (isToggledChange)=\"toggle(value, $event === true)\">\n {{ labelFor(value) }}\n </bs-checkbox>\n }\n\n @if (isEmpty()) {\n <!--\n One message for three states: nothing loaded yet, nothing matches, and the distinct\n request failed. The last is deliberately indistinguishable \u2014 a rejected promise surfaces\n as a null list with no error, and the server already refuses to say whether an empty\n answer means \"no values\" or \"you may not list them\".\n -->\n <p class=\"spark-filter-empty\">No values available</p>\n }\n </div>\n </div>\n\n @if (hasMore()) {\n <!-- Words, not an icon: it is the only signal that typing will fetch different values. -->\n <p class=\"spark-filter-more\">More values exist \u2014 refine the search to see them.</p>\n }\n } @else {\n <!--\n A column whose values may not be enumerated. Free text instead of a list, applied through the\n same `apply` so the grid has exactly one event shape to translate.\n -->\n <div class=\"spark-filter-search\">\n <spark-icon name=\"search\" />\n <input\n class=\"filter-search\"\n type=\"search\"\n [value]=\"freeText()\"\n [attr.aria-label]=\"'Filter value'\"\n (input)=\"freeText.set($any($event.target).value)\"\n (keydown.enter)=\"applyFreeText()\" />\n </div>\n <button type=\"button\" class=\"spark-filter-apply\" (click)=\"applyFreeText()\">Apply</button>\n }\n\n <!-- Last, so it is never the landing spot. Kept in the tab order when disabled. -->\n <button type=\"button\" class=\"spark-filter-clear\" [disabled]=\"!hasSelection()\" (click)=\"clear()\">\n <spark-icon name=\"funnel\" />\n <span>Clear the filter on {{ column().name }}</span>\n </button>\n</div>\n", styles: [".spark-filter-panel{display:flex;flex-direction:column;gap:.5rem;min-width:14rem;max-width:22rem;max-height:min(20rem,60vh);padding:.5rem;font-size:.875rem}.spark-filter-clear{display:flex;align-items:center;gap:.375rem;padding:.25rem .375rem;border:0;border-radius:.25rem;background:transparent;color:var(--bs-body-color);text-align:left;cursor:pointer}.spark-filter-clear:hover:not(:disabled){background:var(--bs-secondary-bg)}.spark-filter-clear:disabled{color:var(--bs-secondary-color);cursor:default}.spark-filter-search{display:flex;align-items:center;gap:.375rem;padding:.25rem .5rem;border:1px solid var(--bs-border-color);border-radius:.25rem}.spark-filter-search input{flex:1 1 auto;min-width:0;border:0;outline:0;background:transparent;color:inherit;font:inherit;line-height:1.5}.spark-filter-body{display:flex;flex:1 1 auto;min-height:0;gap:.375rem}.spark-filter-inverse{flex:0 0 auto;align-self:flex-start;width:1.75rem;height:1.75rem;border:1px solid var(--bs-border-color);border-radius:.25rem;background:transparent;color:var(--bs-body-color);cursor:pointer}.spark-filter-inverse.is-on{background:var(--bs-primary);border-color:var(--bs-primary);color:var(--bs-light)}.spark-filter-values{flex:1 1 auto;min-height:0;overflow-y:auto;display:flex;flex-direction:column;min-width:0}.spark-filter-value{padding:.125rem 0}.spark-filter-value.is-remaining{color:var(--bs-secondary-color)}.spark-filter-empty,.spark-filter-more{margin:0;color:var(--bs-secondary-color)}.spark-filter-apply{align-self:flex-start;padding:.25rem .75rem;border:1px solid var(--bs-primary);border-radius:.25rem;background:var(--bs-primary);color:var(--bs-light);cursor:pointer}\n"], dependencies: [{ kind: "component", type: BsCheckboxComponent, selector: "bs-checkbox", inputs: ["type", "isToggled", "indeterminate", "name", "value", "group"], outputs: ["isToggledChange", "indeterminateChange"] }, { kind: "component", type: SparkIconComponent, selector: "spark-icon", inputs: ["name"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
118
- }
119
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: SparkColumnFilterPanelComponent, decorators: [{
120
- type: Component,
121
- args: [{ selector: 'spark-column-filter-panel', standalone: true, imports: [BsCheckboxComponent, SparkIconComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "<!--\n \u26A0\uFE0F DOM ORDER IS THE FOCUS ORDER. The overlay resolves initial focus by querying\n `.filter-search, .filter-operand` and falls back to the first tabbable element, so the search box\n is first AND carries that class deliberately. The built-in panel aims focus past its own Clear\n button for the same reason: opening onto a disabled-or-destructive control is the wrong place to\n land, and Clear is disabled exactly when the panel is freshly opened.\n\n No role=\"dialog\" and no aria-label here: the overlay's own `.filter-panel` wrapper already carries\n both, and a second dialog role nested inside the first is worse than none.\n-->\n<div class=\"spark-filter-panel\">\n @if (listsDistincts()) {\n <div class=\"spark-filter-search\">\n <spark-icon name=\"search\" />\n <input\n class=\"filter-search\"\n type=\"search\"\n [attr.aria-label]=\"'Search values'\"\n (input)=\"onSearch($any($event.target).value)\" />\n </div>\n\n <div class=\"spark-filter-body\">\n <!--\n Not a checkbox: it does not select a value, it changes what the selection means.\n aria-pressed is written for both states so the off state is announced too.\n -->\n <button\n type=\"button\"\n class=\"spark-filter-inverse\"\n [class.is-on]=\"inverse()\"\n [attr.aria-pressed]=\"inverse()\"\n [attr.aria-label]=\"'Exclude the selected values'\"\n (click)=\"toggleInverse()\">\n \u2260\n </button>\n\n <div class=\"spark-filter-values\" role=\"group\" [attr.aria-label]=\"'Values of ' + column().name\">\n <!-- `values()` is already narrowed by the search term; re-filtering here would double-apply it. -->\n @for (value of matching(); track value.value) {\n <bs-checkbox\n class=\"spark-filter-value\"\n [isToggled]=\"isChecked(value)\"\n (isToggledChange)=\"toggle(value, $event === true)\">\n {{ labelFor(value) }}\n </bs-checkbox>\n }\n\n <!--\n Values that were present when the filter was first applied but are not any more. Dimmed\n and still selectable, so a selection can be widened without clearing it first. The\n datatable derives this bucket by diffing its own snapshot; the server always sends it empty.\n -->\n @for (value of remaining(); track value.value) {\n <bs-checkbox\n class=\"spark-filter-value is-remaining\"\n [isToggled]=\"isChecked(value)\"\n (isToggledChange)=\"toggle(value, $event === true)\">\n {{ labelFor(value) }}\n </bs-checkbox>\n }\n\n @if (isEmpty()) {\n <!--\n One message for three states: nothing loaded yet, nothing matches, and the distinct\n request failed. The last is deliberately indistinguishable \u2014 a rejected promise surfaces\n as a null list with no error, and the server already refuses to say whether an empty\n answer means \"no values\" or \"you may not list them\".\n -->\n <p class=\"spark-filter-empty\">No values available</p>\n }\n </div>\n </div>\n\n @if (hasMore()) {\n <!-- Words, not an icon: it is the only signal that typing will fetch different values. -->\n <p class=\"spark-filter-more\">More values exist \u2014 refine the search to see them.</p>\n }\n } @else {\n <!--\n A column whose values may not be enumerated. Free text instead of a list, applied through the\n same `apply` so the grid has exactly one event shape to translate.\n -->\n <div class=\"spark-filter-search\">\n <spark-icon name=\"search\" />\n <input\n class=\"filter-search\"\n type=\"search\"\n [value]=\"freeText()\"\n [attr.aria-label]=\"'Filter value'\"\n (input)=\"freeText.set($any($event.target).value)\"\n (keydown.enter)=\"applyFreeText()\" />\n </div>\n <button type=\"button\" class=\"spark-filter-apply\" (click)=\"applyFreeText()\">Apply</button>\n }\n\n <!-- Last, so it is never the landing spot. Kept in the tab order when disabled. -->\n <button type=\"button\" class=\"spark-filter-clear\" [disabled]=\"!hasSelection()\" (click)=\"clear()\">\n <spark-icon name=\"funnel\" />\n <span>Clear the filter on {{ column().name }}</span>\n </button>\n</div>\n", styles: [".spark-filter-panel{display:flex;flex-direction:column;gap:.5rem;min-width:14rem;max-width:22rem;max-height:min(20rem,60vh);padding:.5rem;font-size:.875rem}.spark-filter-clear{display:flex;align-items:center;gap:.375rem;padding:.25rem .375rem;border:0;border-radius:.25rem;background:transparent;color:var(--bs-body-color);text-align:left;cursor:pointer}.spark-filter-clear:hover:not(:disabled){background:var(--bs-secondary-bg)}.spark-filter-clear:disabled{color:var(--bs-secondary-color);cursor:default}.spark-filter-search{display:flex;align-items:center;gap:.375rem;padding:.25rem .5rem;border:1px solid var(--bs-border-color);border-radius:.25rem}.spark-filter-search input{flex:1 1 auto;min-width:0;border:0;outline:0;background:transparent;color:inherit;font:inherit;line-height:1.5}.spark-filter-body{display:flex;flex:1 1 auto;min-height:0;gap:.375rem}.spark-filter-inverse{flex:0 0 auto;align-self:flex-start;width:1.75rem;height:1.75rem;border:1px solid var(--bs-border-color);border-radius:.25rem;background:transparent;color:var(--bs-body-color);cursor:pointer}.spark-filter-inverse.is-on{background:var(--bs-primary);border-color:var(--bs-primary);color:var(--bs-light)}.spark-filter-values{flex:1 1 auto;min-height:0;overflow-y:auto;display:flex;flex-direction:column;min-width:0}.spark-filter-value{padding:.125rem 0}.spark-filter-value.is-remaining{color:var(--bs-secondary-color)}.spark-filter-empty,.spark-filter-more{margin:0;color:var(--bs-secondary-color)}.spark-filter-apply{align-self:flex-start;padding:.25rem .75rem;border:1px solid var(--bs-primary);border-radius:.25rem;background:var(--bs-primary);color:var(--bs-light);cursor:pointer}\n"] }]
122
- }], propDecorators: { column: [{ type: i0.Input, args: [{ isSignal: true, alias: "column", required: true }] }], values: [{ type: i0.Input, args: [{ isSignal: true, alias: "values", required: false }] }], ctx: [{ type: i0.Input, args: [{ isSignal: true, alias: "ctx", required: true }] }], filterLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "filterLabel", required: false }] }] } });
123
- /**
124
- * SameValueZero, matching how the datatable keys its list.
125
- *
126
- * `NaN` must equal itself or a numeric column's "not a number" entry can be ticked but never
127
- * unticked, and `===` gets that wrong.
128
- */
1
+ import * as i0 from "@angular/core";
2
+ import { ChangeDetectionStrategy, Component, computed, input, signal } from "@angular/core";
3
+ import { BsCheckboxComponent } from "@mintplayer/ng-bootstrap/checkbox";
4
+ import { SparkIconComponent } from "@mintplayer/ng-spark/icon";
5
+ var SparkColumnFilterPanelComponent = class SparkColumnFilterPanelComponent {
6
+ column = input.required(...ngDevMode ? [{ debugName: "column" }] : /* istanbul ignore next */ []);
7
+ values = input(null, ...ngDevMode ? [{ debugName: "values" }] : /* istanbul ignore next */ []);
8
+ ctx = input.required(...ngDevMode ? [{ debugName: "ctx" }] : /* istanbul ignore next */ []);
9
+ filterLabel = input(null, ...ngDevMode ? [{ debugName: "filterLabel" }] : /* istanbul ignore next */ []);
10
+ listsDistincts = computed(() => this.column().canListDistincts !== false, ...ngDevMode ? [{ debugName: "listsDistincts" }] : /* istanbul ignore next */ []);
11
+ selected = signal([], ...ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []);
12
+ inverse = signal(false, ...ngDevMode ? [{ debugName: "inverse" }] : /* istanbul ignore next */ []);
13
+ freeText = signal("", ...ngDevMode ? [{ debugName: "freeText" }] : /* istanbul ignore next */ []);
14
+ hasSelection = computed(() => this.selected().length > 0, ...ngDevMode ? [{ debugName: "hasSelection" }] : /* istanbul ignore next */ []);
15
+ matching = computed(() => this.values()?.matching ?? [], ...ngDevMode ? [{ debugName: "matching" }] : /* istanbul ignore next */ []);
16
+ remaining = computed(() => this.values()?.remaining ?? [], ...ngDevMode ? [{ debugName: "remaining" }] : /* istanbul ignore next */ []);
17
+ hasMore = computed(() => this.values()?.hasMore === true, ...ngDevMode ? [{ debugName: "hasMore" }] : /* istanbul ignore next */ []);
18
+ isEmpty = computed(() => !this.matching().length && !this.remaining().length, ...ngDevMode ? [{ debugName: "isEmpty" }] : /* istanbul ignore next */ []);
19
+ labelFor(value) {
20
+ const relabel = this.filterLabel();
21
+ return relabel ? relabel(value.value) : value.label;
22
+ }
23
+ isChecked(value) {
24
+ return this.selected().some((v) => sameValue(v.value, value.value));
25
+ }
26
+ toggle(value, checked) {
27
+ const next = checked ? [...this.selected(), value] : this.selected().filter((v) => !sameValue(v.value, value.value));
28
+ this.selected.set(next);
29
+ this.ctx().apply(next, this.inverse());
30
+ }
31
+ toggleInverse() {
32
+ this.inverse.update((v) => !v);
33
+ this.ctx().apply(this.selected(), this.inverse());
34
+ }
35
+ onSearch(term) {
36
+ this.ctx().search(term);
37
+ }
38
+ applyFreeText() {
39
+ const text = this.freeText().trim();
40
+ const next = text ? [{
41
+ value: text,
42
+ label: text
43
+ }] : [];
44
+ this.selected.set(next);
45
+ this.ctx().apply(next, this.inverse());
46
+ }
47
+ clear() {
48
+ this.selected.set([]);
49
+ this.inverse.set(false);
50
+ this.freeText.set("");
51
+ this.ctx().clear();
52
+ }
53
+ static ɵfac = i0.ɵɵngDeclareFactory({
54
+ minVersion: "12.0.0",
55
+ version: "22.2.0",
56
+ ngImport: i0,
57
+ type: SparkColumnFilterPanelComponent,
58
+ deps: [],
59
+ target: i0.ɵɵFactoryTarget.Component
60
+ });
61
+ static ɵcmp = i0.ɵɵngDeclareComponent({
62
+ minVersion: "17.0.0",
63
+ version: "22.2.0",
64
+ type: SparkColumnFilterPanelComponent,
65
+ isStandalone: true,
66
+ selector: "spark-column-filter-panel",
67
+ inputs: {
68
+ column: {
69
+ classPropertyName: "column",
70
+ publicName: "column",
71
+ isSignal: true,
72
+ isRequired: true,
73
+ transformFunction: null
74
+ },
75
+ values: {
76
+ classPropertyName: "values",
77
+ publicName: "values",
78
+ isSignal: true,
79
+ isRequired: false,
80
+ transformFunction: null
81
+ },
82
+ ctx: {
83
+ classPropertyName: "ctx",
84
+ publicName: "ctx",
85
+ isSignal: true,
86
+ isRequired: true,
87
+ transformFunction: null
88
+ },
89
+ filterLabel: {
90
+ classPropertyName: "filterLabel",
91
+ publicName: "filterLabel",
92
+ isSignal: true,
93
+ isRequired: false,
94
+ transformFunction: null
95
+ }
96
+ },
97
+ ngImport: i0,
98
+ template: "<!--\n ⚠️ DOM ORDER IS THE FOCUS ORDER. The overlay resolves initial focus by querying\n `.filter-search, .filter-operand` and falls back to the first tabbable element, so the search box\n is first AND carries that class deliberately. The built-in panel aims focus past its own Clear\n button for the same reason: opening onto a disabled-or-destructive control is the wrong place to\n land, and Clear is disabled exactly when the panel is freshly opened.\n\n No role=\"dialog\" and no aria-label here: the overlay's own `.filter-panel` wrapper already carries\n both, and a second dialog role nested inside the first is worse than none.\n-->\n<div class=\"spark-filter-panel\">\n @if (listsDistincts()) {\n <div class=\"spark-filter-search\">\n <spark-icon name=\"search\" />\n <input\n class=\"filter-search\"\n type=\"search\"\n [attr.aria-label]=\"'Search values'\"\n (input)=\"onSearch($any($event.target).value)\" />\n </div>\n\n <div class=\"spark-filter-body\">\n <!--\n Not a checkbox: it does not select a value, it changes what the selection means.\n aria-pressed is written for both states so the off state is announced too.\n -->\n <button\n type=\"button\"\n class=\"spark-filter-inverse\"\n [class.is-on]=\"inverse()\"\n [attr.aria-pressed]=\"inverse()\"\n [attr.aria-label]=\"'Exclude the selected values'\"\n (click)=\"toggleInverse()\">\n ≠\n </button>\n\n <div class=\"spark-filter-values\" role=\"group\" [attr.aria-label]=\"'Values of ' + column().name\">\n <!-- `values()` is already narrowed by the search term; re-filtering here would double-apply it. -->\n @for (value of matching(); track value.value) {\n <bs-checkbox\n class=\"spark-filter-value\"\n [isToggled]=\"isChecked(value)\"\n (isToggledChange)=\"toggle(value, $event === true)\">\n {{ labelFor(value) }}\n </bs-checkbox>\n }\n\n <!--\n Values that were present when the filter was first applied but are not any more. Dimmed\n and still selectable, so a selection can be widened without clearing it first. The\n datatable derives this bucket by diffing its own snapshot; the server always sends it empty.\n -->\n @for (value of remaining(); track value.value) {\n <bs-checkbox\n class=\"spark-filter-value is-remaining\"\n [isToggled]=\"isChecked(value)\"\n (isToggledChange)=\"toggle(value, $event === true)\">\n {{ labelFor(value) }}\n </bs-checkbox>\n }\n\n @if (isEmpty()) {\n <!--\n One message for three states: nothing loaded yet, nothing matches, and the distinct\n request failed. The last is deliberately indistinguishable — a rejected promise surfaces\n as a null list with no error, and the server already refuses to say whether an empty\n answer means \"no values\" or \"you may not list them\".\n -->\n <p class=\"spark-filter-empty\">No values available</p>\n }\n </div>\n </div>\n\n @if (hasMore()) {\n <!-- Words, not an icon: it is the only signal that typing will fetch different values. -->\n <p class=\"spark-filter-more\">More values exist — refine the search to see them.</p>\n }\n } @else {\n <!--\n A column whose values may not be enumerated. Free text instead of a list, applied through the\n same `apply` so the grid has exactly one event shape to translate.\n -->\n <div class=\"spark-filter-search\">\n <spark-icon name=\"search\" />\n <input\n class=\"filter-search\"\n type=\"search\"\n [value]=\"freeText()\"\n [attr.aria-label]=\"'Filter value'\"\n (input)=\"freeText.set($any($event.target).value)\"\n (keydown.enter)=\"applyFreeText()\" />\n </div>\n <button type=\"button\" class=\"spark-filter-apply\" (click)=\"applyFreeText()\">Apply</button>\n }\n\n <!-- Last, so it is never the landing spot. Kept in the tab order when disabled. -->\n <button type=\"button\" class=\"spark-filter-clear\" [disabled]=\"!hasSelection()\" (click)=\"clear()\">\n <spark-icon name=\"funnel\" />\n <span>Clear the filter on {{ column().name }}</span>\n </button>\n</div>\n",
99
+ styles: [".spark-filter-panel{display:flex;flex-direction:column;gap:.5rem;min-width:14rem;max-width:22rem;max-height:min(20rem,60vh);padding:.5rem;font-size:.875rem}.spark-filter-clear{display:flex;align-items:center;gap:.375rem;padding:.25rem .375rem;border:0;border-radius:.25rem;background:transparent;color:var(--bs-body-color);text-align:left;cursor:pointer}.spark-filter-clear:hover:not(:disabled){background:var(--bs-secondary-bg)}.spark-filter-clear:disabled{color:var(--bs-secondary-color);cursor:default}.spark-filter-search{display:flex;align-items:center;gap:.375rem;padding:.25rem .5rem;border:1px solid var(--bs-border-color);border-radius:.25rem}.spark-filter-search input{flex:1 1 auto;min-width:0;border:0;outline:0;background:transparent;color:inherit;font:inherit;line-height:1.5}.spark-filter-body{display:flex;flex:1 1 auto;min-height:0;gap:.375rem}.spark-filter-inverse{flex:0 0 auto;align-self:flex-start;width:1.75rem;height:1.75rem;border:1px solid var(--bs-border-color);border-radius:.25rem;background:transparent;color:var(--bs-body-color);cursor:pointer}.spark-filter-inverse.is-on{background:var(--bs-primary);border-color:var(--bs-primary);color:var(--bs-light)}.spark-filter-values{flex:1 1 auto;min-height:0;overflow-y:auto;display:flex;flex-direction:column;min-width:0}.spark-filter-value{padding:.125rem 0}.spark-filter-value.is-remaining{color:var(--bs-secondary-color)}.spark-filter-empty,.spark-filter-more{margin:0;color:var(--bs-secondary-color)}.spark-filter-apply{align-self:flex-start;padding:.25rem .75rem;border:1px solid var(--bs-primary);border-radius:.25rem;background:var(--bs-primary);color:var(--bs-light);cursor:pointer}\n"],
100
+ dependencies: [{
101
+ kind: "component",
102
+ type: BsCheckboxComponent,
103
+ selector: "bs-checkbox",
104
+ inputs: [
105
+ "type",
106
+ "isToggled",
107
+ "indeterminate",
108
+ "name",
109
+ "value",
110
+ "group"
111
+ ],
112
+ outputs: ["isToggledChange", "indeterminateChange"]
113
+ }, {
114
+ kind: "component",
115
+ type: SparkIconComponent,
116
+ selector: "spark-icon",
117
+ inputs: ["name"]
118
+ }],
119
+ changeDetection: i0.ChangeDetectionStrategy.OnPush
120
+ });
121
+ };
122
+ i0.ɵɵngDeclareClassMetadata({
123
+ minVersion: "12.0.0",
124
+ version: "22.2.0",
125
+ ngImport: i0,
126
+ type: SparkColumnFilterPanelComponent,
127
+ decorators: [{
128
+ type: Component,
129
+ args: [{
130
+ selector: "spark-column-filter-panel",
131
+ standalone: true,
132
+ imports: [BsCheckboxComponent, SparkIconComponent],
133
+ changeDetection: ChangeDetectionStrategy.OnPush,
134
+ template: "<!--\n ⚠️ DOM ORDER IS THE FOCUS ORDER. The overlay resolves initial focus by querying\n `.filter-search, .filter-operand` and falls back to the first tabbable element, so the search box\n is first AND carries that class deliberately. The built-in panel aims focus past its own Clear\n button for the same reason: opening onto a disabled-or-destructive control is the wrong place to\n land, and Clear is disabled exactly when the panel is freshly opened.\n\n No role=\"dialog\" and no aria-label here: the overlay's own `.filter-panel` wrapper already carries\n both, and a second dialog role nested inside the first is worse than none.\n-->\n<div class=\"spark-filter-panel\">\n @if (listsDistincts()) {\n <div class=\"spark-filter-search\">\n <spark-icon name=\"search\" />\n <input\n class=\"filter-search\"\n type=\"search\"\n [attr.aria-label]=\"'Search values'\"\n (input)=\"onSearch($any($event.target).value)\" />\n </div>\n\n <div class=\"spark-filter-body\">\n <!--\n Not a checkbox: it does not select a value, it changes what the selection means.\n aria-pressed is written for both states so the off state is announced too.\n -->\n <button\n type=\"button\"\n class=\"spark-filter-inverse\"\n [class.is-on]=\"inverse()\"\n [attr.aria-pressed]=\"inverse()\"\n [attr.aria-label]=\"'Exclude the selected values'\"\n (click)=\"toggleInverse()\">\n ≠\n </button>\n\n <div class=\"spark-filter-values\" role=\"group\" [attr.aria-label]=\"'Values of ' + column().name\">\n <!-- `values()` is already narrowed by the search term; re-filtering here would double-apply it. -->\n @for (value of matching(); track value.value) {\n <bs-checkbox\n class=\"spark-filter-value\"\n [isToggled]=\"isChecked(value)\"\n (isToggledChange)=\"toggle(value, $event === true)\">\n {{ labelFor(value) }}\n </bs-checkbox>\n }\n\n <!--\n Values that were present when the filter was first applied but are not any more. Dimmed\n and still selectable, so a selection can be widened without clearing it first. The\n datatable derives this bucket by diffing its own snapshot; the server always sends it empty.\n -->\n @for (value of remaining(); track value.value) {\n <bs-checkbox\n class=\"spark-filter-value is-remaining\"\n [isToggled]=\"isChecked(value)\"\n (isToggledChange)=\"toggle(value, $event === true)\">\n {{ labelFor(value) }}\n </bs-checkbox>\n }\n\n @if (isEmpty()) {\n <!--\n One message for three states: nothing loaded yet, nothing matches, and the distinct\n request failed. The last is deliberately indistinguishable — a rejected promise surfaces\n as a null list with no error, and the server already refuses to say whether an empty\n answer means \"no values\" or \"you may not list them\".\n -->\n <p class=\"spark-filter-empty\">No values available</p>\n }\n </div>\n </div>\n\n @if (hasMore()) {\n <!-- Words, not an icon: it is the only signal that typing will fetch different values. -->\n <p class=\"spark-filter-more\">More values exist — refine the search to see them.</p>\n }\n } @else {\n <!--\n A column whose values may not be enumerated. Free text instead of a list, applied through the\n same `apply` so the grid has exactly one event shape to translate.\n -->\n <div class=\"spark-filter-search\">\n <spark-icon name=\"search\" />\n <input\n class=\"filter-search\"\n type=\"search\"\n [value]=\"freeText()\"\n [attr.aria-label]=\"'Filter value'\"\n (input)=\"freeText.set($any($event.target).value)\"\n (keydown.enter)=\"applyFreeText()\" />\n </div>\n <button type=\"button\" class=\"spark-filter-apply\" (click)=\"applyFreeText()\">Apply</button>\n }\n\n <!-- Last, so it is never the landing spot. Kept in the tab order when disabled. -->\n <button type=\"button\" class=\"spark-filter-clear\" [disabled]=\"!hasSelection()\" (click)=\"clear()\">\n <spark-icon name=\"funnel\" />\n <span>Clear the filter on {{ column().name }}</span>\n </button>\n</div>\n",
135
+ styles: [".spark-filter-panel{display:flex;flex-direction:column;gap:.5rem;min-width:14rem;max-width:22rem;max-height:min(20rem,60vh);padding:.5rem;font-size:.875rem}.spark-filter-clear{display:flex;align-items:center;gap:.375rem;padding:.25rem .375rem;border:0;border-radius:.25rem;background:transparent;color:var(--bs-body-color);text-align:left;cursor:pointer}.spark-filter-clear:hover:not(:disabled){background:var(--bs-secondary-bg)}.spark-filter-clear:disabled{color:var(--bs-secondary-color);cursor:default}.spark-filter-search{display:flex;align-items:center;gap:.375rem;padding:.25rem .5rem;border:1px solid var(--bs-border-color);border-radius:.25rem}.spark-filter-search input{flex:1 1 auto;min-width:0;border:0;outline:0;background:transparent;color:inherit;font:inherit;line-height:1.5}.spark-filter-body{display:flex;flex:1 1 auto;min-height:0;gap:.375rem}.spark-filter-inverse{flex:0 0 auto;align-self:flex-start;width:1.75rem;height:1.75rem;border:1px solid var(--bs-border-color);border-radius:.25rem;background:transparent;color:var(--bs-body-color);cursor:pointer}.spark-filter-inverse.is-on{background:var(--bs-primary);border-color:var(--bs-primary);color:var(--bs-light)}.spark-filter-values{flex:1 1 auto;min-height:0;overflow-y:auto;display:flex;flex-direction:column;min-width:0}.spark-filter-value{padding:.125rem 0}.spark-filter-value.is-remaining{color:var(--bs-secondary-color)}.spark-filter-empty,.spark-filter-more{margin:0;color:var(--bs-secondary-color)}.spark-filter-apply{align-self:flex-start;padding:.25rem .75rem;border:1px solid var(--bs-primary);border-radius:.25rem;background:var(--bs-primary);color:var(--bs-light);cursor:pointer}\n"]
136
+ }]
137
+ }],
138
+ propDecorators: {
139
+ column: [{
140
+ type: i0.Input,
141
+ args: [{
142
+ isSignal: true,
143
+ alias: "column",
144
+ required: true
145
+ }]
146
+ }],
147
+ values: [{
148
+ type: i0.Input,
149
+ args: [{
150
+ isSignal: true,
151
+ alias: "values",
152
+ required: false
153
+ }]
154
+ }],
155
+ ctx: [{
156
+ type: i0.Input,
157
+ args: [{
158
+ isSignal: true,
159
+ alias: "ctx",
160
+ required: true
161
+ }]
162
+ }],
163
+ filterLabel: [{
164
+ type: i0.Input,
165
+ args: [{
166
+ isSignal: true,
167
+ alias: "filterLabel",
168
+ required: false
169
+ }]
170
+ }]
171
+ }
172
+ });
129
173
  function sameValue(a, b) {
130
- return a === b || (Number.isNaN(a) && Number.isNaN(b));
174
+ return a === b || Number.isNaN(a) && Number.isNaN(b);
131
175
  }
132
-
133
- /**
134
- * Generated bundle index. Do not edit.
135
- */
136
-
137
176
  export { SparkColumnFilterPanelComponent };
138
- //# sourceMappingURL=mintplayer-ng-spark-column-filter.mjs.map
177
+
178
+ //# sourceMappingURL=mintplayer-ng-spark-column-filter.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"mintplayer-ng-spark-column-filter.mjs","sources":["../../column-filter/src/spark-column-filter-panel.component.ts","../../column-filter/src/spark-column-filter-panel.component.html","../../column-filter/mintplayer-ng-spark-column-filter.ts"],"sourcesContent":["import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core';\nimport { DistinctValue, DistinctValues, FilterContext } from '@mintplayer/ng-bootstrap/datatable';\nimport { QueryColumn } from '@mintplayer/ng-spark/models';\nimport { BsCheckboxComponent } from '@mintplayer/ng-bootstrap/checkbox';\nimport { SparkIconComponent } from '@mintplayer/ng-spark/icon';\n\n/**\n * The contents of one column's filter panel (#431).\n *\n * Spark supplies its own panel on **every** filterable column rather than using the datatable's\n * built-in one. Two reasons, and the first forces the second:\n *\n * - A `canListDistincts: false` column has no built-in mode that fits. `FilterMode` is\n * `'values' | 'comparison'` and there is no `contains` operator, so a free-text filter cannot be\n * expressed — and resolving `null` from the distinct source is not an escape either, because that\n * means \"fall back to the local pass\", which is disabled for a `[fetch]`-bound grid. Such a column\n * would render the built-in panel showing its \"no values\" string: a dead panel.\n * - Since some columns must nest regardless, mixing would put two visually different panels in one\n * grid.\n *\n * What the datatable still owns, and this component must not reimplement: the async distinct source\n * and its debounce, request cancellation, the matching/remaining bucketing, the change event, the\n * document-root overlay, the focus trap and Escape. This is markup over {@link FilterContext}.\n *\n * ⚠️ **Styling is ours.** The panel is portalled out of the grid's subtree, so Bootstrap's\n * `.form-control` and `.btn` do not reach it — they are only styled inside `bs-*` components. Every\n * visual here is explicit.\n */\n@Component({\n selector: 'spark-column-filter-panel',\n standalone: true,\n imports: [BsCheckboxComponent, SparkIconComponent],\n templateUrl: './spark-column-filter-panel.component.html',\n styleUrl: './spark-column-filter-panel.component.scss',\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SparkColumnFilterPanelComponent {\n readonly column = input.required<QueryColumn>();\n\n /** The datatable's bucketed list, or null while nothing has loaded. */\n readonly values = input<DistinctValues | null>(null);\n\n readonly ctx = input.required<FilterContext>();\n\n /**\n * Optional relabelling for a column with a custom cell renderer.\n *\n * A renderer is a component that paints arbitrary DOM, and a distinct value has no row, so it\n * cannot be instantiated to produce text. The label therefore defaults to the server-computed cell\n * text — a status rendered as a coloured pill lists `Active`, a rating rendered as stars lists `3`.\n * This hook is the escape for the case where that is too raw: a pure function, no DOM, no row.\n */\n readonly filterLabel = input<((value: unknown) => string) | null>(null);\n\n /** Whether this column offers a value list at all, or only free text. */\n protected readonly listsDistincts = computed(() => this.column().canListDistincts !== false);\n\n /** Selected values, by value. Identity is the value, never the label — two rows may share text. */\n private readonly selected = signal<DistinctValue[]>([]);\n\n protected readonly inverse = signal(false);\n\n protected readonly freeText = signal('');\n\n protected readonly hasSelection = computed(() => this.selected().length > 0);\n\n protected readonly matching = computed(() => this.values()?.matching ?? []);\n protected readonly remaining = computed(() => this.values()?.remaining ?? []);\n protected readonly hasMore = computed(() => this.values()?.hasMore === true);\n\n /**\n * Whether to show the empty state.\n *\n * Covers three states on purpose: nothing loaded yet, nothing matched, and the distinct request\n * failed. The datatable swallows a rejected promise into a null list with no error, so the third\n * is not distinguishable here - and the server already refuses to distinguish \"no values\" from\n * \"you may not list them\".\n */\n protected readonly isEmpty = computed(() => !this.matching().length && !this.remaining().length);\n\n protected labelFor(value: DistinctValue): string {\n const relabel = this.filterLabel();\n return relabel ? relabel(value.value) : value.label;\n }\n\n protected isChecked(value: DistinctValue): boolean {\n return this.selected().some(v => sameValue(v.value, value.value));\n }\n\n protected toggle(value: DistinctValue, checked: boolean): void {\n const next = checked\n ? [...this.selected(), value]\n : this.selected().filter(v => !sameValue(v.value, value.value));\n\n this.selected.set(next);\n this.ctx().apply(next, this.inverse());\n }\n\n protected toggleInverse(): void {\n // Not a flag on the wire: the grid moves the selected values between `includes` and `excludes`.\n // Applying immediately keeps the panel's state and the grid's in step without an Apply button.\n this.inverse.update(v => !v);\n this.ctx().apply(this.selected(), this.inverse());\n }\n\n protected onSearch(term: string): void {\n // Debounced and cancelled by the datatable; re-queried only when it cannot answer locally.\n this.ctx().search(term);\n }\n\n /**\n * Applies the free-text filter of a column whose values may not be enumerated.\n *\n * It goes through the same `apply` as a checkbox list, carrying one synthetic entry, so the grid\n * has exactly one event shape to translate and no second wire form to maintain.\n */\n protected applyFreeText(): void {\n const text = this.freeText().trim();\n const next: DistinctValue[] = text ? [{ value: text, label: text }] : [];\n\n this.selected.set(next);\n this.ctx().apply(next, this.inverse());\n }\n\n protected clear(): void {\n this.selected.set([]);\n this.inverse.set(false);\n this.freeText.set('');\n this.ctx().clear();\n }\n}\n\n/**\n * SameValueZero, matching how the datatable keys its list.\n *\n * `NaN` must equal itself or a numeric column's \"not a number\" entry can be ticked but never\n * unticked, and `===` gets that wrong.\n */\nfunction sameValue(a: unknown, b: unknown): boolean {\n return a === b || (Number.isNaN(a as number) && Number.isNaN(b as number));\n}\n","<!--\n ⚠️ DOM ORDER IS THE FOCUS ORDER. The overlay resolves initial focus by querying\n `.filter-search, .filter-operand` and falls back to the first tabbable element, so the search box\n is first AND carries that class deliberately. The built-in panel aims focus past its own Clear\n button for the same reason: opening onto a disabled-or-destructive control is the wrong place to\n land, and Clear is disabled exactly when the panel is freshly opened.\n\n No role=\"dialog\" and no aria-label here: the overlay's own `.filter-panel` wrapper already carries\n both, and a second dialog role nested inside the first is worse than none.\n-->\n<div class=\"spark-filter-panel\">\n @if (listsDistincts()) {\n <div class=\"spark-filter-search\">\n <spark-icon name=\"search\" />\n <input\n class=\"filter-search\"\n type=\"search\"\n [attr.aria-label]=\"'Search values'\"\n (input)=\"onSearch($any($event.target).value)\" />\n </div>\n\n <div class=\"spark-filter-body\">\n <!--\n Not a checkbox: it does not select a value, it changes what the selection means.\n aria-pressed is written for both states so the off state is announced too.\n -->\n <button\n type=\"button\"\n class=\"spark-filter-inverse\"\n [class.is-on]=\"inverse()\"\n [attr.aria-pressed]=\"inverse()\"\n [attr.aria-label]=\"'Exclude the selected values'\"\n (click)=\"toggleInverse()\">\n ≠\n </button>\n\n <div class=\"spark-filter-values\" role=\"group\" [attr.aria-label]=\"'Values of ' + column().name\">\n <!-- `values()` is already narrowed by the search term; re-filtering here would double-apply it. -->\n @for (value of matching(); track value.value) {\n <bs-checkbox\n class=\"spark-filter-value\"\n [isToggled]=\"isChecked(value)\"\n (isToggledChange)=\"toggle(value, $event === true)\">\n {{ labelFor(value) }}\n </bs-checkbox>\n }\n\n <!--\n Values that were present when the filter was first applied but are not any more. Dimmed\n and still selectable, so a selection can be widened without clearing it first. The\n datatable derives this bucket by diffing its own snapshot; the server always sends it empty.\n -->\n @for (value of remaining(); track value.value) {\n <bs-checkbox\n class=\"spark-filter-value is-remaining\"\n [isToggled]=\"isChecked(value)\"\n (isToggledChange)=\"toggle(value, $event === true)\">\n {{ labelFor(value) }}\n </bs-checkbox>\n }\n\n @if (isEmpty()) {\n <!--\n One message for three states: nothing loaded yet, nothing matches, and the distinct\n request failed. The last is deliberately indistinguishable — a rejected promise surfaces\n as a null list with no error, and the server already refuses to say whether an empty\n answer means \"no values\" or \"you may not list them\".\n -->\n <p class=\"spark-filter-empty\">No values available</p>\n }\n </div>\n </div>\n\n @if (hasMore()) {\n <!-- Words, not an icon: it is the only signal that typing will fetch different values. -->\n <p class=\"spark-filter-more\">More values exist — refine the search to see them.</p>\n }\n } @else {\n <!--\n A column whose values may not be enumerated. Free text instead of a list, applied through the\n same `apply` so the grid has exactly one event shape to translate.\n -->\n <div class=\"spark-filter-search\">\n <spark-icon name=\"search\" />\n <input\n class=\"filter-search\"\n type=\"search\"\n [value]=\"freeText()\"\n [attr.aria-label]=\"'Filter value'\"\n (input)=\"freeText.set($any($event.target).value)\"\n (keydown.enter)=\"applyFreeText()\" />\n </div>\n <button type=\"button\" class=\"spark-filter-apply\" (click)=\"applyFreeText()\">Apply</button>\n }\n\n <!-- Last, so it is never the landing spot. Kept in the tab order when disabled. -->\n <button type=\"button\" class=\"spark-filter-clear\" [disabled]=\"!hasSelection()\" (click)=\"clear()\">\n <spark-icon name=\"funnel\" />\n <span>Clear the filter on {{ column().name }}</span>\n </button>\n</div>\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":";;;;;AAMA;;;;;;;;;;;;;;;;;;;;;AAqBG;MASU,+BAA+B,CAAA;IACjC,MAAM,GAAG,KAAK,CAAC,QAAQ;+EAAe;;IAGtC,MAAM,GAAG,KAAK,CAAwB,IAAI;+EAAC;IAE3C,GAAG,GAAG,KAAK,CAAC,QAAQ;4EAAiB;AAE9C;;;;;;;AAOG;IACM,WAAW,GAAG,KAAK,CAAsC,IAAI;oFAAC;;AAGpD,IAAA,cAAc,GAAG,QAAQ,CAAC,MAAM,IAAI,CAAC,MAAM,EAAE,CAAC,gBAAgB,KAAK,KAAK;uFAAC;;IAG3E,QAAQ,GAAG,MAAM,CAAkB,EAAE;iFAAC;IAEpC,OAAO,GAAG,MAAM,CAAC,KAAK;gFAAC;IAEvB,QAAQ,GAAG,MAAM,CAAC,EAAE;iFAAC;AAErB,IAAA,YAAY,GAAG,QAAQ,CAAC,MAAM,IAAI,CAAC,QAAQ,EAAE,CAAC,MAAM,GAAG,CAAC;qFAAC;AAEzD,IAAA,QAAQ,GAAG,QAAQ,CAAC,MAAM,IAAI,CAAC,MAAM,EAAE,EAAE,QAAQ,IAAI,EAAE;iFAAC;AACxD,IAAA,SAAS,GAAG,QAAQ,CAAC,MAAM,IAAI,CAAC,MAAM,EAAE,EAAE,SAAS,IAAI,EAAE;kFAAC;AAC1D,IAAA,OAAO,GAAG,QAAQ,CAAC,MAAM,IAAI,CAAC,MAAM,EAAE,EAAE,OAAO,KAAK,IAAI;gFAAC;AAE5E;;;;;;;AAOG;IACgB,OAAO,GAAG,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,MAAM,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC,MAAM;gFAAC;AAEtF,IAAA,QAAQ,CAAC,KAAoB,EAAA;AACrC,QAAA,MAAM,OAAO,GAAG,IAAI,CAAC,WAAW,EAAE;AAClC,QAAA,OAAO,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,KAAK,CAAC,KAAK;IACrD;AAEU,IAAA,SAAS,CAAC,KAAoB,EAAA;QACtC,OAAO,IAAI,CAAC,QAAQ,EAAE,CAAC,IAAI,CAAC,CAAC,IAAI,SAAS,CAAC,CAAC,CAAC,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;IACnE;IAEU,MAAM,CAAC,KAAoB,EAAE,OAAgB,EAAA;QACrD,MAAM,IAAI,GAAG;cACT,CAAC,GAAG,IAAI,CAAC,QAAQ,EAAE,EAAE,KAAK;cAC1B,IAAI,CAAC,QAAQ,EAAE,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;AAEjE,QAAA,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC;AACvB,QAAA,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC;IACxC;IAEU,aAAa,GAAA;;;AAGrB,QAAA,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;AAC5B,QAAA,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,QAAQ,EAAE,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC;IACnD;AAEU,IAAA,QAAQ,CAAC,IAAY,EAAA;;QAE7B,IAAI,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC;IACzB;AAEA;;;;;AAKG;IACO,aAAa,GAAA;QACrB,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE;QACnC,MAAM,IAAI,GAAoB,IAAI,GAAG,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,GAAG,EAAE;AAExE,QAAA,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC;AACvB,QAAA,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC;IACxC;IAEU,KAAK,GAAA;AACb,QAAA,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;AACrB,QAAA,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC;AACvB,QAAA,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;AACrB,QAAA,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,EAAE;IACpB;uGA7FW,+BAA+B,EAAA,IAAA,EAAA,EAAA,EAAA,MAAA,EAAA,EAAA,CAAA,eAAA,CAAA,SAAA,EAAA,CAAA;AAA/B,IAAA,OAAA,IAAA,GAAA,EAAA,CAAA,oBAAA,CAAA,EAAA,UAAA,EAAA,QAAA,EAAA,OAAA,EAAA,QAAA,EAAA,IAAA,EAAA,+BAA+B,EAAA,YAAA,EAAA,IAAA,EAAA,QAAA,EAAA,2BAAA,EAAA,MAAA,EAAA,EAAA,MAAA,EAAA,EAAA,iBAAA,EAAA,QAAA,EAAA,UAAA,EAAA,QAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,IAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,MAAA,EAAA,EAAA,iBAAA,EAAA,QAAA,EAAA,UAAA,EAAA,QAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,GAAA,EAAA,EAAA,iBAAA,EAAA,KAAA,EAAA,UAAA,EAAA,KAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,IAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,WAAA,EAAA,EAAA,iBAAA,EAAA,aAAA,EAAA,UAAA,EAAA,aAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,EAAA,QAAA,EAAA,EAAA,EAAA,QAAA,ECpC5C,41IAqGA,EAAA,MAAA,EAAA,CAAA,ooDAAA,CAAA,EAAA,YAAA,EAAA,CAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EDtEY,mBAAmB,uLAAE,kBAAkB,EAAA,QAAA,EAAA,YAAA,EAAA,MAAA,EAAA,CAAA,MAAA,CAAA,EAAA,CAAA,EAAA,eAAA,EAAA,EAAA,CAAA,uBAAA,CAAA,MAAA,EAAA,CAAA;;2FAKtC,+BAA+B,EAAA,UAAA,EAAA,CAAA;kBAR3C,SAAS;+BACE,2BAA2B,EAAA,UAAA,EACzB,IAAI,EAAA,OAAA,EACP,CAAC,mBAAmB,EAAE,kBAAkB,CAAC,EAAA,eAAA,EAGjC,uBAAuB,CAAC,MAAM,EAAA,QAAA,EAAA,41IAAA,EAAA,MAAA,EAAA,CAAA,ooDAAA,CAAA,EAAA;;AAkGjD;;;;;AAKG;AACH,SAAS,SAAS,CAAC,CAAU,EAAE,CAAU,EAAA;AACvC,IAAA,OAAO,CAAC,KAAK,CAAC,KAAK,MAAM,CAAC,KAAK,CAAC,CAAW,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,CAAW,CAAC,CAAC;AAC5E;;AE5IA;;AAEG;;;;"}
1
+ {"version":3,"file":"mintplayer-ng-spark-column-filter.mjs","names":[],"sources":["../../column-filter/src/spark-column-filter-panel.component.ts","../../column-filter/src/spark-column-filter-panel.component.html"],"sourcesContent":["import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core';\nimport { DistinctValue, DistinctValues, FilterContext } from '@mintplayer/ng-bootstrap/datatable';\nimport { QueryColumn } from '@mintplayer/ng-spark/models';\nimport { BsCheckboxComponent } from '@mintplayer/ng-bootstrap/checkbox';\nimport { SparkIconComponent } from '@mintplayer/ng-spark/icon';\n\n/**\n * The contents of one column's filter panel (#431).\n *\n * Spark supplies its own panel on **every** filterable column rather than using the datatable's\n * built-in one. Two reasons, and the first forces the second:\n *\n * - A `canListDistincts: false` column has no built-in mode that fits. `FilterMode` is\n * `'values' | 'comparison'` and there is no `contains` operator, so a free-text filter cannot be\n * expressed — and resolving `null` from the distinct source is not an escape either, because that\n * means \"fall back to the local pass\", which is disabled for a `[fetch]`-bound grid. Such a column\n * would render the built-in panel showing its \"no values\" string: a dead panel.\n * - Since some columns must nest regardless, mixing would put two visually different panels in one\n * grid.\n *\n * What the datatable still owns, and this component must not reimplement: the async distinct source\n * and its debounce, request cancellation, the matching/remaining bucketing, the change event, the\n * document-root overlay, the focus trap and Escape. This is markup over {@link FilterContext}.\n *\n * ⚠️ **Styling is ours.** The panel is portalled out of the grid's subtree, so Bootstrap's\n * `.form-control` and `.btn` do not reach it — they are only styled inside `bs-*` components. Every\n * visual here is explicit.\n */\n@Component({\n selector: 'spark-column-filter-panel',\n standalone: true,\n imports: [BsCheckboxComponent, SparkIconComponent],\n templateUrl: './spark-column-filter-panel.component.html',\n styleUrl: './spark-column-filter-panel.component.scss',\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SparkColumnFilterPanelComponent {\n readonly column = input.required<QueryColumn>();\n\n /** The datatable's bucketed list, or null while nothing has loaded. */\n readonly values = input<DistinctValues | null>(null);\n\n readonly ctx = input.required<FilterContext>();\n\n /**\n * Optional relabelling for a column with a custom cell renderer.\n *\n * A renderer is a component that paints arbitrary DOM, and a distinct value has no row, so it\n * cannot be instantiated to produce text. The label therefore defaults to the server-computed cell\n * text — a status rendered as a coloured pill lists `Active`, a rating rendered as stars lists `3`.\n * This hook is the escape for the case where that is too raw: a pure function, no DOM, no row.\n */\n readonly filterLabel = input<((value: unknown) => string) | null>(null);\n\n /** Whether this column offers a value list at all, or only free text. */\n protected readonly listsDistincts = computed(() => this.column().canListDistincts !== false);\n\n /** Selected values, by value. Identity is the value, never the label — two rows may share text. */\n private readonly selected = signal<DistinctValue[]>([]);\n\n protected readonly inverse = signal(false);\n\n protected readonly freeText = signal('');\n\n protected readonly hasSelection = computed(() => this.selected().length > 0);\n\n protected readonly matching = computed(() => this.values()?.matching ?? []);\n protected readonly remaining = computed(() => this.values()?.remaining ?? []);\n protected readonly hasMore = computed(() => this.values()?.hasMore === true);\n\n /**\n * Whether to show the empty state.\n *\n * Covers three states on purpose: nothing loaded yet, nothing matched, and the distinct request\n * failed. The datatable swallows a rejected promise into a null list with no error, so the third\n * is not distinguishable here - and the server already refuses to distinguish \"no values\" from\n * \"you may not list them\".\n */\n protected readonly isEmpty = computed(() => !this.matching().length && !this.remaining().length);\n\n protected labelFor(value: DistinctValue): string {\n const relabel = this.filterLabel();\n return relabel ? relabel(value.value) : value.label;\n }\n\n protected isChecked(value: DistinctValue): boolean {\n return this.selected().some(v => sameValue(v.value, value.value));\n }\n\n protected toggle(value: DistinctValue, checked: boolean): void {\n const next = checked\n ? [...this.selected(), value]\n : this.selected().filter(v => !sameValue(v.value, value.value));\n\n this.selected.set(next);\n this.ctx().apply(next, this.inverse());\n }\n\n protected toggleInverse(): void {\n // Not a flag on the wire: the grid moves the selected values between `includes` and `excludes`.\n // Applying immediately keeps the panel's state and the grid's in step without an Apply button.\n this.inverse.update(v => !v);\n this.ctx().apply(this.selected(), this.inverse());\n }\n\n protected onSearch(term: string): void {\n // Debounced and cancelled by the datatable; re-queried only when it cannot answer locally.\n this.ctx().search(term);\n }\n\n /**\n * Applies the free-text filter of a column whose values may not be enumerated.\n *\n * It goes through the same `apply` as a checkbox list, carrying one synthetic entry, so the grid\n * has exactly one event shape to translate and no second wire form to maintain.\n */\n protected applyFreeText(): void {\n const text = this.freeText().trim();\n const next: DistinctValue[] = text ? [{ value: text, label: text }] : [];\n\n this.selected.set(next);\n this.ctx().apply(next, this.inverse());\n }\n\n protected clear(): void {\n this.selected.set([]);\n this.inverse.set(false);\n this.freeText.set('');\n this.ctx().clear();\n }\n}\n\n/**\n * SameValueZero, matching how the datatable keys its list.\n *\n * `NaN` must equal itself or a numeric column's \"not a number\" entry can be ticked but never\n * unticked, and `===` gets that wrong.\n */\nfunction sameValue(a: unknown, b: unknown): boolean {\n return a === b || (Number.isNaN(a as number) && Number.isNaN(b as number));\n}\n","<!--\n ⚠️ DOM ORDER IS THE FOCUS ORDER. The overlay resolves initial focus by querying\n `.filter-search, .filter-operand` and falls back to the first tabbable element, so the search box\n is first AND carries that class deliberately. The built-in panel aims focus past its own Clear\n button for the same reason: opening onto a disabled-or-destructive control is the wrong place to\n land, and Clear is disabled exactly when the panel is freshly opened.\n\n No role=\"dialog\" and no aria-label here: the overlay's own `.filter-panel` wrapper already carries\n both, and a second dialog role nested inside the first is worse than none.\n-->\n<div class=\"spark-filter-panel\">\n @if (listsDistincts()) {\n <div class=\"spark-filter-search\">\n <spark-icon name=\"search\" />\n <input\n class=\"filter-search\"\n type=\"search\"\n [attr.aria-label]=\"'Search values'\"\n (input)=\"onSearch($any($event.target).value)\" />\n </div>\n\n <div class=\"spark-filter-body\">\n <!--\n Not a checkbox: it does not select a value, it changes what the selection means.\n aria-pressed is written for both states so the off state is announced too.\n -->\n <button\n type=\"button\"\n class=\"spark-filter-inverse\"\n [class.is-on]=\"inverse()\"\n [attr.aria-pressed]=\"inverse()\"\n [attr.aria-label]=\"'Exclude the selected values'\"\n (click)=\"toggleInverse()\">\n ≠\n </button>\n\n <div class=\"spark-filter-values\" role=\"group\" [attr.aria-label]=\"'Values of ' + column().name\">\n <!-- `values()` is already narrowed by the search term; re-filtering here would double-apply it. -->\n @for (value of matching(); track value.value) {\n <bs-checkbox\n class=\"spark-filter-value\"\n [isToggled]=\"isChecked(value)\"\n (isToggledChange)=\"toggle(value, $event === true)\">\n {{ labelFor(value) }}\n </bs-checkbox>\n }\n\n <!--\n Values that were present when the filter was first applied but are not any more. Dimmed\n and still selectable, so a selection can be widened without clearing it first. The\n datatable derives this bucket by diffing its own snapshot; the server always sends it empty.\n -->\n @for (value of remaining(); track value.value) {\n <bs-checkbox\n class=\"spark-filter-value is-remaining\"\n [isToggled]=\"isChecked(value)\"\n (isToggledChange)=\"toggle(value, $event === true)\">\n {{ labelFor(value) }}\n </bs-checkbox>\n }\n\n @if (isEmpty()) {\n <!--\n One message for three states: nothing loaded yet, nothing matches, and the distinct\n request failed. The last is deliberately indistinguishable — a rejected promise surfaces\n as a null list with no error, and the server already refuses to say whether an empty\n answer means \"no values\" or \"you may not list them\".\n -->\n <p class=\"spark-filter-empty\">No values available</p>\n }\n </div>\n </div>\n\n @if (hasMore()) {\n <!-- Words, not an icon: it is the only signal that typing will fetch different values. -->\n <p class=\"spark-filter-more\">More values exist — refine the search to see them.</p>\n }\n } @else {\n <!--\n A column whose values may not be enumerated. Free text instead of a list, applied through the\n same `apply` so the grid has exactly one event shape to translate.\n -->\n <div class=\"spark-filter-search\">\n <spark-icon name=\"search\" />\n <input\n class=\"filter-search\"\n type=\"search\"\n [value]=\"freeText()\"\n [attr.aria-label]=\"'Filter value'\"\n (input)=\"freeText.set($any($event.target).value)\"\n (keydown.enter)=\"applyFreeText()\" />\n </div>\n <button type=\"button\" class=\"spark-filter-apply\" (click)=\"applyFreeText()\">Apply</button>\n }\n\n <!-- Last, so it is never the landing spot. Kept in the tab order when disabled. -->\n <button type=\"button\" class=\"spark-filter-clear\" [disabled]=\"!hasSelection()\" (click)=\"clear()\">\n <spark-icon name=\"funnel\" />\n <span>Clear the filter on {{ column().name }}</span>\n </button>\n</div>\n"],"mappings":"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"}