Skip to content
Merged
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
21 changes: 21 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,25 @@
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

/* Pointer on interactive controls (Tailwind preflight defaults buttons to default cursor). */
button:not(:disabled),
input[type="button"]:not(:disabled),
input[type="submit"]:not(:disabled),
input[type="reset"]:not(:disabled),
[role="button"]:not([aria-disabled="true"]) {
cursor: pointer;
}

button:disabled,
input[type="button"]:disabled,
input[type="submit"]:disabled,
input[type="reset"]:disabled,
[role="button"][aria-disabled="true"] {
cursor: not-allowed;
}

::file-selector-button {
cursor: pointer;
}
}
6 changes: 3 additions & 3 deletions src/components/volunteers/AddVolunteerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,11 +252,11 @@ function CohortField({
onRemove={() => removeAt(i)}
/>
))}
<div className="flex flex-1 min-w-[min(100%,12rem)] flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<select
value={term}
onChange={(e) => setTerm(e.target.value as CohortTerm)}
className="rounded-md border border-gray-200 bg-gray-50/80 py-1.5 pl-2 pr-7 text-sm text-gray-900 outline-none focus:border-purple-400 focus:ring-1 focus:ring-purple-500/30"
className="w-23 shrink-0 rounded-md border border-gray-200 bg-gray-50/80 py-1.5 pl-1.5 pr-6 text-sm text-gray-900 outline-none focus:border-purple-400 focus:ring-1 focus:ring-purple-500/30"
aria-label="Cohort term"
>
{COHORT_TERMS.map((t) => (
Expand All @@ -277,7 +277,7 @@ function CohortField({
addDraft();
}
}}
className="w-24 rounded-md border border-gray-200 bg-gray-50/80 py-1.5 px-2 text-sm tabular-nums outline-none focus:border-purple-400 focus:ring-1 focus:ring-purple-500/30"
className="w-16 shrink-0 rounded-md border border-gray-200 bg-gray-50/80 py-1.5 px-1.5 text-center text-sm tabular-nums outline-none focus:border-purple-400 focus:ring-1 focus:ring-purple-500/30"
aria-label="Cohort year"
/>
<button
Expand Down
138 changes: 108 additions & 30 deletions src/components/volunteers/FilterBar.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import React, { useCallback, useEffect, useState } from "react";
import { FilterTuple } from "@/lib/api/getVolunteersByMultipleColumns";
import { ChevronDown, Plus, X, ShieldCheck, AlertTriangle } from "lucide-react";
import {
ChevronDown,
Plus,
X,
ShieldCheck,
AlertTriangle,
Filter,
} from "lucide-react";
import { SortingState } from "@tanstack/react-table";
import clsx from "clsx";
import { FILTERABLE_COLUMNS } from "./volunteerColumns";
import { FilterModal, filterModalAlignRight } from "./FilterModal";
import { DEFAULT_OPT_IN_FILTER } from "./useVolunteersData";
import {
BLANK_FIELD_FILTER_VALUE,
CONTACT_INCOMPLETE_FIELD,
} from "@/lib/volunteerFilterShortcuts";

function isDefaultFilter(filter: FilterTuple): boolean {
return (
Expand All @@ -20,6 +31,30 @@ function columnFilterCount(filters: FilterTuple[]): number {
return filters.filter((f) => f.field !== DEFAULT_OPT_IN_FILTER.field).length;
}

function isShortcutPresetFilter(f: FilterTuple): boolean {
if (f.field === CONTACT_INCOMPLETE_FIELD) return true;
if (
(f.field === "email" || f.field === "phone") &&
f.values[0] === BLANK_FIELD_FILTER_VALUE
) {
return true;
}
return false;
}

function shortcutPresetLabel(f: FilterTuple): string | null {
if (f.field === "email" && f.values[0] === BLANK_FIELD_FILTER_VALUE) {
return "Missing email";
}
if (f.field === "phone" && f.values[0] === BLANK_FIELD_FILTER_VALUE) {
return "Missing phone";
}
if (f.field === CONTACT_INCOMPLETE_FIELD) {
return "Missing email or phone";
}
return null;
}

type OptInWarningVariant = "remove" | "include-no";

const VARIANT_CONTENT: Record<
Expand Down Expand Up @@ -162,6 +197,9 @@ export const FilterBar = ({
if (filters.length === 0 && sorting.length === 0) return null;

const handleEditClick = (e: React.MouseEvent, index: number): void => {
const target = filters[index];
if (target && isShortcutPresetFilter(target)) return;

setIsAddingNew(false);
setNewAnchorRect(null);
if (editingIndex === index) {
Expand Down Expand Up @@ -314,6 +352,15 @@ export const FilterBar = ({
const isCurrentlyEditing = editingIndex === index;
const Icon = colDef?.icon;
const isDefault = isDefaultFilter(filter);
const presetShortcut = shortcutPresetLabel(filter);
const isPreset = isShortcutPresetFilter(filter);

const chipStyle = clsx(
"flex h-9 items-center gap-2 rounded-lg border text-sm font-medium transition-colors",
isDefault
? "border-emerald-200/90 bg-emerald-50 text-emerald-900"
: "border-gray-200 bg-white text-gray-900"
);

return (
<div
Expand All @@ -323,36 +370,67 @@ export const FilterBar = ({
isCurrentlyEditing ? "z-50" : "z-10"
)}
>
<button
type="button"
onClick={(e) => handleEditClick(e, index)}
aria-expanded={isCurrentlyEditing}
aria-haspopup="dialog"
title={
isDefault
? "Default privacy filter — click to edit or remove"
: `Filter by ${colDef?.label ?? filter.field} — click to edit`
}
className={clsx(
"flex h-9 cursor-pointer items-center gap-2 rounded-lg border px-2.5 text-sm font-medium transition-colors",
"focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-300 focus-visible:ring-offset-1",
isDefault
? "border-emerald-200/90 bg-emerald-50 text-emerald-900 hover:bg-emerald-100"
: "border-gray-200 bg-white text-gray-900 hover:border-gray-300 hover:bg-white"
)}
>
{isDefault ? (
<ShieldCheck className="h-3.5 w-3.5 shrink-0 text-gray-600" />
) : (
Icon && (
{isPreset ? (
<div
className={clsx(
chipStyle,
"cursor-default pr-0.5 focus-within:ring-2 focus-within:ring-gray-300 focus-within:ring-offset-1"
)}
title="Shortcut filter from Shortcuts panel"
>
{Icon ? (
<Icon className="ml-2.5 h-3.5 w-3.5 shrink-0 text-gray-600" />
) : (
<Filter className="ml-2.5 h-3.5 w-3.5 shrink-0 text-gray-600" />
)}
<span className="max-w-44 truncate whitespace-nowrap sm:max-w-60">
{presetShortcut}
</span>
<button
type="button"
onClick={() => {
setFilters((prev) =>
prev.filter((_, i) => i !== index)
);
setEditingIndex(null);
setEditAnchorRect(null);
}}
className="mr-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-800"
aria-label="Remove filter"
title="Remove filter"
>
<X className="h-3.5 w-3.5" strokeWidth={2} />
</button>
</div>
) : (
<button
type="button"
onClick={(e) => handleEditClick(e, index)}
aria-expanded={isCurrentlyEditing}
aria-haspopup="dialog"
title={
isDefault
? "Default privacy filter — click to edit or remove"
: `Filter by ${colDef?.label ?? filter.field} — click to edit`
}
className={clsx(
chipStyle,
"cursor-pointer px-2.5 hover:border-gray-300 hover:bg-white",
"focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-300 focus-visible:ring-offset-1",
isDefault && "hover:bg-emerald-100"
)}
>
{isDefault ? (
<ShieldCheck className="h-3.5 w-3.5 shrink-0 text-gray-600" />
) : Icon ? (
<Icon className="h-3.5 w-3.5 shrink-0 text-gray-600" />
)
)}
<span className="max-w-48 truncate whitespace-nowrap sm:max-w-64">
{isDefault ? "Opt-in (default)" : colDef?.label}
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-gray-500" />
</button>
) : null}
<span className="max-w-48 truncate whitespace-nowrap sm:max-w-64">
{isDefault ? "Opt-in (default)" : colDef?.label}
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-gray-500" />
</button>
)}

<FilterModal
isOpen={isCurrentlyEditing}
Expand Down
Loading
Loading