'use client'; import { Star } from 'lucide-react'; import { Slot as SlotPrimitive } from '@radix-ui/react-slot'; import { useDirection as useDirectionPrimitive } from '@radix-ui/react-direction'; import * as React from 'react'; import { useComposedRefs } from '@djangocfg/ui-core/lib'; import { cn } from '@djangocfg/ui-core/lib'; import { VisuallyHiddenInput } from '@djangocfg/ui-core/components'; import type { RatingProps, RatingItemProps, StoreState, Store, RatingContextValue, FocusContextValue, ItemData, Direction, Orientation, FocusIntent, DataState, } from './types'; const ROOT_NAME = 'Rating'; const ITEM_NAME = 'RatingItem'; const ENTRY_FOCUS = 'ratingFocusGroup.onEntryFocus'; const EVENT_OPTIONS = { bubbles: false, cancelable: true }; function getItemId(id: string, value: number) { return `${id}-item-${value}`; } function getPartialFillGradientId(id: string, step: number) { return `partial-fill-gradient-${id}-${step}`; } const MAP_KEY_TO_FOCUS_INTENT: Record = { ArrowLeft: 'prev', ArrowUp: 'prev', ArrowRight: 'next', ArrowDown: 'next', Home: 'first', End: 'last', }; function getDirectionAwareKey(key: string, dir?: Direction) { if (dir !== 'rtl') return key; return key === 'ArrowLeft' ? 'ArrowRight' : key === 'ArrowRight' ? 'ArrowLeft' : key; } function getFocusIntent( event: React.KeyboardEvent, dir?: Direction, orientation?: Orientation, ) { const key = getDirectionAwareKey(event.key, dir); if (orientation === 'horizontal' && ['ArrowUp', 'ArrowDown'].includes(key)) return undefined; if (orientation === 'vertical' && ['ArrowLeft', 'ArrowRight'].includes(key)) return undefined; return MAP_KEY_TO_FOCUS_INTENT[key]; } function focusFirst( candidates: React.RefObject[], preventScroll = false, ) { const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement; for (const candidateRef of candidates) { const candidate = candidateRef.current; if (!candidate) continue; if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return; candidate.focus({ preventScroll }); if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return; } } function useLazyRef(init: () => T): React.RefObject { const ref = React.useRef(null); if (ref.current === null) { ref.current = init(); } return ref as React.RefObject; } function useAsRef(value: T) { const ref = React.useRef(value); React.useEffect(() => { ref.current = value; }); return ref; } const StoreContext = React.createContext(null); function useStoreContext(consumerName: string) { const context = React.useContext(StoreContext); if (!context) { throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); } return context; } function useStore( selector: (state: StoreState) => T, ogStore?: Store | null, ): T { const contextStore = React.useContext(StoreContext); const store = ogStore ?? contextStore; if (!store) { throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``); } const getSnapshot = React.useCallback( () => selector(store.getState()), [store, selector], ); return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); } const RatingContext = React.createContext(null); function useRatingContext(consumerName: string) { const context = React.useContext(RatingContext); if (!context) { throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); } return context; } const FocusContext = React.createContext(null); function useFocusContext(consumerName: string) { const context = React.useContext(FocusContext); if (!context) { throw new Error( `\`${consumerName}\` must be used within \`FocusProvider\``, ); } return context; } export function Rating(props: RatingProps) { const { value: valueProp, defaultValue = 0, onValueChange, onHover, onFocus: onFocusProp, onMouseDown: onMouseDownProp, dir: dirProp, orientation = 'horizontal', activationMode = 'automatic', size = 'default', max = 5, step = 1, clearable = false, asChild, disabled = false, readOnly = false, required = false, className, id, name, ref, ...rootProps } = props; const dir = useDirectionPrimitive(dirProp); const instanceId = React.useId(); const rootId = id ?? instanceId; const listenersRef = useLazyRef(() => new Set<() => void>()); const stateRef = useLazyRef(() => ({ value: valueProp ?? defaultValue, hoveredValue: null, })); const propsRef = useAsRef({ onValueChange, onHover, onFocus: onFocusProp, onMouseDown: onMouseDownProp, step, }); const store = React.useMemo(() => { return { subscribe: (cb) => { listenersRef.current.add(cb); return () => listenersRef.current.delete(cb); }, getState: () => stateRef.current, setState: (key, value) => { if (Object.is(stateRef.current[key], value)) return; if (key === 'value' && typeof value === 'number') { stateRef.current.value = value; propsRef.current.onValueChange?.(value); } else if (key === 'hoveredValue') { stateRef.current.hoveredValue = value as number | null; propsRef.current.onHover?.(value as number | null); } else { stateRef.current[key] = value; } store.notify(); }, notify: () => { for (const cb of listenersRef.current) { cb(); } }, }; }, [listenersRef, stateRef, propsRef]); React.useLayoutEffect(() => { if (valueProp !== undefined) { store.setState('value', valueProp); } }, [valueProp, store]); const value = useStore((state) => state.value, store); const [formTrigger, setFormTrigger] = React.useState(null); const composedRef = useComposedRefs(ref, (node: HTMLDivElement | null) => setFormTrigger(node)); const isFormControl = formTrigger ? !!formTrigger.closest('form') : true; const [tabStopId, setTabStopId] = React.useState(null); const [isTabbingBackOut, setIsTabbingBackOut] = React.useState(false); const [focusableItemCount, setFocusableItemCount] = React.useState(0); const isClickFocusRef = React.useRef(false); const itemsRef = React.useRef>(new Map()); const autoIndexMapRef = React.useRef(new Map()); const nextAutoIndexRef = React.useRef(0); const getAutoIndex = React.useCallback((instanceId: string) => { const existingIndex = autoIndexMapRef.current.get(instanceId); if (existingIndex !== undefined) { return existingIndex; } const newIndex = nextAutoIndexRef.current++; autoIndexMapRef.current.set(instanceId, newIndex); return newIndex; }, []); const onItemFocus = React.useCallback((tabStopId: string) => { setTabStopId(tabStopId); }, []); const onItemShiftTab = React.useCallback(() => { setIsTabbingBackOut(true); }, []); const onFocusableItemAdd = React.useCallback(() => { setFocusableItemCount((prevCount) => prevCount + 1); }, []); const onFocusableItemRemove = React.useCallback(() => { setFocusableItemCount((prevCount) => prevCount - 1); }, []); const onItemRegister = React.useCallback((item: ItemData) => { itemsRef.current.set(item.id, item); }, []); const onItemUnregister = React.useCallback((id: string) => { itemsRef.current.delete(id); }, []); const getItems = React.useCallback(() => { return Array.from(itemsRef.current.values()) .filter((item) => item.ref.current) .sort((a, b) => { const elementA = a.ref.current; const elementB = b.ref.current; if (!elementA || !elementB) return 0; const position = elementA.compareDocumentPosition(elementB); if (position & Node.DOCUMENT_POSITION_FOLLOWING) { return -1; } if (position & Node.DOCUMENT_POSITION_PRECEDING) { return 1; } return 0; }); }, []); const onBlur = React.useCallback( (event: React.FocusEvent) => { rootProps.onBlur?.(event); if (event.defaultPrevented) return; setIsTabbingBackOut(false); }, [rootProps.onBlur], ); const onFocus = React.useCallback( (event: React.FocusEvent) => { propsRef.current.onFocus?.(event); if (event.defaultPrevented) return; const isKeyboardFocus = !isClickFocusRef.current; if ( event.target === event.currentTarget && isKeyboardFocus && !isTabbingBackOut ) { const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS); event.currentTarget.dispatchEvent(entryFocusEvent); if (!entryFocusEvent.defaultPrevented) { const items = Array.from(itemsRef.current.values()).filter( (item) => !item.disabled, ); const selectedItem = propsRef.current.step < 1 ? items.find((item) => item.value === Math.ceil(value)) : items.find((item) => item.value === value); const currentItem = items.find((item) => item.id === tabStopId); const candidateItems = [selectedItem, currentItem, ...items].filter( Boolean, ) as ItemData[]; const candidateRefs = candidateItems.map((item) => item.ref); focusFirst(candidateRefs, false); } } isClickFocusRef.current = false; }, [propsRef, isTabbingBackOut, value, tabStopId], ); const onMouseDown = React.useCallback( (event: React.MouseEvent) => { propsRef.current.onMouseDown?.(event); if (event.defaultPrevented) return; isClickFocusRef.current = true; }, [propsRef], ); const contextValue = React.useMemo( () => ({ rootId, dir, orientation, activationMode, disabled, readOnly, size, max, step, clearable, getAutoIndex, }), [ rootId, dir, orientation, activationMode, disabled, readOnly, size, max, step, clearable, getAutoIndex, ], ); const focusContextValue = React.useMemo( () => ({ tabStopId, onItemFocus, onItemShiftTab, onFocusableItemAdd, onFocusableItemRemove, onItemRegister, onItemUnregister, getItems, }), [ tabStopId, onItemFocus, onItemShiftTab, onFocusableItemAdd, onFocusableItemRemove, onItemRegister, onItemUnregister, getItems, ], ); const RootPrimitive = asChild ? SlotPrimitive : 'div'; return ( {dir === 'rtl' ? ( <> ) : ( <> )} {isFormControl && ( )} ); } Rating.displayName = ROOT_NAME; export function RatingItem(props: RatingItemProps) { const { index, asChild, onClick: onClickProp, onFocus: onFocusProp, onKeyDown: onKeyDownProp, onMouseDown: onMouseDownProp, onMouseEnter: onMouseEnterProp, onMouseMove: onMouseMoveProp, onMouseLeave: onMouseLeaveProp, disabled, className, children, ref, ...itemProps } = props; const itemRef = React.useRef(null); const composedRef = useComposedRefs(ref, itemRef); const context = useRatingContext(ITEM_NAME); const instanceId = React.useId(); const actualIndex = React.useMemo(() => { if (index !== undefined) { return index; } return context.getAutoIndex(instanceId); }, [index, context, instanceId]); const itemValue = actualIndex + 1; const store = useStoreContext(ITEM_NAME); const focusContext = useFocusContext(ITEM_NAME); const value = useStore((state) => state.value); const hoveredValue = useStore((state) => state.hoveredValue); const clearable = context.clearable; const step = context.step; const activationMode = context.activationMode; const itemId = getItemId(context.rootId, itemValue); const isDisabled = context.disabled || disabled; const isReadOnly = context.readOnly; const isTabStop = focusContext.tabStopId === itemId; const displayValue = hoveredValue ?? value; const isFilled = displayValue >= itemValue; const isPartiallyFilled = step < 1 && displayValue >= itemValue - step && displayValue < itemValue; const isHovered = hoveredValue !== null && hoveredValue < itemValue; const isMouseClickRef = React.useRef(false); const propsRef = useAsRef({ onClick: onClickProp, onFocus: onFocusProp, onKeyDown: onKeyDownProp, onMouseDown: onMouseDownProp, onMouseEnter: onMouseEnterProp, onMouseMove: onMouseMoveProp, onMouseLeave: onMouseLeaveProp, }); React.useLayoutEffect(() => { focusContext.onItemRegister({ id: itemId, ref: itemRef, value: itemValue, disabled: !!isDisabled, }); if (!isDisabled) { focusContext.onFocusableItemAdd(); } return () => { focusContext.onItemUnregister(itemId); if (!isDisabled) { focusContext.onFocusableItemRemove(); } }; }, [focusContext, itemId, itemValue, isDisabled]); const onClick = React.useCallback( (event: React.MouseEvent) => { propsRef.current.onClick?.(event); if (event.defaultPrevented) return; if (!isDisabled && !isReadOnly) { let newValue = itemValue; if (step < 1) { const rect = event.currentTarget.getBoundingClientRect(); const clickX = event.clientX - rect.left; const isLeftHalf = clickX < rect.width / 2; if (context.dir === 'rtl') { if (!isLeftHalf) { newValue = itemValue - step; } } else { if (isLeftHalf) { newValue = itemValue - step; } } } if (clearable && value === newValue) { newValue = 0; } store.setState('value', newValue); } }, [ isDisabled, isReadOnly, clearable, step, value, itemValue, store, context.dir, propsRef, ], ); const onFocus = React.useCallback( (event: React.FocusEvent) => { propsRef.current.onFocus?.(event); if (event.defaultPrevented) return; focusContext.onItemFocus(itemId); const isKeyboardFocus = !isMouseClickRef.current; if ( !isDisabled && !isReadOnly && activationMode !== 'manual' && isKeyboardFocus ) { const isHalfStepValue = step < 1 && value === itemValue - step; if (!isHalfStepValue) { const newValue = clearable && value === itemValue ? 0 : itemValue; store.setState('value', newValue); } } isMouseClickRef.current = false; }, [ focusContext, itemId, activationMode, isDisabled, isReadOnly, clearable, value, itemValue, step, store, propsRef, ], ); const onKeyDown = React.useCallback( (event: React.KeyboardEvent) => { propsRef.current.onKeyDown?.(event); if (event.defaultPrevented) return; if ( (event.key === 'Enter' || event.key === ' ') && activationMode === 'manual' ) { event.preventDefault(); if (!isDisabled && !isReadOnly && itemRef.current) { itemRef.current.click(); } return; } if (event.key === 'Tab' && event.shiftKey) { focusContext.onItemShiftTab(); return; } if (event.target !== event.currentTarget) return; const focusIntent = getFocusIntent( event, context.dir, context.orientation, ); if (focusIntent !== undefined) { if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return; event.preventDefault(); if (step < 1 && (focusIntent === 'prev' || focusIntent === 'next')) { if (!isDisabled && !isReadOnly) { let newValue = value; if (focusIntent === 'next') { newValue = Math.min(value + step, context.max); } else { newValue = Math.max(value - step, 0); } store.setState('value', newValue); const items = focusContext .getItems() .filter((item) => !item.disabled); const targetItem = items.find( (item) => item.value === Math.ceil(newValue), ); if (targetItem?.ref.current) { queueMicrotask(() => targetItem.ref.current?.focus()); } } return; } const items = focusContext.getItems().filter((item) => !item.disabled); let candidateRefs = items.map((item) => item.ref); if (focusIntent === 'last') { candidateRefs.reverse(); } else if (focusIntent === 'prev' || focusIntent === 'next') { if (focusIntent === 'prev') candidateRefs.reverse(); const currentIndex = candidateRefs.findIndex( (ref) => ref.current === event.currentTarget, ); candidateRefs = candidateRefs.slice(currentIndex + 1); } queueMicrotask(() => focusFirst(candidateRefs)); } }, [ focusContext, context.dir, context.orientation, activationMode, isDisabled, isReadOnly, step, value, context.max, store, propsRef, ], ); const onMouseDown = React.useCallback( (event: React.MouseEvent) => { propsRef.current.onMouseDown?.(event); if (event.defaultPrevented) return; isMouseClickRef.current = true; if (isDisabled) { event.preventDefault(); } else { focusContext.onItemFocus(itemId); } }, [focusContext, itemId, isDisabled, propsRef], ); const onMouseEnter = React.useCallback( (event: React.MouseEvent) => { propsRef.current.onMouseEnter?.(event); if (event.defaultPrevented) return; if (!isDisabled && !isReadOnly) { let hoverValue = itemValue; if (step < 1) { const rect = event.currentTarget.getBoundingClientRect(); const mouseX = event.clientX - rect.left; const isLeftHalf = mouseX < rect.width / 2; if (context.dir === 'rtl') { if (!isLeftHalf) { hoverValue = itemValue - step; } } else { if (isLeftHalf) { hoverValue = itemValue - step; } } } store.setState('hoveredValue', hoverValue); } }, [isDisabled, isReadOnly, step, itemValue, store, context.dir, propsRef], ); const onMouseLeave = React.useCallback( (event: React.MouseEvent) => { propsRef.current.onMouseLeave?.(event); if (event.defaultPrevented) return; if (!isDisabled && !isReadOnly) { store.setState('hoveredValue', null); } }, [isDisabled, isReadOnly, store, propsRef], ); const onMouseMove = React.useCallback( (event: React.MouseEvent) => { propsRef.current.onMouseMove?.(event); if (event.defaultPrevented) return; if (!isDisabled && !isReadOnly && step < 1) { const rect = event.currentTarget.getBoundingClientRect(); const mouseX = event.clientX - rect.left; const isLeftHalf = mouseX < rect.width / 2; let hoverValue = itemValue; if (context.dir === 'rtl') { hoverValue = !isLeftHalf ? itemValue - step : itemValue; } else { hoverValue = isLeftHalf ? itemValue - step : itemValue; } store.setState('hoveredValue', hoverValue); } }, [isDisabled, isReadOnly, step, itemValue, store, context.dir, propsRef], ); const dataState: DataState = isFilled ? 'full' : isPartiallyFilled ? 'partial' : 'empty'; const ItemPrimitive = asChild ? SlotPrimitive : 'button'; return ( {typeof children === 'function' ? children(dataState) : (children ?? )} ); } RatingItem.displayName = ITEM_NAME;