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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
229 changes: 223 additions & 6 deletions assets/controllers/form_progress_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand All @@ -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}%`;
Expand All @@ -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".
Expand Down
44 changes: 44 additions & 0 deletions assets/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions src/Form/InitiativeType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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([
Expand Down
7 changes: 7 additions & 0 deletions templates/form/fields.html.twig
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
{% use 'form_div_layout.html.twig' %}

{% block form_label_content %}
{{- parent() -}}
{%- if completion_field|default(false) -%}
<span class="completion-star" aria-hidden="true" title="{{ 'initiative.completion_field_hint'|trans }}">★</span>
{%- endif -%}
{% endblock %}

{% block form_row %}
{% set row_class = 'form-row' %}
<div class="{{ row_class }}">
Expand Down
1 change: 1 addition & 0 deletions templates/initiative/_form.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
<div class="form-progress__track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="{{ 'autosave.completion'|trans }}">
<div class="form-progress__fill" data-form-progress-target="fill"></div>
</div>
<span class="form-progress__star" data-form-progress-target="star" aria-hidden="true">★</span>
<span class="form-progress__label" data-form-progress-target="label">0%</span>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions translations/messages.da.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions translations/messages.en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading