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

feat: File input with preview #1699

Closed
wants to merge 13 commits into from
39 changes: 39 additions & 0 deletions packages/css/src/components/file-input/file-input.scss
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
padding-inline: var(--ams-file-input-padding-inline);
touch-action: manipulation;

&.ams-file-input--has-preview {
inline-size: 100vw;
}

@include text-rendering;
}

Expand Down Expand Up @@ -67,3 +71,38 @@
box-shadow: var(--ams-file-input-file-selector-button-hover-box-shadow);
color: var(--ams-file-input-file-selector-button-hover-color);
}

.ams-file-input__preview {
display: flex;
flex-direction: column;
gap: var(--ams-file-input-preview-gap);
padding-block: var(--ams-file-input-preview-padding-block);
}

.ams-file-input__file {
display: flex;
flex-direction: row;
font-family: var(--ams-file-input-preview-file-font-family);
font-size: var(--ams-file-input-preview-file-font-size);
font-weight: var(--ams-file-input-preview-file-font-weight);
gap: var(--ams-file-input-preview-file-gap);
line-height: var(--ams-file-input-preview-file-line-height);
}

.ams-file-input__file-title {
flex: 1;
gap: var(--ams-file-input-preview-file-gap);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.ams-file-input__file-details {
color: var(--ams-file-input-preview-file-details-color);
}

.ams-file-input__file-preview {
display: grid;
flex: 0 0 50px;
place-items: center;
}
128 changes: 122 additions & 6 deletions packages/react/src/FileInput/FileInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,132 @@
* Copyright Gemeente Amsterdam
*/

import { DocumentIcon } from '@amsterdam/design-system-react-icons'
import clsx from 'clsx'
import { forwardRef } from 'react'
import { forwardRef, useEffect, useId, useImperativeHandle, useRef, useState } from 'react'
import type { ForwardedRef, InputHTMLAttributes } from 'react'
import { Button } from '../Button'
import { Icon } from '../Icon'

export type FileInputProps = InputHTMLAttributes<HTMLInputElement>
export type FileInputProps = {
showFiles?: boolean
} & InputHTMLAttributes<HTMLInputElement>

export const FileInput = forwardRef(
({ className, ...restProps }: FileInputProps, ref: ForwardedRef<HTMLInputElement>) => (
<input {...restProps} ref={ref} className={clsx('ams-file-input', className)} type="file" />
),
export const FileInput = forwardRef<HTMLInputElement, FileInputProps>(
({ showFiles, className, ...restProps }, ref: ForwardedRef<HTMLInputElement>) => {
const fileInputId = useId()
const previewRef = useRef<HTMLDivElement>(null)
const [files, setFiles] = useState<FileList | null>(null)

const inputRef = useRef<HTMLInputElement | null>(null)

useImperativeHandle(ref, () => inputRef.current as HTMLInputElement)

const updateFilesPreview = () => {
if (inputRef.current) {
setFiles(inputRef.current.files)
}
}

useEffect(() => {
if (showFiles && inputRef.current) {
inputRef.current.addEventListener('change', updateFilesPreview)
return () => {
inputRef.current?.removeEventListener('change', updateFilesPreview)
}
}
return () => window.removeEventListener('change', updateFilesPreview)
}, [showFiles])

const prettyBytes = (num: number, precision = 3) => {
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']

if (num === 0) return '0 B'

const exponent = Math.floor(Math.log10(num) / 3)
const size = (num / Math.pow(1000, exponent)).toPrecision(precision)

return `${size}${UNITS[exponent]}`
}

const prettyType = (type: string) => {
switch (type) {
case 'image/gif':
return 'gif'
case 'image/jpeg':
return 'jpg'
case 'image/png':
return 'png'
case 'application/pdf':
return 'pdf'
case 'application/msword':
return 'Word'
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
return 'Word'
case 'application/vnd.ms-excel':
return 'Excel'
case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
return 'Excel'
case 'application/vnd.ms-powerpoint':
return 'PowerPoint'
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
return 'PowerPoint'
default:
return type
}
}

return (
<>
<input
{...restProps}
id={showFiles ? fileInputId : undefined}
ref={inputRef}
className={clsx('ams-file-input', showFiles && 'ams-file-input--has-preview', className)}
type="file"
/>
{showFiles && (
<div ref={previewRef} className="ams-file-input__preview">
{files && files.length > 0
? Array.from(files).map((file) => (
<div className="ams-file-input__file">
<div className="ams-file-input__file-preview">
{file.type.includes('image') ? (
<img src={URL.createObjectURL(file)} alt={file.name} width={50} height="auto" />
) : (
<Icon svg={DocumentIcon} size="level-3" square />
)}
</div>
<div className="ams-file-input__file-title">
{file.name}
<div className="ams-file-input__file-details">
({prettyType(file.type)}, {prettyBytes(file.size)} )
</div>
</div>
<Button
variant="tertiary"
onClick={() => {
setFiles((prevFiles) => {
const newFiles = new DataTransfer()
Array.from(prevFiles || []).forEach((f) => newFiles.items.add(f))
newFiles.items.remove(Array.from(prevFiles || []).indexOf(file))
if (inputRef.current) {
inputRef.current.files = newFiles.files
}
return newFiles.files
})
}}
>
Verwijder
</Button>
</div>
))
: null}
</div>
)}
</>
)
},
)

FileInput.displayName = 'FileInput'
14 changes: 14 additions & 0 deletions proprietary/tokens/src/components/ams/file-input.tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@
"forced-color-mode": {
"border": { "value": "{ams.border.width.md} solid" }
}
},
"preview": {
"gap": { "value": "{ams.space.md}" },
"padding-block": { "value": "{ams.space.md}" },
"file": {
"font-family": { "value": "{ams.text.font-family}" },
"font-size": { "value": "{ams.text.level.6.font-size}" },
"font-weight": { "value": "{ams.text.font-weight.normal}" },
"gap": { "value": "{ams.space.sm}" },
"line-height": { "value": "{ams.text.level.6.line-height}" },
"details": {
"color": { "value": "{ams.color.neutral-grey3}" }
}
}
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions storybook/src/components/FileInput/FileInput.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,7 @@ export const InAField: Story = {
</Field>
),
}

export const WithPreview: Story = {
args: { showFiles: true, multiple: true },
}
Loading