Add multi-select to Lumière project card grid

Each project card now has a checkbox (top-left corner, semi-transparent by
default, fully opaque on hover). When any card is selected a selection bar
slides in above the grid showing: select-all checkbox, count, the existing
bulk-action toolbar (archive, trash, tags, delete), and a deselect-all button.
:has(input:checked) keeps all checkboxes visible once a selection is active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-11 16:03:15 +00:00
co-authored by Claude Sonnet 4.6
parent 484a2e3aac
commit e19cd6e8ba
3 changed files with 174 additions and 24 deletions
@@ -1,4 +1,4 @@
import { memo } from 'react'
import { memo, useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '../context/project-list-context'
import { Project } from '../../../../../types/project/dashboard/api'
@@ -17,6 +17,9 @@ import NewProjectButton from './new-project-button'
import ProjectListTitle from './title/project-list-title'
import LoadMore from './load-more'
import DashApiError from './dash-api-error'
import { ProjectCheckbox } from './table/project-checkbox'
import ProjectTools from './table/project-tools/project-tools'
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
type FormatVariant = 'latex' | 'typst' | 'quarto' | 'quarto-slides'
@@ -55,31 +58,36 @@ const ProjectCard = memo(function ProjectCard({
const initial = project.name.charAt(0).toUpperCase() || '?'
return (
<a
href={`/project/${project.id}`}
className={`lumiere-card lumiere-card--${variant}`}
translate="no"
>
<div className="lumiere-card-thumb">
<span className="lumiere-card-initial">{initial}</span>
<div className="lumiere-card-wrapper">
<div className="lumiere-card-checkbox">
<ProjectCheckbox projectId={project.id} projectName={project.name} />
</div>
<div className="lumiere-card-body">
<span className="lumiere-card-name">{project.name}</span>
<div className="lumiere-card-meta">
<span
className={`lumiere-format-badge lumiere-format-badge--${variant}`}
>
{getFormatLabel(variant)}
</span>
{ownerName && (
<span className="lumiere-card-owner" translate="yes">
{ownerName}
</span>
)}
<a
href={`/project/${project.id}`}
className={`lumiere-card lumiere-card--${variant}`}
translate="no"
>
<div className="lumiere-card-thumb">
<span className="lumiere-card-initial">{initial}</span>
</div>
<span className="lumiere-card-date">{date}</span>
</div>
</a>
<div className="lumiere-card-body">
<span className="lumiere-card-name">{project.name}</span>
<div className="lumiere-card-meta">
<span
className={`lumiere-format-badge lumiere-format-badge--${variant}`}
>
{getFormatLabel(variant)}
</span>
{ownerName && (
<span className="lumiere-card-owner" translate="yes">
{ownerName}
</span>
)}
</div>
<span className="lumiere-card-date">{date}</span>
</div>
</a>
</div>
)
})
@@ -95,9 +103,33 @@ export function ProjectListLumiere() {
filter,
tags,
selectedTagId,
selectedProjects,
selectOrUnselectAllProjects,
} = useProjectListContext()
const selectedTag = tags.find(tag => tag._id === selectedTagId)
const allSelected =
visibleProjects.length > 0 &&
selectedProjects.length === visibleProjects.length
const checkAllRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (checkAllRef.current) {
checkAllRef.current.indeterminate =
selectedProjects.length > 0 && !allSelected
}
}, [selectedProjects, allSelected])
const handleSelectAll = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
selectOrUnselectAllProjects(e.target.checked)
},
[selectOrUnselectAllProjects]
)
const handleDeselectAll = useCallback(() => {
selectOrUnselectAllProjects(false)
}, [selectOrUnselectAllProjects])
return (
// Keep project-ds-nav-page + website-redesign so the sidebar and navbar
@@ -138,6 +170,32 @@ export function ProjectListLumiere() {
/>
</div>
</div>
{selectedProjects.length > 0 && (
<div className="lumiere-selection-bar">
<div className="lumiere-selection-bar-left">
<OLFormCheckbox
autoComplete="off"
checked={allSelected}
onChange={handleSelectAll}
inputRef={checkAllRef}
aria-label={t('select_all_projects')}
/>
<span className="lumiere-selection-count">
{t('n_projects_selected', {
count: selectedProjects.length,
})}
</span>
</div>
<ProjectTools />
<button
type="button"
className="lumiere-selection-deselect"
onClick={handleDeselectAll}
>
{t('deselect_all')}
</button>
</div>
)}
{visibleProjects.length === 0 ? (
<p className="lumiere-empty">{t('no_projects')}</p>
) : (
@@ -331,6 +331,51 @@ $lum-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' wi
margin-top: 2rem;
}
// ── Selection bar (appears when projects are selected) ────────────────────
.lumiere-selection-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
margin-bottom: 1rem;
background: rgba($lum-teal, 0.08);
border: 1px solid rgba($lum-teal, 0.22);
border-radius: 10px;
flex-wrap: wrap;
}
.lumiere-selection-bar-left {
display: flex;
align-items: center;
gap: 0.6rem;
flex-shrink: 0;
}
.lumiere-selection-count {
font-size: 0.875rem;
font-weight: 600;
color: $lum-teal;
white-space: nowrap;
}
.lumiere-selection-deselect {
margin-left: auto;
font-size: 0.8rem;
color: $lum-text-sub;
background: none;
border: none;
padding: 0.2rem 0.4rem;
cursor: pointer;
border-radius: 4px;
flex-shrink: 0;
&:hover {
color: $lum-text;
background: rgba(0, 0, 0, 0.06);
}
}
// ── Card grid ─────────────────────────────────────────────────────────────
.lumiere-card-grid {
@@ -340,6 +385,50 @@ $lum-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' wi
margin-top: 0.5rem;
}
// ── Card wrapper (checkbox + link) ────────────────────────────────────────
.lumiere-card-wrapper {
position: relative;
}
// Checkbox overlay: top-left corner of the card, low opacity by default.
// Becomes fully opaque on hover, on focus, and for all cards when any is
// selected (:has(input:checked) on the grid).
.lumiere-card-checkbox {
position: absolute;
top: 8px;
left: 8px;
z-index: 2;
opacity: 0.35;
transition: opacity 0.15s ease;
input[type='checkbox'] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: $lum-teal;
}
}
.lumiere-card-wrapper:hover .lumiere-card-checkbox,
.lumiere-card-wrapper:focus-within .lumiere-card-checkbox {
opacity: 1;
}
// When any card in the grid is checked, show all checkboxes fully
.lumiere-card-grid:has(input[type='checkbox']:checked) .lumiere-card-checkbox {
opacity: 1;
}
// Selected card: teal border + tint
.lumiere-card-wrapper:has(input[type='checkbox']:checked) .lumiere-card {
border-color: rgba($lum-teal, 0.5);
background: rgba($lum-teal, 0.06);
box-shadow:
0 0 0 2px rgba($lum-teal, 0.18),
0 2px 8px rgba(0, 0, 0, 0.07);
}
// ── Individual card ───────────────────────────────────────────────────────
.lumiere-card {
+3
View File
@@ -625,6 +625,7 @@
"demonstrating_track_changes_feature": "Demonstrating Track Changes feature",
"department": "Department",
"descending": "Descending",
"deselect_all": "Deselect all",
"description": "Description",
"details": "Details",
"details_provided_by_google_explanation": "Your details were provided by your Google account. Please check youre happy with them.",
@@ -1725,6 +1726,8 @@
"no_planned_maintenance": "There is currently no planned maintenance",
"no_preview_available": "Sorry, no preview is available.",
"no_project_notifications_description": "You wont be notified about this project.",
"n_projects_selected": "__count__ project selected",
"n_projects_selected_plural": "__count__ projects selected",
"no_projects": "No projects",
"no_resolved_comments": "No resolved comments",
"no_search_results": "No Search Results",