code: fakemui,tsx,timepicker (6 files)

This commit is contained in:
Richard Ward
2025-12-30 13:49:19 +00:00
parent c939eff626
commit a6c8fbd165
6 changed files with 749 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
import React, { forwardRef, useState, useCallback } from 'react'
import { FormLabel } from './FormLabel'
import { FormHelperText } from './FormHelperText'
export interface ColorPickerProps {
label?: React.ReactNode
helperText?: React.ReactNode
error?: boolean
value?: string
onChange?: (value: string) => void
disabled?: boolean
required?: boolean
className?: string
showInput?: boolean
presetColors?: string[]
alpha?: boolean
}
const DEFAULT_PRESET_COLORS = [
'#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff',
'#ffff00', '#ff00ff', '#00ffff', '#ff8000', '#8000ff',
'#0080ff', '#ff0080', '#808080', '#c0c0c0', '#400000',
'#004000', '#000040', '#404000', '#400040', '#004040'
]
export const ColorPicker = forwardRef<HTMLInputElement, ColorPickerProps>(
(
{
label,
helperText,
error,
value = '#000000',
onChange,
disabled,
required,
className = '',
showInput = true,
presetColors = DEFAULT_PRESET_COLORS,
alpha = false,
...props
},
ref
) => {
const [localValue, setLocalValue] = useState(value)
const handleColorChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
setLocalValue(newValue)
onChange?.(newValue)
}, [onChange])
const handleTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
setLocalValue(newValue)
if (/^#[0-9A-Fa-f]{6}$/.test(newValue) || /^#[0-9A-Fa-f]{8}$/.test(newValue)) {
onChange?.(newValue)
}
}, [onChange])
const handlePresetClick = useCallback((color: string) => {
setLocalValue(color)
onChange?.(color)
}, [onChange])
return (
<div className={`color-picker ${error ? 'color-picker--error' : ''} ${disabled ? 'color-picker--disabled' : ''} ${className}`}>
{label && <FormLabel required={required}>{label}</FormLabel>}
<div className="color-picker__controls">
<input
ref={ref}
type="color"
value={localValue.slice(0, 7)}
onChange={handleColorChange}
disabled={disabled}
className="color-picker__input"
{...props}
/>
{showInput && (
<input
type="text"
value={localValue}
onChange={handleTextChange}
disabled={disabled}
className="color-picker__text"
placeholder="#000000"
maxLength={alpha ? 9 : 7}
/>
)}
</div>
{presetColors.length > 0 && (
<div className="color-picker__presets">
{presetColors.map((color) => (
<button
key={color}
type="button"
className={`color-picker__preset ${localValue === color ? 'color-picker__preset--active' : ''}`}
style={{ backgroundColor: color }}
onClick={() => handlePresetClick(color)}
disabled={disabled}
aria-label={`Select color ${color}`}
/>
))}
</div>
)}
{helperText && <FormHelperText error={error}>{helperText}</FormHelperText>}
</div>
)
}
)
ColorPicker.displayName = 'ColorPicker'

View File

@@ -0,0 +1,66 @@
import React, { forwardRef, useState, useRef, useEffect } from 'react'
import { FormLabel } from './FormLabel'
import { FormHelperText } from './FormHelperText'
import { Input } from './Input'
export interface DatePickerProps {
label?: React.ReactNode
helperText?: React.ReactNode
error?: boolean
value?: string
onChange?: (value: string) => void
min?: string
max?: string
disabled?: boolean
required?: boolean
className?: string
format?: 'date' | 'datetime-local' | 'month' | 'week'
placeholder?: string
}
export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(
(
{
label,
helperText,
error,
value,
onChange,
min,
max,
disabled,
required,
className = '',
format = 'date',
placeholder,
...props
},
ref
) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e.target.value)
}
return (
<div className={`date-picker ${error ? 'date-picker--error' : ''} ${className}`}>
{label && <FormLabel required={required}>{label}</FormLabel>}
<Input
ref={ref}
type={format}
value={value}
onChange={handleChange}
min={min}
max={max}
disabled={disabled}
required={required}
error={error}
placeholder={placeholder}
{...props}
/>
{helperText && <FormHelperText error={error}>{helperText}</FormHelperText>}
</div>
)
}
)
DatePicker.displayName = 'DatePicker'

View File

@@ -0,0 +1,196 @@
import React, { forwardRef, useCallback, useRef, useState } from 'react'
import { FormLabel } from './FormLabel'
import { FormHelperText } from './FormHelperText'
export interface FileUploadProps {
label?: React.ReactNode
helperText?: React.ReactNode
error?: boolean
value?: File | File[]
onChange?: (files: File[]) => void
onRemove?: (file: File) => void
disabled?: boolean
required?: boolean
className?: string
accept?: string
multiple?: boolean
maxSize?: number
maxFiles?: number
dragDrop?: boolean
showPreview?: boolean
variant?: 'default' | 'button' | 'dropzone'
}
export const FileUpload = forwardRef<HTMLInputElement, FileUploadProps>(
(
{
label,
helperText,
error,
onChange,
onRemove,
disabled,
required,
className = '',
accept,
multiple = false,
maxSize,
maxFiles,
dragDrop = true,
showPreview = true,
variant = 'default',
...props
},
ref
) => {
const inputRef = useRef<HTMLInputElement>(null)
const [files, setFiles] = useState<File[]>([])
const [isDragging, setIsDragging] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const validateFiles = useCallback((fileList: File[]): File[] => {
let validFiles = fileList
if (maxSize) {
validFiles = validFiles.filter((file) => {
if (file.size > maxSize) {
setErrorMessage(`File ${file.name} exceeds max size of ${maxSize / 1024 / 1024}MB`)
return false
}
return true
})
}
if (maxFiles && validFiles.length > maxFiles) {
setErrorMessage(`Maximum ${maxFiles} files allowed`)
validFiles = validFiles.slice(0, maxFiles)
}
return validFiles
}, [maxSize, maxFiles])
const handleFiles = useCallback((fileList: FileList | null) => {
if (!fileList) return
setErrorMessage(null)
const newFiles = Array.from(fileList)
const validFiles = validateFiles(newFiles)
if (multiple) {
const combined = [...files, ...validFiles]
const limited = maxFiles ? combined.slice(0, maxFiles) : combined
setFiles(limited)
onChange?.(limited)
} else {
setFiles(validFiles.slice(0, 1))
onChange?.(validFiles.slice(0, 1))
}
}, [files, multiple, maxFiles, onChange, validateFiles])
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
handleFiles(e.target.files)
}, [handleFiles])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
if (!disabled && dragDrop) {
setIsDragging(true)
}
}, [disabled, dragDrop])
const handleDragLeave = useCallback(() => {
setIsDragging(false)
}, [])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (!disabled && dragDrop) {
handleFiles(e.dataTransfer.files)
}
}, [disabled, dragDrop, handleFiles])
const handleRemove = useCallback((file: File) => {
const newFiles = files.filter((f) => f !== file)
setFiles(newFiles)
onChange?.(newFiles)
onRemove?.(file)
}, [files, onChange, onRemove])
const handleClick = useCallback(() => {
inputRef.current?.click()
}, [])
const displayError = errorMessage || (error ? helperText : null)
return (
<div className={`file-upload file-upload--${variant} ${error || errorMessage ? 'file-upload--error' : ''} ${disabled ? 'file-upload--disabled' : ''} ${className}`}>
{label && <FormLabel required={required}>{label}</FormLabel>}
<div
className={`file-upload__dropzone ${isDragging ? 'file-upload__dropzone--dragging' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
>
<input
ref={(el) => {
if (typeof ref === 'function') ref(el)
else if (ref) ref.current = el
;(inputRef as React.MutableRefObject<HTMLInputElement | null>).current = el
}}
type="file"
onChange={handleChange}
disabled={disabled}
accept={accept}
multiple={multiple}
className="file-upload__input"
{...props}
/>
<div className="file-upload__content">
<span className="file-upload__icon">📁</span>
<span className="file-upload__text">
{isDragging
? 'Drop files here'
: dragDrop
? 'Drag and drop or click to upload'
: 'Click to upload'}
</span>
{accept && <span className="file-upload__hint">Accepted: {accept}</span>}
</div>
</div>
{showPreview && files.length > 0 && (
<div className="file-upload__preview">
{files.map((file, index) => (
<div key={`${file.name}-${index}`} className="file-upload__file">
<span className="file-upload__filename">{file.name}</span>
<span className="file-upload__size">
{(file.size / 1024).toFixed(1)} KB
</span>
<button
type="button"
className="file-upload__remove"
onClick={(e) => {
e.stopPropagation()
handleRemove(file)
}}
disabled={disabled}
aria-label={`Remove ${file.name}`}
>
×
</button>
</div>
))}
</div>
)}
{displayError && <FormHelperText error>{displayError}</FormHelperText>}
{!displayError && helperText && <FormHelperText>{helperText}</FormHelperText>}
</div>
)
}
)
FileUpload.displayName = 'FileUpload'

View File

@@ -0,0 +1,67 @@
import React, { forwardRef } from 'react'
import { FormLabel } from './FormLabel'
import { FormHelperText } from './FormHelperText'
import { Input } from './Input'
export interface TimePickerProps {
label?: React.ReactNode
helperText?: React.ReactNode
error?: boolean
value?: string
onChange?: (value: string) => void
min?: string
max?: string
disabled?: boolean
required?: boolean
className?: string
step?: number
placeholder?: string
}
export const TimePicker = forwardRef<HTMLInputElement, TimePickerProps>(
(
{
label,
helperText,
error,
value,
onChange,
min,
max,
disabled,
required,
className = '',
step,
placeholder,
...props
},
ref
) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e.target.value)
}
return (
<div className={`time-picker ${error ? 'time-picker--error' : ''} ${className}`}>
{label && <FormLabel required={required}>{label}</FormLabel>}
<Input
ref={ref}
type="time"
value={value}
onChange={handleChange}
min={min}
max={max}
disabled={disabled}
required={required}
error={error}
step={step}
placeholder={placeholder}
{...props}
/>
{helperText && <FormHelperText error={error}>{helperText}</FormHelperText>}
</div>
)
}
)
TimePicker.displayName = 'TimePicker'

View File

@@ -21,3 +21,7 @@ export { Autocomplete } from './Autocomplete'
export { Rating } from './Rating'
export { ButtonBase, InputBase, FilledInput, OutlinedInput } from './InputBase'
export { FormField } from './FormField'
export { DatePicker } from './DatePicker'
export { TimePicker } from './TimePicker'
export { ColorPicker } from './ColorPicker'
export { FileUpload } from './FileUpload'

View File

@@ -0,0 +1,305 @@
// DatePicker styles
.date-picker {
display: flex;
flex-direction: column;
gap: var(--spacing-xs, 4px);
&--error {
.input {
border-color: var(--color-error, #d32f2f);
}
}
input[type="date"],
input[type="datetime-local"],
input[type="month"],
input[type="week"] {
&::-webkit-calendar-picker-indicator {
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
}
}
}
// TimePicker styles
.time-picker {
display: flex;
flex-direction: column;
gap: var(--spacing-xs, 4px);
&--error {
.input {
border-color: var(--color-error, #d32f2f);
}
}
input[type="time"] {
&::-webkit-calendar-picker-indicator {
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
}
}
}
// ColorPicker styles
.color-picker {
display: flex;
flex-direction: column;
gap: var(--spacing-sm, 8px);
&--error {
.color-picker__text {
border-color: var(--color-error, #d32f2f);
}
}
&--disabled {
opacity: 0.5;
pointer-events: none;
}
&__controls {
display: flex;
align-items: center;
gap: var(--spacing-sm, 8px);
}
&__input {
width: 48px;
height: 48px;
padding: 0;
border: 2px solid var(--color-border, #e0e0e0);
border-radius: var(--radius-md, 8px);
cursor: pointer;
background: transparent;
&::-webkit-color-swatch-wrapper {
padding: 4px;
}
&::-webkit-color-swatch {
border: none;
border-radius: var(--radius-sm, 4px);
}
&::-moz-color-swatch {
border: none;
border-radius: var(--radius-sm, 4px);
}
&:hover {
border-color: var(--color-primary, #1976d2);
}
&:focus {
outline: none;
border-color: var(--color-primary, #1976d2);
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.2);
}
}
&__text {
flex: 1;
padding: var(--spacing-sm, 8px) var(--spacing-md, 12px);
border: 1px solid var(--color-border, #e0e0e0);
border-radius: var(--radius-md, 8px);
font-family: var(--font-mono, monospace);
font-size: var(--font-size-sm, 14px);
background: var(--color-bg, #ffffff);
color: var(--color-text, #000000);
text-transform: uppercase;
&:focus {
outline: none;
border-color: var(--color-primary, #1976d2);
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.2);
}
}
&__presets {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs, 4px);
padding: var(--spacing-xs, 4px);
border: 1px solid var(--color-border, #e0e0e0);
border-radius: var(--radius-md, 8px);
background: var(--color-bg-subtle, #f5f5f5);
}
&__preset {
width: 24px;
height: 24px;
padding: 0;
border: 2px solid transparent;
border-radius: var(--radius-sm, 4px);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
transform: scale(1.1);
}
&--active {
border-color: var(--color-primary, #1976d2);
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.3);
}
&:focus {
outline: none;
border-color: var(--color-primary, #1976d2);
}
}
}
// FileUpload styles
.file-upload {
display: flex;
flex-direction: column;
gap: var(--spacing-sm, 8px);
&--error {
.file-upload__dropzone {
border-color: var(--color-error, #d32f2f);
background: rgba(211, 47, 47, 0.05);
}
}
&--disabled {
opacity: 0.5;
pointer-events: none;
}
&__dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-xl, 32px);
border: 2px dashed var(--color-border, #e0e0e0);
border-radius: var(--radius-lg, 12px);
background: var(--color-bg-subtle, #f5f5f5);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--color-primary, #1976d2);
background: rgba(25, 118, 210, 0.05);
}
&--dragging {
border-color: var(--color-primary, #1976d2);
background: rgba(25, 118, 210, 0.1);
border-style: solid;
}
}
&__input {
display: none;
}
&__content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-sm, 8px);
text-align: center;
}
&__icon {
font-size: 48px;
line-height: 1;
}
&__text {
font-size: var(--font-size-md, 16px);
color: var(--color-text, #000000);
}
&__hint {
font-size: var(--font-size-sm, 14px);
color: var(--color-text-muted, #666666);
}
&__preview {
display: flex;
flex-direction: column;
gap: var(--spacing-xs, 4px);
padding: var(--spacing-sm, 8px);
border: 1px solid var(--color-border, #e0e0e0);
border-radius: var(--radius-md, 8px);
background: var(--color-bg, #ffffff);
}
&__file {
display: flex;
align-items: center;
gap: var(--spacing-sm, 8px);
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
border-radius: var(--radius-sm, 4px);
background: var(--color-bg-subtle, #f5f5f5);
}
&__filename {
flex: 1;
font-size: var(--font-size-sm, 14px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__size {
font-size: var(--font-size-xs, 12px);
color: var(--color-text-muted, #666666);
white-space: nowrap;
}
&__remove {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--color-error, #d32f2f);
color: white;
font-size: 14px;
line-height: 1;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--color-error-dark, #b71c1c);
transform: scale(1.1);
}
}
// Variants
&--button {
.file-upload__dropzone {
flex-direction: row;
padding: var(--spacing-sm, 8px) var(--spacing-md, 12px);
border-style: solid;
border-width: 1px;
}
.file-upload__content {
flex-direction: row;
}
.file-upload__icon {
font-size: 24px;
}
}
}