From fd366cec440c287267b13d94bcccd3315aa8d10a Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:18:02 -0700 Subject: [PATCH 1/3] Add presentational Viewing-menu (Timeline filter) demo story Move the Playground surface data and pure derivation helpers out of Timeline.stories.tsx into internal/timelinePlaygroundData so both the Playground and the new filter demo share them. Behavior is preserved: the Playground stories render identically. Add Timeline.filter.features.stories.tsx: an ActionMenu multi-select "Viewing" control that filters Timeline rows consumer-side. The opening and closing lifecycle bookends stay pinned outside the predicate so the timeline never renders empty regardless of toggles; the consumer owns that guarantee, not the Timeline or control components. Story and internal scaffolding only: no public API export, no changeset. The authoritative Timeline taxonomy stays in github-ui. --- ...imeline.filter.features.stories.module.css | 4 + .../Timeline.filter.features.stories.tsx | 206 +++++ .../src/Timeline/Timeline.stories.module.css | 9 +- .../react/src/Timeline/Timeline.stories.tsx | 706 +----------------- .../timelinePlaygroundData.module.css | 10 + .../internal/timelinePlaygroundData.tsx | 701 +++++++++++++++++ 6 files changed, 933 insertions(+), 703 deletions(-) create mode 100644 packages/react/src/Timeline/Timeline.filter.features.stories.module.css create mode 100644 packages/react/src/Timeline/Timeline.filter.features.stories.tsx create mode 100644 packages/react/src/Timeline/internal/timelinePlaygroundData.module.css create mode 100644 packages/react/src/Timeline/internal/timelinePlaygroundData.tsx diff --git a/packages/react/src/Timeline/Timeline.filter.features.stories.module.css b/packages/react/src/Timeline/Timeline.filter.features.stories.module.css new file mode 100644 index 00000000000..f76b0293ace --- /dev/null +++ b/packages/react/src/Timeline/Timeline.filter.features.stories.module.css @@ -0,0 +1,4 @@ +/* Story-only styles for the Timeline "Viewing" filter demo. Not shipped. */ +.Toolbar { + margin-block-end: var(--base-size-16); +} diff --git a/packages/react/src/Timeline/Timeline.filter.features.stories.tsx b/packages/react/src/Timeline/Timeline.filter.features.stories.tsx new file mode 100644 index 00000000000..38f529023ef --- /dev/null +++ b/packages/react/src/Timeline/Timeline.filter.features.stories.tsx @@ -0,0 +1,206 @@ +import type {Meta} from '@storybook/react-vite' +import {useState} from 'react' +import {CheckCircleIcon, EyeIcon, IssueOpenedIcon} from '@primer/octicons-react' +import type {ComponentProps} from '../utils/types' +import {ActionList} from '../ActionList' +import {ActionMenu} from '../ActionMenu' +import {FeatureFlags} from '../FeatureFlags' +import Timeline from './Timeline' +import {Examples, MutedTime, UserActor} from './internal/timelineStoryHelpers' +import { + PLAYGROUND_SURFACES, + playgroundCategoryIds, + playgroundEvents, + type PlaygroundCategoryId, +} from './internal/timelinePlaygroundData' +import classes from './Timeline.filter.features.stories.module.css' + +export default { + title: 'Components/Timeline/Filter', + component: Timeline, + subcomponents: { + 'Timeline.Item': Timeline.Item, + 'Timeline.Badge': Timeline.Badge, + 'Timeline.Body': Timeline.Body, + }, + decorators: [ + // File-scoped: render every story in the future-state list semantics + // (`
    `/`
  1. `), matching the other Timeline surface stories. + Story => ( + + + + ), + ], +} as Meta> + +// The single representative surface for this demo. `issue` offers the richest +// category set (status / references / moderation / metadata). +const SURFACE_ID = 'issue' as const + +// Lifecycle types the consumer PINS as never-filtered bookends (see the story +// notes). They are excluded from the filterable interior so a toggled-on +// category never renders a duplicate opening/closing row. +const BOOKEND_LIFECYCLE_TYPES = new Set(['opened', 'closed']) + +type ViewingOption = {id: PlaygroundCategoryId; label: string} + +/** + * Presentational "Viewing" control. Its entire contract is `options` / + * `selected` / `onSelectedChange`; it reads NO `data-*` attributes and knows + * nothing about the Timeline it filters. The consumer owns the predicate and + * decides which rows render. This mirrors the prototype's per-surface Viewing + * menu: a Primer `ActionMenu` multi-select (`selectionVariant="multiple"`) whose + * checklist items stay open on select and expose `role="menuitemcheckbox"` / + * `aria-checked` for screen readers. The trigger's accessible name is its text + * ("Viewing"); the eye icon is decorative. + */ +function ViewingFilterMenu({ + options, + selected, + onSelectedChange, +}: { + options: ViewingOption[] + selected: PlaygroundCategoryId[] + onSelectedChange: (next: PlaygroundCategoryId[]) => void +}) { + return ( + + Viewing + + + {options.map(option => { + const isSelected = selected.includes(option.id) + return ( + + onSelectedChange(isSelected ? selected.filter(id => id !== option.id) : [...selected, option.id]) + } + > + {option.label} + + ) + })} + + + + ) +} + +/** + * Viewing menu (consumer-side category filtering). + * + * This is a PRESENTATIONAL demo of the prototype's per-surface "Viewing" menu. A + * Primer `ActionMenu` multi-select toggles which event CATEGORIES the selected + * surface renders. There is NO public `Timeline.Filter` API: the control + * (`ViewingFilterMenu`) is story-local scaffolding and filtering is 100% + * consumer-side. + * + * How the pieces divide responsibility: + * - The control is presentational. Its contract is `options` / `selected` / + * `onSelectedChange`. It does NOT read the `data-*` attributes on the rows. + * - The consumer (this story) owns the predicate. It filters the representative + * rows on each event's `category` and hands the survivors to `Timeline`. + * - `Timeline` and its slots stay taxonomy-agnostic. The `data-event-*` + * attributes ride along on `Timeline.Item` exactly as the Playground renders + * them; the control never inspects them. + * + * Never-empty guarantee (CONSUMER-owned): the opening and closing lifecycle + * events are PINNED outside the category predicate, so the timeline never + * renders empty no matter which categories are toggled off. This is the + * consumer's responsibility, NOT the Timeline or control components. Because the + * consumer pins these bookends, it also excludes their lifecycle types from the + * filterable interior (`BOOKEND_LIFECYCLE_TYPES`) so a toggled-on category can't + * duplicate them. + * + * The representative data is illustrative, github-flavored sample data only. The + * authoritative Timeline event taxonomy lives in github-ui as + * `@github-ui/timeline-taxonomy`; primer/react is taxonomy-agnostic. + */ +export const ViewingMenu = () => { + const surface = PLAYGROUND_SURFACES[SURFACE_ID] + const categoryIds = playgroundCategoryIds(SURFACE_ID) + const options: ViewingOption[] = categoryIds.map(id => ({ + id, + label: surface.categories[id]?.label ?? id, + })) + + // All categories start selected: nothing is filtered out initially. + const [selected, setSelected] = useState(categoryIds) + + // Consumer-side predicate: keep rows whose category is still selected, minus + // the pinned lifecycle bookends. + const interior = playgroundEvents(SURFACE_ID, selected).filter(event => !BOOKEND_LIFECYCLE_TYPES.has(event.type)) + + return ( + +
    + +
    + + {/* + PINNED opening bookend — consumer-owned, never filtered. It carries no + `data-event-category`, so it sits outside the category axis the Viewing + menu toggles. + */} + + + {/* Decorative: the summary text in Timeline.Body is the accessible description. */} + + + + + {'opened this '} + + + + + {interior.map(event => { + const BadgeIcon = event.badge.icon + return ( + + + {/* Decorative: the summary text in Timeline.Body is the accessible description. */} + + + {event.body} + {event.actions ? {event.actions} : null} + + ) + })} + + {/* PINNED closing bookend — consumer-owned, never filtered. */} + + + {/* Decorative: the summary text in Timeline.Body is the accessible description. */} + + + + + {'closed this as completed '} + + + + +
    + ) +} diff --git a/packages/react/src/Timeline/Timeline.stories.module.css b/packages/react/src/Timeline/Timeline.stories.module.css index beaf0839b4c..2945adf4784 100644 --- a/packages/react/src/Timeline/Timeline.stories.module.css +++ b/packages/react/src/Timeline/Timeline.stories.module.css @@ -52,16 +52,11 @@ } /* - * Timeline Playground: story-local styles for the representative event bodies and the empty - * state rendered by the `TimelinePlayground` story (its controls are Storybook argTypes). + * Timeline Playground: story-local style for the empty state rendered by the + * `TimelinePlayground` story (its controls are Storybook argTypes). */ .PlaygroundEmpty { max-width: 1012px; color: var(--fgColor-muted); } - -.Strong { - font-weight: var(--base-text-weight-semibold); - color: var(--fgColor-default); -} diff --git a/packages/react/src/Timeline/Timeline.stories.tsx b/packages/react/src/Timeline/Timeline.stories.tsx index d77135d4b79..56db93ac39e 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -7,7 +7,6 @@ import {TimelineBadgeVariants} from './constants' import Avatar from '../Avatar' import {Button} from '../Button' import Link from '../Link' -import Label from '../Label' import RelativeTime from '../RelativeTime' import { AlertIcon, @@ -42,24 +41,18 @@ import { TrashIcon, UnlockIcon, XCircleIcon, - // Additional badge icons used by the Timeline Playground story below. - BlockedIcon, - CheckIcon, - CommentIcon, - DotFillIcon, - type Icon, - LinkExternalIcon, - MarkGithubIcon, - NoteIcon, - ShieldCheckIcon, - ShieldSlashIcon, - ShieldXIcon, - SyncIcon, - XIcon, } from '@primer/octicons-react' import {FeatureFlags} from '../FeatureFlags' import Text from '../Text' -import {BoldLink, EventSubRow, Examples, MONALISA_AVATAR, MutedTime, UserActor} from './internal/timelineStoryHelpers' +import {Examples} from './internal/timelineStoryHelpers' +import { + PLAYGROUND_SURFACES, + PLAYGROUND_SURFACE_IDS, + playgroundCategoryIds, + playgroundEvents, + type PlaygroundCategoryId, + type PlaygroundSurfaceId, +} from './internal/timelinePlaygroundData' import classes from './Timeline.stories.module.css' export default { @@ -554,685 +547,6 @@ EventPlayground.argTypes = { // Timeline Playground // ============================================================================ -/** - * ILLUSTRATIVE, REPRESENTATIVE DATA — read this before treating anything below as canonical. - * - * The **Timeline Playground** demonstrates how filtering `data-*` attributes - * (`data-event-scope`, `data-event-type`, `data-event-category`, `data-event-visibility`, - * `data-actor-type`) are embedded on each `Timeline.Item` across GitHub surfaces. It mirrors - * the Figma prototype: a `surface` control picks a surface, a categories control filters by the - * categories that surface offers, and an event-types control selects which event types render. - * Changing the surface reveals that surface's category and event-type controls and hides the - * others. - * - * The `PLAYGROUND_SURFACES` map below is a small, hardcoded, story-local sample of - * github-flavored events (a handful per surface, NOT the full ~160-row catalog). Its - * surface/category/type shape and every copy string are ILLUSTRATIVE examples for this demo - * only. The AUTHORITATIVE, per-surface timeline taxonomy is a GitHub product concern owned by - * `github-ui` (the product repositories that render these timelines). Primer does not host an - * authoritative taxonomy; any internal Primer taxonomy code is non-authoritative and not public - * API. Do not treat this inline map as the real catalog, do not export it, and do not promote it - * into a reusable module — it is intentionally confined to this story file. - * - * The picker is structured so a future `Timeline.Filter` can drive it: the render pipeline - * derives `visibleRows` from the selected categories and types, then maps each row to a - * ``. - */ - -type PlaygroundSurfaceId = 'code-scanning' | 'secret-scanning' | 'dependabot' | 'license-compliance' | 'issue' -type PlaygroundCategoryId = 'findings' | 'status' | 'reviews' | 'references' | 'moderation' | 'metadata' -// Actor classification, mirrors the authoritative `ActorType` value space (`user | bot`): -// first-party automation such as the GitHub secret-scanning system actor is `bot`. -type PlaygroundActorType = 'user' | 'bot' - -type PlaygroundEvent = { - /** `data-event-type` value */ - type: string - /** `data-event-category` value */ - category: PlaygroundCategoryId - /** Human-readable label shown in the event-type picker */ - label: string - /** - * `data-event-visibility` value. Matches the authoritative `EventVisibility` value space - * (`@github-ui/timeline-taxonomy`): `primary` rows render in the main timeline; `auditOnly` - * rows are metadata-only. - */ - visibility: 'primary' | 'auditOnly' - /** `data-actor-type` value; omit for actor-less rows so no `data-actor-type` attribute renders */ - actorType?: PlaygroundActorType - badge: {icon: Icon; variant?: TimelineBadgeVariant} - /** Contents of `Timeline.Body` */ - body: React.ReactNode - /** Optional contents of the right-aligned `Timeline.Actions` slot */ - actions?: React.ReactNode -} - -type PlaygroundSurface = { - label: string - /** Accessible name for the rendered `` */ - ariaLabel: string - categories: Partial> -} - -// Story-local demo avatars (MONALISA_AVATAR is imported from the shared helpers). -const DEPENDABOT_BOT_AVATAR = 'https://avatars.githubusercontent.com/u/27347476?v=4' -const LICENSE_BOT_AVATAR = 'https://avatars.githubusercontent.com/u/9919?s=40&v=4' -const HUBOT_AVATAR = 'https://avatars.githubusercontent.com/u/480938?v=4' - -// ILLUSTRATIVE representative data (see the canon note above). Each event mirrors the -// badge/icon/copy/actor of the matching VariantSection in that surface's existing -// `Timeline..features.stories.tsx`, reduced to a demonstrative subset. The four -// security surfaces offer findings/status/reviews; issues offers status/references/moderation. -const PLAYGROUND_SURFACES: Record = { - 'code-scanning': { - label: 'Code scanning', - ariaLabel: 'Code scanning alert timeline', - categories: { - findings: { - label: 'Findings', - events: [ - { - type: 'detected', - category: 'findings', - label: 'First detected in commit', - visibility: 'primary', - badge: {icon: ShieldIcon}, - body: ( - <> - First detected in commit{' '} - - - ), - }, - { - type: 'fixed', - category: 'findings', - label: 'Fixed in branch', - visibility: 'primary', - badge: {icon: ShieldCheckIcon, variant: 'done'}, - body: ( - <> - Fixed in branch main{' '} - - - ), - }, - ], - }, - status: { - label: 'Status', - events: [ - { - type: 'closed', - category: 'status', - label: 'Closed as false positive', - visibility: 'primary', - actorType: 'user', - badge: {icon: ShieldXIcon, variant: 'danger'}, - body: ( - <> - - {'closed this as '} - false positive{' '} - - - ), - }, - { - type: 'reopened', - category: 'status', - label: 'Reopened', - visibility: 'primary', - actorType: 'user', - badge: {icon: DotFillIcon, variant: 'success'}, - body: ( - <> - - {'reopened this '} - - - ), - }, - ], - }, - reviews: { - label: 'Reviews', - events: [ - { - type: 'dismissal_requested', - category: 'reviews', - label: 'Requested to dismiss', - visibility: 'primary', - actorType: 'user', - badge: {icon: CommentIcon}, - body: ( - <> - - {'requested to dismiss this as false positive '} - - This finding is a test-only helper, safe to dismiss. - - ), - actions: ( - - ), - }, - { - type: 'dismissal_reviewed', - category: 'reviews', - label: 'Approved dismissal', - visibility: 'primary', - actorType: 'user', - badge: {icon: CheckIcon}, - body: ( - <> - - {'approved dismissal '} - - - ), - }, - ], - }, - }, - }, - 'secret-scanning': { - label: 'Secret scanning', - ariaLabel: 'Secret scanning alert timeline', - categories: { - findings: { - label: 'Findings', - events: [ - { - type: 'detected', - category: 'findings', - label: 'Created', - visibility: 'primary', - actorType: 'bot', - badge: {icon: ShieldIcon, variant: 'success'}, - body: ( - <> - - {'opened this alert '} - - - ), - }, - { - type: 'validity_active', - category: 'findings', - label: 'Validity: active', - visibility: 'primary', - actorType: 'bot', - badge: {icon: AlertIcon, variant: 'danger'}, - body: ( - <> - - {'verified this secret is active '} - - - ), - }, - ], - }, - status: { - label: 'Status', - events: [ - { - type: 'closed', - category: 'status', - label: 'Closed as revoked', - visibility: 'primary', - actorType: 'user', - badge: {icon: ShieldCheckIcon, variant: 'done'}, - body: ( - <> - - {'closed this as '} - revoked - - Rotated the leaked token and confirmed the provider revoked it. - - - ), - }, - { - type: 'reopened', - category: 'status', - label: 'Reopened', - visibility: 'primary', - actorType: 'user', - badge: {icon: SyncIcon, variant: 'success'}, - body: ( - <> - - {'reopened this '} - - - ), - }, - ], - }, - reviews: { - label: 'Reviews', - events: [ - { - type: 'closure_requested', - category: 'reviews', - label: 'Requested to dismiss', - visibility: 'primary', - actorType: 'user', - badge: {icon: CommentIcon}, - body: ( - <> - - {'requested to dismiss this as false positive '} - - - ), - actions: ( - - ), - }, - { - type: 'bypass_approved', - category: 'reviews', - label: 'Bypass approved', - visibility: 'primary', - actorType: 'user', - badge: {icon: CheckCircleIcon}, - body: ( - <> - - {'approved a bypass '} - - - ), - }, - ], - }, - }, - }, - dependabot: { - label: 'Dependabot', - ariaLabel: 'Dependabot alert timeline', - categories: { - findings: { - label: 'Findings', - events: [ - { - type: 'opened', - category: 'findings', - label: 'Opened', - visibility: 'primary', - actorType: 'bot', - badge: {icon: ShieldIcon, variant: 'success'}, - body: ( - <> - - {'opened this '} - - - ), - }, - { - type: 'fixed', - category: 'findings', - label: 'Fixed', - visibility: 'primary', - actorType: 'bot', - badge: {icon: ShieldCheckIcon, variant: 'done'}, - body: ( - <> - - {'closed this as completed '} - - - ), - }, - ], - }, - status: { - label: 'Status', - events: [ - { - type: 'reopened', - category: 'status', - label: 'Reopened', - visibility: 'primary', - actorType: 'user', - badge: {icon: SyncIcon, variant: 'success'}, - body: ( - <> - - {'reopened this '} - - - ), - }, - { - type: 'dismissed', - category: 'status', - label: 'Dismissed', - visibility: 'primary', - actorType: 'user', - badge: {icon: ShieldSlashIcon}, - body: ( - <> - - {'dismissed this as '} - risk is tolerable{' '} - - Only reachable from a dev-only script we do not ship. - - ), - }, - ], - }, - reviews: { - label: 'Reviews', - events: [ - { - type: 'dismissal_requested', - category: 'reviews', - label: 'Dismissal requested', - visibility: 'primary', - actorType: 'user', - badge: {icon: CommentIcon}, - body: ( - <> - - {'requested to dismiss this '} - - - ), - actions: ( - - ), - }, - ], - }, - }, - }, - 'license-compliance': { - label: 'License compliance', - ariaLabel: 'License compliance alert timeline', - categories: { - findings: { - label: 'Findings', - events: [ - { - type: 'opened', - category: 'findings', - label: 'Opened', - visibility: 'primary', - actorType: 'bot', - badge: {icon: ShieldIcon, variant: 'success'}, - body: ( - <> - - {' opened this alert '} - - - ), - }, - { - type: 'appeared_in_branch', - category: 'findings', - label: 'Appeared in branch', - visibility: 'primary', - badge: {icon: GitBranchIcon}, - body: ( - <> - {'Appeared in branch '} - feature-branch{' '} - - - ), - }, - ], - }, - status: { - label: 'Status', - events: [ - { - type: 'closed', - category: 'status', - label: 'Closed as amendment', - visibility: 'primary', - actorType: 'user', - badge: {icon: ShieldCheckIcon, variant: 'done'}, - body: ( - <> - - {' closed as amendment '} - - Added a policy exception covering this package. - - ), - }, - ], - }, - reviews: { - label: 'Reviews', - events: [ - { - type: 'review_requested', - category: 'reviews', - label: 'Requested to close', - visibility: 'primary', - actorType: 'user', - badge: {icon: CommentIcon}, - body: ( - <> - - {' requested to close '} - - - ), - actions: ( - - ), - }, - { - type: 'review_approved', - category: 'reviews', - label: 'Approved closure request', - visibility: 'primary', - actorType: 'user', - badge: {icon: CheckIcon}, - body: ( - <> - - {' approved closure request '} - - - ), - }, - { - type: 'review_denied', - category: 'reviews', - label: 'Denied closure request', - visibility: 'primary', - actorType: 'user', - badge: {icon: XIcon}, - body: ( - <> - - {' denied closure request '} - - - ), - }, - ], - }, - }, - }, - issue: { - label: 'Issues', - ariaLabel: 'Issue timeline', - categories: { - status: { - label: 'Status', - events: [ - { - type: 'closed', - category: 'status', - label: 'Closed as completed', - visibility: 'primary', - actorType: 'user', - badge: {icon: CheckCircleIcon, variant: 'done'}, - body: ( - <> - - {'closed this as '} - - completed - {' '} - - - ), - }, - { - type: 'reopened', - category: 'status', - label: 'Reopened', - visibility: 'primary', - actorType: 'user', - badge: {icon: IssueReopenedIcon, variant: 'open'}, - body: ( - <> - - {'reopened this '} - - - ), - }, - ], - }, - references: { - label: 'References', - events: [ - { - type: 'connected', - category: 'references', - label: 'Linked pull request', - visibility: 'primary', - actorType: 'user', - badge: {icon: CrossReferenceIcon}, - body: ( - <> - - {'linked a pull request that will close this issue '} - Add retry logic to the uploader - {' #42 '} - - - ), - }, - { - type: 'cross_referenced', - category: 'references', - label: 'Mentioned in an issue', - visibility: 'primary', - actorType: 'user', - badge: {icon: LinkExternalIcon}, - body: ( - <> - - {'mentioned this '} - - - - Track flaky upload retries - - {' #128'} - - - ), - }, - ], - }, - moderation: { - label: 'Moderation', - events: [ - { - type: 'user_blocked', - category: 'moderation', - label: 'User blocked', - visibility: 'primary', - actorType: 'user', - badge: {icon: BlockedIcon}, - body: ( - <> - - {'blocked '} - six7 - - ), - }, - { - type: 'comment_pinned', - category: 'moderation', - label: 'Comment pinned', - visibility: 'primary', - actorType: 'user', - badge: {icon: PinIcon}, - body: ( - <> - - {'pinned a '} - - comment - {' '} - - - ), - }, - ], - }, - metadata: { - label: 'Metadata', - events: [ - { - type: 'labeled', - category: 'metadata', - label: 'Labeled (audit only)', - visibility: 'auditOnly', - actorType: 'user', - badge: {icon: TagIcon}, - body: ( - <> - - {'added the '} - - {' label '} - - - ), - }, - ], - }, - }, - }, -} - -const PLAYGROUND_SURFACE_IDS = Object.keys(PLAYGROUND_SURFACES) as PlaygroundSurfaceId[] - -const playgroundCategoryIds = (surface: PlaygroundSurfaceId): PlaygroundCategoryId[] => - Object.keys(PLAYGROUND_SURFACES[surface].categories) as PlaygroundCategoryId[] - -const playgroundEvents = (surface: PlaygroundSurfaceId, categories: PlaygroundCategoryId[]): PlaygroundEvent[] => - categories.flatMap(category => PLAYGROUND_SURFACES[surface].categories[category]?.events ?? []) - /** * Args for the {@link TimelinePlayground} story. A single `surface` selector, plus a categories * multi-select and an event-types multi-select for each of the five surfaces. Only the selected @@ -1301,7 +615,7 @@ const playgroundTypeControl = (surface: PlaygroundSurfaceId) => { } /** - * The **Timeline Playground** (see the canon note above for the illustrative-data caveat). + * The **Timeline Playground** (see the illustrative-data note in `internal/timelinePlaygroundData.tsx`). * * The picker is built from real Storybook controls, not in-canvas form elements. Storybook cannot * repopulate one control's options from another control's value, so instead of a single dependent diff --git a/packages/react/src/Timeline/internal/timelinePlaygroundData.module.css b/packages/react/src/Timeline/internal/timelinePlaygroundData.module.css new file mode 100644 index 00000000000..1ec9c063c58 --- /dev/null +++ b/packages/react/src/Timeline/internal/timelinePlaygroundData.module.css @@ -0,0 +1,10 @@ +/* + * Story-local styles for the representative Timeline event bodies in + * `timelinePlaygroundData.tsx`. Shared by the Timeline Playground story and the + * Timeline "Viewing" filter story so the emphasized inline text renders identically. + */ + +.Strong { + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-default); +} diff --git a/packages/react/src/Timeline/internal/timelinePlaygroundData.tsx b/packages/react/src/Timeline/internal/timelinePlaygroundData.tsx new file mode 100644 index 00000000000..d9dffed2611 --- /dev/null +++ b/packages/react/src/Timeline/internal/timelinePlaygroundData.tsx @@ -0,0 +1,701 @@ +/** + * Internal, story-only Timeline "playground" sample data. Not part of the public + * API (stories don't ship) and intentionally NOT exported from the package index. + * + * `PLAYGROUND_SURFACES` is an ILLUSTRATIVE, github-flavored map of representative + * event surfaces/categories used only to drive the Timeline Playground and the + * Timeline "Viewing" filter stories. It is NOT an authoritative taxonomy. + * + * The canonical Timeline event taxonomy lives in github-ui as + * `@github-ui/timeline-taxonomy`. primer/react is taxonomy-agnostic: do not treat + * this sample as a source of truth, do not promote it into an exported reusable + * taxonomy module, and do not add it to `packages/react/src/index.ts`. It stays + * confined to internal story scaffolding (alongside `timelineStoryHelpers.tsx`) so + * the surfaces stay consistent across stories without leaking into the public + * package. Keep it in sync with github-ui only as needed for the demos. + */ +import type React from 'react' +import { + AlertIcon, + BlockedIcon, + CheckCircleIcon, + CheckIcon, + CommentIcon, + CrossReferenceIcon, + DotFillIcon, + GitBranchIcon, + type Icon, + IssueOpenedIcon, + IssueReopenedIcon, + LinkExternalIcon, + MarkGithubIcon, + NoteIcon, + PinIcon, + ShieldCheckIcon, + ShieldIcon, + ShieldSlashIcon, + ShieldXIcon, + SyncIcon, + TagIcon, + XIcon, +} from '@primer/octicons-react' +import {Button} from '../../Button' +import Label from '../../Label' +import Link from '../../Link' +import {type TimelineBadgeVariant} from '../Timeline' +import {BoldLink, EventSubRow, MONALISA_AVATAR, MutedTime, UserActor} from './timelineStoryHelpers' +import classes from './timelinePlaygroundData.module.css' + +export type PlaygroundSurfaceId = 'code-scanning' | 'secret-scanning' | 'dependabot' | 'license-compliance' | 'issue' +export type PlaygroundCategoryId = 'findings' | 'status' | 'reviews' | 'references' | 'moderation' | 'metadata' +// Actor classification, mirrors the authoritative `ActorType` value space (`user | bot`): +// first-party automation such as the GitHub secret-scanning system actor is `bot`. +type PlaygroundActorType = 'user' | 'bot' + +export type PlaygroundEvent = { + /** `data-event-type` value */ + type: string + /** `data-event-category` value */ + category: PlaygroundCategoryId + /** Human-readable label shown in the event-type picker */ + label: string + /** + * `data-event-visibility` value. Matches the authoritative `EventVisibility` value space + * (`@github-ui/timeline-taxonomy`): `primary` rows render in the main timeline; `auditOnly` + * rows are metadata-only. + */ + visibility: 'primary' | 'auditOnly' + /** `data-actor-type` value; omit for actor-less rows so no `data-actor-type` attribute renders */ + actorType?: PlaygroundActorType + badge: {icon: Icon; variant?: TimelineBadgeVariant} + /** Contents of `Timeline.Body` */ + body: React.ReactNode + /** Optional contents of the right-aligned `Timeline.Actions` slot */ + actions?: React.ReactNode +} + +export type PlaygroundSurface = { + label: string + /** Accessible name for the rendered `` */ + ariaLabel: string + categories: Partial> +} + +// Story-local demo avatars (MONALISA_AVATAR is imported from the shared helpers). +const DEPENDABOT_BOT_AVATAR = 'https://avatars.githubusercontent.com/u/27347476?v=4' +const LICENSE_BOT_AVATAR = 'https://avatars.githubusercontent.com/u/9919?s=40&v=4' +const HUBOT_AVATAR = 'https://avatars.githubusercontent.com/u/480938?v=4' + +// ILLUSTRATIVE representative data (see the module header above). Each event mirrors the +// badge/icon/copy/actor of the matching VariantSection in that surface's existing +// `Timeline..features.stories.tsx`, reduced to a demonstrative subset. The four +// security surfaces offer findings/status/reviews; issues offers status/references/moderation/metadata. +export const PLAYGROUND_SURFACES: Record = { + 'code-scanning': { + label: 'Code scanning', + ariaLabel: 'Code scanning alert timeline', + categories: { + findings: { + label: 'Findings', + events: [ + { + type: 'detected', + category: 'findings', + label: 'First detected in commit', + visibility: 'primary', + badge: {icon: ShieldIcon}, + body: ( + <> + First detected in commit{' '} + + + ), + }, + { + type: 'fixed', + category: 'findings', + label: 'Fixed in branch', + visibility: 'primary', + badge: {icon: ShieldCheckIcon, variant: 'done'}, + body: ( + <> + Fixed in branch main{' '} + + + ), + }, + ], + }, + status: { + label: 'Status', + events: [ + { + type: 'closed', + category: 'status', + label: 'Closed as false positive', + visibility: 'primary', + actorType: 'user', + badge: {icon: ShieldXIcon, variant: 'danger'}, + body: ( + <> + + {'closed this as '} + false positive{' '} + + + ), + }, + { + type: 'reopened', + category: 'status', + label: 'Reopened', + visibility: 'primary', + actorType: 'user', + badge: {icon: DotFillIcon, variant: 'success'}, + body: ( + <> + + {'reopened this '} + + + ), + }, + ], + }, + reviews: { + label: 'Reviews', + events: [ + { + type: 'dismissal_requested', + category: 'reviews', + label: 'Requested to dismiss', + visibility: 'primary', + actorType: 'user', + badge: {icon: CommentIcon}, + body: ( + <> + + {'requested to dismiss this as false positive '} + + This finding is a test-only helper, safe to dismiss. + + ), + actions: ( + + ), + }, + { + type: 'dismissal_reviewed', + category: 'reviews', + label: 'Approved dismissal', + visibility: 'primary', + actorType: 'user', + badge: {icon: CheckIcon}, + body: ( + <> + + {'approved dismissal '} + + + ), + }, + ], + }, + }, + }, + 'secret-scanning': { + label: 'Secret scanning', + ariaLabel: 'Secret scanning alert timeline', + categories: { + findings: { + label: 'Findings', + events: [ + { + type: 'detected', + category: 'findings', + label: 'Created', + visibility: 'primary', + actorType: 'bot', + badge: {icon: ShieldIcon, variant: 'success'}, + body: ( + <> + + {'opened this alert '} + + + ), + }, + { + type: 'validity_active', + category: 'findings', + label: 'Validity: active', + visibility: 'primary', + actorType: 'bot', + badge: {icon: AlertIcon, variant: 'danger'}, + body: ( + <> + + {'verified this secret is active '} + + + ), + }, + ], + }, + status: { + label: 'Status', + events: [ + { + type: 'closed', + category: 'status', + label: 'Closed as revoked', + visibility: 'primary', + actorType: 'user', + badge: {icon: ShieldCheckIcon, variant: 'done'}, + body: ( + <> + + {'closed this as '} + revoked + + Rotated the leaked token and confirmed the provider revoked it. + + + ), + }, + { + type: 'reopened', + category: 'status', + label: 'Reopened', + visibility: 'primary', + actorType: 'user', + badge: {icon: SyncIcon, variant: 'success'}, + body: ( + <> + + {'reopened this '} + + + ), + }, + ], + }, + reviews: { + label: 'Reviews', + events: [ + { + type: 'closure_requested', + category: 'reviews', + label: 'Requested to dismiss', + visibility: 'primary', + actorType: 'user', + badge: {icon: CommentIcon}, + body: ( + <> + + {'requested to dismiss this as false positive '} + + + ), + actions: ( + + ), + }, + { + type: 'bypass_approved', + category: 'reviews', + label: 'Bypass approved', + visibility: 'primary', + actorType: 'user', + badge: {icon: CheckCircleIcon}, + body: ( + <> + + {'approved a bypass '} + + + ), + }, + ], + }, + }, + }, + dependabot: { + label: 'Dependabot', + ariaLabel: 'Dependabot alert timeline', + categories: { + findings: { + label: 'Findings', + events: [ + { + type: 'opened', + category: 'findings', + label: 'Opened', + visibility: 'primary', + actorType: 'bot', + badge: {icon: ShieldIcon, variant: 'success'}, + body: ( + <> + + {'opened this '} + + + ), + }, + { + type: 'fixed', + category: 'findings', + label: 'Fixed', + visibility: 'primary', + actorType: 'bot', + badge: {icon: ShieldCheckIcon, variant: 'done'}, + body: ( + <> + + {'closed this as completed '} + + + ), + }, + ], + }, + status: { + label: 'Status', + events: [ + { + type: 'reopened', + category: 'status', + label: 'Reopened', + visibility: 'primary', + actorType: 'user', + badge: {icon: SyncIcon, variant: 'success'}, + body: ( + <> + + {'reopened this '} + + + ), + }, + { + type: 'dismissed', + category: 'status', + label: 'Dismissed', + visibility: 'primary', + actorType: 'user', + badge: {icon: ShieldSlashIcon}, + body: ( + <> + + {'dismissed this as '} + risk is tolerable{' '} + + Only reachable from a dev-only script we do not ship. + + ), + }, + ], + }, + reviews: { + label: 'Reviews', + events: [ + { + type: 'dismissal_requested', + category: 'reviews', + label: 'Dismissal requested', + visibility: 'primary', + actorType: 'user', + badge: {icon: CommentIcon}, + body: ( + <> + + {'requested to dismiss this '} + + + ), + actions: ( + + ), + }, + ], + }, + }, + }, + 'license-compliance': { + label: 'License compliance', + ariaLabel: 'License compliance alert timeline', + categories: { + findings: { + label: 'Findings', + events: [ + { + type: 'opened', + category: 'findings', + label: 'Opened', + visibility: 'primary', + actorType: 'bot', + badge: {icon: ShieldIcon, variant: 'success'}, + body: ( + <> + + {' opened this alert '} + + + ), + }, + { + type: 'appeared_in_branch', + category: 'findings', + label: 'Appeared in branch', + visibility: 'primary', + badge: {icon: GitBranchIcon}, + body: ( + <> + {'Appeared in branch '} + feature-branch{' '} + + + ), + }, + ], + }, + status: { + label: 'Status', + events: [ + { + type: 'closed', + category: 'status', + label: 'Closed as amendment', + visibility: 'primary', + actorType: 'user', + badge: {icon: ShieldCheckIcon, variant: 'done'}, + body: ( + <> + + {' closed as amendment '} + + Added a policy exception covering this package. + + ), + }, + ], + }, + reviews: { + label: 'Reviews', + events: [ + { + type: 'review_requested', + category: 'reviews', + label: 'Requested to close', + visibility: 'primary', + actorType: 'user', + badge: {icon: CommentIcon}, + body: ( + <> + + {' requested to close '} + + + ), + actions: ( + + ), + }, + { + type: 'review_approved', + category: 'reviews', + label: 'Approved closure request', + visibility: 'primary', + actorType: 'user', + badge: {icon: CheckIcon}, + body: ( + <> + + {' approved closure request '} + + + ), + }, + { + type: 'review_denied', + category: 'reviews', + label: 'Denied closure request', + visibility: 'primary', + actorType: 'user', + badge: {icon: XIcon}, + body: ( + <> + + {' denied closure request '} + + + ), + }, + ], + }, + }, + }, + issue: { + label: 'Issues', + ariaLabel: 'Issue timeline', + categories: { + status: { + label: 'Status', + events: [ + { + type: 'closed', + category: 'status', + label: 'Closed as completed', + visibility: 'primary', + actorType: 'user', + badge: {icon: CheckCircleIcon, variant: 'done'}, + body: ( + <> + + {'closed this as '} + + completed + {' '} + + + ), + }, + { + type: 'reopened', + category: 'status', + label: 'Reopened', + visibility: 'primary', + actorType: 'user', + badge: {icon: IssueReopenedIcon, variant: 'open'}, + body: ( + <> + + {'reopened this '} + + + ), + }, + ], + }, + references: { + label: 'References', + events: [ + { + type: 'connected', + category: 'references', + label: 'Linked pull request', + visibility: 'primary', + actorType: 'user', + badge: {icon: CrossReferenceIcon}, + body: ( + <> + + {'linked a pull request that will close this issue '} + Add retry logic to the uploader + {' #42 '} + + + ), + }, + { + type: 'cross_referenced', + category: 'references', + label: 'Mentioned in an issue', + visibility: 'primary', + actorType: 'user', + badge: {icon: LinkExternalIcon}, + body: ( + <> + + {'mentioned this '} + + + + Track flaky upload retries + + {' #128'} + + + ), + }, + ], + }, + moderation: { + label: 'Moderation', + events: [ + { + type: 'user_blocked', + category: 'moderation', + label: 'User blocked', + visibility: 'primary', + actorType: 'user', + badge: {icon: BlockedIcon}, + body: ( + <> + + {'blocked '} + six7 + + ), + }, + { + type: 'comment_pinned', + category: 'moderation', + label: 'Comment pinned', + visibility: 'primary', + actorType: 'user', + badge: {icon: PinIcon}, + body: ( + <> + + {'pinned a '} + + comment + {' '} + + + ), + }, + ], + }, + metadata: { + label: 'Metadata', + events: [ + { + type: 'labeled', + category: 'metadata', + label: 'Labeled (audit only)', + visibility: 'auditOnly', + actorType: 'user', + badge: {icon: TagIcon}, + body: ( + <> + + {'added the '} + + {' label '} + + + ), + }, + ], + }, + }, + }, +} + +export const PLAYGROUND_SURFACE_IDS = Object.keys(PLAYGROUND_SURFACES) as PlaygroundSurfaceId[] + +export const playgroundCategoryIds = (surface: PlaygroundSurfaceId): PlaygroundCategoryId[] => + Object.keys(PLAYGROUND_SURFACES[surface].categories) as PlaygroundCategoryId[] + +export const playgroundEvents = (surface: PlaygroundSurfaceId, categories: PlaygroundCategoryId[]): PlaygroundEvent[] => + categories.flatMap(category => PLAYGROUND_SURFACES[surface].categories[category]?.events ?? []) From 4ed9540ea0d3260005f266eec8db2c67b514431a Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:33:59 -0700 Subject: [PATCH 2/3] Refine Viewing-menu demo: Features title, Strong helper, descriptions Retitle the filter story to Components/Timeline/Features with export WithFiltering so it reads as a feature demo rather than implying a shipped Timeline.Filter subcomponent. Move the semibold emphasis into a shared Strong helper in timelineStoryHelpers (mirroring BoldLink) and delete the standalone timelinePlaygroundData.module.css. The default color is load-bearing over the muted Timeline body, so the class stays rather than inlining bare Text. Add representative per-category descriptions in the Viewing menu, rendered through ActionList.Description (block variant). The authoritative per-category text lives in github-ui timeline-taxonomy; Primer renders the slot only. Stories and internal scaffolding only: no public API export, no changeset. --- .../Timeline.filter.features.stories.tsx | 22 +++++++++----- .../timelinePlaygroundData.module.css | 10 ------- .../internal/timelinePlaygroundData.tsx | 30 +++++++++++-------- .../internal/timelineStoryHelpers.module.css | 7 +++++ .../internal/timelineStoryHelpers.tsx | 9 ++++++ 5 files changed, 47 insertions(+), 31 deletions(-) delete mode 100644 packages/react/src/Timeline/internal/timelinePlaygroundData.module.css diff --git a/packages/react/src/Timeline/Timeline.filter.features.stories.tsx b/packages/react/src/Timeline/Timeline.filter.features.stories.tsx index 38f529023ef..f8e840d180c 100644 --- a/packages/react/src/Timeline/Timeline.filter.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.filter.features.stories.tsx @@ -16,7 +16,7 @@ import { import classes from './Timeline.filter.features.stories.module.css' export default { - title: 'Components/Timeline/Filter', + title: 'Components/Timeline/Features', component: Timeline, subcomponents: { 'Timeline.Item': Timeline.Item, @@ -43,17 +43,19 @@ const SURFACE_ID = 'issue' as const // category never renders a duplicate opening/closing row. const BOOKEND_LIFECYCLE_TYPES = new Set(['opened', 'closed']) -type ViewingOption = {id: PlaygroundCategoryId; label: string} +type ViewingOption = {id: PlaygroundCategoryId; label: string; description?: string} /** * Presentational "Viewing" control. Its entire contract is `options` / * `selected` / `onSelectedChange`; it reads NO `data-*` attributes and knows * nothing about the Timeline it filters. The consumer owns the predicate and - * decides which rows render. This mirrors the prototype's per-surface Viewing - * menu: a Primer `ActionMenu` multi-select (`selectionVariant="multiple"`) whose - * checklist items stay open on select and expose `role="menuitemcheckbox"` / - * `aria-checked` for screen readers. The trigger's accessible name is its text - * ("Viewing"); the eye icon is decorative. + * decides which rows render. Each option may also carry an optional + * consumer-supplied `description`, rendered as an `ActionList.Description` block + * under the label; the control authors none of this text. This mirrors the + * prototype's per-surface Viewing menu: a Primer `ActionMenu` multi-select + * (`selectionVariant="multiple"`) whose checklist items stay open on select and + * expose `role="menuitemcheckbox"` / `aria-checked` for screen readers. The + * trigger's accessible name is its text ("Viewing"); the eye icon is decorative. */ function ViewingFilterMenu({ options, @@ -80,6 +82,9 @@ function ViewingFilterMenu({ } > {option.label} + {option.description ? ( + {option.description} + ) : null} ) })} @@ -119,12 +124,13 @@ function ViewingFilterMenu({ * authoritative Timeline event taxonomy lives in github-ui as * `@github-ui/timeline-taxonomy`; primer/react is taxonomy-agnostic. */ -export const ViewingMenu = () => { +export const WithFiltering = () => { const surface = PLAYGROUND_SURFACES[SURFACE_ID] const categoryIds = playgroundCategoryIds(SURFACE_ID) const options: ViewingOption[] = categoryIds.map(id => ({ id, label: surface.categories[id]?.label ?? id, + description: surface.categories[id]?.description, })) // All categories start selected: nothing is filtered out initially. diff --git a/packages/react/src/Timeline/internal/timelinePlaygroundData.module.css b/packages/react/src/Timeline/internal/timelinePlaygroundData.module.css deleted file mode 100644 index 1ec9c063c58..00000000000 --- a/packages/react/src/Timeline/internal/timelinePlaygroundData.module.css +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Story-local styles for the representative Timeline event bodies in - * `timelinePlaygroundData.tsx`. Shared by the Timeline Playground story and the - * Timeline "Viewing" filter story so the emphasized inline text renders identically. - */ - -.Strong { - font-weight: var(--base-text-weight-semibold); - color: var(--fgColor-default); -} diff --git a/packages/react/src/Timeline/internal/timelinePlaygroundData.tsx b/packages/react/src/Timeline/internal/timelinePlaygroundData.tsx index d9dffed2611..f0baffa3bc7 100644 --- a/packages/react/src/Timeline/internal/timelinePlaygroundData.tsx +++ b/packages/react/src/Timeline/internal/timelinePlaygroundData.tsx @@ -43,8 +43,7 @@ import {Button} from '../../Button' import Label from '../../Label' import Link from '../../Link' import {type TimelineBadgeVariant} from '../Timeline' -import {BoldLink, EventSubRow, MONALISA_AVATAR, MutedTime, UserActor} from './timelineStoryHelpers' -import classes from './timelinePlaygroundData.module.css' +import {BoldLink, EventSubRow, MONALISA_AVATAR, MutedTime, Strong, UserActor} from './timelineStoryHelpers' export type PlaygroundSurfaceId = 'code-scanning' | 'secret-scanning' | 'dependabot' | 'license-compliance' | 'issue' export type PlaygroundCategoryId = 'findings' | 'status' | 'reviews' | 'references' | 'moderation' | 'metadata' @@ -78,7 +77,7 @@ export type PlaygroundSurface = { label: string /** Accessible name for the rendered `` */ ariaLabel: string - categories: Partial> + categories: Partial> } // Story-local demo avatars (MONALISA_AVATAR is imported from the shared helpers). @@ -106,8 +105,7 @@ export const PLAYGROUND_SURFACES: Record badge: {icon: ShieldIcon}, body: ( <> - First detected in commit{' '} - + First detected in commit ), }, @@ -119,7 +117,7 @@ export const PLAYGROUND_SURFACES: Record badge: {icon: ShieldCheckIcon, variant: 'done'}, body: ( <> - Fixed in branch main{' '} + Fixed in branch main{' '} ), @@ -140,8 +138,7 @@ export const PLAYGROUND_SURFACES: Record <> {'closed this as '} - false positive{' '} - + false positive ), }, @@ -258,7 +255,7 @@ export const PLAYGROUND_SURFACES: Record <> {'closed this as '} - revoked + revoked Rotated the leaked token and confirmed the provider revoked it. @@ -392,8 +389,7 @@ export const PLAYGROUND_SURFACES: Record <> {'dismissed this as '} - risk is tolerable{' '} - + risk is tolerable Only reachable from a dev-only script we do not ship. ), @@ -458,8 +454,7 @@ export const PLAYGROUND_SURFACES: Record body: ( <> {'Appeared in branch '} - feature-branch{' '} - + feature-branch ), }, @@ -546,9 +541,15 @@ export const PLAYGROUND_SURFACES: Record issue: { label: 'Issues', ariaLabel: 'Issue timeline', + // Per-category `description` strings below are REPRESENTATIVE one-liners, surfaced by + // the Viewing menu via `ActionList.Description`. They are NOT authoritative: the + // canonical per-category descriptions (and cross-surface consistency) belong to + // `@github-ui/timeline-taxonomy` (event-categories.ts). Primer renders the slot but + // does not own the category semantics. categories: { status: { label: 'Status', + description: 'Opened, closed, and reopened lifecycle events.', events: [ { type: 'closed', @@ -587,6 +588,7 @@ export const PLAYGROUND_SURFACES: Record }, references: { label: 'References', + description: 'Cross-links to pull requests, commits, and other issues.', events: [ { type: 'connected', @@ -630,6 +632,7 @@ export const PLAYGROUND_SURFACES: Record }, moderation: { label: 'Moderation', + description: 'Blocks, hides, and other moderation actions.', events: [ { type: 'user_blocked', @@ -668,6 +671,7 @@ export const PLAYGROUND_SURFACES: Record }, metadata: { label: 'Metadata', + description: 'Labels, milestones, and other bookkeeping changes.', events: [ { type: 'labeled', diff --git a/packages/react/src/Timeline/internal/timelineStoryHelpers.module.css b/packages/react/src/Timeline/internal/timelineStoryHelpers.module.css index 4783e9d3b8b..4b5aca62624 100644 --- a/packages/react/src/Timeline/internal/timelineStoryHelpers.module.css +++ b/packages/react/src/Timeline/internal/timelineStoryHelpers.module.css @@ -51,6 +51,13 @@ margin-right: var(--base-size-4); } +/* Inline semibold emphasis in event bodies. Timeline.Body is muted, so the explicit + default color lifts the emphasized phrase out of the muted run. */ +.Strong { + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-default); +} + /* Trailing "bot" Label spacing in UserActor. */ .BotLabel { margin-left: var(--base-size-4); diff --git a/packages/react/src/Timeline/internal/timelineStoryHelpers.tsx b/packages/react/src/Timeline/internal/timelineStoryHelpers.tsx index f50d805ca7b..4593d257dfc 100644 --- a/packages/react/src/Timeline/internal/timelineStoryHelpers.tsx +++ b/packages/react/src/Timeline/internal/timelineStoryHelpers.tsx @@ -62,6 +62,15 @@ export const BoldLink = ({className, ...props}: React.ComponentProps ) +/** + * Inline semibold emphasis for event-body text. `Timeline.Body` renders muted, so this + * also restores the default foreground color to lift the emphasized phrase out of the + * muted run (the two-tone event sentences live-GitHub uses). + */ +export const Strong = ({className, ...props}: React.ComponentProps<'span'>) => ( + +) + // TODO(github/primer#6827): remove when Primer ships an inline (in-text) avatar treatment export const InlineAvatar = ({className, size = 20, alt = '', ...props}: React.ComponentProps) => ( From c05219f052b6c99831fc5ad170e356a957391591 Mon Sep 17 00:00:00 2001 From: Jan Maarten <83665577+janmaarten-a11y@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:57:30 -0700 Subject: [PATCH 3/3] Keep Timeline Viewing menu open across category toggles Call event.preventDefault() in the multi-select ViewingFilterMenu onSelect so ActionMenu keeps its overlay open across toggles for mouse and keyboard. The prior handler let afterSelect close the menu on each item-select, contradicting the stay-open doc comment. --- .../src/Timeline/Timeline.filter.features.stories.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/react/src/Timeline/Timeline.filter.features.stories.tsx b/packages/react/src/Timeline/Timeline.filter.features.stories.tsx index f8e840d180c..ccbc92ec7fd 100644 --- a/packages/react/src/Timeline/Timeline.filter.features.stories.tsx +++ b/packages/react/src/Timeline/Timeline.filter.features.stories.tsx @@ -77,9 +77,14 @@ function ViewingFilterMenu({ + onSelect={event => { + // Keep the multi-select Viewing menu open across toggles: ActionMenu + // closes its overlay on item-select unless the handler prevents the + // default. This works for mouse and keyboard (the Space path resets + // `defaultPrevented` before calling this handler). + event.preventDefault() onSelectedChange(isSelected ? selected.filter(id => id !== option.id) : [...selected, option.id]) - } + }} > {option.label} {option.description ? (