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 (
+
+ {children}
+ {expandIcon && {expandIcon} }
+
+ );
+}
+
+/**
+ * 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 }}
+ >
+
+
+
+
+
+
+
+ {showInputs && (
+
+ )}
+
+ {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 (
+
+ handleMouseEnter(e, itemValue)}
+ aria-label={getLabelText(itemValue)}
+ >
+
+ {iconNode}
+
+
+ );
+ })}
+
+ );
+}
\ 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 (
+
+
+
+
+
+
+
+
+ {_('Contents')}
+
+
+
Accordion
+
+ {_('Props')}
+ {_('Simple Usage')}
+ {_('Default Expanded')}
+ {_('Disabled')}
+ {_('Controlled / Grouped')}
+ {_('Custom Icon')}
+ {_('Code Snippets')}
+
+
+
+
+
+
+ {_('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')}
+
+
+ {_('Props')}
+ {_('Basic Usage')}
+ {_('Uncontrolled')}
+ {_('Customization')}
+ {_('Layout & Style')}
+
+
+
+
+
+ {_('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.
+
+
+
+
+
+{`
+
+
+
+
+
+const defaultSwatches = ['#D0021B'];
+
+`}
+
+
+
+
+ {_('Layout & Style')}
+
+
+
+ Adjust the trigger display using , , , , or .
+ Use or for the wrapper, and or for the popover.
+
+
+
+
+
+
+
+
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')}
+
+
+
+
+ {_('Props')}
+
+
+
+
+ {_('Basics')}
+
+
+
+
+ {_('Controlled')}
+
+
+
+
+ {_('Sizes')}
+
+
+
+
+ {_('Custom Icons')}
+
+
+
+
+ {_('Highlighting')}
+
+
+
+
+ {_('Read Only & Disabled')}
+
+
+
+
+ {_('Events')}
+
+
+
+
+
+
+
+ {_('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 .
+
+
+
+
+
+ {_('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