-
-
Notifications
You must be signed in to change notification settings - Fork 6
🎨 Palette: Accessibility and loading state improvements #520
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| # 2026-02-15 - [Accessibility Audit & Fix for Icon Buttons] | ||
|
|
||
| **Learning:** Icon-only buttons without aria-labels are common accessibility gaps in this project. Adding them consistently across Core UI (Header, ChatPanel, MobileIconsBar) significantly improves the experience for screen reader users without visual clutter. | ||
| **Action:** Always check icon-only buttons for aria-label or title attributes when modifying UI. | ||
|
|
||
| ## 2026-02-15 - [Loading Feedback for Submissions] | ||
|
|
||
| **Learning:** Reusing existing state (like isSubmitting) to provide visual feedback (Spinner) is preferred over creating redundant states. It keeps the architecture clean and prevents synchronization issues. | ||
| **Action:** Look for existing submission/loading states in parent components before introducing new ones in child components. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils' | |
| import { UserMessage } from './user-message' | ||
| import { Button } from './ui/button' | ||
| import { ArrowRight, Plus, Paperclip, X, Sprout } from 'lucide-react' | ||
| import { Spinner } from './ui/spinner' | ||
| import Textarea from 'react-textarea-autosize' | ||
| import { nanoid } from '@/lib/utils' | ||
| import { useSettingsStore } from '@/lib/store/settings' | ||
|
|
@@ -20,14 +21,16 @@ interface ChatPanelProps { | |
| input: string | ||
| setInput: (value: string) => void | ||
| onSuggestionsChange?: (suggestions: PartialRelated | null) => void | ||
| isPending?: boolean | ||
| setIsPending?: (pending: boolean) => void | ||
| } | ||
|
|
||
| export interface ChatPanelRef { | ||
| handleAttachmentClick: () => void | ||
| submitForm: () => void | ||
| } | ||
|
|
||
| export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, input, setInput, onSuggestionsChange }, ref) => { | ||
| export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, input, setInput, onSuggestionsChange, isPending, setIsPending }, ref) => { | ||
| const [, setMessages] = useUIState<typeof AI>() | ||
| const { submit, clearChat } = useActions() | ||
| const { mapProvider } = useSettingsStore() | ||
|
|
@@ -43,6 +46,15 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i | |
| const inputRef = useRef<HTMLTextAreaElement>(null) | ||
| const formRef = useRef<HTMLFormElement>(null) | ||
| const fileInputRef = useRef<HTMLInputElement>(null) | ||
| const objectUrls = useRef<Set<string>>(new Set()) | ||
|
|
||
| useEffect(() => { | ||
| const urls = objectUrls.current | ||
| return () => { | ||
| urls.forEach(url => URL.revokeObjectURL(url)) | ||
| urls.clear() | ||
| } | ||
| }, []) | ||
|
|
||
| useImperativeHandle(ref, () => ({ | ||
| handleAttachmentClick() { | ||
|
|
@@ -87,42 +99,72 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i | |
|
|
||
| const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { | ||
| e.preventDefault() | ||
| if (!input.trim() && !selectedFile) { | ||
| if ((!input.trim() && !selectedFile) || isPending) { | ||
| return | ||
| } | ||
|
|
||
| const content: ({ type: 'text'; text: string } | { type: 'image'; image: string })[] = [] | ||
| if (input) { | ||
| content.push({ type: 'text', text: input }) | ||
| } | ||
| if (selectedFile && selectedFile.type.startsWith('image/')) { | ||
| content.push({ | ||
| type: 'image', | ||
| image: URL.createObjectURL(selectedFile) | ||
| }) | ||
| } | ||
| setIsPending?.(true) | ||
|
|
||
| const userMessageId = nanoid() | ||
| const currentInput = input | ||
| const currentFile = selectedFile | ||
| const createdUrls: string[] = [] | ||
|
|
||
| try { | ||
| const content: ({ type: 'text'; text: string } | { type: 'image'; image: string; isOptimistic?: boolean })[] = [] | ||
| if (input) { | ||
| content.push({ type: 'text', text: input }) | ||
| } | ||
| if (selectedFile && selectedFile.type.startsWith('image/')) { | ||
| const url = URL.createObjectURL(selectedFile) | ||
| createdUrls.push(url) | ||
| objectUrls.current.add(url) | ||
| content.push({ | ||
| type: 'image', | ||
| image: url, | ||
| isOptimistic: true | ||
| }) | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| setMessages(currentMessages => [ | ||
| ...currentMessages, | ||
| { | ||
| id: nanoid(), | ||
| component: <UserMessage content={content} /> | ||
| setMessages(currentMessages => [ | ||
| ...currentMessages, | ||
| { | ||
| id: userMessageId, | ||
| component: <UserMessage content={content} /> | ||
| } | ||
| ]) | ||
|
|
||
| const formData = new FormData(e.currentTarget) | ||
| if (selectedFile) { | ||
| formData.append('file', selectedFile) | ||
| } | ||
| ]) | ||
|
|
||
| const formData = new FormData(e.currentTarget) | ||
| if (selectedFile) { | ||
| formData.append('file', selectedFile) | ||
| } | ||
| // Include drawn features in the form data | ||
| formData.append('drawnFeatures', JSON.stringify(mapData.drawnFeatures || [])) | ||
|
|
||
| // Include drawn features in the form data | ||
| formData.append('drawnFeatures', JSON.stringify(mapData.drawnFeatures || [])) | ||
| setInput('') | ||
| clearAttachment() | ||
|
|
||
| setInput('') | ||
| clearAttachment() | ||
| const responseMessage = await submit(formData) | ||
| setMessages(currentMessages => [...currentMessages, responseMessage as any]) | ||
|
|
||
| const responseMessage = await submit(formData) | ||
| setMessages(currentMessages => [...currentMessages, responseMessage as any]) | ||
| // Revoke URLs after upload finishes | ||
| createdUrls.forEach(url => { | ||
| URL.revokeObjectURL(url) | ||
| objectUrls.current.delete(url) | ||
| }) | ||
| } catch (error) { | ||
| console.error('Failed to submit message:', error) | ||
| setMessages(currentMessages => currentMessages.filter(m => m.id !== userMessageId)) | ||
| setInput(currentInput) | ||
| setSelectedFile(currentFile) | ||
| createdUrls.forEach(url => { | ||
| URL.revokeObjectURL(url) | ||
| objectUrls.current.delete(url) | ||
| }) | ||
| } finally { | ||
| setIsPending?.(false) | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
Comment on lines
100
to
167
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
SuggestionRevoke any created object URL once it's no longer needed. One pragmatic approach is to create the object URL in a local variable and revoke it in the Example: let imageUrl: string | undefined
try {
const content: ({ type: 'text'; text: string } | { type: 'image'; image: string })[] = []
if (input) content.push({ type: 'text', text: input })
if (selectedFile && selectedFile.type.startsWith('image/')) {
imageUrl = URL.createObjectURL(selectedFile)
content.push({ type: 'image', image: imageUrl })
}
// ...existing setMessages + submit logic...
} finally {
if (imageUrl) URL.revokeObjectURL(imageUrl)
setIsPending?.(false)
}Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this change. |
||
| } | ||
|
|
||
| const handleClear = async () => { | ||
|
|
@@ -177,6 +219,7 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i | |
| onClick={() => handleClear()} | ||
| data-testid="new-chat-button" | ||
| title="New Chat" | ||
| aria-label="Start new chat" | ||
| > | ||
| <Sprout size={28} className="fill-primary/20" /> | ||
| </Button> | ||
|
|
@@ -225,6 +268,7 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i | |
| )} | ||
| onClick={handleAttachmentClick} | ||
| data-testid="desktop-attachment-button" | ||
| aria-label="Attach file" | ||
| > | ||
| <Paperclip size={isMobile ? 18 : 20} /> | ||
| </Button> | ||
|
|
@@ -281,11 +325,11 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i | |
| 'absolute top-1/2 transform -translate-y-1/2', | ||
| isMobile ? 'right-1' : 'right-2' | ||
| )} | ||
| disabled={input.length === 0 && !selectedFile} | ||
| disabled={(!input.trim() && !selectedFile) || isPending} | ||
| aria-label="Send message" | ||
| data-testid="chat-submit" | ||
| > | ||
| <ArrowRight size={isMobile ? 18 : 20} /> | ||
| {isPending ? <Spinner /> : <ArrowRight size={isMobile ? 18 : 20} />} | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| </Button> | ||
| </div> | ||
| </form> | ||
|
|
@@ -295,7 +339,7 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i | |
| <span className="text-sm text-muted-foreground truncate max-w-xs"> | ||
| {selectedFile.name} | ||
| </span> | ||
| <Button variant="ghost" size="icon" onClick={clearAttachment} data-testid="clear-attachment-button"> | ||
| <Button variant="ghost" size="icon" onClick={clearAttachment} data-testid="clear-attachment-button" aria-label="Clear attachment"> | ||
| <X size={16} /> | ||
| </Button> | ||
| </div> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,8 @@ | |
| import React from 'react' | ||
| import { useUIState, useActions } from 'ai/rsc' | ||
| import { AI } from '@/app/actions' | ||
| import { Button } from '@/components/ui/button' | ||
| import { Button, buttonVariants } from '@/components/ui/button' | ||
| import { cn } from '@/lib/utils' | ||
| import { | ||
| Search, | ||
| CircleUserRound, | ||
|
|
@@ -19,13 +20,15 @@ import { MapToggle } from './map-toggle' | |
| import { ModeToggle } from './mode-toggle' | ||
| import { ProfileToggle } from './profile-toggle' | ||
| import { useCalendarToggle } from './calendar-toggle-context' | ||
| import { Spinner } from './ui/spinner' | ||
|
|
||
| interface MobileIconsBarProps { | ||
| onAttachmentClick: () => void; | ||
| onSubmitClick: () => void; | ||
| isPending?: boolean; | ||
| } | ||
|
|
||
| export const MobileIconsBar: React.FC<MobileIconsBarProps> = ({ onAttachmentClick, onSubmitClick }) => { | ||
| export const MobileIconsBar: React.FC<MobileIconsBarProps> = ({ onAttachmentClick, onSubmitClick, isPending }) => { | ||
| const [, setMessages] = useUIState<typeof AI>() | ||
| const { clearChat } = useActions() | ||
| const { toggleCalendar } = useCalendarToggle() | ||
|
|
@@ -37,27 +40,31 @@ export const MobileIconsBar: React.FC<MobileIconsBarProps> = ({ onAttachmentClic | |
|
|
||
| return ( | ||
| <div className="mobile-icons-bar-content"> | ||
| <Button variant="ghost" size="icon" onClick={handleNewChat} data-testid="mobile-new-chat-button"> | ||
| <Button variant="ghost" size="icon" onClick={handleNewChat} data-testid="mobile-new-chat-button" aria-label="Start new chat"> | ||
| <Plus className="h-[1.2rem] w-[1.2rem]" /> | ||
| </Button> | ||
| <ProfileToggle /> | ||
| <MapToggle /> | ||
| <Button variant="ghost" size="icon" onClick={toggleCalendar} title="Open Calendar" data-testid="mobile-calendar-button"> | ||
| <Button variant="ghost" size="icon" onClick={toggleCalendar} title="Open Calendar" aria-label="Open Calendar" data-testid="mobile-calendar-button"> | ||
| <CalendarDays className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" /> | ||
| </Button> | ||
| <Button variant="ghost" size="icon" data-testid="mobile-search-button"> | ||
| <Button variant="ghost" size="icon" data-testid="mobile-search-button" aria-label="Search"> | ||
| <Search className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" /> | ||
| </Button> | ||
| <a href="https://buy.stripe.com/14A3cv7K72TR3go14Nasg02" target="_blank" rel="noopener noreferrer"> | ||
| <Button variant="ghost" size="icon"> | ||
| <TentTree className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" /> | ||
| </Button> | ||
| <a | ||
| href="https://buy.stripe.com/14A3cv7K72TR3go14Nasg02" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| aria-label="Purchase credits" | ||
| className={cn(buttonVariants({ variant: 'ghost', size: 'icon' }))} | ||
| > | ||
| <TentTree className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" /> | ||
| </a> | ||
| <Button variant="ghost" size="icon" onClick={onAttachmentClick} data-testid="mobile-attachment-button"> | ||
| <Button variant="ghost" size="icon" onClick={onAttachmentClick} data-testid="mobile-attachment-button" aria-label="Attach file"> | ||
| <Paperclip className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" /> | ||
| </Button> | ||
| <Button variant="ghost" size="icon" data-testid="mobile-submit-button" onClick={onSubmitClick}> | ||
| <ArrowRight className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" /> | ||
| <Button variant="ghost" size="icon" data-testid="mobile-submit-button" onClick={onSubmitClick} aria-label="Send message" disabled={isPending}> | ||
| {isPending ? <Spinner /> : <ArrowRight className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" />} | ||
| </Button> | ||
|
Comment on lines
64
to
68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The mobile submit button is now disabled based solely on SuggestionPass a second prop (e.g., Example API: interface MobileIconsBarProps {
onAttachmentClick: () => void
onSubmitClick: () => void
isPending: boolean
canSubmit: boolean
}
// ...
disabled={isPending || !canSubmit}Reply with "@CharlieHelps yes please" if you’d like me to add a commit wiring |
||
| <History location="header" /> | ||
| <ModeToggle /> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Good addition — object URL cleanup prevents memory leaks.
This addresses the previously flagged blob URL leak. The
Set-based tracking with cleanup on unmount is a clean pattern.One minor lint fix: Biome flags the implicit return in the
forEachcallback (Line 54). Add braces to silence it:🔧 Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 Biome (2.3.14)
[error] 54-54: This callback passed to forEach() iterable method should not return a value.
Either remove this return or remove the returned value.
(lint/suspicious/useIterableCallbackReturn)
🤖 Prompt for AI Agents