From ed0f9c76bd7c9d494e4d43ac89a62389245c3735 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 3 Jun 2026 18:18:32 -0400 Subject: [PATCH 1/2] Make the card-chooser selection trigger a primary dropdown button The "N selected" bulk-selection trigger was a flat teal pill that read as a regression from the previous dropdown. Keep BoxelDropdown + Menu (the right primitives for an action menu) and restyle the trigger as a proper primary dropdown button, modeled on the boxel-ui primary Button / highlight ContextButton: - highlight fill with --boxel-highlight-foreground text, deepening to --boxel-highlight-hover on hover and while the menu is open - roomier internal spacing and a taller min-height so the content is no longer squished - keyboard focus draws a ring outside the button and no longer darkens the fill (deepening is reserved for hover / open) - the caret flips to point up while the menu is open Co-Authored-By: Claude Opus 4.8 (1M context) --- .../card-search/search-result-header.gts | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/host/app/components/card-search/search-result-header.gts b/packages/host/app/components/card-search/search-result-header.gts index 229e9a6067c..04158b52dd6 100644 --- a/packages/host/app/components/card-search/search-result-header.gts +++ b/packages/host/app/components/card-search/search-result-header.gts @@ -141,23 +141,35 @@ export default class SearchResultHeader extends Component { gap: var(--boxel-sp-xs); } + /* Primary dropdown trigger (not a flat pill): the highlight fill + with its readable foreground, deepening on hover and while the + menu is open. Modeled on the boxel-ui primary button / highlight + ContextButton so it reads as a standard dropdown control rather + than a one-off chip. */ .selection-dropdown-trigger { display: inline-flex; align-items: center; - gap: var(--boxel-sp-5xs); - min-height: 1.625rem; - padding: 0 var(--boxel-sp-xs); + gap: var(--boxel-sp-xxs); + min-height: 2rem; + padding-inline: var(--boxel-sp-xs); border: none; - border-radius: 0.375rem; + border-radius: var(--boxel-border-radius-sm); background-color: var(--boxel-highlight); - color: var(--boxel-dark); + color: var(--boxel-highlight-foreground); font: 700 var(--boxel-font-sm); cursor: pointer; + transition: background-color var(--boxel-transition); } .selection-dropdown-trigger:hover, - .selection-dropdown-trigger:focus-visible { + .selection-dropdown-trigger[aria-expanded='true'] { background-color: var(--boxel-highlight-hover); } + /* Keyboard focus shows a ring just outside the button; the fill is + not darkened on focus (deepening is reserved for hover / open). */ + .selection-dropdown-trigger:focus-visible { + outline: 2px solid var(--boxel-highlight); + outline-offset: 2px; + } .selection-trigger-icon { width: 0.875rem; height: 0.875rem; @@ -169,6 +181,12 @@ export default class SearchResultHeader extends Component { } .dropdown-arrow { flex-shrink: 0; + transition: transform var(--boxel-transition); + } + /* Caret flips to point up while the menu is open, matching the + standard dropdown affordance. */ + .selection-dropdown-trigger[aria-expanded='true'] .dropdown-arrow { + transform: rotate(180deg); } .selection-menu { --boxel-menu-item-content-padding: var(--boxel-sp-xs) var(--boxel-sp-sm); From 3e1120c5e392409a46fae7b97ecdc737c302811c Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 3 Jun 2026 18:22:25 -0400 Subject: [PATCH 2/2] Extract SelectionMenu into boxel-ui as a shared component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bulk-selection control (primary dropdown trigger + action menu) lived inline in the card chooser's results header. Extract it into a generic boxel-ui SelectionMenu so any surface — including the base CardsGrid card, which can only import boxel-ui — can adopt it. The trigger is a standard primary BoxelButton (inheriting the design system's highlight colors, hover, and focus-ring), with a leading selection checkmark, the count, and a caret that flips while the menu is open. The component is content-agnostic: the consumer supplies the menu items via @items, so Select All / Deselect All and the inert count header stay in the app (built in SearchResultHeader) rather than the design system. Also move the shared SelectionCheckmark artwork into boxel-ui and reuse it in CardHeader, removing a duplicated inline SVG (advances CS-11333). No behavior change for the card chooser: same markup, data-test hooks, aria-label, and menu items. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/boxel-ui/addon/src/components.ts | 4 + .../src/components/card-header/index.gts | 22 +-- .../components/selection-checkmark/index.gts | 31 ++++ .../src/components/selection-menu/index.gts | 139 ++++++++++++++++ .../src/components/selection-menu/usage.gts | 69 ++++++++ packages/boxel-ui/addon/src/usage.ts | 2 + .../adorn/selection-checkmark-icon.gts | 30 ---- .../card-search/search-result-header.gts | 153 +++++------------- .../components/operator-mode/stack-item.gts | 4 +- 9 files changed, 286 insertions(+), 168 deletions(-) create mode 100644 packages/boxel-ui/addon/src/components/selection-checkmark/index.gts create mode 100644 packages/boxel-ui/addon/src/components/selection-menu/index.gts create mode 100644 packages/boxel-ui/addon/src/components/selection-menu/usage.gts delete mode 100644 packages/host/app/components/adorn/selection-checkmark-icon.gts diff --git a/packages/boxel-ui/addon/src/components.ts b/packages/boxel-ui/addon/src/components.ts index aee2d35cff2..84ea2e85f2f 100644 --- a/packages/boxel-ui/addon/src/components.ts +++ b/packages/boxel-ui/addon/src/components.ts @@ -72,6 +72,8 @@ import ResizablePanelGroup, { ResizeHandle, } from './components/resizable-panel-group/index.gts'; import BoxelSelect from './components/select/index.gts'; +import SelectionCheckmark from './components/selection-checkmark/index.gts'; +import SelectionMenu from './components/selection-menu/index.gts'; import SkeletonPlaceholder from './components/skeleton-placeholder/index.gts'; import SortDropdown, { type SortOption, @@ -160,6 +162,8 @@ export { ResizablePanelGroup, ResizeHandle, resolveInsertion, + SelectionCheckmark, + SelectionMenu, SkeletonPlaceholder, SortDropdown, Swatch, diff --git a/packages/boxel-ui/addon/src/components/card-header/index.gts b/packages/boxel-ui/addon/src/components/card-header/index.gts index 9a19280164f..9e9a25a05cb 100644 --- a/packages/boxel-ui/addon/src/components/card-header/index.gts +++ b/packages/boxel-ui/addon/src/components/card-header/index.gts @@ -16,6 +16,7 @@ import ContextButton from '../context-button/index.gts'; import BoxelDropdown from '../dropdown/index.gts'; import Menu from '../menu/index.gts'; import RealmIcon, { type RealmDisplayInfo } from '../realm-icon/index.gts'; +import SelectionCheckmark from '../selection-checkmark/index.gts'; import Tooltip from '../tooltip/index.gts'; export interface CardHeaderUtilityMenu { @@ -133,26 +134,7 @@ export default class CardHeader extends Component { class='utility-menu-trigger' {{ddModifier}} > - + {{@utilityMenu.triggerText}} diff --git a/packages/boxel-ui/addon/src/components/selection-checkmark/index.gts b/packages/boxel-ui/addon/src/components/selection-checkmark/index.gts new file mode 100644 index 00000000000..4909c8ba2b4 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/selection-checkmark/index.gts @@ -0,0 +1,31 @@ +import type { TemplateOnlyComponent } from '@ember/component/template-only'; + +// SelectionCheckmark: the dark-circle-with-highlight-check artwork used by +// selection affordances — the SelectionMenu trigger and its inert count +// header, and the card-header utility-menu trigger. The circle reads as the +// highlight foreground and the check as the highlight accent, so it stands +// out against a highlight-colored surface. It is a two-color composite (not +// a monochrome, currentColor icon), so it lives here as a component rather +// than in the generated icon set. +const SelectionCheckmark: TemplateOnlyComponent<{ + Element: SVGSVGElement; +}> = ; + +export default SelectionCheckmark; diff --git a/packages/boxel-ui/addon/src/components/selection-menu/index.gts b/packages/boxel-ui/addon/src/components/selection-menu/index.gts new file mode 100644 index 00000000000..71e5e7d426b --- /dev/null +++ b/packages/boxel-ui/addon/src/components/selection-menu/index.gts @@ -0,0 +1,139 @@ +import Component from '@glimmer/component'; + +import type { MenuDivider } from '../../helpers/menu-divider.ts'; +import type { MenuItem } from '../../helpers/menu-item.ts'; +import { DropdownArrowDown } from '../../icons.gts'; +import BoxelButton from '../button/index.gts'; +import BoxelDropdown from '../dropdown/index.gts'; +import Menu from '../menu/index.gts'; +import SelectionCheckmark from '../selection-checkmark/index.gts'; + +// SelectionMenu: a primary dropdown control for bulk selection. The trigger +// shows a selection checkmark, the current count, and a caret that flips +// while the menu is open; the menu body is whatever the caller supplies via +// `@items`. +// +// It is deliberately content-agnostic — actions such as "Select All" / +// "Deselect All" (and the inert count header) are app concerns the consumer +// builds and passes in, so the design system owns only the trigger styling +// and the dropdown shell, not the selection semantics. +// +// The caller decides when to render it (typically only once something is +// selected) and what the items do. +interface Signature { + Args: { + items: Array; + // Accessible name for the trigger; defaults to the count. + label?: string; + selectedCount: number; + }; + Blocks: {}; + Element: HTMLButtonElement; +} + +export default class SelectionMenu extends Component { + private get triggerLabel(): string { + return ( + this.args.label ?? `Selection menu, ${this.args.selectedCount} selected` + ); + } + + +} diff --git a/packages/boxel-ui/addon/src/components/selection-menu/usage.gts b/packages/boxel-ui/addon/src/components/selection-menu/usage.gts new file mode 100644 index 00000000000..b1f13fee738 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/selection-menu/usage.gts @@ -0,0 +1,69 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import FreestyleUsage from 'ember-freestyle/components/freestyle/usage'; + +import type { MenuDivider } from '../../helpers/menu-divider.ts'; +import { MenuItem } from '../../helpers/menu-item.ts'; +import SelectionCheckmark from '../selection-checkmark/index.gts'; +import SelectionMenu from './index.gts'; + +export default class SelectionMenuUsage extends Component { + @tracked private selectedCount = 3; + + @action private selectAll() { + this.selectedCount = 10; + } + + @action private deselectAll() { + this.selectedCount = 0; + } + + // The items are supplied by the consumer — the component itself is + // content-agnostic. Here we mirror a typical bulk-selection menu: an + // inert count header plus a couple of actions. + private get items(): Array { + return [ + new MenuItem({ + label: `${this.selectedCount} Selected`, + action: () => {}, + icon: SelectionCheckmark, + header: true, + }), + new MenuItem({ label: 'Select All', action: this.selectAll }), + new MenuItem({ label: 'Deselect All', action: this.deselectAll }), + ]; + } + + +} diff --git a/packages/boxel-ui/addon/src/usage.ts b/packages/boxel-ui/addon/src/usage.ts index e9cae56c73e..deb4b8a4f16 100644 --- a/packages/boxel-ui/addon/src/usage.ts +++ b/packages/boxel-ui/addon/src/usage.ts @@ -45,6 +45,7 @@ import RadioInputUsage from './components/radio-input/usage.gts'; import RealmIconUsage from './components/realm-icon/usage.gts'; import ResizablePanelGroupUsage from './components/resizable-panel-group/usage.gts'; import SelectUsage from './components/select/usage.gts'; +import SelectionMenuUsage from './components/selection-menu/usage.gts'; import SkeletonPlaceholderUsage from './components/skeleton-placeholder/usage.gts'; import SortDropdownUsage from './components/sort-dropdown/usage.gts'; import SwatchUsage from './components/swatch/usage.gts'; @@ -100,6 +101,7 @@ export const ALL_USAGE_COMPONENTS = [ ['RealmIcon', RealmIconUsage], ['ResizablePanelGroup', ResizablePanelGroupUsage], ['Select', SelectUsage], + ['SelectionMenu', SelectionMenuUsage], ['SkeletonPlaceholder', SkeletonPlaceholderUsage], ['SortDropdown', SortDropdownUsage], ['Swatch', SwatchUsage], diff --git a/packages/host/app/components/adorn/selection-checkmark-icon.gts b/packages/host/app/components/adorn/selection-checkmark-icon.gts deleted file mode 100644 index c3445ceabce..00000000000 --- a/packages/host/app/components/adorn/selection-checkmark-icon.gts +++ /dev/null @@ -1,30 +0,0 @@ -import type { TemplateOnlyComponent } from '@ember/component/template-only'; - -// SelectionCheckmarkIcon: the dark-circle-with-highlight-checkmark artwork -// shared by the Adorn bulk-selection affordances — the teal "N Selected" -// menu trigger and its inert menu header. The companion AdornSelectChip -// renders the same circle/check but strokes with `currentColor` so the -// chip can be themed; here the check is always the highlight accent to -// read against the dark circle on a teal pill. -const SelectionCheckmarkIcon: TemplateOnlyComponent<{ - Element: SVGSVGElement; -}> = ; - -export default SelectionCheckmarkIcon; diff --git a/packages/host/app/components/card-search/search-result-header.gts b/packages/host/app/components/card-search/search-result-header.gts index 04158b52dd6..ef469f62d7c 100644 --- a/packages/host/app/components/card-search/search-result-header.gts +++ b/packages/host/app/components/card-search/search-result-header.gts @@ -4,15 +4,13 @@ import DeselectIcon from '@cardstack/boxel-icons/deselect'; import SelectAllIcon from '@cardstack/boxel-icons/select-all'; import { - BoxelDropdown, - Menu, + SelectionCheckmark, + SelectionMenu, SortDropdown, ViewSelector, } from '@cardstack/boxel-ui/components'; import { MenuItem } from '@cardstack/boxel-ui/helpers'; -import { DropdownArrowDown } from '@cardstack/boxel-ui/icons'; -import SelectionCheckmarkIcon from '@cardstack/host/components/adorn/selection-checkmark-icon'; import type { NewCardArgs } from '@cardstack/host/utils/card-search/types'; import type { SortOption } from './constants'; @@ -49,50 +47,51 @@ export default class SearchResultHeader extends Component { } // The trigger's visible text is just the count, so spell the control out - // for assistive tech (and include the count it stands in for). + // for assistive tech. "cards" is an app concern, so it's supplied here + // rather than baked into the generic SelectionMenu. get selectionMenuLabel(): string { let count = this.selectedCount; return `Selection menu, ${count} card${count === 1 ? '' : 's'} selected`; } + // Select All / Deselect All are app actions, so the items (including the + // inert count header) are built here and handed to the generic + // SelectionMenu via @items. + private get selectionMenuItems() { + return [ + new MenuItem({ + label: `${this.selectedCount} Selected`, + action: () => {}, + icon: SelectionCheckmark, + header: true, + }), + new MenuItem({ + label: 'Select All', + action: () => { + if (this.args.allCards && this.args.onSelectAll) { + this.args.onSelectAll(this.args.allCards); + } + }, + icon: SelectAllIcon, + }), + new MenuItem({ + label: 'Deselect All', + action: () => this.args.onDeselectAll?.(), + icon: DeselectIcon, + }), + ]; + } + - - private get selectionMenuItems() { - return [ - // Inert teal header echoing the trigger's count — uses the same - // dark-circle-with-teal-check artwork as the Adorn selection chip. - new MenuItem({ - label: `${this.selectedCount} Selected`, - action: () => {}, - icon: SelectionCheckmarkIcon, - header: true, - }), - new MenuItem({ - label: 'Select All', - action: () => { - if (this.args.allCards && this.args.onSelectAll) { - this.args.onSelectAll(this.args.allCards); - } - }, - icon: SelectAllIcon, - }), - new MenuItem({ - label: 'Deselect All', - action: () => this.args.onDeselectAll?.(), - icon: DeselectIcon, - }), - ]; - } } diff --git a/packages/host/app/components/operator-mode/stack-item.gts b/packages/host/app/components/operator-mode/stack-item.gts index 1e764f12bca..4d61ced8b6d 100644 --- a/packages/host/app/components/operator-mode/stack-item.gts +++ b/packages/host/app/components/operator-mode/stack-item.gts @@ -28,6 +28,7 @@ import { CardContainer, CardHeader, LoadingIndicator, + SelectionCheckmark, } from '@cardstack/boxel-ui/components'; import { MenuDivider, @@ -79,7 +80,6 @@ import consumeContext from '../../helpers/consume-context'; import ElementTracker, { type RenderedCardForOverlayActions, } from '../../resources/element-tracker'; -import SelectionCheckmarkIcon from '../adorn/selection-checkmark-icon'; import CardRenderer from '../card-renderer'; import CardError from './card-error'; @@ -560,7 +560,7 @@ export default class OperatorModeStackItem extends Component { new MenuItem({ label: `${selectedCount} Selected`, action: () => {}, - icon: SelectionCheckmarkIcon, + icon: SelectionCheckmark, header: true, }), );