Skip to content
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

[docs] add avatar upload example #45131

Open
Demianeen opened this issue Jan 27, 2025 · 4 comments
Open

[docs] add avatar upload example #45131

Demianeen opened this issue Jan 27, 2025 · 4 comments
Assignees
Labels
enhancement This is not a bug, nor a new feature ready to take Help wanted. Guidance available. There is a high chance the change will be accepted support: docs-feedback Feedback from documentation page waiting for 👍 Waiting for upvotes

Comments

@Demianeen
Copy link

Demianeen commented Jan 27, 2025

Related page

https://mui.com/material-ui/react-avatar/

Kind of issue

Missing information

Issue description

I recently needed to implement avatar upload inside form during sign up flow, I ended up with a bit hacky solution where I need to have visually hidden input nearby the clickable avatar, and that avatar will be a label, so that click on label will cause click on input. I also needed to put another hack in place so that input navigation with tab would work. It seems like quite popular usecase. Potentially adding section in the docs on how to implement it could save a lot of time and hacks in code to other people like me.

Just as the reference, this is what I end up with:

import { memo, useId, useState } from 'react'
import type { AvatarProps, FormHelperTextProps } from '@mui/material'
import { Avatar, FormHelperText, Stack, styled } from '@mui/material'
import PersonIcon from '@mui/icons-material/Person'
import { avatarConstraints } from '../../model/contact-info-schema'
import { useFocus } from '@/shared/lib/react/useFocus'
import { useTab } from '@/shared/lib/react/useTab'

const VisuallyHiddenInput = styled('input')({
	clip: 'rect(0 0 0 0)',
	clipPath: 'inset(50%)',
	height: 1,
	overflow: 'hidden',
	position: 'absolute',
	bottom: 0,
	left: 0,
	whiteSpace: 'nowrap',
	width: 1,
})

const ClickableAvatar = styled(Avatar)(({ theme }) => ({
	width: 100,
	height: 100,
	cursor: 'pointer',
	transition: 'all .1s',
	'&[data-focused="true"]': {
		outline: `4px solid ${theme.palette.primary.main}`, // Replace with your desired style
		outlineOffset: '4px',
	},
	'&:hover': {
		filter: 'brightness(90%)',
	},
	'&:active': {
		scale: 0.9,
	},
}))

type ClickableAvatarProps = Omit<AvatarProps<'label'>, 'component'>

interface AvatarUploadProps {
	avatarProps?: ClickableAvatarProps
	inputProps?: React.InputHTMLAttributes<HTMLInputElement>
	helperTextProps?: FormHelperTextProps
}

export const AvatarUpload = memo(function AvatarUpload({
	avatarProps,
	inputProps,
	helperTextProps,
}: AvatarUploadProps) {
	const [imageSrc, setImageSrc] = useState<string>()

	const id = useId()
	const [isInputFocused, bindFocus] = useFocus()
	const { isTabLastKey } = useTab()

	const handleImageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
		const file = event.target.files?.[0]
		if (file) {
			// Read the file as a data URL
			const reader = new FileReader()
			reader.onload = () => {
				setImageSrc(reader.result as string)
			}
			reader.readAsDataURL(file)
		}

		inputProps?.onChange?.(event)
	}

	return (
		<Stack alignItems={'center'}>
			<ClickableAvatar
				// @ts-expect-error it can't see component prop for some reason
				component='label'
				role={undefined}
				variant='circular'
				src={imageSrc}
				htmlFor={id}
				data-focused={isInputFocused && isTabLastKey.current}
				{...avatarProps}
			>
				<PersonIcon
					sx={{
						fontSize: '40px',
					}}
				/>
			</ClickableAvatar>
			<VisuallyHiddenInput
				id={id}
				type='file'
				accept={avatarConstraints.type.join(', ')}
				multiple={false}
				{...inputProps}
				{...bindFocus}
				onChange={handleImageChange}
			/>
			<FormHelperText {...helperTextProps} />
		</Stack>
	)
})

useFocus.ts (just tracks focus of the elem):

import { useCallback, useMemo, useState } from 'react'

interface UseFocusBind {
	onBlur: () => void
	onFocus: () => void
}

export type UseFocusReturnType = [boolean, UseFocusBind]

/**
 * Tracks if an element is focused or not.
 * @returns {[boolean, {onBlur: () => void, onFocus: () => void}]}
 */
export const useFocus = (): UseFocusReturnType => {
	const [isFocused, setIsFocused] = useState(false)

	const onBlur = useCallback(() => {
		setIsFocused(false)
	}, [])

	const onFocus = useCallback(() => {
		setIsFocused(true)
	}, [])

	return useMemo(
		() => [isFocused, { onBlur, onFocus }],
		[isFocused, onBlur, onFocus],
	)
}

useTab(tracks if the tab key is the last pressed):

import { useEffect, useRef } from 'react'

/**
 * Custom React Hook to track if the Tab key was the last key pressed.
 *
 * This hook sets up global event listeners for 'keydown' and 'mousedown' events.
 * It updates a ref `isTabLastKey` to determine if the Tab key was the last key pressed.
 * The use of a ref prevents unnecessary re-renders of your component when these events occur.
 *
 * @returns {Object} - An object containing:
 *   - `isTabLastKey` (Ref<boolean>): A ref that is `true` if the last key pressed was Tab, `false` otherwise.
 *
 * @example
 * const { isTabLastKey } = useTab();
 * // You can now use isTabLastKey.current in your component logic
 */
export const useTab = () => {
	const isTabLastKey = useRef(false)

	useEffect(() => {
		const handleKeyDown = (event: KeyboardEvent) => {
			if (event.key === 'Tab') {
				isTabLastKey.current = true
			} else {
				isTabLastKey.current = false
			}
		}

		const handleMouseDown = () => {
			isTabLastKey.current = false
		}

		window.addEventListener('keydown', handleKeyDown)

		window.addEventListener('mousedown', handleMouseDown)

		return () => {
			window.removeEventListener('keydown', handleKeyDown)
			window.removeEventListener('click', handleMouseDown)
		}
	}, [])

	return { isTabLastKey }
}

Context

No response

Search keywords: mui avatar profile upload input hidden

@Demianeen Demianeen added status: waiting for maintainer These issues haven't been looked at yet by a maintainer support: docs-feedback Feedback from documentation page labels Jan 27, 2025
@DiegoAndai DiegoAndai added enhancement This is not a bug, nor a new feature and removed status: waiting for maintainer These issues haven't been looked at yet by a maintainer labels Jan 27, 2025
@DiegoAndai DiegoAndai self-assigned this Jan 27, 2025
@DiegoAndai DiegoAndai added waiting for 👍 Waiting for upvotes ready to take Help wanted. Guidance available. There is a high chance the change will be accepted labels Jan 27, 2025
@DiegoAndai
Copy link
Member

Hey @Demianeen, thanks for reaching out!

This would be useful. I added the ready to take label in case anyone wants to work on this before the team gets to it. Please let me know if you're interested, and I'll gladly guide you.

Just a note for anyone working on this: I think we need to find a more straightforward way to achieve the demo, as the code shared in the description is a bit intricate for a demo.

@Demianeen
Copy link
Author

Demianeen commented Jan 27, 2025

I never added stuff like this to the docs, but I am happy to try! The only thing is that I agree that code is a bit too complicated for a demo. Does mui has some helper functions that I can look into that can help to remove useTab or useFocus? It would remove almost half of the code

Also, there is an @ts-expect-error in the component itself, I am not sure if I would be able to fix that types issue. Should we do anything about it?

@DiegoAndai
Copy link
Member

DiegoAndai commented Jan 28, 2025

@Demianeen I would suggest using ButtonBase, here's how IconButton uses it, for example:

const IconButtonRoot = styled(ButtonBase, {

ButtonBase is intended to be a building block for buttons, and handles focus and keyboard navigation.

About the @ts-expect-error, I think we can remove it with this workaround: #44931 (comment)

Let me know if it works 👍🏼

@Demianeen
Copy link
Author

Demianeen commented Feb 3, 2025

Thanks for pointers! I managed to remove dependency on additional hooks:

import type { ElementType } from 'react'
import { forwardRef, memo, useImperativeHandle, useRef, useState } from 'react'
import type {
	AvatarProps,
	ButtonBaseProps,
	FormHelperTextProps,
} from '@mui/material'
import { Avatar, ButtonBase, FormHelperText, styled } from '@mui/material'
import PersonIcon from '@mui/icons-material/Person'

const VisuallyHiddenInput = styled('input')({
	display: 'none',
})

const ClickableAvatarWrapper = styled(ButtonBase)<
	ButtonBaseProps & { component: ElementType }
>(({ theme }) => ({
	width: 100,
	height: 100,
	cursor: 'pointer',
	transition: 'all .1s',
	borderRadius: '50%',
	'&.Mui-focusVisible': {
		outline: `4px solid ${theme.palette.primary.main}`,
		outlineOffset: '4px',
	},
}))

const ClickableAvatar = styled(Avatar)(() => ({
	width: 100,
	height: 100,
	'&:hover': {
		filter: 'brightness(90%)',
	},
}))

interface AvatarUploadProps {
	avatarProps?: AvatarProps
	inputProps?: React.InputHTMLAttributes<HTMLInputElement>
	helperTextProps?: FormHelperTextProps
}

export const AvatarUpload = memo(
	forwardRef<HTMLInputElement, AvatarUploadProps>(function AvatarUpload(
		{ avatarProps, inputProps, helperTextProps },
		ref,
	) {
		const [imageSrc, setImageSrc] = useState<string>()
		const inputRef = useRef<HTMLInputElement>(null)

		useImperativeHandle(ref, () => inputRef.current!)

		const handleImageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
			const file = event.target.files?.[0]
			if (file) {
				// Read the file as a data URL
				const reader = new FileReader()
				reader.onload = () => {
					setImageSrc(reader.result as string)
				}
				reader.readAsDataURL(file)
			}

			inputProps?.onChange?.(event)
		}

                // https://stackoverflow.com/questions/75121073/unable-to-trigger-input-event-from-label-element-with-keyboard
		const handleKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => {
			if (event.key === 'Enter' || event.key === ' ') {
				event.preventDefault()
				inputRef.current?.click()
			}
		}

		return (
			<ClickableAvatarWrapper
				component='label'
				tabIndex={0}
				role='button'
				onKeyDown={handleKeyDown}
			>
				<ClickableAvatar
					variant='circular'
					src={imageSrc}
					{...avatarProps}
				>
					<PersonIcon
						sx={{
							fontSize: '40px',
						}}
					/>
				</ClickableAvatar>
				<VisuallyHiddenInput
					type='file'
					accept={'image/png, image/jpg, image/jpeg'}
					multiple={false}
					{...inputProps}
					// those props couldn't be overridden directly, but you still can use on change or ref
					onChange={handleImageChange}
					ref={inputRef}
				/>
				<FormHelperText {...helperTextProps} />
			</ClickableAvatarWrapper>
		)
	}),
)

The only other thing I am unsure about is form helper text, because I use label here for focus and not the input itself, user wouldn't be able to focus on hidden input. I am not sure if FormHelperText will be accessible with this approach. What do you think?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement This is not a bug, nor a new feature ready to take Help wanted. Guidance available. There is a high chance the change will be accepted support: docs-feedback Feedback from documentation page waiting for 👍 Waiting for upvotes
Projects
None yet
Development

No branches or pull requests

2 participants