diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/components/dialogs/MoreDialog/MoreDialog.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/components/dialogs/MoreDialog/MoreDialog.tsx index 4e8cb05060..780003ae3c 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/components/dialogs/MoreDialog/MoreDialog.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/components/dialogs/MoreDialog/MoreDialog.tsx @@ -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"; @@ -140,6 +141,7 @@ export const MoreDialog = () => { setItem={setItem} setIsValid={setIsNumberFieldOptionsValid} /> + diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/components/dialogs/MoreDialog/StarRatingSparkleOptions.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/components/dialogs/MoreDialog/StarRatingSparkleOptions.tsx new file mode 100644 index 0000000000..61d2b901c0 --- /dev/null +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/components/dialogs/MoreDialog/StarRatingSparkleOptions.tsx @@ -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 ( +
+
+

{t("moreDialog.starRatingSparkle.title")}

+
+
+ ) => { + setItem({ + ...item, + properties: { + ...item.properties, + sparkleOnSelect: e.target.checked, + }, + }); + }} + /> + +
+
+ ); +}; diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/edit/components/SelectedElement.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/edit/components/SelectedElement.tsx index 040a637d2b..c3425b6286 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/edit/components/SelectedElement.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/edit/components/SelectedElement.tsx @@ -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"; @@ -119,6 +120,14 @@ export const SelectedElement = ({ ); } break; + case FormElementTypes.starRating: + element = ( + <> + {t("addElementDialog.starRating.title")} + + + ); + break; case "checkbox": if (elIndex !== -1) { element = } />; diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/edit/components/elements/StarRatingSelector.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/edit/components/elements/StarRatingSelector.tsx new file mode 100644 index 0000000000..764f25bbe8 --- /dev/null +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/edit/components/elements/StarRatingSelector.tsx @@ -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 ( +
+ + +
+ ); +}; diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/edit/components/elements/element-dialog/descriptions/StarRating.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/edit/components/elements/element-dialog/descriptions/StarRating.tsx new file mode 100644 index 0000000000..4255a54171 --- /dev/null +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/edit/components/elements/element-dialog/descriptions/StarRating.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useTranslation } from "@i18n/client"; +import { ExampleWrapper } from "./ExampleWrapper"; + +export const StarRating = () => { + const { t } = useTranslation("form-builder"); + + return ( + <> +

+ {t("addElementDialog.starRating.title")} +

+

{t("addElementDialog.starRating.description")}

+ + +
+ + {t("addElementDialog.starRating.exampleQuestion")} + +
+ + + + + +
+
+
+ + ); +}; diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/edit/translate/components/TranslateWithGroups.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/edit/translate/components/TranslateWithGroups.tsx index 81bac1fa8b..de37d168c7 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/edit/translate/components/TranslateWithGroups.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/edit/translate/components/TranslateWithGroups.tsx @@ -140,6 +140,7 @@ const Element = ({ FormElementTypes.formattedDate, FormElementTypes.addressComplete, FormElementTypes.fileInput, + FormElementTypes.starRating, ]; if (element.type === FormElementTypes.dynamicRow) { diff --git a/components/clientComponents/forms/StarRating/StarRating.tsx b/components/clientComponents/forms/StarRating/StarRating.tsx new file mode 100644 index 0000000000..5e80a4ee8d --- /dev/null +++ b/components/clientComponents/forms/StarRating/StarRating.tsx @@ -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) => void; + onBlur: () => void; + onKeyDown: (e: React.KeyboardEvent) => 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 ( + + + + + ); +}); + +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(null); + const [focused, setFocused] = useState(null); + const [sparkleStar, setSparkleStar] = useState(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, 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 ( +
+ {meta.error && {meta.error}} +
+ {stars.map((starValue, index) => ( + = 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)} + /> + ))} +
+
+ ); +}; diff --git a/components/clientComponents/forms/index.ts b/components/clientComponents/forms/index.ts index 3da8834d39..569061dc71 100644 --- a/components/clientComponents/forms/index.ts +++ b/components/clientComponents/forms/index.ts @@ -22,3 +22,4 @@ export { Combobox } from "./Combobox/Combobox"; export { ManagedCombobox } from "./AddressComplete/ManagedCombobox"; export { AddressComplete } from "./AddressComplete/AddressComplete"; export { FormattedDate } from "./FormattedDate/FormattedDate"; +export { StarRating } from "./StarRating/StarRating"; diff --git a/components/serverComponents/icons/index.ts b/components/serverComponents/icons/index.ts index 7afc4c18dc..a9291c010c 100644 --- a/components/serverComponents/icons/index.ts +++ b/components/serverComponents/icons/index.ts @@ -84,5 +84,6 @@ export { ChatIcon } from "./ChatIcon"; export { CheckNoBorderIcon } from "./CheckNoBorderIcon"; export { XIcon } from "./XIcon"; export { ArchiveIcon } from "./ArchiveIcon"; +export { StarIcon } from "./StarIcon"; export { PersonIcon } from "./PersonIcon"; export { ClosedStatusIcon } from "./ClosedStatusIcon"; diff --git a/i18n/translations/en/common.json b/i18n/translations/en/common.json index 7a49e1962c..1c5d2c3cca 100644 --- a/i18n/translations/en/common.json +++ b/i18n/translations/en/common.json @@ -385,6 +385,10 @@ "confirmFileWarning": { "text": "This copy of your answers will not include the files you attached to your submission." }, + "starRating": { + "starLabel_one": "{{count}} star", + "starLabel_other": "{{count}} stars" + }, "loginPilot": { "title": "Sign in to GC Forms", "description": "Use your government account to build forms and access submitted responses.", diff --git a/i18n/translations/en/form-builder.json b/i18n/translations/en/form-builder.json index 77b7bea2cf..8fb6c44a4d 100644 --- a/i18n/translations/en/form-builder.json +++ b/i18n/translations/en/form-builder.json @@ -38,6 +38,7 @@ "textField": "Add short answer", "textArea": "Add long answer", "radio": "Add radio buttons", + "starRating": "Add star rating", "checkbox": "Add checkboxes", "dropdown": "Add dropdown", "fileInput": "Add file upload", @@ -280,6 +281,12 @@ "chooseAnOption": "Question", "selectOnlyOne": "Select one option." }, + "starRating": { + "description": "A star rating element where users select a rating from 1 to N stars.", + "title": "Star rating", + "exampleQuestion": "How would you rate your experience?", + "numberOfStars": "Number of stars" + }, "richText": { "description": "A rich text element for inserting headings, paragraph text, lists, or links.", "example": "Paragraph text that can be formatted in bold or italics, or with hyperlinks.", @@ -754,6 +761,10 @@ "errorInvalidRegex": "The regex pattern is invalid. Please enter a valid regex pattern.", "previousPatterns": "Previous patterns", "errorUnsafeRegex": "This regex pattern is potentially unsafe. Avoid patterns that could cause performance issues, such as those with nested quantifiers or excessive backtracking." + }, + "starRatingSparkle": { + "title": "Sparkle effect", + "checkboxLabel": "Show a sparkle animation when a star is selected" } }, "moreOptions": "More options", @@ -1175,6 +1186,7 @@ "shortAnswerText": "Short answer", "signInToTest": "Sign in to test how you can submit and view responses", "singleChoice": "Radio buttons", + "starRating": "Star rating", "start": "Start", "startErrorTemplateSize": "The form file is too large", "startErrorDuplicateQuestionId": "Question IDs must be unique", diff --git a/i18n/translations/fr/common.json b/i18n/translations/fr/common.json index 29676465ab..b0f00117a5 100644 --- a/i18n/translations/fr/common.json +++ b/i18n/translations/fr/common.json @@ -384,6 +384,10 @@ "confirmFileWarning": { "text": "Cette copie de vos réponses n'aura pas les fichiers que vous avez joints à votre soumission." }, + "starRating": { + "starLabel_one": "{{count}} étoile", + "starLabel_other": "{{count}} étoiles" + }, "loginPilot": { "title": "Se connecter à Formulaires GC", "description": "Utilisez votre compte du gouvernement pour créer des formulaires et accéder aux réponses soumises.", diff --git a/i18n/translations/fr/form-builder.json b/i18n/translations/fr/form-builder.json index d4e866f8c0..bc4c9d9551 100644 --- a/i18n/translations/fr/form-builder.json +++ b/i18n/translations/fr/form-builder.json @@ -38,6 +38,7 @@ "textField": "Ajouter une réponse courte", "textArea": "Ajouter une réponse longue", "radio": "Ajouter des boutons radio", + "starRating": "Ajouter une évaluation par étoiles", "checkbox": "Ajouter des cases à cocher", "dropdown": "Ajouter une liste déroulante", "fileInput": "Ajouter un téléverseur de fichier", @@ -280,6 +281,12 @@ "chooseAnOption": "Question", "selectOnlyOne": "Sélectionnez une option." }, + "starRating": { + "description": "Un élément d'évaluation par étoiles où les utilisateurs sélectionnent une note de 1 à N étoiles.", + "title": "Évaluation par étoiles", + "exampleQuestion": "Comment évalueriez-vous votre expérience?", + "numberOfStars": "Nombre d'étoiles" + }, "richText": { "description": "Élément de texte enrichi pour insérer des en-têtes, des paragraphes, des listes ou des liens.", "example": "Texte de paragraphe qui peut être mis en forme avec du gras ou de l'italique, ou avec des hyperliens.", @@ -751,6 +758,10 @@ "errorInvalidRegex": "Le modèle d'expression régulière (regex) n'est pas valide. Veuillez saisir un modèle regex valide.", "previousPatterns": "Modèles précédents", "errorUnsafeRegex": "Ce modèle regex est potentiellement dangereux. Évitez les modèles susceptibles de causer des problèmes de performance, tels que ceux comportant des quantificateurs imbriqués ou un retour en arrière excessif. " + }, + "starRatingSparkle": { + "title": "Effet scintillant", + "checkboxLabel": "Afficher une animation scintillante lorsqu'une étoile est sélectionnée" } }, "moreOptions": "Plus d'options ", @@ -1182,6 +1193,7 @@ "shortAnswerText": "Réponse courte", "signInToTest": "Connectez-vous pour tester comment vous pouvez soumettre et visualiser les réponses.", "singleChoice": "Boutons radio", + "starRating": "Évaluation par étoiles", "start": "Commencer", "startErrorTemplateSize": "La taille du fichier de formulaire est trop importante", "startErrorDuplicateQuestionId": "Les identifiants de questions doivent être uniques", diff --git a/lib/formBuilder.tsx b/lib/formBuilder.tsx index 33a2e3a8b0..4bcb8b3d12 100644 --- a/lib/formBuilder.tsx +++ b/lib/formBuilder.tsx @@ -17,6 +17,7 @@ import { ConditionalWrapper, Combobox, FormattedDate, + StarRating, } from "@clientComponents/forms"; import { FormElement, @@ -92,7 +93,7 @@ function _buildForm(element: FormElement, lang: string): ReactElement { className={isRequired ? "required" : ""} required={isRequired} validation={element.properties.validation} - group={["radio", "checkbox"].indexOf(element.type) !== -1} + group={["radio", "checkbox", "starRating"].indexOf(element.type) !== -1} lang={lang} > {labelText} @@ -240,6 +241,24 @@ function _buildForm(element: FormElement, lang: string): ReactElement { ); } + case FormElementTypes.starRating: { + const numberOfStars = element.properties.numberOfStars ?? 5; + const sparkleOnSelect = element.properties.sparkleOnSelect ?? false; + return ( + + {labelComponent} + {description && {description}} + + + ); + } case FormElementTypes.dropdown: return (
@@ -414,6 +433,7 @@ const _getElementInitialValue = (element: FormElement, language: string): Respon switch (element.type) { // Radio and dropdown resolve to string values case FormElementTypes.radio: + case FormElementTypes.starRating: case FormElementTypes.dropdown: case FormElementTypes.combobox: case FormElementTypes.formattedDate: diff --git a/lib/hooks/form-builder/useElementOptions.tsx b/lib/hooks/form-builder/useElementOptions.tsx index b76935f927..5ea51d7642 100644 --- a/lib/hooks/form-builder/useElementOptions.tsx +++ b/lib/hooks/form-builder/useElementOptions.tsx @@ -15,6 +15,7 @@ import { UploadIcon, GavelIcon, DepartmentsIcon, + StarIcon, } from "@serverComponents/icons"; import dynamic from "next/dynamic"; @@ -60,6 +61,13 @@ const Radio = dynamic( ), { ssr: false, loading: () => } ); +const StarRatingDescription = dynamic( + () => + import("@formBuilder/[id]/edit/components/elements/element-dialog/descriptions/StarRating").then( + (mod) => ({ default: mod.StarRating }) + ), + { ssr: false, loading: () => } +); const CheckBox = dynamic( () => import("@formBuilder/[id]/edit/components/elements/element-dialog/descriptions/CheckBox").then( @@ -83,16 +91,16 @@ const Number = dynamic( ); const QuestionSet = dynamic( () => - import( - "@formBuilder/[id]/edit/components/elements/element-dialog/descriptions/QuestionSet" - ).then((mod) => ({ default: mod.QuestionSet })), + import("@formBuilder/[id]/edit/components/elements/element-dialog/descriptions/QuestionSet").then( + (mod) => ({ default: mod.QuestionSet }) + ), { ssr: false, loading: () => } ); const Attestation = dynamic( () => - import( - "@formBuilder/[id]/edit/components/elements/element-dialog/descriptions/Attestation" - ).then((mod) => ({ default: mod.Attestation })), + import("@formBuilder/[id]/edit/components/elements/element-dialog/descriptions/Attestation").then( + (mod) => ({ default: mod.Attestation }) + ), { ssr: false, loading: () => } ); const Address = dynamic( @@ -104,9 +112,9 @@ const Address = dynamic( ); const AddressComplete = dynamic( () => - import( - "@formBuilder/[id]/edit/components/elements/element-dialog/descriptions/AddressComplete" - ).then((mod) => ({ default: mod.AddressComplete })), + import("@formBuilder/[id]/edit/components/elements/element-dialog/descriptions/AddressComplete").then( + (mod) => ({ default: mod.AddressComplete }) + ), { ssr: false, loading: () => } ); const Name = dynamic( @@ -125,9 +133,9 @@ const Contact = dynamic( ); const FirstMiddleLastName = dynamic( () => - import( - "@formBuilder/[id]/edit/components/elements/element-dialog/descriptions/FirstMiddleLastName" - ).then((mod) => ({ default: mod.FirstMiddleLastName })), + import("@formBuilder/[id]/edit/components/elements/element-dialog/descriptions/FirstMiddleLastName").then( + (mod) => ({ default: mod.FirstMiddleLastName }) + ), { ssr: false, loading: () => } ); const FileInput = dynamic( @@ -139,9 +147,9 @@ const FileInput = dynamic( ); const Departments = dynamic( () => - import( - "@formBuilder/[id]/edit/components/elements/element-dialog/descriptions/Departments" - ).then((mod) => ({ default: mod.Departments })), + import("@formBuilder/[id]/edit/components/elements/element-dialog/descriptions/Departments").then( + (mod) => ({ default: mod.Departments }) + ), { ssr: false, loading: () => } ); const Combobox = dynamic( @@ -153,16 +161,16 @@ const Combobox = dynamic( ); const FormattedDate = dynamic( () => - import( - "@formBuilder/[id]/edit/components/elements/element-dialog/descriptions/FormattedDate" - ).then((mod) => ({ default: mod.FormattedDate })), + import("@formBuilder/[id]/edit/components/elements/element-dialog/descriptions/FormattedDate").then( + (mod) => ({ default: mod.FormattedDate }) + ), { ssr: false, loading: () => } ); const CustomJson = dynamic( () => - import( - "@formBuilder/[id]/edit/components/elements/element-dialog/descriptions/CustomJson" - ).then((mod) => ({ default: mod.CustomJson })), + import("@formBuilder/[id]/edit/components/elements/element-dialog/descriptions/CustomJson").then( + (mod) => ({ default: mod.CustomJson }) + ), { ssr: false, loading: () => } ); @@ -290,6 +298,14 @@ export const useElementOptions = (filterElements?: ElementOptionsFilter | undefi className: "", group: groups.other, }, + { + id: "starRating", + value: t("starRating"), + icon: StarIcon, + description: StarRatingDescription, + className: "", + group: groups.other, + }, { id: "attestation", value: t("attestation"), diff --git a/lib/middleware/schemas/templates.schema.json b/lib/middleware/schemas/templates.schema.json index 949952a235..19d58d8527 100644 --- a/lib/middleware/schemas/templates.schema.json +++ b/lib/middleware/schemas/templates.schema.json @@ -236,7 +236,8 @@ "combobox", "addressComplete", "formattedDate", - "numberInput" + "numberInput", + "starRating" ] }, "properties": { @@ -370,6 +371,16 @@ "description": "If set to true, the number input will format numbers with a thousands separator.", "type": "boolean" }, + "numberOfStars": { + "description": "The number of stars to display in a star rating element.", + "type": "integer", + "minimum": 3, + "maximum": 10 + }, + "sparkleOnSelect": { + "description": "If true, a sparkle animation plays when a star is selected.", + "type": "boolean" + }, "validation": { "type": "object", "properties": { diff --git a/lib/store/types.ts b/lib/store/types.ts index d466c6b24f..f05c1c1ffb 100644 --- a/lib/store/types.ts +++ b/lib/store/types.ts @@ -86,7 +86,7 @@ export interface TemplateStoreState extends TemplateStoreProps { getChoice: (elIndex: number, choiceIndex: number) => { en: string; fr: string } | undefined; updateField: ( path: string, - value: string | boolean | ElementProperties | BrandProperties + value: string | boolean | number | ElementProperties | BrandProperties ) => void; updateSecurityAttribute: (value: SecurityAttribute) => void; propertyPath: (id: number, field: string, lang?: Language) => string; diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index c681e86050..0f0e3fbc7b 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog + +## [2.2.15] - 2026-05-08 + +- Add new Star Rating input validation type + ## [2.2.14] - 2026-06-15 - Replaced the self-import from @gcforms/core with a local import diff --git a/packages/core/package.json b/packages/core/package.json index 433c2090c6..022221c6aa 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@gcforms/core", - "version": "2.2.14", + "version": "2.2.15", "author": "Canadian Digital Service", "license": "MIT", "publishConfig": { diff --git a/packages/core/src/validation/validation.ts b/packages/core/src/validation/validation.ts index e6ff63fab4..24709f033e 100644 --- a/packages/core/src/validation/validation.ts +++ b/packages/core/src/validation/validation.ts @@ -137,6 +137,7 @@ export const isFieldResponseValid = ( break; } case FormElementTypes.radio: + case FormElementTypes.starRating: case FormElementTypes.dropdown: { if (validator.required && (value === undefined || value === "")) { return t("input-validation.required"); diff --git a/packages/types/CHANGELOG.md b/packages/types/CHANGELOG.md index a4c5e12310..b78530cdab 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.35] - 2026-06-23 + +- Add Star Rating element type + ## [1.0.34] - 2026-06-11 - Add lastEditedBy to the FormRecord type @@ -41,7 +45,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove File Upload as a beta option - ## [1.0.23] - 2025-10-01 - Bump yarn diff --git a/packages/types/package.json b/packages/types/package.json index b15435f9fd..405be314a1 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@gcforms/types", - "version": "1.0.34", + "version": "1.0.35", "author": "Canadian Digital Service", "license": "MIT", "publishConfig": { diff --git a/packages/types/src/form-types.ts b/packages/types/src/form-types.ts index e966345510..5a1d03c6b0 100644 --- a/packages/types/src/form-types.ts +++ b/packages/types/src/form-types.ts @@ -64,6 +64,7 @@ export const FormElementTypes = { formattedDate: "formattedDate", numberInput: "numberInput", customJson: "customJson", + starRating: "starRating", } as const; export type FormElementTypes = (typeof FormElementTypes)[keyof typeof FormElementTypes]; @@ -143,6 +144,8 @@ export interface ElementProperties { stepCount?: number; currencyCode?: string; useThousandsSeparator?: boolean; + numberOfStars?: number; + sparkleOnSelect?: boolean; conditionalRules?: ConditionalRule[]; full?: boolean; addressComponents?: AddressComponents | undefined; diff --git a/styles/animations.css b/styles/animations.css index 8060a76f9f..283f9b7769 100644 --- a/styles/animations.css +++ b/styles/animations.css @@ -48,6 +48,41 @@ } } +@keyframes star-particle { + 0% { + transform: translate(-50%, -50%) scale(1); + opacity: 1; + } + 100% { + transform: translate(calc(-50% + var(--tx, 0px)), calc(-50% + var(--ty, 0px))) scale(0); + opacity: 0; + } +} + +.gc-star-particle { + position: absolute; + top: 50%; + left: 50%; + font-size: 0.45em; + line-height: 1; + pointer-events: none; + user-select: none; + opacity: 0; /* hidden by default; animation overrides via keyframe */ +} + +@media (prefers-reduced-motion: no-preference) { + .gc-star-particle { + animation: star-particle 500ms ease-out forwards; + } + + .gc-star-particle--1 { --tx: -20px; --ty: -22px; } + .gc-star-particle--2 { --tx: 0px; --ty: -28px; animation-delay: 30ms; } + .gc-star-particle--3 { --tx: 22px; --ty: -20px; animation-delay: 15ms; } + .gc-star-particle--4 { --tx: 26px; --ty: 10px; animation-delay: 25ms; } + .gc-star-particle--5 { --tx: 0px; --ty: 28px; animation-delay: 10ms; } + .gc-star-particle--6 { --tx: -26px; --ty: 10px; animation-delay: 20ms; } +} + ::view-transition-old(.accounts-nav-forward) { animation: 90ms linear both accounts-fade reverse; } diff --git a/tests/browser/form-builder/add-element-dialog/ElementDialog.browser.test.tsx b/tests/browser/form-builder/add-element-dialog/ElementDialog.browser.test.tsx index c793d0f700..8b944a758e 100644 --- a/tests/browser/form-builder/add-element-dialog/ElementDialog.browser.test.tsx +++ b/tests/browser/form-builder/add-element-dialog/ElementDialog.browser.test.tsx @@ -499,6 +499,10 @@ describe("", () => { const fileInput = listbox.getByTestId("fileInput"); await expect.element(fileInput).toHaveAttribute("aria-selected", "true"); + await userEvent.keyboard("{ArrowDown}"); + const starRating = listbox.getByTestId("starRating"); + await expect.element(starRating).toHaveAttribute("aria-selected", "true"); + await userEvent.keyboard("{ArrowDown}"); const dynamicRow = listbox.getByTestId("dynamicRow"); await expect.element(dynamicRow).toHaveAttribute("aria-selected", "true"); @@ -548,11 +552,11 @@ describe("", () => { await render(); - // All filter should show 17 elements + // All filter should show 18 elements const allFilter = page.getByTestId("all-filter"); await allFilter.click(); let options = await page.getByRole("option").all(); - expect(options.length).toBe(17); + expect(options.length).toBe(18); // Basic filter should show 7 elements const basicFilter = page.getByTestId("basic-filter"); @@ -566,15 +570,15 @@ describe("", () => { options = await page.getByRole("option").all(); expect(options.length).toBe(7); - // Other filter should show 3 elements + // Other filter should show 4 elements const otherFilter = page.getByTestId("other-filter"); await otherFilter.click(); options = await page.getByRole("option").all(); - expect(options.length).toBe(3); + expect(options.length).toBe(4); // Go back to all filter await allFilter.click(); options = await page.getByRole("option").all(); - expect(options.length).toBe(17); + expect(options.length).toBe(18); }); });