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 229e9a6067c..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, }), );