Skip to content

Commit

Permalink
feat: add FocusGesture feature to enhance focus handling in motion co…
Browse files Browse the repository at this point in the history
…mponents

- Introduced FocusGesture to manage focus states and interactions.
- Updated FeatureManager to include FocusGesture.
- Added focus-related props and types for better integration.
- Updated motion state to support focus as a valid state type.
- Adjusted package dependencies and versions in package.json for compatibility.
- Added tests for focus behavior in motion components.
  • Loading branch information
rick-hup committed Jan 19, 2025
1 parent 56f9d50 commit 020f506
Show file tree
Hide file tree
Showing 9 changed files with 867 additions and 7 deletions.
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default antfu(
'unused-imports/no-unused-vars': 'off',
'regexp/no-super-linear-backtracking': 'off',
'vue/no-parsing-error': [2, { 'x-invalid-namespace': false }],
'no-async-promise-executor': 'off',
},
},
{
Expand Down
3 changes: 2 additions & 1 deletion packages/motion/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,13 @@
"vue": ">=3.0.0"
},
"dependencies": {
"@vueuse/core": "^12.0.0",
"@vueuse/core": "^10.0.0",
"framer-motion": "11.16.6",
"hey-listen": "^1.0.8",
"motion-dom": "^11.16.4"
},
"devDependencies": {
"@testing-library/vue": "^8.1.0",
"@vitejs/plugin-vue": "^5.1.4",
"@vitejs/plugin-vue-jsx": "^4.0.1",
"@vitest/coverage-v8": "^1.4.0",
Expand Down
2 changes: 2 additions & 0 deletions packages/motion/src/features/feature-manager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DragGesture, type Feature, HoverGesture, InViewGesture, LayoutFeature, PanGesture, PressGesture, SVGFeature } from '@/features'
import type { MotionState } from '@/state'
import { ProjectionFeature } from './layout/projection'
import { FocusGesture } from '@/features/gestures/focus'

export class FeatureManager {
features: Feature[] = []
Expand All @@ -15,6 +16,7 @@ export class FeatureManager {
new ProjectionFeature(state),
new PanGesture(state),
new DragGesture(state),
new FocusGesture(state),
]
}

Expand Down
178 changes: 178 additions & 0 deletions packages/motion/src/features/gestures/focus/__tests__/focus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { describe, expect, it } from 'vitest'
import { render } from '@testing-library/vue'
import { motion } from '@/components'
import { defineComponent, nextTick, ref } from 'vue'
import { frame, motionValue } from 'framer-motion/dom'
import { delay } from '@/shared/test'

describe('focus Gesture', () => {
it('focus applied', async () => {
const promise = new Promise(async (resolve) => {
const opacity = motionValue(1)
const { getByTestId } = render(defineComponent({
setup() {
return () => (
<motion.a
data-testid="myAnchorElement"
href="#"
focus={{ opacity: 0.1 }}
transition={{ duration: 0 }}
style={{ opacity }}
>
</motion.a>
)
},
}))
await nextTick()
const element = getByTestId('myAnchorElement') as HTMLElement
element.matches = () => true
element.focus()
await delay(0)
resolve(opacity.get())
})

return expect(promise).resolves.toBe(0.1)
})

it('whileFocus not applied when :focus-visible is false', async () => {
const promise = new Promise(async (resolve) => {
const opacity = motionValue(1)
const { getByTestId } = render(defineComponent({
setup() {
return () => (
<motion.a
data-testid="myAnchorElement"
href="#"
focus={{ opacity: 0.1 }}
transition={{ duration: 0 }}
style={{ opacity }}
>
</motion.a>
)
},
}))
await nextTick()
const element = getByTestId('myAnchorElement') as HTMLElement
element.matches = () => false
element.focus()
await delay(0)
resolve(opacity.get())
})

return expect(promise).resolves.toBe(1)
})

it('focus applied if focus-visible selector throws unsupported', async () => {
const promise = new Promise(async (resolve) => {
const opacity = motionValue(1)
const { getByTestId } = render(defineComponent({
setup() {
return () => (
<motion.a
data-testid="myAnchorElement"
href="#"
focus={{ opacity: 0.1 }}
transition={{ duration: 0 }}
style={{ opacity }}
>
</motion.a>
)
},
}))
await nextTick()
const element = getByTestId('myAnchorElement') as HTMLElement
element.matches = () => {
/**
* Explicitly throw as while Jest throws we want to ensure this
* behaviour isn't silently fixed should it fix this in the future.
*/
throw new Error('this selector not supported')
}
element.focus()
await delay(0)
resolve(opacity.get())
})

return expect(promise).resolves.toBe(0.1)
})

it('whileFocus applied as variant', async () => {
const target = 0.5
const promise = new Promise(async (resolve) => {
const variant = {
hidden: { opacity: target },
}
const opacity = motionValue(1)
const { getByTestId } = render(defineComponent({
setup() {
const Aref = ref<HTMLAnchorElement>()
return () => (
<motion.a
ref={Aref}
data-testid="myAnchorElement"
href="#"
focus="hidden"
variants={variant}
transition={{ type: false }}
style={{ opacity }}
>
</motion.a>
)
},
}))
await nextTick()
const element = getByTestId('myAnchorElement') as HTMLElement
element.matches = () => true
element.focus()
await delay(0)
resolve(opacity.get())
})

return expect(promise).resolves.toBe(target)
})

it('focus is unapplied when blur', () => {
const promise = new Promise(async (resolve) => {
const variant = {
hidden: { opacity: 0.5, transitionEnd: { opacity: 0.75 } },
}
const opacity = motionValue(1)

let blurred = false
const onComplete = () => {
frame.postRender(() => blurred && resolve(opacity.get()))
}

const { getByTestId } = render(defineComponent({
setup() {
const aRef = ref<HTMLAnchorElement>()
return () => (
<motion.a
ref={aRef}
data-testid="myAnchorElement"
href="#"
focus="hidden"
variants={variant}
transition={{ type: false }}
style={{ opacity }}
onMotioncomplete={onComplete}
>
</motion.a>
)
},
}))

await nextTick()
const element = getByTestId('myAnchorElement') as HTMLElement
element.matches = () => true
element.focus()
await nextTick()
setTimeout(() => {
blurred = true
element.blur()
}, 10)
})

return expect(promise).resolves.toBe('1')
})
})
41 changes: 41 additions & 0 deletions packages/motion/src/features/gestures/focus/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { pipe } from 'framer-motion/dom'
import { addDomEvent } from '@/events'
import { Feature } from '@/features/feature'

export class FocusGesture extends Feature {
private isActive = false

onFocus() {
let isFocusVisible = false
/**
* If this element doesn't match focus-visible then don't
* apply whileHover. But, if matches throws that focus-visible
* is not a valid selector then in that browser outline styles will be applied
* to the element by default and we want to match that behaviour with whileFocus.
*/
try {
isFocusVisible = this.state.element.matches(':focus-visible')
}
catch (e) {
isFocusVisible = true
}
if (!isFocusVisible)
return
this.state.setActive('focus', true)
this.isActive = true
}

onBlur() {
if (!this.isActive)
return
this.state.setActive('focus', false)
this.isActive = false
}

mount() {
this.unmount = pipe(
addDomEvent(this.state.element!, 'focus', () => this.onFocus()),
addDomEvent(this.state.element!, 'blur', () => this.onBlur()),
) as VoidFunction
}
}
7 changes: 7 additions & 0 deletions packages/motion/src/features/gestures/focus/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { Variant } from '@/types'

export type FocusProps = {
focus?: string | Variant
onFocus?: (e: FocusEvent) => void
onBlur?: (e: FocusEvent) => void
}
3 changes: 1 addition & 2 deletions packages/motion/src/state/motion-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { createVisualElement } from '@/state/create-visual-element'
import { doneCallbacks } from '@/components/presence'

// Animation state types that can be active
const STATE_TYPES = ['initial', 'animate', 'inView', 'hover', 'press', 'whileDrag', 'exit'] as const
const STATE_TYPES = ['initial', 'animate', 'inView', 'hover', 'press', 'whileDrag', 'focus', 'exit'] as const
type StateType = typeof STATE_TYPES[number]

// Map to track mounted motion states by element
Expand Down Expand Up @@ -247,7 +247,6 @@ export class MotionState {
continue
}
const definition = isDef(this.options[name]) ? this.options[name] : this.context[name]

const variant = resolveVariant(
definition,
this.options.variants,
Expand Down
3 changes: 2 additions & 1 deletion packages/motion/src/types/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { LayoutGroupState } from '@/components/context'
import type { PanProps } from '@/features/gestures/pan/types'
import type { MotionConfigState } from '@/components/motion-config/types'
import type { $Transition } from './framer-motion'
import type { FocusProps } from '@/features/gestures/focus/types'

type AnimationPlaybackControls = ReturnType<typeof animate>

Expand Down Expand Up @@ -41,7 +42,7 @@ export type ElementType = keyof IntrinsicElementAttributes
export interface Options<T = any> extends
LayoutOptions, PressProps,
HoverProps, InViewProps, DragProps,
PanProps {
PanProps, FocusProps {
custom?: T
as?: ElementType
initial?: string | Variant | boolean
Expand Down
Loading

0 comments on commit 020f506

Please sign in to comment.