Improve avatar expression and drag interactions
This commit is contained in:
parent
99300b3b30
commit
698d21feb1
@ -1,7 +1,7 @@
|
|||||||
import { validateAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
|
import { validateAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
|
||||||
import { Avatar, type AvatarController } from '@bible-strong/avatar-react'
|
import { Avatar } from '@bible-strong/avatar-react'
|
||||||
import '@bible-strong/avatar-react/styles.css'
|
import '@bible-strong/avatar-react/styles.css'
|
||||||
import { StrictMode, useRef, useState } from 'react'
|
import { StrictMode, useState } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
import definitionJson from './strobi.avatar.json'
|
import definitionJson from './strobi.avatar.json'
|
||||||
@ -10,39 +10,79 @@ import './styles.css'
|
|||||||
const validation = validateAvatarDefinition(definitionJson)
|
const validation = validateAvatarDefinition(definitionJson)
|
||||||
if (!validation.ok) throw new Error(validation.errors[0]?.message)
|
if (!validation.ok) throw new Error(validation.errors[0]?.message)
|
||||||
const definition = validation.value as AvatarDefinition
|
const definition = validation.value as AvatarDefinition
|
||||||
|
const expressions = Object.keys(definition.expressions)
|
||||||
|
const horizontalPosition = (expression: string) =>
|
||||||
|
expression.includes('left') ? 'left' : expression.includes('right') ? 'right' : 'center'
|
||||||
|
const verticalPosition = (expression: string) =>
|
||||||
|
expression.includes('up') ? 'up' : expression.includes('down') ? 'down' : 'middle'
|
||||||
|
const expressionGroups = [
|
||||||
|
['up', 'left', 'Up left'],
|
||||||
|
['up', 'center', 'Up'],
|
||||||
|
['up', 'right', 'Up right'],
|
||||||
|
['middle', 'left', 'Left'],
|
||||||
|
['middle', 'center', 'Neutral'],
|
||||||
|
['middle', 'right', 'Right'],
|
||||||
|
['down', 'left', 'Down left'],
|
||||||
|
['down', 'center', 'Down'],
|
||||||
|
['down', 'right', 'Down right'],
|
||||||
|
].map(([vertical, horizontal, label]) => ({
|
||||||
|
key: `${vertical}-${horizontal}`,
|
||||||
|
label,
|
||||||
|
expressions: expressions.filter(
|
||||||
|
expression =>
|
||||||
|
verticalPosition(expression) === vertical && horizontalPosition(expression) === horizontal
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const formatExpressionName = (expression: string) =>
|
||||||
|
expression.replaceAll('-', ' ').replace(/\b\w/g, letter => letter.toUpperCase())
|
||||||
|
|
||||||
function Demo() {
|
function Demo() {
|
||||||
const controller = useRef<AvatarController>(null)
|
const [expression, setExpression] = useState('neutral')
|
||||||
const [status, setStatus] = useState('neutral')
|
|
||||||
const play = () => {
|
|
||||||
const result = controller.current?.play('idle')
|
|
||||||
setStatus(result?.ok ? 'idle' : (result?.error.code ?? 'unavailable'))
|
|
||||||
}
|
|
||||||
const setNeutral = () => {
|
|
||||||
const result = controller.current?.setExpression('neutral')
|
|
||||||
setStatus(result?.ok ? 'neutral' : (result?.error.code ?? 'unavailable'))
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<h1>Avatar package consumer</h1>
|
<h1>Avatar package consumer</h1>
|
||||||
<section className="avatar-host" aria-label="Embedded avatar example">
|
<section className="avatar-host" aria-label="Embedded avatar example">
|
||||||
<Avatar
|
<Avatar
|
||||||
ref={controller}
|
|
||||||
definition={definition}
|
definition={definition}
|
||||||
|
expression={expression}
|
||||||
size={240}
|
size={240}
|
||||||
onExpressionChange={setStatus}
|
|
||||||
ariaLabel="Embedded Strobi avatar"
|
ariaLabel="Embedded Strobi avatar"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
<button type="button" onClick={play}>
|
<section className="expression-picker" aria-labelledby="expression-picker-title">
|
||||||
Play idle
|
<div className="expression-picker__heading">
|
||||||
</button>
|
<h2 id="expression-picker-title">Available expressions ({expressions.length})</h2>
|
||||||
<button type="button" onClick={setNeutral}>
|
<output aria-live="polite">Active: {formatExpressionName(expression)}</output>
|
||||||
Set neutral
|
</div>
|
||||||
</button>
|
<div className="expression-picker__list">
|
||||||
<output aria-live="polite">{status}</output>
|
{expressionGroups.map(group => (
|
||||||
|
<section
|
||||||
|
key={group.key}
|
||||||
|
className={`expression-picker__group expression-picker__group--${group.key}`}
|
||||||
|
>
|
||||||
|
<h3>{group.label}</h3>
|
||||||
|
<div className="expression-picker__group-list">
|
||||||
|
{group.expressions.map(key => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
className={key === expression ? 'is-selected' : undefined}
|
||||||
|
aria-pressed={key === expression}
|
||||||
|
onClick={() => setExpression(key)}
|
||||||
|
>
|
||||||
|
{formatExpressionName(key)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<Avatar
|
<Avatar
|
||||||
definition={definition}
|
definition={definition}
|
||||||
|
expression={expression}
|
||||||
mode="floating"
|
mode="floating"
|
||||||
draggable
|
draggable
|
||||||
size={128}
|
size={128}
|
||||||
|
|||||||
@ -27,6 +27,136 @@ main {
|
|||||||
background: white;
|
background: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.expression-picker {
|
||||||
|
width: min(100%, 720px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker output {
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas:
|
||||||
|
'up-left up-center up-right'
|
||||||
|
'middle-left middle-center middle-right'
|
||||||
|
'down-left down-center down-right';
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
grid-template-rows: 1fr 3fr 1fr;
|
||||||
|
align-items: center;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
min-height: 640px;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, #ffffff 0 28%, #eef2ff 28.25% 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--up-left {
|
||||||
|
grid-area: up-left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--up-center {
|
||||||
|
grid-area: up-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--up-right {
|
||||||
|
grid-area: up-right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--middle-left {
|
||||||
|
grid-area: middle-left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--middle-center {
|
||||||
|
grid-area: middle-center;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 8px 24px rgb(15 23 42 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--middle-center .expression-picker__group-list {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--middle-right {
|
||||||
|
grid-area: middle-right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--down-left {
|
||||||
|
grid-area: down-left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--down-center {
|
||||||
|
grid-area: down-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--down-right {
|
||||||
|
grid-area: down-right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #1e293b;
|
||||||
|
background: white;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
background-color 160ms ease,
|
||||||
|
color 160ms ease,
|
||||||
|
transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group button:hover {
|
||||||
|
border-color: #818cf8;
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
@ -38,7 +168,46 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button.is-selected {
|
||||||
|
border-color: #312e81;
|
||||||
|
background: #312e81;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 0 0 3px #c7d2fe;
|
||||||
|
}
|
||||||
|
|
||||||
button:focus-visible {
|
button:focus-visible {
|
||||||
outline: 3px solid #4338ca;
|
outline: 3px solid #4338ca;
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.expression-picker__heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__list {
|
||||||
|
grid-template-areas: none;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: none;
|
||||||
|
aspect-ratio: auto;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group {
|
||||||
|
grid-area: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--middle-center {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -84,6 +84,30 @@ describe('@bible-strong/avatar-core', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('interpolates and completes a direct expression transition', () => {
|
||||||
|
const state = {
|
||||||
|
...createAvatarPlaybackState(),
|
||||||
|
activeExpression: 'curious-left',
|
||||||
|
status: 'playing' as const,
|
||||||
|
directTransition: {
|
||||||
|
from: 'neutral',
|
||||||
|
startedAt: 1_000,
|
||||||
|
durationMs: 400,
|
||||||
|
transition: 'smooth' as const,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const start = renderAvatarFrame(definition, state, 1_000, { random: () => 0.5 })
|
||||||
|
const midway = renderAvatarFrame(definition, state, 1_200, { random: () => 0.5 })
|
||||||
|
const end = renderAvatarFrame(definition, state, 1_400, { random: () => 0.5 })
|
||||||
|
|
||||||
|
expect(midway.geometry).not.toEqual(start.geometry)
|
||||||
|
expect(midway.geometry).not.toEqual(end.geometry)
|
||||||
|
expect(advanceAvatarPlayback(definition, state, 1_400, { random: () => 0.5 })).toMatchObject({
|
||||||
|
activeExpression: 'curious-left',
|
||||||
|
status: 'stopped',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('generates the same geometry through the public definition adapter', () => {
|
it('generates the same geometry through the public definition adapter', () => {
|
||||||
const scene = renderAvatarDefinition(definition, 'curious-left')
|
const scene = renderAvatarDefinition(definition, 'curious-left')
|
||||||
const body = bodyFromDefinition(definition.body)
|
const body = bodyFromDefinition(definition.body)
|
||||||
|
|||||||
@ -74,6 +74,12 @@ export type AvatarPlaybackState = {
|
|||||||
pausedAt?: number
|
pausedAt?: number
|
||||||
blinkDueAt?: number
|
blinkDueAt?: number
|
||||||
blinkStartedAt?: number
|
blinkStartedAt?: number
|
||||||
|
directTransition?: {
|
||||||
|
from: ExpressionKey
|
||||||
|
startedAt: number
|
||||||
|
durationMs: number
|
||||||
|
transition: AvatarAnimationDefinition['steps'][number]['transition']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AvatarRuntimeEnvironment = {
|
export type AvatarRuntimeEnvironment = {
|
||||||
@ -141,6 +147,13 @@ export const advanceAvatarPlayback = (
|
|||||||
now: number,
|
now: number,
|
||||||
environment: AvatarRuntimeEnvironment
|
environment: AvatarRuntimeEnvironment
|
||||||
): AvatarPlaybackState => {
|
): AvatarPlaybackState => {
|
||||||
|
if (state.directTransition) {
|
||||||
|
if (now < state.directTransition.startedAt + state.directTransition.durationMs) {
|
||||||
|
return { ...state }
|
||||||
|
}
|
||||||
|
const { directTransition: _directTransition, ...next } = state
|
||||||
|
return { ...next, status: 'stopped' }
|
||||||
|
}
|
||||||
if (state.status !== 'playing' || !state.activeAnimation) return { ...state }
|
if (state.status !== 'playing' || !state.activeAnimation) return { ...state }
|
||||||
const resolved = resolveAnimation(definition, state.activeAnimation)
|
const resolved = resolveAnimation(definition, state.activeAnimation)
|
||||||
if (!resolved.ok || !resolved.value.steps.length) return { ...createAvatarPlaybackState() }
|
if (!resolved.ok || !resolved.value.steps.length) return { ...createAvatarPlaybackState() }
|
||||||
@ -241,7 +254,21 @@ export const renderAvatarFrame = (
|
|||||||
)
|
)
|
||||||
let expression = expressionFromDefinition(state.activeExpression, targetDefinition)
|
let expression = expressionFromDefinition(state.activeExpression, targetDefinition)
|
||||||
let blink = 1
|
let blink = 1
|
||||||
if (state.activeAnimation) {
|
if (state.directTransition && !environment.reduceMotion) {
|
||||||
|
const fromDefinition = definition.expressions[state.directTransition.from]
|
||||||
|
if (fromDefinition) {
|
||||||
|
const from = expressionFromDefinition(state.directTransition.from, fromDefinition)
|
||||||
|
const progress = easing(
|
||||||
|
state.directTransition.transition,
|
||||||
|
(now - state.directTransition.startedAt) / Math.max(state.directTransition.durationMs, 1)
|
||||||
|
)
|
||||||
|
expression = interpolatePose(
|
||||||
|
poseFromExpression(from),
|
||||||
|
poseFromExpression(expression),
|
||||||
|
progress
|
||||||
|
).expression
|
||||||
|
}
|
||||||
|
} else if (state.activeAnimation) {
|
||||||
const resolved = resolveAnimation(definition, state.activeAnimation)
|
const resolved = resolveAnimation(definition, state.activeAnimation)
|
||||||
if (resolved.ok) {
|
if (resolved.ok) {
|
||||||
const step = resolved.value.steps[state.stepIndex]
|
const step = resolved.value.steps[state.stepIndex]
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import { createPortal } from 'react-dom'
|
|||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
const validatedDefinitions = new WeakSet<object>()
|
const validatedDefinitions = new WeakSet<object>()
|
||||||
|
const controlledExpressionTransitionMs = 420
|
||||||
|
|
||||||
const assertValidDefinition = (definition: AvatarDefinition) => {
|
const assertValidDefinition = (definition: AvatarDefinition) => {
|
||||||
if (validatedDefinitions.has(definition)) return
|
if (validatedDefinitions.has(definition)) return
|
||||||
@ -107,7 +108,11 @@ const samePlayback = (left: CorePlaybackState, right: CorePlaybackState) =>
|
|||||||
left.phaseStartedAt === right.phaseStartedAt &&
|
left.phaseStartedAt === right.phaseStartedAt &&
|
||||||
left.transitionFrom === right.transitionFrom &&
|
left.transitionFrom === right.transitionFrom &&
|
||||||
left.blinkDueAt === right.blinkDueAt &&
|
left.blinkDueAt === right.blinkDueAt &&
|
||||||
left.blinkStartedAt === right.blinkStartedAt
|
left.blinkStartedAt === right.blinkStartedAt &&
|
||||||
|
left.directTransition?.from === right.directTransition?.from &&
|
||||||
|
left.directTransition?.startedAt === right.directTransition?.startedAt &&
|
||||||
|
left.directTransition?.durationMs === right.directTransition?.durationMs &&
|
||||||
|
left.directTransition?.transition === right.directTransition?.transition
|
||||||
|
|
||||||
const samePosition = (left: AvatarPosition, right: AvatarPosition) =>
|
const samePosition = (left: AvatarPosition, right: AvatarPosition) =>
|
||||||
left.x === right.x && left.y === right.y
|
left.x === right.x && left.y === right.y
|
||||||
@ -332,7 +337,24 @@ export function Avatar({
|
|||||||
if (expression !== undefined) {
|
if (expression !== undefined) {
|
||||||
const resolved = resolveExpression(definition, expression)
|
const resolved = resolveExpression(definition, expression)
|
||||||
if (resolved.ok) {
|
if (resolved.ok) {
|
||||||
setPlayback({ ...createAvatarPlaybackState(), activeExpression: expression })
|
const current = playbackRef.current
|
||||||
|
const next = {
|
||||||
|
...createAvatarPlaybackState(),
|
||||||
|
activeExpression: expression,
|
||||||
|
...(current.activeExpression === expression
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
status: 'playing' as const,
|
||||||
|
directTransition: {
|
||||||
|
from: current.activeExpression,
|
||||||
|
startedAt: performance.now(),
|
||||||
|
durationMs: controlledExpressionTransitionMs,
|
||||||
|
transition: 'smooth' as const,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
playbackRef.current = next
|
||||||
|
setPlayback(next)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -470,6 +492,12 @@ export function Avatar({
|
|||||||
|
|
||||||
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
if (!draggable || event.button !== 0) return
|
if (!draggable || event.button !== 0) return
|
||||||
|
if (
|
||||||
|
mode === 'floating' &&
|
||||||
|
!(event.target instanceof Element && event.target.closest('.bs-avatar__drag-grip'))
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
event.currentTarget.setPointerCapture(event.pointerId)
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
drag.current = {
|
drag.current = {
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
@ -545,7 +573,9 @@ export function Avatar({
|
|||||||
role={draggable ? 'group' : 'img'}
|
role={draggable ? 'group' : 'img'}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-description={
|
aria-description={
|
||||||
draggable ? 'Use arrow keys or move controls to reposition the avatar.' : undefined
|
draggable
|
||||||
|
? 'Use the drag handle, arrow keys or move controls to reposition the avatar.'
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
tabIndex={draggable ? 0 : undefined}
|
tabIndex={draggable ? 0 : undefined}
|
||||||
onPointerDown={startDrag}
|
onPointerDown={startDrag}
|
||||||
@ -565,6 +595,7 @@ export function Avatar({
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{draggable && <div className="bs-avatar__drag-grip" aria-hidden="true" title="Drag avatar" />}
|
||||||
<svg className="bs-avatar__svg" viewBox="-150 -150 300 300" aria-hidden="true">
|
<svg className="bs-avatar__svg" viewBox="-150 -150 300 300" aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
<clipPath id={clipId}>
|
<clipPath id={clipId}>
|
||||||
|
|||||||
@ -137,6 +137,18 @@ describe('@bible-strong/avatar-react', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('smoothly transitions when a controlled expression changes', () => {
|
||||||
|
const controller = createRef<AvatarController>()
|
||||||
|
const view = render(<Avatar definition={definition} expression="neutral" ref={controller} />)
|
||||||
|
|
||||||
|
view.rerender(<Avatar definition={definition} expression="smile" ref={controller} />)
|
||||||
|
|
||||||
|
expect(controller.current?.getState()).toEqual({
|
||||||
|
activeExpression: 'smile',
|
||||||
|
status: 'playing',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('rejects simultaneous controlled animation and expression props', () => {
|
it('rejects simultaneous controlled animation and expression props', () => {
|
||||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
expect(() =>
|
expect(() =>
|
||||||
@ -226,6 +238,29 @@ describe('@bible-strong/avatar-react', () => {
|
|||||||
expect(previews.length).toBeLessThanOrEqual(1)
|
expect(previews.length).toBeLessThanOrEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the visible grip as the floating avatar drag target', () => {
|
||||||
|
const view = render(
|
||||||
|
<Avatar
|
||||||
|
definition={definition}
|
||||||
|
mode="floating"
|
||||||
|
draggable
|
||||||
|
constrainTo="none"
|
||||||
|
initialPosition={{ x: 0, y: 0 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const avatar = view.getByRole('group')
|
||||||
|
const grip = avatar.querySelector('.bs-avatar__drag-grip')
|
||||||
|
expect(grip).not.toBeNull()
|
||||||
|
|
||||||
|
fireEvent.pointerDown(avatar, { pointerId: 8, button: 0, clientX: 0, clientY: 0 })
|
||||||
|
fireEvent.pointerMove(avatar, { pointerId: 8, clientX: 20, clientY: 20 })
|
||||||
|
expect(avatar.style.transform).toBe('translate3d(0px, 0px, 0)')
|
||||||
|
|
||||||
|
fireEvent.pointerDown(grip!, { pointerId: 9, button: 0, clientX: 0, clientY: 0 })
|
||||||
|
fireEvent.pointerMove(avatar, { pointerId: 9, clientX: 20, clientY: 20 })
|
||||||
|
expect(avatar.style.transform).toBe('translate3d(20px, 20px, 0)')
|
||||||
|
})
|
||||||
|
|
||||||
it('limits drag previews to one callback per animation frame with the latest position', () => {
|
it('limits drag previews to one callback per animation frame with the latest position', () => {
|
||||||
let nextFrame = 0
|
let nextFrame = 0
|
||||||
const frames = new Map<number, FrameRequestCallback>()
|
const frames = new Map<number, FrameRequestCallback>()
|
||||||
|
|||||||
@ -27,6 +27,55 @@
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bs-avatar--floating.bs-avatar--draggable {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-avatar__drag-grip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 4px;
|
||||||
|
left: 50%;
|
||||||
|
width: 44px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid rgb(15 23 42 / 18%);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(255 255 255 / 92%);
|
||||||
|
box-shadow: 0 2px 6px rgb(15 23 42 / 14%);
|
||||||
|
cursor: grab;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
touch-action: none;
|
||||||
|
transform: translateX(-50%) translateY(-4px);
|
||||||
|
transition:
|
||||||
|
opacity 160ms ease,
|
||||||
|
transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-avatar__drag-grip::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 13px;
|
||||||
|
width: 18px;
|
||||||
|
height: 12px;
|
||||||
|
content: '';
|
||||||
|
background-image: radial-gradient(circle, #475569 1.5px, transparent 1.7px);
|
||||||
|
background-size: 6px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-avatar--dragging .bs-avatar__drag-grip {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-avatar:hover .bs-avatar__drag-grip,
|
||||||
|
.bs-avatar:focus-visible .bs-avatar__drag-grip,
|
||||||
|
.bs-avatar:focus-within .bs-avatar__drag-grip,
|
||||||
|
.bs-avatar--dragging .bs-avatar__drag-grip {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
.bs-avatar:focus-visible {
|
.bs-avatar:focus-visible {
|
||||||
outline: 3px solid currentColor;
|
outline: 3px solid currentColor;
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
@ -89,6 +138,14 @@
|
|||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.bs-avatar__drag-grip {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.bs-avatar,
|
.bs-avatar,
|
||||||
.bs-avatar * {
|
.bs-avatar * {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user