, 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);
});
});