Skip to content

Commit 639345b

Browse files
lukemeliaclaude
andcommitted
Extract SelectionMenu into boxel-ui as a shared component
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) <noreply@anthropic.com>
1 parent ed0f9c7 commit 639345b

9 files changed

Lines changed: 274 additions & 168 deletions

File tree

packages/boxel-ui/addon/src/components.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ import ResizablePanelGroup, {
7272
ResizeHandle,
7373
} from './components/resizable-panel-group/index.gts';
7474
import BoxelSelect from './components/select/index.gts';
75+
import SelectionCheckmark from './components/selection-checkmark/index.gts';
76+
import SelectionMenu from './components/selection-menu/index.gts';
7577
import SkeletonPlaceholder from './components/skeleton-placeholder/index.gts';
7678
import SortDropdown, {
7779
type SortOption,
@@ -160,6 +162,8 @@ export {
160162
ResizablePanelGroup,
161163
ResizeHandle,
162164
resolveInsertion,
165+
SelectionCheckmark,
166+
SelectionMenu,
163167
SkeletonPlaceholder,
164168
SortDropdown,
165169
Swatch,

packages/boxel-ui/addon/src/components/card-header/index.gts

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import ContextButton from '../context-button/index.gts';
1616
import BoxelDropdown from '../dropdown/index.gts';
1717
import Menu from '../menu/index.gts';
1818
import RealmIcon, { type RealmDisplayInfo } from '../realm-icon/index.gts';
19+
import SelectionCheckmark from '../selection-checkmark/index.gts';
1920
import Tooltip from '../tooltip/index.gts';
2021

2122
export interface CardHeaderUtilityMenu {
@@ -133,26 +134,7 @@ export default class CardHeader extends Component<Signature> {
133134
class='utility-menu-trigger'
134135
{{ddModifier}}
135136
>
136-
<svg
137-
class='utility-menu-trigger-icon'
138-
viewBox='0 0 14 14'
139-
fill='none'
140-
aria-hidden='true'
141-
>
142-
<circle
143-
cx='7'
144-
cy='7'
145-
r='7'
146-
fill='var(--boxel-highlight-foreground)'
147-
/>
148-
<path
149-
d='M3.5 7.5L5.5 9.5L10.5 4.5'
150-
stroke='var(--boxel-highlight)'
151-
stroke-width='1.5'
152-
stroke-linecap='round'
153-
stroke-linejoin='round'
154-
/>
155-
</svg>
137+
<SelectionCheckmark class='utility-menu-trigger-icon' />
156138
<span class='utility-menu-trigger-text'>
157139
{{@utilityMenu.triggerText}}
158140
</span>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { TemplateOnlyComponent } from '@ember/component/template-only';
2+
3+
// SelectionCheckmark: the dark-circle-with-highlight-check artwork used by
4+
// selection affordances — the SelectionMenu trigger and its inert count
5+
// header, and the card-header utility-menu trigger. The circle reads as the
6+
// highlight foreground and the check as the highlight accent, so it stands
7+
// out against a highlight-colored surface. It is a two-color composite (not
8+
// a monochrome, currentColor icon), so it lives here as a component rather
9+
// than in the generated icon set.
10+
const SelectionCheckmark: TemplateOnlyComponent<{
11+
Element: SVGSVGElement;
12+
}> = <template>
13+
<svg
14+
viewBox='0 0 14 14'
15+
fill='none'
16+
xmlns='http://www.w3.org/2000/svg'
17+
aria-hidden='true'
18+
...attributes
19+
>
20+
<circle cx='7' cy='7' r='7' fill='var(--boxel-highlight-foreground)' />
21+
<path
22+
d='M3.5 7.5L5.5 9.5L10.5 4.5'
23+
stroke='var(--boxel-highlight)'
24+
stroke-width='1.5'
25+
stroke-linecap='round'
26+
stroke-linejoin='round'
27+
/>
28+
</svg>
29+
</template>;
30+
31+
export default SelectionCheckmark;
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import Component from '@glimmer/component';
2+
3+
import type { MenuDivider } from '../../helpers/menu-divider.ts';
4+
import type { MenuItem } from '../../helpers/menu-item.ts';
5+
import { DropdownArrowDown } from '../../icons.gts';
6+
import BoxelButton from '../button/index.gts';
7+
import BoxelDropdown from '../dropdown/index.gts';
8+
import Menu from '../menu/index.gts';
9+
import SelectionCheckmark from '../selection-checkmark/index.gts';
10+
11+
// SelectionMenu: a primary dropdown control for bulk selection. The trigger
12+
// shows a selection checkmark, the current count, and a caret that flips
13+
// while the menu is open; the menu body is whatever the caller supplies via
14+
// `@items`.
15+
//
16+
// It is deliberately content-agnostic — actions such as "Select All" /
17+
// "Deselect All" (and the inert count header) are app concerns the consumer
18+
// builds and passes in, so the design system owns only the trigger styling
19+
// and the dropdown shell, not the selection semantics.
20+
//
21+
// The caller decides when to render it (typically only once something is
22+
// selected) and what the items do.
23+
interface Signature {
24+
Args: {
25+
items: Array<MenuItem | MenuDivider>;
26+
// Accessible name for the trigger; defaults to the count.
27+
label?: string;
28+
selectedCount: number;
29+
};
30+
Blocks: {};
31+
Element: HTMLButtonElement;
32+
}
33+
34+
export default class SelectionMenu extends Component<Signature> {
35+
private get triggerLabel(): string {
36+
return (
37+
this.args.label ?? `Selection menu, ${this.args.selectedCount} selected`
38+
);
39+
}
40+
41+
<template>
42+
<BoxelDropdown
43+
@contentClass='selection-menu-content'
44+
@matchTriggerWidth={{false}}
45+
>
46+
<:trigger as |bindings|>
47+
{{! The trigger is a standard primary Button so it inherits the
48+
design system's highlight colors, hover, and focus-ring; the
49+
class only adds what Button doesn't: layout gap, the readable
50+
highlight foreground, the open-state deepening, and the caret
51+
flip. }}
52+
<BoxelButton
53+
@kind='primary'
54+
@rectangular={{true}}
55+
@class='selection-menu-trigger'
56+
aria-label={{this.triggerLabel}}
57+
{{bindings}}
58+
data-test-selection-dropdown-trigger
59+
...attributes
60+
>
61+
<SelectionCheckmark class='selection-menu-icon' />
62+
<span class='selection-menu-count'>{{@selectedCount}}</span>
63+
<DropdownArrowDown
64+
class='selection-menu-caret'
65+
width='13px'
66+
height='13px'
67+
/>
68+
</BoxelButton>
69+
</:trigger>
70+
<:content as |dd|>
71+
<Menu
72+
class='selection-menu-list'
73+
@items={{@items}}
74+
@closeMenu={{dd.close}}
75+
/>
76+
</:content>
77+
</BoxelDropdown>
78+
<style scoped>
79+
/* The trigger is a primary BoxelButton; it supplies the highlight
80+
fill, hover, and disabled handling. These rules only add what
81+
Button's defaults don't fit here: a gap between the icon/count/
82+
caret, the readable highlight foreground (Button's primary text
83+
defaults to --boxel-dark), a tighter radius, and compact sizing —
84+
the base size's wide --boxel-sp-xl padding + 5rem min-width make
85+
this count trigger far too wide, so collapse both to fit content. */
86+
.selection-menu-trigger {
87+
gap: var(--boxel-sp-xxs);
88+
--boxel-button-text-color: var(--boxel-highlight-foreground);
89+
--boxel-button-border-radius: var(--boxel-border-radius-sm);
90+
--boxel-button-padding: var(--boxel-sp-5xs) var(--boxel-sp-xs);
91+
--boxel-button-min-width: 0;
92+
}
93+
/* Keyboard focus ring just outside the button. (Set explicitly so it
94+
renders regardless of the global button outline setup.) */
95+
.selection-menu-trigger:focus-visible {
96+
outline: var(--boxel-outline-width) var(--boxel-outline-style)
97+
var(--boxel-highlight);
98+
outline-offset: 2px;
99+
}
100+
/* Deepen the fill while the menu is open, matching Button's hover. */
101+
.selection-menu-trigger[aria-expanded='true'] {
102+
--boxel-button-color: var(--boxel-highlight-hover);
103+
}
104+
.selection-menu-icon {
105+
width: 0.875rem;
106+
height: 0.875rem;
107+
flex-shrink: 0;
108+
}
109+
.selection-menu-count {
110+
line-height: 1;
111+
white-space: nowrap;
112+
}
113+
.selection-menu-caret {
114+
flex-shrink: 0;
115+
transition: transform var(--boxel-transition);
116+
}
117+
/* Caret flips to point up while the menu is open, matching the standard
118+
dropdown affordance. */
119+
.selection-menu-trigger[aria-expanded='true'] .selection-menu-caret {
120+
transform: rotate(180deg);
121+
}
122+
.selection-menu-list {
123+
--boxel-menu-item-content-padding: var(--boxel-sp-xs) var(--boxel-sp-sm);
124+
}
125+
</style>
126+
</template>
127+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { action } from '@ember/object';
2+
import Component from '@glimmer/component';
3+
import { tracked } from '@glimmer/tracking';
4+
import FreestyleUsage from 'ember-freestyle/components/freestyle/usage';
5+
6+
import type { MenuDivider } from '../../helpers/menu-divider.ts';
7+
import { MenuItem } from '../../helpers/menu-item.ts';
8+
import SelectionCheckmark from '../selection-checkmark/index.gts';
9+
import SelectionMenu from './index.gts';
10+
11+
export default class SelectionMenuUsage extends Component {
12+
@tracked private selectedCount = 3;
13+
14+
@action private selectAll() {
15+
this.selectedCount = 10;
16+
}
17+
18+
@action private deselectAll() {
19+
this.selectedCount = 0;
20+
}
21+
22+
// The items are supplied by the consumer — the component itself is
23+
// content-agnostic. Here we mirror a typical bulk-selection menu: an
24+
// inert count header plus a couple of actions.
25+
private get items(): Array<MenuItem | MenuDivider> {
26+
return [
27+
new MenuItem({
28+
label: `${this.selectedCount} Selected`,
29+
action: () => {},
30+
icon: SelectionCheckmark,
31+
header: true,
32+
}),
33+
new MenuItem({ label: 'Select All', action: this.selectAll }),
34+
new MenuItem({ label: 'Deselect All', action: this.deselectAll }),
35+
];
36+
}
37+
38+
<template>
39+
<FreestyleUsage
40+
@name='SelectionMenu'
41+
@description='Primary dropdown control for bulk selection: a trigger showing a selection checkmark + count + flipping caret, opening a caller-supplied action menu. Content-agnostic — Select All / Deselect All and the count header are passed in via @items.'
42+
>
43+
<:example>
44+
<SelectionMenu
45+
@selectedCount={{this.selectedCount}}
46+
@items={{this.items}}
47+
/>
48+
</:example>
49+
<:api as |Args|>
50+
<Args.Number
51+
@name='selectedCount'
52+
@description='Count shown in the trigger'
53+
@value={{this.selectedCount}}
54+
@required={{true}}
55+
/>
56+
<Args.Object
57+
@name='items'
58+
@description='Menu items (MenuItem | MenuDivider) supplied by the consumer'
59+
@value={{this.items}}
60+
@required={{true}}
61+
/>
62+
<Args.String
63+
@name='label'
64+
@description='Accessible name for the trigger; defaults to the count'
65+
/>
66+
</:api>
67+
</FreestyleUsage>
68+
</template>
69+
}

packages/boxel-ui/addon/src/usage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import RadioInputUsage from './components/radio-input/usage.gts';
4545
import RealmIconUsage from './components/realm-icon/usage.gts';
4646
import ResizablePanelGroupUsage from './components/resizable-panel-group/usage.gts';
4747
import SelectUsage from './components/select/usage.gts';
48+
import SelectionMenuUsage from './components/selection-menu/usage.gts';
4849
import SkeletonPlaceholderUsage from './components/skeleton-placeholder/usage.gts';
4950
import SortDropdownUsage from './components/sort-dropdown/usage.gts';
5051
import SwatchUsage from './components/swatch/usage.gts';
@@ -100,6 +101,7 @@ export const ALL_USAGE_COMPONENTS = [
100101
['RealmIcon', RealmIconUsage],
101102
['ResizablePanelGroup', ResizablePanelGroupUsage],
102103
['Select', SelectUsage],
104+
['SelectionMenu', SelectionMenuUsage],
103105
['SkeletonPlaceholder', SkeletonPlaceholderUsage],
104106
['SortDropdown', SortDropdownUsage],
105107
['Swatch', SwatchUsage],

packages/host/app/components/adorn/selection-checkmark-icon.gts

Lines changed: 0 additions & 30 deletions
This file was deleted.

0 commit comments

Comments
 (0)