@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.
- package/CHANGELOG.md +36 -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 +46 -0
- package/dist/attribute-classes.d.ts.map +1 -0
- package/dist/attribute-classes.js +124 -0
- package/dist/dialogs/dynamic-record.d.ts +2 -2
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +14 -9
- package/dist/dynamic-form-schema.d.ts +6 -0
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +12 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/motion.d.ts +19 -0
- package/dist/motion.d.ts.map +1 -0
- package/dist/motion.js +35 -0
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- 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 +79 -0
- package/src/attribute-classes.ts +147 -0
- package/src/dialogs/dynamic-record.tsx +22 -7
- package/src/dynamic-form-schema.ts +13 -1
- package/src/index.ts +35 -0
- package/src/motion.ts +47 -0
- package/src/types.ts +8 -1
- package/src/use-flip-animation.ts +157 -0
- package/src/use-optimistic-mutation.ts +218 -0
- 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 (
|
|
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
|
|
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
|
+
}
|