|
|
@@ -2,10 +2,22 @@ import * as React from 'react'; |
|
|
|
import { useClientSide, useFallbackId, useProxyInput } from '@modal-sh/react-utils'; |
|
|
|
import clsx from 'clsx'; |
|
|
|
|
|
|
|
export interface CommonPreviewProps<F extends Partial<File> = Partial<File>> { |
|
|
|
export interface CommonPreviewComponentProps<F extends Partial<File> = Partial<File>> { |
|
|
|
/** |
|
|
|
* The file to preview. |
|
|
|
*/ |
|
|
|
file?: F; |
|
|
|
/** |
|
|
|
* Is the component disabled? |
|
|
|
*/ |
|
|
|
disabled?: boolean; |
|
|
|
/** |
|
|
|
* Should the component be enhanced? |
|
|
|
*/ |
|
|
|
enhanced?: boolean; |
|
|
|
/** |
|
|
|
* Should the component use minimal space? |
|
|
|
*/ |
|
|
|
mini?: boolean; |
|
|
|
} |
|
|
|
|
|
|
@@ -13,7 +25,7 @@ export type FileSelectBoxDerivedElement = HTMLInputElement; |
|
|
|
|
|
|
|
export interface FileSelectBoxProps< |
|
|
|
F extends Partial<File> = Partial<File>, |
|
|
|
P extends CommonPreviewProps<F> = CommonPreviewProps<F> |
|
|
|
P extends CommonPreviewComponentProps<F> = CommonPreviewComponentProps<F> |
|
|
|
> extends Omit<React.HTMLProps<FileSelectBoxDerivedElement>, 'size' | 'type' | 'label' | 'list'> { |
|
|
|
/** |
|
|
|
* Should the component display a border? |
|
|
@@ -39,46 +51,67 @@ export interface FileSelectBoxProps< |
|
|
|
* Is the label hidden? |
|
|
|
*/ |
|
|
|
hiddenLabel?: boolean, |
|
|
|
/** |
|
|
|
* Preview component for the selected file(s). |
|
|
|
*/ |
|
|
|
previewComponent?: React.ElementType<P>, |
|
|
|
} |
|
|
|
|
|
|
|
export const FileSelectBoxDefaultPreviewComponent: React.FC<CommonPreviewProps> = ({ |
|
|
|
export const FileSelectBoxDefaultPreviewComponent = React.forwardRef< |
|
|
|
HTMLDivElement, |
|
|
|
CommonPreviewComponentProps |
|
|
|
>(({ |
|
|
|
file, |
|
|
|
mini, |
|
|
|
}) => ( |
|
|
|
<> |
|
|
|
enhanced, |
|
|
|
disabled, |
|
|
|
}, forwardedRef) => ( |
|
|
|
<div |
|
|
|
data-enhanced={enhanced} |
|
|
|
className={clsx({ |
|
|
|
'opacity-50': disabled, |
|
|
|
})} |
|
|
|
ref={forwardedRef} |
|
|
|
> |
|
|
|
<div className="w-full whitespace-nowrap overflow-hidden text-ellipsis"> |
|
|
|
{file?.name ?? ( |
|
|
|
<span className="opacity-50"> |
|
|
|
File |
|
|
|
</span> |
|
|
|
<span className="opacity-50"> |
|
|
|
File |
|
|
|
</span> |
|
|
|
)} |
|
|
|
</div> |
|
|
|
{!mini && ( |
|
|
|
<> |
|
|
|
{typeof file?.type === 'string' && ( |
|
|
|
<div className="w-full whitespace-nowrap overflow-hidden text-ellipsis">{file?.type}</div> |
|
|
|
)} |
|
|
|
{typeof file?.size === 'number' && ( |
|
|
|
<div className="w-full whitespace-nowrap overflow-hidden text-ellipsis"> |
|
|
|
{new Intl.NumberFormat(undefined, { |
|
|
|
style: 'unit', |
|
|
|
unit: 'byte', |
|
|
|
unitDisplay: 'long', |
|
|
|
}).format(file.size ?? 0)} |
|
|
|
</div> |
|
|
|
)} |
|
|
|
{typeof file?.lastModified === 'number' && ( |
|
|
|
<div className="w-full whitespace-nowrap overflow-hidden text-ellipsis"> |
|
|
|
<time dateTime={new Date(file.lastModified).toISOString()}> |
|
|
|
{new Date(file.lastModified).toDateString()} |
|
|
|
</time> |
|
|
|
</div> |
|
|
|
)} |
|
|
|
</> |
|
|
|
<> |
|
|
|
{typeof file?.type === 'string' && ( |
|
|
|
<div className="w-full whitespace-nowrap overflow-hidden text-ellipsis">{file?.type}</div> |
|
|
|
)} |
|
|
|
{typeof file?.size === 'number' && ( |
|
|
|
<div |
|
|
|
title={new Intl.NumberFormat(undefined, { |
|
|
|
style: 'unit', |
|
|
|
unit: 'byte', |
|
|
|
unitDisplay: 'long', |
|
|
|
}).format(file.size ?? 0)} |
|
|
|
className="w-full whitespace-nowrap overflow-hidden text-ellipsis tabular-nums" |
|
|
|
> |
|
|
|
{new Intl.NumberFormat(undefined, { |
|
|
|
style: 'unit', |
|
|
|
unit: 'kilobyte', |
|
|
|
unitDisplay: 'long', |
|
|
|
}).format(file.size ?? 0)} |
|
|
|
</div> |
|
|
|
)} |
|
|
|
{typeof file?.lastModified === 'number' && ( |
|
|
|
<div className="w-full whitespace-nowrap overflow-hidden text-ellipsis"> |
|
|
|
<time dateTime={new Date(file.lastModified).toISOString()}> |
|
|
|
{new Date(file.lastModified).toDateString()} |
|
|
|
</time> |
|
|
|
</div> |
|
|
|
)} |
|
|
|
</> |
|
|
|
)} |
|
|
|
</> |
|
|
|
); |
|
|
|
</div> |
|
|
|
)); |
|
|
|
|
|
|
|
export const FileSelectBox = React.forwardRef<FileSelectBoxDerivedElement, FileSelectBoxProps>(( |
|
|
|
{ |
|
|
@@ -104,6 +137,8 @@ export const FileSelectBox = React.forwardRef<FileSelectBoxDerivedElement, FileS |
|
|
|
const [fileList, setFileList] = React.useState<FileList>(); |
|
|
|
const [lastUpdated, setLastUpdated] = React.useState<number>(); |
|
|
|
const clearFileListRef = React.useRef(false); |
|
|
|
const [deleteKeyPressed, setDeleteKeyPressed] = React.useState(false); |
|
|
|
const [aboutToSelect, setAboutToSelect] = React.useState(false); |
|
|
|
const { defaultRef, handleChange: doSetFileList } = useProxyInput< |
|
|
|
React.ChangeEvent<FileSelectBoxDerivedElement> |
|
|
|
| React.MouseEvent<HTMLButtonElement> |
|
|
@@ -113,7 +148,7 @@ export const FileSelectBox = React.forwardRef<FileSelectBoxDerivedElement, FileS |
|
|
|
>({ |
|
|
|
forwardedRef, |
|
|
|
valueSetterFn: (e) => { |
|
|
|
if (e.type === 'click') { |
|
|
|
if (e.type === 'mouseup') { |
|
|
|
// delete |
|
|
|
const fileInput = defaultRef.current as FileSelectBoxDerivedElement; |
|
|
|
clearFileListRef.current = true; |
|
|
@@ -143,7 +178,6 @@ export const FileSelectBox = React.forwardRef<FileSelectBoxDerivedElement, FileS |
|
|
|
} |
|
|
|
}, |
|
|
|
}); |
|
|
|
const ref = forwardedRef ?? defaultRef; |
|
|
|
const labelId = React.useId(); |
|
|
|
const id = useFallbackId(idProp); |
|
|
|
|
|
|
@@ -173,17 +207,61 @@ export const FileSelectBox = React.forwardRef<FileSelectBoxDerivedElement, FileS |
|
|
|
e.currentTarget.files = fileList ?? null; |
|
|
|
}; |
|
|
|
|
|
|
|
const handleKeyDown: React.KeyboardEventHandler<FileSelectBoxDerivedElement> = (e) => { |
|
|
|
const { code } = e; |
|
|
|
if (code === 'Backspace' || code === 'Delete') { |
|
|
|
setDeleteKeyPressed(true); |
|
|
|
} else if (code === 'Enter' || code === 'Space' || code === 'Return') { |
|
|
|
setAboutToSelect(true); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
const handleKeyUp: React.KeyboardEventHandler<FileSelectBoxDerivedElement> = (e) => { |
|
|
|
const { code } = e; |
|
|
|
if (code === 'Backspace' || code === 'Delete') { |
|
|
|
doSetFileList(e); |
|
|
|
setDeleteKeyPressed(false); |
|
|
|
} else if (code === 'Enter' || code === 'Space' || code === 'Return') { |
|
|
|
setAboutToSelect(false); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
const handleReselectMouseDown: React.MouseEventHandler< |
|
|
|
HTMLLabelElement |
|
|
|
> = React.useCallback(() => { |
|
|
|
setAboutToSelect(true); |
|
|
|
setTimeout(() => { |
|
|
|
const fileInput = defaultRef.current as FileSelectBoxDerivedElement; |
|
|
|
fileInput.focus(); |
|
|
|
}); |
|
|
|
}, [defaultRef]); |
|
|
|
|
|
|
|
const handleDeleteMouseDown: React.MouseEventHandler< |
|
|
|
HTMLButtonElement |
|
|
|
> = React.useCallback(() => { |
|
|
|
setDeleteKeyPressed(true); |
|
|
|
setTimeout(() => { |
|
|
|
const fileInput = defaultRef.current as FileSelectBoxDerivedElement; |
|
|
|
fileInput.focus(); |
|
|
|
}); |
|
|
|
}, [defaultRef]); |
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
|
const handleLabelMouseUp = () => { |
|
|
|
setAboutToSelect(false); |
|
|
|
setDeleteKeyPressed(false); |
|
|
|
}; |
|
|
|
|
|
|
|
window.addEventListener('mouseup', handleLabelMouseUp, { capture: true }); |
|
|
|
return () => { |
|
|
|
window.removeEventListener('mouseup', handleLabelMouseUp, { capture: true }); |
|
|
|
}; |
|
|
|
}, []); |
|
|
|
|
|
|
|
return ( |
|
|
|
<div |
|
|
|
className={clsx( |
|
|
|
'relative rounded ring-secondary/50 group', |
|
|
|
'relative rounded ring-secondary/50 group file-select-box', |
|
|
|
'focus-within:ring-4', |
|
|
|
block && 'flex w-full', |
|
|
|
!block && 'inline-flex w-64 min-h-16 justify-center items-center', |
|
|
@@ -210,6 +288,7 @@ export const FileSelectBox = React.forwardRef<FileSelectBoxDerivedElement, FileS |
|
|
|
)} |
|
|
|
data-testid="clickArea" |
|
|
|
htmlFor={id} |
|
|
|
onMouseDown={handleReselectMouseDown} |
|
|
|
> |
|
|
|
{placeholder} |
|
|
|
</label> |
|
|
@@ -218,8 +297,9 @@ export const FileSelectBox = React.forwardRef<FileSelectBoxDerivedElement, FileS |
|
|
|
{...etcProps} |
|
|
|
id={id} |
|
|
|
disabled={disabled} |
|
|
|
ref={typeof ref === 'function' ? defaultRef : ref} |
|
|
|
ref={defaultRef} |
|
|
|
type="file" |
|
|
|
onKeyDown={clientSide ? handleKeyDown : undefined} |
|
|
|
onKeyUp={clientSide ? handleKeyUp : undefined} |
|
|
|
className={clsx( |
|
|
|
'peer box-border focus:outline-0 px-4 pt-2 block resize-y min-h-16 cursor-pointer disabled:cursor-not-allowed file:bg-transparent file:text-primary file:block file:font-bold file:font-semi-expanded file:uppercase file:p-0 file:border-0 group-focus-within:file:text-secondary', |
|
|
@@ -323,7 +403,14 @@ export const FileSelectBox = React.forwardRef<FileSelectBoxDerivedElement, FileS |
|
|
|
<label |
|
|
|
data-testid="reselect" |
|
|
|
htmlFor={id} |
|
|
|
className="flex w-full h-full bg-negative text-primary disabled:text-primary cursor-pointer group-focus-within:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none" |
|
|
|
onMouseDown={handleReselectMouseDown} |
|
|
|
className={clsx( |
|
|
|
'flex w-full h-full bg-negative text-primary cursor-pointer items-center justify-center leading-none gap-4 select-none', |
|
|
|
{ |
|
|
|
'group-focus-within:text-secondary group-focus-within:active:text-tertiary': !aboutToSelect, |
|
|
|
'text-tertiary': aboutToSelect, |
|
|
|
}, |
|
|
|
)} |
|
|
|
> |
|
|
|
<span |
|
|
|
className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded" |
|
|
@@ -334,13 +421,21 @@ export const FileSelectBox = React.forwardRef<FileSelectBoxDerivedElement, FileS |
|
|
|
</div> |
|
|
|
<div className="w-0 flex-auto flex flex-col items-center justify-center h-full"> |
|
|
|
<button |
|
|
|
disabled={disabled} |
|
|
|
data-testid="clear" |
|
|
|
type="button" |
|
|
|
name="action" |
|
|
|
value="clear" |
|
|
|
onClick={doSetFileList} |
|
|
|
onMouseDown={handleDeleteMouseDown} |
|
|
|
onMouseUp={doSetFileList} |
|
|
|
tabIndex={-1} |
|
|
|
className="flex w-full h-full bg-negative text-primary disabled:text-primary group-focus-within:text-secondary group-focus-within:active:text-tertiary items-center justify-center leading-none gap-4 select-none focus:outline-0" |
|
|
|
className={clsx( |
|
|
|
'flex w-full h-full bg-negative text-primary disabled:text-primary items-center justify-center leading-none gap-4 select-none focus:outline-0', |
|
|
|
{ |
|
|
|
'group-focus-within:text-secondary group-focus-within:active:text-tertiary': !deleteKeyPressed, |
|
|
|
'text-tertiary': deleteKeyPressed, |
|
|
|
}, |
|
|
|
)} |
|
|
|
> |
|
|
|
<span |
|
|
|
className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded" |
|
|
@@ -365,11 +460,18 @@ export const FileSelectBox = React.forwardRef<FileSelectBoxDerivedElement, FileS |
|
|
|
FileSelectBox.displayName = 'FileSelectBox'; |
|
|
|
|
|
|
|
FileSelectBox.defaultProps = { |
|
|
|
border: false, |
|
|
|
block: false, |
|
|
|
enhanced: false, |
|
|
|
hiddenLabel: false, |
|
|
|
border: false as const, |
|
|
|
block: false as const, |
|
|
|
enhanced: false as const, |
|
|
|
hiddenLabel: false as const, |
|
|
|
label: undefined, |
|
|
|
hint: undefined, |
|
|
|
previewComponent: FileSelectBoxDefaultPreviewComponent, |
|
|
|
}; |
|
|
|
|
|
|
|
FileSelectBoxDefaultPreviewComponent.defaultProps = { |
|
|
|
file: undefined, |
|
|
|
mini: false, |
|
|
|
disabled: FileSelectBox.defaultProps.disabled, |
|
|
|
enhanced: FileSelectBox.defaultProps.enhanced, |
|
|
|
}; |