feat(RecordButton): Complete rewrite with enhanced visual design and states#73
Conversation
…states ## Major Changes ### Component Architecture (RecordButton.tsx) - Complete rewrite based on modern v0.app reference design - 6 distinct states: idle, recording, processing, disabled, error, success - 4 visual variants with updated color schemes: - default: Red/pink gradient with glow effects - outline: Subtle border with amber/orange recording state - ghost: Transparent with emerald/green recording state - minimal: Clean, understated with blue/indigo recording state - 3 sizes: sm (40px), md (48px), lg (56px) - Animated waveform bars component for recording visualization - Pulse ring animation during recording state - Duration display support while recording - Transcription state integration for AI applications - Both controlled and uncontrolled recording modes - Built-in MediaRecorder support with callbacks ### Stories & Documentation (RecordButton. ## Major Changes ### Component Architecture (RecordButton.tsx) - Complete rewrite based on modern v0.app reference design - 6 distinct states: idle, tch ### Component - - Complete rewrite based on modern v0.app reta- 6 distinct states: idle, recording, processing, disablegr- 4 visual variants with updated color schemes: - default: Red/pink gralt - default: Red/pink gradient with glow effec e - outline: Subtle border with amber/orange reng - ghost: Transparent with emerald/green recording state al - minimal: Clean, understated with blue/indigo recordird- 3 sizize, TranscriptionState, TranscriptionResult - Clean named- Animated waveform bars component for ree - Pulse ring animation during recording state - Duration dispti- Duration display support while recording -lating animation for recording state bars
…onflict The 'onError' prop conflicted with the native HTMLButtonElement 'onError' event handler which expects a ReactEventHandler<HTMLButtonElement>. Renamed to 'onRecordingError' for clarity and type safety.
Deploying ui with
|
| Latest commit: |
e39a804
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://a37423c2.ui-6d0.pages.dev |
| Branch Preview URL: | https://feature-record-button-updste.ui-6d0.pages.dev |
There was a problem hiding this comment.
Pull request overview
This PR completely rewrites the RecordButton component with a modern design featuring 6 distinct states (idle, recording, processing, disabled, error, success) and 4 visual variants (default, outline, ghost, minimal). The component now supports both controlled and uncontrolled usage patterns, includes animated waveform bars and pulse ring effects for the recording state, and provides better integration with transcription services.
Changes:
- Complete component rewrite with enhanced state management (6 states vs 3 previously)
- New visual variants system with updated color schemes and animations
- Added waveform animation CSS keyframes to base.css
- Extensive updates to Storybook documentation with new interactive demos
- Exports updated to remove unused
recordingIndicatorVariants
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 9 comments.
| File | Description |
|---|---|
| src/styles/base.css | Adds waveform animation keyframes for the recording visualization feature |
| src/components/RecordButton/index.ts | Updates exports to reflect new type definitions and remove unused variant |
| src/components/RecordButton/RecordButton.tsx | Complete rewrite with new state system, variants, animations, and controlled/uncontrolled support |
| src/components/RecordButton/RecordButton.stories.tsx | Comprehensive Storybook updates with new interactive demos and variant showcases |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…tionality - Default story now shows uncontrolled recording with audio playback - InInputField story shows integration with form inputs - Recording functionality was already implemented, just hidden by controlled state - Added eslint-disable for audio element caption requirement in demos
… demo - Default story: Controllable via controls panel (state, variant, size, etc.) - LiveRecording story: Fully functional recording with audio playback - Renamed RecordingState to Recording for consistency
Replace native <audio> element with AudioPlayer component for consistent styling
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 11 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Added the live recording demo live-recording.mov |
✅ All Copilot Review Comments AddressedCommit Memory Leak Fixes
Dependency Array Improvements
Accessibility Improvements
Code Quality
Documentation & DX
Breaking Change Fix
Design Decisions (Documented, Not Changed)
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 10 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Dev mode warnings for conflicting states | ||
| React.useEffect(() => { | ||
| // Only warn in development | ||
| if (typeof window === 'undefined') return; |
There was a problem hiding this comment.
The check typeof window === 'undefined' is used to determine if the code is running in development mode, but this check actually determines if the code is running in a browser environment vs Node.js (SSR). This means the warnings won't appear in development SSR builds.
A more appropriate check would be process.env.NODE_ENV !== 'production' to ensure warnings only appear in development, regardless of whether it's client-side or server-side rendering. However, note that this requires a bundler that handles environment variables.
| if (typeof window === 'undefined') return; | |
| if (process.env.NODE_ENV === 'production') return; |
| const clearTimers = () => { | ||
| timersRef.current.forEach(clearTimeout); | ||
| timersRef.current = []; | ||
| }; | ||
|
|
||
| const handleClick = () => { | ||
| if (state === 'idle') { | ||
| // Start recording | ||
| setState('recording'); | ||
| } else if (state === 'recording') { | ||
| // Stop recording, start processing | ||
| clearTimers(); | ||
| setState('processing'); | ||
|
|
||
| // Simulate processing time | ||
| const processingTimer = setTimeout(() => { | ||
| setState('success'); | ||
|
|
||
| // Reset to idle after showing success | ||
| const successTimer = setTimeout(() => { | ||
| setState('idle'); | ||
| }, 1500); | ||
| timersRef.current.push(successTimer); | ||
| }, 2000); | ||
| timersRef.current.push(processingTimer); | ||
| } | ||
| }; | ||
|
|
||
| React.useEffect(() => { | ||
| return () => { | ||
| clearTimers(); | ||
| }; | ||
| }, []); |
There was a problem hiding this comment.
The clearTimers function is defined inside the component but is also used in the cleanup useEffect. This works, but clearTimers is recreated on every render, which means the effect cleanup will potentially reference a stale version.
While this pattern works due to closure semantics (the cleanup will run with the timersRef from the appropriate render), for consistency and clarity, consider wrapping clearTimers in a useCallback with no dependencies since it only uses timersRef.current which doesn't need to be in dependencies.
| /** Callback when recording is complete with the audio blob */ | ||
| onRecordingComplete?: (blob: Blob, duration: number) => void; | ||
| /** Callback when recording starts */ | ||
| onRecordingStart?: () => void; |
There was a problem hiding this comment.
The callback prop has been renamed from onError to onRecordingError. This is a breaking change that will affect any existing consumers using the onError prop.
While the new name is more specific and clear about when the error occurs, this breaking change should be clearly documented in the PR description and migration guide. Consider whether providing backward compatibility (accepting both prop names with a deprecation warning) would be beneficial for a smoother migration path.
| onRecordingStart?: () => void; | |
| onRecordingStart?: () => void; | |
| /** | |
| * @deprecated Use `onRecordingError` instead. This will be removed in a future major version. | |
| * Backward-compatible alias for `onRecordingError`. | |
| */ | |
| onError?: (error: Error) => void; |
| @keyframes waveform { | ||
| 0%, | ||
| 100% { | ||
| transform: scaleY(0.3); | ||
| } | ||
| 50% { | ||
| transform: scaleY(1); | ||
| } | ||
| } | ||
|
|
||
| .animate-waveform { | ||
| animation: waveform 0.5s ease-in-out infinite; | ||
| } |
There was a problem hiding this comment.
The waveform animation keyframes at lines 179-187 are not wrapped in a @media (prefers-reduced-motion: no-preference) query. This means users who have set prefers-reduced-motion: reduce will still see the waveform animation initially, even though the global rule at line 165 attempts to override animation durations.
The global override at lines 165-173 will reduce the animation duration to 0.01ms, but it's better practice to conditionally define or apply animations only when motion is acceptable. Consider either:
- Wrapping the keyframes definition in
@media (prefers-reduced-motion: no-preference) - Or providing an alternative static visual indicator for users with reduced motion preferences
| @keyframes waveform { | |
| 0%, | |
| 100% { | |
| transform: scaleY(0.3); | |
| } | |
| 50% { | |
| transform: scaleY(1); | |
| } | |
| } | |
| .animate-waveform { | |
| animation: waveform 0.5s ease-in-out infinite; | |
| } | |
| @media (prefers-reduced-motion: no-preference) { | |
| @keyframes waveform { | |
| 0%, | |
| 100% { | |
| transform: scaleY(0.3); | |
| } | |
| 50% { | |
| transform: scaleY(1); | |
| } | |
| } | |
| .animate-waveform { | |
| animation: waveform 0.5s ease-in-out infinite; | |
| } | |
| } |
| const isDisabled = | ||
| effectiveState === 'disabled' || effectiveState === 'processing'; | ||
|
|
||
| // Cleanup on unmount | ||
| React.useEffect(() => { | ||
| return () => { | ||
| if (timerRef.current) { | ||
| clearInterval(timerRef.current); | ||
| } | ||
| clearAllTimeouts(); | ||
| if (streamRef.current) { | ||
| streamRef.current.getTracks().forEach((track) => track.stop()); | ||
| } | ||
| }; | ||
| }, []); | ||
|
|
||
| mediaRecorderRef.current.onstop = () => { | ||
| setState('processing'); | ||
|
|
||
| const blob = new Blob(chunksRef.current, { type: mimeType }); | ||
| const finalDuration = duration; | ||
|
|
||
| // Small delay to show processing state | ||
| setTimeout(() => { | ||
| onRecordingComplete?.(blob, finalDuration); | ||
| setState('idle'); | ||
| setDuration(0); | ||
| }, 200); | ||
| }; | ||
| const stopRecording = React.useCallback(() => { | ||
| if (timerRef.current) { | ||
| clearInterval(timerRef.current); | ||
| } | ||
|
|
||
| mediaRecorderRef.current.start(100); | ||
| startTimeRef.current = Date.now(); | ||
| setState('recording'); | ||
| onRecordingStart?.(); | ||
| if ( | ||
| mediaRecorderRef.current && | ||
| mediaRecorderRef.current.state !== 'inactive' | ||
| ) { | ||
| mediaRecorderRef.current.stop(); | ||
| } | ||
|
|
||
| timerRef.current = window.setInterval(() => { | ||
| const elapsed = (Date.now() - startTimeRef.current) / 1000; | ||
| setDuration(elapsed); | ||
| if (streamRef.current) { | ||
| streamRef.current.getTracks().forEach((track) => track.stop()); | ||
| } | ||
| }, []); | ||
|
|
||
| const startRecording = React.useCallback(async () => { | ||
| // Guard: don't start if disabled, already recording, or processing | ||
| if ( | ||
| disabled || | ||
| effectiveState === 'recording' || | ||
| effectiveState === 'processing' | ||
| ) | ||
| return; | ||
|
|
||
| try { | ||
| const stream = await navigator.mediaDevices.getUserMedia({ | ||
| audio: true, | ||
| }); | ||
| streamRef.current = stream; | ||
|
|
||
| const options = { mimeType }; | ||
| if (!MediaRecorder.isTypeSupported(mimeType)) { | ||
| mediaRecorderRef.current = new MediaRecorder(stream); | ||
| } else { | ||
| mediaRecorderRef.current = new MediaRecorder(stream, options); | ||
| } | ||
|
|
||
| if (maxDuration > 0 && elapsed >= maxDuration) { | ||
| stopRecording(); | ||
| chunksRef.current = []; | ||
|
|
||
| mediaRecorderRef.current.ondataavailable = (e) => { | ||
| if (e.data.size > 0) { | ||
| chunksRef.current.push(e.data); | ||
| } | ||
| }; | ||
|
|
||
| mediaRecorderRef.current.onstop = () => { | ||
| if (!isControlled) { | ||
| setInternalState('processing'); | ||
| } | ||
|
|
||
| const blob = new Blob(chunksRef.current, { type: mimeType }); | ||
| const finalDuration = duration; | ||
|
|
||
| // Small delay to show processing state | ||
| addTimeout(() => { | ||
| onRecordingComplete?.(blob, finalDuration); | ||
| if (!isControlled) { | ||
| setInternalState('success'); | ||
| // Reset to idle after showing success | ||
| addTimeout(() => { | ||
| setInternalState('idle'); | ||
| }, 1500); | ||
| } | ||
| setDuration(0); | ||
| }, 200); | ||
| }; | ||
|
|
||
| mediaRecorderRef.current.start(100); | ||
| startTimeRef.current = Date.now(); | ||
|
|
||
| if (!isControlled) { | ||
| setInternalState('recording'); | ||
| } | ||
| }, 100); | ||
| } catch (error) { | ||
| onError?.(error as Error); | ||
| setState('idle'); | ||
| } | ||
| }, [ | ||
| disabled, | ||
| isRecording, | ||
| isProcessing, | ||
| mimeType, | ||
| maxDuration, | ||
| duration, | ||
| onRecordingComplete, | ||
| onRecordingStart, | ||
| onError, | ||
| stopRecording, | ||
| ]); | ||
|
|
||
| const handleClick = React.useCallback(() => { | ||
| if (isRecording) { | ||
| stopRecording(); | ||
| } else { | ||
| startRecording(); | ||
| } | ||
| }, [isRecording, startRecording, stopRecording]); | ||
|
|
||
| const iconSize = | ||
| size === 'sm' ? 'h-4 w-4' : size === 'lg' ? 'h-6 w-6' : 'h-5 w-5'; | ||
|
|
||
| const getIcon = () => { | ||
| if (isProcessing || isTranscribing) { | ||
| return <SpinnerIcon className={iconSize} />; | ||
| } | ||
| if (isRecording) { | ||
| return recordingIcon || <StopIcon className={iconSize} />; | ||
| } | ||
| return idleIcon || <MicrophoneIcon className={iconSize} />; | ||
| }; | ||
| onRecordingStart?.(); | ||
|
|
||
| timerRef.current = window.setInterval(() => { | ||
| const elapsed = (Date.now() - startTimeRef.current) / 1000; | ||
| setDuration(elapsed); | ||
|
|
||
| if (maxDuration > 0 && elapsed >= maxDuration) { | ||
| stopRecording(); | ||
| } | ||
| }, 100); | ||
| } catch (error) { | ||
| onRecordingError?.(error as Error); | ||
| if (!isControlled) { | ||
| setInternalState('error'); | ||
| // Reset to idle after showing error | ||
| addTimeout(() => { | ||
| setInternalState('idle'); | ||
| }, 2000); | ||
| } | ||
| } | ||
| }, [ | ||
| disabled, | ||
| effectiveState, | ||
| isControlled, | ||
| mimeType, | ||
| maxDuration, | ||
| duration, | ||
| onRecordingComplete, | ||
| onRecordingStart, | ||
| onRecordingError, | ||
| stopRecording, | ||
| ]); | ||
|
|
||
| const handleClick = React.useCallback( | ||
| (e: React.MouseEvent<HTMLButtonElement>) => { | ||
| // Call external onClick if provided | ||
| onClick?.(e); | ||
|
|
||
| // Handle internal recording logic only if not fully controlled | ||
| if (!isControlled) { | ||
| if (effectiveState === 'recording') { | ||
| stopRecording(); | ||
| } else if (effectiveState === 'idle') { | ||
| startRecording(); | ||
| } | ||
| } | ||
| }, | ||
| [onClick, isControlled, effectiveState, startRecording, stopRecording] | ||
| ); | ||
|
|
||
| // Determine which icon to show | ||
| const renderIcon = () => { | ||
| switch (effectiveState) { | ||
| case 'recording': | ||
| if (showWaveform) { | ||
| return <WaveformBars size={size} />; | ||
| } | ||
| return recordingIcon || <StopIcon className={iconSize} />; | ||
| case 'processing': | ||
| return <LoadingSpinner className={iconSize} />; | ||
| case 'disabled': | ||
| case 'error': | ||
| return <MicOffIcon className={iconSize} />; | ||
| case 'success': | ||
| return <CheckIcon className={iconSize} />; | ||
| default: | ||
| return idleIcon || <MicIcon className={iconSize} />; | ||
| } | ||
| }; | ||
|
|
||
| const getAriaLabel = () => { | ||
| if (ariaLabel) return ariaLabel; | ||
| if (isTranscribing) return 'Transcribing audio'; | ||
| if (isProcessing) return 'Processing recording'; | ||
| if (isRecording) return 'Stop recording'; | ||
| return 'Start recording'; | ||
| }; | ||
| const getAriaLabel = () => { | ||
| switch (effectiveState) { | ||
| case 'recording': | ||
| return 'Stop recording'; | ||
| case 'processing': | ||
| return 'Processing recording'; | ||
| case 'disabled': | ||
| return 'Recording unavailable'; | ||
| case 'error': | ||
| return 'Recording failed'; | ||
| case 'success': | ||
| return 'Recording complete'; | ||
| default: | ||
| return 'Start recording'; | ||
| } | ||
| }; | ||
|
|
||
| const getTranscriptionLabel = () => { | ||
| if (transcriptionState === 'streaming') return 'Listening...'; | ||
| if (transcriptionState === 'transcribing') return 'Transcribing...'; | ||
| return null; | ||
| }; | ||
| const getTranscriptionLabel = () => { | ||
| if (transcriptionState === 'streaming') return 'Listening...'; | ||
| if (transcriptionState === 'transcribing') return 'Transcribing...'; | ||
| return null; | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="relative inline-flex items-center gap-2"> | ||
| <button | ||
| type="button" | ||
| onClick={handleClick} | ||
| disabled={disabled || isProcessing || isTranscribing} | ||
| className={cn( | ||
| recordButtonVariants({ variant, size }), | ||
| isRecording && 'text-red-600 dark:text-red-400', | ||
| isTranscribing && 'text-primary-600 dark:text-primary-400', | ||
| className | ||
| return ( | ||
| <div className="relative inline-flex items-center gap-2"> | ||
| <button | ||
| ref={ref} | ||
| type="button" | ||
| disabled={isDisabled} |
There was a problem hiding this comment.
The disabled attribute on the button element is set based on isDisabled, which is true when effectiveState is 'disabled' or 'processing'. However, the native disabled HTML attribute and the disabled prop are semantically different:
- The
disabledprop is meant to indicate the button should not be interactive (e.g., no microphone permission) - The 'processing' state means the button is temporarily busy, but not permanently disabled
Setting disabled on the button when state='processing' prevents users from canceling or interacting during processing, which might be the desired behavior. However, this conflicts with the standard HTML behavior where disabled elements are not focusable and don't trigger events. Consider using aria-busy and aria-disabled instead of the native disabled attribute for the 'processing' state, or make this behavior configurable.
| if (effectiveState === 'recording') { | ||
| stopRecording(); | ||
| } else if (effectiveState === 'idle') { | ||
| startRecording(); | ||
| } | ||
| } | ||
| }, | ||
| [onClick, isControlled, effectiveState, startRecording, stopRecording] |
There was a problem hiding this comment.
In the handleClick function, the internal recording logic only runs when !isControlled, but the guard conditions check effectiveState === 'recording' and effectiveState === 'idle'. However, effectiveState can be influenced by transcriptionState and disabled props even in uncontrolled mode.
This means if someone sets transcriptionState="transcribing" on an uncontrolled button, the user won't be able to stop an active recording because effectiveState will be 'processing' instead of 'recording', even though the internal state might still be 'recording'.
Consider checking internalState directly when !isControlled, or add logic to handle cases where external state props override the internal recording state.
| if (effectiveState === 'recording') { | |
| stopRecording(); | |
| } else if (effectiveState === 'idle') { | |
| startRecording(); | |
| } | |
| } | |
| }, | |
| [onClick, isControlled, effectiveState, startRecording, stopRecording] | |
| // In uncontrolled mode, drive behavior from the internal state | |
| if (internalState === 'recording') { | |
| stopRecording(); | |
| } else if (internalState === 'idle') { | |
| startRecording(); | |
| } | |
| } | |
| }, | |
| [onClick, isControlled, internalState, startRecording, stopRecording] |
| export { | ||
| RecordButton, | ||
| recordButtonVariants, | ||
| recordingIndicatorVariants, | ||
| formatDuration, | ||
| type RecordButtonProps, | ||
| type RecordButtonState, | ||
| type RecordButtonVariant, | ||
| type RecordButtonSize, | ||
| type TranscriptionState, | ||
| type TranscriptionResult, | ||
| } from './RecordButton'; |
There was a problem hiding this comment.
The recordingIndicatorVariants export has been removed from the public API. This is a breaking change for any consumers who were importing and using this variant function directly.
While it's reasonable to remove internal implementation details from the public API, this should be documented in the PR description or in a CHANGELOG/breaking changes section to help consumers migrate. If this variant is no longer needed because the recording indicator functionality has changed, consider mentioning this explicitly.
| | 'error' | ||
| | 'success'; | ||
|
|
||
| export type RecordButtonVariant = 'default' | 'outline' | 'ghost' | 'minimal'; |
There was a problem hiding this comment.
The variant prop values have completely changed from the old set ('default', 'filled', 'primary') to a new set ('default', 'outline', 'ghost', 'minimal'). This is a breaking change where:
- 'filled' variant is removed (replaced by 'default' or other variants)
- 'primary' variant is removed (no direct replacement)
- 'outline', 'ghost', and 'minimal' are new additions
All existing code using 'filled' or 'primary' variants will need to be updated. The Dashboard.stories.tsx file shows examples of this migration where 'filled' was changed to 'default'. Consider:
- Documenting a clear migration path for each removed variant
- Adding runtime warnings when deprecated variants are used (if supporting a transition period)
- Providing a codemod script to help automate the migration
| export type RecordButtonVariant = 'default' | 'outline' | 'ghost' | 'minimal'; | |
| /** | |
| * Visual style variants for the RecordButton. | |
| * | |
| * Legacy variants: | |
| * - 'filled' – deprecated, use 'default' (or another new variant) instead. | |
| * - 'primary' – deprecated, no direct replacement; prefer 'default' or a | |
| * context-appropriate combination of 'outline' | 'ghost' | 'minimal'. | |
| */ | |
| export type RecordButtonVariant = | |
| | 'default' | |
| | 'outline' | |
| | 'ghost' | |
| | 'minimal' | |
| // Legacy, supported for backwards compatibility: | |
| | 'filled' | |
| | 'primary'; |
| sm: 'size-10', | ||
| md: 'size-12', | ||
| lg: 'size-14', |
There was a problem hiding this comment.
The button sizes have changed from height-based definitions (h-7/w-7, h-9/w-9, h-11/w-11) to the newer size-* utility (size-10, size-12, size-14). This is a breaking change in actual rendered dimensions:
Old sizes:
- sm: 28px (h-7/w-7 = 1.75rem)
- md: 36px (h-9/w-9 = 2.25rem)
- lg: 44px (h-11/w-11 = 2.75rem)
New sizes:
- sm: 40px (size-10 = 2.5rem)
- md: 48px (size-12 = 3rem)
- lg: 56px (size-14 = 3.5rem)
All sizes have increased by 12px, which may affect layouts where RecordButton is used. While the PR description mentions sizes of "40px, 48px, 56px" matching the implementation, existing integrations expecting the old sizes will see larger buttons. Consider documenting this size change explicitly in the migration guide.
| sm: 'size-10', | |
| md: 'size-12', | |
| lg: 'size-14', | |
| sm: 'h-7 w-7', | |
| md: 'h-9 w-9', | |
| lg: 'h-11 w-11', |
| addTimeout(() => { | ||
| onRecordingComplete?.(blob, finalDuration); | ||
| if (!isControlled) { | ||
| setInternalState('success'); | ||
| // Reset to idle after showing success | ||
| addTimeout(() => { | ||
| setInternalState('idle'); | ||
| }, 1500); | ||
| } | ||
| setDuration(0); | ||
| }, 200); | ||
| }; | ||
|
|
||
| mediaRecorderRef.current.start(100); | ||
| startTimeRef.current = Date.now(); | ||
|
|
||
| if (!isControlled) { | ||
| setInternalState('recording'); | ||
| } | ||
| }, 100); | ||
| } catch (error) { | ||
| onError?.(error as Error); | ||
| setState('idle'); | ||
| } | ||
| }, [ | ||
| disabled, | ||
| isRecording, | ||
| isProcessing, | ||
| mimeType, | ||
| maxDuration, | ||
| duration, | ||
| onRecordingComplete, | ||
| onRecordingStart, | ||
| onError, | ||
| stopRecording, | ||
| ]); | ||
|
|
||
| const handleClick = React.useCallback(() => { | ||
| if (isRecording) { | ||
| stopRecording(); | ||
| } else { | ||
| startRecording(); | ||
| } | ||
| }, [isRecording, startRecording, stopRecording]); | ||
|
|
||
| const iconSize = | ||
| size === 'sm' ? 'h-4 w-4' : size === 'lg' ? 'h-6 w-6' : 'h-5 w-5'; | ||
|
|
||
| const getIcon = () => { | ||
| if (isProcessing || isTranscribing) { | ||
| return <SpinnerIcon className={iconSize} />; | ||
| } | ||
| if (isRecording) { | ||
| return recordingIcon || <StopIcon className={iconSize} />; | ||
| } | ||
| return idleIcon || <MicrophoneIcon className={iconSize} />; | ||
| }; | ||
| onRecordingStart?.(); | ||
|
|
||
| timerRef.current = window.setInterval(() => { | ||
| const elapsed = (Date.now() - startTimeRef.current) / 1000; | ||
| setDuration(elapsed); | ||
|
|
||
| if (maxDuration > 0 && elapsed >= maxDuration) { | ||
| stopRecording(); | ||
| } | ||
| }, 100); | ||
| } catch (error) { | ||
| onRecordingError?.(error as Error); | ||
| if (!isControlled) { | ||
| setInternalState('error'); | ||
| // Reset to idle after showing error | ||
| addTimeout(() => { | ||
| setInternalState('idle'); | ||
| }, 2000); | ||
| } |
There was a problem hiding this comment.
The timeout callbacks in addTimeout at lines 547, 552, 581 are wrapped in the tracking mechanism, but they use the isControlled variable from the outer closure. If the component's controlled state changes (e.g., the state prop is added or removed) between when the timeout is set and when it fires, the behavior could be inconsistent.
For safety, consider capturing isControlled in a variable at the time the timeout is set, or checking the current controlled state when the timeout fires.
Major Changes
Component Architecture (RecordButton.tsx)
Stories & Documentation (RecordButton.
Major Changes
Component Architecture (RecordButton.tsx)
record-button.mov