Skip to content

Add shared activist tag-input component - #468

Open
jakehobbs wants to merge 5 commits into
mainfrom
jake/react-shared-tag-input
Open

Add shared activist tag-input component#468
jakehobbs wants to merge 5 commits into
mainfrom
jake/react-shared-tag-input

Conversation

@jakehobbs

Copy link
Copy Markdown
Member

Summary

  • Two of the parallel Vue→React port PRs independently built near-duplicate chip/autocomplete multi-select components for picking activist names:
  • This PR extracts a single shared TagInput component at frontend-v2/src/components/tag-input.tsx covering the union of both usages (single-select via max=1, unbounded multi-select, disabled state, optional built-in label vs. externally-supplied label).
  • It's a base PR: the component is unused here (no callers yet) so CI/build has nothing to exercise it against. Port working groups page to React #464 and Port circles page to React #466 are expected to rebase/adopt this component on top of this branch and delete their local duplicate files as follow-up commits, per the parent task that spawned all three PRs.

Notes for adopters

  • Filtering is case-insensitive "starts with", matching legacy Vue getFilteredActivists/getFilteredOrganizers in WorkingGroupList.vue and CirclesList.vue. The Working Groups PR's person-multi-select.tsx had drifted to substring (includes) matching — adopting TagInput restores parity with the legacy app and circles' existing behavior. This is an intentional, documented behavior change for that one call site.
  • Prop names differ slightly from both originals (unified on max instead of max/maxItems, added maxSuggestions, label is optional and only renders internally when passed).

Test plan

🤖 Generated with Claude Code

Working Groups (#464) and Circles (#466) independently built near-duplicate
chip/autocomplete multi-select components for picking activist names
(person-multi-select.tsx and activist-tag-input.tsx). Extract a single
shared, presentational TagInput covering the union of both: single- and
multi-select via `max`, disabled state, optional built-in label, and
type-ahead filtering restricted to the given `options`.

Filtering uses case-insensitive "starts with", matching the legacy Vue
b-taginput behavior in both WorkingGroupList.vue and CirclesList.vue (the
Working Groups PR's port had drifted to substring matching).

This is a base PR — the component is unused here; #464 and #466 will adopt
it and delete their local copies in follow-up commits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 19, 2026 01:36
@jakehobbs
jakehobbs requested a review from alexsapps as a code owner July 19, 2026 01:36
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5b7d434e-b07c-4c75-8021-db22e5d5d8c1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jake/react-shared-tag-input

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

jakehobbs added a commit that referenced this pull request Jul 19, 2026
Replaces the page-local PersonMultiSelect with the shared TagInput
component from frontend-v2/src/components/tag-input.tsx (introduced in
#468), which the circles page also uses. Prop mapping is 1:1. Suggestion
filtering changes from substring match to case-insensitive prefix match,
which is a parity correction matching the legacy Vue
getFilteredOrganizers behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Extracts a shared TagInput chip/autocomplete component into frontend-v2 to unify the activist-name multi-select UI used by the parallel Working Groups and Circles React ports.

Changes:

  • Adds a new TagInput component that supports single-select (max=1), multi-select, disabled state, and optional internal labeling.
  • Implements case-insensitive “starts with” filtering for suggestions to match legacy Vue behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +143 to +153
<input
id={inputId}
className="min-w-[8rem] flex-1 border-0 bg-transparent p-1 text-sm outline-none placeholder:text-muted-foreground"
value={text}
placeholder={value.length === 0 ? placeholder : undefined}
onChange={(e) => {
setText(e.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Fixed in daced44: the input now has role="combobox", aria-autocomplete, aria-expanded, and aria-controls, linking it to the suggestions list. This mirrors the existing combobox pattern in suggestion-input.tsx.

Comment on lines +175 to +183
<ul
role="listbox"
className="max-h-[240px] overflow-y-auto rounded-md border border-gray-200 bg-white shadow-lg"
>
{suggestions.map((s) => (
<li
key={s}
role="option"
aria-selected={false}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Fixed in daced44 (same fix as the combobox comment above): the listbox now has a stable id and each option has an id, completing the aria-controls/aria-activedescendant linkage.


return (
<div className="space-y-1.5">
<Label htmlFor={inputId}>{label}</Label>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Fixed in daced44: the <Label>'s htmlFor is now only set while the input is actually rendered, so there's no dangling reference when max is reached or the field is disabled.

Comment on lines +103 to +107
const addValue = (name: string) => {
if (!name.trim() || value.includes(name) || atMax) return
onChange([...value, name])
setText('')
setOpen(false)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Acknowledged — keyboard-interaction test coverage is a good candidate to add, especially alongside the arrow-key navigation work tracked in issue #474. Not added in this PR to keep it reviewable.

@jakehobbs jakehobbs added the react-port Vue 2 → React (frontend-v2) port work label Jul 19, 2026
jakehobbs and others added 3 commits July 18, 2026 18:55
Address two bot-review findings on the shared TagInput:

- Add the combobox ARIA pattern already used by
  events/suggestion-input.tsx: role="combobox", aria-autocomplete="list",
  aria-expanded, and aria-controls on the input, plus a stable id on the
  role="listbox" list and per-option ids.
- Only set the built-in Label's htmlFor while the input is actually
  rendered — the input unmounts when `disabled` or once `max` selections
  are reached (e.g. max={1} fields after picking a value), which left the
  label pointing at a non-existent element. Also document this pitfall on
  the `id` prop for callers rendering external labels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Final-review follow-ups for the shared TagInput base PR:

- Add lib/members.ts with findPointPerson and countMailingListMembers,
  which the Working Groups (#464) and Circles (#466) tables currently
  duplicate (getPointPersonName/countMailingListMembers and
  hostName/memberCount respectively). Typed against a minimal structural
  member shape so both pages' member types satisfy them; the adopting
  branches will switch to these themselves.
- Replace the multi-line JSX comment around the conditional Label
  htmlFor in tag-input.tsx with a named labelFor variable and a
  one-line comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Apply the repo-wide comment-style directive: every TagInputProps property
JSDoc is now a single line, the component-level JSDoc is two lines, and
the members.ts file header (which restated the function docs) is removed.
Single-line comments carrying non-obvious context are kept as-is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/** Placeholder shown in the text input while no chips are selected. */
placeholder?: string
/** Max selections — the input hides once reached; pass 1 for single-select, omit for unbounded. */
max?: number

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we just have an option for single-select instead of max options if the only used values are binary (1 or no limit)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Good call — checked every call site on both adopting branches (jake/react-working-groups, jake/react-circles): all 5 usages are either max={1} (point person, host) or unlimited (members, non-members). Replaced max?: number with single?: boolean in 546908a. Adopters change max={1} to single.

/** Called with the full next array whenever a chip is added or removed. */
onChange: (next: string[]) => void
/** All selectable values — callers own data fetching; already-selected values are excluded from suggestions. */
options: string[]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmm is this really just an array? is it at least pre-sorted? wonder if it worth be a worthwhile perf win to add context for these options that all tag inputs could share, so if that is an overoptimization. my frontend skills are rusty.

@jakehobbs jakehobbs Jul 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Assessed and left as-is — I think context would be overoptimization here. The option lists are name-only arrays (a few thousand entries at most — the full activist dataset with metadata is ~3MB per activist-storage.ts, and these name lists are a small fraction of that). Both pages already share the fetch across components via the react-query cache key, so context would only save prop-drilling one array one level deep. Filtering is a single O(n) prefix scan per keystroke, capped at 20 results — well under a millisecond at this n. Pre-sorting would actually be wrong: GetAutocompleteNames deliberately orders by most recent event attendance (ORDER BY MAX(e.date) DESC) so recently active people rank first in suggestions, same as the legacy Vue pages that use these endpoints.

if (!query) return []
return options
.filter(
(name) => !value.includes(name) && name.toLowerCase().startsWith(query),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks like terrible perf

@jakehobbs jakehobbs Jul 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Fixed in 546908a. Before: the filter ran value.includes(name) per option — O(options x selected) per keystroke, plus it re-ran on every re-render. After: suggestions are computed in a useMemo keyed on (options, text, selected set, cap), with a memoized Set for the selected-value check — O(options) per keystroke and nothing on unrelated re-renders. At the actual data size (a few thousand short strings) this is comfortably sub-millisecond.

disabled = false,
}: TagInputProps) {
const [text, setText] = useState('')
const [open, setOpen] = useState(false)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would prefer isOpen over open, unless this pattern is already established repo-wide

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Renamed to isOpen in 546908a. Grep confirms isOpen is the established convention for internal state: 3 existing uses (suggestion-input.tsx, column-selector.tsx, debug-menu.tsx) plus pervasive is* boolean state names, vs a single const [open, (filters/filter-chip.tsx). Radix component props (<Popover open=...>) are untouched — that name is the library API.

/** Optional label rendered above the control, wired to the input via `htmlFor`. */
label?: string
/** Input id for an external label (auto-generated if omitted); prefer `label`, since the input unmounts while hidden. */
id?: string

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pretty confused what these ids are actually used for. if safe to remove, remove.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Cleaned up in 546908a. Removed: the id prop (neither adopting branch passes it now that the built-in label prop handles wiring) and the per-option ids, which nothing referenced — they only matter for aria-activedescendant keyboard highlighting, which this component does not implement. What remains: one useId()-generated input id that the label points at via htmlFor, and a derived listbox id linking the input to the dropdown via aria-controls so screen readers associate the combobox with its popup.

- Replace `max?: number` with `single?: boolean` — every call site on the
  adopting branches uses either max={1} or no limit, so a boolean states
  the intent directly.
- Use a memoized Set for selected-value lookup in the suggestions filter,
  dropping the per-keystroke cost from O(options x selected) to O(options).
- Rename internal popover state open -> isOpen to match the prevailing
  repo convention (suggestion-input, column-selector, debug-menu).
- Remove the `id` prop (no caller passes it now that the built-in label
  handles htmlFor) and the per-option list ids, which nothing referenced
  since we don't use aria-activedescendant. The remaining internal ids
  wire the label to the input and aria-controls to the listbox.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

react-port Vue 2 → React (frontend-v2) port work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants