From a6c8fbd1656fc4478129390d56ccefc206f17acd Mon Sep 17 00:00:00 2001 From: Richard Ward Date: Tue, 30 Dec 2025 13:49:19 +0000 Subject: [PATCH] code: fakemui,tsx,timepicker (6 files) --- fakemui/fakemui/inputs/ColorPicker.tsx | 111 +++++++ fakemui/fakemui/inputs/DatePicker.tsx | 66 ++++ fakemui/fakemui/inputs/FileUpload.tsx | 196 +++++++++++ fakemui/fakemui/inputs/TimePicker.tsx | 67 ++++ fakemui/fakemui/inputs/index.js | 4 + fakemui/styles/components/Pickers.module.scss | 305 ++++++++++++++++++ 6 files changed, 749 insertions(+) create mode 100644 fakemui/fakemui/inputs/ColorPicker.tsx create mode 100644 fakemui/fakemui/inputs/DatePicker.tsx create mode 100644 fakemui/fakemui/inputs/FileUpload.tsx create mode 100644 fakemui/fakemui/inputs/TimePicker.tsx create mode 100644 fakemui/styles/components/Pickers.module.scss diff --git a/fakemui/fakemui/inputs/ColorPicker.tsx b/fakemui/fakemui/inputs/ColorPicker.tsx new file mode 100644 index 000000000..ab685bffc --- /dev/null +++ b/fakemui/fakemui/inputs/ColorPicker.tsx @@ -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( + ( + { + 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) => { + const newValue = e.target.value + setLocalValue(newValue) + onChange?.(newValue) + }, [onChange]) + + const handleTextChange = useCallback((e: React.ChangeEvent) => { + 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 ( +
+ {label && {label}} +
+ + {showInput && ( + + )} +
+ {presetColors.length > 0 && ( +
+ {presetColors.map((color) => ( +
+ )} + {helperText && {helperText}} +
+ ) + } +) + +ColorPicker.displayName = 'ColorPicker' diff --git a/fakemui/fakemui/inputs/DatePicker.tsx b/fakemui/fakemui/inputs/DatePicker.tsx new file mode 100644 index 000000000..1fb3e1b4f --- /dev/null +++ b/fakemui/fakemui/inputs/DatePicker.tsx @@ -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( + ( + { + label, + helperText, + error, + value, + onChange, + min, + max, + disabled, + required, + className = '', + format = 'date', + placeholder, + ...props + }, + ref + ) => { + const handleChange = (e: React.ChangeEvent) => { + onChange?.(e.target.value) + } + + return ( +
+ {label && {label}} + + {helperText && {helperText}} +
+ ) + } +) + +DatePicker.displayName = 'DatePicker' diff --git a/fakemui/fakemui/inputs/FileUpload.tsx b/fakemui/fakemui/inputs/FileUpload.tsx new file mode 100644 index 000000000..8a8f1a4e6 --- /dev/null +++ b/fakemui/fakemui/inputs/FileUpload.tsx @@ -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( + ( + { + label, + helperText, + error, + onChange, + onRemove, + disabled, + required, + className = '', + accept, + multiple = false, + maxSize, + maxFiles, + dragDrop = true, + showPreview = true, + variant = 'default', + ...props + }, + ref + ) => { + const inputRef = useRef(null) + const [files, setFiles] = useState([]) + const [isDragging, setIsDragging] = useState(false) + const [errorMessage, setErrorMessage] = useState(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) => { + 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 ( +
+ {label && {label}} + +
+ { + if (typeof ref === 'function') ref(el) + else if (ref) ref.current = el + ;(inputRef as React.MutableRefObject).current = el + }} + type="file" + onChange={handleChange} + disabled={disabled} + accept={accept} + multiple={multiple} + className="file-upload__input" + {...props} + /> +
+ 📁 + + {isDragging + ? 'Drop files here' + : dragDrop + ? 'Drag and drop or click to upload' + : 'Click to upload'} + + {accept && Accepted: {accept}} +
+
+ + {showPreview && files.length > 0 && ( +
+ {files.map((file, index) => ( +
+ {file.name} + + {(file.size / 1024).toFixed(1)} KB + + +
+ ))} +
+ )} + + {displayError && {displayError}} + {!displayError && helperText && {helperText}} +
+ ) + } +) + +FileUpload.displayName = 'FileUpload' diff --git a/fakemui/fakemui/inputs/TimePicker.tsx b/fakemui/fakemui/inputs/TimePicker.tsx new file mode 100644 index 000000000..3d6f17e22 --- /dev/null +++ b/fakemui/fakemui/inputs/TimePicker.tsx @@ -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( + ( + { + label, + helperText, + error, + value, + onChange, + min, + max, + disabled, + required, + className = '', + step, + placeholder, + ...props + }, + ref + ) => { + const handleChange = (e: React.ChangeEvent) => { + onChange?.(e.target.value) + } + + return ( +
+ {label && {label}} + + {helperText && {helperText}} +
+ ) + } +) + +TimePicker.displayName = 'TimePicker' diff --git a/fakemui/fakemui/inputs/index.js b/fakemui/fakemui/inputs/index.js index c26132d26..a951986e7 100644 --- a/fakemui/fakemui/inputs/index.js +++ b/fakemui/fakemui/inputs/index.js @@ -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' diff --git a/fakemui/styles/components/Pickers.module.scss b/fakemui/styles/components/Pickers.module.scss new file mode 100644 index 000000000..8bd3c8748 --- /dev/null +++ b/fakemui/styles/components/Pickers.module.scss @@ -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; + } + } +}