diff --git a/CHANGELOG.md b/CHANGELOG.md index b518e6d..80186fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-6](https://github.com/itk-dev/itk-project-database/pull/6) + Mark udfyldningsgrad fields with a star that flies into a progress trophy * [PR-5](https://github.com/itk-dev/itk-project-database/pull/5) Improve initiative list, search and filters using partial page rendering * [PR-4](https://github.com/itk-dev/itk-project-database/pull/4) diff --git a/assets/controllers/form_progress_controller.js b/assets/controllers/form_progress_controller.js index 0b4a94c..165b2df 100644 --- a/assets/controllers/form_progress_controller.js +++ b/assets/controllers/form_progress_controller.js @@ -4,17 +4,26 @@ import { Controller } from "@hotwired/stimulus"; * Drives the initiative form's completion bar. Completion = filled / total over a * fixed set of fields (the `fields` value, shared with the server so the bar and * the list percentage always agree), grouped by field key so a multi-input field - * (links, funding) counts once. The bar's width and hue (red -> green) track the - * ratio. Used on both the new and edit forms. + * (links, funding) counts once. Used on both the new and edit forms. + * + * Stars: each starred (completion) field shows a star by its label. Filling the + * field makes that star fly into the "trophy" star at the end of the bar; clearing + * the field sends a star back from the trophy to the label. The bar and the trophy + * advance only once a forward star lands on the trophy, and deduct the moment a + * star leaves the trophy on the way back — so the motion and the numbers stay in + * sync. `shownCount` is what the bar currently shows, which trails the true field + * state during flight. */ export default class extends Controller { - static targets = ["fill", "label"]; + static targets = ["fill", "label", "star"]; static values = { fields: { type: Array, default: [] }, }; connect() { + this.filledKeys = null; + this.shownCount = 0; this.recompute(); } @@ -25,18 +34,113 @@ export default class extends Controller { const wanted = new Set(this.fieldsValue); const filled = new Set(); + const starForKey = new Map(); for (const el of this.element.querySelectorAll( "input, select, textarea", )) { const key = this.fieldKey(el); - if (key && wanted.has(key) && this.isFilled(el)) { + if (!key || !wanted.has(key)) { + continue; + } + if (!starForKey.has(key)) { + starForKey.set( + key, + el + .closest(".form-row") + ?.querySelector(".completion-star") ?? null, + ); + } + if (this.isFilled(el)) { filled.add(key); } } - const total = wanted.size; - const ratio = total ? filled.size / total : 0; + this.total = wanted.size; + + // First pass (page load): reflect the current state with no animation. + if (!this.filledKeys) { + this.shownCount = filled.size; + this.render(); + for (const [key, star] of starForKey) { + star?.classList.toggle( + "completion-star--gone", + filled.has(key), + ); + } + this.filledKeys = filled; + + return; + } + + for (const [key, star] of starForKey) { + const isFilled = filled.has(key); + const wasFilled = this.filledKeys.has(key); + if (isFilled && !wasFilled) { + this.sendStarToTrophy(star); + } else if (!isFilled && wasFilled) { + this.sendStarToLabel(star); + } + } + + this.filledKeys = filled; + } + + // Field filled: the label star flies up; the bar/trophy advance only on arrival. + sendStarToTrophy(star) { + if (!this.hasStarTarget || !star) { + return; + } + + const fromRect = this.realRect(star); + star.classList.add("completion-star--gone"); + + this.flyBetween( + fromRect, + this.starTarget.getBoundingClientRect(), + () => { + this.shownCount += 1; + this.render(); + this.popTrophy(); + }, + 38, + ); + } + + // Field cleared: deduct from the trophy now, then a star flies back to the label. + sendStarToLabel(star) { + if (!this.hasStarTarget || !star) { + return; + } + + this.shownCount -= 1; + this.render(); + + const fromRect = this.starTarget.getBoundingClientRect(); + this.flyBetween(fromRect, this.realRect(star), () => { + star.classList.remove("completion-star--gone"); + }); + } + + // The label star's real on-screen box, even while collapsed (--gone), so a + // returning star lands exactly where the star will sit, not at the text end. + realRect(star) { + const gone = star.classList.contains("completion-star--gone"); + if (gone) { + star.classList.remove("completion-star--gone"); + } + const rect = star.getBoundingClientRect(); + if (gone) { + star.classList.add("completion-star--gone"); + } + + return rect; + } + + render() { + const total = this.total || 0; + const count = Math.max(0, Math.min(this.shownCount, total)); + const ratio = total ? count / total : 0; const percent = Math.round(ratio * 100); this.fillTarget.style.width = `${percent}%`; @@ -49,6 +153,119 @@ export default class extends Controller { if (this.hasLabelTarget) { this.labelTarget.textContent = `${percent}%`; } + + this.updateTrophy(ratio); + } + + updateTrophy(ratio) { + if (!this.hasStarTarget) { + return; + } + + // Only the size tracks progress; colour stays gold like the label stars. + this.starTarget.style.fontSize = `${(1 + ratio * 0.85).toFixed(2)}em`; + } + + // `lift` (px) gives the forward flight a slow celebratory rise before it + // launches fast into the trophy; 0 keeps the plain arc used on the way back. + flyBetween(fromRect, toRect, onArrive, lift = 0) { + const startX = fromRect.left + fromRect.width / 2; + const startY = fromRect.top + fromRect.height / 2; + const dx = toRect.left + toRect.width / 2 - startX; + const dy = toRect.top + toRect.height / 2 - startY; + + if (this.prefersReducedMotion) { + onArrive(); + + return; + } + + const star = document.createElement("span"); + star.className = "flying-star"; + star.textContent = "★"; + star.setAttribute("aria-hidden", "true"); + star.style.left = `${startX}px`; + star.style.top = `${startY}px`; + document.body.appendChild(star); + + const arrive = { + transform: `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) scale(0.85) rotate(360deg)`, + opacity: 0, + }; + + let keyframes; + let duration; + if (lift > 0) { + keyframes = [ + { + transform: "translate(-50%, -50%) scale(1.3) rotate(0deg)", + opacity: 1, + easing: "cubic-bezier(0.16, 1, 0.3, 1)", + }, + { + transform: `translate(-50%, calc(-50% - ${lift}px)) scale(1.45) rotate(35deg)`, + opacity: 1, + offset: 0.5, + easing: "cubic-bezier(0.7, 0, 0.84, 0)", + }, + { + transform: `translate(calc(-50% + ${dx * 0.94}px), calc(-50% + ${dy * 0.94}px)) scale(1) rotate(345deg)`, + opacity: 1, + offset: 0.94, + }, + arrive, + ]; + duration = 1000; + } else { + const apex = dy < 0 ? -14 : -8; + keyframes = [ + { + transform: "translate(-50%, -50%) scale(1.3) rotate(0deg)", + opacity: 1, + }, + { + transform: `translate(calc(-50% + ${dx * 0.5}px), calc(-50% + ${dy * 0.5 + apex}px)) scale(1.15) rotate(180deg)`, + opacity: 1, + offset: 0.5, + }, + { + transform: `translate(calc(-50% + ${dx * 0.92}px), calc(-50% + ${dy * 0.92}px)) scale(1) rotate(330deg)`, + opacity: 1, + offset: 0.9, + }, + arrive, + ]; + duration = 760; + } + + const flight = star.animate(keyframes, { + duration, + easing: "cubic-bezier(0.3, 0, 0.5, 1)", + }); + + flight.onfinish = () => { + star.remove(); + onArrive(); + }; + } + + popTrophy() { + if (!this.hasStarTarget || this.prefersReducedMotion) { + return; + } + + this.starTarget.animate( + [ + { transform: "scale(1)" }, + { transform: "scale(1.55)" }, + { transform: "scale(1)" }, + ], + { duration: 360, easing: "ease-out" }, + ); + } + + get prefersReducedMotion() { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } // "initiative[links][0]" -> "links", "initiative[funding][]" -> "funding". diff --git a/assets/styles/app.css b/assets/styles/app.css index 894cf14..466ef33 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -1056,6 +1056,25 @@ h3 { color: var(--itk-slate-700); } +.completion-star { + display: inline-block; + margin-left: var(--itk-space-1); + color: gold; + font-size: 1.1em; + line-height: 1; + vertical-align: text-top; + -webkit-text-stroke: 0.75px #000; + paint-order: stroke fill; + cursor: help; +} + +.completion-star--gone { + width: 0; + margin-left: 0; + opacity: 0; + overflow: hidden; +} + .form-row .help-text, .form-row .form-help { font-size: var(--itk-text-xs); @@ -1482,6 +1501,31 @@ textarea { font-variant-numeric: tabular-nums; } +.form-progress__star { + flex: none; + display: inline-block; + position: relative; + top: -0.1em; + margin-left: calc(-1 * var(--itk-space-2)); + font-size: var(--itk-text-md); + line-height: 1; + color: gold; + -webkit-text-stroke: 0.75px #000; + paint-order: stroke fill; + transition: font-size 240ms ease; +} + +.flying-star { + position: fixed; + z-index: 1000; + pointer-events: none; + color: gold; + font-size: var(--itk-text-lg); + line-height: 1; + -webkit-text-stroke: 0.75px #000; + paint-order: stroke fill; +} + .completion { display: flex; align-items: center; diff --git a/src/Form/InitiativeType.php b/src/Form/InitiativeType.php index 85b2753..d48a6f5 100644 --- a/src/Form/InitiativeType.php +++ b/src/Form/InitiativeType.php @@ -26,6 +26,8 @@ use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints as Assert; @@ -200,6 +202,19 @@ public function buildForm(FormBuilderInterface $builder, array $options): void }); } + /** + * Flag the fields that count towards {@see Initiative::getCompletionPercentage()} + * so the form theme can mark them. Keeps the list in one place. + */ + public function finishView(FormView $view, FormInterface $form, array $options): void + { + foreach (Initiative::COMPLETION_FIELDS as $field) { + if (isset($view[$field])) { + $view[$field]->vars['completion_field'] = true; + } + } + } + public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ diff --git a/templates/form/fields.html.twig b/templates/form/fields.html.twig index 7585252..83c0b77 100644 --- a/templates/form/fields.html.twig +++ b/templates/form/fields.html.twig @@ -1,5 +1,12 @@ {% use 'form_div_layout.html.twig' %} +{% block form_label_content %} + {{- parent() -}} + {%- if completion_field|default(false) -%} + + {%- endif -%} +{% endblock %} + {% block form_row %} {% set row_class = 'form-row' %}
diff --git a/templates/initiative/_form.html.twig b/templates/initiative/_form.html.twig index ef65768..ee58980 100644 --- a/templates/initiative/_form.html.twig +++ b/templates/initiative/_form.html.twig @@ -33,6 +33,7 @@
+ 0%
diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index a16c06a..7b8bfe1 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -119,6 +119,7 @@ initiative: new_contacts: Opret nye kontaktpersoner author: Udfyldt af terms_help: Adskil flere værdier med komma. + completion_field_hint: Dette felt tæller med i udfyldningsgraden. images: Billeder attachments: Filer image_file: Billedfil diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index d5070d7..7781270 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -119,6 +119,7 @@ initiative: new_contacts: Add new contacts author: Filled out by terms_help: Separate multiple values with commas. + completion_field_hint: This field counts towards the completion rate. images: Images attachments: Files image_file: Image file