Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { QuestionIdOptions } from "./QuestionIdOptions";
import { InfoDetails } from "@formBuilder/components/shared/InfoDetails";
import { FileTypeOptions } from "./FileTypeOptions";
import { NumberFieldOptions } from "./NumberFieldOptions";
import { StarRatingSparkleOptions } from "./StarRatingSparkleOptions";

import { CopyItem } from "./CopyItem";
import { CustomRegexOptions } from "./CustomRegexOptions";
Expand Down Expand Up @@ -140,6 +141,7 @@ export const MoreDialog = () => {
setItem={setItem}
setIsValid={setIsNumberFieldOptionsValid}
/>
<StarRatingSparkleOptions item={item} setItem={setItem} />
<DynamicRowOptions item={item} setItem={setItem} />
<TextFieldOptions item={item} setItem={setItem} />
<CharacterLimitOptions item={item} setItem={setItem} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useTranslation } from "@i18n/client";
import { FormElement, FormElementTypes } from "@lib/types";

export const StarRatingSparkleOptions = ({
item,
setItem,
}: {
item: FormElement;
setItem: (item: FormElement) => void;
}) => {
const { t } = useTranslation("form-builder");

if (item.type !== FormElementTypes.starRating) return null;

const checked = item.properties.sparkleOnSelect ?? false;

return (
<section className="mb-4">
<div className="mb-2">
<h3>{t("moreDialog.starRatingSparkle.title")}</h3>
</div>
<div className="gc-input-checkbox">
<input
className="gc-input-checkbox__input"
id={`sparkle-${item.id}-id-modal`}
type="checkbox"
checked={checked}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setItem({
...item,
properties: {
...item.properties,
sparkleOnSelect: e.target.checked,
},
});
}}
/>
<label className="gc-checkbox-label" htmlFor={`sparkle-${item.id}-id-modal`}>
<span className="checkbox-label-text">
{t("moreDialog.starRatingSparkle.checkboxLabel")}
</span>
</label>
</div>
</section>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ShortAnswer, Options, SubOptions, RichText, SubElement } from "./elemen
import { ElementOption, FormElementWithIndex } from "@lib/types/form-builder-types";
import { useElementOptions } from "@lib/hooks/form-builder/useElementOptions";
import { ConditionalIndicator } from "@formBuilder/components/shared/conditionals/ConditionalIndicator";
import { StarRatingSelector } from "./elements/StarRatingSelector";
import { DateElement } from "./elements/DateElement";
import { DateFormat } from "@clientComponents/forms/FormattedDate/types";

Expand Down Expand Up @@ -119,6 +120,14 @@ export const SelectedElement = ({
);
}
break;
case FormElementTypes.starRating:
element = (
<>
<ShortAnswer>{t("addElementDialog.starRating.title")}</ShortAnswer>
<StarRatingSelector item={item} />
</>
);
break;
case "checkbox":
if (elIndex !== -1) {
element = <SubOptions item={item} renderIcon={() => <CheckBoxEmptyIcon />} />;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use client";
import React from "react";
import { useTranslation } from "@i18n/client";
import { useTemplateStore } from "@lib/store/useTemplateStore";
import { FormElementWithIndex } from "@lib/types/form-builder-types";

const DEFAULT_NUMBER_OF_STARS = 5;
const MIN_STARS = 3;
const MAX_STARS = 10;

interface StarRatingSelectorProps {
item: FormElementWithIndex;
}

export const StarRatingSelector = ({ item }: StarRatingSelectorProps) => {
const { t } = useTranslation("form-builder");
const { updateField } = useTemplateStore((s) => ({
updateField: s.updateField,
}));

const numberOfStars = item.properties.numberOfStars ?? DEFAULT_NUMBER_OF_STARS;

const options = Array.from({ length: MAX_STARS - MIN_STARS + 1 }, (_, i) => MIN_STARS + i);

return (
<div className="mt-4">
<label className="mb-2 block text-sm font-semibold" htmlFor={`star-count-${item.id}`}>
{t("addElementDialog.starRating.numberOfStars")}
</label>
<select
id={`star-count-${item.id}`}
className="gc-dropdown rounded border border-gray-400 px-3 py-2 text-sm"
value={numberOfStars}
onChange={(e) => {
updateField(
`form.elements[${item.index}].properties.numberOfStars`,
Number(e.target.value)
);
}}
>
{options.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"use client";

import { useTranslation } from "@i18n/client";
import { ExampleWrapper } from "./ExampleWrapper";

export const StarRating = () => {
const { t } = useTranslation("form-builder");

return (
<>
<h3 className="mb-0" data-testid="element-description-title">
{t("addElementDialog.starRating.title")}
</h3>
<p data-testid="element-description-text">{t("addElementDialog.starRating.description")}</p>

<ExampleWrapper>
<div>
<legend className="gcds-label" id="label-star-example">
{t("addElementDialog.starRating.exampleQuestion")}
</legend>
<div className="mt-2 flex gap-1 text-4xl">
<span className="text-yellow-400">★</span>
<span className="text-yellow-400">★</span>
<span className="text-yellow-400">★</span>
<span className="text-gray-300">★</span>
<span className="text-gray-300">★</span>
</div>
</div>
</ExampleWrapper>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ const Element = ({
FormElementTypes.formattedDate,
FormElementTypes.addressComplete,
FormElementTypes.fileInput,
FormElementTypes.starRating,
];

if (element.type === FormElementTypes.dynamicRow) {
Expand Down
218 changes: 218 additions & 0 deletions components/clientComponents/forms/StarRating/StarRating.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
"use client";
import React, { useCallback, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useField } from "formik";
import { ErrorMessage } from "@clientComponents/forms";
import { InputFieldProps } from "@lib/types";
import { useTranslation } from "@i18n/client";

interface StarRatingProps extends InputFieldProps {
numberOfStars?: number;
sparkleOnSelect?: boolean;
}

interface StarItemProps {
starValue: number;
inputId: string;
name: string;
required: boolean | undefined;
checked: boolean;
tabIndex: number;
ariaLabel: string;
active: boolean;
focused: boolean;
inputRef: (el: HTMLInputElement | null) => void;
onChange: () => void;
onFocus: (e: React.FocusEvent<HTMLInputElement>) => void;
onBlur: () => void;
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
sparkle: boolean;
onSparkleEnd: () => void;
}

const StarItem = React.memo(function StarItem({
starValue,
inputId,
name,
required,
checked,
tabIndex,
ariaLabel,
active,
focused,
inputRef,
onChange,
onFocus,
onBlur,
onKeyDown,
onMouseEnter,
onMouseLeave,
sparkle,
onSparkleEnd,
}: StarItemProps) {
return (
<React.Fragment>
<input
type="radio"
className="sr-only"
id={inputId}
name={name}
value={String(starValue)}
required={required}
checked={checked}
tabIndex={tabIndex}
aria-label={ariaLabel}
ref={inputRef}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>
<label
htmlFor={inputId}
className={`gc-star-rating__label relative cursor-pointer text-4xl leading-none select-none rounded${
focused ? "outline-blue-focus outline-[3px] outline-offset-2 outline-solid" : ""
}`}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{sparkle &&
[1, 2, 3, 4, 5, 6].map((n) => (
<span
key={n}
className={`gc-star-particle gc-star-particle--${n}`}
aria-hidden="true"
{...(n === 2 ? { onAnimationEnd: onSparkleEnd } : {})}
>
</span>
))}
<span
className={
active
? "gc-star-rating__star gc-star-rating__star--active text-yellow-400"
: "gc-star-rating__star text-gray-300"
}
aria-hidden="true"
>
</span>
</label>
</React.Fragment>
);
});

export const StarRating = (props: StarRatingProps): React.ReactElement => {
const { name, required, numberOfStars = 5, sparkleOnSelect = false, id, lang } = props;
const [field, meta, helpers] = useField(name);
const [hovered, setHovered] = useState<number | null>(null);
const [focused, setFocused] = useState<number | null>(null);
const [sparkleStar, setSparkleStar] = useState<number | null>(null);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const { t } = useTranslation("common", { lng: lang });

// Defer to client-side value after mount to avoid hydration mismatch.
// The save-progress feature restores values from localStorage on the client
// but the server renders without them causing a mismatch if used immediately.
// useSyncExternalStore returns the server snapshot (false) on SSR and the
// client snapshot (true) after hydration — a React two-pass pattern.
const isClient = useSyncExternalStore(
() => () => {},
() => true,
() => false
);

const currentValue = isClient && field.value ? Number(field.value) : 0;
const activeValue = hovered !== null ? hovered : currentValue;

// numberOfStars is a static prop — only recompute if it changes
const stars = useMemo(
() => Array.from({ length: numberOfStars }, (_, i) => i + 1),
[numberOfStars]
);

const getTabIndex = (starValue: number): number => {
if (currentValue > 0) return currentValue === starValue ? 0 : -1;
return starValue === 1 ? 0 : -1;
};

const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>, starValue: number) => {
const currentIndex = starValue - 1;
let newIndex: number;

switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
newIndex = (currentIndex + 1) % stars.length;
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
newIndex = (currentIndex - 1 + stars.length) % stars.length;
break;
default:
return;
}

const newValue = stars[newIndex];
helpers.setValue(String(newValue));
setHovered(newValue);
inputRefs.current[newIndex]?.focus();
},
[stars, helpers]
);

const errorId = meta.error ? `error-${id}` : undefined;

return (
<div className="gc-star-rating">
{meta.error && <ErrorMessage id={errorId}>{meta.error}</ErrorMessage>}
<div
className="gc-star-rating__stars flex gap-1"
role="radiogroup"
aria-labelledby={`label-${id}`}
aria-required={required || undefined}
aria-invalid={meta.error ? "true" : undefined}
aria-describedby={errorId}
>
{stars.map((starValue, index) => (
<StarItem
key={starValue}
starValue={starValue}
inputId={`${id}.${starValue - 1}`}
name={name}
required={required}
checked={isClient && field.value === String(starValue)}
tabIndex={getTabIndex(starValue)}
ariaLabel={t("starRating.starLabel", { count: starValue })}
active={activeValue >= starValue}
focused={focused === starValue}
inputRef={(el) => {
inputRefs.current[index] = el;
}}
sparkle={sparkleStar === starValue}
onSparkleEnd={() => setSparkleStar(null)}
onChange={() => {
helpers.setValue(String(starValue));
if (sparkleOnSelect) setSparkleStar(starValue);
}}
onFocus={(e) => {
setHovered(starValue);
if (e.currentTarget.matches(":focus-visible")) setFocused(starValue);
}}
onBlur={() => {
setHovered(null);
setFocused(null);
}}
onKeyDown={(e) => handleKeyDown(e, starValue)}
onMouseEnter={() => setHovered(starValue)}
onMouseLeave={() => setHovered(null)}
/>
))}
</div>
</div>
);
};
Loading
Loading