Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const IconCheck = () => (
</svg>
);

type SectionId = 'properties' | 'group' | 'order';
type SectionId = 'properties' | 'group' | 'subGroup' | 'order';

/** Order matches the work-items Display reference. */
const GROUP_OPTIONS: { value: SavedViewGroupBy; label: string }[] = [
Expand Down Expand Up @@ -136,6 +136,7 @@ export function ProjectIssuesDisplayPanel({ display, setDisplay }: ProjectIssues
const [sections, setSections] = useState<Record<SectionId, boolean>>({
properties: true,
group: true,
subGroup: true,
order: true,
});

Expand Down Expand Up @@ -195,7 +196,34 @@ export function ProjectIssuesDisplayPanel({ display, setDisplay }: ProjectIssues
value={opt.value}
label={opt.label}
selected={display.groupBy === opt.value}
onSelect={(v) => setDisplay((p) => ({ ...p, groupBy: v }))}
onSelect={(v) =>
setDisplay((p) => ({
...p,
groupBy: v,
subGroupBy: p.subGroupBy === v ? 'none' : p.subGroupBy,
}))
}
/>
))}
</div>
</CollapsibleSection>

<CollapsibleSection
id="subGroup"
title="Sub-group by"
expanded={sections.subGroup}
onToggle={toggleSection}
>
<div className="flex flex-col gap-0.5">
{GROUP_OPTIONS.filter(
(opt) => opt.value === 'none' || opt.value !== display.groupBy,
).map((opt) => (
<RadioRow
key={opt.value}
value={opt.value}
label={opt.label}
selected={display.subGroupBy === opt.value}
onSelect={(v) => setDisplay((p) => ({ ...p, subGroupBy: v }))}
Comment on lines +221 to +226

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Expose the selected sub-group to assistive technology.

RadioRow renders plain buttons and its visual checkmark is aria-hidden, so the new selector does not announce which option is selected. Add state semantics in the shared control, e.g. aria-pressed={selected}.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/project-issues/ProjectIssuesDisplayPanel.tsx` around
lines 221 - 226, Add selected-state accessibility semantics to the shared
RadioRow control: apply aria-pressed={selected} to its underlying button so
assistive technology can identify the active sub-group option. Update RadioRow
rather than only the ProjectIssuesDisplayPanel call site.

/>
))}
</div>
Expand Down
93 changes: 76 additions & 17 deletions apps/web/src/components/work-item/layouts/IssueLayoutBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,28 @@ import {
} from '../EditableCells';
import { DatePickerTrigger } from '../DatePickerTrigger';
import { isOverdue, membersFromAssigneeIds } from '../../../lib/issueRowHelpers';
import type { GroupedIssuesResult } from '../../../lib/issueListGroupAndSort';
import type { SavedViewGroupBy } from '../../../lib/projectSavedViewDisplay';
import { buildGroupedIssues, type GroupedIssuesResult } from '../../../lib/issueListGroupAndSort';
import type {
IssueApiResponse,
LabelApiResponse,
StateApiResponse,
WorkspaceMemberApiResponse,
} from '../../../api/types';
import type { Priority } from '../../../types';
import type { SavedViewGroupBy, SavedViewOrderBy } from '../../../lib/projectSavedViewDisplay';
import {
issueDisplayId,
STATE_GROUP_LABELS,
STATE_GROUP_ORDER,
type IssueLayoutProps,
} from './IssueLayoutTypes';

/**
* Kanban board. By default it groups by state; when the parent provides a
* display grouping result, columns follow the selected group-by setting.
*/
interface IssueLayoutBoardProps extends IssueLayoutProps {
groupedIssues?: GroupedIssuesResult;
groupBy?: SavedViewGroupBy;
subGroupBy?: SavedViewGroupBy;
orderBy?: SavedViewOrderBy;
showEmptyGroups?: boolean;
}

export function IssueLayoutBoard({
Expand All @@ -52,6 +51,11 @@ export function IssueLayoutBoard({
issueHref,
now,
projectsById,
cycles = [],
modules = [],
subGroupBy = 'none',
orderBy = 'manual',
showEmptyGroups = false,
groupByStateGroup,
groupedIssues,
groupBy,
Expand Down Expand Up @@ -95,12 +99,14 @@ export function IssueLayoutBoard({
// one column per individual state.
const { columns, orphans } = useMemo(() => {
if (groupedIssues) {
const columns = groupedIssues.order.map((key) => ({
key,
title: groupedIssues.isFlat ? 'All work items' : groupedIssues.title(key),
color: stateById.get(key)?.color ?? labelById.get(key)?.color ?? undefined,
items: groupedIssues.groups.get(key) ?? [],
}));
const columns = groupedIssues.order
.map((key) => ({
key,
title: groupedIssues.isFlat ? 'All work items' : groupedIssues.title(key),
color: stateById.get(key)?.color ?? labelById.get(key)?.color ?? undefined,
items: groupedIssues.groups.get(key) ?? [],
}))
.filter((col) => groupedIssues.isFlat || showEmptyGroups || col.items.length > 0);
return { columns, orphans: [] as IssueApiResponse[] };
}

Expand Down Expand Up @@ -149,7 +155,7 @@ export function IssueLayoutBoard({
items: buckets.get(s.id) ?? [],
}));
return { columns, orphans };
}, [groupedIssues, groupByStateGroup, states, issues, stateById, labelById]);
}, [groupedIssues, groupByStateGroup, states, issues, stateById, labelById, showEmptyGroups]);

const dndEnabled =
Boolean(onCardMove) && (groupByStateGroup || !groupedIssues || groupBy === 'states');
Expand Down Expand Up @@ -184,6 +190,62 @@ export function IssueLayoutBoard({
/>
);

const buildColumnSwimlanes = (items: IssueApiResponse[]) => {
if (items.length === 0 || subGroupBy === 'none' || subGroupBy === 'states') return null;
const grouped = buildGroupedIssues({
baseForGrouping: items,
groupBy: subGroupBy,
orderBy,
showEmptyGroups,
states,
cycles,
modules,
labels,
members,
});
return grouped.isFlat ? null : grouped;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

const renderColumnItems = (items: IssueApiResponse[]) => {
const swimlanes = buildColumnSwimlanes(items);
if (!swimlanes) {
return (
<>
{items.map(renderCard)}
{items.length === 0 && (
<p className="px-2 py-6 text-center text-xs text-(--txt-tertiary)">No work items</p>
)}
</>
);
}

return (
<div className="space-y-3">
{swimlanes.order.map((laneKey) => {
const laneItems = swimlanes.groups.get(laneKey) ?? [];
if (laneItems.length === 0 && !showEmptyGroups) return null;
return (
<section key={laneKey} className="space-y-1.5">
<h4 className="flex items-center gap-1.5 px-1 text-[11px] font-semibold text-(--txt-secondary)">
<span className="truncate">{swimlanes.title(laneKey)}</span>
<span className="font-normal text-(--txt-tertiary)">{laneItems.length}</span>
</h4>
<div className="space-y-2">
{laneItems.length > 0 ? (
laneItems.map(renderCard)
) : (
<p className="rounded-md border border-dashed border-(--border-subtle) px-2 py-4 text-center text-xs text-(--txt-tertiary)">
No work items
</p>
)}
</div>
</section>
);
})}
</div>
);
};

// Whether a column accepts the in-flight card (skip its current column).
const canDropOn = (columnKey: string): boolean => {
if (!dndEnabled || !draggingId) return false;
Expand Down Expand Up @@ -222,10 +284,7 @@ export function IssueLayoutBoard({
: undefined
}
>
{col.items.map(renderCard)}
{col.items.length === 0 && (
<p className="px-2 py-6 text-center text-xs text-(--txt-tertiary)">No work items</p>
)}
{renderColumnItems(col.items)}
</BoardColumn>
))}

Expand Down
Loading
Loading