From db6c392c4c62a2152cbd9fad3cbac8be8d47ffde Mon Sep 17 00:00:00 2001 From: phinome Date: Sat, 3 Aug 2024 10:12:46 +0800 Subject: [PATCH 1/2] feat: add rating component --- packages/codemod/src/bin.ts | 2 +- packages/material/src/Rating/Rating.tsx | 727 ++++++++++++++++++ packages/material/src/Rating/RatingProps.tsx | 162 ++++ packages/material/src/Rating/index.tsx | 7 + packages/material/src/Rating/ratingClasses.ts | 68 ++ packages/material/src/index.tsx | 1 + .../material/src/styles/components-types.ts | 2 + .../RatingPage/CustomizedRatingExample.tsx | 35 + .../RatingPage/HalfRatingExample.tsx | 15 + .../RatingPage/HoverFeedbackExample.tsx | 54 ++ .../RatingPage/RadioGroupExample.tsx | 59 ++ .../components/RatingPage/RatingPage.tsx | 43 ++ .../RatingPage/SimpleRatingExample.tsx | 25 + .../components/RatingPage/SizesExample.tsx | 11 + packages/system/src/createStyled.tsx | 2 +- packages/utils/src/createUniqueId.ts | 2 +- packages/utils/src/index.ts | 1 + packages/utils/src/visuallyHidden.ts | 13 + 18 files changed, 1226 insertions(+), 3 deletions(-) create mode 100644 packages/material/src/Rating/Rating.tsx create mode 100644 packages/material/src/Rating/RatingProps.tsx create mode 100644 packages/material/src/Rating/index.tsx create mode 100644 packages/material/src/Rating/ratingClasses.ts create mode 100644 packages/site/src/pages/components/RatingPage/CustomizedRatingExample.tsx create mode 100644 packages/site/src/pages/components/RatingPage/HalfRatingExample.tsx create mode 100644 packages/site/src/pages/components/RatingPage/HoverFeedbackExample.tsx create mode 100644 packages/site/src/pages/components/RatingPage/RadioGroupExample.tsx create mode 100644 packages/site/src/pages/components/RatingPage/RatingPage.tsx create mode 100644 packages/site/src/pages/components/RatingPage/SimpleRatingExample.tsx create mode 100644 packages/site/src/pages/components/RatingPage/SizesExample.tsx create mode 100644 packages/utils/src/visuallyHidden.ts diff --git a/packages/codemod/src/bin.ts b/packages/codemod/src/bin.ts index b279a9e4b..d220909f4 100644 --- a/packages/codemod/src/bin.ts +++ b/packages/codemod/src/bin.ts @@ -44,7 +44,7 @@ program .command("mui2suid") .description("Transform a MUI React component into SUID SolidJS component.") .option("--package-name [name]", "Package name", "material") - .requiredOption("-n,--name [path]", "Input directory path") + .requiredOption("-n,--name [name]", "Component name") .option("-o,--out [path]", "Output directory path") .option("-v,--version [value]", "MUI version", muiVersion) .option( diff --git a/packages/material/src/Rating/Rating.tsx b/packages/material/src/Rating/Rating.tsx new file mode 100644 index 000000000..c6a8ba14c --- /dev/null +++ b/packages/material/src/Rating/Rating.tsx @@ -0,0 +1,727 @@ +import { IconContainerProps, RatingTypeMap } from "."; +import Star from "../internal/svg-icons/Star"; +import StarBorder from "../internal/svg-icons/StarBorder"; +import styled from "../styles/styled"; +import useTheme from "../styles/useTheme"; +import capitalize from "../utils/capitalize"; +import useControlled from "../utils/useControlled"; +import { getRatingUtilityClass } from "./ratingClasses"; +import ratingClasses from "./ratingClasses"; +import createComponentFactory from "@suid/base/createComponentFactory"; +import createRef from "@suid/system/createRef"; +import { + ChangeEvent, + ElementType, + EventParam, + FocusEventHandler, + InPropsOf, + MouseEventHandler, +} from "@suid/types"; +import { visuallyHidden } from "@suid/utils"; +import useId from "@suid/utils/createUniqueId"; +import useIsFocusVisible from "@suid/utils/useIsFocusVisible"; +import clsx from "clsx"; +import { + type JSX, + type Accessor, + createSignal, + splitProps, + mergeProps, + createEffect, + Component, +} from "solid-js"; + +type OwnerState = InPropsOf & { + focusVisible: boolean; + emptyValueFocused: boolean; + iconActive: boolean; + iconEmpty: boolean; +}; + +type ElementTypeWithProps< + T extends ElementType, + P extends object = { value: number }, +> = + T extends Component + ? Component

[0]> + : T extends keyof JSX.IntrinsicElements + ? T + : never; + +type RatingItemProps = { + highlightSelectedOnly: boolean; + itemValue: number; + ratingValue: number; + hover: number; + focus: number; + ratingValueRounded: number; + IconContainerComponent: ElementType; + classes: Record; + name: Accessor; + disabled: boolean; + emptyIcon: JSX.Element; + icon: JSX.Element; + getLabelText: (value: number) => string; + readOnly: boolean; + isActive: boolean; + labelProps?: JSX.IntrinsicElements["span"] & JSX.IntrinsicElements["label"]; + onBlur?: FocusEventHandler; + onChange?: JSX.ChangeEventHandlerUnion; + onClick?: MouseEventHandler; + onFocus?: FocusEventHandler; + ownerState?: OwnerState; +}; + +const $ = createComponentFactory()({ + name: "MuiRating", + selfPropNames: [ + "classes", + "defaultValue", + "disabled", + "emptyIcon", + "emptyLabelText", + "getLabelText", + "highlightSelectedOnly", + "icon", + "IconContainerComponent", + "max", + "name", + "onChange", + "onChangeActive", + "precision", + "readOnly", + "size", + "value", + ], + utilityClass: getRatingUtilityClass, + slotClasses: (ownerState) => ({ + root: [ + "root", + `size${capitalize(ownerState.size)}`, + ownerState.disabled && "disabled", + ownerState.focusVisible && "focusVisible", + ownerState.readOnly && "readyOnly", + ], + label: ["label", "pristine"], + labelEmptyValue: [ownerState.emptyValueFocused && "labelEmptyValueActive"], + icon: ["icon"], + iconEmpty: ["iconEmpty"], + iconFilled: ["iconFilled"], + iconHover: ["iconHover"], + iconFocus: ["iconFocus"], + iconActive: ["iconActive"], + decimal: ["decimal"], + visuallyHidden: ["visuallyHidden"], + }), +}); + +function clamp(value: number, min: number, max: number) { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +} + +function getDecimalPrecision(num: number) { + const decimalPart = num.toString().split(".")[1]; + return decimalPart ? decimalPart.length : 0; +} + +function roundValueToPrecision(value: number, precision: number) { + if (value == null) { + return value; + } + + const nearest = Math.round(value / precision) * precision; + return Number(nearest.toFixed(getDecimalPrecision(precision))); +} + +const RatingRoot = styled("span", { + name: "MuiRating", + slot: "Root", + overridesResolver: (props, styles) => { + const { ownerState } = props; + + return [ + { [`& .${ratingClasses.visuallyHidden}`]: styles.visuallyHidden }, + styles.root, + styles[`size${capitalize(ownerState.size)}`], + ownerState.readOnly && styles.readOnly, + ]; + }, +})(({ theme, ownerState }) => ({ + display: "inline-flex", + // Required to position the pristine input absolutely + position: "relative", + fontSize: theme.typography.pxToRem(24), + color: "#faaf00", + cursor: "pointer", + textAlign: "left", + WebkitTapHighlightColor: "transparent", + [`&.${ratingClasses.disabled}`]: { + opacity: theme.palette.action.disabledOpacity, + pointerEvents: "none", + }, + [`&.${ratingClasses.focusVisible} .${ratingClasses.iconActive}`]: { + outline: "1px solid #999", + }, + [`& .${ratingClasses.visuallyHidden}`]: visuallyHidden, + ...(ownerState.size === "small" && { + fontSize: theme.typography.pxToRem(18), + }), + ...(ownerState.size === "large" && { + fontSize: theme.typography.pxToRem(30), + }), + ...(ownerState.readOnly && { + pointerEvents: "none", + }), +})); + +const RatingLabel = styled("label", { + name: "MuiRating", + slot: "Label", + overridesResolver: (_, styles) => styles.label, +})(({ ownerState }) => ({ + cursor: "inherit", + ...(ownerState.emptyValueFocused && { + top: 0, + bottom: 0, + position: "absolute", + outline: "1px solid #999", + width: "100%", + }), +})); + +const RatingIcon = styled>("span", { + name: "MuiRating", + slot: "Icon", + overridesResolver: (props, styles) => { + const { ownerState } = props; + + return [ + styles.icon, + ownerState.iconEmpty && styles.iconEmpty, + ownerState.iconFilled && styles.iconFilled, + ownerState.iconHover && styles.iconHover, + ownerState.iconFocus && styles.iconFocus, + ownerState.iconActive && styles.iconActive, + ]; + }, +})(({ theme, ownerState }) => ({ + // Fit wrapper to actual icon size. + display: "flex", + transition: theme.transitions.create("transform", { + duration: theme.transitions.duration.shortest, + }), + // Fix mouseLeave issue. + // https://github.com/facebook/react/issues/4492 + pointerEvents: "none", + ...(ownerState.iconActive && { + transform: "scale(1.2)", + }), + ...(ownerState.iconEmpty && { + color: theme.palette.action.disabled, + }), +})); + +const RatingDecimal = styled< + ElementTypeWithProps +>("span", { + name: "MuiRating", + slot: "Decimal", + overridesResolver: (props, styles) => { + const { iconActive } = props; + + return [styles.decimal, iconActive && styles.iconActive]; + }, +})(({ ownerState }) => ({ + position: "relative", + ...(ownerState.iconActive && { + transform: "scale(1.2)", + }), +})); + +function IconContainer(props: IconContainerProps) { + const [, other] = splitProps(props, ["value"]); + return ; +} + +function RatingItem(props: RatingItemProps) { + const isFilled = props.highlightSelectedOnly + ? props.itemValue === props.ratingValue + : props.itemValue <= props.ratingValue; + const isHovered = props.itemValue <= props.hover; + const isFocused = props.itemValue <= props.focus; + const isChecked = props.itemValue === props.ratingValueRounded; + + const id = useId(); + const container = ( + props.ownerState, { + get iconEmpty() { + return !isFilled; + }, + iconFilled: isFilled, + iconHover: isHovered, + iconFocus: isFocused, + get iconActive() { + return props.isActive; + }, + })} + > + {props.emptyIcon && !isFilled ? props.emptyIcon : props.icon} + + ); + + if (props.readOnly) { + return {container}; + } + + return ( + <> + props.ownerState, { + emptyValueFocused: undefined, + })} + for={id()} + {...props.labelProps} + > + {container} + + {props.getLabelText(props.itemValue)} + + + + + ); +} + +const defaultIcon = () => ; +const defaultEmptyIcon = () => ; + +function defaultLabelText(value: number) { + return `${value} Star${value !== 1 ? "s" : ""}`; +} + +const Rating = $.defineComponent(function Rating(inProps) { + const focusVisibleRef = createRef(inProps); + const props = $.useThemeProps({ props: inProps }); + const [, other] = splitProps(props, [ + "classes", + "defaultValue", + "disabled", + "emptyIcon", + "emptyLabelText", + "getLabelText", + "highlightSelectedOnly", + "icon", + "IconContainerComponent", + "max", + "name", + "onChange", + "onChangeActive", + "onMouseLeave", + "onMouseMove", + "precision", + "readOnly", + "size", + "value", + ]); + + const baseProps = mergeProps( + { + defaultValue: null, + disabled: false, + emptyIcon: defaultEmptyIcon, + emptyLabelText: "Empty", + getLabelText: defaultLabelText, + highlightSelectedOnly: false, + icon: defaultIcon, + IconContainerComponent: IconContainer, + max: 5, + precision: 1, + readOnly: false, + size: "medium", + }, + props + ); + + const name = useId(() => props.name); + + const [valueDerived, setValueState] = useControlled({ + controlled: () => props.value, + default: () => baseProps.defaultValue, + name: "Rating", + }); + + const valueRounded = roundValueToPrecision( + valueDerived() as number, + baseProps.precision + ); + const theme = useTheme(); + const [valueState, setState] = createSignal({ + get hover() { + return -1; + }, + get focus() { + return -1; + }, + }); + + const [value, setValue] = createSignal(valueRounded); + + createEffect(() => { + if (valueState().hover !== -1) { + setValue(valueState().hover); + } else { + setValue(valueRounded); + } + if (valueState().focus !== -1) { + setValue(valueState().focus); + } else { + setValue(valueRounded); + } + }); + + const { + isFocusVisibleRef, + onBlur: handleBlurVisible, + onFocus: handleFocusVisible, + } = useIsFocusVisible(); + const [focusVisible, setFocusVisible] = createSignal(false); + const handleMouseMove: MouseEventHandler = (event) => { + if ("function" === typeof props.onMouseMove) { + props.onMouseMove(event); + } + + const rootNode = focusVisibleRef.current as HTMLElement; + const { right, left } = rootNode.getBoundingClientRect(); + const { width } = ( + rootNode.firstChild as HTMLElement + ).getBoundingClientRect(); + let percent; + + if (theme.direction === "rtl") { + percent = (right - event.clientX) / (width * baseProps.max); + } else { + percent = (event.clientX - left) / (width * baseProps.max); + } + + let newHover = roundValueToPrecision( + baseProps.max * percent + baseProps.precision / 2, + baseProps.precision + ); + + newHover = clamp(newHover, baseProps.precision, baseProps.max); + + setState((prev) => + prev.hover === newHover && prev.focus === newHover + ? prev + : { + hover: newHover, + focus: newHover, + } + ); + + setFocusVisible(false); + + if (props.onChangeActive) { + props.onChangeActive(event, newHover); + } + }; + + const handleMouseLeave: MouseEventHandler = (event) => { + if ("function" === typeof props.onMouseLeave) { + props.onMouseLeave(event); + } + + const newHover = -1; + setState({ + hover: newHover, + focus: newHover, + }); + + if (props.onChangeActive) { + props.onChangeActive(event, newHover); + } + }; + + const handleChange = (event: Event) => { + let newValue = + (event as ChangeEvent).target.value === "" + ? null + : parseFloat((event as ChangeEvent).target?.value); + + // Give mouse priority over keyboard + // Fix https://github.com/mui/material-ui/issues/22827 + if (valueState().hover !== -1) { + newValue = valueState().hover; + } + + setValueState(newValue); + + if (props.onChange) { + props.onChange(event, newValue); + } + }; + + const handleClear: JSX.EventHandlerUnion = ( + event + ) => { + // Ignore keyboard events + // https://github.com/facebook/react/issues/7407 + if (event.clientX === 0 && event.clientY === 0) { + return; + } + + setState({ + hover: -1, + focus: -1, + }); + + setValueState(null); + + if ( + props.onChange && + parseFloat((event.target as HTMLInputElement).value) === valueRounded + ) { + props.onChange(event, null); + } + }; + + const handleFocus = (event: EventParam) => { + handleFocusVisible(event); + if (isFocusVisibleRef.current) { + setFocusVisible(true); + } + + const newFocus = parseFloat((event.target as HTMLInputElement).value); + setState((prev) => ({ + hover: prev.hover, + focus: newFocus, + })); + }; + + const handleBlur: FocusEventHandler = (event) => { + if (valueState().hover !== -1) { + return; + } + + handleBlurVisible(event); + if (!isFocusVisibleRef.current) { + setFocusVisible(false); + } + + const newFocus = -1; + setState((prev) => ({ + hover: prev.hover, + focus: newFocus, + })); + }; + + const [emptyValueFocused, setEmptyValueFocused] = createSignal(false); + + const ownerState = mergeProps(props, { + get defaultValue() { + return baseProps.defaultValue; + }, + get disabled() { + return baseProps.disabled; + }, + get emptyIcon() { + return baseProps.emptyIcon; + }, + get emptyLabelText() { + return baseProps.emptyLabelText; + }, + get emptyValueFocused() { + return emptyValueFocused(); + }, + get focusVisible() { + return focusVisible(); + }, + get getLabelText() { + return baseProps.getLabelText; + }, + get icon() { + return baseProps.icon; + }, + get IconContainerComponent() { + return baseProps.IconContainerComponent; + }, + get max() { + return baseProps.max; + }, + get precision() { + return baseProps.precision; + }, + get readOnly() { + return baseProps.readOnly; + }, + get size() { + return baseProps.size; + }, + }); + + const classes = $.useClasses(ownerState as OwnerState); + + return ( + + {Array.from(new Array(baseProps.max)).map((_, index) => { + const itemValue = index + 1; + + const ratingItemProps = { + classes: classes, + get disabled() { + return baseProps.disabled; + }, + get emptyIcon() { + return baseProps.emptyIcon; + }, + get focus() { + return valueState().focus; + }, + get getLabelText() { + return baseProps.getLabelText; + }, + get highlightSelectedOnly() { + return baseProps.highlightSelectedOnly; + }, + get hover() { + return valueState().hover; + }, + get icon() { + return baseProps.icon; + }, + get IconContainerComponent() { + return baseProps.IconContainerComponent; + }, + name: name, + onBlur: handleBlur, + onChange: handleChange, + onClick: handleClear, + onFocus: handleFocus, + ratingValue: value(), + ratingValueRounded: valueRounded, + get readOnly() { + return baseProps.readOnly; + }, + ownerState: ownerState as OwnerState, + }; + + const isActive = + itemValue === Math.ceil(value()) && + (valueState().hover !== -1 || valueState().focus !== -1); + if (baseProps.precision < 1) { + const items = Array.from(new Array(1 / baseProps.precision)); + return ( + + {items.map((_, indexDecimal) => { + const itemDecimalValue = roundValueToPrecision( + itemValue - 1 + (indexDecimal + 1) * baseProps.precision, + baseProps.precision + ); + + return ( + + ); + })} + + ); + } + + return ( + + ); + })} + {!baseProps.readOnly && !baseProps.disabled && ( + + setEmptyValueFocused(true)} + onBlur={() => setEmptyValueFocused(false)} + onChange={handleChange} + /> + {baseProps.emptyLabelText} + + )} + + ); +}); + +export default Rating; diff --git a/packages/material/src/Rating/RatingProps.tsx b/packages/material/src/Rating/RatingProps.tsx new file mode 100644 index 000000000..07f8cf1e0 --- /dev/null +++ b/packages/material/src/Rating/RatingProps.tsx @@ -0,0 +1,162 @@ +import { Theme } from ".."; +import { RatingClasses } from "./ratingClasses"; +import { SxProps } from "@suid/system"; +import { OverridableStringUnion } from "@suid/types"; +import * as ST from "@suid/types"; +import { JSXElement, Component, type JSX } from "solid-js"; + +export interface IconContainerProps + extends JSX.HTMLAttributes { + value: number; +} + +export interface RatingPropsSizeOverrides {} +export type RatingTypeMap

= { + name: "MuiRating"; + defaultPropNames: + | "defaultValue" + | "disabled" + | "emptyIcon" + | "emptyLabelText" + | "getLabelText" + | "highlightSelectedOnly" + | "icon" + | "IconContainerComponent" + | "max" + | "precision" + | "readOnly" + | "size"; + selfProps: { + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial; + + /** + * The default value. Use when the component is not controlled. + * @default null + */ + defaultValue?: number; + + /** + * If `true`, the component is disabled. + * @default false + */ + disabled?: boolean; + + /** + * The icon to display when empty. + * @default + */ + emptyIcon?: JSXElement; + + /** + * The label read when the rating input is empty. + * @default 'Empty' + */ + emptyLabelText?: JSXElement; + + /** + * Accepts a function which returns a string value that provides a user-friendly name for the current value of the rating. + * This is important for screen reader users. + * + * For localization purposes, you can use the provided [translations](/guides/localization/). + * @param {number} value The rating label's value to format. + * @returns {string} + * @default function defaultLabelText(value) { + * return `${value} Star${value !== 1 ? 's' : ''}`; + * } + */ + getLabelText?: (value: number) => string; + + /** + * If `true`, only the selected icon will be highlighted. + * @default false + */ + highlightSelectedOnly?: boolean; + + /** + * The icon to display. + * @default + */ + icon?: JSXElement; + + /** + * The component containing the icon. + * @default function IconContainer(props) { + * const { value, ...other } = props; + * return ; + * } + */ + IconContainerComponent?: Component; + + /** + * Maximum rating. + * @default 5 + */ + max?: number; + + /** + * The name attribute of the radio `input` elements. + * This input `name` should be unique within the page. + * Being unique within a form is insufficient since the `name` is used to generated IDs. + */ + name?: string; + + /** + * Callback fired when the value changes. + * @param {Event} event The event source of the callback. + * @param {number|null} value The new value. + */ + onChange?: (event: Event, value: number | null) => void; + + /** + * Callback function that is fired when the hover state changes. + * @param {MouseEvent} event The event source of the callback. + * @param {number} value The new value. + */ + onChangeActive?: (event: MouseEvent, value: number) => void; + + /** + * The minimum increment value change allowed. + * @default 1 + */ + precision?: number; + + /** + * Removes all hover effects and pointer events. + * @default false + */ + readOnly?: boolean; + + /** + * The size of the component. + * @default 'medium' + */ + size?: OverridableStringUnion< + "small" | "medium" | "large", + RatingPropsSizeOverrides + >; + + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps; + + /** + * The rating value. + */ + value?: number | null; + }; + props: P & + RatingTypeMap["selfProps"] & + Omit, "children" | "onChange">; + defaultComponent: D; +}; + +export type RatingProps< + D extends ST.ElementType = RatingTypeMap["defaultComponent"], + P = {}, +> = ST.OverrideProps, D>; + +export default RatingProps; diff --git a/packages/material/src/Rating/index.tsx b/packages/material/src/Rating/index.tsx new file mode 100644 index 000000000..13ec203c6 --- /dev/null +++ b/packages/material/src/Rating/index.tsx @@ -0,0 +1,7 @@ +export { default } from "./Rating"; +export * from "./Rating"; + +export { default as ratingClasses } from "./ratingClasses"; +export * from "./ratingClasses"; + +export * from "./RatingProps"; diff --git a/packages/material/src/Rating/ratingClasses.ts b/packages/material/src/Rating/ratingClasses.ts new file mode 100644 index 000000000..ad678c288 --- /dev/null +++ b/packages/material/src/Rating/ratingClasses.ts @@ -0,0 +1,68 @@ +import generateUtilityClass from "@suid/base/generateUtilityClass"; +import generateUtilityClasses from "@suid/base/generateUtilityClasses"; + +export interface RatingClasses { + /** Styles applied to the root element. */ + root: string; + /** Styles applied to the root element if `size="small"`. */ + sizeSmall: string; + /** Styles applied to the root element if `size="medium"`. */ + sizeMedium: string; + /** Styles applied to the root element if `size="large"`. */ + sizeLarge: string; + /** Styles applied to the root element if `readOnly={true}`. */ + readOnly: string; + /** State class applied to the root element if `disabled={true}`. */ + disabled: string; + /** State class applied to the root element if keyboard focused. */ + focusVisible: string; + /** Visually hide an element. */ + visuallyHidden: string; + /** Styles applied to the label elements. */ + label: string; + /** Styles applied to the label of the "no value" input when it is active. */ + labelEmptyValueActive: string; + /** Styles applied to the icon wrapping elements. */ + icon: string; + /** Styles applied to the icon wrapping elements when empty. */ + iconEmpty: string; + /** Styles applied to the icon wrapping elements when filled. */ + iconFilled: string; + /** Styles applied to the icon wrapping elements when hover. */ + iconHover: string; + /** Styles applied to the icon wrapping elements when focus. */ + iconFocus: string; + /** Styles applied to the icon wrapping elements when active. */ + iconActive: string; + /** Styles applied to the icon wrapping elements when decimals are necessary. */ + decimal: string; +} + +export type RatingClassKey = keyof RatingClasses; + +export function getRatingUtilityClass(slot: string): string { + return generateUtilityClass("MuiRating", slot); +} + +const ratingClasses: RatingClasses = generateUtilityClasses("MuiRating", [ + "root", + "sizeSmall", + "sizeMedium", + "sizeLarge", + "readOnly", + "disabled", + "focusVisible", + "visuallyHidden", + "pristine", + "label", + "labelEmptyValueActive", + "icon", + "iconEmpty", + "iconFilled", + "iconHover", + "iconFocus", + "iconActive", + "decimal", +]); + +export default ratingClasses; diff --git a/packages/material/src/index.tsx b/packages/material/src/index.tsx index 0ef906630..4481fe958 100644 --- a/packages/material/src/index.tsx +++ b/packages/material/src/index.tsx @@ -74,6 +74,7 @@ export { default as Popper } from "./Popper"; export { default as Portal } from "./Portal"; export { default as Radio } from "./Radio"; export { default as RadioGroup } from "./RadioGroup"; +export { default as Rating } from "./Rating"; export { default as Select } from "./Select"; export { default as Skeleton } from "./Skeleton"; export { default as Slide } from "./Slide"; diff --git a/packages/material/src/styles/components-types.ts b/packages/material/src/styles/components-types.ts index acde87c2e..66aad16e7 100644 --- a/packages/material/src/styles/components-types.ts +++ b/packages/material/src/styles/components-types.ts @@ -66,6 +66,7 @@ import type MuiPaper from "../Paper"; import type MuiPopover from "../Popover"; import type MuiRadio from "../Radio"; import type MuiRadioGroup from "../RadioGroup"; +import type MuiRating from "../Rating"; import type MuiSelect from "../Select"; import type MuiSkeleton from "../Skeleton"; import type MuiSlide from "../Slide"; @@ -155,6 +156,7 @@ export type ComponentsTypes = { MuiPopover: typeof MuiPopover; MuiRadio: typeof MuiRadio; MuiRadioGroup: typeof MuiRadioGroup; + MuiRating: typeof MuiRating; MuiSelect: typeof MuiSelect; MuiSkeleton: typeof MuiSkeleton; MuiSlide: typeof MuiSlide; diff --git a/packages/site/src/pages/components/RatingPage/CustomizedRatingExample.tsx b/packages/site/src/pages/components/RatingPage/CustomizedRatingExample.tsx new file mode 100644 index 000000000..6fe6184cd --- /dev/null +++ b/packages/site/src/pages/components/RatingPage/CustomizedRatingExample.tsx @@ -0,0 +1,35 @@ +import { + Favorite as FavoriteIcon, + FavoriteBorder as FavoriteBorderIcon, +} from "@suid/icons-material"; +import { Box, Rating, Typography } from "@suid/material"; +import { styled } from "@suid/material/styles"; + +const StyledRating = styled(Rating)({ + "& .MuiRating-iconFilled": { + color: "#ff6d75", + }, + "& .MuiRating-iconHover": { + color: "#ff3d47", + }, +}); + +export default function CustomizedRatingExample() { + return ( + legend": { mt: 2 } }}> + Custom icon and color + + `${value} Heart${value !== 1 ? "s" : ""}` + } + precision={0.5} + icon={} + emptyIcon={} + /> + 10 stars + + + ); +} diff --git a/packages/site/src/pages/components/RatingPage/HalfRatingExample.tsx b/packages/site/src/pages/components/RatingPage/HalfRatingExample.tsx new file mode 100644 index 000000000..88da8efd4 --- /dev/null +++ b/packages/site/src/pages/components/RatingPage/HalfRatingExample.tsx @@ -0,0 +1,15 @@ +import { Rating, Stack } from "@suid/material"; + +export default function HalfRatingExample() { + return ( + + + + + ); +} diff --git a/packages/site/src/pages/components/RatingPage/HoverFeedbackExample.tsx b/packages/site/src/pages/components/RatingPage/HoverFeedbackExample.tsx new file mode 100644 index 000000000..3b330e5d3 --- /dev/null +++ b/packages/site/src/pages/components/RatingPage/HoverFeedbackExample.tsx @@ -0,0 +1,54 @@ +import StarIcon from "@suid/icons-material/Star"; +import { Box, Rating } from "@suid/material"; +import { createSignal } from "solid-js"; + +const labels: { [index: string]: string } = { + 0.5: "Useless", + 1: "Useless+", + 1.5: "Poor", + 2: "Poor+", + 2.5: "Ok", + 3: "Ok+", + 3.5: "Good", + 4: "Good+", + 4.5: "Excellent", + 5: "Excellent+", +}; + +function getLabelText(value: number) { + return `${value} Star${value !== 1 ? "s" : ""}, ${labels[value]}`; +} + +export default function HoverRating() { + const [value, setValue] = createSignal(2); + const [hover, setHover] = createSignal(-1); + + return ( + + { + setValue(newValue); + }} + onChangeActive={(event, newHover) => { + setHover(newHover); + }} + emptyIcon={} + /> + {value() !== null && ( + + {labels[(hover() !== -1 ? hover() : value()) as number]} + + )} + + ); +} diff --git a/packages/site/src/pages/components/RatingPage/RadioGroupExample.tsx b/packages/site/src/pages/components/RatingPage/RadioGroupExample.tsx new file mode 100644 index 000000000..a42d5569e --- /dev/null +++ b/packages/site/src/pages/components/RatingPage/RadioGroupExample.tsx @@ -0,0 +1,59 @@ +import SentimentDissatisfiedIcon from "@suid/icons-material/SentimentDissatisfied"; +import SentimentSatisfiedIcon from "@suid/icons-material/SentimentSatisfied"; +import SentimentSatisfiedAltIcon from "@suid/icons-material/SentimentSatisfiedAltOutlined"; +import SentimentVeryDissatisfiedIcon from "@suid/icons-material/SentimentVeryDissatisfied"; +import SentimentVerySatisfiedIcon from "@suid/icons-material/SentimentVerySatisfied"; +import Rating, { IconContainerProps } from "@suid/material/Rating"; +import { styled } from "@suid/material/styles"; +import { type JSX, splitProps } from "solid-js"; + +const StyledRating = styled(Rating)(({ theme }) => ({ + "& .MuiRating-iconEmpty .MuiSvgIcon-root": { + color: theme.palette.action.disabled, + }, +})); + +const customIcons: { + [index: string]: { + icon: JSX.Element; + label: string; + }; +} = { + 1: { + icon: , + label: "Very Dissatisfied", + }, + 2: { + icon: , + label: "Dissatisfied", + }, + 3: { + icon: , + label: "Neutral", + }, + 4: { + icon: , + label: "Satisfied", + }, + 5: { + icon: , + label: "Very Satisfied", + }, +}; + +function IconContainer(props: IconContainerProps) { + const [local, other] = splitProps(props, ["value"]); + return {customIcons[local.value].icon}; +} + +export default function RadioGroupRatingExample() { + return ( + customIcons[value].label} + highlightSelectedOnly + /> + ); +} diff --git a/packages/site/src/pages/components/RatingPage/RatingPage.tsx b/packages/site/src/pages/components/RatingPage/RatingPage.tsx new file mode 100644 index 000000000..06a1bf559 --- /dev/null +++ b/packages/site/src/pages/components/RatingPage/RatingPage.tsx @@ -0,0 +1,43 @@ +import { Rating } from "@suid/material"; +import ComponentInfo from "~/components/ComponentInfo"; +import CustomizedRatingExample from "./CustomizedRatingExample"; +import HalfRatingExample from "./HalfRatingExample"; +import HoverFeedbackExample from "./HoverFeedbackExample"; +import RadioGroupRatingExample from "./RadioGroupExample"; +import SimpleBadgeExample from "./SimpleRatingExample"; +import RatingSizeExample from "./SizesExample"; + +export default function RatingPage() { + return ( + + ); +} diff --git a/packages/site/src/pages/components/RatingPage/SimpleRatingExample.tsx b/packages/site/src/pages/components/RatingPage/SimpleRatingExample.tsx new file mode 100644 index 000000000..02dc4b757 --- /dev/null +++ b/packages/site/src/pages/components/RatingPage/SimpleRatingExample.tsx @@ -0,0 +1,25 @@ +import { Box, Rating, Typography } from "@suid/material"; +import { createSignal } from "solid-js"; + +export default function SimpleRating() { + const [value, setValue] = createSignal(2); + + return ( + legend": { mt: 2 } }}> + Controlled + { + setValue(newValue); + }} + /> + Read only + + Disabled + + No rating given + + + ); +} diff --git a/packages/site/src/pages/components/RatingPage/SizesExample.tsx b/packages/site/src/pages/components/RatingPage/SizesExample.tsx new file mode 100644 index 000000000..f49ef6d00 --- /dev/null +++ b/packages/site/src/pages/components/RatingPage/SizesExample.tsx @@ -0,0 +1,11 @@ +import { Rating, Stack } from "@suid/material"; + +export default function RatingSizeExample() { + return ( + + + + + + ); +} diff --git a/packages/system/src/createStyled.tsx b/packages/system/src/createStyled.tsx index b4e4e47f3..94ed07e66 100644 --- a/packages/system/src/createStyled.tsx +++ b/packages/system/src/createStyled.tsx @@ -1,6 +1,6 @@ import { createDynamicComponent } from "./Dynamic"; import createStyle from "./createStyle"; -import type { Theme } from "./createTheme/createTheme"; +import type { Theme } from "./createTheme"; import resolveStyledProps from "./resolveStyledProps"; import resolveSxProps from "./resolveSxProps"; import { StyledProps } from "./styledProps"; diff --git a/packages/utils/src/createUniqueId.ts b/packages/utils/src/createUniqueId.ts index 842b61260..bed844836 100644 --- a/packages/utils/src/createUniqueId.ts +++ b/packages/utils/src/createUniqueId.ts @@ -7,5 +7,5 @@ import { export default function createUniqueId( idOverride?: Accessor ) { - return createMemo(() => idOverride?.() ?? `mui-${_createUniqueId()}`); + return createMemo(() => idOverride?.() ?? `suid-${_createUniqueId()}`); } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index f00068641..7fe2f0e49 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -17,6 +17,7 @@ export { default as toArray } from "./toArray"; export { default as uncapitalize } from "./uncapitalize"; export { default as useIsFocusVisible } from "./useIsFocusVisible"; export { default as usePreviousProps } from "./usePreviousProps"; +export { default as visuallyHidden } from "./visuallyHidden"; export { isSuidElement, isElement, diff --git a/packages/utils/src/visuallyHidden.ts b/packages/utils/src/visuallyHidden.ts new file mode 100644 index 000000000..d5de4c4d2 --- /dev/null +++ b/packages/utils/src/visuallyHidden.ts @@ -0,0 +1,13 @@ +const visuallyHidden: import("solid-js").JSX.CSSProperties = { + position: "absolute", + width: "1px", + height: "1px", + margin: "-1px", + padding: 0, + border: 0, + clip: "rect(0 0 0 0)", + overflow: "hidden", + "white-space": "nowrap", +}; + +export default visuallyHidden; From 4f6d06d6de1cfcc45ad760fdd2b64ac62b3b5496 Mon Sep 17 00:00:00 2001 From: phinome Date: Sat, 3 Aug 2024 10:18:47 +0800 Subject: [PATCH 2/2] chore: rating component changeset --- .changeset/stupid-days-travel.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/stupid-days-travel.md diff --git a/.changeset/stupid-days-travel.md b/.changeset/stupid-days-travel.md new file mode 100644 index 000000000..08fa4da82 --- /dev/null +++ b/.changeset/stupid-days-travel.md @@ -0,0 +1,9 @@ +--- +"@suid/material": minor +"@suid/codemod": minor +"@suid/system": minor +"@suid/utils": minor +"@suid/site": minor +--- + +feat: add Rating Component