diff --git a/docs/react-v9/contributing/rfcs/react-components/convergence/pointer-interactions.md b/docs/react-v9/contributing/rfcs/react-components/convergence/pointer-interactions.md new file mode 100644 index 0000000000000..64c37e34b104f --- /dev/null +++ b/docs/react-v9/contributing/rfcs/react-components/convergence/pointer-interactions.md @@ -0,0 +1,713 @@ +# RFC: Pointer Interactions + +--- + +@anthropic-assistant + +## Contributors + +- _Add contributors here_ + +## Stakeholders + +- Fluent UI React v9 consumers +- Component authors +- Accessibility team + +## Timeline + +- **Authored:** January 2026 +- **Feedback deadline:** _TBD_ + +--- + +## Summary + +This RFC proposes adding interaction hooks to `@fluentui/react-utilities` for handling pointer interactions in a modality-aware way. The primary goal is to solve the "sticky hover" problem on touch devices while providing a comprehensive, semantically correct interaction model across mouse, touch, pen, and keyboard inputs. + +The solution is inspired by and based on React Aria's `@react-aria/interactions` package, which has proven effective in production at scale. + +## Background + +### The Sticky Hover Problem + +On touch devices, tapping an element triggers the following browser event sequence: + +``` +touchstart → touchend → mouseover → mouseenter → mousemove → mousedown → mouseup → click +``` + +The emulated `mouseenter` event causes CSS `:hover` styles to activate and **persist** until the user taps elsewhere. This creates a poor user experience where buttons appear "stuck" in their hover state after being tapped. + +### Current State in Fluent UI v9 + +Fluent UI v9 components use CSS `:hover` pseudo-selectors extensively. For example, `Button` has 80+ hover-related style rules in `useButtonStyles.styles.ts`: + +```ts +// Current approach - problematic on touch devices +':hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + borderColor: tokens.colorNeutralStroke1Hover, + color: tokens.colorNeutralForeground1Hover, +}, +``` + +### How React Aria Solves This + +React Aria's `@react-aria/interactions` package provides hooks that: + +1. **Separate hover from press semantically** - Hover is mouse/pen-specific; press is universal +2. **Use `pointerType` to distinguish input modalities** - `'mouse' | 'pen' | 'touch' | 'keyboard' | 'virtual'` +3. **Ignore emulated mouse events after touch** - Uses a global flag with 50ms timeout +4. **Handle browser quirks** - iOS double pointerEnter, Safari scroll issues, etc. + +Key insight: **Touch devices don't have a hover concept.** Your finger is either touching or not - there's no "floating over" state. React Aria correctly models this by never firing hover events for touch interactions. + +### References + +- [React Aria: Building a Button Part 2](https://react-spectrum.adobe.com/blog/building-a-button-part-2.html) - Detailed explanation of the problem and solution +- [React Aria useHover source](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/interactions/src/useHover.ts) +- [React Aria usePress source](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/interactions/src/usePress.ts) + +## Problem Statement + +1. **Sticky hover on touch devices** - CSS `:hover` persists after tap, causing visual bugs +2. **No semantic distinction between hover and press** - Components conflate preview (hover) with activation (press) +3. **Inconsistent behavior across input types** - Mouse, touch, pen, and keyboard handled differently +4. **No unified interaction modality tracking** - Hard to implement focus-visible correctly + +These issues affect all interactive components: Button, Link, Input, Checkbox, Radio, Switch, Tab, MenuItem, Slider, and more. + +## Detailed Design or Proposal + +### Location in react-utilities + +Add interaction hooks to `@fluentui/react-utilities` under the `hooks` directory: + +``` +@fluentui/react-utilities +└── src/ + └── hooks/ + ├── index.ts + ├── useHover.ts # Mouse/pen hover only (excludes touch) + └── ... (other hooks) +``` + +**Note:** The hook is exported as `useHover_unstable` to indicate its experimental status. + +### Opt-in via FluentProvider + +The new behavior will be **opt-in** via a feature flag on `FluentProvider`: + +```tsx +import { FluentProvider } from '@fluentui/react-components'; + +function App() { + return ( + // Opt-in to pointer-aware interactions + + + + ); +} +``` + +When the flag is enabled: + +- Components use `useHover` and `usePress` hooks +- Hover only fires for mouse/pen, never touch +- Components render `data-hovered` and `data-pressed` attributes +- Styles target data attributes instead of `:hover` + +When the flag is disabled (default): + +- Current CSS `:hover` behavior is preserved +- No breaking changes for existing consumers + +### Hook APIs + +#### `useHover_unstable` + +```ts +/** + * The type of pointer that triggered the hover event. + * Touch is intentionally excluded as hover is not a valid interaction for touch devices. + */ +export type PointerType = 'mouse' | 'pen'; + +/** + * Event object passed to hover event handlers. + */ +export interface HoverEvent { + /** The type of hover event. */ + type: 'hoverstart' | 'hoverend'; + /** The pointer type that triggered the event. */ + pointerType: PointerType; + /** The target element. */ + target: HTMLElement; +} + +/** + * Props for the useHover hook. + */ +export interface HoverProps { + /** Whether hover events should be disabled. */ + isDisabled?: boolean; + /** Handler called when a hover interaction starts. */ + onHoverStart?: (e: HoverEvent) => void; + /** Handler called when a hover interaction ends. */ + onHoverEnd?: (e: HoverEvent) => void; + /** Handler called when the hover state changes. */ + onHoverChange?: (isHovered: boolean) => void; +} + +/** + * Result returned by the useHover hook. + */ +export interface HoverResult { + /** Props to spread on the target element. */ + hoverProps: React.DOMAttributes; + /** Whether the element is currently hovered. */ + isHovered: boolean; +} + +function useHover_unstable(props: HoverProps): HoverResult; +``` + +#### `usePress` (Future) + +```ts +interface PressProps { + /** Whether press events should be disabled. */ + isDisabled?: boolean; + /** Handler called when press starts. */ + onPressStart?: (e: PressEvent) => void; + /** Handler called when press ends. */ + onPressEnd?: (e: PressEvent) => void; + /** Handler called on press up. */ + onPressUp?: (e: PressEvent) => void; + /** Handler called when a press is released over the target. */ + onPress?: (e: PressEvent) => void; + /** Handler called when press state changes. */ + onPressChange?: (isPressed: boolean) => void; + /** Whether the target should not receive focus on press. */ + preventFocusOnPress?: boolean; +} + +interface PressResult { + /** Props to spread on the target element. */ + pressProps: React.HTMLAttributes; + /** Whether the element is currently pressed. */ + isPressed: boolean; +} + +interface PressEvent { + /** The type of press event. */ + type: 'pressstart' | 'pressend' | 'pressup' | 'press'; + /** The pointer type that triggered the event. */ + pointerType: 'mouse' | 'pen' | 'touch' | 'keyboard' | 'virtual'; + /** The target element. */ + target: HTMLElement; + /** Modifier keys. */ + shiftKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + altKey: boolean; +} + +function usePress(props: PressProps): PressResult; +``` + +#### `useInteractionModality` (Future) + +```ts +type Modality = 'keyboard' | 'pointer' | 'virtual'; + +/** Returns the current interaction modality (keyboard, pointer, or virtual). */ +function useInteractionModality(): Modality; + +/** Returns whether focus should be visible (keyboard navigation). */ +function useFocusVisible(): boolean; + +/** Programmatically set the interaction modality. */ +function setInteractionModality(modality: Modality): void; +``` + +### Implementation: `useHover_unstable` + +The implementation is based on React Aria's approach with the following key features: + +1. **Global touch event tracking** - A `pointerup` listener on `document` sets a flag when touch interactions end +2. **50ms ignore window** - After touch, emulated mouse events are ignored for 50ms (handles iOS quirks) +3. **Element removal handling** - A `pointerover` listener detects when hovered elements are removed from DOM +4. **SSR-safe** - Uses `canUseDOM()` to avoid running in server environments +5. **Cleanup on unmount** - Properly removes event listeners + +```ts +// packages/react-components/react-utilities/src/hooks/useHover.ts + +// Global state for ignoring emulated mouse events after touch. +// iOS fires onPointerEnter twice: once with pointerType="touch" and again with pointerType="mouse". +let globalIgnoreEmulatedMouseEvents = false; +let hoverCount = 0; + +function setGlobalIgnoreEmulatedMouseEvents() { + globalIgnoreEmulatedMouseEvents = true; + setTimeout(() => { + globalIgnoreEmulatedMouseEvents = false; + }, 50); +} + +function handleGlobalPointerEvent(e: PointerEvent) { + if (e.pointerType === 'touch') { + setGlobalIgnoreEmulatedMouseEvents(); + } +} + +function setupGlobalTouchEvents() { + if (!canUseDOM()) { + return; + } + + if (hoverCount === 0) { + document.addEventListener('pointerup', handleGlobalPointerEvent); + } + + hoverCount++; + + return () => { + hoverCount--; + if (hoverCount === 0) { + document.removeEventListener('pointerup', handleGlobalPointerEvent); + } + }; +} + +export function useHover_unstable(props: HoverProps): HoverResult { + const { onHoverStart, onHoverChange, onHoverEnd, isDisabled } = props; + + const [isHovered, setHovered] = React.useState(false); + const state = React.useRef({ + isHovered: false, + pointerType: '' as string, + target: null as HTMLElement | null, + }); + + // Setup/teardown global touch event listener + React.useEffect(setupGlobalTouchEvents, []); + + const hoverProps = React.useMemo>( + () => ({ + onPointerEnter: (e: React.PointerEvent) => { + // Ignore emulated mouse events after touch + if (globalIgnoreEmulatedMouseEvents && e.pointerType === 'mouse') { + return; + } + // Never trigger hover for touch + if (e.pointerType === 'touch' || isDisabled || state.current.isHovered) { + return; + } + // ... trigger hover start + }, + onPointerLeave: (e: React.PointerEvent) => { + if (!isDisabled && e.pointerType !== 'touch') { + // ... trigger hover end + } + }, + }), + [isDisabled /* callbacks */], + ); + + return { hoverProps, isHovered }; +} +``` + +### Context Integration + +A new context was added to `@fluentui/react-shared-contexts`: + +```ts +// packages/react-components/react-shared-contexts/library/src/PointerInteractionsContext/PointerInteractionsContext.ts + +/** + * Context value for pointer-aware interaction behavior. + * @internal + */ +export type PointerInteractionsContextValue = { + /** + * Whether pointer-aware hover behavior is enabled. + * When enabled, hover only fires for mouse/pen interactions, not touch. + * This fixes "sticky hover" on touch devices. + */ + usePointerHover: boolean; +}; + +const defaultValue: PointerInteractionsContextValue = { + usePointerHover: false, +}; + +export const PointerInteractionsContext = React.createContext(defaultValue); +export const PointerInteractionsProvider = PointerInteractionsContext.Provider; + +export function usePointerInteractions(): PointerInteractionsContextValue { + return React.useContext(PointerInteractionsContext); +} +``` + +Exported from `@fluentui/react-shared-contexts` with `_unstable` suffix: + +- `PointerInteractionsProvider_unstable` +- `usePointerInteractions_unstable` +- `PointerInteractionsContextValue_unstable` + +Update `FluentProvider`: + +```ts +// packages/react-components/react-provider/library/src/components/FluentProvider/FluentProvider.types.ts +export type FluentProviderProps = { + // ... existing props + + /** + * Enables pointer-aware hover behavior. When enabled, hover only fires for + * mouse/pen interactions, not touch. This fixes "sticky hover" on touch devices. + * @default false + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + UNSTABLE_usePointerHover?: boolean; +}; + +// The context is integrated in: +// - useFluentProvider.ts: reads the prop and passes to state +// - useFluentProviderContextValues.ts: creates memoized context value +// - renderFluentProvider.tsx: wraps children with PointerInteractionsProvider +``` + +### Component Integration: Button + +The Button component was updated to integrate with the pointer-aware hover system: + +```tsx +// packages/react-components/react-button/library/src/components/Button/useButtonBase.ts +import { useHover_unstable } from '@fluentui/react-utilities'; +import { usePointerInteractions_unstable } from '@fluentui/react-shared-contexts'; + +export const useButtonBase_unstable = ( + props: ButtonBaseProps, + ref: React.Ref, +): ButtonBaseState => { + const { disabled = false, disabledFocusable = false /* ... */ } = props; + + // Check if pointer-aware hover behavior is enabled via FluentProvider + const { usePointerHover } = usePointerInteractions_unstable(); + + // Use pointer-aware hover hook - disabled when feature flag is off or button is disabled + const { hoverProps, isHovered } = useHover_unstable({ + isDisabled: !usePointerHover || disabled || disabledFocusable, + }); + + // Get base ARIA button props + const ariaButtonProps = useARIAButtonProps(props.as, props); + + // Merge hover props with ARIA button props when pointer hover is enabled + const mergedProps = usePointerHover + ? { + ...ariaButtonProps, + ...hoverProps, + // Merge event handlers - ensure both hover and aria handlers are called + onPointerEnter: e => { + hoverProps.onPointerEnter?.(e); + ariaButtonProps.onPointerEnter?.(e); + }, + onPointerLeave: e => { + hoverProps.onPointerLeave?.(e); + ariaButtonProps.onPointerLeave?.(e); + }, + // Add data-hovered attribute for CSS styling when hovered + 'data-hovered': isHovered ? '' : undefined, + } + : ariaButtonProps; + + return { + disabled, + disabledFocusable, + isHovered, + // ... + root: slot.always(getIntrinsicElementProps(as, mergedProps), { + /* ... */ + }), + }; +}; +``` + +The `isHovered` state is added to `ButtonState` and `ButtonBaseState` types: + +```ts +// packages/react-components/react-button/library/src/components/Button/Button.types.ts +export type ButtonState = ComponentState & + Required> & { + iconOnly: boolean; + /** + * Whether the button is currently hovered (via pointer-aware hover detection). + * This is used when `UNSTABLE_usePointerHover` is enabled on FluentProvider. + * @default false + */ + isHovered: boolean; + }; +``` + +### Styling with Data Attributes + +The Button styles were updated to include `&[data-hovered]` selectors that mirror the existing `:hover` styles: + +```ts +// packages/react-components/react-button/library/src/components/Button/useButtonStyles.styles.ts + +const useRootBaseClassName = makeResetStyles({ + // ... base styles + + ':hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + borderColor: tokens.colorNeutralStroke1Hover, + color: tokens.colorNeutralForeground1Hover, + cursor: 'pointer', + }, + + // Pointer-aware hover styles (used when UNSTABLE_usePointerHover is enabled) + '&[data-hovered]': { + backgroundColor: tokens.colorNeutralBackground1Hover, + borderColor: tokens.colorNeutralStroke1Hover, + color: tokens.colorNeutralForeground1Hover, + cursor: 'pointer', + }, + + // High contrast styles also include &[data-hovered] + '@media (forced-colors: active)': { + ':hover': { + backgroundColor: 'HighlightText', + borderColor: 'Highlight', + color: 'Highlight', + forcedColorAdjust: 'none', + }, + '&[data-hovered]': { + backgroundColor: 'HighlightText', + borderColor: 'Highlight', + color: 'Highlight', + forcedColorAdjust: 'none', + }, + }, +}); + +// Appearance-specific styles (outline, primary, subtle, transparent) +// also include &[data-hovered] selectors mirroring :hover +const useRootStyles = makeStyles({ + primary: { + ':hover': { + backgroundColor: tokens.colorBrandBackgroundHover, + // ... + }, + '&[data-hovered]': { + backgroundColor: tokens.colorBrandBackgroundHover, + // ... + }, + }, + // ... other appearances +}); + +// Disabled styles override hover to prevent visual feedback +const useRootDisabledStyles = makeStyles({ + base: { + ':hover': { + backgroundColor: tokens.colorNeutralBackgroundDisabled, + cursor: 'not-allowed', + }, + '&[data-hovered]': { + backgroundColor: tokens.colorNeutralBackgroundDisabled, + cursor: 'not-allowed', + }, + }, +}); +``` + +**Note:** The styles file does NOT conditionally apply styles based on the feature flag. Both `:hover` and `&[data-hovered]` styles are always present. This approach: + +- Avoids runtime style computation overhead +- Allows smooth transition when flag is enabled/disabled +- Ensures consistent bundle size regardless of feature flag + +### Phased Rollout + +#### Phase 1: Foundation (MVP) - COMPLETED + +- [x] Add `useHover_unstable` hook to `@fluentui/react-utilities` +- [x] Add `UNSTABLE_usePointerHover` flag to FluentProvider +- [x] Add `PointerInteractionsContext` to `@fluentui/react-shared-contexts` +- [x] Integrate with `Button` component (including all variants via `useButtonBase_unstable`) +- [x] Update Button styles with `&[data-hovered]` selectors + +#### Phase 2: Core Interactions (Future) + +- Implement `usePress` hook +- Implement `useLongPress` hook +- Integrate with all button variants (ToggleButton, CompoundButton, SplitButton, MenuButton) +- Integrate with Link component + +#### Phase 3: Form Controls + +- Integrate with Input, Textarea, Select, Combobox, Dropdown +- Integrate with Checkbox, Radio, Switch +- Integrate with Slider, SpinButton + +#### Phase 4: Complex Components + +- Integrate with Tab, MenuItem, TreeItem +- Integrate with Card (when interactive) +- Implement `useMove` for drag interactions +- Implement `useInteractionModality` for focus-visible coordination + +#### Phase 5: Stabilization + +- Remove `UNSTABLE_` prefix after sufficient testing +- Make pointer-aware hover the default behavior +- Deprecate CSS `:hover` approach in documentation + +### Affected Components + +All interactive components will need updates: + +| Component | Priority | Notes | +| --------------- | -------- | ------------------------ | +| Button | High | Primary proof of concept | +| ToggleButton | High | Same as Button | +| CompoundButton | High | Same as Button | +| SplitButton | High | Same as Button | +| MenuButton | High | Same as Button | +| Link | High | Similar to Button | +| Input | Medium | Focus + hover states | +| Textarea | Medium | Same as Input | +| Select | Medium | Dropdown hover | +| Combobox | Medium | Input + dropdown | +| Dropdown | Medium | Similar to Select | +| Checkbox | Medium | Toggle interaction | +| Radio | Medium | Same as Checkbox | +| Switch | Medium | Same as Checkbox | +| Slider | Medium | Thumb hover + drag | +| SpinButton | Medium | Buttons + input | +| Tab | Medium | Tab hover | +| MenuItem | Medium | Menu item hover | +| TreeItem | Low | Tree node hover | +| Card | Low | When interactive | +| Tooltip trigger | Low | Hover-triggered | + +### Pros and Cons + +#### Pros + +- **Fixes sticky hover** - The primary goal is achieved +- **Semantically correct** - Hover and press have distinct meanings +- **Proven approach** - Based on React Aria's battle-tested implementation +- **Opt-in** - No breaking changes for existing consumers +- **Comprehensive** - Provides foundation for future interaction improvements +- **Modality-aware** - Can correctly handle mouse, touch, pen, and keyboard +- **Accessibility-ready** - Enables better focus-visible implementation + +#### Cons + +- **Implementation effort** - Requires updating all interactive components +- **Style duplication** - Need both CSS `:hover` and `[data-hovered]` styles during transition +- **Testing complexity** - Touch interactions are harder to test +- **Learning curve** - Contributors need to understand new patterns +- **Coordination with Tabster** - Need to ensure compatibility with existing focus management + +## Discarded Solutions + +### CSS-only: `@media (hover: hover)` + +```css +@media (hover: hover) { + .button:hover { + background-color: var(--hover-color); + } +} +``` + +**Why discarded:** + +- Doesn't work on hybrid devices (laptops with touchscreens) +- The media query reflects device capability, not current interaction modality +- A touch-capable laptop would still show hover on touch + +### CSS-only: `:hover:not(:active)` + +```css +.button:hover:not(:active) { + background-color: var(--hover-color); +} +``` + +**Why discarded:** + +- Only partially works - hover still persists after touch ends +- Doesn't distinguish between mouse hover and touch-emulated hover + +### Always use JS hover (breaking change) + +**Why discarded:** + +- Would be a breaking change for all consumers +- Some consumers may have CSS customizations that depend on `:hover` +- Risk of regression in existing applications + +## Open Issues + +### 1. Styling approach + +Should hover state be indicated via: + +- **Data attribute:** `data-hovered` (recommended) +- **CSS class:** `.fui-hovered` +- **CSS variable:** `--fui-hovered: 1` + +**Recommendation:** Data attribute for semantic clarity and CSS specificity. + +### 2. Coordination with Tabster + +Fluent UI already has `useFocusVisible` and `useFocusWithin` in `@fluentui/react-tabster`. How should the interaction hooks in `@fluentui/react-utilities` coordinate with these? + +Options: + +- Keep focus hooks in react-tabster, interaction hooks in react-utilities +- Move all interaction-related hooks to react-utilities +- Create shared modality tracking used by both + +### 3. Testing strategy + +How to test touch interactions in unit tests? + +- JSDOM doesn't support PointerEvents well +- May need custom test utilities +- React Aria uses a test-specific fallback branch + +### 4. Migration path + +When the feature is stabilized: + +- Should pointer-aware hover become the default? +- How long to support the CSS `:hover` fallback? +- How to communicate the change to consumers? + +### 5. Server-side rendering + +The global event listeners for touch detection need SSR-safe initialization. Need to ensure: + +- No errors during SSR +- Proper hydration +- Event listeners attached only on client + +--- + +## References + +- [React Aria Interactions](https://react-spectrum.adobe.com/react-aria/interactions.html) +- [Building a Button Part 2 - React Spectrum Blog](https://react-spectrum.adobe.com/blog/building-a-button-part-2.html) +- [Fluent UI Button styles](https://github.com/microsoft/fluentui/blob/master/packages/react-components/react-button/library/src/components/Button/useButtonStyles.styles.ts) +- [CSS :hover media query issues](https://css-tricks.com/touch-devices-not-quite-hover/) diff --git a/packages/react-components/react-button/library/etc/react-button.api.md b/packages/react-components/react-button/library/etc/react-button.api.md index 9148c6c57585e..7012aa25b31cd 100644 --- a/packages/react-components/react-button/library/etc/react-button.api.md +++ b/packages/react-components/react-button/library/etc/react-button.api.md @@ -47,6 +47,7 @@ export type ButtonSlots = { // @public (undocumented) export type ButtonState = ComponentState & Required> & { iconOnly: boolean; + isHovered: boolean; }; // @public diff --git a/packages/react-components/react-button/library/src/components/Button/Button.types.ts b/packages/react-components/react-button/library/src/components/Button/Button.types.ts index 54ff9e596bd81..e854da90ca066 100644 --- a/packages/react-components/react-button/library/src/components/Button/Button.types.ts +++ b/packages/react-components/react-button/library/src/components/Button/Button.types.ts @@ -86,6 +86,24 @@ export type ButtonState = ComponentState & * @default false */ iconOnly: boolean; + + /** + * Whether the button is currently hovered (via pointer-aware hover detection). + * This is used when `UNSTABLE_usePointerHover` is enabled on FluentProvider to provide + * touch-safe hover behavior that doesn't trigger on touch interactions. + * + * @default false + */ + isHovered: boolean; }; -export type ButtonBaseState = DistributiveOmit; +export type ButtonBaseState = DistributiveOmit & { + /** + * Whether the button is currently hovered (via pointer-aware hover detection). + * This is used when `UNSTABLE_usePointerHover` is enabled on FluentProvider to provide + * touch-safe hover behavior that doesn't trigger on touch interactions. + * + * @default false + */ + isHovered: boolean; +}; diff --git a/packages/react-components/react-button/library/src/components/Button/useButtonBase.ts b/packages/react-components/react-button/library/src/components/Button/useButtonBase.ts index 1492edf41d3da..8aa4802dd68b8 100644 --- a/packages/react-components/react-button/library/src/components/Button/useButtonBase.ts +++ b/packages/react-components/react-button/library/src/components/Button/useButtonBase.ts @@ -2,7 +2,8 @@ import * as React from 'react'; import { ARIAButtonSlotProps, useARIAButtonProps } from '@fluentui/react-aria'; -import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; +import { getIntrinsicElementProps, slot, useHover_unstable } from '@fluentui/react-utilities'; +import { useOverrides_unstable as useOverrides } from '@fluentui/react-shared-contexts'; import type { ButtonBaseProps, ButtonBaseState } from './Button.types'; /** @@ -16,15 +17,53 @@ export const useButtonBase_unstable = ( ): ButtonBaseState => { const { as = 'button', disabled = false, disabledFocusable = false, icon, iconPosition = 'before' } = props; const iconShorthand = slot.optional(icon, { elementType: 'span' }); + + // Check if smart hover behavior is enabled via FluentProvider + const overrides = useOverrides(); + const smartHover = overrides.smartHover ?? false; + console.log('Smart Hover Enabled:', smartHover); + + // Use smart hover hook - disabled when feature flag is off or button is disabled + const { hoverProps, isHovered } = useHover_unstable({ + isDisabled: !smartHover, + }); + + console.log('hovered', isHovered); + + console.log(hoverProps); + + // Get base ARIA button props + const ariaButtonProps = useARIAButtonProps(props.as, props); + + // Merge hover props with ARIA button props when smart hover is enabled + const mergedProps = smartHover + ? { + ...ariaButtonProps, + ...hoverProps, + // Merge event handlers - ensure both hover and aria handlers are called + onPointerEnter: (e: React.PointerEvent) => { + hoverProps.onPointerEnter?.(e as React.PointerEvent); + ariaButtonProps.onPointerEnter?.(e); + }, + onPointerLeave: (e: React.PointerEvent) => { + hoverProps.onPointerLeave?.(e as React.PointerEvent); + ariaButtonProps.onPointerLeave?.(e); + }, + // Add data-hovered attribute for CSS styling when hovered + 'data-hovered': isHovered ? '' : undefined, + } + : ariaButtonProps; + return { // Props passed at the top-level disabled, disabledFocusable, iconPosition, iconOnly: Boolean(iconShorthand?.children && !props.children), + isHovered, // Slots definition components: { root: 'button', icon: 'span' }, - root: slot.always>(getIntrinsicElementProps(as, useARIAButtonProps(props.as, props)), { + root: slot.always>(getIntrinsicElementProps(as, mergedProps), { elementType: 'button', defaultProps: { ref: ref as React.Ref, diff --git a/packages/react-components/react-button/library/src/components/Button/useButtonStyles.styles.ts b/packages/react-components/react-button/library/src/components/Button/useButtonStyles.styles.ts index cb99c3e70d97b..946165e166aa2 100644 --- a/packages/react-components/react-button/library/src/components/Button/useButtonStyles.styles.ts +++ b/packages/react-components/react-button/library/src/components/Button/useButtonStyles.styles.ts @@ -52,6 +52,15 @@ const useRootBaseClassName = makeResetStyles({ cursor: 'pointer', }, + // Pointer-aware hover styles (used when UNSTABLE_usePointerHover is enabled) + '&[data-hovered]': { + backgroundColor: tokens.colorNeutralBackground1Hover, + borderColor: tokens.colorNeutralStroke1Hover, + color: tokens.colorNeutralForeground1Hover, + + cursor: 'pointer', + }, + ':hover:active,:active:focus-visible': { backgroundColor: tokens.colorNeutralBackground1Pressed, borderColor: tokens.colorNeutralStroke1Pressed, @@ -92,6 +101,13 @@ const useRootBaseClassName = makeResetStyles({ forcedColorAdjust: 'none', }, + '&[data-hovered]': { + backgroundColor: 'HighlightText', + borderColor: 'Highlight', + color: 'Highlight', + forcedColorAdjust: 'none', + }, + ':hover:active,:active:focus-visible': { backgroundColor: 'HighlightText', borderColor: 'Highlight', @@ -144,6 +160,10 @@ const useRootStyles = makeStyles({ backgroundColor: tokens.colorTransparentBackgroundHover, }, + '&[data-hovered]': { + backgroundColor: tokens.colorTransparentBackgroundHover, + }, + ':hover:active,:active:focus-visible': { backgroundColor: tokens.colorTransparentBackgroundPressed, }, @@ -159,6 +179,12 @@ const useRootStyles = makeStyles({ color: tokens.colorNeutralForegroundOnBrand, }, + '&[data-hovered]': { + backgroundColor: tokens.colorBrandBackgroundHover, + ...shorthands.borderColor('transparent'), + color: tokens.colorNeutralForegroundOnBrand, + }, + ':hover:active,:active:focus-visible': { backgroundColor: tokens.colorBrandBackgroundPressed, ...shorthands.borderColor('transparent'), @@ -177,6 +203,12 @@ const useRootStyles = makeStyles({ color: 'Highlight', }, + '&[data-hovered]': { + backgroundColor: 'HighlightText', + ...shorthands.borderColor('Highlight'), + color: 'Highlight', + }, + ':hover:active,:active:focus-visible': { backgroundColor: 'HighlightText', ...shorthands.borderColor('Highlight'), @@ -207,6 +239,21 @@ const useRootStyles = makeStyles({ }, }, + '&[data-hovered]': { + backgroundColor: tokens.colorSubtleBackgroundHover, + ...shorthands.borderColor('transparent'), + color: tokens.colorNeutralForeground2Hover, + [`& .${iconFilledClassName}`]: { + display: 'inline', + }, + [`& .${iconRegularClassName}`]: { + display: 'none', + }, + [`& .${buttonClassNames.icon}`]: { + color: tokens.colorNeutralForeground2BrandHover, + }, + }, + ':hover:active,:active:focus-visible': { backgroundColor: tokens.colorSubtleBackgroundPressed, ...shorthands.borderColor('transparent'), @@ -230,6 +277,13 @@ const useRootStyles = makeStyles({ color: 'Highlight', }, }, + '&[data-hovered]': { + color: 'Highlight', + + [`& .${buttonClassNames.icon}`]: { + color: 'Highlight', + }, + }, ':hover:active,:active:focus-visible': { color: 'Highlight', @@ -256,6 +310,18 @@ const useRootStyles = makeStyles({ }, }, + '&[data-hovered]': { + backgroundColor: tokens.colorTransparentBackgroundHover, + ...shorthands.borderColor('transparent'), + color: tokens.colorNeutralForeground2BrandHover, + [`& .${iconFilledClassName}`]: { + display: 'inline', + }, + [`& .${iconRegularClassName}`]: { + display: 'none', + }, + }, + ':hover:active,:active:focus-visible': { backgroundColor: tokens.colorTransparentBackgroundPressed, ...shorthands.borderColor('transparent'), @@ -273,6 +339,10 @@ const useRootStyles = makeStyles({ backgroundColor: tokens.colorTransparentBackground, color: 'Highlight', }, + '&[data-hovered]': { + backgroundColor: tokens.colorTransparentBackground, + color: 'Highlight', + }, ':hover:active,:active:focus-visible': { backgroundColor: tokens.colorTransparentBackground, color: 'Highlight', @@ -349,6 +419,25 @@ const useRootDisabledStyles = makeStyles({ }, }, + // Disabled buttons should not show hover styles even when data-hovered is present + '&[data-hovered]': { + backgroundColor: tokens.colorNeutralBackgroundDisabled, + ...shorthands.borderColor(tokens.colorNeutralStrokeDisabled), + color: tokens.colorNeutralForegroundDisabled, + + cursor: 'not-allowed', + + [`& .${iconFilledClassName}`]: { + display: 'none', + }, + [`& .${iconRegularClassName}`]: { + display: 'inline', + }, + [`& .${buttonClassNames.icon}`]: { + color: tokens.colorNeutralForegroundDisabled, + }, + }, + ':hover:active,:active:focus-visible': { backgroundColor: tokens.colorNeutralBackgroundDisabled, ...shorthands.borderColor(tokens.colorNeutralStrokeDisabled), @@ -393,6 +482,16 @@ const useRootDisabledStyles = makeStyles({ }, }, + '&[data-hovered]': { + backgroundColor: 'ButtonFace', + ...shorthands.borderColor('GrayText'), + color: 'GrayText', + + [`& .${buttonClassNames.icon}`]: { + color: 'GrayText', + }, + }, + ':hover:active,:active:focus-visible': { backgroundColor: 'ButtonFace', ...shorthands.borderColor('GrayText'), @@ -413,6 +512,10 @@ const useRootDisabledStyles = makeStyles({ backgroundColor: tokens.colorTransparentBackground, }, + '&[data-hovered]': { + backgroundColor: tokens.colorTransparentBackground, + }, + ':hover:active,:active:focus-visible': { backgroundColor: tokens.colorTransparentBackground, }, @@ -424,6 +527,10 @@ const useRootDisabledStyles = makeStyles({ ...shorthands.borderColor('transparent'), }, + '&[data-hovered]': { + ...shorthands.borderColor('transparent'), + }, + ':hover:active,:active:focus-visible': { ...shorthands.borderColor('transparent'), }, @@ -440,6 +547,11 @@ const useRootDisabledStyles = makeStyles({ ...shorthands.borderColor('transparent'), }, + '&[data-hovered]': { + backgroundColor: tokens.colorTransparentBackground, + ...shorthands.borderColor('transparent'), + }, + ':hover:active,:active:focus-visible': { backgroundColor: tokens.colorTransparentBackground, ...shorthands.borderColor('transparent'), @@ -454,6 +566,11 @@ const useRootDisabledStyles = makeStyles({ ...shorthands.borderColor('transparent'), }, + '&[data-hovered]': { + backgroundColor: tokens.colorTransparentBackground, + ...shorthands.borderColor('transparent'), + }, + ':hover:active,:active:focus-visible': { backgroundColor: tokens.colorTransparentBackground, ...shorthands.borderColor('transparent'), diff --git a/packages/react-components/react-button/library/src/components/SplitButton/useSplitButtonBase.ts b/packages/react-components/react-button/library/src/components/SplitButton/useSplitButtonBase.ts index e2be9ccae3737..356aa5655229f 100644 --- a/packages/react-components/react-button/library/src/components/SplitButton/useSplitButtonBase.ts +++ b/packages/react-components/react-button/library/src/components/SplitButton/useSplitButtonBase.ts @@ -63,6 +63,8 @@ export const useSplitButtonBase_unstable = ( disabled, disabledFocusable, iconPosition, + // SplitButton container doesn't track hover state - child buttons handle their own hover + isHovered: false, components: { root: 'div', menuButton: MenuButton, primaryActionButton: Button }, root: slot.always(getIntrinsicElementProps('div', { ref, ...props }), { elementType: 'div' }), menuButton: menuButtonShorthand, diff --git a/packages/react-components/react-button/stories/src/Button/ButtonDefault.stories.tsx b/packages/react-components/react-button/stories/src/Button/ButtonDefault.stories.tsx index cda4373a9d055..58d5c80c63130 100644 --- a/packages/react-components/react-button/stories/src/Button/ButtonDefault.stories.tsx +++ b/packages/react-components/react-button/stories/src/Button/ButtonDefault.stories.tsx @@ -1,6 +1,14 @@ import * as React from 'react'; import type { JSXElement } from '@fluentui/react-components'; -import { Button } from '@fluentui/react-components'; +import { Button, FluentProvider } from '@fluentui/react-components'; import type { ButtonProps } from '@fluentui/react-components'; -export const Default = (props: ButtonProps): JSXElement => ; +export const Default = (props: ButtonProps): JSXElement => ( + + + +); diff --git a/packages/react-components/react-shared-contexts/library/src/OverridesContext/OverridesContext.ts b/packages/react-components/react-shared-contexts/library/src/OverridesContext/OverridesContext.ts index 618b8b694be96..21d8e67c4ba12 100644 --- a/packages/react-components/react-shared-contexts/library/src/OverridesContext/OverridesContext.ts +++ b/packages/react-components/react-shared-contexts/library/src/OverridesContext/OverridesContext.ts @@ -8,6 +8,7 @@ import * as React from 'react'; export type OverridesContextValue = { // No 'underline' as it is not supported by TextArea inputDefaultAppearance?: 'outline' | 'filled-darker' | 'filled-lighter'; + smartHover?: boolean; }; /** diff --git a/packages/react-components/react-utilities/src/hooks/index.ts b/packages/react-components/react-utilities/src/hooks/index.ts index 2985ecf995dbe..88049d3a9a6e4 100644 --- a/packages/react-components/react-utilities/src/hooks/index.ts +++ b/packages/react-components/react-utilities/src/hooks/index.ts @@ -5,6 +5,8 @@ export { useControllableState } from './useControllableState'; export { useEventCallback } from './useEventCallback'; export { useFirstMount } from './useFirstMount'; export { useForceUpdate } from './useForceUpdate'; +export type { HoverEvent, HoverProps, HoverResult, PointerType } from './useHover'; +export { useHover_unstable } from './useHover'; export { IdPrefixProvider, resetIdsForTests, useId } from './useId'; export { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; export type { RefObjectFunction } from './useMergedRefs'; diff --git a/packages/react-components/react-utilities/src/hooks/useHover.ts b/packages/react-components/react-utilities/src/hooks/useHover.ts new file mode 100644 index 0000000000000..bd602d45647d8 --- /dev/null +++ b/packages/react-components/react-utilities/src/hooks/useHover.ts @@ -0,0 +1,229 @@ +'use client'; + +import * as React from 'react'; +import { useEventCallback } from './useEventCallback'; +import { canUseDOM } from '../ssr/canUseDOM'; +import { elementContains } from '../virtualParent/elementContains'; + +/** + * The type of pointer that triggered the hover event. + * Touch is intentionally excluded as hover is not a valid interaction for touch devices. + */ +export type PointerType = 'mouse' | 'pen'; + +/** + * Event object passed to hover event handlers. + */ +export interface HoverEvent { + /** The type of hover event. */ + type: 'hoverstart' | 'hoverend'; + /** The pointer type that triggered the event. */ + pointerType: PointerType; + /** The target element. */ + target: HTMLElement; +} + +/** + * Props for the useHover hook. + */ +export interface HoverProps { + /** Whether hover events should be disabled. */ + isDisabled?: boolean; + /** Handler called when a hover interaction starts. */ + onHoverStart?: (e: HoverEvent) => void; + /** Handler called when a hover interaction ends. */ + onHoverEnd?: (e: HoverEvent) => void; + /** Handler called when the hover state changes. */ + onHoverChange?: (isHovered: boolean) => void; +} + +/** + * Result returned by the useHover hook. + */ +export interface HoverResult { + /** Props to spread on the target element. */ + hoverProps: React.DOMAttributes; + /** Whether the element is currently hovered. */ + isHovered: boolean; +} + +// Global state for ignoring emulated mouse events after touch. +// iOS fires onPointerEnter twice: once with pointerType="touch" and again with pointerType="mouse". +// We want to ignore these emulated events so they do not trigger hover behavior. +// See https://bugs.webkit.org/show_bug.cgi?id=214609 +let globalIgnoreEmulatedMouseEvents = false; +let hoverCount = 0; + +function setGlobalIgnoreEmulatedMouseEvents() { + globalIgnoreEmulatedMouseEvents = true; + + // Clear globalIgnoreEmulatedMouseEvents after a short timeout. iOS fires onPointerEnter + // with pointerType="mouse" immediately after onPointerUp and before onFocus. On other + // devices that don't have this quirk, we don't want to ignore a mouse hover sometime in + // the distant future because a user previously touched the element. + setTimeout(() => { + globalIgnoreEmulatedMouseEvents = false; + }, 50); +} + +function handleGlobalPointerEvent(e: PointerEvent) { + if (e.pointerType === 'touch') { + setGlobalIgnoreEmulatedMouseEvents(); + } +} + +function setupGlobalTouchEvents() { + if (!canUseDOM()) { + return; + } + + if (hoverCount === 0) { + document.addEventListener('pointerup', handleGlobalPointerEvent); + } + + hoverCount++; + + return () => { + hoverCount--; + if (hoverCount === 0) { + document.removeEventListener('pointerup', handleGlobalPointerEvent); + } + }; +} + +/** + * Handles pointer hover interactions for an element. Normalizes behavior + * across browsers and platforms, and ignores emulated mouse events on touch devices. + * + * This hook only triggers hover for mouse and pen pointer types, never for touch. + * This fixes the "sticky hover" problem on touch devices where CSS :hover persists + * after tapping an element. + * + * @param props - Props for the hover interaction + * @returns Hover state and props to spread on the target element + */ +export function useHover_unstable(props: HoverProps): HoverResult { + const { onHoverStart, onHoverChange, onHoverEnd, isDisabled } = props; + const [isHovered, setHovered] = React.useState(false); + + const state = React.useRef({ + isHovered: false, + pointerType: '', + target: null as HTMLElement | null, + }).current; + + // Setup/teardown global touch event listener + React.useEffect(setupGlobalTouchEvents, []); + + // Ref to store cleanup function for global pointerover listener + const removePointerOverListener = React.useRef<(() => void) | null>(null); + + const triggerHoverEnd = useEventCallback((pointerType: string) => { + console.log('triggerHoverEnd', { pointerType, isDisabled, currentHovered: state.isHovered }); + const target = state.target; + state.pointerType = ''; + state.target = null; + + if (pointerType === 'touch' || !state.isHovered || !target) { + return; + } + + state.isHovered = false; + + // Cleanup global listener + removePointerOverListener.current?.(); + removePointerOverListener.current = null; + + onHoverEnd?.({ + type: 'hoverend', + target, + pointerType: pointerType as PointerType, + }); + + onHoverChange?.(false); + setHovered(false); + }); + + const triggerHoverStart = useEventCallback((event: React.PointerEvent, pointerType: string) => { + console.log('triggerHoverStart', { pointerType, isDisabled, currentHovered: state.isHovered }); + state.pointerType = pointerType; + + if ( + isDisabled || + pointerType === 'touch' || + state.isHovered || + !elementContains(event.currentTarget, event.target as HTMLElement) + ) { + return; + } + + state.isHovered = true; + state.target = event.currentTarget; + + // When an element that is hovered over is removed, no pointerleave event is fired by the browser, + // even though the originally hovered target may have shrunk in size so it is no longer hovered. + // However, a pointerover event will be fired on the new target the mouse is over. + // We use this to detect when hover should end due to element removal. + const doc = event.currentTarget.ownerDocument; + const onPointerOver = (e: PointerEvent) => { + if (state.isHovered && state.target && !elementContains(state.target, e.target as HTMLElement)) { + triggerHoverEnd(e.pointerType); + } + }; + + doc.addEventListener('pointerover', onPointerOver, true); + removePointerOverListener.current = () => { + doc.removeEventListener('pointerover', onPointerOver, true); + }; + + onHoverStart?.({ + type: 'hoverstart', + target: event.currentTarget, + pointerType: pointerType as PointerType, + }); + + onHoverChange?.(true); + setHovered(true); + }); + + // Cleanup on unmount + React.useEffect(() => { + return () => { + removePointerOverListener.current?.(); + }; + }, []); + + // End hover when disabled changes to true + React.useEffect(() => { + if (isDisabled && state.isHovered) { + triggerHoverEnd(state.pointerType); + } + }, [isDisabled, triggerHoverEnd]); + + const hoverProps = React.useMemo>( + () => ({ + onPointerEnter: (e: React.PointerEvent) => { + // Ignore emulated mouse events after touch + console.log('entered'); + if (globalIgnoreEmulatedMouseEvents && e.pointerType === 'mouse') { + return; + } + triggerHoverStart(e, e.pointerType as PointerType); + }, + onPointerLeave: (e: React.PointerEvent) => { + console.log('leave'); + if (!isDisabled && elementContains(e.currentTarget, e.target as HTMLElement)) { + triggerHoverEnd(e.pointerType); + } + }, + }), + [isDisabled, triggerHoverStart, triggerHoverEnd], + ); + + console.log('count', hoverCount); + + return { + hoverProps, + isHovered, + }; +} diff --git a/packages/react-components/react-utilities/src/index.ts b/packages/react-components/react-utilities/src/index.ts index 68d394bf67003..f50a26e0af6b0 100644 --- a/packages/react-components/react-utilities/src/index.ts +++ b/packages/react-components/react-utilities/src/index.ts @@ -49,6 +49,7 @@ export { useEventCallback, useFirstMount, useForceUpdate, + useHover_unstable, useId, useIsomorphicLayoutEffect, useMergedRefs, @@ -58,7 +59,15 @@ export { useScrollbarWidth, useTimeout, } from './hooks/index'; -export type { RefObjectFunction, UseControllableStateOptions, UseOnClickOrScrollOutsideOptions } from './hooks/index'; +export type { + HoverEvent, + HoverProps, + HoverResult, + PointerType, + RefObjectFunction, + UseControllableStateOptions, + UseOnClickOrScrollOutsideOptions, +} from './hooks/index'; export { canUseDOM, useIsSSR, SSRProvider } from './ssr/index';