buds Phase 2: top-3 username|email autocomplete on #id_recipient (post share + my_buds add); implicit symmetric auto-add on share_post (sharer ↔ recipient buds graph); recipient field accepts username OR email — TDD
- billboard.views.search_buds(GET /billboard/buds/search?q=...) — top-3 prefix match against request.user.buds via Q(username__istartswith) | Q(email__istartswith). Returns {buds: [{id, username, email}]}. Privacy: only the user's own buds are searched, no leak of strangers.
- _resolve_recipient(raw) helper resolves a free-form recipient (email if "@" present, else username, both case-insensitive). Wired into add_bud + share_post so #id_recipient accepts either form.
- share_post implicit auto-add (per-spec): when recipient is registered + first-time-shared, both directions of buds M2M get the link — request.user.buds.add(recipient) AND recipient.buds.add(request.user). Idempotent, no auto-add on reshare/self/unregistered.
- new bud-autocomplete.js shared module (apps/billboard/static/apps/billboard/) — bindBudAutocomplete(input, suggestionsEl, {searchUrl}). Mirrors sky.html birth-place picker: 250ms debounced fetch from MIN_CHARS=1, click-to-fill, Escape closes, click-outside closes, late-response drop. e.stopPropagation on suggestion-click so the bud-panel's outside-click handler doesn't fire and clear the input.
- SCSS .bud-suggestions / .bud-suggestion-item mirrors .sky-suggestions but position:fixed bottom:4rem (aligned above the bud panel, with overflow:hidden on the panel forcing the dropdown to live as a sibling rather than a child). Landscape breakpoints clear the navbar/footer 4rem sidebars, 8rem at min-width 1800px.
- both _bud_panel.html (post share) + _bud_add_panel.html (my_buds add) get the suggestions div sibling + script tags. Each panel's existing document click-outside handler now skips the suggestions container so a click inside doesn't close+clear. type="email" → type="text" since usernames are accepted; placeholder "friend@example.com or username".
- new test classes in test_buds.py: SearchBudsViewTest (6 — prefix match, cap-3, email prefix, non-bud leakproof, empty-q, anon redirect) + SharePostImplicitAutoAddTest (4 — sharer.buds += recipient, recipient.buds += sharer, username-typed share, unregistered no-add) + AddBudViewTest.test_add_resolves_username_too. test_my_buds.py FT adds test_autocomplete_suggests_buds_by_username_prefix. test_sharing.py placeholder assertion updated to "friend@example.com or username".
- 852 ITs (+11) + 5 my_buds FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
115
src/apps/billboard/static/apps/billboard/bud-autocomplete.js
Normal file
115
src/apps/billboard/static/apps/billboard/bud-autocomplete.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// Bud-list autocomplete for #id_recipient inputs (post share panel + my_buds
|
||||||
|
// add panel). Mirrors the sky.html birth-place picker pattern: debounced
|
||||||
|
// fetch on input, top-3 suggestions rendered as buttons, click-to-fill,
|
||||||
|
// Escape closes, click-outside closes. No keyboard arrow/Enter cycling.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// <div class="bud-panel-wrap">
|
||||||
|
// <input id="id_recipient" ...>
|
||||||
|
// <div id="id_bud_suggestions" class="bud-suggestions" hidden></div>
|
||||||
|
// </div>
|
||||||
|
// <script src="{% static 'apps/billboard/bud-autocomplete.js' %}"></script>
|
||||||
|
// <script>bindBudAutocomplete(
|
||||||
|
// document.getElementById('id_recipient'),
|
||||||
|
// document.getElementById('id_bud_suggestions'),
|
||||||
|
// { searchUrl: '{% url "billboard:search_buds" %}' }
|
||||||
|
// );</script>
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var DEBOUNCE_MS = 250;
|
||||||
|
var MIN_CHARS = 1;
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.textContent = s == null ? '' : s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.bindBudAutocomplete = function (input, suggestions, options) {
|
||||||
|
if (!input || !suggestions || !options || !options.searchUrl) return;
|
||||||
|
|
||||||
|
var debounceTimer = null;
|
||||||
|
var lastQuery = '';
|
||||||
|
|
||||||
|
function _hide() {
|
||||||
|
suggestions.hidden = true;
|
||||||
|
suggestions.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _render(buds) {
|
||||||
|
if (!buds || !buds.length) {
|
||||||
|
_hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suggestions.innerHTML = buds.map(function (b) {
|
||||||
|
// data-email + data-username so the click handler can fill the
|
||||||
|
// input with whichever the user originally typed (email if they
|
||||||
|
// started with `@`, else username).
|
||||||
|
return (
|
||||||
|
'<button type="button" class="bud-suggestion-item" ' +
|
||||||
|
'data-email="' + _esc(b.email) + '" ' +
|
||||||
|
'data-username="' + _esc(b.username) + '">' +
|
||||||
|
_esc(b.username) +
|
||||||
|
'</button>'
|
||||||
|
);
|
||||||
|
}).join('');
|
||||||
|
suggestions.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fetch(q) {
|
||||||
|
var url = options.searchUrl + '?q=' + encodeURIComponent(q);
|
||||||
|
fetch(url, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
||||||
|
.then(function (data) {
|
||||||
|
// Drop late responses if the user has typed past this query.
|
||||||
|
if (input.value.trim() !== q) return;
|
||||||
|
_render(data.buds || []);
|
||||||
|
})
|
||||||
|
.catch(function () { _hide(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
var q = input.value.trim();
|
||||||
|
lastQuery = q;
|
||||||
|
if (q.length < MIN_CHARS) { _hide(); return; }
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(function () { _fetch(q); }, DEBOUNCE_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') _hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
suggestions.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('.bud-suggestion-item');
|
||||||
|
if (!btn) return;
|
||||||
|
// Stop propagation so the bud-panel's document-level click-
|
||||||
|
// outside handler doesn't fire and close+clear the panel —
|
||||||
|
// _hide() about to detach the target makes a `sg.contains(e.target)`
|
||||||
|
// check at the document level unreliable.
|
||||||
|
e.stopPropagation();
|
||||||
|
// Fill w. whichever form the user was typing (email vs username).
|
||||||
|
// If the input value already contains '@', prefer email; else
|
||||||
|
// prefer username. This keeps the OK-submit semantics consistent
|
||||||
|
// w. what the user intended.
|
||||||
|
var typed = input.value.trim();
|
||||||
|
input.value = typed.indexOf('@') !== -1
|
||||||
|
? btn.dataset.email
|
||||||
|
: btn.dataset.username;
|
||||||
|
_hide();
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (suggestions.hidden) return;
|
||||||
|
if (suggestions.contains(e.target)) return;
|
||||||
|
if (e.target === input) return;
|
||||||
|
_hide();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}());
|
||||||
@@ -141,3 +141,101 @@ class AddBudViewTest(TestCase):
|
|||||||
def test_get_returns_405(self):
|
def test_get_returns_405(self):
|
||||||
response = self.client.get(reverse("billboard:add_bud"))
|
response = self.client.get(reverse("billboard:add_bud"))
|
||||||
self.assertEqual(response.status_code, 405)
|
self.assertEqual(response.status_code, 405)
|
||||||
|
|
||||||
|
def test_add_resolves_username_too_not_just_email(self):
|
||||||
|
"""Phase 2: recipient field accepts usernames as well as emails."""
|
||||||
|
alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:add_bud"),
|
||||||
|
data={"recipient": "alice"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["bud"]["username"], "alice")
|
||||||
|
self.assertIn(alice, self.user.buds.all())
|
||||||
|
|
||||||
|
|
||||||
|
class SearchBudsViewTest(TestCase):
|
||||||
|
"""Top-3 prefix-match autocomplete endpoint backing #id_recipient."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="me@test.io", username="me")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.albert = User.objects.create(email="albert@test.io", username="albert")
|
||||||
|
self.alvin = User.objects.create(email="alvin@test.io", username="alvin")
|
||||||
|
self.bob = User.objects.create(email="bob@test.io", username="bob")
|
||||||
|
self.user.buds.add(self.alice, self.albert, self.alvin, self.bob)
|
||||||
|
|
||||||
|
def test_username_prefix_match(self):
|
||||||
|
response = self.client.get(reverse("billboard:search_buds"), {"q": "al"})
|
||||||
|
usernames = [b["username"] for b in response.json()["buds"]]
|
||||||
|
# alice, albert, alvin all start with "al" — exactly 3 (cap)
|
||||||
|
self.assertEqual(len(usernames), 3)
|
||||||
|
self.assertIn("alice", usernames)
|
||||||
|
self.assertIn("albert", usernames)
|
||||||
|
self.assertIn("alvin", usernames)
|
||||||
|
self.assertNotIn("bob", usernames)
|
||||||
|
|
||||||
|
def test_caps_at_three_results(self):
|
||||||
|
d = User.objects.create(email="alfred@test.io", username="alfred")
|
||||||
|
self.user.buds.add(d)
|
||||||
|
response = self.client.get(reverse("billboard:search_buds"), {"q": "al"})
|
||||||
|
self.assertEqual(len(response.json()["buds"]), 3)
|
||||||
|
|
||||||
|
def test_email_prefix_also_matches(self):
|
||||||
|
response = self.client.get(reverse("billboard:search_buds"), {"q": "bob@"})
|
||||||
|
usernames = [b["username"] for b in response.json()["buds"]]
|
||||||
|
self.assertIn("bob", usernames)
|
||||||
|
|
||||||
|
def test_does_not_leak_non_buds(self):
|
||||||
|
"""Non-buds (other registered users) don't appear in suggestions."""
|
||||||
|
User.objects.create(email="stranger@test.io", username="stranger")
|
||||||
|
response = self.client.get(reverse("billboard:search_buds"), {"q": "str"})
|
||||||
|
self.assertEqual(response.json()["buds"], [])
|
||||||
|
|
||||||
|
def test_empty_q_returns_empty_list(self):
|
||||||
|
response = self.client.get(reverse("billboard:search_buds"), {"q": ""})
|
||||||
|
self.assertEqual(response.json()["buds"], [])
|
||||||
|
|
||||||
|
def test_anon_redirects(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(reverse("billboard:search_buds"), {"q": "al"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
|
||||||
|
class SharePostImplicitAutoAddTest(TestCase):
|
||||||
|
"""Per-spec: when a share lands a recipient on Post.shared_with, the
|
||||||
|
sharer + recipient mutually auto-add each other to their buds lists."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from apps.billboard.models import Post
|
||||||
|
self.sharer = User.objects.create(email="sharer@test.io", username="sharer")
|
||||||
|
self.client.force_login(self.sharer)
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.post = Post.objects.create(owner=self.sharer)
|
||||||
|
|
||||||
|
def _share(self, recipient):
|
||||||
|
return self.client.post(
|
||||||
|
reverse("billboard:share_post", args=[self.post.id]),
|
||||||
|
data={"recipient": recipient},
|
||||||
|
HTTP_ACCEPT="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_share_adds_recipient_to_sharer_buds(self):
|
||||||
|
self._share("alice@test.io")
|
||||||
|
self.assertIn(self.alice, self.sharer.buds.all())
|
||||||
|
|
||||||
|
def test_share_adds_sharer_to_recipient_buds(self):
|
||||||
|
"""Symmetric on shared events — recipient also gets the sharer."""
|
||||||
|
self._share("alice@test.io")
|
||||||
|
self.assertIn(self.sharer, self.alice.buds.all())
|
||||||
|
|
||||||
|
def test_share_with_username_also_auto_adds(self):
|
||||||
|
self._share("alice")
|
||||||
|
self.assertIn(self.alice, self.sharer.buds.all())
|
||||||
|
self.assertIn(self.sharer, self.alice.buds.all())
|
||||||
|
|
||||||
|
def test_unregistered_recipient_does_not_auto_add(self):
|
||||||
|
"""Privacy: unregistered email doesn't touch the buds graph."""
|
||||||
|
self._share("ghost@test.io")
|
||||||
|
self.assertEqual(self.sharer.buds.count(), 0)
|
||||||
|
|||||||
@@ -20,4 +20,5 @@ urlpatterns = [
|
|||||||
path("users/<uuid:user_id>/", views.my_posts, name="my_posts"),
|
path("users/<uuid:user_id>/", views.my_posts, name="my_posts"),
|
||||||
path("my-buds/", views.my_buds, name="my_buds"),
|
path("my-buds/", views.my_buds, name="my_buds"),
|
||||||
path("buds/add", views.add_bud, name="add_bud"),
|
path("buds/add", views.add_bud, name="add_bud"),
|
||||||
|
path("buds/search", views.search_buds, name="search_buds"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -311,12 +311,12 @@ def share_post(request, post_id):
|
|||||||
our_post = Post.objects.get(id=post_id)
|
our_post = Post.objects.get(id=post_id)
|
||||||
is_ajax = "application/json" in request.headers.get("Accept", "")
|
is_ajax = "application/json" in request.headers.get("Accept", "")
|
||||||
|
|
||||||
recipient_email = request.POST.get("recipient", "")
|
# Recipient may be email OR username — _resolve_recipient handles both
|
||||||
recipient = None
|
# (email if "@" present, else username lookup). The raw value is kept
|
||||||
try:
|
# for the Line text since users see what they typed in the per-line
|
||||||
recipient = User.objects.get(email=recipient_email)
|
# rendering (post-refresh + optimistic JS append).
|
||||||
except User.DoesNotExist:
|
recipient_email = (request.POST.get("recipient") or "").strip()
|
||||||
pass
|
recipient = _resolve_recipient(recipient_email)
|
||||||
|
|
||||||
# Sharer-tries-to-share-with-themselves: silent no-op (existing behavior).
|
# Sharer-tries-to-share-with-themselves: silent no-op (existing behavior).
|
||||||
if recipient is not None and recipient == request.user:
|
if recipient is not None and recipient == request.user:
|
||||||
@@ -332,6 +332,12 @@ def share_post(request, post_id):
|
|||||||
|
|
||||||
if recipient is not None and not is_reshare:
|
if recipient is not None and not is_reshare:
|
||||||
our_post.shared_with.add(recipient)
|
our_post.shared_with.add(recipient)
|
||||||
|
# Implicit auto-add to the buds graph — symmetric on shared events
|
||||||
|
# (per-spec): a share-event implies a mutual social link.
|
||||||
|
# `add()` is idempotent on M2M, no need to pre-check membership.
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
request.user.buds.add(recipient)
|
||||||
|
recipient.buds.add(request.user)
|
||||||
|
|
||||||
line = None
|
line = None
|
||||||
brief = None
|
brief = None
|
||||||
@@ -390,20 +396,34 @@ def my_buds(request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_recipient(raw):
|
||||||
|
"""Resolve a free-form recipient (email OR username) to a User, or None.
|
||||||
|
Email match takes precedence — if the input contains '@' we don't even
|
||||||
|
try the username lookup, so a username that happens to match an email
|
||||||
|
user's local part doesn't get coerced. Used by add_bud + share_post."""
|
||||||
|
raw = (raw or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
if "@" in raw:
|
||||||
|
try:
|
||||||
|
return User.objects.get(email__iexact=raw)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return User.objects.get(username__iexact=raw)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
def add_bud(request):
|
def add_bud(request):
|
||||||
if request.method != "POST":
|
if request.method != "POST":
|
||||||
from django.http import HttpResponseNotAllowed
|
from django.http import HttpResponseNotAllowed
|
||||||
return HttpResponseNotAllowed(["POST"])
|
return HttpResponseNotAllowed(["POST"])
|
||||||
|
|
||||||
email = (request.POST.get("recipient") or "").strip()
|
candidate = _resolve_recipient(request.POST.get("recipient"))
|
||||||
|
|
||||||
bud = None
|
bud = None
|
||||||
try:
|
|
||||||
candidate = User.objects.get(email=email)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
candidate = None
|
|
||||||
|
|
||||||
if candidate is not None and candidate != request.user:
|
if candidate is not None and candidate != request.user:
|
||||||
if candidate not in request.user.buds.all():
|
if candidate not in request.user.buds.all():
|
||||||
request.user.buds.add(candidate)
|
request.user.buds.add(candidate)
|
||||||
@@ -416,6 +436,32 @@ def add_bud(request):
|
|||||||
return JsonResponse({"bud": bud})
|
return JsonResponse({"bud": bud})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def search_buds(request):
|
||||||
|
"""Top-3 prefix-match autocomplete pool for #id_recipient inputs.
|
||||||
|
Pulls only from request.user.buds — buds that haven't been added yet
|
||||||
|
don't appear in the autocomplete (privacy-by-default; new buds enter
|
||||||
|
the list via explicit add or implicit auto-add on share/invite).
|
||||||
|
Matches case-insensitive on either username or email prefix."""
|
||||||
|
from django.db.models import Q
|
||||||
|
q = (request.GET.get("q") or "").strip()
|
||||||
|
if not q:
|
||||||
|
return JsonResponse({"buds": []})
|
||||||
|
matches = (
|
||||||
|
request.user.buds
|
||||||
|
.filter(Q(username__istartswith=q) | Q(email__istartswith=q))
|
||||||
|
.order_by("username", "email")[:3]
|
||||||
|
)
|
||||||
|
return JsonResponse({"buds": [
|
||||||
|
{
|
||||||
|
"id": str(b.id),
|
||||||
|
"username": b.username or b.email,
|
||||||
|
"email": b.email,
|
||||||
|
}
|
||||||
|
for b in matches
|
||||||
|
]})
|
||||||
|
|
||||||
|
|
||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
def save_scroll_position(request, room_id):
|
def save_scroll_position(request, room_id):
|
||||||
if request.method != "POST":
|
if request.method != "POST":
|
||||||
|
|||||||
@@ -57,6 +57,31 @@ class MyBudsPageTest(FunctionalTest):
|
|||||||
self.alice, list(self.gamer.buds.all())
|
self.alice, list(self.gamer.buds.all())
|
||||||
))
|
))
|
||||||
|
|
||||||
|
def test_autocomplete_suggests_buds_by_username_prefix(self):
|
||||||
|
"""Phase 2: typing in #id_recipient pulls top-3 prefix matches from
|
||||||
|
request.user.buds and renders them as .bud-suggestion-item buttons.
|
||||||
|
Click → input.value fills with the bud's username (or email if the
|
||||||
|
user typed an `@` already)."""
|
||||||
|
self.gamer.buds.add(self.alice)
|
||||||
|
bob = User.objects.create(email="bob@test.io", username="bob")
|
||||||
|
self.gamer.buds.add(bob)
|
||||||
|
|
||||||
|
self.browser.get(self.live_server_url + "/billboard/my-buds/")
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||||
|
btn.click()
|
||||||
|
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||||
|
recipient.send_keys("al")
|
||||||
|
|
||||||
|
suggestions = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_bud_suggestions .bud-suggestion-item"
|
||||||
|
))
|
||||||
|
self.assertEqual(suggestions.text.strip(), "alice")
|
||||||
|
|
||||||
|
suggestions.click()
|
||||||
|
self.wait_for(lambda: self.assertEqual(
|
||||||
|
recipient.get_attribute("value"), "alice"
|
||||||
|
))
|
||||||
|
|
||||||
def test_add_unregistered_email_is_silent_noop(self):
|
def test_add_unregistered_email_is_silent_noop(self):
|
||||||
self.browser.get(self.live_server_url + "/billboard/my-buds/")
|
self.browser.get(self.live_server_url + "/billboard/my-buds/")
|
||||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class SharingTest(FunctionalTest):
|
|||||||
share_box = post_page.get_share_box()
|
share_box = post_page.get_share_box()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
share_box.get_attribute("placeholder"),
|
share_box.get_attribute("placeholder"),
|
||||||
"friend@example.com",
|
"friend@example.com or username",
|
||||||
)
|
)
|
||||||
|
|
||||||
post_page.share_post_with("alice@test.io")
|
post_page.share_post_with("alice@test.io")
|
||||||
|
|||||||
@@ -118,3 +118,54 @@ html:has(#id_kit_bag_dialog[open]) #id_bud_btn {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Bud autocomplete suggestions (mirror of sky-place birth picker) ──
|
||||||
|
// Sibling of #id_bud_panel (which has overflow:hidden for the scaleX
|
||||||
|
// slide animation, so the suggestions can't be a child or they'd clip).
|
||||||
|
// Position-fixed above the panel; same left/right inset as the panel
|
||||||
|
// at each breakpoint so the dropdown lines up.
|
||||||
|
.bud-suggestions {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 4rem; // panel bottom (0.5rem) + height (3rem) + gap (0.5rem)
|
||||||
|
left: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
z-index: 320; // above the panel itself
|
||||||
|
background: rgba(var(--priUser), 1);
|
||||||
|
border: 0.1rem solid rgba(var(--terUser), 0.3);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 10rem;
|
||||||
|
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
left: calc(4rem + 0.5rem);
|
||||||
|
right: calc(4rem + 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (orientation: landscape) and (min-width: 1800px) {
|
||||||
|
left: calc(8rem + 0.5rem);
|
||||||
|
right: calc(8rem + 0.5rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bud-suggestion-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 0.05rem solid rgba(var(--terUser), 0.1);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(var(--ninUser), 0.85);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1.35;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
&:hover, &:focus {
|
||||||
|
background: rgba(var(--terUser), 0.12);
|
||||||
|
color: rgba(var(--ninUser), 1);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,12 +13,25 @@
|
|||||||
<div id="id_bud_panel" data-add-url="{% url 'billboard:add_bud' %}">
|
<div id="id_bud_panel" data-add-url="{% url 'billboard:add_bud' %}">
|
||||||
<input id="id_recipient"
|
<input id="id_recipient"
|
||||||
name="recipient"
|
name="recipient"
|
||||||
type="email"
|
type="text"
|
||||||
placeholder="friend@example.com"
|
placeholder="friend@example.com or username"
|
||||||
autocomplete="off">
|
autocomplete="off">
|
||||||
<button id="id_bud_ok" type="button" class="btn btn-confirm">OK</button>
|
<button id="id_bud_ok" type="button" class="btn btn-confirm">OK</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Autocomplete suggestions list — sibling of #id_bud_panel because the #}
|
||||||
|
{# panel has overflow:hidden for its scaleX slide animation. #}
|
||||||
|
<div id="id_bud_suggestions" class="bud-suggestions" hidden></div>
|
||||||
|
|
||||||
|
<script src="{% static 'apps/billboard/bud-autocomplete.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
bindBudAutocomplete(
|
||||||
|
document.getElementById('id_recipient'),
|
||||||
|
document.getElementById('id_bud_suggestions'),
|
||||||
|
{ searchUrl: '{% url "billboard:search_buds" %}' }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -64,6 +77,10 @@
|
|||||||
if (!html.classList.contains('bud-open')) return;
|
if (!html.classList.contains('bud-open')) return;
|
||||||
if (panel.contains(e.target)) return;
|
if (panel.contains(e.target)) return;
|
||||||
if (e.target === btn || btn.contains(e.target)) return;
|
if (e.target === btn || btn.contains(e.target)) return;
|
||||||
|
// Suggestions live outside the panel (panel has overflow:hidden
|
||||||
|
// for its scaleX slide); a click inside them must NOT close+clear.
|
||||||
|
var sg = document.getElementById('id_bud_suggestions');
|
||||||
|
if (sg && sg.contains(e.target)) return;
|
||||||
_close();
|
_close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,25 @@
|
|||||||
data-sharer-name="{% if request.user.is_authenticated %}{{ request.user|display_name }}{% endif %}">
|
data-sharer-name="{% if request.user.is_authenticated %}{{ request.user|display_name }}{% endif %}">
|
||||||
<input id="id_recipient"
|
<input id="id_recipient"
|
||||||
name="recipient"
|
name="recipient"
|
||||||
type="email"
|
type="text"
|
||||||
placeholder="friend@example.com"
|
placeholder="friend@example.com or username"
|
||||||
autocomplete="off">
|
autocomplete="off">
|
||||||
<button id="id_bud_ok" type="button" class="btn btn-confirm">OK</button>
|
<button id="id_bud_ok" type="button" class="btn btn-confirm">OK</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Autocomplete suggestions — sibling of #id_bud_panel because the panel #}
|
||||||
|
{# has overflow:hidden for its scaleX slide animation. #}
|
||||||
|
<div id="id_bud_suggestions" class="bud-suggestions" hidden></div>
|
||||||
|
|
||||||
|
<script src="{% static 'apps/billboard/bud-autocomplete.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
bindBudAutocomplete(
|
||||||
|
document.getElementById('id_recipient'),
|
||||||
|
document.getElementById('id_bud_suggestions'),
|
||||||
|
{ searchUrl: '{% url "billboard:search_buds" %}' }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -73,6 +86,11 @@
|
|||||||
if (!html.classList.contains('bud-open')) return;
|
if (!html.classList.contains('bud-open')) return;
|
||||||
if (panel.contains(e.target)) return;
|
if (panel.contains(e.target)) return;
|
||||||
if (e.target === btn || btn.contains(e.target)) return;
|
if (e.target === btn || btn.contains(e.target)) return;
|
||||||
|
// Autocomplete suggestions sit outside the panel (panel overflow
|
||||||
|
// is hidden for the scaleX slide). A click inside them must NOT
|
||||||
|
// close+clear the panel.
|
||||||
|
var sg = document.getElementById('id_bud_suggestions');
|
||||||
|
if (sg && sg.contains(e.target)) return;
|
||||||
_close();
|
_close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user