-
-
- {bucket.selected.map((item) => (
-
moveChip(item, "remove", () => onRemove(item))}
- >
- {item}
-
-
- ))}
-
-
- Add
-
-
-
-
+
+
+
+ {bucket.label}
+
+ {bucket.required ? "Required" : "Optional"}
+ {bucket.locked ? " · Locked" : null}
+ {bucket.disabled ? " · Disabled" : null}
+
+
+
+ )
+}
+
+function SelectedContext({
+ selectedRef,
+ onSelectedWheel,
+ bucket,
+ chipMotions,
+ moveChip,
+ onRemove,
+ onAdd,
+}: {
+ selectedRef: React.RefObject
+ onSelectedWheel: (event: React.WheelEvent) => void
+ bucket: ReportContextBucket
+ chipMotions: Record
+ moveChip: (
+ value: string,
+ direction: ContextChipMotion["direction"],
+ commit: () => void
+ ) => void
+ onRemove: (value: string) => void
+ onAdd: (() => void) | undefined
+}) {
+ return (
+
+
+ {bucket.selected.map((item) => (
+
moveChip(item, "remove", () => onRemove(item))}
+ >
+ {item}
+
+
+ ))}
+
+
+ Add
+
+
+
+
+ )
+}
+
function chipMotionClass(motion?: ContextChipMotion) {
if (!motion) return undefined
return motion.direction === "select"
diff --git a/packages/ui/src/blocks/sidebar.tsx b/packages/ui/src/blocks/sidebar.tsx
index f5f0a98..abe3648 100644
--- a/packages/ui/src/blocks/sidebar.tsx
+++ b/packages/ui/src/blocks/sidebar.tsx
@@ -81,7 +81,6 @@ function SidebarToggleButton({
)
}
-// oxlint-disable-next-line complexity, max-lines-per-function -- Legacy baseline: function `SidebarBrand` has a complexity of 33; The function `SidebarBrand` has too many lines (182); extract this function in the follow-up refactor.
function SidebarBrand({
brand = "Nextide UI",
eyebrow = "Package",
@@ -95,8 +94,6 @@ function SidebarBrand({
onToggle,
className,
}: SidebarBrandProps) {
- const clipBrandText = drawerCollapsed || drawerTransitioning
- const condensed = density !== "current"
const toggleButton = onToggle ? (
) : null
- const brandMark = (
+ return (
+
+ )
+}
+
+export {
+ SidebarBrand,
+ SidebarToggleButton,
+ type SidebarBrandProps,
+ type SidebarToggleButtonProps,
+}
+
+function SidebarBrandMark({
+ density,
+ logo,
+}: {
+ density: ShellDensity
+ logo: React.ReactNode
+}) {
+ return (
)
+}
+
+type BrandTextProps = Required<
+ Pick<
+ SidebarBrandProps,
+ | "brand"
+ | "eyebrow"
+ | "byline"
+ | "collapsed"
+ | "drawerCollapsed"
+ | "drawerTransitioning"
+ | "density"
+ >
+> &
+ Pick
+function SidebarBrandText({
+ brand,
+ eyebrow,
+ byline,
+ bylineLogo,
+ collapsed,
+ drawerCollapsed,
+ drawerTransitioning,
+ density,
+}: BrandTextProps) {
+ const clipBrandText = drawerCollapsed || drawerTransitioning
const bylineMark = bylineLogo ?? (
)
-
return (
-
- {brandMark}
-
+
+
+ )
+}
+
+function SidebarBrandCopy({
+ brand,
+ eyebrow,
+ density,
+ bylineMark,
+}: Pick & {
+ bylineMark: React.ReactNode
+}) {
+ const condensed = density !== "current"
+ return condensed ? (
+ <>
+
+ {brand}
+
+
+ {eyebrow}
+
+
+
- {condensed ? (
- <>
-
- {brand}
-
-
- {eyebrow}
-
-
-
- By
-
- {/* The wordmark's lowercase cap begins 28% below the image edge. */}
-
- {bylineMark}
-
-
- >
- ) : (
- <>
-
- {brand}
-
-
- {eyebrow}
-
-
-
- By
-
-
- {bylineMark}
-
-
- >
+ By
+
+ {/* The wordmark's lowercase cap begins 28% below the image edge. */}
+
+ {bylineMark}
- {toggleButton}
-
+ >
+ ) : (
+ <>
+
+ {brand}
+
+
+ {eyebrow}
+
+
+
+ By
+
+
+ {bylineMark}
+
+
+ >
)
}
-
-export {
- SidebarBrand,
- SidebarToggleButton,
- type SidebarBrandProps,
- type SidebarToggleButtonProps,
-}
diff --git a/packages/ui/src/blocks/stream-selector-motion.ts b/packages/ui/src/blocks/stream-selector-motion.ts
new file mode 100644
index 0000000..7a9e18d
--- /dev/null
+++ b/packages/ui/src/blocks/stream-selector-motion.ts
@@ -0,0 +1,369 @@
+import * as React from "react"
+import type { CreatorScopeItem } from "@nextide/ui/blocks/creator-scope-panel"
+import type { StreamSelectorItem } from "./stream-selector.js"
+import { useContainedScroll } from "@nextide/ui/hooks/use-contained-scroll"
+
+const filterEase = "cubic-bezier(0.76, 0, 0.24, 1)"
+const defaultFilterMotion = { exit: 220, move: 300, enter: 220 }
+
+type MotionContext = {
+ activeCreatorId: string
+ sortedStreams: StreamSelectorItem[]
+ renderedStreams: StreamSelectorItem[]
+ motionLocked: boolean
+ rowRefs: React.RefObject>
+ listRef: React.RefObject
+ filterMotion: React.RefObject
+ reflowMotion: React.RefObject<{
+ previousRects: Map
+ enteringIds: Set
+ } | null>
+ takeReflowMotion: () => MotionContext["reflowMotion"]["current"]
+ queueMotionTimer: (callback: () => void, delay: number) => void
+ setActiveCreatorId: React.Dispatch>
+ setMotionLocked: React.Dispatch>
+ setEnteringStreamIds: React.Dispatch>>
+ setRenderedStreams: React.Dispatch>
+}
+
+function useStreamFilterMotion(
+ creators: CreatorScopeItem[],
+ streams: StreamSelectorItem[]
+) {
+ const [activeCreatorId, setActiveCreatorId] = React.useState("all")
+ const sortedStreams = React.useMemo(() => streams, [streams])
+ const [renderedStreams, setRenderedStreams] = React.useState(sortedStreams)
+ const [enteringStreamIds, setEnteringStreamIds] = React.useState>(
+ () => new Set()
+ )
+ const [motionLocked, setMotionLocked] = React.useState(false)
+ const rowRefs = React.useRef>({})
+ const filterMotion = React.useRef(defaultFilterMotion)
+ const reflowMotion = React.useRef<{
+ previousRects: Map
+ enteringIds: Set
+ } | null>(null)
+ const { ref: listRef, onWheel } = useContainedScroll({
+ axis: "y",
+ })
+ const visibleStreams = React.useMemo(
+ () =>
+ activeCreatorId === "all"
+ ? sortedStreams
+ : sortedStreams.filter(
+ (stream) => stream.creatorId === activeCreatorId
+ ),
+ [activeCreatorId, sortedStreams]
+ )
+
+ const { clearMotionTimers, queueMotionTimer } = useMotionTimers()
+ const takeReflowMotion = React.useCallback(() => {
+ const motion = reflowMotion.current
+ reflowMotion.current = null
+ return motion
+ }, [])
+ const setRowRef = (id: string, node: HTMLButtonElement | null) => {
+ if (node) rowRefs.current[id] = node
+ else delete rowRefs.current[id]
+ }
+
+ const context = {
+ activeCreatorId,
+ sortedStreams,
+ renderedStreams,
+ motionLocked,
+ rowRefs,
+ listRef,
+ filterMotion,
+ reflowMotion,
+ takeReflowMotion,
+ queueMotionTimer,
+ setActiveCreatorId,
+ setMotionLocked,
+ setEnteringStreamIds,
+ setRenderedStreams,
+ }
+ React.useEffect(() => () => clearMotionTimers(), [clearMotionTimers])
+ useStreamReflow(context)
+ useStreamFilterSync({
+ activeCreatorId,
+ creators,
+ motionLocked,
+ renderedStreams,
+ visibleStreams,
+ setRenderedStreams,
+ setActiveCreatorId,
+ })
+ return {
+ activeCreatorId,
+ renderedStreams,
+ enteringStreamIds,
+ motionLocked,
+ setRowRef,
+ listRef,
+ onWheel,
+ changeCreatorFilter: (nextCreatorId: string) =>
+ changeCreatorFilter(nextCreatorId, context),
+ }
+}
+
+function changeCreatorFilter(
+ nextCreatorId: string,
+ {
+ motionLocked,
+ activeCreatorId,
+ filterMotion,
+ listRef,
+ sortedStreams,
+ renderedStreams,
+ rowRefs,
+ setActiveCreatorId,
+ setMotionLocked,
+ queueMotionTimer,
+ reflowMotion,
+ setEnteringStreamIds,
+ setRenderedStreams,
+ }: MotionContext
+) {
+ if (motionLocked || nextCreatorId === activeCreatorId) return
+
+ filterMotion.current = readFilterMotion(listRef.current)
+ const motion = filterMotion.current
+ const stateScale = motion.exit / defaultFilterMotion.exit
+
+ const nextStreams =
+ nextCreatorId === "all"
+ ? sortedStreams
+ : sortedStreams.filter((stream) => stream.creatorId === nextCreatorId)
+ const previousIds = new Set(renderedStreams.map((stream) => stream.id))
+ const nextIds = new Set(nextStreams.map((stream) => stream.id))
+ const exitingIds = new Set(
+ [...previousIds].filter((streamId) => !nextIds.has(streamId))
+ )
+ const enteringIds = new Set()
+ nextStreams.forEach((stream) => {
+ if (!previousIds.has(stream.id)) {
+ enteringIds.add(stream.id)
+ }
+ })
+ const previousRects = new Map()
+
+ renderedStreams.forEach((stream) => {
+ const row = rowRefs.current[stream.id]
+ if (row) {
+ row.getAnimations().forEach((animation) => animation.cancel())
+ previousRects.set(stream.id, row.getBoundingClientRect())
+ }
+ })
+
+ setActiveCreatorId(nextCreatorId)
+ setMotionLocked(true)
+ ;[...exitingIds].forEach((streamId, index) => {
+ const row = rowRefs.current[streamId]
+ if (!row) return
+
+ row.animate(
+ [
+ { opacity: 1, transform: "translate3d(0, 0, 0) scale(1)" },
+ { opacity: 0, transform: "translate3d(108%, 0, 0) scale(0.985)" },
+ ],
+ {
+ delay: Math.min(index * 17, 50) * stateScale,
+ duration: motion.exit,
+ easing: filterEase,
+ fill: "forwards",
+ }
+ )
+ })
+
+ const exitDelay =
+ exitingIds.size > 0
+ ? motion.exit + Math.min((exitingIds.size - 1) * 17, 50) * stateScale
+ : 0
+ queueMotionTimer(() => {
+ reflowMotion.current = { previousRects, enteringIds }
+ setEnteringStreamIds(enteringIds)
+ setRenderedStreams(nextStreams)
+ }, exitDelay)
+}
+
+function useStreamReflow({
+ takeReflowMotion,
+ filterMotion,
+ renderedStreams,
+ rowRefs,
+ queueMotionTimer,
+ setEnteringStreamIds,
+ setMotionLocked,
+}: MotionContext) {
+ React.useLayoutEffect(() => {
+ const motion = takeReflowMotion()
+ if (!motion) return
+ const durations = filterMotion.current
+ const stateScale = durations.enter / defaultFilterMotion.enter
+
+ const survivors = renderedStreams.filter(
+ (stream) =>
+ motion.previousRects.has(stream.id) &&
+ !motion.enteringIds.has(stream.id)
+ )
+
+ survivors.forEach((stream) => {
+ const row = rowRefs.current[stream.id]
+ const previousRect = motion.previousRects.get(stream.id)
+ if (!row || !previousRect) return
+
+ const nextRect = row.getBoundingClientRect()
+ const deltaX = previousRect.left - nextRect.left
+ const deltaY = previousRect.top - nextRect.top
+
+ if (Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5) {
+ row.animate(
+ [
+ {
+ transform: `translate3d(${deltaX}px, ${deltaY}px, 0)`,
+ opacity: 1,
+ },
+ { transform: "translate3d(0, 0, 0)", opacity: 1 },
+ ],
+ { duration: durations.move, easing: filterEase }
+ )
+ }
+ })
+
+ queueMotionTimer(
+ () => {
+ const enteringIds = [...motion.enteringIds]
+ enteringIds.forEach((streamId, index) => {
+ const row = rowRefs.current[streamId]
+ if (!row) return
+
+ row.animate(
+ [
+ { opacity: 0, transform: "translate3d(108%, 0, 0) scale(0.985)" },
+ { opacity: 1, transform: "translate3d(0, 0, 0) scale(1)" },
+ ],
+ {
+ delay: Math.min(index * 20, 61) * stateScale,
+ duration: durations.enter,
+ easing: filterEase,
+ fill: "both",
+ }
+ )
+ })
+
+ const enterDelay =
+ enteringIds.length > 0
+ ? durations.enter +
+ Math.min((enteringIds.length - 1) * 20, 61) * stateScale
+ : 0
+ queueMotionTimer(() => {
+ setEnteringStreamIds(new Set())
+ setMotionLocked(false)
+ }, enterDelay)
+ },
+ survivors.length > 0 ? durations.move : 0
+ )
+ }, [
+ queueMotionTimer,
+ renderedStreams,
+ takeReflowMotion,
+ filterMotion,
+ rowRefs,
+ setEnteringStreamIds,
+ setMotionLocked,
+ ])
+}
+
+function useStreamFilterSync({
+ activeCreatorId,
+ creators,
+ motionLocked,
+ renderedStreams,
+ visibleStreams,
+ setRenderedStreams,
+ setActiveCreatorId,
+}: Pick<
+ MotionContext,
+ | "activeCreatorId"
+ | "motionLocked"
+ | "renderedStreams"
+ | "setRenderedStreams"
+ | "setActiveCreatorId"
+> & {
+ creators: CreatorScopeItem[]
+ visibleStreams: StreamSelectorItem[]
+}) {
+ React.useEffect(() => {
+ if (motionLocked) return
+
+ const renderedIds = renderedStreams.map((stream) => stream.id).join("|")
+ const visibleIds = visibleStreams.map((stream) => stream.id).join("|")
+ if (renderedIds === visibleIds) return
+
+ const syncTimer = window.setTimeout(() => {
+ setRenderedStreams(visibleStreams)
+ }, 0)
+
+ return () => window.clearTimeout(syncTimer)
+ }, [motionLocked, renderedStreams, visibleStreams, setRenderedStreams])
+
+ React.useEffect(() => {
+ if (activeCreatorId === "all") return
+ if (creators.some((creator) => creator.id === activeCreatorId)) return
+
+ const syncTimer = window.setTimeout(() => {
+ setActiveCreatorId("all")
+ }, 0)
+
+ return () => window.clearTimeout(syncTimer)
+ }, [activeCreatorId, creators, setActiveCreatorId])
+}
+
+function readFilterMotion(node: HTMLElement | null) {
+ if (!node || typeof window === "undefined") return defaultFilterMotion
+
+ const styles = window.getComputedStyle(node)
+ return {
+ exit: readCssTime(
+ styles.getPropertyValue("--nextide-motion-state"),
+ defaultFilterMotion.exit
+ ),
+ move: readCssTime(
+ styles.getPropertyValue("--nextide-motion-layout"),
+ defaultFilterMotion.move
+ ),
+ enter: readCssTime(
+ styles.getPropertyValue("--nextide-motion-state"),
+ defaultFilterMotion.enter
+ ),
+ }
+}
+
+function readCssTime(value: string, fallback: number) {
+ const parsed = Number.parseFloat(value)
+ if (!Number.isFinite(parsed)) return fallback
+ return value.trim().endsWith("s") && !value.trim().endsWith("ms")
+ ? parsed * 1000
+ : parsed
+}
+
+export { useStreamFilterMotion }
+
+function useMotionTimers() {
+ const motionTimers = React.useRef([])
+ const clearMotionTimers = React.useCallback(() => {
+ motionTimers.current.forEach((timer) => window.clearTimeout(timer))
+ motionTimers.current = []
+ }, [])
+
+ const queueMotionTimer = React.useCallback(
+ (callback: () => void, delay: number) => {
+ const timer = window.setTimeout(callback, delay)
+ motionTimers.current.push(timer)
+ },
+ []
+ )
+
+ return { clearMotionTimers, queueMotionTimer }
+}
diff --git a/packages/ui/src/blocks/stream-selector.tsx b/packages/ui/src/blocks/stream-selector.tsx
index c9ae334..9d0ecf8 100644
--- a/packages/ui/src/blocks/stream-selector.tsx
+++ b/packages/ui/src/blocks/stream-selector.tsx
@@ -7,7 +7,7 @@ import {
} from "@nextide/ui/blocks/creator-scope-panel"
import { Empty, EmptyDescription } from "@nextide/ui/components/empty"
import { StatusBadge } from "@nextide/ui/components/status-badge"
-import { useContainedScroll } from "@nextide/ui/hooks/use-contained-scroll"
+import { useStreamFilterMotion } from "./stream-selector-motion.js"
import { cn } from "@nextide/ui/lib/utils"
type StreamSelectorTone = "success" | "processing" | "warning" | "danger"
@@ -31,10 +31,6 @@ type StreamSelectorCreatorScopeProps = Pick<
"beforeHeader" | "children" | "getAction" | "title" | "allLabel"
>
-const filterEase = "cubic-bezier(0.76, 0, 0.24, 1)"
-const defaultFilterMotion = { exit: 220, move: 300, enter: 220 }
-
-// oxlint-disable-next-line max-lines-per-function -- Legacy baseline: The function `StreamSelector` has too many lines (302); extract this function in the follow-up refactor.
function StreamSelector({
creators,
streams,
@@ -54,207 +50,17 @@ function StreamSelector({
creatorScopeProps?: StreamSelectorCreatorScopeProps
emptyLabel?: React.ReactNode
}) {
- const [activeCreatorId, setActiveCreatorId] = React.useState("all")
- const sortedStreams = React.useMemo(() => streams, [streams])
- const [renderedStreams, setRenderedStreams] = React.useState(sortedStreams)
- const [enteringStreamIds, setEnteringStreamIds] = React.useState>(
- () => new Set()
- )
- const [motionLocked, setMotionLocked] = React.useState(false)
+ const {
+ activeCreatorId,
+ renderedStreams,
+ enteringStreamIds,
+ motionLocked,
+ setRowRef,
+ listRef,
+ onWheel,
+ changeCreatorFilter,
+ } = useStreamFilterMotion(creators, streams)
const selectedIdSet = React.useMemo(() => new Set(selectedIds), [selectedIds])
- const rowRefs = React.useRef>({})
- const motionTimers = React.useRef([])
- const filterMotion = React.useRef(defaultFilterMotion)
- const reflowMotion = React.useRef<{
- previousRects: Map
- enteringIds: Set
- } | null>(null)
- const { ref: listRef, onWheel } = useContainedScroll({
- axis: "y",
- })
- const visibleStreams = React.useMemo(
- () =>
- activeCreatorId === "all"
- ? sortedStreams
- : sortedStreams.filter(
- (stream) => stream.creatorId === activeCreatorId
- ),
- [activeCreatorId, sortedStreams]
- )
-
- const clearMotionTimers = React.useCallback(() => {
- motionTimers.current.forEach((timer) => window.clearTimeout(timer))
- motionTimers.current = []
- }, [])
-
- const queueMotionTimer = React.useCallback(
- (callback: () => void, delay: number) => {
- const timer = window.setTimeout(callback, delay)
- motionTimers.current.push(timer)
- },
- []
- )
-
- const changeCreatorFilter = (nextCreatorId: string) => {
- if (motionLocked || nextCreatorId === activeCreatorId) return
-
- filterMotion.current = readFilterMotion(listRef.current)
- const motion = filterMotion.current
- const stateScale = motion.exit / defaultFilterMotion.exit
-
- const nextStreams =
- nextCreatorId === "all"
- ? sortedStreams
- : sortedStreams.filter((stream) => stream.creatorId === nextCreatorId)
- const previousIds = new Set(renderedStreams.map((stream) => stream.id))
- const nextIds = new Set(nextStreams.map((stream) => stream.id))
- const exitingIds = new Set(
- [...previousIds].filter((streamId) => !nextIds.has(streamId))
- )
- const enteringIds = new Set()
- nextStreams.forEach((stream) => {
- if (!previousIds.has(stream.id)) {
- enteringIds.add(stream.id)
- }
- })
- const previousRects = new Map()
-
- renderedStreams.forEach((stream) => {
- const row = rowRefs.current[stream.id]
- if (row) {
- row.getAnimations().forEach((animation) => animation.cancel())
- previousRects.set(stream.id, row.getBoundingClientRect())
- }
- })
-
- setActiveCreatorId(nextCreatorId)
- setMotionLocked(true)
- ;[...exitingIds].forEach((streamId, index) => {
- const row = rowRefs.current[streamId]
- if (!row) return
-
- row.animate(
- [
- { opacity: 1, transform: "translate3d(0, 0, 0) scale(1)" },
- { opacity: 0, transform: "translate3d(108%, 0, 0) scale(0.985)" },
- ],
- {
- delay: Math.min(index * 17, 50) * stateScale,
- duration: motion.exit,
- easing: filterEase,
- fill: "forwards",
- }
- )
- })
-
- const exitDelay =
- exitingIds.size > 0
- ? motion.exit + Math.min((exitingIds.size - 1) * 17, 50) * stateScale
- : 0
- queueMotionTimer(() => {
- reflowMotion.current = { previousRects, enteringIds }
- setEnteringStreamIds(enteringIds)
- setRenderedStreams(nextStreams)
- }, exitDelay)
- }
-
- React.useEffect(() => () => clearMotionTimers(), [clearMotionTimers])
-
- React.useLayoutEffect(() => {
- const motion = reflowMotion.current
- if (!motion) return
- const durations = filterMotion.current
- const stateScale = durations.enter / defaultFilterMotion.enter
-
- reflowMotion.current = null
- const survivors = renderedStreams.filter(
- (stream) =>
- motion.previousRects.has(stream.id) &&
- !motion.enteringIds.has(stream.id)
- )
-
- survivors.forEach((stream) => {
- const row = rowRefs.current[stream.id]
- const previousRect = motion.previousRects.get(stream.id)
- if (!row || !previousRect) return
-
- const nextRect = row.getBoundingClientRect()
- const deltaX = previousRect.left - nextRect.left
- const deltaY = previousRect.top - nextRect.top
-
- if (Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5) {
- row.animate(
- [
- {
- transform: `translate3d(${deltaX}px, ${deltaY}px, 0)`,
- opacity: 1,
- },
- { transform: "translate3d(0, 0, 0)", opacity: 1 },
- ],
- { duration: durations.move, easing: filterEase }
- )
- }
- })
-
- queueMotionTimer(
- () => {
- const enteringIds = [...motion.enteringIds]
- enteringIds.forEach((streamId, index) => {
- const row = rowRefs.current[streamId]
- if (!row) return
-
- row.animate(
- [
- { opacity: 0, transform: "translate3d(108%, 0, 0) scale(0.985)" },
- { opacity: 1, transform: "translate3d(0, 0, 0) scale(1)" },
- ],
- {
- delay: Math.min(index * 20, 61) * stateScale,
- duration: durations.enter,
- easing: filterEase,
- fill: "both",
- }
- )
- })
-
- const enterDelay =
- enteringIds.length > 0
- ? durations.enter +
- Math.min((enteringIds.length - 1) * 20, 61) * stateScale
- : 0
- queueMotionTimer(() => {
- setEnteringStreamIds(new Set())
- setMotionLocked(false)
- }, enterDelay)
- },
- survivors.length > 0 ? durations.move : 0
- )
- }, [queueMotionTimer, renderedStreams])
-
- React.useEffect(() => {
- if (motionLocked) return
-
- const renderedIds = renderedStreams.map((stream) => stream.id).join("|")
- const visibleIds = visibleStreams.map((stream) => stream.id).join("|")
- if (renderedIds === visibleIds) return
-
- const syncTimer = window.setTimeout(() => {
- setRenderedStreams(visibleStreams)
- }, 0)
-
- return () => window.clearTimeout(syncTimer)
- }, [motionLocked, renderedStreams, visibleStreams])
-
- React.useEffect(() => {
- if (activeCreatorId === "all") return
- if (creators.some((creator) => creator.id === activeCreatorId)) return
-
- const syncTimer = window.setTimeout(() => {
- setActiveCreatorId("all")
- }, 0)
-
- return () => window.clearTimeout(syncTimer)
- }, [activeCreatorId, creators])
return (
{emptyLabel}
) : null}
- {renderedStreams.map((stream) => {
- const selected = selectedIdSet.has(stream.id)
- const entering = enteringStreamIds.has(stream.id)
-
- return (
- {
- if (node) {
- rowRefs.current[stream.id] = node
- } else {
- delete rowRefs.current[stream.id]
- }
- }}
- className={cn(
- "grid min-h-[4.9rem] w-full min-w-0 grid-cols-[5.5rem_minmax(0,1fr)_7rem_auto_1.75rem] items-center gap-3 rounded-lg border border-nextide-line bg-nextide-panel px-3 py-2 text-left transition-[background-color,border-color,box-shadow]",
- selected &&
- "border-nextide-tide/55 bg-nextide-tide/10 shadow-[0_0_24px_rgb(30_228_188/0.13)]",
- "disabled:cursor-not-allowed disabled:opacity-45"
- )}
- style={
- entering
- ? {
- opacity: 0,
- transform: "translate3d(108%, 0, 0) scale(0.985)",
- }
- : undefined
- }
- onClick={() =>
- onSelectedIdsChange(
- selected
- ? selectedIds.filter((id) => id !== stream.id)
- : [...selectedIds, stream.id]
- )
- }
- >
-
- {!stream.thumbnail ? : null}
-
-
- {stream.title}
-
- {stream.creatorName}
- {stream.meta ? <> - {stream.meta}> : null}
-
-
-
- {stream.dateLabel ? {stream.dateLabel} : null}
- {stream.durationLabel ? (
- {stream.durationLabel}
- ) : null}
-
-
- {stream.readinessLabel ?? "Ready"}
-
-
- {selected ? : null}
-
-
- )
- })}
+ {renderedStreams.map((stream) => (
+
+ ))}
)
}
-function readFilterMotion(node: HTMLElement | null) {
- if (!node || typeof window === "undefined") return defaultFilterMotion
-
- const styles = window.getComputedStyle(node)
- return {
- exit: readCssTime(
- styles.getPropertyValue("--nextide-motion-state"),
- defaultFilterMotion.exit
- ),
- move: readCssTime(
- styles.getPropertyValue("--nextide-motion-layout"),
- defaultFilterMotion.move
- ),
- enter: readCssTime(
- styles.getPropertyValue("--nextide-motion-state"),
- defaultFilterMotion.enter
- ),
- }
+function StreamRow({
+ stream,
+ selected,
+ entering,
+ setRowRef,
+ selectedIds,
+ onSelectedIdsChange,
+}: {
+ stream: StreamSelectorItem
+ selected: boolean
+ entering: boolean
+ setRowRef: (id: string, node: HTMLButtonElement | null) => void
+ selectedIds: string[]
+ onSelectedIdsChange: (ids: string[]) => void
+}) {
+ return (
+
setRowRef(stream.id, node)}
+ className={cn(
+ "grid min-h-[4.9rem] w-full min-w-0 grid-cols-[5.5rem_minmax(0,1fr)_7rem_auto_1.75rem] items-center gap-3 rounded-lg border border-nextide-line bg-nextide-panel px-3 py-2 text-left transition-[background-color,border-color,box-shadow]",
+ selected &&
+ "border-nextide-tide/55 bg-nextide-tide/10 shadow-[0_0_24px_rgb(30_228_188/0.13)]",
+ "disabled:cursor-not-allowed disabled:opacity-45"
+ )}
+ style={
+ entering
+ ? {
+ opacity: 0,
+ transform: "translate3d(108%, 0, 0) scale(0.985)",
+ }
+ : undefined
+ }
+ onClick={() =>
+ onSelectedIdsChange(
+ selected
+ ? selectedIds.filter((id) => id !== stream.id)
+ : [...selectedIds, stream.id]
+ )
+ }
+ >
+
+
+ )
}
-function readCssTime(value: string, fallback: number) {
- const parsed = Number.parseFloat(value)
- if (!Number.isFinite(parsed)) return fallback
- return value.trim().endsWith("s") && !value.trim().endsWith("ms")
- ? parsed * 1000
- : parsed
+function StreamRowContents({
+ stream,
+ selected,
+}: {
+ stream: StreamSelectorItem
+ selected: boolean
+}) {
+ return (
+ <>
+
+ {!stream.thumbnail ? : null}
+
+
+ {stream.title}
+
+ {stream.creatorName}
+ {stream.meta ? <> - {stream.meta}> : null}
+
+
+
+ {stream.dateLabel ? {stream.dateLabel} : null}
+ {stream.durationLabel ? {stream.durationLabel} : null}
+
+
+ {stream.readinessLabel ?? "Ready"}
+
+
+ {selected ? : null}
+
+ >
+ )
}
export {
diff --git a/packages/ui/src/blocks/workflow-stepper.tsx b/packages/ui/src/blocks/workflow-stepper.tsx
index bdfd212..0826f25 100644
--- a/packages/ui/src/blocks/workflow-stepper.tsx
+++ b/packages/ui/src/blocks/workflow-stepper.tsx
@@ -12,7 +12,6 @@ type WorkflowStep = {
completed?: boolean
}
-// oxlint-disable-next-line max-lines-per-function -- Legacy baseline: The function `WorkflowStepper` has too many lines (135); extract this function in the follow-up refactor.
function WorkflowStepper({
steps,
activeStepId,
@@ -39,61 +38,7 @@ function WorkflowStepper({
onContainedWheel(event)
}
- React.useLayoutEffect(() => {
- const stepper = stepperRef.current
- if (!stepper) return
-
- const setOutline = (left: number, width: number) => {
- stepper.style.setProperty("--workflow-outline-left", `${left}px`)
- stepper.style.setProperty("--workflow-outline-width", `${width}px`)
- }
-
- if (activeIndex < 0) {
- const left =
- Number.parseFloat(
- stepper.style.getPropertyValue("--workflow-outline-left")
- ) || 8
- const width =
- Number.parseFloat(
- stepper.style.getPropertyValue("--workflow-outline-width")
- ) || 0
- setOutline(left + width / 2, 0)
- return
- }
-
- const activeStepElement = stepRefs.current[activeIndex]
- if (!activeStepElement) return
-
- let frame = 0
- const measureOutline = () => {
- setOutline(activeStepElement.offsetLeft, activeStepElement.offsetWidth)
- }
- const scheduleMeasureOutline = () => {
- window.cancelAnimationFrame(frame)
- frame = window.requestAnimationFrame(measureOutline)
- }
-
- measureOutline()
- const reduceMotion = window.matchMedia(
- "(prefers-reduced-motion: reduce)"
- ).matches
- activeStepElement.scrollIntoView({
- block: "nearest",
- inline: "nearest",
- behavior: reduceMotion ? "auto" : "smooth",
- })
-
- const resizeObserver = new ResizeObserver(scheduleMeasureOutline)
- resizeObserver.observe(stepper)
- resizeObserver.observe(activeStepElement)
- window.addEventListener("resize", scheduleMeasureOutline)
-
- return () => {
- window.cancelAnimationFrame(frame)
- resizeObserver.disconnect()
- window.removeEventListener("resize", scheduleMeasureOutline)
- }
- }, [activeIndex, stepperRef])
+ useWorkflowOutline(activeIndex, stepperRef, stepRefs)
return (
,
+ stepRefs: React.RefObject>
+) {
+ React.useLayoutEffect(() => {
+ const stepper = stepperRef.current
+ if (!stepper) return
+
+ const setOutline = (left: number, width: number) => {
+ stepper.style.setProperty("--workflow-outline-left", `${left}px`)
+ stepper.style.setProperty("--workflow-outline-width", `${width}px`)
+ }
+
+ if (activeIndex < 0) {
+ const left =
+ Number.parseFloat(
+ stepper.style.getPropertyValue("--workflow-outline-left")
+ ) || 8
+ const width =
+ Number.parseFloat(
+ stepper.style.getPropertyValue("--workflow-outline-width")
+ ) || 0
+ setOutline(left + width / 2, 0)
+ return
+ }
+
+ const activeStepElement = stepRefs.current[activeIndex]
+ if (!activeStepElement) return
+
+ let frame = 0
+ const measureOutline = () => {
+ setOutline(activeStepElement.offsetLeft, activeStepElement.offsetWidth)
+ }
+ const scheduleMeasureOutline = () => {
+ window.cancelAnimationFrame(frame)
+ frame = window.requestAnimationFrame(measureOutline)
+ }
+
+ measureOutline()
+ const reduceMotion = window.matchMedia(
+ "(prefers-reduced-motion: reduce)"
+ ).matches
+ activeStepElement.scrollIntoView({
+ block: "nearest",
+ inline: "nearest",
+ behavior: reduceMotion ? "auto" : "smooth",
+ })
+
+ const resizeObserver = new ResizeObserver(scheduleMeasureOutline)
+ resizeObserver.observe(stepper)
+ resizeObserver.observe(activeStepElement)
+ window.addEventListener("resize", scheduleMeasureOutline)
+
+ return () => {
+ window.cancelAnimationFrame(frame)
+ resizeObserver.disconnect()
+ window.removeEventListener("resize", scheduleMeasureOutline)
+ }
+ }, [activeIndex, stepperRef, stepRefs])
+}
+
function StepNumber({
value,
active,
diff --git a/packages/ui/src/components/creator-flow-chart.tsx b/packages/ui/src/components/creator-flow-chart.tsx
index e55411b..d2b2a74 100644
--- a/packages/ui/src/components/creator-flow-chart.tsx
+++ b/packages/ui/src/components/creator-flow-chart.tsx
@@ -37,7 +37,6 @@ const toneClasses: Record = {
danger: "border-nextide-red/60 bg-nextide-red/16 text-nextide-red",
}
-// oxlint-disable-next-line max-lines-per-function -- Legacy baseline: The function `CreatorFlowChart` has too many lines (219); extract this function in the follow-up refactor.
function CreatorFlowChart({
creators,
days,
@@ -51,94 +50,11 @@ function CreatorFlowChart({
sessions: CreatorFlowSession[]
onSessionsChange?: (sessions: CreatorFlowSession[]) => void
}) {
- const gridRef = React.useRef(null)
- const dragState = React.useRef(null)
const { ref: scrollRef, onWheel } = useContainedScroll({
axis: "x",
})
- const columnCount = Math.max(1, days.length)
-
- const updateSession = React.useCallback(
- (sessionId: string, startIndex: number, endIndex: number) => {
- onSessionsChange?.(
- sessions.map((session) =>
- session.id === sessionId
- ? {
- ...session,
- startIndex: clamp(startIndex, 0, columnCount - 1),
- endIndex: clamp(endIndex, 0, columnCount - 1),
- }
- : session
- )
- )
- },
- [columnCount, onSessionsChange, sessions]
- )
-
- const beginDrag = (
- event: React.PointerEvent,
- session: CreatorFlowSession,
- mode: DragState["mode"]
- ) => {
- const grid = gridRef.current
- if (!grid || !onSessionsChange) return
-
- const rect = grid.getBoundingClientRect()
- dragState.current = {
- id: session.id,
- mode,
- pointerStartX: event.clientX,
- originalStart: session.startIndex,
- originalEnd: session.endIndex,
- columnWidth: rect.width / columnCount,
- }
- event.currentTarget.setPointerCapture(event.pointerId)
- }
-
- const moveDrag = (event: React.PointerEvent) => {
- const drag = dragState.current
- if (!drag) return
-
- const deltaColumns = Math.round(
- (event.clientX - drag.pointerStartX) / drag.columnWidth
- )
- const span = drag.originalEnd - drag.originalStart
- if (drag.mode === "move") {
- const nextStart = clamp(
- drag.originalStart + deltaColumns,
- 0,
- columnCount - span - 1
- )
- updateSession(drag.id, nextStart, nextStart + span)
- return
- }
- if (drag.mode === "start") {
- updateSession(
- drag.id,
- clamp(drag.originalStart + deltaColumns, 0, drag.originalEnd),
- drag.originalEnd
- )
- return
- }
- updateSession(
- drag.id,
- drag.originalStart,
- clamp(
- drag.originalEnd + deltaColumns,
- drag.originalStart,
- columnCount - 1
- )
- )
- }
-
- const endDrag = (event: React.PointerEvent) => {
- dragState.current = null
- try {
- event.currentTarget.releasePointerCapture(event.pointerId)
- } catch {
- // The resize handles own pointer capture, while the row handles bubbling.
- }
- }
+ const { gridRef, columnCount, beginDrag, moveDrag, endDrag } =
+ useFlowInteraction(days.length, sessions, onSessionsChange)
return (
-
- {creators.map((creator) => {
- const creatorSessions = sessions.filter(
- (session) => session.creatorId === creator.id
- )
- return (
-
- {creatorSessions.map((session) => {
- const start = clamp(
- session.startIndex,
- 0,
- columnCount - 1
- )
- const end = clamp(
- session.endIndex,
- start,
- columnCount - 1
- )
- const left = (start / columnCount) * 100
- const width = ((end - start + 1) / columnCount) * 100
-
- return (
-
- beginDrag(event, session, "move")
- }
- onPointerMove={moveDrag}
- onPointerUp={endDrag}
- onPointerCancel={endDrag}
- >
- {
- event.stopPropagation()
- beginDrag(event, session, "start")
- }}
- />
- {session.label}
- {
- event.stopPropagation()
- beginDrag(event, session, "end")
- }}
- />
-
- )
- })}
-
- )
- })}
-
+