@asteby/metacore-runtime-react 39.2.10 → 41.0.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 (43) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/agent-result-registry.d.ts +44 -0
  3. package/dist/agent-result-registry.d.ts.map +1 -0
  4. package/dist/agent-result-registry.js +78 -0
  5. package/dist/attribute-classes.d.ts +46 -0
  6. package/dist/attribute-classes.d.ts.map +1 -0
  7. package/dist/attribute-classes.js +124 -0
  8. package/dist/dialogs/dynamic-record.d.ts +2 -2
  9. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  10. package/dist/dialogs/dynamic-record.js +14 -9
  11. package/dist/dynamic-form-schema.d.ts +6 -0
  12. package/dist/dynamic-form-schema.d.ts.map +1 -1
  13. package/dist/dynamic-form-schema.js +12 -1
  14. package/dist/index.d.ts +6 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +6 -1
  17. package/dist/motion.d.ts +19 -0
  18. package/dist/motion.d.ts.map +1 -0
  19. package/dist/motion.js +35 -0
  20. package/dist/types.d.ts +8 -1
  21. package/dist/types.d.ts.map +1 -1
  22. package/dist/use-flip-animation.d.ts +30 -0
  23. package/dist/use-flip-animation.d.ts.map +1 -0
  24. package/dist/use-flip-animation.js +123 -0
  25. package/dist/use-optimistic-mutation.d.ts +73 -0
  26. package/dist/use-optimistic-mutation.d.ts.map +1 -0
  27. package/dist/use-optimistic-mutation.js +153 -0
  28. package/package.json +3 -3
  29. package/src/__tests__/motion.test.ts +29 -0
  30. package/src/__tests__/use-flip-animation.test.tsx +109 -0
  31. package/src/__tests__/use-optimistic-mutation.test.tsx +201 -0
  32. package/src/agent-result-registry.test.tsx +52 -0
  33. package/src/agent-result-registry.tsx +129 -0
  34. package/src/attribute-classes.test.ts +79 -0
  35. package/src/attribute-classes.ts +147 -0
  36. package/src/dialogs/dynamic-record.tsx +22 -7
  37. package/src/dynamic-form-schema.ts +13 -1
  38. package/src/index.ts +35 -0
  39. package/src/motion.ts +47 -0
  40. package/src/types.ts +8 -1
  41. package/src/use-flip-animation.ts +157 -0
  42. package/src/use-optimistic-mutation.ts +218 -0
  43. package/tsconfig.json +1 -1
@@ -0,0 +1,147 @@
1
+ // Attribute classes (CONTRACT-item-master §3.2): an extension table's columns
2
+ // are grouped into classes, and each organization assigns classes to its
3
+ // categories. A field with `visible_when.class` shows when the record's
4
+ // category (or one of its parents) carries the class, or when the saved record
5
+ // already has data in the class's fields (existing data is never hidden).
6
+ //
7
+ // The host serves the classes on the modal metadata (`attribute_classes`) and,
8
+ // optionally, the field whose referenced record carries them
9
+ // (`attribute_class_field`, default `category_id`). The referenced record
10
+ // exposes its classes as `attribute_classes` (array of keys) and its parent as
11
+ // `parent_id`.
12
+ import { useEffect, useMemo, useState } from 'react'
13
+ import type { ApiClient } from './api-context'
14
+
15
+ export interface AttributeClassSection {
16
+ key: string
17
+ label?: string
18
+ fields: string[]
19
+ }
20
+
21
+ export interface AttributeClass {
22
+ key: string
23
+ label?: string
24
+ sections: AttributeClassSection[]
25
+ }
26
+
27
+ /** How many parent hops the class lookup walks (a category tree is shallow). */
28
+ const MAX_PARENT_HOPS = 5
29
+
30
+ const cache = new Map<string, Promise<{ classes: string[]; parent: string | null }>>()
31
+
32
+ function refModel(ref: string | undefined): string | null {
33
+ if (!ref) return null
34
+ const i = ref.lastIndexOf('.')
35
+ return i >= 0 ? ref.slice(i + 1) : ref
36
+ }
37
+
38
+ function loadNode(api: ApiClient, model: string, id: string) {
39
+ const key = `${model}\n${id}`
40
+ let hit = cache.get(key)
41
+ if (!hit) {
42
+ hit = api
43
+ .get(`/data/${model}/${id}`)
44
+ .then((res) => {
45
+ const row = (res?.data?.data ?? {}) as Record<string, unknown>
46
+ const raw = row.attribute_classes
47
+ const classes = Array.isArray(raw) ? raw.map(String) : []
48
+ const parent = row.parent_id ? String(row.parent_id) : null
49
+ return { classes, parent }
50
+ })
51
+ .catch(() => ({ classes: [] as string[], parent: null }))
52
+ cache.set(key, hit)
53
+ }
54
+ return hit
55
+ }
56
+
57
+ /**
58
+ * Resolves the attribute classes of the record's category and its parents.
59
+ * Returns [] when the model serves no classes or the category is empty.
60
+ */
61
+ export async function resolveAttributeClasses(api: ApiClient, model: string, id: string): Promise<string[]> {
62
+ const out = new Set<string>()
63
+ let current: string | null = id
64
+ for (let hop = 0; current && hop <= MAX_PARENT_HOPS; hop++) {
65
+ const node: { classes: string[]; parent: string | null } = await loadNode(api, model, current)
66
+ node.classes.forEach((c) => out.add(c))
67
+ current = node.parent
68
+ }
69
+ return [...out]
70
+ }
71
+
72
+ /** Test hook: forget cached category lookups. */
73
+ export function resetAttributeClassCache(): void {
74
+ cache.clear()
75
+ }
76
+
77
+ function readPath(rec: Record<string, unknown>, key: string): unknown {
78
+ if (key in rec) return rec[key]
79
+ let cur: unknown = rec
80
+ for (const part of key.split('.')) {
81
+ if (cur == null || typeof cur !== 'object') return undefined
82
+ cur = (cur as Record<string, unknown>)[part]
83
+ }
84
+ return cur
85
+ }
86
+
87
+ /**
88
+ * The classes a saved record already carries by its own data: a class whose
89
+ * section fields hold a value in the record. Its fields stay visible even when
90
+ * the category lacks the class (a tire with a spec sheet but no category, or a
91
+ * category the org has not tagged yet) — hiding a class must never hide data
92
+ * that exists. Field keys are matched as served, bare or `<Extension>.<column>`.
93
+ */
94
+ export function classesCarriedByRecord(
95
+ meta: { attribute_classes?: AttributeClass[]; fields?: { key: string }[] } | null | undefined,
96
+ record: Record<string, unknown> | null | undefined,
97
+ ): string[] {
98
+ if (!record || !Array.isArray(meta?.attribute_classes)) return []
99
+ const keys = (meta?.fields ?? []).map((f) => f.key)
100
+ const out: string[] = []
101
+ for (const cls of meta!.attribute_classes!) {
102
+ const cols = new Set((cls.sections ?? []).flatMap((s) => s.fields ?? []))
103
+ const carried = keys.some((k) => {
104
+ const col = k.includes('.') ? k.slice(k.lastIndexOf('.') + 1) : k
105
+ if (!cols.has(col) && !cols.has(k)) return false
106
+ const v = readPath(record, k)
107
+ return v != null && v !== ''
108
+ })
109
+ if (carried) out.push(cls.key)
110
+ }
111
+ return out
112
+ }
113
+
114
+ /**
115
+ * The classes that apply to the form: those of its current category plus
116
+ * those the saved record carries by its data (classesCarriedByRecord). `meta`
117
+ * is the modal metadata; `fields` its field list (to find the class field's
118
+ * ref). `record` is the record as loaded (null on create).
119
+ */
120
+ export function useAttributeClasses(
121
+ api: ApiClient,
122
+ meta: { attribute_classes?: AttributeClass[]; attribute_class_field?: string; fields?: { key: string; ref?: string }[] } | null | undefined,
123
+ formValues: Record<string, unknown>,
124
+ record?: Record<string, unknown> | null,
125
+ ): string[] {
126
+ const hasClasses = Array.isArray(meta?.attribute_classes) && meta!.attribute_classes!.length > 0
127
+ const classField = meta?.attribute_class_field || 'category_id'
128
+ const model = refModel(meta?.fields?.find((f) => f.key === classField)?.ref) ?? 'Category'
129
+ const id = hasClasses ? formValues[classField] : undefined
130
+ const idStr = id == null || id === '' ? '' : String(id)
131
+ const [classes, setClasses] = useState<string[]>([])
132
+ useEffect(() => {
133
+ if (!idStr) {
134
+ setClasses([])
135
+ return
136
+ }
137
+ let alive = true
138
+ void resolveAttributeClasses(api, model, idStr).then((c) => {
139
+ if (alive) setClasses(c)
140
+ })
141
+ return () => {
142
+ alive = false
143
+ }
144
+ }, [api, model, idStr])
145
+ const carried = useMemo(() => classesCarriedByRecord(meta, record), [meta, record])
146
+ return useMemo(() => (carried.length ? [...new Set([...classes, ...carried])] : classes), [classes, carried])
147
+ }
@@ -60,7 +60,8 @@ import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-f
60
60
  import { DynamicMultiSelectField } from '../dynamic-multi-select-field'
61
61
  import { DynamicRelations } from '../dynamic-relations'
62
62
  import { useOptionsResolver, type ResolvedOption } from '../use-options-resolver'
63
- import { getFieldRef, getVisibleWhen, evaluateVisibleWhen } from '../dynamic-form-schema'
63
+ import { getFieldRef, getVisibleWhen, evaluateVisibleWhen, ATTRIBUTE_CLASSES_KEY } from '../dynamic-form-schema'
64
+ import { useAttributeClasses, type AttributeClass } from '../attribute-classes'
64
65
  import type { VisibleWhen } from '../types'
65
66
  import { groupFieldsBySection, type FormLayout } from '../form-layout'
66
67
  import { FieldSection, WizardProgress } from '../form-layout-ui'
@@ -203,6 +204,14 @@ interface ModalMetadata {
203
204
  form_layout?: FormLayout
204
205
  /** camelCase alias for `form_layout`. */
205
206
  formLayout?: FormLayout
207
+ /**
208
+ * Attribute classes of the model's extensions enabled for the org
209
+ * (CONTRACT-item-master §3.2); fields with `visible_when.class` show when
210
+ * the record's category carries the class.
211
+ */
212
+ attribute_classes?: AttributeClass[]
213
+ /** Field whose referenced record carries the classes (default category_id). */
214
+ attribute_class_field?: string
206
215
  /**
207
216
  * Backend-localized CRUD success messages (modal metadata). Preferred over
208
217
  * the raw response message which is not localized.
@@ -552,11 +561,13 @@ export function filterVisibleFields(
552
561
  fields: FieldDef[] | undefined,
553
562
  mode: 'view' | 'edit' | 'create',
554
563
  formValues?: Record<string, any>,
564
+ attributeClasses?: string[],
555
565
  ): FieldDef[] {
566
+ const values = formValues && attributeClasses ? { ...formValues, [ATTRIBUTE_CLASSES_KEY]: attributeClasses } : formValues
556
567
  return (fields ?? []).filter(f => {
557
568
  if (f.hidden) return false
558
569
  if (mode === 'create' && f.readonly) return false
559
- if (formValues && !evaluateVisibleWhen(getVisibleWhen(f), formValues)) return false
570
+ if (values && !evaluateVisibleWhen(getVisibleWhen(f), values)) return false
560
571
  return true
561
572
  })
562
573
  }
@@ -573,8 +584,9 @@ export function stripHiddenFieldValues(
573
584
  values: Record<string, any>,
574
585
  fields: FieldDef[] | undefined,
575
586
  mode: 'view' | 'edit' | 'create',
587
+ attributeClasses?: string[],
576
588
  ): Record<string, any> {
577
- const visibleKeys = new Set(filterVisibleFields(fields, mode, values).map(f => f.key))
589
+ const visibleKeys = new Set(filterVisibleFields(fields, mode, values, attributeClasses).map(f => f.key))
578
590
  const out: Record<string, any> = {}
579
591
  for (const [key, value] of Object.entries(values)) {
580
592
  const field = (fields ?? []).find(f => f.key === key)
@@ -632,6 +644,9 @@ export function DynamicRecordDialog({
632
644
  const [relations, setRelations] = useState<RelationMeta[]>([])
633
645
  const [record, setRecord] = useState<any | null>(null)
634
646
  const [formValues, setFormValues] = useState<Record<string, any>>({})
647
+ // Classes of the record's category (plus those its saved data carries), for
648
+ // `visible_when.class` fields.
649
+ const attributeClasses = useAttributeClasses(api, modalMeta, formValues, record)
635
650
  // Per-field validation errors (localized strings), keyed by field.key. Shown
636
651
  // inline under each input; populated from a 422 `errors` map or the client
637
652
  // required-field check, cleared per-field on change and wholesale on reopen.
@@ -846,7 +861,7 @@ export function DynamicRecordDialog({
846
861
  }
847
862
  setFieldErrors(next)
848
863
  const visibleKeys = new Set(
849
- filterVisibleFields(modalMeta?.fields ?? [], mode, formValues).map(f => f.key),
864
+ filterVisibleFields(modalMeta?.fields ?? [], mode, formValues, attributeClasses).map(f => f.key),
850
865
  )
851
866
  const orphans = Object.entries(next).filter(([k]) => !visibleKeys.has(k))
852
867
  const description = orphans.length
@@ -868,7 +883,7 @@ export function DynamicRecordDialog({
868
883
  if (isEditable) {
869
884
  // Laravel-style: collect every issue from the shared validator
870
885
  // (required + rule strings / min/max / email…) on visible fields only.
871
- const visible = filterVisibleFields(modalMeta.fields, mode, formValues)
886
+ const visible = filterVisibleFields(modalMeta.fields, mode, formValues, attributeClasses)
872
887
  let bag = validateValues(visible as ActionFieldDef[], formValues)
873
888
  // Edit: a legacy value re-sent unchanged is not re-judged by a rule
874
889
  // added after it was written (same grandfathering as the kernel).
@@ -909,7 +924,7 @@ export function DynamicRecordDialog({
909
924
  // filter, so a DiscountRule with scope=category never submits the
910
925
  // product_id / customer_id it isn't showing. Mirrors dynamic-form.tsx,
911
926
  // which builds its Zod only over visibleFields.
912
- const submittedValues = stripHiddenFieldValues(formValues, modalMeta.fields, mode)
927
+ const submittedValues = stripHiddenFieldValues(formValues, modalMeta.fields, mode, attributeClasses)
913
928
 
914
929
  // Empty reference pickers → null (not "" / nil-UUID) so nullable FK
915
930
  // columns accept them instead of raising a 23503 FK violation.
@@ -1002,7 +1017,7 @@ export function DynamicRecordDialog({
1002
1017
  ? 'Editar registro'
1003
1018
  : 'Ver registro'
1004
1019
 
1005
- const visibleFields = filterVisibleFields(modalMeta?.fields, mode, formValues)
1020
+ const visibleFields = filterVisibleFields(modalMeta?.fields, mode, formValues, attributeClasses)
1006
1021
 
1007
1022
  // Declarative form layout: group the (already visibility-filtered) fields by
1008
1023
  // their section. Empty sections drop out for free. Steps mode only drives a
@@ -404,9 +404,16 @@ export function getVisibleWhen(
404
404
  ): VisibleWhen | undefined {
405
405
  if (!field) return undefined
406
406
  const vw = field.visible_when ?? field.visibleWhen
407
- return vw && typeof vw === 'object' && typeof vw.field === 'string' ? vw : undefined
407
+ return vw && typeof vw === 'object' && (typeof vw.field === 'string' || typeof vw.class === 'string') ? vw : undefined
408
408
  }
409
409
 
410
+ /**
411
+ * Form-values key that carries the record's attribute classes (resolved from
412
+ * its category) so `visible_when.class` evaluates with the same pure function
413
+ * as the sibling-field predicates. Never submitted.
414
+ */
415
+ export const ATTRIBUTE_CLASSES_KEY = '__attribute_classes'
416
+
410
417
  /**
411
418
  * Evaluates a `visible_when` predicate against the current flat form values.
412
419
  * Pure — no React, no side effects.
@@ -426,6 +433,10 @@ export function evaluateVisibleWhen(
426
433
  cond: VisibleWhen | null | undefined,
427
434
  formValues: Record<string, any> | null | undefined,
428
435
  ): boolean {
436
+ if (cond && typeof cond.class === 'string' && cond.class !== '') {
437
+ const classes = formValues ? formValues[ATTRIBUTE_CLASSES_KEY] : undefined
438
+ return Array.isArray(classes) && classes.includes(cond.class)
439
+ }
429
440
  if (!cond || typeof cond.field !== 'string' || cond.field.trim() === '') return true
430
441
  const raw = formValues ? formValues[cond.field.trim()] : undefined
431
442
  const current = raw == null ? '' : String(raw)
@@ -513,6 +524,7 @@ export function evaluateVisibleWhenForListScope(
513
524
  cond: VisibleWhen | null | undefined,
514
525
  scope: Record<string, any> | null | undefined,
515
526
  ): boolean {
527
+ // A class predicate depends on each row's category: a list keeps the column.
516
528
  if (!cond || typeof cond.field !== 'string' || cond.field.trim() === '') return true
517
529
  const key = cond.field.trim()
518
530
  if (!scope || !(key in scope)) return true
package/src/index.ts CHANGED
@@ -129,6 +129,20 @@ export {
129
129
  type UseDynamicFiltersResult,
130
130
  } from './use-dynamic-filters'
131
131
  export { useDebouncedValue, SEARCH_DEBOUNCE_MS } from './use-debounced-value'
132
+ export {
133
+ useOptimisticMutation,
134
+ type UseOptimisticMutationOptions,
135
+ type UseOptimisticMutationResult,
136
+ } from './use-optimistic-mutation'
137
+ export { useFlipAnimation, type UseFlipAnimationOptions } from './use-flip-animation'
138
+ export {
139
+ motionDuration,
140
+ motionEasing,
141
+ prefersReducedMotion,
142
+ MOTION_DEFAULTS,
143
+ type MotionDuration,
144
+ type MotionEasing,
145
+ } from './motion'
132
146
  export {
133
147
  useResource,
134
148
  useMutation,
@@ -385,6 +399,19 @@ export {
385
399
  type ModelExtension,
386
400
  type ModelExtensionProps,
387
401
  } from './model-extension-registry'
402
+ export {
403
+ registerAgentResultRenderer,
404
+ resolveAgentResultRenderer,
405
+ listAgentResultRenderers,
406
+ clearAgentResultRenderers,
407
+ useAgentResultRegistryVersion,
408
+ AgentResultView,
409
+ type AgentResult,
410
+ type AgentResultRenderer,
411
+ type AgentResultRendererOptions,
412
+ type AgentResultRendererProps,
413
+ type AgentResultViewProps,
414
+ } from './agent-result-registry'
388
415
  export {
389
416
  isColumnVisibleInTable,
390
417
  isColumnVisibleInModal,
@@ -431,6 +458,7 @@ export {
431
458
  resolveOptionsSource,
432
459
  getVisibleWhen,
433
460
  evaluateVisibleWhen,
461
+ ATTRIBUTE_CLASSES_KEY,
434
462
  scopeValueFromFilterToken,
435
463
  buildListScopeValues,
436
464
  evaluateVisibleWhenForListScope,
@@ -519,3 +547,10 @@ export {
519
547
  } from './widgets/widget-format'
520
548
  export { AssistInterview, AssistCardView } from './assist-interview'
521
549
  export type { AssistSession, AssistTurn, AssistQuestion, AssistCard, AssistProgressStep } from './assist-interview'
550
+ export {
551
+ useAttributeClasses,
552
+ resolveAttributeClasses,
553
+ resetAttributeClassCache,
554
+ type AttributeClass,
555
+ type AttributeClassSection,
556
+ } from './attribute-classes'
package/src/motion.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Runtime access to the theme's motion tokens (`--motion-duration-*`,
3
+ * `--motion-ease-*` from @asteby/metacore-theme/tokens.css). Hosts that do
4
+ * not ship those variables get the same defaults, so JS-driven animations
5
+ * (WAAPI, dnd-kit) and CSS transitions stay on one scale.
6
+ */
7
+ export type MotionDuration = 'instant' | 'fast' | 'moderate' | 'slow'
8
+ export type MotionEasing = 'standard' | 'emphasized' | 'exit'
9
+
10
+ export const MOTION_DEFAULTS = {
11
+ duration: { instant: 100, fast: 150, moderate: 220, slow: 320 } as Record<MotionDuration, number>,
12
+ easing: {
13
+ standard: 'cubic-bezier(0.2, 0, 0, 1)',
14
+ emphasized: 'cubic-bezier(0.3, 0, 0, 1)',
15
+ exit: 'cubic-bezier(0.4, 0, 1, 1)',
16
+ } as Record<MotionEasing, string>,
17
+ }
18
+
19
+ function cssVar(name: string): string {
20
+ if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') return ''
21
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
22
+ }
23
+
24
+ /** True when the user asked the OS for reduced motion. */
25
+ export function prefersReducedMotion(): boolean {
26
+ return (
27
+ typeof window !== 'undefined' &&
28
+ typeof window.matchMedia === 'function' &&
29
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
30
+ )
31
+ }
32
+
33
+ /** Duration token in ms (0 under prefers-reduced-motion). */
34
+ export function motionDuration(token: MotionDuration): number {
35
+ if (prefersReducedMotion()) return 0
36
+ const raw = cssVar(`--motion-duration-${token}`)
37
+ if (raw) {
38
+ const n = parseFloat(raw)
39
+ if (Number.isFinite(n)) return raw.endsWith('ms') || !raw.endsWith('s') ? n : n * 1000
40
+ }
41
+ return MOTION_DEFAULTS.duration[token]
42
+ }
43
+
44
+ /** Easing token as a CSS timing function. */
45
+ export function motionEasing(token: MotionEasing): string {
46
+ return cssVar(`--motion-ease-${token}`) || MOTION_DEFAULTS.easing[token]
47
+ }
package/src/types.ts CHANGED
@@ -386,9 +386,16 @@ export interface ActionCondition {
386
386
  * values; a hidden field never gates submit (its required-check is skipped).
387
387
  */
388
388
  export interface VisibleWhen {
389
- field: string
389
+ field?: string
390
390
  equals?: string
391
391
  in?: string[]
392
+ /**
393
+ * Attribute class (kernel v3 `visible_when.class`, CONTRACT-item-master):
394
+ * the field shows when the record's category carries this class. Used
395
+ * alone, without `field`. Evaluated against the classes the dialog
396
+ * resolves for the record (see `useAttributeClasses`).
397
+ */
398
+ class?: string
392
399
  }
393
400
 
394
401
  // Write-time + client-side constraints. The kernel enforces these on
@@ -0,0 +1,157 @@
1
+ import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react'
2
+ import {
3
+ motionDuration,
4
+ motionEasing,
5
+ prefersReducedMotion,
6
+ type MotionDuration,
7
+ type MotionEasing,
8
+ } from './motion'
9
+
10
+ export interface UseFlipAnimationOptions {
11
+ /** Elements to animate, queried inside the root. */
12
+ selector?: string
13
+ /** Stable identity of an element across renders. Default: data-flip-key, then href, then text. */
14
+ keyOf?: (el: HTMLElement) => string | null
15
+ /**
16
+ * Scroll container used as the coordinate origin, so scrolling between two
17
+ * snapshots does not read as movement. Default: the root.
18
+ */
19
+ scrollContainer?: (root: HTMLElement) => HTMLElement | null
20
+ /** Motion token (default `moderate`) or explicit ms. */
21
+ duration?: MotionDuration | number
22
+ /** Motion token (default `standard`) or a CSS timing function. */
23
+ easing?: MotionEasing | string
24
+ /** Off switch; the hook also stays still under prefers-reduced-motion. */
25
+ disabled?: boolean
26
+ }
27
+
28
+ const DEFAULT_SELECTOR = '[data-flip-key]'
29
+ const MOTION_EASING_KEYS = { standard: 1, emphasized: 1, exit: 1 }
30
+
31
+ const defaultKeyOf = (el: HTMLElement): string | null =>
32
+ el.dataset.flipKey ?? el.getAttribute('href') ?? (el.textContent?.trim() || null)
33
+
34
+ type Positions = Map<string, { top: number; left: number }>
35
+
36
+ /**
37
+ * FLIP reorder animation: when `trigger` changes, elements that exist before
38
+ * and after the change glide from their old position to the new one.
39
+ *
40
+ * Positions are snapshotted when the list is at rest (after mount, after each
41
+ * animation, and after pointer interaction inside the root), never in the
42
+ * render path, so a re-render costs nothing. Uses the Web Animations API on
43
+ * `transform` only; with prefers-reduced-motion the change is instant.
44
+ */
45
+ export function useFlipAnimation(
46
+ rootRef: RefObject<HTMLElement | null>,
47
+ trigger: unknown,
48
+ options: UseFlipAnimationOptions = {},
49
+ ): void {
50
+ const {
51
+ selector = DEFAULT_SELECTOR,
52
+ keyOf = defaultKeyOf,
53
+ scrollContainer,
54
+ duration = 'moderate',
55
+ easing = 'standard',
56
+ disabled = false,
57
+ } = options
58
+ const positionsRef = useRef<Positions | null>(null)
59
+ const triggerRef = useRef(trigger)
60
+ const configRef = useRef({ selector, keyOf, scrollContainer })
61
+ useEffect(() => {
62
+ configRef.current = { selector, keyOf, scrollContainer }
63
+ })
64
+
65
+ const measure = (): Positions | null => {
66
+ const root = rootRef.current
67
+ if (!root) return null
68
+ const { selector: sel, keyOf: key, scrollContainer: sc } = configRef.current
69
+ const origin = (sc ? sc(root) : null) ?? root
70
+ const box = origin.getBoundingClientRect()
71
+ const out: Positions = new Map()
72
+ root.querySelectorAll<HTMLElement>(sel).forEach((el) => {
73
+ const k = key(el)
74
+ if (!k || out.has(k)) return
75
+ const r = el.getBoundingClientRect()
76
+ if (r.width === 0 && r.height === 0) return
77
+ out.set(k, {
78
+ top: r.top - box.top + origin.scrollTop,
79
+ left: r.left - box.left + origin.scrollLeft,
80
+ })
81
+ })
82
+ return out
83
+ }
84
+ const measureRef = useRef(measure)
85
+ useEffect(() => {
86
+ measureRef.current = measure
87
+ })
88
+
89
+ // Snapshot at rest: after mount and after interactions that move things
90
+ // without a trigger change (expanding a collapsible, for instance).
91
+ // Listens on the document and resolves the root per event, so a ref whose
92
+ // element is swapped (remount, mobile sheet) keeps working.
93
+ useEffect(() => {
94
+ let timer: ReturnType<typeof setTimeout> | null = null
95
+ const schedule = () => {
96
+ if (timer) clearTimeout(timer)
97
+ timer = setTimeout(() => {
98
+ positionsRef.current = measureRef.current()
99
+ }, 350)
100
+ }
101
+ const onInteraction = (e: Event) => {
102
+ const root = rootRef.current
103
+ if (root && e.target instanceof Node && root.contains(e.target)) schedule()
104
+ }
105
+ schedule()
106
+ document.addEventListener('pointerup', onInteraction, true)
107
+ document.addEventListener('keyup', onInteraction, true)
108
+ window.addEventListener('resize', schedule)
109
+ return () => {
110
+ if (timer) clearTimeout(timer)
111
+ document.removeEventListener('pointerup', onInteraction, true)
112
+ document.removeEventListener('keyup', onInteraction, true)
113
+ window.removeEventListener('resize', schedule)
114
+ }
115
+ }, [rootRef])
116
+
117
+ useLayoutEffect(() => {
118
+ if (Object.is(triggerRef.current, trigger)) return
119
+ triggerRef.current = trigger
120
+ const before = positionsRef.current
121
+ const root = rootRef.current
122
+ if (!root) return
123
+ const after = measureRef.current()
124
+ positionsRef.current = after
125
+ if (!before || !after || disabled || prefersReducedMotion()) return
126
+ const durationMs = typeof duration === 'number' ? duration : motionDuration(duration)
127
+ if (durationMs <= 0) return
128
+ const timing = easing in MOTION_EASING_KEYS ? motionEasing(easing as MotionEasing) : easing
129
+
130
+ const { selector: sel, keyOf: key } = configRef.current
131
+ // A stale snapshot can report huge jumps; those would read as a glitch.
132
+ const maxJump = root.clientHeight > 0 ? root.clientHeight * 1.5 : Infinity
133
+ root.querySelectorAll<HTMLElement>(sel).forEach((el) => {
134
+ const k = key(el)
135
+ if (!k) return
136
+ const from = before.get(k)
137
+ const to = after.get(k)
138
+ if (!to || typeof el.animate !== 'function') return
139
+ if (!from) {
140
+ // Entered with this change: fade in instead of popping.
141
+ el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: durationMs, easing: timing })
142
+ return
143
+ }
144
+ const dy = from.top - to.top
145
+ const dx = from.left - to.left
146
+ if (Math.abs(dy) < 1 && Math.abs(dx) < 1) return
147
+ if (Math.abs(dy) > maxJump) return
148
+ el.animate(
149
+ [
150
+ { transform: `translate(${dx}px, ${dy}px)` },
151
+ { transform: 'translate(0, 0)' },
152
+ ],
153
+ { duration: durationMs, easing: timing },
154
+ )
155
+ })
156
+ }, [trigger, rootRef, disabled, duration, easing])
157
+ }