@asteby/metacore-runtime-react 39.3.0 → 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.
- package/CHANGELOG.md +30 -0
- package/dist/agent-result-registry.d.ts +44 -0
- package/dist/agent-result-registry.d.ts.map +1 -0
- package/dist/agent-result-registry.js +78 -0
- package/dist/attribute-classes.d.ts +18 -3
- package/dist/attribute-classes.d.ts.map +1 -1
- package/dist/attribute-classes.js +47 -6
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +3 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/motion.d.ts +19 -0
- package/dist/motion.d.ts.map +1 -0
- package/dist/motion.js +35 -0
- package/dist/use-flip-animation.d.ts +30 -0
- package/dist/use-flip-animation.d.ts.map +1 -0
- package/dist/use-flip-animation.js +123 -0
- package/dist/use-optimistic-mutation.d.ts +73 -0
- package/dist/use-optimistic-mutation.d.ts.map +1 -0
- package/dist/use-optimistic-mutation.js +153 -0
- package/package.json +3 -3
- package/src/__tests__/motion.test.ts +29 -0
- package/src/__tests__/use-flip-animation.test.tsx +109 -0
- package/src/__tests__/use-optimistic-mutation.test.tsx +201 -0
- package/src/agent-result-registry.test.tsx +52 -0
- package/src/agent-result-registry.tsx +129 -0
- package/src/attribute-classes.test.ts +37 -1
- package/src/attribute-classes.ts +47 -5
- package/src/dialogs/dynamic-record.tsx +3 -2
- package/src/index.ts +27 -0
- package/src/motion.ts +47 -0
- package/src/use-flip-animation.ts +157 -0
- package/src/use-optimistic-mutation.ts +218 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Agent result renderer registry. An agent reply carries structured tool
|
|
2
|
+
// results (a record, a filtered view, a count, a chart, a plan of changes, a
|
|
3
|
+
// suggested action…) next to its prose. Each result declares a `kind`; the
|
|
4
|
+
// host renders it through whatever component is registered for that kind, so
|
|
5
|
+
// the chat never prints a raw tool payload and an addon can teach the chat
|
|
6
|
+
// to render its own results without touching the host:
|
|
7
|
+
//
|
|
8
|
+
// import { registerAgentResultRenderer } from '@asteby/metacore-runtime-react'
|
|
9
|
+
//
|
|
10
|
+
// registerAgentResultRenderer('record', RecordCard) // any model
|
|
11
|
+
// registerAgentResultRenderer('record', InvoiceCard, { model: 'invoices' })
|
|
12
|
+
// registerAgentResultRenderer('shipment_quote', QuoteCard) // addon kind
|
|
13
|
+
//
|
|
14
|
+
// Resolution is most specific first: `kind` + `model`, then `kind`. A result
|
|
15
|
+
// with no renderer renders nothing (never a JSON dump) unless the caller
|
|
16
|
+
// passes a fallback.
|
|
17
|
+
//
|
|
18
|
+
// Module-level singleton like the model-extension registry, but observable:
|
|
19
|
+
// federated addons register after the host has already painted, so views
|
|
20
|
+
// subscribe and re-render when a renderer lands.
|
|
21
|
+
import * as React from 'react'
|
|
22
|
+
|
|
23
|
+
/** A structured tool result shown under an agent reply. */
|
|
24
|
+
export interface AgentResult {
|
|
25
|
+
/** Result type, e.g. record | view | count | chart | image | plan | proposal | action. */
|
|
26
|
+
kind: string
|
|
27
|
+
/** Model key the result belongs to, when it has one. */
|
|
28
|
+
model?: string
|
|
29
|
+
[key: string]: unknown
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AgentResultRendererProps<R extends AgentResult = AgentResult> {
|
|
33
|
+
result: R
|
|
34
|
+
/**
|
|
35
|
+
* Interactive results (approve a plan, launch a guide) report back through
|
|
36
|
+
* here; the host decides what an action means.
|
|
37
|
+
*/
|
|
38
|
+
onAction?: (action: string, payload?: unknown) => void
|
|
39
|
+
/** Host is processing an action for this result. */
|
|
40
|
+
busy?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type AgentResultRenderer<R extends AgentResult = AgentResult> = React.ComponentType<
|
|
44
|
+
AgentResultRendererProps<R>
|
|
45
|
+
>
|
|
46
|
+
|
|
47
|
+
export interface AgentResultRendererOptions {
|
|
48
|
+
/** Only for results of this model (overrides the generic renderer of the kind). */
|
|
49
|
+
model?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const renderers = new Map<string, AgentResultRenderer<any>>()
|
|
53
|
+
const listeners = new Set<() => void>()
|
|
54
|
+
let version = 0
|
|
55
|
+
|
|
56
|
+
function keyOf(kind: string, model?: string): string {
|
|
57
|
+
return model ? `${kind}:${model}` : kind
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function emit(): void {
|
|
61
|
+
version++
|
|
62
|
+
listeners.forEach((l) => l())
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Registers the component that renders results of `kind` (optionally only
|
|
67
|
+
* for one model). Returns an unregister function; a later registration for
|
|
68
|
+
* the same key replaces the earlier one.
|
|
69
|
+
*/
|
|
70
|
+
export function registerAgentResultRenderer<R extends AgentResult = AgentResult>(
|
|
71
|
+
kind: string,
|
|
72
|
+
renderer: AgentResultRenderer<R>,
|
|
73
|
+
options: AgentResultRendererOptions = {},
|
|
74
|
+
): () => void {
|
|
75
|
+
const key = keyOf(kind, options.model)
|
|
76
|
+
renderers.set(key, renderer)
|
|
77
|
+
emit()
|
|
78
|
+
return () => {
|
|
79
|
+
if (renderers.get(key) === renderer) {
|
|
80
|
+
renderers.delete(key)
|
|
81
|
+
emit()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Most specific renderer for a result: kind+model, then kind. */
|
|
87
|
+
export function resolveAgentResultRenderer(
|
|
88
|
+
result: Pick<AgentResult, 'kind' | 'model'>,
|
|
89
|
+
): AgentResultRenderer | undefined {
|
|
90
|
+
if (!result?.kind) return undefined
|
|
91
|
+
return (result.model && renderers.get(keyOf(result.kind, result.model))) || renderers.get(result.kind)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Registered keys (`kind` or `kind:model`), for diagnostics. */
|
|
95
|
+
export function listAgentResultRenderers(): string[] {
|
|
96
|
+
return [...renderers.keys()]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function clearAgentResultRenderers(): void {
|
|
100
|
+
renderers.clear()
|
|
101
|
+
emit()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function subscribe(listener: () => void): () => void {
|
|
105
|
+
listeners.add(listener)
|
|
106
|
+
return () => listeners.delete(listener)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Re-renders the caller whenever a renderer is (un)registered. */
|
|
110
|
+
export function useAgentResultRegistryVersion(): number {
|
|
111
|
+
return React.useSyncExternalStore(
|
|
112
|
+
subscribe,
|
|
113
|
+
() => version,
|
|
114
|
+
() => version,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface AgentResultViewProps extends AgentResultRendererProps {
|
|
119
|
+
/** Rendered when no renderer matches. Defaults to nothing. */
|
|
120
|
+
fallback?: React.ReactNode
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Renders one agent result through the registry. */
|
|
124
|
+
export function AgentResultView({ result, fallback = null, ...rest }: AgentResultViewProps) {
|
|
125
|
+
useAgentResultRegistryVersion()
|
|
126
|
+
const Renderer = resolveAgentResultRenderer(result)
|
|
127
|
+
if (!Renderer) return <>{fallback}</>
|
|
128
|
+
return <Renderer result={result} {...rest} />
|
|
129
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import { resolveAttributeClasses, resetAttributeClassCache } from './attribute-classes'
|
|
2
|
+
import { classesCarriedByRecord, resolveAttributeClasses, resetAttributeClassCache } from './attribute-classes'
|
|
3
3
|
import { ATTRIBUTE_CLASSES_KEY, evaluateVisibleWhen, getVisibleWhen } from './dynamic-form-schema'
|
|
4
4
|
import { filterVisibleFields } from './dialogs/dynamic-record'
|
|
5
5
|
|
|
@@ -41,3 +41,39 @@ describe('resolveAttributeClasses', () => {
|
|
|
41
41
|
expect(get).toHaveBeenCalledWith('/data/Category/c1')
|
|
42
42
|
})
|
|
43
43
|
})
|
|
44
|
+
|
|
45
|
+
describe('classesCarriedByRecord', () => {
|
|
46
|
+
const meta = {
|
|
47
|
+
attribute_classes: [
|
|
48
|
+
{ key: 'tire', sections: [{ key: 'size', fields: ['section_width_mm', 'rim_diameter_in'] }] },
|
|
49
|
+
{ key: 'battery', sections: [{ key: 'b', fields: ['capacity_ah'] }] },
|
|
50
|
+
],
|
|
51
|
+
fields: [
|
|
52
|
+
{ key: 'name' },
|
|
53
|
+
{ key: 'TireSpec.section_width_mm' },
|
|
54
|
+
{ key: 'TireSpec.rim_diameter_in' },
|
|
55
|
+
{ key: 'BatterySpec.capacity_ah' },
|
|
56
|
+
],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
it('a saved tire with a spec sheet keeps its fields without a category class', () => {
|
|
60
|
+
expect(classesCarriedByRecord(meta, { name: 'x', 'TireSpec.rim_diameter_in': 16 })).toEqual(['tire'])
|
|
61
|
+
// nested shape
|
|
62
|
+
expect(classesCarriedByRecord(meta, { TireSpec: { section_width_mm: 205 } })).toEqual(['tire'])
|
|
63
|
+
const fields = [
|
|
64
|
+
{ key: 'name', label: 'Nombre', type: 'text' },
|
|
65
|
+
{ key: 'TireSpec.rim_diameter_in', label: 'Rin', type: 'number', visible_when: { class: 'tire' } },
|
|
66
|
+
] as never
|
|
67
|
+
const carried = classesCarriedByRecord(meta, { 'TireSpec.rim_diameter_in': 16 })
|
|
68
|
+
expect(filterVisibleFields(fields, 'edit', { name: 'x' }, carried).map((f) => f.key)).toEqual([
|
|
69
|
+
'name',
|
|
70
|
+
'TireSpec.rim_diameter_in',
|
|
71
|
+
])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('a record without data in the class fields carries nothing', () => {
|
|
75
|
+
expect(classesCarriedByRecord(meta, { name: 'x', 'TireSpec.rim_diameter_in': null, 'TireSpec.section_width_mm': '' })).toEqual([])
|
|
76
|
+
expect(classesCarriedByRecord(meta, null)).toEqual([])
|
|
77
|
+
expect(classesCarriedByRecord({ fields: meta.fields }, { 'TireSpec.rim_diameter_in': 16 })).toEqual([])
|
|
78
|
+
})
|
|
79
|
+
})
|
package/src/attribute-classes.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// Attribute classes (CONTRACT-item-master §3.2): an extension table's columns
|
|
2
2
|
// are grouped into classes, and each organization assigns classes to its
|
|
3
3
|
// categories. A field with `visible_when.class` shows when the record's
|
|
4
|
-
// category (or one of its parents) carries the class
|
|
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).
|
|
5
6
|
//
|
|
6
7
|
// The host serves the classes on the modal metadata (`attribute_classes`) and,
|
|
7
8
|
// optionally, the field whose referenced record carries them
|
|
8
9
|
// (`attribute_class_field`, default `category_id`). The referenced record
|
|
9
10
|
// exposes its classes as `attribute_classes` (array of keys) and its parent as
|
|
10
11
|
// `parent_id`.
|
|
11
|
-
import { useEffect, useState } from 'react'
|
|
12
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
12
13
|
import type { ApiClient } from './api-context'
|
|
13
14
|
|
|
14
15
|
export interface AttributeClassSection {
|
|
@@ -73,14 +74,54 @@ export function resetAttributeClassCache(): void {
|
|
|
73
74
|
cache.clear()
|
|
74
75
|
}
|
|
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
|
+
|
|
76
114
|
/**
|
|
77
|
-
* The classes that apply to the form
|
|
78
|
-
*
|
|
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).
|
|
79
119
|
*/
|
|
80
120
|
export function useAttributeClasses(
|
|
81
121
|
api: ApiClient,
|
|
82
122
|
meta: { attribute_classes?: AttributeClass[]; attribute_class_field?: string; fields?: { key: string; ref?: string }[] } | null | undefined,
|
|
83
123
|
formValues: Record<string, unknown>,
|
|
124
|
+
record?: Record<string, unknown> | null,
|
|
84
125
|
): string[] {
|
|
85
126
|
const hasClasses = Array.isArray(meta?.attribute_classes) && meta!.attribute_classes!.length > 0
|
|
86
127
|
const classField = meta?.attribute_class_field || 'category_id'
|
|
@@ -101,5 +142,6 @@ export function useAttributeClasses(
|
|
|
101
142
|
alive = false
|
|
102
143
|
}
|
|
103
144
|
}, [api, model, idStr])
|
|
104
|
-
|
|
145
|
+
const carried = useMemo(() => classesCarriedByRecord(meta, record), [meta, record])
|
|
146
|
+
return useMemo(() => (carried.length ? [...new Set([...classes, ...carried])] : classes), [classes, carried])
|
|
105
147
|
}
|
|
@@ -644,8 +644,9 @@ export function DynamicRecordDialog({
|
|
|
644
644
|
const [relations, setRelations] = useState<RelationMeta[]>([])
|
|
645
645
|
const [record, setRecord] = useState<any | null>(null)
|
|
646
646
|
const [formValues, setFormValues] = useState<Record<string, any>>({})
|
|
647
|
-
// Classes of the record's category, for
|
|
648
|
-
|
|
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)
|
|
649
650
|
// Per-field validation errors (localized strings), keyed by field.key. Shown
|
|
650
651
|
// inline under each input; populated from a 422 `errors` map or the client
|
|
651
652
|
// required-field check, cleared per-field on change and wholesale on reopen.
|
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,
|
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
|
+
}
|
|
@@ -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
|
+
}
|