From a64dce18842c9e98095199a04dd6402f5b471685 Mon Sep 17 00:00:00 2001 From: HBS999 Date: Mon, 4 Nov 2024 14:54:00 +0300 Subject: [PATCH 01/10] feat: RatingGroup component --- .../docs/src/examples/rating-group.module.css | 62 +++ apps/docs/src/examples/rating-group.tsx | 243 ++++++++++ apps/docs/src/routes/docs/core.tsx | 5 + .../docs/core/components/rating-group.mdx | 425 ++++++++++++++++++ packages/core/src/index.tsx | 1 + packages/core/src/rating-group/index.tsx | 131 ++++++ .../src/rating-group/rating-group-context.tsx | 35 ++ .../src/rating-group/rating-group-control.tsx | 71 +++ .../rating-group-hidden-input.tsx | 26 ++ .../rating-group-item-context.tsx | 37 ++ .../rating-group-item-control.tsx | 85 ++++ .../rating-group-item-description.tsx | 52 +++ .../rating-group/rating-group-item-label.tsx | 65 +++ .../src/rating-group/rating-group-item.tsx | 286 ++++++++++++ .../src/rating-group/rating-group-label.tsx | 29 ++ .../src/rating-group/rating-group-root.tsx | 223 +++++++++ packages/core/src/rating-group/utils.ts | 58 +++ 17 files changed, 1834 insertions(+) create mode 100644 apps/docs/src/examples/rating-group.module.css create mode 100644 apps/docs/src/examples/rating-group.tsx create mode 100644 apps/docs/src/routes/docs/core/components/rating-group.mdx create mode 100644 packages/core/src/rating-group/index.tsx create mode 100644 packages/core/src/rating-group/rating-group-context.tsx create mode 100644 packages/core/src/rating-group/rating-group-control.tsx create mode 100644 packages/core/src/rating-group/rating-group-hidden-input.tsx create mode 100644 packages/core/src/rating-group/rating-group-item-context.tsx create mode 100644 packages/core/src/rating-group/rating-group-item-control.tsx create mode 100644 packages/core/src/rating-group/rating-group-item-description.tsx create mode 100644 packages/core/src/rating-group/rating-group-item-label.tsx create mode 100644 packages/core/src/rating-group/rating-group-item.tsx create mode 100644 packages/core/src/rating-group/rating-group-label.tsx create mode 100644 packages/core/src/rating-group/rating-group-root.tsx create mode 100644 packages/core/src/rating-group/utils.ts diff --git a/apps/docs/src/examples/rating-group.module.css b/apps/docs/src/examples/rating-group.module.css new file mode 100644 index 00000000..e7430e1c --- /dev/null +++ b/apps/docs/src/examples/rating-group.module.css @@ -0,0 +1,62 @@ +.rating-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.rating-group__label { + color: hsl(240 6% 10%); + font-size: 14px; + font-weight: 500; + user-select: none; +} + +.rating-group__description { + color: hsl(240 5% 26%); + font-size: 12px; + user-select: none; +} + +.rating-group__error-message { + color: hsl(0 72% 51%); + font-size: 12px; + user-select: none; +} + +.rating-group__control { + display: flex; + gap: 4px; +} + +.rating-group-item { + cursor: pointer; + fill: hsl(240 6% 90%); +} + +.rating-group-item:focus { + outline: none; +} + +[data-kb-theme="dark"] .rating-group-item { + fill: hsl(240 5% 26%); +} + +.rating-group-item[data-highlighted] { + fill: hsl(200 98% 39%); +} + +.half-star-icon > path + path { + fill: hsl(240 6% 90%); +} + +[data-kb-theme="dark"] .half-star-icon > path + path { + fill: hsl(240 5% 26%); +} + +[data-kb-theme="dark"] .rating-group__label { + color: hsl(240 5% 84%); +} + +[data-kb-theme="dark"] .rating-group__description { + color: hsl(240 5% 65%); +} diff --git a/apps/docs/src/examples/rating-group.tsx b/apps/docs/src/examples/rating-group.tsx new file mode 100644 index 00000000..bf28a075 --- /dev/null +++ b/apps/docs/src/examples/rating-group.tsx @@ -0,0 +1,243 @@ +import { RatingGroup } from "../../../../packages/core/src/rating-group"; +import { Index, createSignal } from "solid-js"; + +import style from "./rating-group.module.css"; + +export function BasicExample() { + return ( + + + Rate Us: + + + + {(_) => ( + + + + + + )} + + + + ); +} + +export function DefaultValueExample() { + return ( + + + + {(_) => ( + + + + + + )} + + + + ); +} + +export function ControlledExample() { + const [value, setValue] = createSignal(0); + + return ( + <> + + + + {(_) => ( + + + + + + )} + + + +

Your rating is: {value()}/5

+ + ); +} + +export function HalfRatingsExample() { + return ( + + + + {(_) => ( + + + {(state) => + state.half() ? ( + + ) : state.highlighted() ? ( + + ) : ( + + ) + } + + + )} + + + + ); +} + +export function DescriptionExample() { + return ( + + + Rate Us: + + + + {(_) => ( + + + + + + )} + + + + Rate your experience with us. + + + ); +} + +export function ErrorMessageExample() { + const [value, setValue] = createSignal(0); + + return ( + + + Rate Us: + + + + {(_) => ( + + + + + + )} + + + + Please select a rating between 1 and 5. + + + ); +} + +export function HTMLFormExample() { + let formRef: HTMLFormElement | undefined; + + const onSubmit = (e: SubmitEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const formData = new FormData(formRef); + + alert(JSON.stringify(Object.fromEntries(formData), null, 2)); + }; + + return ( +
+ + + + {(_) => ( + + + + + + )} + + + + +
+ + +
+
+ ); +} + +function StarIcon() { + return ( + + Star Icon + + + ); +} + +function StarHalfIcon() { + return ( + + Half Star Icon + + + + ); +} diff --git a/apps/docs/src/routes/docs/core.tsx b/apps/docs/src/routes/docs/core.tsx index dc9f20f8..2af9a14d 100644 --- a/apps/docs/src/routes/docs/core.tsx +++ b/apps/docs/src/routes/docs/core.tsx @@ -160,6 +160,11 @@ const CORE_NAV_SECTIONS: NavSection[] = [ title: "Radio Group", href: "/docs/core/components/radio-group", }, + { + title: "Rating Group", + href: "/docs/core/components/rating-group", + status: "new", + }, { title: "Select", href: "/docs/core/components/select", diff --git a/apps/docs/src/routes/docs/core/components/rating-group.mdx b/apps/docs/src/routes/docs/core/components/rating-group.mdx new file mode 100644 index 00000000..20b4b80f --- /dev/null +++ b/apps/docs/src/routes/docs/core/components/rating-group.mdx @@ -0,0 +1,425 @@ +import { Preview, TabsSnippets, Kbd } from "../../../../components"; +import { + BasicExample, + DefaultValueExample, + ControlledExample, + HalfRatingsExample, + DescriptionExample, + ErrorMessageExample, + HTMLFormExample, +} from "../../../../examples/rating-group"; + +# Rating Group + +Allows users to rate items using a set of icons. + +## Import + +```ts +import { RatingGroup } from "@kobalte/core/rating-group"; +// or +import { Root, Label, ... } from "@kobalte/core/rating-group"; +``` + +## Features + +- Syncs with form reset events. +- Group and rating labeling support for assistive technology. +- Can be controlled or uncontrolled. + +## Anatomy + +The rating group consists of: + +- **RatingGroup**: The root container for the rating group. +- **RatingGroup.Control**: The container for the rating items. +- **RatingGroup.Label**: The label that gives the user information on the rating group. +- **RatingGroup.HiddenInput**: The native html input that is visually hidden in the rating group. +- **RatingGroup.Description**: The description that gives the user more information on the rating group. +- **RatingGroup.ErrorMessage**: The error message that gives the user information about how to fix a validation error on the rating group. + +The rating item consists of: + +- **RatingGroup.Item**: The root container for a rating item. +- **RatingGroup.ItemControl**: The element that visually represents a rating item. +- **RatingGroup.ItemLabel**: The label that gives the user information on the rating item. +- **RatingGroup.ItemDescription**: The description that gives the user more information on the rating item. + +```tsx + + + + + + + + + + + + + +``` + +## Example + + + + + + + + index.tsx + style.css + + {/* */} + + ```tsx + import { RatingGroup } from "@kobalte/core/rating-group"; + import "./style.css"; + + function App() { + return ( + + Rate Us: + + + {_ => ( + + + + + + )} + + + + ); + } + ``` + + + +```css +.rating-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.rating-group__label { + color: hsl(240 6% 10%); + font-size: 14px; + font-weight: 500; + user-select: none; +} + +.rating-group__description { + color: hsl(240 5% 26%); + font-size: 12px; + user-select: none; +} + +.rating-group__error-message { + color: hsl(0 72% 51%); + font-size: 12px; + user-select: none; +} + +.rating-group__control { + display: flex; + gap: 4px; +} + +.rating-group-item { + cursor: pointer; + fill: hsl(240 6% 90%); + transition: fill 200ms cubic-bezier(0.2, 0, 0, 1); +} + +.rating-group-item[data-highlighted] { + fill: hsl(200 98% 39%); +} +``` + + + {/* */} + + +## Usage + +### Default value + +An initial, uncontrolled value can be provided using the `defaultValue` prop. + + + + + +```tsx {0} + + + + {_ => ( + + + + + + )} + + + +``` + +### Controlled value + +The `value` prop can be used to make the value controlled. The `onChange` event is fired when the user selects a rating, and receives the new value. + + + + + +```tsx {3,7} +import { createSignal } from "solid-js"; + +function ControlledExample() { + const [value, setValue] = createSignal(0); + + return ( + <> + + + + {_ => ( + + + + + + )} + + + +

Your rating is: {value()}/5

+ + ); +} +``` + +### Half Ratings + +Allow 0.5 value steps by setting the `allowHalf` prop to true. + + + + + +```tsx{0} + + + + {_ => ( + + + {state => + state.half() ? : state.highlighted() ? : + } + + + )} + + + +``` + +### Description + +The `RatingGroup.Description` component can be used to associate additional help text with a rating group. + + + + + +```tsx {13} + + Rate Us: + + + {_ => ( + + + + + + )} + + + Rate your experience with us. + +``` + +### Error message + +The `RatingGroup.ErrorMessage` component can be used to help the user fix a validation error. It should be combined with the `validationState` prop to semantically mark the rating group as invalid for assistive technologies. + +By default, it will render only when the `validationState` prop is set to `invalid`, use the `forceMount` prop to always render the error message (ex: for usage with animation libraries). + + + + + +```tsx {9,23-25} +import { createSignal } from "solid-js"; + +function ErrorMessageExample() { + const [value, setValue] = createSignal(0); + + return ( + + Rate Us: + + + {_ => ( + + + + + + )} + + + + Please select a rating between 1 and 5. + + + ); +} +``` + +### HTML forms + +The `name` prop can be used for integration with HTML forms. + + + + + +```tsx {7,19} +function HTMLFormExample() { + const onSubmit = (e: SubmitEvent) => { + // handle form submission. + }; + + return ( +
+ + + + {_ => ( + + + + + + )} + + + + +
+ + +
+
+ ); +} +``` + +## API Reference + +### RatingGroup + +`RatingGroup` is equivalent to the `Root` import from `@kobalte/core/Rating-group`. + +| Prop | Description | +| :-------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| value | `number`
The current rating value. | +| defaultValue | `number`
The initial value of the rating group when it is first rendered. Use when you do not need to control the state of the rating group.| +| onChange | `(value: number) => void`
Event handler called when the value changes. | +| allowHalf | `boolean`
Whether to allow half ratings. | +| orientation | `'horizontal' \| 'vertical'`
The axis the rating group items should align with. | +| name | `string`
The name of the rating group. Submitted with its owning form as part of a name/value pair. | +| validationState | `'valid' \| 'invalid'`
Whether the rating group should display its "valid" or "invalid" visual styling. | +| required | `boolean`
Whether the user must select an item before the owning form can be submitted. | +| disabled | `boolean`
Whether the rating group is disabled. | +| readOnly | `boolean`
Whether the rating group items can be selected but not changed by the user. | + +| Data attribute | Description | +| :------------- | :------------------------------------------------------------------------------------------- | +| data-valid | Present when the rating group is valid according to the validation rules. | +| data-invalid | Present when the rating group is invalid according to the validation rules. | +| data-required | Present when the user must select a rating group item before the owning form can be submitted. | +| data-disabled | Present when the rating group is disabled. | +| data-readonly | Present when the rating group is read only. | + +`RatingGroup.Label`, `RatingGroup.Description` and `RatingGroup.ErrorMesssage` shares the same data-attributes. + +### RatingGroup.ErrorMessage + +| Prop | Description | +| :--------- | :-------------------------------------------------------------------------------------------------------------------------------------- | +| forceMount | `boolean`
Used to force mounting when more control is needed. Useful when controlling animation with SolidJS animation libraries. | + +### RatingGroup.ItemControl + +| Render Prop | Description | +| :-------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| half | `Accessor`
Whether the rating item is half. +| highlighted | `Accessor`
Whether the rating item is highlighted. | + +| Data attribute | Description | +| :------------- | :-------------------------------------------------------------------------------- | +| data-valid | Present when the parent rating group is valid according to the validation rules. | +| data-invalid | Present when the parent rating group is invalid according to the validation rules. | +| data-required | Present when the parent rating group is required. | +| data-disabled | Present when the parent rating group is disabled. | +| data-readonly | Present when the parent rating group is read only. | +| data-checked | Present when the rating is checked. | +| data-half | Present when the rating is half. | +| data-highlighted | Present when the rating is highlighted. | + +`RatingGroup.ItemLabel` and `RatingGroup.ItemDescription` share the same data-attributes. + +## Rendered elements + +| Component | Default rendered element | +| :--------------------------- | :----------------------- | +| `RatingGroup` | `div` | +| `RatingGroup.Control` | `div` | +| `RatingGroup.Label` | `span` | +| `RatingGroup.HiddenInput` | `input` | +| `RatingGroup.Description` | `div` | +| `RatingGroup.ErrorMessage` | `div` | +| `RatingGroup.Item` | `div` | +| `RatingGroup.ItemControl` | `div` | +| `RatingGroup.ItemLabel` | `label` | +| `RatingGroup.ItemDescription` | `div` | + +## Accessibility + +### Keyboard Interactions + +| Key | Description | +| :-------------------- | :------------------------------------------------------------------------------------- | +| ArrowDown | Moves focus to the next item, increasing the rating value based on the `allowHalf` property. | +| ArrowRight | Moves focus to the next item, increasing the rating value based on the `allowHalf` property. | +| ArrowUp | Moves focus to the previous item, decreasing the rating value based on the `allowHalf` property. | +| ArrowLeft | Moves focus to the previous item, decreasing the rating value based on the `allowHalf` property. | +| Space | Selects the focused item in the rating group. | +| Home | Sets the value of the rating group to 1. | +| End | Sets the value of the rating group to the maximum value. | diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index a5650b68..83bba3b0 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -39,6 +39,7 @@ export * as Pagination from "./pagination"; export * as Popover from "./popover"; export * as Progress from "./progress"; export * as RadioGroup from "./radio-group"; +export * as RatingGroup from "./rating-group"; export * as Select from "./select"; export * as Separator from "./separator"; export * as Skeleton from "./skeleton"; diff --git a/packages/core/src/rating-group/index.tsx b/packages/core/src/rating-group/index.tsx new file mode 100644 index 00000000..e4ba35c4 --- /dev/null +++ b/packages/core/src/rating-group/index.tsx @@ -0,0 +1,131 @@ +import { + FormControlDescription as Description, + FormControlErrorMessage as ErrorMessage, + type FormControlDescriptionCommonProps as RatingGroupDescriptionCommonProps, + type FormControlDescriptionOptions as RatingGroupDescriptionOptions, + type FormControlDescriptionProps as RatingGroupDescriptionProps, + type FormControlDescriptionRenderProps as RatingGroupDescriptionRenderProps, + type FormControlErrorMessageCommonProps as RatingGroupErrorMessageCommonProps, + type FormControlErrorMessageOptions as RatingGroupErrorMessageOptions, + type FormControlErrorMessageProps as RatingGroupErrorMessageProps, + type FormControlErrorMessageRenderProps as RatingGroupErrorMessageRenderProps, +} from "../form-control"; + +import { + RatingGroupControl as Control, + type RatingGroupControlCommonProps, + type RatingGroupControlOptions, + type RatingGroupControlProps, + type RatingGroupControlRenderProps, +} from "./rating-group-control"; +import { + RatingGroupHiddenInput as HiddenInput, + type RatingGroupHiddenInputProps, +} from "./rating-group-hidden-input"; +import { + RatingGroupItemControl as ItemControl, + type RatingGroupItemControlCommonProps, + type RatingGroupItemControlOptions, + type RatingGroupItemControlProps, + type RatingGroupItemControlRenderProps, +} from "./rating-group-item-control"; +import { + RatingGroupItemDescription as ItemDescription, + type RatingGroupItemDescriptionCommonProps, + type RatingGroupItemDescriptionOptions, + type RatingGroupItemDescriptionProps, + type RatingGroupItemDescriptionRenderProps, +} from "./rating-group-item-description"; +import { + RatingGroupItemLabel as ItemLabel, + type RatingGroupItemLabelCommonProps, + type RatingGroupItemLabelOptions, + type RatingGroupItemLabelProps, + type RatingGroupItemLabelRenderProps, +} from "./rating-group-item-label"; +import { + RatingGroupItem as Item, + type RatingGroupItemCommonProps, + type RatingGroupItemOptions, + type RatingGroupItemProps, + type RatingGroupItemRenderProps, +} from "./rating-group-item"; +import { + RatingGroupLabel as Label, + type RatingGroupLabelCommonProps, + type RatingGroupLabelOptions, + type RatingGroupLabelProps, + type RatingGroupLabelRenderProps, +} from "./rating-group-label"; +import { + type RatingGroupRootCommonProps, + type RatingGroupRootOptions, + type RatingGroupRootProps, + type RatingGroupRootRenderProps, + RatingGroupRoot as Root, +} from "./rating-group-root"; + +export type { + RatingGroupControlCommonProps, + RatingGroupControlOptions, + RatingGroupControlProps, + RatingGroupControlRenderProps, + RatingGroupDescriptionOptions, + RatingGroupDescriptionCommonProps, + RatingGroupDescriptionRenderProps, + RatingGroupDescriptionProps, + RatingGroupErrorMessageOptions, + RatingGroupErrorMessageCommonProps, + RatingGroupErrorMessageRenderProps, + RatingGroupErrorMessageProps, + RatingGroupHiddenInputProps, + RatingGroupItemControlOptions, + RatingGroupItemControlCommonProps, + RatingGroupItemControlRenderProps, + RatingGroupItemControlProps, + RatingGroupItemDescriptionOptions, + RatingGroupItemDescriptionCommonProps, + RatingGroupItemDescriptionRenderProps, + RatingGroupItemDescriptionProps, + RatingGroupItemLabelOptions, + RatingGroupItemLabelCommonProps, + RatingGroupItemLabelRenderProps, + RatingGroupItemLabelProps, + RatingGroupItemOptions, + RatingGroupItemCommonProps, + RatingGroupItemRenderProps, + RatingGroupItemProps, + RatingGroupLabelOptions, + RatingGroupLabelCommonProps, + RatingGroupLabelRenderProps, + RatingGroupLabelProps, + RatingGroupRootOptions, + RatingGroupRootCommonProps, + RatingGroupRootRenderProps, + RatingGroupRootProps, +}; + +export { + Description, + ErrorMessage, + Control, + HiddenInput, + ItemControl, + ItemDescription, + ItemLabel, + Item, + Label, + Root, +}; + +export const RatingGroup = Object.assign(Root, { + Description, + ErrorMessage, + Control, + HiddenInput, + ItemControl, + ItemDescription, + ItemLabel, + Item, + Label, +}); diff --git a/packages/core/src/rating-group/rating-group-context.tsx b/packages/core/src/rating-group/rating-group-context.tsx new file mode 100644 index 00000000..64459e18 --- /dev/null +++ b/packages/core/src/rating-group/rating-group-context.tsx @@ -0,0 +1,35 @@ +import { + type Accessor, + createContext, + type Setter, + useContext, +} from "solid-js"; +import type { CollectionItemWithRef } from "../primitives"; +import type { Orientation } from "@kobalte/utils"; + +export interface RatingGroupContextValue { + value: Accessor; + setValue: (value: number) => void; + allowHalf: Accessor; + orientation: Accessor; + hoveredValue: Accessor; + setHoveredValue: Setter; + isHovering: Accessor; + ariaDescribedBy: Accessor; + items: Accessor; + setItems: Setter; +} + +export const RatingGroupContext = createContext(); + +export function useRatingGroupContext() { + const context = useContext(RatingGroupContext); + + if (context === undefined) { + throw new Error( + "[kobalte]: `useRatingGroupContext` must be used within a `RatingGroup` component", + ); + } + + return context; +} diff --git a/packages/core/src/rating-group/rating-group-control.tsx b/packages/core/src/rating-group/rating-group-control.tsx new file mode 100644 index 00000000..d6db72ad --- /dev/null +++ b/packages/core/src/rating-group/rating-group-control.tsx @@ -0,0 +1,71 @@ +import { callHandler, mergeDefaultProps } from "@kobalte/utils"; +import { type JSX, type ValidComponent, splitProps } from "solid-js"; + +import { + type ElementOf, + Polymorphic, + type PolymorphicProps, +} from "../polymorphic"; +import { useFormControlContext } from "../form-control"; +import { useRatingGroupContext } from "./rating-group-context"; + +export interface RatingGroupControlOptions {} + +export interface RatingGroupControlCommonProps< + T extends HTMLElement = HTMLElement, +> { + id: string; + onPointerLeave: JSX.EventHandlerUnion; +} + +export interface RatingGroupControlRenderProps + extends RatingGroupControlCommonProps { + role: "presentation"; +} + +export type RatingGroupControlProps< + T extends ValidComponent | HTMLElement = HTMLElement, +> = RatingGroupControlOptions & + Partial>>; + +export function RatingGroupControl( + props: PolymorphicProps>, +) { + const formControlContext = useFormControlContext(); + const context = useRatingGroupContext(); + + const defaultId = `${formControlContext.generateId("control")}`; + + const mergedProps = mergeDefaultProps( + { + id: defaultId, + }, + props as RatingGroupControlProps, + ); + + const [local, others] = splitProps(mergedProps, ["onPointerLeave"]); + + const onPointerLeave: JSX.EventHandlerUnion = ( + e, + ) => { + if (formControlContext.isDisabled() || formControlContext.isReadOnly()) + return; + + callHandler(e, local.onPointerLeave); + + if (e.pointerType === "touch") { + return; + } + + context.setHoveredValue(-1); + }; + + return ( + + as="div" + role="presentation" + onPointerLeave={onPointerLeave} + {...others} + /> + ); +} diff --git a/packages/core/src/rating-group/rating-group-hidden-input.tsx b/packages/core/src/rating-group/rating-group-hidden-input.tsx new file mode 100644 index 00000000..8e1aacce --- /dev/null +++ b/packages/core/src/rating-group/rating-group-hidden-input.tsx @@ -0,0 +1,26 @@ +import { visuallyHiddenStyles } from "@kobalte/utils"; +import type { ComponentProps } from "solid-js"; + +import { useFormControlContext } from "../form-control"; +import { useRatingGroupContext } from "./rating-group-context"; + +export interface RatingGroupHiddenInputProps extends ComponentProps<"input"> {} + +export function RatingGroupHiddenInput(props: RatingGroupHiddenInputProps) { + const formControlContext = useFormControlContext(); + const context = useRatingGroupContext(); + + return ( + + ); +} diff --git a/packages/core/src/rating-group/rating-group-item-context.tsx b/packages/core/src/rating-group/rating-group-item-context.tsx new file mode 100644 index 00000000..9904842c --- /dev/null +++ b/packages/core/src/rating-group/rating-group-item-context.tsx @@ -0,0 +1,37 @@ +import { type Accessor, createContext, useContext } from "solid-js"; +import type { FormControlDataSet } from "../form-control"; + +export interface RatingGroupItemDataSet extends FormControlDataSet { + "data-checked": string | undefined; + "data-half": string | undefined; + "data-highlighted": string | undefined; +} + +export interface RatingGroupItemState { + half: Accessor; + highlighted: Accessor; +} + +export interface RatingGroupItemContextValue { + state: RatingGroupItemState; + dataset: Accessor; + itemId: Accessor; + generateId: (part: string) => string; + registerLabel: (id: string) => () => void; + registerDescription: (id: string) => () => void; +} + +export const RatingGroupItemContext = + createContext(); + +export function useRatingGroupItemContext() { + const context = useContext(RatingGroupItemContext); + + if (context === undefined) { + throw new Error( + "[kobalte]: `useRatingGroupItemContext` must be used within a `RatingGroup.Item` component", + ); + } + + return context; +} diff --git a/packages/core/src/rating-group/rating-group-item-control.tsx b/packages/core/src/rating-group/rating-group-item-control.tsx new file mode 100644 index 00000000..0ae65545 --- /dev/null +++ b/packages/core/src/rating-group/rating-group-item-control.tsx @@ -0,0 +1,85 @@ +import { callHandler, isFunction, mergeDefaultProps } from "@kobalte/utils"; +import { type JSX, type ValidComponent, children, splitProps } from "solid-js"; + +import { + type ElementOf, + Polymorphic, + type PolymorphicProps, +} from "../polymorphic"; +import { + type RatingGroupItemState, + useRatingGroupItemContext, +} from "./rating-group-item-context"; + +export interface RatingGroupItemControlOptions { + /** + * The children of the rating group item. + * Can be a `JSX.Element` or a _render prop_ for having access to the internal state. + */ + children?: JSX.Element | ((state: RatingGroupItemState) => JSX.Element); +} + +export interface RatingGroupItemControlCommonProps< + T extends HTMLElement = HTMLElement, +> { + id: string; +} + +export interface RatingGroupItemControlRenderProps + extends RatingGroupItemControlCommonProps { + role: "presentation"; + children: JSX.Element; +} + +export type RatingGroupItemControlProps< + T extends ValidComponent | HTMLElement = HTMLElement, +> = RatingGroupItemControlOptions & + Partial>>; + +export function RatingGroupItemControl( + props: PolymorphicProps>, +) { + const context = useRatingGroupItemContext(); + + const defaultId = `${context.generateId("control")}`; + + const mergedProps = mergeDefaultProps( + { + id: defaultId, + }, + props as RatingGroupItemControlProps, + ); + + const [local, others] = splitProps(mergedProps, ["children"]); + + return ( + + as="div" + role="presentation" + {...others} + > + + {local.children} + + + ); +} + +interface RatingGroupItemControlChildProps + extends Pick { + state: RatingGroupItemState; +} + +function RatingGroupItemControlChild(props: RatingGroupItemControlChildProps) { + const resolvedChildren = children(() => { + const body = props.children; + return isFunction(body) ? body(props.state) : body; + }); + + return <>{resolvedChildren()}; +} diff --git a/packages/core/src/rating-group/rating-group-item-description.tsx b/packages/core/src/rating-group/rating-group-item-description.tsx new file mode 100644 index 00000000..c94dca90 --- /dev/null +++ b/packages/core/src/rating-group/rating-group-item-description.tsx @@ -0,0 +1,52 @@ +import { mergeDefaultProps } from "@kobalte/utils"; +import { type ValidComponent, createEffect, onCleanup } from "solid-js"; + +import { + type ElementOf, + Polymorphic, + type PolymorphicProps, +} from "../polymorphic"; +import { + type RatingGroupItemDataSet, + useRatingGroupItemContext, +} from "./rating-group-item-context"; + +export interface RatingGroupItemDescriptionOptions {} + +export interface RatingGroupItemDescriptionCommonProps< + T extends HTMLElement = HTMLElement, +> { + id: string; +} + +export interface RatingGroupItemDescriptionRenderProps + extends RatingGroupItemDescriptionCommonProps, + RatingGroupItemDataSet {} + +export type RatingGroupItemDescriptionProps< + T extends ValidComponent | HTMLElement = HTMLElement, +> = RatingGroupItemDescriptionOptions & + Partial>>; + +export function RatingGroupItemDescription( + props: PolymorphicProps>, +) { + const context = useRatingGroupItemContext(); + + const mergedProps = mergeDefaultProps( + { + id: context.generateId("description"), + }, + props as RatingGroupItemDescriptionProps, + ); + + createEffect(() => onCleanup(context.registerDescription(mergedProps.id))); + + return ( + + as="div" + {...context.dataset()} + {...mergedProps} + /> + ); +} diff --git a/packages/core/src/rating-group/rating-group-item-label.tsx b/packages/core/src/rating-group/rating-group-item-label.tsx new file mode 100644 index 00000000..1bae7080 --- /dev/null +++ b/packages/core/src/rating-group/rating-group-item-label.tsx @@ -0,0 +1,65 @@ +import { mergeDefaultProps, visuallyHiddenStyles } from "@kobalte/utils"; +import { + type JSX, + type ValidComponent, + createEffect, + onCleanup, + splitProps, +} from "solid-js"; +import { + type ElementOf, + Polymorphic, + type PolymorphicProps, +} from "../polymorphic"; +import { + type RatingGroupItemDataSet, + useRatingGroupItemContext, +} from "./rating-group-item-context"; +import { combineStyle } from "@solid-primitives/props"; + +export interface RatingGroupItemLabelOptions {} + +export interface RatingGroupItemLabelCommonProps< + T extends HTMLElement = HTMLElement, +> { + id: string; + style: JSX.CSSProperties | string; +} + +export interface RatingGroupItemLabelRenderProps + extends RatingGroupItemLabelCommonProps, + RatingGroupItemDataSet { + for: string | undefined; +} + +export type RatingGroupItemLabelProps< + T extends ValidComponent | HTMLElement = HTMLElement, +> = RatingGroupItemLabelOptions & + Partial>>; + +export function RatingGroupItemLabel( + props: PolymorphicProps>, +) { + const context = useRatingGroupItemContext(); + + const mergedProps = mergeDefaultProps( + { + id: context.generateId("label"), + }, + props as RatingGroupItemLabelProps, + ); + + const [local, others] = splitProps(mergedProps, ["style"]); + + createEffect(() => onCleanup(context.registerLabel(others.id!))); + + return ( + + as="label" + for={context.itemId()} + style={combineStyle(visuallyHiddenStyles, local.style)} + {...context.dataset()} + {...others} + /> + ); +} diff --git a/packages/core/src/rating-group/rating-group-item.tsx b/packages/core/src/rating-group/rating-group-item.tsx new file mode 100644 index 00000000..3d75ddaa --- /dev/null +++ b/packages/core/src/rating-group/rating-group-item.tsx @@ -0,0 +1,286 @@ +import { + callHandler, + createGenerateId, + EventKey, + mergeDefaultProps, + mergeRefs, +} from "@kobalte/utils"; +import { + type Accessor, + type JSX, + type ValidComponent, + createEffect, + createMemo, + createSignal, + createUniqueId, + onMount, + splitProps, +} from "solid-js"; + +import { useFormControlContext } from "../form-control"; +import { + type ElementOf, + Polymorphic, + type PolymorphicProps, +} from "../polymorphic"; +import { type CollectionItemWithRef, createRegisterId } from "../primitives"; +import { useRatingGroupContext } from "./rating-group-context"; +import { + RatingGroupItemContext, + type RatingGroupItemContextValue, + type RatingGroupItemDataSet, +} from "./rating-group-item-context"; +import { createDomCollectionItem } from "../primitives/create-dom-collection"; +import { getEventPoint, getRelativePoint } from "./utils"; +import { useLocale } from "../i18n"; + +export interface RatingGroupItemOptions {} + +export interface RatingGroupItemCommonProps< + T extends HTMLElement = HTMLElement, +> { + id: string; + ref: T | ((el: T) => void); + "aria-labelledby": string | undefined; + "aria-describedby": string | undefined; + "aria-label"?: string; + onClick: JSX.EventHandlerUnion; + onKeyDown: JSX.EventHandlerUnion; + onPointerMove: JSX.EventHandlerUnion; +} + +export interface RatingGroupItemRenderProps + extends RatingGroupItemCommonProps, + RatingGroupItemDataSet { + role: "radio"; + tabIndex: number | undefined; + "aria-required": boolean | undefined; + "aria-disabled": boolean | undefined; + "aria-readonly": boolean | undefined; + "aria-checked": boolean; +} + +export type RatingGroupItemProps< + T extends ValidComponent | HTMLElement = HTMLElement, +> = RatingGroupItemOptions & Partial>>; + +export function RatingGroupItem( + props: PolymorphicProps>, +) { + let ref: HTMLElement | undefined; + + const formControlContext = useFormControlContext(); + const ratingGroupContext = useRatingGroupContext(); + + const defaultId = `${formControlContext.generateId("item")}-${createUniqueId()}`; + + const mergedProps = mergeDefaultProps( + { + id: defaultId, + }, + props as RatingGroupItemProps, + ); + + const [local, others] = splitProps(mergedProps, [ + "ref", + "aria-labelledby", + "aria-describedby", + "onClick", + "onKeyDown", + "onPointerMove", + ]); + + createDomCollectionItem({ + getItem: () => ({ + ref: () => ref, + disabled: formControlContext.isDisabled()!, + key: others.id, + textValue: "", + type: "item", + }), + }); + + const ariaLabelledBy = () => { + return ( + [ + local["aria-labelledby"], + labelId(), + local["aria-labelledby"] != null && others["aria-label"] != null + ? others.id + : undefined, + ] + .filter(Boolean) + .join(" ") || undefined + ); + }; + + const ariaDescribedBy = () => { + return ( + [ + local["aria-describedby"], + descriptionId(), + ratingGroupContext.ariaDescribedBy(), + ] + .filter(Boolean) + .join(" ") || undefined + ); + }; + + const { direction } = useLocale(); + const isLTR = () => direction() === "ltr"; + + const [labelId, setLabelId] = createSignal(); + const [descriptionId, setDescriptionId] = createSignal(); + + const index = () => + ref ? ratingGroupContext.items().findIndex((v) => v.ref() === ref) : -1; + const [value, setValue] = createSignal(); + const newValue = () => + ratingGroupContext.isHovering() + ? ratingGroupContext.hoveredValue()! + : ratingGroupContext.value()!; + const equal = () => Math.ceil(newValue()!) === value(); + const highlighted = () => value()! <= newValue()! || equal(); + const half = () => equal() && Math.abs(newValue()! - value()!) === 0.5; + + onMount(() => { + setValue( + direction() === "ltr" + ? index() + 1 + : ratingGroupContext.items().length - index(), + ); + }); + + const tabIndex = () => { + if (formControlContext.isDisabled()) return undefined; + if (formControlContext.isReadOnly()) equal() ? 0 : undefined; + return equal() ? 0 : -1; + }; + + const focusItem = (index: number) => + ( + ratingGroupContext.items()[Math.round(index)].ref() as HTMLElement + ).focus(); + + const setPrevValue = () => { + const factor = ratingGroupContext.allowHalf() ? 0.5 : 1; + const value = Math.max(0, ratingGroupContext.value()! - factor); + ratingGroupContext.setValue(value); + focusItem(Math.max(value - 1, 0)); + }; + + const setNextValue = () => { + const factor = ratingGroupContext.allowHalf() ? 0.5 : 1; + const value = Math.min( + ratingGroupContext.items().length, + (ratingGroupContext.value() === -1 ? 0 : ratingGroupContext.value())! + + factor, + ); + ratingGroupContext.setValue(value); + focusItem(value - 1); + }; + + const onClick: JSX.EventHandlerUnion = (e) => { + callHandler(e, local.onClick); + + ratingGroupContext.setValue(newValue()); + focusItem(newValue() - 1); + ratingGroupContext.setHoveredValue(-1); + }; + + const onPointerMove: JSX.EventHandlerUnion = (e) => { + if (formControlContext.isDisabled() || formControlContext.isReadOnly()) + return; + callHandler(e, local.onPointerMove); + + const point = getEventPoint(e); + const relativePoint = getRelativePoint(point, e.currentTarget); + const percentX = relativePoint.getPercentValue({ + orientation: ratingGroupContext.orientation(), + dir: direction(), + }); + const isMidway = percentX < 0.5; + const half = ratingGroupContext.allowHalf() && isMidway; + const factor = half ? 0.5 : 0; + ratingGroupContext.setHoveredValue(value()! - factor); + }; + + const onKeyDown: JSX.EventHandlerUnion = (e) => { + callHandler(e, local.onKeyDown); + + switch (e.key) { + case EventKey.ArrowLeft: + case EventKey.ArrowUp: + e.preventDefault(); + if (isLTR()) { + setPrevValue(); + } else { + setNextValue(); + } + break; + case EventKey.ArrowRight: + case EventKey.ArrowDown: + e.preventDefault(); + if (isLTR()) { + setNextValue(); + } else { + setPrevValue(); + } + break; + case EventKey.Space: + e.preventDefault(); + ratingGroupContext.setValue(newValue()!); + // ratingGroupContext.setHoveredValue(-1); + break; + case EventKey.Home: + e.preventDefault(); + ratingGroupContext.setValue(1); + break; + case EventKey.End: + e.preventDefault(); + ratingGroupContext.setValue(ratingGroupContext.items().length); + break; + } + if (e.key === EventKey.Space) { + ratingGroupContext.setValue(newValue()!); + } + }; + + const dataset: Accessor = createMemo(() => ({ + ...formControlContext.dataset(), + "data-checked": equal() ? "" : undefined, + "data-half": half() ? "" : undefined, + "data-highlighted": highlighted() ? "" : undefined, + })); + + const context: RatingGroupItemContextValue = { + state: { highlighted, half }, + dataset, + generateId: createGenerateId(() => others.id!), + itemId: () => others.id, + registerLabel: createRegisterId(setLabelId), + registerDescription: createRegisterId(setDescriptionId), + }; + + return ( + + + as="div" + ref={mergeRefs((el) => (ref = el), local.ref)} + role="radio" + tabIndex={tabIndex()} + aria-checked={equal()} + aria-required={formControlContext.isRequired() || undefined} + aria-disabled={formControlContext.isDisabled() || undefined} + aria-readonly={formControlContext.isReadOnly() || undefined} + aria-labelledby={ariaLabelledBy()} + aria-describedby={ariaDescribedBy()} + onClick={onClick} + onPointerMove={onPointerMove} + onKeyDown={onKeyDown} + {...dataset()} + {...others} + /> + + ); +} diff --git a/packages/core/src/rating-group/rating-group-label.tsx b/packages/core/src/rating-group/rating-group-label.tsx new file mode 100644 index 00000000..dd9ae788 --- /dev/null +++ b/packages/core/src/rating-group/rating-group-label.tsx @@ -0,0 +1,29 @@ +import type { Component, ValidComponent } from "solid-js"; + +import { FormControlLabel } from "../form-control"; +import type { ElementOf, PolymorphicProps } from "../polymorphic"; + +export interface RatingGroupLabelOptions {} + +export interface RatingGroupLabelCommonProps< + T extends HTMLElement = HTMLElement, +> {} + +export interface RatingGroupLabelRenderProps + extends RatingGroupLabelCommonProps {} + +export type RatingGroupLabelProps< + T extends ValidComponent | HTMLElement = HTMLElement, +> = RatingGroupLabelOptions & + Partial>>; + +export function RatingGroupLabel( + props: PolymorphicProps>, +) { + return ( + > + as="span" + {...(props as RatingGroupLabelProps)} + /> + ); +} diff --git a/packages/core/src/rating-group/rating-group-root.tsx b/packages/core/src/rating-group/rating-group-root.tsx new file mode 100644 index 00000000..154e0c0a --- /dev/null +++ b/packages/core/src/rating-group/rating-group-root.tsx @@ -0,0 +1,223 @@ +import { + type Orientation, + type ValidationState, + access, + mergeDefaultProps, + mergeRefs, +} from "@kobalte/utils"; +import { + type ValidComponent, + createSignal, + createUniqueId, + splitProps, +} from "solid-js"; + +import { + FORM_CONTROL_PROP_NAMES, + FormControlContext, + type FormControlDataSet, + createFormControl, +} from "../form-control"; +import { + type ElementOf, + Polymorphic, + type PolymorphicProps, +} from "../polymorphic"; +import { + type CollectionItemWithRef, + createControllableSignal, + createFormResetListener, +} from "../primitives"; +import { + RatingGroupContext, + type RatingGroupContextValue, +} from "./rating-group-context"; +import { createDomCollection } from "../primitives/create-dom-collection"; + +export interface RatingGroupRootOptions { + /** The current rating value. */ + value?: number; + + /** + * The initial value of the rating group when it is first rendered. + * Use when you do not need to control the state of the rating group. + */ + defaultValue?: number; + + /** Event handler called when the value changes. */ + onChange?: (value: number) => void; + + /** Whether to allow half ratings. */ + allowHalf?: boolean; + + /** The axis the rating group items should align with. */ + orientation?: Orientation; + + /** + * A unique identifier for the component. + * The id is used to generate id attributes for nested components. + * If no id prop is provided, a generated id will be used. + */ + id?: string; + + /** + * The name of the rating group. + * Submitted with its owning form as part of a name/value pair. + */ + name?: string; + + /** Whether the rating group should display its "valid" or "invalid" visual styling. */ + validationState?: ValidationState; + + /** Whether the user must select an item before the owning form can be submitted. */ + required?: boolean; + + /** Whether the rating group is disabled. */ + disabled?: boolean; + + /** Whether the rating group is read only. */ + readOnly?: boolean; +} + +export interface RatingGroupRootCommonProps< + T extends HTMLElement = HTMLElement, +> { + id: string; + ref: T | ((el: T) => void); + "aria-labelledby": string | undefined; + "aria-describedby": string | undefined; + "aria-label"?: string; +} + +export interface RatingGroupRootRenderProps + extends RatingGroupRootCommonProps, + FormControlDataSet { + role: "radiogroup"; + "aria-invalid": boolean | undefined; + "aria-required": boolean | undefined; + "aria-disabled": boolean | undefined; + "aria-readonly": boolean | undefined; + "aria-orientation": Orientation | undefined; +} + +export type RatingGroupRootProps< + T extends ValidComponent | HTMLElement = HTMLElement, +> = RatingGroupRootOptions & Partial>>; + +export function RatingGroupRoot( + props: PolymorphicProps>, +) { + let ref: HTMLElement | undefined; + + const defaultId = `ratinggroup-${createUniqueId()}`; + + const mergedProps = mergeDefaultProps( + { + id: defaultId, + orientation: "horizontal", + }, + props as RatingGroupRootProps, + ); + + const [local, formControlProps, others] = splitProps( + mergedProps, + [ + "ref", + "value", + "defaultValue", + "onChange", + "allowHalf", + "orientation", + "aria-labelledby", + "aria-describedby", + ], + FORM_CONTROL_PROP_NAMES, + ); + + const [items, setItems] = createSignal([]); + const { DomCollectionProvider } = createDomCollection({ + items, + onItemsChange: setItems, + }); + + const [hoveredValue, setHoveredValue] = createSignal(-1); + + const [value, setValue] = createControllableSignal({ + value: () => local.value, + defaultValue: () => local.defaultValue ?? 0, + onChange: (value) => local.onChange?.(value), + }); + + const { formControlContext } = createFormControl(formControlProps); + + createFormResetListener( + () => ref, + () => setValue(local.defaultValue!), + ); + + const ariaLabelledBy = () => { + return formControlContext.getAriaLabelledBy( + access(formControlProps.id), + others["aria-label"], + local["aria-labelledby"], + ); + }; + + const ariaDescribedBy = () => { + return formControlContext.getAriaDescribedBy(local["aria-describedby"]); + }; + + const context: RatingGroupContextValue = { + value, + setValue: (newValue) => { + if (formControlContext.isReadOnly() || formControlContext.isDisabled()) { + return; + } + + setValue(newValue); + + // Sync all radio input checked state in the group with the selected value. + // This is necessary because checked state might be out of sync + // (ex: when using controlled radio-group). + if (ref) + for (const el of ref.querySelectorAll("[role='radio']")) { + const radio = el as HTMLInputElement; + radio.checked = Number(radio.value) === value(); + } + }, + allowHalf: () => local.allowHalf, + orientation: () => local.orientation!, + hoveredValue, + setHoveredValue, + isHovering: () => hoveredValue() > -1, + ariaDescribedBy, + items, + setItems, + }; + + return ( + + + + + as="div" + ref={mergeRefs((el) => (ref = el), local.ref)} + role="radiogroup" + id={access(formControlProps.id)!} + aria-invalid={ + formControlContext.validationState() === "invalid" || undefined + } + aria-required={formControlContext.isRequired() || undefined} + aria-disabled={formControlContext.isDisabled() || undefined} + aria-readonly={formControlContext.isReadOnly() || undefined} + aria-orientation={local.orientation} + aria-labelledby={ariaLabelledBy()} + aria-describedby={ariaDescribedBy()} + {...formControlContext.dataset()} + {...others} + /> + + + + ); +} diff --git a/packages/core/src/rating-group/utils.ts b/packages/core/src/rating-group/utils.ts new file mode 100644 index 00000000..63abe159 --- /dev/null +++ b/packages/core/src/rating-group/utils.ts @@ -0,0 +1,58 @@ +type PointType = "page" | "client"; +type AnyPointerEvent = MouseEvent | TouchEvent | PointerEvent; +type Point = { + x: number; + y: number; +}; +type PercentValueOptions = { + inverted?: boolean | { x?: boolean; y?: boolean } | undefined; + dir?: "ltr" | "rtl" | undefined; + orientation?: "vertical" | "horizontal" | undefined; +}; + +function clamp(value: number) { + return Math.max(0, Math.min(1, value)); +} + +function pointFromTouch(e: TouchEvent, type: PointType = "client") { + const point = e.touches[0] || e.changedTouches[0]; + return { x: point[`${type}X`], y: point[`${type}Y`] }; +} + +function pointFromMouse( + point: MouseEvent | PointerEvent, + type: PointType = "client", +) { + return { x: point[`${type}X`], y: point[`${type}Y`] }; +} + +const isTouchEvent = (event: AnyPointerEvent): event is TouchEvent => + "touches" in event && event.touches.length > 0; + +export function getEventPoint(event: any, type: PointType = "client") { + return isTouchEvent(event) + ? pointFromTouch(event, type) + : pointFromMouse(event, type); +} + +export function getRelativePoint(point: Point, element: HTMLElement) { + const { left, top, width, height } = element.getBoundingClientRect(); + + const offset = { x: point.x - left, y: point.y - top }; + const percent = { x: clamp(offset.x / width), y: clamp(offset.y / height) }; + + function getPercentValue(options: PercentValueOptions = {}) { + const { dir = "ltr", orientation = "horizontal", inverted } = options; + + const invertX = typeof inverted === "object" ? inverted.x : inverted; + const invertY = typeof inverted === "object" ? inverted.y : inverted; + + if (orientation === "horizontal") { + return dir === "rtl" || invertX ? 1 - percent.x : percent.x; + } + + return invertY ? 1 - percent.y : percent.y; + } + + return { offset, percent, getPercentValue }; +} From fc2cf2f65f2f1f44d714cb6777592e34f648a4bf Mon Sep 17 00:00:00 2001 From: HBS999 Date: Mon, 4 Nov 2024 14:55:30 +0300 Subject: [PATCH 02/10] chore: remove comment --- packages/core/src/rating-group/rating-group-item.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/rating-group/rating-group-item.tsx b/packages/core/src/rating-group/rating-group-item.tsx index 3d75ddaa..bf51c444 100644 --- a/packages/core/src/rating-group/rating-group-item.tsx +++ b/packages/core/src/rating-group/rating-group-item.tsx @@ -230,7 +230,6 @@ export function RatingGroupItem( case EventKey.Space: e.preventDefault(); ratingGroupContext.setValue(newValue()!); - // ratingGroupContext.setHoveredValue(-1); break; case EventKey.Home: e.preventDefault(); From 3d9ce3ba2e81a14ff72d436e10f82705445f043d Mon Sep 17 00:00:00 2001 From: HBS999 Date: Mon, 4 Nov 2024 15:24:33 +0300 Subject: [PATCH 03/10] chore: resolve conflicts --- apps/docs/src/routes/docs/core.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/docs/src/routes/docs/core.tsx b/apps/docs/src/routes/docs/core.tsx index 2af9a14d..af15ab45 100644 --- a/apps/docs/src/routes/docs/core.tsx +++ b/apps/docs/src/routes/docs/core.tsx @@ -165,6 +165,11 @@ const CORE_NAV_SECTIONS: NavSection[] = [ href: "/docs/core/components/rating-group", status: "new", }, + { + title: "Search", + href: "/docs/core/components/search", + status: "new", + }, { title: "Select", href: "/docs/core/components/select", From 78bcb80710e33fd45cb6ee8b1d0f7bd2971516d0 Mon Sep 17 00:00:00 2001 From: HBS999 Date: Mon, 4 Nov 2024 15:38:31 +0300 Subject: [PATCH 04/10] chore: format --- apps/docs/src/examples/rating-group.tsx | 2 +- packages/core/src/rating-group/index.tsx | 14 +++++++------- .../core/src/rating-group/rating-group-context.tsx | 4 ++-- .../core/src/rating-group/rating-group-control.tsx | 3 +-- .../src/rating-group/rating-group-item-label.tsx | 2 +- .../core/src/rating-group/rating-group-item.tsx | 7 +++---- .../core/src/rating-group/rating-group-root.tsx | 2 +- 7 files changed, 16 insertions(+), 18 deletions(-) diff --git a/apps/docs/src/examples/rating-group.tsx b/apps/docs/src/examples/rating-group.tsx index bf28a075..e3dcba51 100644 --- a/apps/docs/src/examples/rating-group.tsx +++ b/apps/docs/src/examples/rating-group.tsx @@ -1,5 +1,5 @@ -import { RatingGroup } from "../../../../packages/core/src/rating-group"; import { Index, createSignal } from "solid-js"; +import { RatingGroup } from "../../../../packages/core/src/rating-group"; import style from "./rating-group.module.css"; diff --git a/packages/core/src/rating-group/index.tsx b/packages/core/src/rating-group/index.tsx index e4ba35c4..35c1cfb2 100644 --- a/packages/core/src/rating-group/index.tsx +++ b/packages/core/src/rating-group/index.tsx @@ -22,6 +22,13 @@ import { RatingGroupHiddenInput as HiddenInput, type RatingGroupHiddenInputProps, } from "./rating-group-hidden-input"; +import { + RatingGroupItem as Item, + type RatingGroupItemCommonProps, + type RatingGroupItemOptions, + type RatingGroupItemProps, + type RatingGroupItemRenderProps, +} from "./rating-group-item"; import { RatingGroupItemControl as ItemControl, type RatingGroupItemControlCommonProps, @@ -43,13 +50,6 @@ import { type RatingGroupItemLabelProps, type RatingGroupItemLabelRenderProps, } from "./rating-group-item-label"; -import { - RatingGroupItem as Item, - type RatingGroupItemCommonProps, - type RatingGroupItemOptions, - type RatingGroupItemProps, - type RatingGroupItemRenderProps, -} from "./rating-group-item"; import { RatingGroupLabel as Label, type RatingGroupLabelCommonProps, diff --git a/packages/core/src/rating-group/rating-group-context.tsx b/packages/core/src/rating-group/rating-group-context.tsx index 64459e18..5ecdd19f 100644 --- a/packages/core/src/rating-group/rating-group-context.tsx +++ b/packages/core/src/rating-group/rating-group-context.tsx @@ -1,11 +1,11 @@ +import type { Orientation } from "@kobalte/utils"; import { type Accessor, - createContext, type Setter, + createContext, useContext, } from "solid-js"; import type { CollectionItemWithRef } from "../primitives"; -import type { Orientation } from "@kobalte/utils"; export interface RatingGroupContextValue { value: Accessor; diff --git a/packages/core/src/rating-group/rating-group-control.tsx b/packages/core/src/rating-group/rating-group-control.tsx index d6db72ad..fe1f097e 100644 --- a/packages/core/src/rating-group/rating-group-control.tsx +++ b/packages/core/src/rating-group/rating-group-control.tsx @@ -1,12 +1,11 @@ import { callHandler, mergeDefaultProps } from "@kobalte/utils"; import { type JSX, type ValidComponent, splitProps } from "solid-js"; - +import { useFormControlContext } from "../form-control"; import { type ElementOf, Polymorphic, type PolymorphicProps, } from "../polymorphic"; -import { useFormControlContext } from "../form-control"; import { useRatingGroupContext } from "./rating-group-context"; export interface RatingGroupControlOptions {} diff --git a/packages/core/src/rating-group/rating-group-item-label.tsx b/packages/core/src/rating-group/rating-group-item-label.tsx index 1bae7080..bb5c1921 100644 --- a/packages/core/src/rating-group/rating-group-item-label.tsx +++ b/packages/core/src/rating-group/rating-group-item-label.tsx @@ -1,4 +1,5 @@ import { mergeDefaultProps, visuallyHiddenStyles } from "@kobalte/utils"; +import { combineStyle } from "@solid-primitives/props"; import { type JSX, type ValidComponent, @@ -15,7 +16,6 @@ import { type RatingGroupItemDataSet, useRatingGroupItemContext, } from "./rating-group-item-context"; -import { combineStyle } from "@solid-primitives/props"; export interface RatingGroupItemLabelOptions {} diff --git a/packages/core/src/rating-group/rating-group-item.tsx b/packages/core/src/rating-group/rating-group-item.tsx index bf51c444..e82208f1 100644 --- a/packages/core/src/rating-group/rating-group-item.tsx +++ b/packages/core/src/rating-group/rating-group-item.tsx @@ -1,7 +1,7 @@ import { + EventKey, callHandler, createGenerateId, - EventKey, mergeDefaultProps, mergeRefs, } from "@kobalte/utils"; @@ -9,7 +9,6 @@ import { type Accessor, type JSX, type ValidComponent, - createEffect, createMemo, createSignal, createUniqueId, @@ -18,21 +17,21 @@ import { } from "solid-js"; import { useFormControlContext } from "../form-control"; +import { useLocale } from "../i18n"; import { type ElementOf, Polymorphic, type PolymorphicProps, } from "../polymorphic"; import { type CollectionItemWithRef, createRegisterId } from "../primitives"; +import { createDomCollectionItem } from "../primitives/create-dom-collection"; import { useRatingGroupContext } from "./rating-group-context"; import { RatingGroupItemContext, type RatingGroupItemContextValue, type RatingGroupItemDataSet, } from "./rating-group-item-context"; -import { createDomCollectionItem } from "../primitives/create-dom-collection"; import { getEventPoint, getRelativePoint } from "./utils"; -import { useLocale } from "../i18n"; export interface RatingGroupItemOptions {} diff --git a/packages/core/src/rating-group/rating-group-root.tsx b/packages/core/src/rating-group/rating-group-root.tsx index 154e0c0a..c1b452db 100644 --- a/packages/core/src/rating-group/rating-group-root.tsx +++ b/packages/core/src/rating-group/rating-group-root.tsx @@ -28,11 +28,11 @@ import { createControllableSignal, createFormResetListener, } from "../primitives"; +import { createDomCollection } from "../primitives/create-dom-collection"; import { RatingGroupContext, type RatingGroupContextValue, } from "./rating-group-context"; -import { createDomCollection } from "../primitives/create-dom-collection"; export interface RatingGroupRootOptions { /** The current rating value. */ From f71334d517556caf022637930e5ee1f6bc19cb66 Mon Sep 17 00:00:00 2001 From: HBS999 Date: Mon, 4 Nov 2024 15:44:38 +0300 Subject: [PATCH 05/10] chore: format --- apps/docs/src/examples/rating-group.module.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/src/examples/rating-group.module.css b/apps/docs/src/examples/rating-group.module.css index e7430e1c..30b84be4 100644 --- a/apps/docs/src/examples/rating-group.module.css +++ b/apps/docs/src/examples/rating-group.module.css @@ -46,11 +46,11 @@ } .half-star-icon > path + path { - fill: hsl(240 6% 90%); + fill: hsl(240 6% 90%); } [data-kb-theme="dark"] .half-star-icon > path + path { - fill: hsl(240 5% 26%); + fill: hsl(240 5% 26%); } [data-kb-theme="dark"] .rating-group__label { From 546f78f1245c3082d72043b6be9e07a4de71420f Mon Sep 17 00:00:00 2001 From: HBS999 Date: Mon, 4 Nov 2024 15:59:07 +0300 Subject: [PATCH 06/10] chore: format --- .../docs/core/components/rating-group.mdx | 171 +++++++++--------- 1 file changed, 85 insertions(+), 86 deletions(-) diff --git a/apps/docs/src/routes/docs/core/components/rating-group.mdx b/apps/docs/src/routes/docs/core/components/rating-group.mdx index 20b4b80f..cbb90573 100644 --- a/apps/docs/src/routes/docs/core/components/rating-group.mdx +++ b/apps/docs/src/routes/docs/core/components/rating-group.mdx @@ -3,7 +3,7 @@ import { BasicExample, DefaultValueExample, ControlledExample, - HalfRatingsExample, + HalfRatingsExample, DescriptionExample, ErrorMessageExample, HTMLFormExample, @@ -81,19 +81,19 @@ The rating item consists of: function App() { return ( - Rate Us: - - - {_ => ( - - - - - - )} - - - + Rate Us: + + + {_ => ( + + + + + + )} + + + ); } ``` @@ -107,40 +107,41 @@ The rating item consists of: gap: 8px; } -.rating-group__label { - color: hsl(240 6% 10%); - font-size: 14px; - font-weight: 500; - user-select: none; +.rating-group\_\_label { +color: hsl(240 6% 10%); +font-size: 14px; +font-weight: 500; +user-select: none; } -.rating-group__description { - color: hsl(240 5% 26%); - font-size: 12px; - user-select: none; +.rating-group\_\_description { +color: hsl(240 5% 26%); +font-size: 12px; +user-select: none; } -.rating-group__error-message { - color: hsl(0 72% 51%); - font-size: 12px; - user-select: none; +.rating-group\_\_error-message { +color: hsl(0 72% 51%); +font-size: 12px; +user-select: none; } -.rating-group__control { - display: flex; - gap: 4px; +.rating-group\_\_control { +display: flex; +gap: 4px; } .rating-group-item { - cursor: pointer; - fill: hsl(240 6% 90%); - transition: fill 200ms cubic-bezier(0.2, 0, 0, 1); +cursor: pointer; +fill: hsl(240 6% 90%); +transition: fill 200ms cubic-bezier(0.2, 0, 0, 1); } .rating-group-item[data-highlighted] { - fill: hsl(200 98% 39%); +fill: hsl(200 98% 39%); } -``` + +```` {/* */} @@ -170,7 +171,7 @@ An initial, uncontrolled value can be provided using the `defaultValue` prop. -``` +```` ### Controlled value @@ -255,7 +256,7 @@ The `RatingGroup.Description` component can be used to associate additional help )} - Rate your experience with us. + Rate your experience with us. ``` @@ -293,9 +294,7 @@ function ErrorMessageExample() { )} - - Please select a rating between 1 and 5. - + Please select a rating between 1 and 5. ); } @@ -329,7 +328,7 @@ function HTMLFormExample() { )} - +
@@ -346,26 +345,26 @@ function HTMLFormExample() { `RatingGroup` is equivalent to the `Root` import from `@kobalte/core/Rating-group`. -| Prop | Description | -| :-------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| value | `number`
The current rating value. | -| defaultValue | `number`
The initial value of the rating group when it is first rendered. Use when you do not need to control the state of the rating group.| -| onChange | `(value: number) => void`
Event handler called when the value changes. | -| allowHalf | `boolean`
Whether to allow half ratings. | -| orientation | `'horizontal' \| 'vertical'`
The axis the rating group items should align with. | -| name | `string`
The name of the rating group. Submitted with its owning form as part of a name/value pair. | -| validationState | `'valid' \| 'invalid'`
Whether the rating group should display its "valid" or "invalid" visual styling. | -| required | `boolean`
Whether the user must select an item before the owning form can be submitted. | -| disabled | `boolean`
Whether the rating group is disabled. | -| readOnly | `boolean`
Whether the rating group items can be selected but not changed by the user. | - -| Data attribute | Description | -| :------------- | :------------------------------------------------------------------------------------------- | -| data-valid | Present when the rating group is valid according to the validation rules. | -| data-invalid | Present when the rating group is invalid according to the validation rules. | +| Prop | Description | +| :-------------- | :------------------------------------------------------------------------------------------------------------------------------------------------- | +| value | `number`
The current rating value. | +| defaultValue | `number`
The initial value of the rating group when it is first rendered. Use when you do not need to control the state of the rating group. | +| onChange | `(value: number) => void`
Event handler called when the value changes. | +| allowHalf | `boolean`
Whether to allow half ratings. | +| orientation | `'horizontal' \| 'vertical'`
The axis the rating group items should align with. | +| name | `string`
The name of the rating group. Submitted with its owning form as part of a name/value pair. | +| validationState | `'valid' \| 'invalid'`
Whether the rating group should display its "valid" or "invalid" visual styling. | +| required | `boolean`
Whether the user must select an item before the owning form can be submitted. | +| disabled | `boolean`
Whether the rating group is disabled. | +| readOnly | `boolean`
Whether the rating group items can be selected but not changed by the user. | + +| Data attribute | Description | +| :------------- | :--------------------------------------------------------------------------------------------- | +| data-valid | Present when the rating group is valid according to the validation rules. | +| data-invalid | Present when the rating group is invalid according to the validation rules. | | data-required | Present when the user must select a rating group item before the owning form can be submitted. | -| data-disabled | Present when the rating group is disabled. | -| data-readonly | Present when the rating group is read only. | +| data-disabled | Present when the rating group is disabled. | +| data-readonly | Present when the rating group is read only. | `RatingGroup.Label`, `RatingGroup.Description` and `RatingGroup.ErrorMesssage` shares the same data-attributes. @@ -377,28 +376,28 @@ function HTMLFormExample() { ### RatingGroup.ItemControl -| Render Prop | Description | -| :-------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| half | `Accessor`
Whether the rating item is half. -| highlighted | `Accessor`
Whether the rating item is highlighted. | - -| Data attribute | Description | -| :------------- | :-------------------------------------------------------------------------------- | -| data-valid | Present when the parent rating group is valid according to the validation rules. | -| data-invalid | Present when the parent rating group is invalid according to the validation rules. | -| data-required | Present when the parent rating group is required. | -| data-disabled | Present when the parent rating group is disabled. | -| data-readonly | Present when the parent rating group is read only. | -| data-checked | Present when the rating is checked. | -| data-half | Present when the rating is half. | -| data-highlighted | Present when the rating is highlighted. | +| Render Prop | Description | +| :---------- | :---------------------------------------------------------------- | +| half | `Accessor`
Whether the rating item is half. | +| highlighted | `Accessor`
Whether the rating item is highlighted. | + +| Data attribute | Description | +| :--------------- | :--------------------------------------------------------------------------------- | +| data-valid | Present when the parent rating group is valid according to the validation rules. | +| data-invalid | Present when the parent rating group is invalid according to the validation rules. | +| data-required | Present when the parent rating group is required. | +| data-disabled | Present when the parent rating group is disabled. | +| data-readonly | Present when the parent rating group is read only. | +| data-checked | Present when the rating is checked. | +| data-half | Present when the rating is half. | +| data-highlighted | Present when the rating is highlighted. | `RatingGroup.ItemLabel` and `RatingGroup.ItemDescription` share the same data-attributes. ## Rendered elements -| Component | Default rendered element | -| :--------------------------- | :----------------------- | +| Component | Default rendered element | +| :---------------------------- | :----------------------- | | `RatingGroup` | `div` | | `RatingGroup.Control` | `div` | | `RatingGroup.Label` | `span` | @@ -414,12 +413,12 @@ function HTMLFormExample() { ### Keyboard Interactions -| Key | Description | -| :-------------------- | :------------------------------------------------------------------------------------- | -| ArrowDown | Moves focus to the next item, increasing the rating value based on the `allowHalf` property. | -| ArrowRight | Moves focus to the next item, increasing the rating value based on the `allowHalf` property. | -| ArrowUp | Moves focus to the previous item, decreasing the rating value based on the `allowHalf` property. | -| ArrowLeft | Moves focus to the previous item, decreasing the rating value based on the `allowHalf` property. | -| Space | Selects the focused item in the rating group. | -| Home | Sets the value of the rating group to 1. | -| End | Sets the value of the rating group to the maximum value. | +| Key | Description | +| :-------------------- | :----------------------------------------------------------------------------------------------- | +| ArrowDown | Moves focus to the next item, increasing the rating value based on the `allowHalf` property. | +| ArrowRight | Moves focus to the next item, increasing the rating value based on the `allowHalf` property. | +| ArrowUp | Moves focus to the previous item, decreasing the rating value based on the `allowHalf` property. | +| ArrowLeft | Moves focus to the previous item, decreasing the rating value based on the `allowHalf` property. | +| Space | Selects the focused item in the rating group. | +| Home | Sets the value of the rating group to 1. | +| End | Sets the value of the rating group to the maximum value. | From ea8a123d3a96a310d81ce271f699f206e53ce5d4 Mon Sep 17 00:00:00 2001 From: HBS999 Date: Sat, 9 Nov 2024 17:39:55 +0300 Subject: [PATCH 07/10] chore: attribution and docs nitpicks --- .../src/routes/docs/core/components/rating-group.mdx | 3 ++- packages/core/src/rating-group/rating-group-root.tsx | 9 --------- packages/core/src/rating-group/utils.ts | 8 ++++++++ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/docs/src/routes/docs/core/components/rating-group.mdx b/apps/docs/src/routes/docs/core/components/rating-group.mdx index cbb90573..ff596e93 100644 --- a/apps/docs/src/routes/docs/core/components/rating-group.mdx +++ b/apps/docs/src/routes/docs/core/components/rating-group.mdx @@ -23,6 +23,7 @@ import { Root, Label, ... } from "@kobalte/core/rating-group"; ## Features +- Precise ratings with half-value increments. - Syncs with form reset events. - Group and rating labeling support for assistive technology. - Can be controlled or uncontrolled. @@ -141,7 +142,7 @@ transition: fill 200ms cubic-bezier(0.2, 0, 0, 1); fill: hsl(200 98% 39%); } -```` +``` {/* */} diff --git a/packages/core/src/rating-group/rating-group-root.tsx b/packages/core/src/rating-group/rating-group-root.tsx index c1b452db..dff4c4d9 100644 --- a/packages/core/src/rating-group/rating-group-root.tsx +++ b/packages/core/src/rating-group/rating-group-root.tsx @@ -175,15 +175,6 @@ export function RatingGroupRoot( } setValue(newValue); - - // Sync all radio input checked state in the group with the selected value. - // This is necessary because checked state might be out of sync - // (ex: when using controlled radio-group). - if (ref) - for (const el of ref.querySelectorAll("[role='radio']")) { - const radio = el as HTMLInputElement; - radio.checked = Number(radio.value) === value(); - } }, allowHalf: () => local.allowHalf, orientation: () => local.orientation!, diff --git a/packages/core/src/rating-group/utils.ts b/packages/core/src/rating-group/utils.ts index 63abe159..bcee35c9 100644 --- a/packages/core/src/rating-group/utils.ts +++ b/packages/core/src/rating-group/utils.ts @@ -1,3 +1,11 @@ +/* + * Portions of this file are based on code from zag. + * MIT License, Copyright 2021 Chakra UI. + * + * Credits to the Chakra UI team: + * https://github.com/chakra-ui/zag/blob/87ebdd171d5e28fffe2cec7d0b0d5f5a68601963/packages/utilities/dom-event/src/get-event-point.ts + */ + type PointType = "page" | "client"; type AnyPointerEvent = MouseEvent | TouchEvent | PointerEvent; type Point = { From ba82c021323e0a28348e0f83330ff541abbcfc16 Mon Sep 17 00:00:00 2001 From: HBS999 Date: Sat, 9 Nov 2024 17:50:40 +0300 Subject: [PATCH 08/10] chore: format --- apps/docs/src/routes/docs/core/components/rating-group.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/src/routes/docs/core/components/rating-group.mdx b/apps/docs/src/routes/docs/core/components/rating-group.mdx index ff596e93..87094419 100644 --- a/apps/docs/src/routes/docs/core/components/rating-group.mdx +++ b/apps/docs/src/routes/docs/core/components/rating-group.mdx @@ -142,7 +142,7 @@ transition: fill 200ms cubic-bezier(0.2, 0, 0, 1); fill: hsl(200 98% 39%); } -``` +```` {/* */} From 19972a224c8062571e16912fc24ff32f1e82710c Mon Sep 17 00:00:00 2001 From: HBS999 Date: Tue, 12 Nov 2024 14:33:38 +0300 Subject: [PATCH 09/10] simplify --- apps/docs/src/examples/rating-group.tsx | 10 +--------- .../src/routes/docs/core/components/rating-group.mdx | 6 ++---- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/apps/docs/src/examples/rating-group.tsx b/apps/docs/src/examples/rating-group.tsx index e3dcba51..c7d7189c 100644 --- a/apps/docs/src/examples/rating-group.tsx +++ b/apps/docs/src/examples/rating-group.tsx @@ -77,15 +77,7 @@ export function HalfRatingsExample() { {(_) => ( - {(state) => - state.half() ? ( - - ) : state.highlighted() ? ( - - ) : ( - - ) - } + {(state) => (state.half() ? : )} )} diff --git a/apps/docs/src/routes/docs/core/components/rating-group.mdx b/apps/docs/src/routes/docs/core/components/rating-group.mdx index 87094419..7d467a9d 100644 --- a/apps/docs/src/routes/docs/core/components/rating-group.mdx +++ b/apps/docs/src/routes/docs/core/components/rating-group.mdx @@ -224,9 +224,7 @@ Allow 0.5 value steps by setting the `allowHalf` prop to true. {_ => ( - {state => - state.half() ? : state.highlighted() ? : - } + {(state) => (state.half() ? : )} )} @@ -344,7 +342,7 @@ function HTMLFormExample() { ### RatingGroup -`RatingGroup` is equivalent to the `Root` import from `@kobalte/core/Rating-group`. +`RatingGroup` is equivalent to the `Root` import from `@kobalte/core/rating-group`. | Prop | Description | | :-------------- | :------------------------------------------------------------------------------------------------------------------------------------------------- | From 1135ac2107ae1bfa523bc619b4565ef516ccd0a1 Mon Sep 17 00:00:00 2001 From: HBS999 Date: Tue, 12 Nov 2024 16:46:08 +0300 Subject: [PATCH 10/10] fix: clicking on ratings on mobile --- packages/core/src/rating-group/rating-group-item.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/core/src/rating-group/rating-group-item.tsx b/packages/core/src/rating-group/rating-group-item.tsx index e82208f1..945e8593 100644 --- a/packages/core/src/rating-group/rating-group-item.tsx +++ b/packages/core/src/rating-group/rating-group-item.tsx @@ -182,9 +182,13 @@ export function RatingGroupItem( const onClick: JSX.EventHandlerUnion = (e) => { callHandler(e, local.onClick); - ratingGroupContext.setValue(newValue()); - focusItem(newValue() - 1); + const value = + ratingGroupContext.hoveredValue() === -1 + ? index() + 1 + : ratingGroupContext.hoveredValue(); + ratingGroupContext.setValue(value); ratingGroupContext.setHoveredValue(-1); + focusItem(value - 1); }; const onPointerMove: JSX.EventHandlerUnion = (e) => { @@ -239,9 +243,6 @@ export function RatingGroupItem( ratingGroupContext.setValue(ratingGroupContext.items().length); break; } - if (e.key === EventKey.Space) { - ratingGroupContext.setValue(newValue()!); - } }; const dataset: Accessor = createMemo(() => ({