diff --git a/frui/frui.css b/frui/frui.css index db78489..b441bf1 100644 --- a/frui/frui.css +++ b/frui/frui.css @@ -1,5 +1,6 @@ @import url('./styles/globals.css'); @import url('./styles/alert.css'); +@import url('./styles/accordion.css'); @import url('./styles/badge.css'); @import url('./styles/button.css'); @import url('./styles/control.css'); @@ -18,6 +19,7 @@ @import url('./styles/fields/autocomplete.css'); @import url('./styles/fields/date.css'); +@import url('./styles/fields/colorpicker.css'); @import url('./styles/fields/datetime.css'); @import url('./styles/fields/file.css'); @import url('./styles/fields/filelist.css'); @@ -29,6 +31,7 @@ @import url('./styles/fields/multiselect.css'); @import url('./styles/fields/option.css'); @import url('./styles/fields/password.css'); +@import url('./styles/fields/rating.css'); @import url('./styles/fields/select.css'); @import url('./styles/fields/switch.css'); @import url('./styles/fields/taglist.css'); diff --git a/frui/src/element/Accordion.tsx b/frui/src/element/Accordion.tsx new file mode 100644 index 0000000..5b2ff6d --- /dev/null +++ b/frui/src/element/Accordion.tsx @@ -0,0 +1,165 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + useMemo, + ReactNode, + HTMLAttributes, + CSSProperties, + ButtonHTMLAttributes, + useId, +} from 'react'; + +interface AccordionContextProps { + isOpen: boolean; + detailsId: string; + summaryId: string; + disabled: boolean; + toggle: (event: React.SyntheticEvent) => void; +} + +export type AccordionProps = Omit, 'onChange'> & { + children: ReactNode; + id?: string; // Optional ID - will be generated if missing + expanded?: boolean; // Controlled state + defaultExpanded?: boolean; // Uncontrolled state + disabled?: boolean; + onChange?: (event: React.SyntheticEvent, isExpanded: boolean) => void; + className?: string; + style?: CSSProperties; +}; + +export type AccordionSummaryProps = Omit, 'onClick'> & { + children: ReactNode; + expandIcon?: ReactNode; + className?: string; + style?: CSSProperties; +}; + +export type AccordionDetailsProps = HTMLAttributes & { + children: ReactNode; + className?: string; + style?: CSSProperties; +}; + +const AccordionContext = createContext(undefined); + +const useAccordionContext = () => { + const context = useContext(AccordionContext); + if (!context) { + throw new Error('Accordion components must be used within an Accordion'); + } + return context; +}; + + +/** + * Accordion Component + */ +export function Accordion({ + children, + id: providedId, + expanded: controlledExpanded, + defaultExpanded = false, + disabled = false, + onChange, + className, + style, + ...attributes +}: AccordionProps) { + const [uncontrolledExpanded, setUncontrolledExpanded] = useState(defaultExpanded); + const generatedId = useId(); // Generate a unique ID if none is provided + const id = providedId || generatedId; // Use provided ID or generated one + + const isControlled = controlledExpanded !== undefined; + const isOpen = isControlled ? controlledExpanded : uncontrolledExpanded; + + const summaryId = `${id}-summary`; + const detailsId = `${id}-details`; + + const toggle = useCallback((event: React.SyntheticEvent) => { + if (disabled) return; + const newState = !isOpen; + if (!isControlled) { + setUncontrolledExpanded(newState); + } + onChange?.(event, newState); + }, [isControlled, isOpen, onChange, disabled]); + + const contextValue = useMemo(() => ({ + isOpen, + detailsId, + summaryId, + disabled, + toggle, + }), [isOpen, detailsId, summaryId, disabled, toggle]); + + const accordionClassName = `frui-accordion ${disabled ? 'frui-accordion-disabled' : ''} ${isOpen ? 'frui-accordion-open' : ''} ${className || ''}`; + + return ( + +
+ {children} +
+
+ ); +} + +/** + * Accordion Summary Component + */ +export function AccordionSummary({ + children, + expandIcon, + className, + style, + ...attributes +}: AccordionSummaryProps) { + const { isOpen, detailsId, summaryId, disabled, toggle } = useAccordionContext(); + const summaryClassName = `frui-accordion-button ${disabled ? 'frui-accordion-button-disabled' : ''} ${className || ''}`; + const iconClassName = `frui-accordion-icon ${isOpen ? 'frui-accordion-icon-rotate' : ''}`; + + return ( + + ); +} + +/** + * Accordion Details Component + */ +export function AccordionDetails({ + children, + className, + style, + ...attributes +}: AccordionDetailsProps) { + const { isOpen, detailsId, summaryId } = useAccordionContext(); + const detailsClassName = `frui-accordion-content ${isOpen ? 'frui-accordion-content-open' : ''} ${className || ''}`; + + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/frui/src/field/ColorPicker.tsx b/frui/src/field/ColorPicker.tsx new file mode 100644 index 0000000..a96fa1a --- /dev/null +++ b/frui/src/field/ColorPicker.tsx @@ -0,0 +1,446 @@ +import React, { useState, useRef, useEffect, useCallback, CSSProperties } from 'react'; +import ColorDisplay, { ColorProps } from '../format/Color'; + +interface RGBA { r: number; g: number; b: number; a: number; } +interface HSVA { h: number; s: number; v: number; a: number; } + +function rgbaToString(rgba: RGBA): string { + return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a.toFixed(2)})`; +} + +function parseColorString(colorString: string): RGBA | null { + if (!colorString) return null; + + let match = colorString.match(/^rgba?\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*([\d.]+))?\)$/i); + if (match) { + const r = parseInt(match[1], 10); + const g = parseInt(match[2], 10); + const b = parseInt(match[3], 10); + const a = match[4] !== undefined ? parseFloat(match[4]) : 1; + if (r > 255 || g > 255 || b > 255 || a < 0 || a > 1 || isNaN(a) || isNaN(r) || isNaN(g) || isNaN(b)) { + return null; + } + return { r, g, b, a }; + } + + match = colorString.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i); // #RRGGBBAA or #RRGGBB + if (match) { + const r = parseInt(match[1], 16); + const g = parseInt(match[2], 16); + const b = parseInt(match[3], 16); + const a = match[4] !== undefined ? parseInt(match[4], 16) / 255 : 1; + if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(a) || a < 0 || a > 1) { + return null; + } + return { r, g, b, a }; + } + match = colorString.match(/^#?([a-f\d])([a-f\d])([a-f\d])([a-f\d])?$/i); // #RGBA or #RGB + if (match) { + const r = parseInt(match[1] + match[1], 16); + const g = parseInt(match[2] + match[2], 16); + const b = parseInt(match[3] + match[3], 16); + const a = match[4] !== undefined ? parseInt(match[4] + match[4], 16) / 255 : 1; + if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(a) || a < 0 || a > 1) { + return null; + } + return { r, g, b, a }; + } + + return null; +} + +const defaultHsva: HSVA = { h: 208, s: 61, v: 89, a: 0.75 }; +const defaultRgbaString = rgbaToString(hsvaToRgba(defaultHsva)); + +export type ColorPickerProps = Omit & { + value?: string; + defaultValue?: string; + onChange?: (color: string) => void; + showAlpha?: boolean; + showInputs?: boolean; + swatches?: string[]; + pickerStyle?: CSSProperties; + pickerClassName?: string; +}; + +export default function ColorPicker(props: ColorPickerProps) { + const { + value, + defaultValue = defaultRgbaString, + onChange, + showAlpha = true, + showInputs = true, + swatches = [], + className, + style, + pickerStyle, + pickerClassName, + box = true, + text = true, + sm, + md, + lg, + } = props; + + const isControlled = value !== undefined; + + const getHsvaFromProps = (): HSVA => { + const colorString = isControlled ? value : defaultValue; + const parsedRgba = parseColorString(colorString || defaultRgbaString); + if (parsedRgba) { + return rgbaToHsva(parsedRgba); + } + return defaultHsva; + }; + + const [internalHsva, setInternalHsva] = useState(getHsvaFromProps); + + const hsva = isControlled + ? (parseColorString(value!) ? rgbaToHsva(parseColorString(value!)!) : internalHsva) + : internalHsva; + + const [displayPicker, setDisplayPicker] = useState(false); + const [isDragging, setIsDragging] = useState<'palette' | 'hue' | 'alpha' | null>(null); + + const pickerRef = useRef(null); + const wrapperRef = useRef(null); + const paletteRef = useRef(null); + const hueSliderRef = useRef(null); + const alphaSliderRef = useRef(null); + + const currentRgba = hsvaToRgba(hsva); + const currentRgbaString = rgbaToString(currentRgba); + + useEffect(() => { + if (isControlled) { + const externalRgba = parseColorString(value!); + if (externalRgba) { + const externalHsva = rgbaToHsva(externalRgba); + if (Math.abs(externalHsva.h - internalHsva.h) > 1 || + Math.abs(externalHsva.s - internalHsva.s) > 1 || + Math.abs(externalHsva.v - internalHsva.v) > 1 || + Math.abs(externalHsva.a - internalHsva.a) > 0.01) + { + setInternalHsva(externalHsva); + } + } + } + }, [value, isControlled, internalHsva.h, internalHsva.s, internalHsva.v, internalHsva.a]); + + + const handleColorDisplayClick = () => { + setDisplayPicker(prev => !prev); + }; + + const updateColor = (newHsvaPartial: Partial, source: 'direct' | 'drag' = 'direct') => { + const newHsva = { ...hsva, ...newHsvaPartial }; + newHsva.h = Math.max(0, Math.min(360, Math.round(newHsva.h))); + newHsva.s = Math.max(0, Math.min(100, Math.round(newHsva.s))); + newHsva.v = Math.max(0, Math.min(100, Math.round(newHsva.v))); + newHsva.a = Math.max(0, Math.min(1, parseFloat(newHsva.a.toFixed(2)))); + + const newRgbaString = rgbaToString(hsvaToRgba(newHsva)); + const currentPropRgbaString = isControlled && parseColorString(value!) ? rgbaToString(parseColorString(value!)!) : null; + + if (!isControlled || (isControlled && source === 'drag')) { + setInternalHsva(newHsva); + } + + const notifyValue = currentPropRgbaString ?? rgbaToString(hsvaToRgba(internalHsva)); + if (onChange && newRgbaString !== notifyValue) { + onChange(newRgbaString); + } + }; + + const handleClickOutside = useCallback((event: MouseEvent) => { + if ( + pickerRef.current && !pickerRef.current.contains(event.target as Node) && + wrapperRef.current && !wrapperRef.current.contains(event.target as Node) + ) { + setDisplayPicker(false); + } + }, []); + + const handleMouseDown = (type: 'palette' | 'hue' | 'alpha', e: React.MouseEvent) => { + if (e.button !== 0) return; + setIsDragging(type); + e.preventDefault(); + handleMouseMove(e as unknown as MouseEvent); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging) return; + + if (isDragging === 'palette' && paletteRef.current) { + const rect = paletteRef.current.getBoundingClientRect(); + let x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); + let y = Math.max(0, Math.min(e.clientY - rect.top, rect.height)); + const s = (x / rect.width) * 100; + const v = 100 - (y / rect.height) * 100; + updateColor({ s: Math.round(s), v: Math.round(v) }, 'drag'); + } else if (isDragging === 'hue' && hueSliderRef.current) { + const rect = hueSliderRef.current.getBoundingClientRect(); + let x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); + const h = Math.round((x / rect.width) * 360); + updateColor({ h }, 'drag'); + } else if (isDragging === 'alpha' && alphaSliderRef.current) { + const rect = alphaSliderRef.current.getBoundingClientRect(); + let x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); + const a = parseFloat((x / rect.width).toFixed(2)); + updateColor({ a }, 'drag'); + } + }; + + const handleMouseUp = () => { + if (isDragging) { + setIsDragging(null); + } + }; + + const handleHueChange = (e: React.ChangeEvent) => { + updateColor({ h: parseInt(e.target.value, 10) }); + }; + const handleAlphaChange = (e: React.ChangeEvent) => { + updateColor({ a: parseFloat(e.target.value) }); + }; + + + useEffect(() => { + if (isDragging) { + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp, { once: true }); + } + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging]); + + + useEffect(() => { + if (displayPicker) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [displayPicker, handleClickOutside]); + + const handleRgbaInputChange = (field: 'r' | 'g' | 'b' | 'a', fieldValue: string) => { + let numericValue = field === 'a' ? parseFloat(fieldValue) : parseInt(fieldValue, 10); + + if (isNaN(numericValue) && fieldValue !== '') return; + if (isNaN(numericValue) && fieldValue === '') numericValue = 0; + + + const currentRgba = hsvaToRgba(hsva); + let newRgba = { ...currentRgba }; + + if (field === 'a') { + newRgba.a = Math.max(0, Math.min(1, numericValue)); + } else { + newRgba[field] = Math.max(0, Math.min(255, numericValue)); + } + + updateColor(rgbaToHsva(newRgba), 'direct'); + }; + + const handleSwatchClick = (swatchColor: string) => { + const parsed = parseColorString(swatchColor); + if (parsed) { + updateColor(rgbaToHsva(parsed), 'direct'); + } + }; + + const colorProps = { value: currentRgbaString, box, text, sm, md, lg }; + + const wrapperClasses = ['frui-colorpicker-wrapper', className].filter(Boolean).join(' '); + const popoverClasses = ['frui-colorpicker-popover', pickerClassName].filter(Boolean).join(' '); + + const palettePointerX = (hsva.s / 100) * 100; + const palettePointerY = ((100 - hsva.v) / 100) * 100; + const hueColor = `hsl(${hsva.h}, 100%, 50%)`; + const alphaGradient = `linear-gradient(to right, rgba(${currentRgba.r},${currentRgba.g},${currentRgba.b},0), rgba(${currentRgba.r},${currentRgba.g},${currentRgba.b},1))`; + + const dynamicPopoverStyles: CSSProperties = { + ...pickerStyle, + '--frui-cp-hue-color': hueColor, + '--frui-cp-pointer-x': `${palettePointerX}%`, + '--frui-cp-pointer-y': `${palettePointerY}%`, + '--frui-cp-alpha-gradient': alphaGradient, + } as CSSProperties; + + return ( +
+
+ +
+ + {displayPicker && ( +
e.stopPropagation()} + > +
handleMouseDown('palette', e)} + style={{ backgroundColor: hueColor }} + > +
+
+
+
+ +
+
+ + handleMouseDown('hue', e)} + onChange={handleHueChange} + className="frui-colorpicker-slider frui-colorpicker-slider-hue" + /> +
+ + {showAlpha && ( +
+ + handleMouseDown('alpha', e)} + onChange={handleAlphaChange} + className="frui-colorpicker-slider frui-colorpicker-slider-alpha" + /> +
+ )} +
+ + {showInputs && ( +
+
+
+ handleRgbaInputChange('r', e.target.value)} className="frui-colorpicker-input-field" /> + +
+
+ handleRgbaInputChange('g', e.target.value)} className="frui-colorpicker-input-field" /> + +
+
+ handleRgbaInputChange('b', e.target.value)} className="frui-colorpicker-input-field" /> + +
+ {showAlpha && ( +
+ handleRgbaInputChange('a', e.target.value)} className="frui-colorpicker-input-field" /> + +
+ )} +
+
+ )} + + {swatches && swatches.length > 0 && ( +
+ {swatches.map((swatchColor, index) => { + const parsedSwatch = parseColorString(swatchColor); + const swatchStyle: CSSProperties = parsedSwatch + ? { backgroundColor: rgbaToString(parsedSwatch) } + : { backgroundColor: 'transparent', border: '1px dashed #f00' }; + + return ( +
handleSwatchClick(swatchColor)} + role="button" + aria-label={`Select color ${swatchColor}`} + > +
+
+ ); + })} +
+ )} + +
+ )} +
+ ); +} + +function hsvaToRgba(hsva: HSVA): RGBA { + const s = hsva.s / 100; + const v = hsva.v / 100; + const h = hsva.h / 60; + const a = hsva.a; + const i = Math.floor(h); + const f = h - i; + const p = v * (1 - s); + const q = v * (1 - s * f); + const t = v * (1 - s * (1 - f)); + let r = 0, g = 0, b = 0; + switch (i % 6) { + case 0: r = v; g = t; b = p; break; + case 1: r = q; g = v; b = p; break; + case 2: r = p; g = v; b = t; break; + case 3: r = p; g = q; b = v; break; + case 4: r = t; g = p; b = v; break; + case 5: r = v; g = p; b = q; break; + } + return { + r: Math.round(r * 255), + g: Math.round(g * 255), + b: Math.round(b * 255), + a: a + }; +} + +function rgbaToHsva(rgba: RGBA): HSVA { + const r = rgba.r / 255; + const g = rgba.g / 255; + const b = rgba.b / 255; + const a = rgba.a; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const delta = max - min; + let h = 0; + let s = 0; + const v = max; + if (delta !== 0) { + s = max === 0 ? 0 : delta / max; + switch (max) { + case r: h = (g - b) / delta + (g < b ? 6 : 0); break; + case g: h = (b - r) / delta + 2; break; + case b: h = (r - g) / delta + 4; break; + } + h = Math.round(h * 60); + if (h < 0) h += 360; + } + return { + h: h, + s: Math.round(s * 100), + v: Math.round(v * 100), + a: a + }; +} \ No newline at end of file diff --git a/frui/src/field/Rating.tsx b/frui/src/field/Rating.tsx new file mode 100644 index 0000000..34848aa --- /dev/null +++ b/frui/src/field/Rating.tsx @@ -0,0 +1,137 @@ +import React, { useState, useCallback, ChangeEvent, MouseEvent, ReactNode, useId, CSSProperties } from 'react'; + +// --- Default SVG Star Icon --- +const StarIcon = ({ style, ...props }: React.SVGProps) => ( + + + +); + +// --- Types --- + +export type RatingProps = { + name?: string; + value?: number | null; + defaultValue?: number | null; + max?: number; + // precision?: number; // (Future enhancement: 0.5 for half stars) - Currently supports 1 + onChange?: (event: ChangeEvent, value: number | null) => void; + onChangeActive?: (event: MouseEvent, value: number | null) => void; // Hover change + readOnly?: boolean; + disabled?: boolean; + size?: 'small' | 'medium' | 'large'; + icon?: ReactNode; + emptyIcon?: ReactNode; + getLabelText?: (value: number) => string; + highlightSelectedOnly?: boolean; + className?: string; + style?: CSSProperties; +}; + +const defaultGetLabelText = (value: number): string => `${value} Star${value !== 1 ? 's' : ''}`; + +// --- Component --- + +export default function Rating({ + name: providedName, + value: controlledValue, + defaultValue = null, + max = 5, + // precision = 1, // Currently fixed at 1 + onChange, + onChangeActive, + readOnly = false, + disabled = false, + size = 'medium', + icon, + emptyIcon, + getLabelText = defaultGetLabelText, + highlightSelectedOnly = false, + className = '', + style, +}: RatingProps) { + + const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue); + const [hoverValue, setHoverValue] = useState(null); + const generatedName = useId(); + const name = providedName || generatedName; + + const isControlled = controlledValue !== undefined; + const currentValue = isControlled ? controlledValue : uncontrolledValue; + + const filledIcon = icon || ; + const unfilledIcon = emptyIcon || ; + + const handleRadioChange = useCallback((event: ChangeEvent) => { + if (readOnly) return; + const newValue = parseInt(event.target.value, 10); + if (!isControlled) { + setUncontrolledValue(newValue); + } + onChange?.(event, newValue); + }, [isControlled, onChange, readOnly]); + + const handleMouseEnter = useCallback((event: MouseEvent, indexValue: number) => { + if (readOnly || disabled) return; + setHoverValue(indexValue); + onChangeActive?.(event, indexValue); + }, [readOnly, disabled, onChangeActive]); + + const handleMouseLeave = useCallback((event: MouseEvent) => { + if (readOnly || disabled) return; + setHoverValue(null); + onChangeActive?.(event, null); + }, [readOnly, disabled, onChangeActive]); + + const displayValue = hoverValue !== null ? hoverValue : currentValue; + + const rootClassName = `frui-rating-root frui-rating-size${size.charAt(0).toUpperCase() + size.slice(1)} ${disabled ? 'frui-rating-disabled' : ''} ${readOnly ? 'frui-rating-readOnly' : ''} ${className}`; + + return ( + + {Array.from({ length: max }, (_, index) => { + const itemValue = index + 1; + const isChecked = currentValue === itemValue; + let isFilled: boolean; + if (highlightSelectedOnly) { + isFilled = displayValue === itemValue; + } else { + isFilled = displayValue !== null && itemValue <= displayValue; + } + + const iconNode = isFilled ? filledIcon : unfilledIcon; + const iconClassName = `frui-rating-icon ${isFilled ? 'frui-rating-icon-filled' : 'frui-rating-icon-empty'} ${hoverValue === itemValue ? 'frui-rating-icon-hover' : ''} ${currentValue === itemValue ? 'frui-rating-icon-active' : ''}`; + + return ( + + + + ); + })} + + ); +} \ No newline at end of file diff --git a/frui/src/index.ts b/frui/src/index.ts index 390c759..19d1346 100644 --- a/frui/src/index.ts +++ b/frui/src/index.ts @@ -59,4 +59,4 @@ export { Button, Control, Fieldset -}; \ No newline at end of file +}; diff --git a/frui/styles/accordion.css b/frui/styles/accordion.css new file mode 100644 index 0000000..9423e35 --- /dev/null +++ b/frui/styles/accordion.css @@ -0,0 +1,60 @@ +.frui-accordion { + border-bottom: 1px solid var(--bg-inverse); +} + +.frui-accordion:last-child { + border-bottom: none; +} + +.frui-accordion-button { + align-items: center; + background-color: var(--bg-first); + border: none; + color: var(--fg-default); + cursor: pointer; + display: flex; + font-size: var(--md); + font-weight: bold; + justify-content: space-between; + padding: 10px; + text-align: left; + width: 100%; +} + +.frui-accordion-button:hover { + background-color: var(--bg-second); +} + +.frui-accordion-button-disabled { + background-color: var(--bg-muted) !important; + color: var(--fg-muted) !important; + cursor: not-allowed; +} +.frui-accordion-button-disabled:hover { + background-color: var(--bg-muted) !important; +} + +.frui-accordion-content { + background-color: var(--bg-content, var(--bg-second)); + border-top: 1px solid var(--bg-inverse); + display: none; + padding: 10px; +} + +.frui-accordion-content-open { + display: block; +} + +.frui-accordion-icon { + display: inline-block; + margin-left: 8px; + transition: transform 0.3s ease; +} + +.frui-accordion-icon-rotate { + transform: rotate(180deg); +} + +.frui-accordion-disabled { + opacity: 0.7; +} \ No newline at end of file diff --git a/frui/styles/fields/colorpicker.css b/frui/styles/fields/colorpicker.css new file mode 100644 index 0000000..e7e92a4 --- /dev/null +++ b/frui/styles/fields/colorpicker.css @@ -0,0 +1,232 @@ +.frui-colorpicker-wrapper { + display: inline-block; + position: relative; +} + +.frui-colorpicker-trigger { + cursor: pointer; + display: inline-block; +} + +.frui-colorpicker-popover { + background-color: #fff; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15); + box-sizing: border-box; + left: 0; + margin-top: 5px; + padding: 10px; + position: absolute; + top: 100%; + width: 250px; + z-index: 10; +} + +.frui-colorpicker-popover * { + box-sizing: border-box; +} + +.frui-colorpicker-palette { + background-color: var(--frui-cp-hue-color); + border-radius: 3px; + cursor: crosshair; + overflow: hidden; + padding-bottom: 75%; + position: relative; + width: 100%; +} + +.frui-colorpicker-palette-saturation, +.frui-colorpicker-palette-value { + bottom: 0; + content: ""; + left: 0; + position: absolute; + right: 0; + top: 0; +} + +.frui-colorpicker-palette-saturation { + background: linear-gradient(to right, white, transparent); +} + +.frui-colorpicker-palette-value { + background: linear-gradient(to top, black, transparent); +} + +.frui-colorpicker-palette-pointer { + border: 2px solid white; + border-radius: 50%; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5); + height: 12px; + left: var(--frui-cp-pointer-x); + pointer-events: none; + position: absolute; + top: var(--frui-cp-pointer-y); + transform: translate(-50%, -50%); + width: 12px; +} + +.frui-colorpicker-sliders { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 10px; +} + +.frui-colorpicker-slider-container { + height: 12px; + position: relative; + width: 100%; +} + +.frui-colorpicker-slider { + -webkit-appearance: none; + appearance: none; + background: transparent; + cursor: pointer; + height: 100%; + left: 0; + margin: 0; + padding: 0; + position: absolute; + top: 0; + width: 100%; +} + +.frui-colorpicker-slider-hue::-webkit-slider-runnable-track { + background: linear-gradient(to right, red 0%, yellow 17%, lime 33%, cyan 50%, blue 67%, magenta 83%, red 100%); + border-radius: 3px; + height: 100%; +} + +.frui-colorpicker-slider-hue::-moz-range-track { + background: linear-gradient(to right, red 0%, yellow 17%, lime 33%, cyan 50%, blue 67%, magenta 83%, red 100%); + border-radius: 3px; + height: 100%; +} + +.frui-colorpicker-slider-alpha { + background-image: linear-gradient(45deg, #ccc 25%, transparent 25%), + linear-gradient(-45deg, #ccc 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #ccc 75%), + linear-gradient(-45deg, transparent 75%, #ccc 75%); + background-position: 0 0, 0 5px, 5px -5px, -5px 0px; + background-size: 10px 10px; + border-radius: 3px; +} + +.frui-colorpicker-slider-alpha::-webkit-slider-runnable-track { + background: var(--frui-cp-alpha-gradient); + border-radius: 3px; + height: 100%; +} + +.frui-colorpicker-slider-alpha::-moz-range-track { + background: var(--frui-cp-alpha-gradient); + border-radius: 3px; + height: 100%; +} + +.frui-colorpicker-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + background: white; + border: 1px solid #aaa; + border-radius: 50%; + box-shadow: 0 0 2px rgba(0,0,0,0.3); + cursor: pointer; + height: 16px; + margin-top: -2px; + width: 16px; +} + +.frui-colorpicker-slider::-moz-range-thumb { + background: white; + border: 1px solid #aaa; + border-radius: 50%; + box-shadow: 0 0 2px rgba(0,0,0,0.3); + cursor: pointer; + height: 16px; + width: 16px; +} + +.frui-colorpicker-inputs { + margin-top: 10px; +} + +.frui-colorpicker-input-group { + display: flex; + gap: 5px; + justify-content: space-between; +} + +.frui-colorpicker-input-group > div { + flex: 1; + text-align: center; +} + +.frui-colorpicker-input-field { + -moz-appearance: textfield; + border: 1px solid #ccc; + border-radius: 3px; + color: #333; + font-size: 12px; + padding: 4px; + text-align: center; + width: 100%; +} + +.frui-colorpicker-input-field::-webkit-outer-spin-button, +.frui-colorpicker-input-field::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.frui-colorpicker-input-label { + color: #333; + display: block; + font-size: 11px; + margin-top: 2px; + text-transform: uppercase; +} + +.frui-colorpicker-swatches { + border-top: 1px solid #eee; + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 10px; + padding-top: 10px; +} + +.frui-colorpicker-swatch { + border: 1px solid #eee; + border-radius: 4px; + cursor: pointer; + height: 24px; + overflow: hidden; + transition: transform 0.1s ease-in-out; + width: 24px; +} + +.frui-colorpicker-swatch:hover { + transform: scale(1.1); +} + +.frui-colorpicker-swatch-color { + background-image: linear-gradient(45deg, #ccc 25%, transparent 25%), + linear-gradient(-45deg, #ccc 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #ccc 75%), + linear-gradient(-45deg, transparent 75%, #ccc 75%); + background-position: 0 0, 0 4px, 4px -4px, -4px 0px; + background-size: 8px 8px; + height: 100%; + width: 100%; +} + +.frui-colorpicker-swatch-color { + background-position: 0 0, 0 4px, 4px -4px, -4px 0px, 0 0; + background-repeat: repeat, repeat, repeat, repeat, no-repeat; +} \ No newline at end of file diff --git a/frui/styles/fields/rating.css b/frui/styles/fields/rating.css new file mode 100644 index 0000000..5888eea --- /dev/null +++ b/frui/styles/fields/rating.css @@ -0,0 +1,69 @@ +.frui-rating-root { + -webkit-tap-highlight-color: transparent; + align-items: center; + color: #faaf00; + cursor: pointer; + display: inline-flex; + font-size: 1.5rem; +} + +.frui-rating-icon-label { + display: inline-block; + padding: 0 2px; + position: relative; +} + +.frui-rating-icon { + align-items: center; + display: flex; + justify-content: center; + pointer-events: none; + transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.frui-rating-icon-filled { + color: inherit; +} + +.frui-rating-icon-empty { + color: #bdbdbd; +} + +.frui-rating-visually-hidden { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.frui-rating-icon-hover { + transform: scale(1.2); +} + +.frui-rating-sizeSmall { + font-size: 1.125rem; +} + +.frui-rating-sizeMedium { + font-size: 1.5rem; +} + +.frui-rating-sizeLarge { + font-size: 1.875rem; +} + +.frui-rating-disabled { + cursor: default; + opacity: 0.5; + pointer-events: none; +} + +.frui-rating-readOnly { + cursor: default; + pointer-events: none; +} \ No newline at end of file diff --git a/web/modules/theme/layouts/components/MainMenu.tsx b/web/modules/theme/layouts/components/MainMenu.tsx index ae69747..aeede88 100644 --- a/web/modules/theme/layouts/components/MainMenu.tsx +++ b/web/modules/theme/layouts/components/MainMenu.tsx @@ -40,6 +40,9 @@ const MainMenu: React.FC<{ {_('Alert')} + + {_('Accordion')} + {_('Badge')} @@ -79,6 +82,9 @@ const MainMenu: React.FC<{ {_('Code Editor')} + + {_('Color Picker')} + {_('Country')} @@ -130,6 +136,9 @@ const MainMenu: React.FC<{ {_('Radio')} + + {_('Rating')} + {_('Select')} diff --git a/web/pages/component/accordion.tsx b/web/pages/component/accordion.tsx new file mode 100644 index 0000000..07024e4 --- /dev/null +++ b/web/pages/component/accordion.tsx @@ -0,0 +1,321 @@ +import React from 'react'; +import { useLanguage } from 'r22n'; +import { LayoutPanel } from 'modules/theme'; +import Crumbs from 'modules/components/Crumbs'; +import Code from 'modules/components/Code'; +import Props from 'modules/components/Props'; +import Link from 'next/link'; + +import { Accordion, AccordionSummary, AccordionDetails } from 'frui/element/Accordion'; + +//Mock Icons +const ExpandMoreIcon = () => ; +const ArrowDownwardIcon = () => ; +const ArrowForwardIosSharpIcon = () => ; + +export default function AccordionPage() { + const { _ } = useLanguage(); + + // State for the controlled/grouped accordion example + const [groupedExpanded, setGroupedExpanded] = React.useState('group-panel1'); + + const handleGroupedChange = (panel: string) => (_: React.SyntheticEvent, isExpanded: boolean) => { + setGroupedExpanded(isExpanded ? panel : false); + }; + + const crumbs = [ + { icon: 'icons', label: 'Components', href: '/component' }, + { label: 'Accordion' }, + ]; + + // --- Code Snippets for Documentation (Updated Imports) --- + + const simpleAccordionCode = ` + + }> + Simple Accordion 1 + + + Content for Accordion 1. + + + {/* ... other accordions */} + `.trim(); + + const defaultExpandedCode = ` + + }> + Expanded by Default + + + This content is visible initially because of defaultExpanded. + + + `.trim(); + + const disabledAccordionCode = ` + + }> + Disabled Accordion + + + This content will not be shown as the accordion is disabled. + + + `.trim(); + + const groupedAccordionCode = ` + + }> + Group Item #1 + + + Content for group item 1. Only one item in this group can be open. + + + + + {/* ... summary and details ... */} + + `.trim(); + + const customIconCode = ` + + }> + Custom Expand Icon + + + The icon in the summary can be any React node. + + + `.trim(); + + + + const accordionProps = [ + [_('id'), _('string'), _('No'), _('Unique identifier. Auto-generated if omitted, but recommended.')], + [_('children'), _('ReactNode'), _('Yes'), _('Should contain AccordionSummary and AccordionDetails.')], + [_('expanded'), _('boolean'), _('No'), _('Controls the expanded state (controlled component).')], + [_('defaultExpanded'), _('boolean'), _('No'), _('Sets the initial expanded state (uncontrolled component). Default: false')], + [_('disabled'), _('boolean'), _('No'), _('Disables the accordion. Default: false')], + [_('onChange'), _('(event, isExpanded) => void'), _('No'), _('Callback fired when state changes. Required for controlled component.')], + [_('className'), _('string'), _('No'), _('Custom CSS class for the accordion container.')], + [_('style'), _('CSSProperties'), _('No'), _('Inline styles for the accordion container.')], + ]; + + const accordionSummaryProps = [ + [_('children'), _('ReactNode'), _('Yes'), _('The content/title of the summary (header).')], + [_('expandIcon'), _('ReactNode'), _('No'), _('Icon displayed, rotates on expansion.')], + [_('className'), _('string'), _('No'), _('Custom CSS class for the summary button.')], + [_('style'), _('CSSProperties'), _('No'), _('Inline styles for the summary button.')], + ]; + + const accordionDetailsProps = [ + [_('children'), _('ReactNode'), _('Yes'), _('The content displayed when the accordion is expanded.')], + [_('className'), _('string'), _('No'), _('Custom CSS class for the details container.')], + [_('style'), _('CSSProperties'), _('No'), _('Inline styles for the details container.')], + ]; + + return ( + +
+
+ +
+
+ + +
+

+ {_('Accordion')} +

+ + {`import { Accordion, AccordionSummary, AccordionDetails } from 'frui/element/Accordion';`} + + +

{_('Props')}

+

{_('Accordion Props')}

+ +

{_('AccordionSummary Props')}

+ +

{_('AccordionDetails Props')}

+ + +

{_('Simple Usage')}

+

{_('Basic accordions expand independently.')}

+
+
+ + }> + {_("Simple Accordion 1")} + + + {_("Lorem ipsum dolor sit amet, consectetur adipiscing elit.")} + + + + }> + {_("Simple Accordion 2")} + + + {_("Suspendisse malesuada lacus ex, sit amet blandit leo lobortis eget.")} + + +
+
+ +

{_('Default Expanded')}

+

{_('Using the `defaultExpanded` prop for uncontrolled components.')}

+
+
+ + }> + {_("Expanded by Default")} + + + {_("This content is visible initially because defaultExpanded is true.")} + + + + }> + {_("Not Expanded by Default")} + + + {_("This content is hidden initially.")} + + +
+
+ +

{_('Disabled')}

+

{_('Using the `disabled` prop on the Accordion.')}

+
+
+ + }> + {_("Disabled Accordion")} + + + {_("This content area won't be reachable.")} + + +
+
+ +

{_('Controlled / Grouped')}

+

{_('Using `expanded` and `onChange` props for controlled behavior. Only one item can be open.')}

+
+
+ + }> + {_("Collapsible Group Item #1")} + + + {_("Content for group item 1. Clicking another item header will close this one.")} + + + + + }> + {_("Collapsible Group Item #2")} + + + {_("Content for group item 2.")} + + + + + }> + {_("Collapsible Group Item #3")} + + + {_("Content for group item 3.")} + + +
+
+ +

{_('Custom Icon')}

+

{_('Providing a custom React node to the `expandIcon` prop.')}

+
+
+ + }> + {_("Custom Expand Icon (Down Arrow)")} + + + {_("Any component or element can be used as the expand icon.")} + + +
+
+ +

{_('Code Snippets')}

+

{_('Simple Usage Code')}

+ {simpleAccordionCode} +

{_('Default Expanded Code')}

+ {defaultExpandedCode} +

{_('Disabled Code')}

+ {disabledAccordionCode} +

{_('Controlled / Grouped Code')}

+ {groupedAccordionCode} +

{_('Custom Icon Code')}

+ {customIconCode} + +
+ + + {_('Component')} + +
+ + {_('Alert')} + + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/web/pages/component/index.tsx b/web/pages/component/index.tsx index 9533c2d..b750f78 100644 --- a/web/pages/component/index.tsx +++ b/web/pages/component/index.tsx @@ -7,6 +7,7 @@ import { useRouter } from 'next/router'; import Crumbs from 'modules/components/Crumbs'; import { LayoutPanel } from 'modules/theme'; import Alert from 'frui/element/Alert'; +import { Accordion, AccordionSummary, AccordionDetails } from 'frui/element/Accordion'; import Badge from 'frui/element/Badge'; import Button from 'frui/form/Button'; import Loader from 'frui/element/Loader'; @@ -14,6 +15,8 @@ import Table, { Thead, Trow, Tcol } from 'frui/element/Table'; import Tabs from 'frui/element/Tabs'; import Tooltip from 'frui/element/Tooltip'; +const ExpandMoreIconPreview = () => ; + export default function Home() { //hooks const { _ } = useLanguage(); @@ -42,6 +45,28 @@ export default function Home() { following components have been unlocked and are free to use.

+
router.push('/component/accordion')} + > +
+
+
+ + }> + {_("Accordion")} + + + {_("Content...")} + + +
+
+

+ {_('Accordion')} +

+
+
router.push('/component/alert')} @@ -224,10 +249,6 @@ export default function Home() {
- Unlocks at 10,000 downloads -
-

- {_('Accordion')}

diff --git a/web/pages/field/colorpicker.tsx b/web/pages/field/colorpicker.tsx new file mode 100644 index 0000000..4a80e27 --- /dev/null +++ b/web/pages/field/colorpicker.tsx @@ -0,0 +1,263 @@ +import type { Crumb } from 'modules/components/Crumbs'; +import { useState } from 'react'; +import { useLanguage } from 'r22n'; +import Link from 'next/link'; +import { Translate } from 'r22n'; +import ColorPicker from 'frui/field/ColorPicker'; +import { LayoutPanel } from 'modules/theme'; +import Crumbs from 'modules/components/Crumbs'; +import Props from 'modules/components/Props'; +import Code, { InlineCode as C } from 'modules/components/Code'; + +export default function ColorPickerDemoPage() { + const { _ } = useLanguage(); + const [pickerColor, setPickerColor] = useState('rgba(74, 144, 226, 1)'); + const [pickerColor2, setPickerColor2] = useState('rgba(255, 107, 107, 0.8)'); + + const crumbs: Crumb[] = [ + { icon: 'palette', label: 'Fields', href: '/fields' }, + { label: 'Color Picker' } + ]; + + const propsData = [ + [ _('value'), _('string'), _('No'), _('Current color (hex, rgba). If undefined, component is uncontrolled.') ], + [ _('defaultValue'), _('string'), _('No'), _('Initial color if `value` is undefined (uncontrolled).') ], + [ _('onChange'), _('function'), _('No'), _('Callback `(color: string) => void` returning rgba string.') ], + [ _('showAlpha'), _('boolean'), _('No (true)'), _('Show alpha slider and input.') ], + [ _('showInputs'), _('boolean'), _('No (true)'), _('Show RGBA input fields.') ], + [ _('swatches'), _('string[]'), _('No'), _('Array of hex/rgba colors for swatches.') ], + [ _('pickerStyle'), _('CSS Object'), _('No'), _('Custom CSS for the picker popover element.') ], + [ _('pickerClassName'), _('string'), _('No'), _('Custom class name for the picker popover element.') ], + [ _('className'), _('string'), _('No'), _('Class names for the main wrapper div.') ], + [ _('style'), _('CSS Object'), _('No'), _('Inline styles for the main wrapper div.') ], + [ _('box'), _('boolean'), _('No (true)'), _('Show color preview box in display.') ], + [ _('text'), _('boolean'), _('No (true)'), _('Show color text value in display.') ], + [ _('lg'), _('boolean'), _('No'), _('Use large size for display.') ], + [ _('md'), _('boolean'), _('No'), _('Use medium size for display.') ], + [ _('sm'), _('boolean'), _('No'), _('Use small size for display.') ], + ]; + + const defaultSwatches = [ + '#D0021B', '#F5A623', '#F8E71C', '#8B572A', '#7ED321', + '#417505', '#BD10E0', '#9013FE', '#4A90E2', '#50E3C2', + '#B8E986', '#000000', '#4A4A4A', '#9B9B9B', '#FFFFFF', + 'rgba(0, 0, 255, 0.5)' + ]; + + return ( + +
+
+ +
+
+ + +
+

+ {_('Color Picker')} +

+ + {`import ColorPicker from 'frui/field/ColorPicker;`} + + +

+ {_('Props')} +

+ + +

+ {_('Basic Usage (Controlled)')} +

+

+ + Provide and props for a controlled component. + The component expects hex or rgba strings as input and outputs an rgba string. + +

+
+
+ + Selected: +
+ +{` setColor(newRgbaColor)} +/>`} + +
+ +

+ {_('Uncontrolled Usage')} +

+

+ + Omit the prop to use the component in uncontrolled mode. + You can set an initial color with . Use a or form submission to get the final value if needed, or use just to react to changes. + +

+
+
+ console.log('Uncontrolled changed:', c)} + /> + Default: +
+ +{` console.log(newColor)} +/>`} + +
+ + +

+ {_('Customization')} +

+

+ + Customize the picker's features like alpha, inputs, and swatches. + +

+
+
+
+
No Alpha
+ +
+
+
No Inputs
+ +
+
+
Custom Swatches
+ +
+
+
Default Swatches
+ +
+
+ +{` + + + + + +const defaultSwatches = ['#D0021B']; + +`} + +
+ +

+ {_('Layout & Style')} +

+

+ + Adjust the trigger display using , , , , or . + Use or for the wrapper, and or for the popover. + +

+
+
+
+
Large / No Text
+ +
+
+
Small / No Box
+ +
+
+
Custom Popover Class
+ +
+
+
Custom Popover Style
+ +
+ +
+ +{` + + + + + +`} + +
+ +
+ + + {_('Code Editor')} + +
+ + {_('Country')} + + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/web/pages/field/index.tsx b/web/pages/field/index.tsx index e1866c3..b057c29 100644 --- a/web/pages/field/index.tsx +++ b/web/pages/field/index.tsx @@ -1,6 +1,7 @@ //types import type { Crumb } from 'modules/components/Crumbs'; //hooks +import { useState } from 'react'; import { useLanguage } from 'r22n'; import { useRouter } from 'next/router'; //fields @@ -10,6 +11,7 @@ import Autocomplete from 'frui/field/Autocomplete'; import Checkbox from 'frui/field/Checkbox'; import Checklist, { ChecklistItem } from 'frui/field/Checklist'; import CodeEditor from 'frui/field/CodeEditor'; +import ColorPicker from 'frui/field/ColorPicker'; import Country from 'frui/field/Country'; import Currency from 'frui/field/Currency'; import Date from 'frui/field/Date'; @@ -25,6 +27,7 @@ import Metadata from 'frui/field/Metadata'; import Number from 'frui/field/Number'; import Password from 'frui/field/Password'; import Radio from 'frui/field/Radio'; +import Rating from 'frui/field/Rating'; import Select from 'frui/field/Select'; import Slug from 'frui/field/Slug'; import Switch from 'frui/field/Switch'; @@ -41,6 +44,7 @@ export default function Home() { const crumbs: Crumb[] = [ { icon: 'rectangle-list', label: 'Fields' } ]; + const [previewColor, setPreviewColor] = useState('rgba(74, 144, 226, 0.75)'); //render return (
+
+
+ +
+

+ {_('Checkbox')} +

+
+
+
{ + if ((e.target as HTMLElement).closest('.frui-colorpicker-popover')) { + return; + } + router.push('/field/colorpicker'); + }} + > +
+
+ + {previewColor} +
+

+ {_('Color Picker')} +

+
+
router.push('/field/country')} @@ -334,6 +372,19 @@ export default function Home() {
+
router.push('/field/rating')} + > +
+
+ +
+

+ {_('Rating')} +

+
+
router.push('/field/select')} @@ -457,16 +508,6 @@ export default function Home() {
-
-
-
- Unlocks at 3,000 downloads -
-

- {_('Rating')} -

-
-
@@ -528,7 +569,6 @@ export default function Home() {
- ); diff --git a/web/pages/field/rating.tsx b/web/pages/field/rating.tsx new file mode 100644 index 0000000..43a72af --- /dev/null +++ b/web/pages/field/rating.tsx @@ -0,0 +1,392 @@ +import type { Crumb } from 'modules/components/Crumbs'; +import React, { useState } from 'react'; +import { useLanguage } from 'r22n'; +import Link from 'next/link'; +import { Translate } from 'r22n'; +import Rating from 'frui/field/Rating'; +import Table, { Tcol, Thead, Trow } from 'frui/element/Table'; +import { LayoutPanel } from 'modules/theme'; +import Crumbs from 'modules/components/Crumbs'; +import Props from 'modules/components/Props'; +import Code, { InlineCode as C } from 'modules/components/Code'; + +// Example Custom Icons +const HeartIcon = (props: React.SVGProps) => ( + + + +); + +const CircleIcon = (props: React.SVGProps) => ( + + + +); + +export default function RatingPage() { + const { _ } = useLanguage(); + const [controlledValue, setControlledValue] = useState(2); + const [hoverActiveValue, setHoverActiveValue] = useState(null); + + const crumbs: Crumb[] = [ + { icon: 'shapes', label: 'Components', href: '/component' }, + { label: 'Rating' } + ]; + + const propsData: [string, string, string, string][] = [ + [ _('name'), _('string'), _('No'), _('Name attribute for the radio inputs (form submission). Auto-generated if not provided.') ], + [ _('value'), _('number | null'), _('No'), _('The rating value for controlled mode.') ], + [ _('defaultValue'), _('number | null'), _('No (Defaults to null)'), _('The initial rating value for uncontrolled mode.') ], + [ _('max'), _('number'), _('No (Defaults to 5)'), _('The maximum rating value (number of icons).') ], + [ _('onChange'), _('Function'), _('No'), _('Callback fired when the value changes. `(event, value) => void`') ], + [ _('onChangeActive'), _('Function'), _('No'), _('Callback fired when the mouse hovers over a rating icon. `(event, value) => void`') ], + [ _('readOnly'), _('boolean'), _('No (Defaults to false)'), _('If true, the rating cannot be changed.') ], + [ _('disabled'), _('boolean'), _('No (Defaults to false)'), _('If true, the rating is disabled (visual state and interaction).') ], + [ _('size'), _(`'small' | 'medium' | 'large'`), _('No (Defaults to medium)'), _('The size of the rating icons.') ], + [ _('icon'), _('ReactNode'), _('No'), _('The icon to display as the filled state.') ], + [ _('emptyIcon'), _('ReactNode'), _('No'), _('The icon to display as the empty state (defaults to faded filled icon).') ], + [ _('getLabelText'), _('Function'), _('No'), _('Generates aria-label text for accessibility. `(value) => string`') ], + [ _('highlightSelectedOnly'), _('boolean'), _('No (Defaults to false)'), _('If true, only the selected icon will be highlighted, not the preceding ones.') ], + [ _('className'), _('string'), _('No'), _('Standard HTML class names for the root span element.') ], + [ _('style'), _('CSS Object'), _('No'), _('Standard CSS object for inline styles on the root span element.') ], + ]; + + return ( + +
+
+ +
+
+ +
+

+ + {_('Rating')} +

+ + {`import Rating from 'frui/field/Rating';`} + + +

+ {_('Props')} +

+

+ + The Rating component wraps visually hidden radio inputs for accessibility and form integration. + It accepts the following specific props: + +

+ + +

+ {_('Basics')} +

+

+ + By default, the Rating component is uncontrolled. Use to set an initial value. + It renders 5 stars. + +

+
+
+ +
+ + {``} + +
+ +

+ {_('Controlled')} +

+

+ + For a controlled component, use the and props, typically with React state. + +

+
+
+ { + setControlledValue(newValue); + console.log('Controlled Change:', newValue); + }} + /> + {_('Current Value:')} {controlledValue ?? 'null'} +
+ +{`function ControlledRatingExample() { + const [controlledValue, setControlledValue] = useState(2); + + return ( + { + setControlledValue(newValue); + console.log('Controlled Change:', newValue); + }} + /> + ); +}`} + +
+ +

+ {_('Sizes')} +

+

+ + Use the prop to adjust the icon size. The sizes correspond to CSS classes , , and . + +

+
+
+
:
+
(default):
+
:
+
+ +{` + +`} + +
+ +

+ {_('Custom Icons')} +

+

+ + Provide custom React nodes to the (filled) and props. + If is not provided, a faded version of the is used. Styles target and . + +

+
+
+ } emptyIcon={} max={5} /> + } emptyIcon={} max={6} /> +
+ +{`// Define custom icons (examples) +const HeartIcon = (props) => (/* SVG code */); +const CircleIcon = (props) => (/* SVG code */); + +// Use in Rating +} + emptyIcon={} + max={5} +/> + +} + emptyIcon={} + max={6} +/>`} + +
+ +

+ {_('Highlighting')} +

+

+ + By default, all icons up to the selected/hovered value are filled. Use to only fill the single selected/hovered icon. + +

+
+
+
Default:
+
Highlight Selected Only:
+
+ +{`{/* Default: Icons 1, 2, 3 are filled */} + + +{/* highlightSelectedOnly: Only icon 3 is filled */} +`} + +
+ +

+ {_('Read Only & Disabled')} +

+

+ + Use to display a rating that cannot be changed by the user ( class). + Use to prevent interaction and apply disabled styling ( class). + +

+
+
+
Read Only:
+
Disabled:
+
+ +{` +`} + +
+ +

+ {_('Events')} +

+

+ + The callback fires when a rating is selected. + The callback fires when the mouse enters or leaves an icon, providing the hovered value (or null). Hover styles are mainly handled via CSS (). + +

+
+
+ alert(`onChange: Value ${v} selected!`)} + onChangeActive={(_, v) => setHoverActiveValue(v)} + /> + {_('Hovered Value (via onChangeActive):')} {hoverActiveValue ?? 'null'} +
+ +{` alert(\`onChange: Value \${value} selected!\`)} + onChangeActive={(event, value) => console.log(\`onChangeActive: Hovered \${value}\`)} +/>`} + +
+ +

+ {_('onChange')} +

+

+ + The event is triggered when the + user clicks an icon to change the rating. The following arguments are passed + to the event handler: + +

+ + {_('Name')} + {_('Type')} + {_('Description')} + + + + {_('The change event on the underlying radio input.')} + + + + + {_('The newly selected rating value.')} + +
+ +

+ {_('onChangeActive')} +

+

+ + The event is triggered when the + mouse pointer enters or leaves an icon. The following arguments are + passed to the event handler: + +

+ + {_('Name')} + {_('Type')} + {_('Description')} + + + + {_('The native mouse event.')} + + + + + {_('The value of the icon being hovered, or null if the mouse leaves the component.')} + +
+ +

+ + You can add custom CSS classes via the prop + or inline styles via the prop to the root element. + Component-specific classes like , , , , , and size/state classes are available for more targeted styling. + +

+ +
+ + + {_('Radio')} + +
+ + {_('Select')} + + +
+
+
+
+
+ ); +} \ No newline at end of file