From c9d92de880b12af98bd816a30aa864527dc4bb2b Mon Sep 17 00:00:00 2001
From: Aditya Varun Dhayapulay <93042524+adit24dhaya@users.noreply.github.com>
Date: Sun, 25 Oct 2026 20:26:58 -0700
Subject: [PATCH 1/2] Add smart search to on-site member table
Add column suggestions, literal match highlighting, combined 350ms-debounced filters and immediate reset. Preserve keyboard focus and readable dark-mode styles while avoiding table-body updates during typing.
Implements the medium-priority Jae/Divanshu on-site member search task. Use the existing Project Details table as confirmed; no routing or worker-data API changes.
---
.../ProjectDetails/MemberSearchInput.jsx | 134 +++++++++
.../ProjectDetails/ProjectDetails.jsx | 2 +-
.../Projects/ProjectDetails/ProjectLog.jsx | 117 ++++++--
.../ProjectDetails/ProjectLog.module.css | 137 ++++++++++
.../__tests__/ProjectLog.test.jsx | 257 ++++++++++++++++++
.../Projects/ProjectDetails/memberSearch.js | 53 ++++
6 files changed, 682 insertions(+), 18 deletions(-)
create mode 100644 src/components/BMDashboard/Projects/ProjectDetails/MemberSearchInput.jsx
create mode 100644 src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.module.css
create mode 100644 src/components/BMDashboard/Projects/ProjectDetails/__tests__/ProjectLog.test.jsx
create mode 100644 src/components/BMDashboard/Projects/ProjectDetails/memberSearch.js
diff --git a/src/components/BMDashboard/Projects/ProjectDetails/MemberSearchInput.jsx b/src/components/BMDashboard/Projects/ProjectDetails/MemberSearchInput.jsx
new file mode 100644
index 00000000000..d1d6ee69e02
--- /dev/null
+++ b/src/components/BMDashboard/Projects/ProjectDetails/MemberSearchInput.jsx
@@ -0,0 +1,134 @@
+import { useEffect, useId, useMemo, useRef, useState } from 'react';
+import PropTypes from 'prop-types';
+import { highlightParts, normalizeSearch } from './memberSearch';
+import styles from './ProjectLog.module.css';
+
+export function HighlightMatch({ value, query }) {
+ return highlightParts(value, query).map(part =>
+ part.match ? (
+
+ {part.text}
+
+ ) : (
+ part.text
+ ),
+ );
+}
+
+HighlightMatch.propTypes = {
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ query: PropTypes.string,
+};
+
+export default function MemberSearchInput({ column, value, options, onChange, onSelect }) {
+ const id = useId();
+ const listRef = useRef(null);
+ const [open, setOpen] = useState(false);
+ const [activeIndex, setActiveIndex] = useState(-1);
+ const query = normalizeSearch(value);
+ const matches = useMemo(
+ () =>
+ query ? options.filter(option => normalizeSearch(option).includes(query)).slice(0, 8) : [],
+ [options, query],
+ );
+ const expanded = Boolean(column.suggest && open && matches.length);
+ const active = expanded && activeIndex >= 0 && activeIndex < matches.length;
+
+ useEffect(() => {
+ listRef.current?.children[activeIndex]?.scrollIntoView?.({ block: 'nearest' });
+ }, [activeIndex]);
+
+ const select = option => {
+ onSelect(option);
+ setOpen(false);
+ setActiveIndex(-1);
+ };
+
+ const handleKeyDown = event => {
+ if (event.nativeEvent.isComposing) return;
+ if (event.key === 'Escape') {
+ setOpen(false);
+ setActiveIndex(-1);
+ } else if (column.suggest && matches.length && ['ArrowDown', 'ArrowUp'].includes(event.key)) {
+ event.preventDefault();
+ setOpen(true);
+ const direction = event.key === 'ArrowDown' ? 1 : -1;
+ setActiveIndex(previous => {
+ if (!expanded || previous < 0) return direction === 1 ? 0 : matches.length - 1;
+ return (previous + direction + matches.length) % matches.length;
+ });
+ } else if (event.key === 'Enter' && active) {
+ event.preventDefault();
+ select(matches[activeIndex]);
+ }
+ };
+
+ return (
+
+
+
{
+ onChange(event.target.value);
+ setOpen(true);
+ setActiveIndex(-1);
+ }}
+ onFocus={() => setOpen(true)}
+ onBlur={() => {
+ setOpen(false);
+ setActiveIndex(-1);
+ }}
+ onKeyDown={handleKeyDown}
+ />
+ {expanded && (
+
+ {matches.map((option, index) => (
+ - event.preventDefault()}
+ onClick={() => select(option)}
+ onKeyDown={event => {
+ if (event.key === 'Enter') select(option);
+ }}
+ >
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+MemberSearchInput.propTypes = {
+ column: PropTypes.shape({
+ key: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ suggest: PropTypes.bool,
+ }).isRequired,
+ value: PropTypes.string.isRequired,
+ options: PropTypes.arrayOf(PropTypes.string).isRequired,
+ onChange: PropTypes.func.isRequired,
+ onSelect: PropTypes.func.isRequired,
+};
diff --git a/src/components/BMDashboard/Projects/ProjectDetails/ProjectDetails.jsx b/src/components/BMDashboard/Projects/ProjectDetails/ProjectDetails.jsx
index ca706c27fb2..029d192932d 100644
--- a/src/components/BMDashboard/Projects/ProjectDetails/ProjectDetails.jsx
+++ b/src/components/BMDashboard/Projects/ProjectDetails/ProjectDetails.jsx
@@ -160,7 +160,7 @@ function ProjectDetails() {
padding: '10px',
}}
>
-
+
diff --git a/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.jsx b/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.jsx
index fc870581b77..9f4ee15f763 100644
--- a/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.jsx
+++ b/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.jsx
@@ -1,5 +1,9 @@
+import { memo, useEffect, useMemo, useState } from 'react';
+import PropTypes from 'prop-types';
import { Card, Table } from 'reactstrap';
-import styles from './ProjectDetails.module.css';
+import MemberSearchInput, { HighlightMatch } from './MemberSearchInput';
+import { EMPTY_FILTERS, MEMBER_COLUMNS, filterMembers, getMemberOptions } from './memberSearch';
+import styles from './ProjectLog.module.css';
const DummyData = [
{
id: '12',
@@ -33,24 +37,101 @@ const DummyData = [
},
];
-function ProjectLog() {
- const tableRows = DummyData.map(person => (
-
- | {person.id} |
- {person.firstName} |
- {person.lastName} |
- {person.role} |
- {person.team} |
- {person.currentTask} |
- {person.totalHrs} |
- {person.todaysHrs} |
-
- ));
+// Keep the potentially large table body unchanged while the user types.
+const MemberRows = memo(function MemberRows({ members, filters }) {
+ return (
+
+ {members.length ? (
+ members.map(person => (
+
+ {MEMBER_COLUMNS.map(({ key }) => {
+ const content = ;
+ return key === 'id' ? (
+ |
+ {content}
+ |
+ ) : (
+ {content} |
+ );
+ })}
+
+ ))
+ ) : (
+
+ | No members match these filters. |
+
+ )}
+
+ );
+});
+
+MemberRows.propTypes = {
+ members: PropTypes.array.isRequired,
+ filters: PropTypes.object.isRequired,
+};
+
+function ProjectLog({ members = DummyData, darkMode = false }) {
+ const [filters, setFilters] = useState(EMPTY_FILTERS);
+ const [appliedFilters, setAppliedFilters] = useState(EMPTY_FILTERS);
+ const options = useMemo(
+ () =>
+ Object.fromEntries(
+ MEMBER_COLUMNS.map(column => [
+ column.key,
+ column.suggest ? getMemberOptions(members, column.key) : [],
+ ]),
+ ),
+ [members],
+ );
+ const filtered = useMemo(() => filterMembers(members, appliedFilters), [members, appliedFilters]);
+
+ useEffect(() => {
+ if (filters === appliedFilters) return undefined;
+ const timer = setTimeout(() => setAppliedFilters(filters), 350);
+ return () => clearTimeout(timer);
+ }, [filters, appliedFilters]);
+
+ const clearFilters = () => {
+ setFilters(EMPTY_FILTERS);
+ setAppliedFilters(EMPTY_FILTERS);
+ };
return (
-
+
Members working on site today
-
+
+
+ Showing {filtered.length} of {members.length} members
+
+
+
+
+ {MEMBER_COLUMNS.map(column => (
+ setFilters(previous => ({ ...previous, [column.key]: value }))}
+ onSelect={value => {
+ const next = { ...filters, [column.key]: value };
+ setFilters(next);
+ setAppliedFilters(next);
+ }}
+ />
+ ))}
+
+
+
+ Members working on site today, filtered by the fields above
+
| ID |
@@ -63,10 +144,12 @@ function ProjectLog() {
Today's Hrs |
- {tableRows}
+
);
}
+ProjectLog.propTypes = { members: PropTypes.arrayOf(PropTypes.object), darkMode: PropTypes.bool };
+
export default ProjectLog;
diff --git a/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.module.css b/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.module.css
new file mode 100644
index 00000000000..a13ee227848
--- /dev/null
+++ b/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.module.css
@@ -0,0 +1,137 @@
+.projectLog {
+ --panel: #fff;
+ --text: #172b4d;
+ --border: #768399;
+ --active: #e5efff;
+ --highlight: #fff0a8;
+
+ padding: 16px;
+ min-width: 0;
+ background: var(--panel);
+ color: var(--text);
+}
+
+.dark {
+ --panel: #243447;
+ --text: #fff;
+ --border: #94a3b8;
+ --active: #365a7b;
+ --highlight: #225e7a;
+}
+
+.toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin: 12px 0;
+}
+
+.toolbar p {
+ margin: 0;
+}
+
+.clearButton {
+ padding: 8px 12px;
+ color: var(--text);
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+}
+
+.clearButton:disabled {
+ opacity: 0.65;
+ cursor: not-allowed;
+}
+
+.filters {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.filter {
+ position: relative;
+ min-width: 0;
+ text-align: left;
+}
+
+.filter label {
+ display: block;
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+
+.searchInput {
+ width: 100%;
+ min-width: 0;
+ padding: 8px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--panel);
+ color: var(--text);
+}
+
+.searchInput::placeholder {
+ color: var(--text);
+ opacity: 0.75;
+}
+
+.searchInput:focus-visible,
+.clearButton:focus-visible {
+ outline: 3px solid #598bde;
+ outline-offset: 2px;
+}
+
+.suggestions {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ z-index: 20;
+ width: 100%;
+ max-height: 280px;
+ overflow-y: auto;
+ padding: 0;
+ margin: 4px 0 0;
+ list-style: none;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--panel);
+ color: var(--text);
+ box-shadow: 0 4px 8px rgb(0 0 0 / 20%);
+}
+
+.option {
+ padding: 8px;
+ overflow-wrap: anywhere;
+ cursor: pointer;
+}
+
+.option:hover,
+.option[aria-selected='true'] {
+ background: var(--active);
+}
+
+.match {
+ padding: 0;
+ background: var(--highlight);
+ color: inherit;
+ border-radius: 2px;
+}
+
+.membersTable {
+ min-width: 780px;
+ color: var(--text);
+}
+
+.membersTable th,
+.membersTable td {
+ color: var(--text);
+ background: var(--panel);
+}
+
+.caption {
+ color: var(--text);
+}
diff --git a/src/components/BMDashboard/Projects/ProjectDetails/__tests__/ProjectLog.test.jsx b/src/components/BMDashboard/Projects/ProjectDetails/__tests__/ProjectLog.test.jsx
new file mode 100644
index 00000000000..3a1cb9ca8c5
--- /dev/null
+++ b/src/components/BMDashboard/Projects/ProjectDetails/__tests__/ProjectLog.test.jsx
@@ -0,0 +1,257 @@
+import React from 'react';
+import { act, fireEvent, render, screen, within } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import ProjectLog from '../ProjectLog';
+import { EMPTY_FILTERS, filterMembers, getMemberOptions, highlightParts } from '../memberSearch';
+
+const members = [
+ {
+ id: '1',
+ firstName: 'Cailin',
+ lastName: 'Colby',
+ role: 'Carpenter',
+ team: 'Team A',
+ currentTask: 'Framing',
+ totalHrs: 120,
+ todaysHrs: 0,
+ },
+ {
+ id: '2',
+ firstName: 'Carl',
+ lastName: 'Smith',
+ role: 'Volunteer',
+ team: 'Team B',
+ currentTask: 'Painting',
+ totalHrs: 30,
+ todaysHrs: 2.5,
+ },
+ {
+ id: '3',
+ firstName: 'Casey',
+ lastName: 'Colby',
+ role: 'Carpenter',
+ team: 'Team B',
+ currentTask: 'Framing',
+ totalHrs: 10,
+ todaysHrs: 1,
+ },
+];
+const change = (label, value) =>
+ fireEvent.change(screen.getByLabelText(label), { target: { value } });
+const advance = (ms = 350) => act(() => vi.advanceTimersByTime(ms));
+const table = () => screen.getByRole('table');
+const rowNames = () =>
+ within(table())
+ .getAllByRole('row')
+ .slice(1)
+ .map(row => row.textContent);
+
+beforeEach(() => vi.useFakeTimers());
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe('on-site member filtering', () => {
+ it('preserves all existing columns and starts with clear disabled', () => {
+ render();
+ expect(within(table()).getAllByRole('columnheader')).toHaveLength(8);
+ expect(rowNames()).toHaveLength(3);
+ expect(screen.getByRole('button', { name: 'Clear All Filters' })).toBeDisabled();
+ });
+
+ it('applies case-insensitive partial matching only after 350ms', () => {
+ render();
+ change('First Name', 'AIL');
+ advance(349);
+ expect(rowNames()).toHaveLength(3);
+ advance(1);
+ expect(rowNames()).toHaveLength(1);
+ expect(rowNames()[0]).toContain('Cailin');
+ expect(within(table()).getByText('ail', { selector: 'mark' })).toBeInTheDocument();
+ });
+
+ it('restarts the delay for each keystroke and never applies an older query', () => {
+ render();
+ change('First Name', 'Cail');
+ advance(300);
+ change('First Name', 'Carl');
+ advance(50);
+ expect(rowNames()).toHaveLength(3);
+ advance(300);
+ expect(rowNames()).toHaveLength(1);
+ expect(rowNames()[0]).toContain('Carl');
+ });
+
+ it('combines columns with AND and trims surrounding whitespace', () => {
+ render();
+ change('Role', ' carpenter ');
+ change('Team', 'team b');
+ advance();
+ expect(rowNames()).toHaveLength(1);
+ expect(rowNames()[0]).toContain('Casey');
+ });
+
+ it('clears every field immediately and cancels pending work', () => {
+ render();
+ change('Role', 'Carpenter');
+ advance();
+ change('First Name', 'Nobody');
+ fireEvent.click(screen.getByRole('button', { name: 'Clear All Filters' }));
+ expect(rowNames()).toHaveLength(3);
+ expect(screen.getByLabelText('Role')).toHaveValue('');
+ expect(screen.getByLabelText('First Name')).toHaveValue('');
+ advance(700);
+ expect(rowNames()).toHaveLength(3);
+ expect(within(table()).queryByText('Carpenter', { selector: 'mark' })).toBeNull();
+ expect(screen.queryByRole('listbox')).toBeNull();
+ });
+
+ it('supports task and numeric columns, including zero', () => {
+ render();
+ change('Current Task', 'fram');
+ change("Today's Hrs", '0');
+ advance();
+ expect(rowNames()).toHaveLength(1);
+ expect(rowNames()[0]).toContain('Cailin');
+ expect(within(table()).getByText('0', { selector: 'mark' })).toBeInTheDocument();
+ });
+
+ it('shows no-match and empty-data states without throwing', () => {
+ const { rerender } = render();
+ change('ID', 'unknown');
+ advance();
+ expect(within(table()).getByText('No members match these filters.')).toBeInTheDocument();
+ rerender();
+ expect(screen.getByRole('status')).toHaveTextContent('Showing 0 of 0 members');
+ });
+
+ it('recomputes filtered rows and suggestions when members are refreshed', () => {
+ const { rerender } = render();
+ change('First Name', 'Cam');
+ advance();
+ rerender(
+ ,
+ );
+ expect(rowNames()).toHaveLength(1);
+ expect(screen.getByRole('option', { name: 'Cameron' })).toBeInTheDocument();
+ });
+
+ it('cancels the debounce on unmount', () => {
+ const { unmount } = render();
+ change('First Name', 'Ca');
+ unmount();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('uses the existing sample rows when no member source is supplied', () => {
+ render();
+ expect(rowNames()).toHaveLength(3);
+ expect(within(table()).getByText('Dora')).toBeInTheDocument();
+ });
+});
+
+describe('typeahead', () => {
+ it.each(['First Name', 'Last Name', 'Role', 'Team'])(
+ 'provides a labelled combobox for %s',
+ label => {
+ render();
+ expect(screen.getByRole('combobox', { name: label })).toHaveAttribute(
+ 'aria-autocomplete',
+ 'list',
+ );
+ },
+ );
+
+ it('offers unique values from the matching column and selects by mouse', () => {
+ render();
+ change('Role', 'car');
+ expect(screen.getAllByRole('option')).toHaveLength(1);
+ fireEvent.mouseDown(screen.getByRole('option', { name: 'Carpenter' }));
+ fireEvent.click(screen.getByRole('option', { name: 'Carpenter' }));
+ expect(screen.getByLabelText('Role')).toHaveValue('Carpenter');
+ expect(screen.queryByRole('listbox')).toBeNull();
+ expect(rowNames()).toHaveLength(2);
+ });
+
+ it('supports arrow keys and Enter while retaining input focus semantics', () => {
+ render();
+ const input = screen.getByLabelText('First Name');
+ fireEvent.focus(input);
+ change('First Name', 'ca');
+ fireEvent.keyDown(input, { key: 'ArrowUp' });
+ const selected = screen
+ .getAllByRole('option')
+ .find(option => option.getAttribute('aria-selected') === 'true');
+ expect(input).toHaveAttribute('aria-activedescendant', selected.id);
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(input).toHaveValue('Casey');
+ expect(rowNames()).toHaveLength(1);
+ expect(input).toHaveAttribute('aria-expanded', 'false');
+ expect(input).not.toHaveAttribute('aria-activedescendant');
+ });
+
+ it('Escape and blur dismiss suggestions without changing the query', () => {
+ render();
+ const input = screen.getByLabelText('First Name');
+ change('First Name', 'ca');
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ fireEvent.keyDown(input, { key: 'Escape' });
+ expect(screen.queryByRole('listbox')).toBeNull();
+ expect(input).toHaveValue('ca');
+ fireEvent.focus(input);
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+ fireEvent.blur(input);
+ expect(screen.queryByRole('listbox')).toBeNull();
+ expect(input).toHaveValue('ca');
+ });
+
+ it('does not accept suggestions while composing text', () => {
+ render();
+ const input = screen.getByLabelText('First Name');
+ change('First Name', 'ca');
+ fireEvent.keyDown(input, { key: 'ArrowDown', isComposing: true });
+ expect(input).not.toHaveAttribute('aria-activedescendant');
+ });
+
+ it('limits the dropdown to eight suggestions', () => {
+ render(
+ ({
+ ...members[0],
+ id: `${i}`,
+ firstName: `Name ${i}`,
+ }))}
+ />,
+ );
+ change('First Name', 'Name');
+ expect(screen.getAllByRole('option')).toHaveLength(8);
+ });
+});
+
+describe('safe search values', () => {
+ it('treats punctuation and markup as literal text, never executable HTML', () => {
+ const name = '[Ann].*
';
+ render();
+ change('First Name', '[ann].*');
+ advance();
+ expect(rowNames()).toHaveLength(1);
+ expect(within(table()).getByText('[Ann].*', { selector: 'mark' })).toBeInTheDocument();
+ expect(within(table()).queryByRole('img', { hidden: true })).toBeNull();
+ });
+
+ it('handles absent data and deduplicates names without changing source records', () => {
+ const data = [{ firstName: null }, { firstName: ' Cailin ' }, { firstName: 'cailin' }];
+ expect(getMemberOptions(data, 'firstName')).toEqual(['Cailin']);
+ expect(filterMembers(data, { ...EMPTY_FILTERS, firstName: 'CAI' })).toHaveLength(2);
+ expect(data[1].firstName).toBe(' Cailin ');
+ });
+
+ it('highlights all literal matches and preserves original case', () => {
+ expect(
+ highlightParts('Anna ANNA', 'ann')
+ .filter(part => part.match)
+ .map(part => part.text),
+ ).toEqual(['Ann', 'ANN']);
+ expect(highlightParts(null, '')).toEqual([{ text: '', match: false, key: 0 }]);
+ });
+});
diff --git a/src/components/BMDashboard/Projects/ProjectDetails/memberSearch.js b/src/components/BMDashboard/Projects/ProjectDetails/memberSearch.js
new file mode 100644
index 00000000000..b68daaa1f5e
--- /dev/null
+++ b/src/components/BMDashboard/Projects/ProjectDetails/memberSearch.js
@@ -0,0 +1,53 @@
+export const MEMBER_COLUMNS = [
+ { key: 'id', label: 'ID' },
+ { key: 'firstName', label: 'First Name', suggest: true },
+ { key: 'lastName', label: 'Last Name', suggest: true },
+ { key: 'role', label: 'Role', suggest: true },
+ { key: 'team', label: 'Team', suggest: true },
+ { key: 'currentTask', label: 'Current Task' },
+ { key: 'totalHrs', label: 'Total Hrs' },
+ { key: 'todaysHrs', label: "Today's Hrs" },
+];
+
+export const EMPTY_FILTERS = Object.fromEntries(MEMBER_COLUMNS.map(({ key }) => [key, '']));
+export const normalizeSearch = value =>
+ String(value ?? '')
+ .trim()
+ .toLowerCase();
+
+export function filterMembers(members, filters) {
+ const active = MEMBER_COLUMNS.map(({ key }) => [key, normalizeSearch(filters[key])]).filter(
+ ([, query]) => query,
+ );
+ return members.filter(member =>
+ active.every(([key, query]) => normalizeSearch(member[key]).includes(query)),
+ );
+}
+
+export function getMemberOptions(members, key) {
+ const unique = new Map();
+ members.forEach(member => {
+ const text = String(member[key] ?? '').trim();
+ const normalized = normalizeSearch(text);
+ if (normalized && !unique.has(normalized)) unique.set(normalized, text);
+ });
+ return [...unique.values()].sort((a, b) => a.localeCompare(b));
+}
+
+// Literal string matching: punctuation must never become a regular expression or HTML.
+export function highlightParts(value, query) {
+ const text = String(value ?? '');
+ const search = normalizeSearch(query);
+ if (!search) return [{ text, match: false, key: 0 }];
+ const parts = [];
+ let offset = 0;
+ let index = text.toLowerCase().indexOf(search, offset);
+ while (index !== -1) {
+ if (index > offset) parts.push({ text: text.slice(offset, index), match: false, key: offset });
+ parts.push({ text: text.slice(index, index + search.length), match: true, key: index });
+ offset = index + search.length;
+ index = text.toLowerCase().indexOf(search, offset);
+ }
+ if (offset < text.length) parts.push({ text: text.slice(offset), match: false, key: offset });
+ return parts;
+}
From d503a9bf7b56d3c78c602cdd9763ef2b7bcc7c07 Mon Sep 17 00:00:00 2001
From: Aditya Varun Dhayapulay <93042524+adit24dhaya@users.noreply.github.com>
Date: Sun, 25 Oct 2026 23:22:38 -0700
Subject: [PATCH 2/2] Use native search status and filter group elements
Replace generic status/group containers with output and a fieldset with legend, preserving the responsive grid and accessible filter name. Add regression coverage for their native semantics.
Resolve both SonarCloud javascript:S6819 findings on PR #5718.
---
.../Projects/ProjectDetails/ProjectLog.jsx | 9 +++++----
.../Projects/ProjectDetails/ProjectLog.module.css | 15 ++++++++++++++-
.../ProjectDetails/__tests__/ProjectLog.test.jsx | 7 +++++++
3 files changed, 26 insertions(+), 5 deletions(-)
diff --git a/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.jsx b/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.jsx
index 9f4ee15f763..bd9592755ff 100644
--- a/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.jsx
+++ b/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.jsx
@@ -100,9 +100,9 @@ function ProjectLog({ members = DummyData, darkMode = false }) {
Members working on site today
-
+
+
-
+
+
Members working on site today, filtered by the fields above
diff --git a/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.module.css b/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.module.css
index a13ee227848..8dc55c9d43e 100644
--- a/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.module.css
+++ b/src/components/BMDashboard/Projects/ProjectDetails/ProjectLog.module.css
@@ -28,7 +28,7 @@
margin: 12px 0;
}
-.toolbar p {
+.toolbar output {
margin: 0;
}
@@ -50,6 +50,19 @@
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
gap: 12px;
margin-bottom: 16px;
+ min-width: 0;
+ padding: 0;
+ border: 0;
+}
+
+.filtersLegend {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
}
.filter {
diff --git a/src/components/BMDashboard/Projects/ProjectDetails/__tests__/ProjectLog.test.jsx b/src/components/BMDashboard/Projects/ProjectDetails/__tests__/ProjectLog.test.jsx
index 3a1cb9ca8c5..9fd85f80cea 100644
--- a/src/components/BMDashboard/Projects/ProjectDetails/__tests__/ProjectLog.test.jsx
+++ b/src/components/BMDashboard/Projects/ProjectDetails/__tests__/ProjectLog.test.jsx
@@ -52,6 +52,13 @@ afterEach(() => {
});
describe('on-site member filtering', () => {
+ it('uses a native output and labelled fieldset for accessible search feedback', () => {
+ render();
+ expect(screen.getByRole('status').tagName).toBe('OUTPUT');
+ expect(screen.getByRole('status')).toHaveTextContent('Showing 3 of 3 members');
+ expect(screen.getByRole('group', { name: 'Filter members' }).tagName).toBe('FIELDSET');
+ });
+
it('preserves all existing columns and starts with clear disabled', () => {
render();
expect(within(table()).getAllByRole('columnheader')).toHaveLength(8);