feat: Add non-color indicator to vote button state #168

Open
typosaurus wants to merge 3 commits from typosaurus/162-add-non-color-indicator-to-vote-button-state into master
11 changed files with 118 additions and 36 deletions

View File

@ -190,6 +190,14 @@
color: var(--danger); color: var(--danger);
} }
.post-action-btn.vote-up::before {
content: "+";
}
.post-action-btn.vote-down::before {
content: "\2212";
}
.post-action-btn.vote-up.voted { .post-action-btn.vote-up.voted {
color: var(--accent); color: var(--accent);
font-weight: 700; font-weight: 700;
@ -200,6 +208,14 @@
font-weight: 700; font-weight: 700;
} }
.post-action-btn.vote-up.voted::before {
content: "\2295";
}
.post-action-btn.vote-down.voted::before {
content: "\2296";
}
.post-votes { .post-votes {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@ -138,6 +138,14 @@
color: var(--danger); color: var(--danger);
} }
.comment-vote-btn.vote-up::before {
content: "+";
}
.comment-vote-btn.vote-down::before {
content: "-";
}
.comment-vote-btn.vote-up.voted { .comment-vote-btn.vote-up.voted {
color: var(--accent); color: var(--accent);
font-weight: 700; font-weight: 700;
@ -148,6 +156,14 @@
font-weight: 700; font-weight: 700;
} }
.comment-vote-btn.vote-up.voted::before {
content: "\2295";
}
.comment-vote-btn.vote-down.voted::before {
content: "\2296";
}
.comment-vote-count { .comment-vote-count {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;

View File

@ -57,7 +57,9 @@ export class VoteManager extends OptimisticAction {
}); });
document.querySelectorAll(`form[action="${action}"] button[type="submit"]`).forEach((button) => { document.querySelectorAll(`form[action="${action}"] button[type="submit"]`).forEach((button) => {
const formValue = parseInt(button.closest("form").querySelector('input[name="value"]').value, 10); const formValue = parseInt(button.closest("form").querySelector('input[name="value"]').value, 10);
button.classList.toggle("voted", result.value !== 0 && formValue === result.value); const isVoted = result.value !== 0 && formValue === result.value;
button.classList.toggle("voted", isVoted);
button.setAttribute("aria-pressed", String(isVoted));
}); });
} }
} }

View File

@ -66,7 +66,7 @@ Do NOT hand-write the overlay/header markup. Use the shared macro in `templates/
Reuse these via `{% set _x = ... %}{% include %}` (the `_avatar_link.html` convention) instead of copy-pasting markup: Reuse these via `{% set _x = ... %}{% include %}` (the `_avatar_link.html` convention) instead of copy-pasting markup:
- `_post_composer_form.html` - the create-post form (topic selector, content/title, project select, attachments, poll builder, footer). Locals: `_composer_topic` (preselected topic, default `random`), `_composer_project` (preselected project uid or `""`). Wrapped in the `modal()` macro by `feed.html` (Create New Post) and `project_detail.html` (owner-only Post an update, preset to `devlog` + the project). Never fork a second copy of this form. - `_post_composer_form.html` - the create-post form (topic selector, content/title, project select, attachments, poll builder, footer). Locals: `_composer_topic` (preselected topic, default `random`), `_composer_project` (preselected project uid or `""`). Wrapped in the `modal()` macro by `feed.html` (Create New Post) and `project_detail.html` (owner-only Post an update, preset to `devlog` + the project). Never fork a second copy of this form.
- `_post_votes.html` - post +/- vote bar. Locals: `_uid`, `_my_vote`, `_count`. - `_post_votes.html` - post +/- vote bar. Locals: `_uid`, `_my_vote`, `_count`. The +/- glyphs come from the vote CSS classes via `::before` (`feed.css` for `.post-action-btn`, `post.css` for `.comment-vote-btn`), swapping to `\u2295`/`\u2296` when `.voted` - do not put literal glyphs in markup.
- `_star_vote.html` - project/gist star button. Locals: `_type` (`project`|`gist`), `_uid`, `_my_vote`, `_count`, `_btn_class`, optional `_stop` (adds `data-stop-propagation`). The star glyph (`☆`→`★` when `.voted`) comes from the `vote-star` CSS class via `::before` (`base.css`) - do not put a literal star in markup. - `_star_vote.html` - project/gist star button. Locals: `_type` (`project`|`gist`), `_uid`, `_my_vote`, `_count`, `_btn_class`, optional `_stop` (adds `data-stop-propagation`). The star glyph (`☆`→`★` when `.voted`) comes from the `vote-star` CSS class via `::before` (`base.css`) - do not put a literal star in markup.
- `_post_header.html` - post author/avatar/time header (`.post-header`). Locals: `_author`, `_time`. - `_post_header.html` - post author/avatar/time header (`.post-header`). Locals: `_author`, `_time`.
- `_topic_selector.html` - topic radio group. Locals: `_topics`, `_selected`. - `_topic_selector.html` - topic radio group. Locals: `_topics`, `_selected`.
@ -89,3 +89,4 @@ The three public listings (`/feed`, `/gists`, `/projects`) share one search box:
## Modal pattern ## Modal pattern
`Application.js` `initModals()` toggles a `.visible` CSS class on `.modal-overlay`; the CSS rule `.modal-overlay.visible { display: flex; }` handles visibility. Triggers usually have `href="#"`, so call `e.preventDefault()` in click handlers. `.modal-close` is wired generically - no inline JS needed. `Application.js` `initModals()` toggles a `.visible` CSS class on `.modal-overlay`; the CSS rule `.modal-overlay.visible { display: flex; }` handles visibility. Triggers usually have `href="#"`, so call `e.preventDefault()` in click handlers. `.modal-close` is wired generically - no inline JS needed.

View File

@ -3,12 +3,12 @@
<div class="comment-votes" role="group" aria-label="Comment votes"> <div class="comment-votes" role="group" aria-label="Comment votes">
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}"> <form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if item.my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>+</button> <button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if item.my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}></button>
</form> </form>
<span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span> <span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span>
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}"> <form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="-1"> <input type="hidden" name="value" value="-1">
<button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if item.my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>-</button> <button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if item.my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}></button>
</form> </form>
</div> </div>
@ -70,3 +70,4 @@
{{ render_comment_flat(child, depth, item.author) }} {{ render_comment_flat(child, depth, item.author) }}
{% endfor %} {% endfor %}
{% endmacro %} {% endmacro %}

View File

@ -1,11 +1,12 @@
<div class="post-votes" role="group" aria-label="Post votes"> <div class="post-votes" role="group" aria-label="Post votes">
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form"> <form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
<input type="hidden" name="value" value="-1"> <input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down{% if _my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if _my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}></button> <button type="submit" class="post-action-btn vote-down{% if _my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote" aria-pressed="{% if _my_vote == -1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}></button>
</form> </form>
<span class="post-vote-count" data-vote-count="{{ _uid }}">{{ _count }}</span> <span class="post-vote-count" data-vote-count="{{ _uid }}">{{ _count }}</span>
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form"> <form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up{% if _my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}>+</button> <button type="submit" class="post-action-btn vote-up{% if _my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote" aria-pressed="{% if _my_vote == 1 %}true{% else %}false{% endif %}"{{ guest_disabled(user) }}></button>
</form> </form>
</div> </div>

View File

@ -290,6 +290,14 @@ def assert_share_copies(page, expected_fragment):
) )
def vote_glyph(locator):
value = locator.evaluate("el => getComputedStyle(el, '::before').content")
value = value.strip().strip('"').strip("'")
if value.startswith("\\") and len(value) == 5 and value[1:].isdigit():
value = chr(int(value[1:], 16))
return value
def assert_no_horizontal_overflow(page, layout): def assert_no_horizontal_overflow(page, layout):
report = page.evaluate( report = page.evaluate(
"""(selector) => { """(selector) => {

View File

@ -39,7 +39,7 @@ def test_leaderboard_ranks_after_upvote(app_server, browser, seeded_db):
pa.goto(post_url, wait_until="domcontentloaded") pa.goto(post_url, wait_until="domcontentloaded")
pa.wait_for_timeout(1000) pa.wait_for_timeout(1000)
vote_btn = pa.locator("button.post-action-btn").filter(has_text="+").first vote_btn = pa.locator("button.post-action-btn.vote-up").first
vote_btn.wait_for(state="visible", timeout=10000) vote_btn.wait_for(state="visible", timeout=10000)
vote_btn.click() vote_btn.click()
pa.wait_for_timeout(1500) pa.wait_for_timeout(1500)

View File

@ -310,7 +310,7 @@ def test_vote_notification_on_post(app_server, browser, seeded_db):
pa.goto(post_url, wait_until="domcontentloaded") pa.goto(post_url, wait_until="domcontentloaded")
pa.wait_for_timeout(1000) pa.wait_for_timeout(1000)
vote_btn = pa.locator("button.post-action-btn").filter(has_text="+").first vote_btn = pa.locator("button.post-action-btn.vote-up").first
vote_btn.wait_for(state="visible", timeout=10000) vote_btn.wait_for(state="visible", timeout=10000)
vote_btn.click() vote_btn.click()
pa.wait_for_timeout(1500) pa.wait_for_timeout(1500)
@ -796,7 +796,7 @@ def test_vote_notification_click_opens_target(app_server, browser, seeded_db):
pa.goto(f"{BASE_URL}{post_path}", wait_until="domcontentloaded") pa.goto(f"{BASE_URL}{post_path}", wait_until="domcontentloaded")
pa.wait_for_timeout(1000) pa.wait_for_timeout(1000)
vote_btn = pa.locator("button.post-action-btn").filter(has_text="+").first vote_btn = pa.locator("button.post-action-btn.vote-up").first
vote_btn.wait_for(state="visible", timeout=10000) vote_btn.wait_for(state="visible", timeout=10000)
vote_btn.click() vote_btn.click()
pa.wait_for_timeout(1500) pa.wait_for_timeout(1500)

View File

@ -2,7 +2,7 @@
import re import re
from playwright.sync_api import expect from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies from tests.conftest import BASE_URL, assert_share_copies, vote_glyph
def create_post(page, topic="random", content="Test post content", title=None): def create_post(page, topic="random", content="Test post content", title=None):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded") page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000) page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
@ -74,35 +74,57 @@ def test_post_vote_increment(alice):
def test_post_upvote_voted_state_persists(alice): def test_post_upvote_voted_state_persists(alice):
page, _ = alice page, _ = alice
create_post(page, "showcase", "Upvote persistence test") create_post(page, "showcase", "Upvote persistence test")
vote_up = page.locator(".post-action-btn.vote-up").first
assert vote_glyph(vote_up) == "+"
with page.expect_response( with page.expect_response(
lambda r: "/votes/" in r.url and r.request.method == "POST" lambda r: "/votes/" in r.url and r.request.method == "POST"
): ):
page.locator(".post-action-btn.vote-up").first.click() vote_up.click()
expect(page.locator(".post-vote-count").first).to_have_text("1") expect(page.locator(".post-vote-count").first).to_have_text("1")
expect(vote_up).to_have_class(re.compile(r"\bvoted\b"))
assert vote_glyph(vote_up) == "\u2295"
page.reload(wait_until="domcontentloaded") page.reload(wait_until="domcontentloaded")
expect(page.locator(".post-action-btn.vote-up").first).to_have_class( vote_up = page.locator(".post-action-btn.vote-up").first
re.compile(r"\bvoted\b") expect(vote_up).to_have_class(re.compile(r"\bvoted\b"))
) assert vote_glyph(vote_up) == "\u2295"
assert "voted" not in ( vote_down = page.locator(".post-action-btn.vote-down").first
page.locator(".post-action-btn.vote-down").first.get_attribute("class") or "" assert "voted" not in (vote_down.get_attribute("class") or "")
) assert vote_glyph(vote_down) == "\u2212"
with page.expect_response(
lambda r: "/votes/" in r.url and r.request.method == "POST"
):
vote_up.click()
expect(page.locator(".post-vote-count").first).to_have_text("0")
expect(vote_up).not_to_have_class(re.compile(r"\bvoted\b"))
assert vote_glyph(vote_up) == "+"
def test_post_downvote_voted_state_persists(alice): def test_post_downvote_voted_state_persists(alice):
page, _ = alice page, _ = alice
create_post(page, "showcase", "Downvote persistence test") create_post(page, "showcase", "Downvote persistence test")
vote_down = page.locator(".post-action-btn.vote-down").first
assert vote_glyph(vote_down) == "\u2212"
with page.expect_response( with page.expect_response(
lambda r: "/votes/" in r.url and r.request.method == "POST" lambda r: "/votes/" in r.url and r.request.method == "POST"
): ):
page.locator(".post-action-btn.vote-down").first.click() vote_down.click()
expect(page.locator(".post-vote-count").first).to_have_text("-1") expect(page.locator(".post-vote-count").first).to_have_text("-1")
expect(vote_down).to_have_class(re.compile(r"\bvoted\b"))
assert vote_glyph(vote_down) == "\u2296"
page.reload(wait_until="domcontentloaded") page.reload(wait_until="domcontentloaded")
expect(page.locator(".post-action-btn.vote-down").first).to_have_class( vote_down = page.locator(".post-action-btn.vote-down").first
re.compile(r"\bvoted\b") expect(vote_down).to_have_class(re.compile(r"\bvoted\b"))
) assert vote_glyph(vote_down) == "\u2296"
assert "voted" not in ( vote_up = page.locator(".post-action-btn.vote-up").first
page.locator(".post-action-btn.vote-up").first.get_attribute("class") or "" assert "voted" not in (vote_up.get_attribute("class") or "")
) assert vote_glyph(vote_up) == "+"
with page.expect_response(
lambda r: "/votes/" in r.url and r.request.method == "POST"
):
vote_down.click()
expect(page.locator(".post-vote-count").first).to_have_text("0")
expect(vote_down).not_to_have_class(re.compile(r"\bvoted\b"))
assert vote_glyph(vote_down) == "\u2212"
def test_comment_voted_state_persists(alice): def test_comment_voted_state_persists(alice):
@ -114,14 +136,25 @@ def test_comment_voted_state_persists(alice):
expect( expect(
page.locator(".comment-text:has-text('Comment whose vote should persist')") page.locator(".comment-text:has-text('Comment whose vote should persist')")
).to_be_visible() ).to_be_visible()
page.locator(".comment-vote-btn").first.click() comment_vote = page.locator(".comment-vote-btn.vote-up").first
expect(page.locator(".comment-vote-btn").first).to_have_class( assert vote_glyph(comment_vote) == "+"
re.compile(r"\bvoted\b") with page.expect_response(
) lambda r: "/votes/" in r.url and r.request.method == "POST"
):
comment_vote.click()
expect(comment_vote).to_have_class(re.compile(r"\bvoted\b"))
assert vote_glyph(comment_vote) == "\u2295"
page.reload(wait_until="domcontentloaded") page.reload(wait_until="domcontentloaded")
expect(page.locator(".comment-vote-btn").first).to_have_class( comment_vote = page.locator(".comment-vote-btn.vote-up").first
re.compile(r"\bvoted\b") expect(comment_vote).to_have_class(re.compile(r"\bvoted\b"))
) assert vote_glyph(comment_vote) == "\u2295"
with page.expect_response(
lambda r: "/votes/" in r.url and r.request.method == "POST"
):
comment_vote.click()
expect(page.locator(".comment-vote-count").first).to_have_text("0")
expect(comment_vote).not_to_have_class(re.compile(r"\bvoted\b"))
assert vote_glyph(comment_vote) == "+"
def test_mention_first_match_highlighted(alice): def test_mention_first_match_highlighted(alice):

View File

@ -14,7 +14,7 @@ def _create_plain_post_engagement_ui(page, content):
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded") page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
from uuid import uuid4 from uuid import uuid4
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from tests.conftest import BASE_URL, assert_share_copies from tests.conftest import BASE_URL, assert_share_copies, vote_glyph
from devplacepy.database import get_table from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug from devplacepy.utils import make_combined_slug
def _seed_posts(count): def _seed_posts(count):
@ -404,15 +404,19 @@ def test_feed_vote_voted_state_persists(alice):
card = page.locator( card = page.locator(
".post-card", has_text="Feed vote persistence test content" ".post-card", has_text="Feed vote persistence test content"
).first ).first
card.locator(".post-action-btn.vote-up").click() vote_up = card.locator(".post-action-btn.vote-up")
assert vote_glyph(vote_up) == "+"
vote_up.click()
expect(card.locator(".post-vote-count")).to_have_text("1") expect(card.locator(".post-vote-count")).to_have_text("1")
expect(vote_up).to_have_class(re.compile(r"\bvoted\b"))
assert vote_glyph(vote_up) == "\u2295"
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded") page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
card = page.locator( card = page.locator(
".post-card", has_text="Feed vote persistence test content" ".post-card", has_text="Feed vote persistence test content"
).first ).first
expect(card.locator(".post-action-btn.vote-up")).to_have_class( vote_up = card.locator(".post-action-btn.vote-up")
re.compile(r"\bvoted\b") expect(vote_up).to_have_class(re.compile(r"\bvoted\b"))
) assert vote_glyph(vote_up) == "\u2295"
def test_feed_card_share_button(alice): def test_feed_card_share_button(alice):