Skip to content
Closed
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
4 changes: 4 additions & 0 deletions packages/boxel-ui/addon/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -160,6 +162,8 @@ export {
ResizablePanelGroup,
ResizeHandle,
resolveInsertion,
SelectionCheckmark,
SelectionMenu,
SkeletonPlaceholder,
SortDropdown,
Swatch,
Expand Down
22 changes: 2 additions & 20 deletions packages/boxel-ui/addon/src/components/card-header/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -133,26 +134,7 @@ export default class CardHeader extends Component<Signature> {
class='utility-menu-trigger'
{{ddModifier}}
>
<svg
class='utility-menu-trigger-icon'
viewBox='0 0 14 14'
fill='none'
aria-hidden='true'
>
<circle
cx='7'
cy='7'
r='7'
fill='var(--boxel-highlight-foreground)'
/>
<path
d='M3.5 7.5L5.5 9.5L10.5 4.5'
stroke='var(--boxel-highlight)'
stroke-width='1.5'
stroke-linecap='round'
stroke-linejoin='round'
/>
</svg>
<SelectionCheckmark class='utility-menu-trigger-icon' />
<span class='utility-menu-trigger-text'>
{{@utilityMenu.triggerText}}
</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}> = <template>
<svg
viewBox='0 0 14 14'
fill='none'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
...attributes
>
<circle cx='7' cy='7' r='7' fill='var(--boxel-highlight-foreground)' />
<path
d='M3.5 7.5L5.5 9.5L10.5 4.5'
stroke='var(--boxel-highlight)'
stroke-width='1.5'
stroke-linecap='round'
stroke-linejoin='round'
/>
</svg>
</template>;

export default SelectionCheckmark;
139 changes: 139 additions & 0 deletions packages/boxel-ui/addon/src/components/selection-menu/index.gts
Original file line number Diff line number Diff line change
@@ -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<MenuItem | MenuDivider>;
// Accessible name for the trigger; defaults to the count.
label?: string;
selectedCount: number;
};
Blocks: {};
Element: HTMLButtonElement;
}

export default class SelectionMenu extends Component<Signature> {
private get triggerLabel(): string {
return (
this.args.label ?? `Selection menu, ${this.args.selectedCount} selected`
);
}

<template>
{{! Wrap so the trigger and ember-basic-dropdown's content-origin count
as ONE flex item in the parent toolbar. BasicDropdown renders no
wrapper of its own, so without this the origin becomes a sibling
flex item when the menu opens — adding a parent gap and shifting the
trigger sideways. }}
<div class='selection-menu-root'>
<BoxelDropdown
@contentClass='selection-menu-content'
@matchTriggerWidth={{false}}
>
<:trigger as |bindings|>
{{! The trigger is a standard primary Button so it inherits the
design system's highlight colors, hover, and focus-ring; the
class only adds what Button doesn't: layout gap, the readable
highlight foreground, the open-state deepening, and the caret
flip. }}
<BoxelButton
@kind='primary'
@rectangular={{true}}
@class='selection-menu-trigger'
aria-label={{this.triggerLabel}}
{{bindings}}
data-test-selection-dropdown-trigger
...attributes
>
<SelectionCheckmark class='selection-menu-icon' />
<span class='selection-menu-count'>{{@selectedCount}}</span>
<DropdownArrowDown
class='selection-menu-caret'
width='13px'
height='13px'
/>
</BoxelButton>
</:trigger>
<:content as |dd|>
<Menu
class='selection-menu-list'
@items={{@items}}
@closeMenu={{dd.close}}
/>
</:content>
</BoxelDropdown>
</div>
<style scoped>
/* Hold the trigger + basic-dropdown origin as a single flex item so
opening the menu doesn't shift the trigger (see template note). */
.selection-menu-root {
display: inline-flex;
}
/* The trigger is a primary BoxelButton; it supplies the highlight
fill, hover, and disabled handling. These rules only add what
Button's defaults don't fit here: a gap between the icon/count/
caret, the readable highlight foreground (Button's primary text
defaults to --boxel-dark), a tighter radius, and compact sizing —
the base size's wide --boxel-sp-xl padding + 5rem min-width make
this count trigger far too wide, so collapse both to fit content. */
.selection-menu-trigger {
gap: var(--boxel-sp-xxs);
--boxel-button-text-color: var(--boxel-highlight-foreground);
--boxel-button-border-radius: var(--boxel-border-radius-sm);
--boxel-button-padding: var(--boxel-sp-5xs) var(--boxel-sp-xs);
--boxel-button-min-width: 0;
}
/* Keyboard focus ring just outside the button. (Set explicitly so it
renders regardless of the global button outline setup.) */
.selection-menu-trigger:focus-visible {
outline: var(--boxel-outline-width) var(--boxel-outline-style)
var(--boxel-highlight);
outline-offset: 2px;
}
/* Deepen the fill while the menu is open, matching Button's hover. */
.selection-menu-trigger[aria-expanded='true'] {
--boxel-button-color: var(--boxel-highlight-hover);
}
.selection-menu-icon {
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
}
.selection-menu-count {
line-height: 1;
white-space: nowrap;
}
.selection-menu-caret {
flex-shrink: 0;
transition: transform var(--boxel-transition);
}
/* Caret flips to point up while the menu is open, matching the standard
dropdown affordance. */
.selection-menu-trigger[aria-expanded='true'] .selection-menu-caret {
transform: rotate(180deg);
}
.selection-menu-list {
--boxel-menu-item-content-padding: var(--boxel-sp-xs) var(--boxel-sp-sm);
}
</style>
</template>
}
69 changes: 69 additions & 0 deletions packages/boxel-ui/addon/src/components/selection-menu/usage.gts
Original file line number Diff line number Diff line change
@@ -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<MenuItem | MenuDivider> {
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 }),
];
}

<template>
<FreestyleUsage
@name='SelectionMenu'
@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.'
>
<:example>
<SelectionMenu
@selectedCount={{this.selectedCount}}
@items={{this.items}}
/>
</:example>
<:api as |Args|>
<Args.Number
@name='selectedCount'
@description='Count shown in the trigger'
@value={{this.selectedCount}}
@required={{true}}
/>
<Args.Object
@name='items'
@description='Menu items (MenuItem | MenuDivider) supplied by the consumer'
@value={{this.items}}
@required={{true}}
/>
<Args.String
@name='label'
@description='Accessible name for the trigger; defaults to the count'
/>
</:api>
</FreestyleUsage>
</template>
}
2 changes: 2 additions & 0 deletions packages/boxel-ui/addon/src/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -100,6 +101,7 @@ export const ALL_USAGE_COMPONENTS = [
['RealmIcon', RealmIconUsage],
['ResizablePanelGroup', ResizablePanelGroupUsage],
['Select', SelectUsage],
['SelectionMenu', SelectionMenuUsage],
['SkeletonPlaceholder', SkeletonPlaceholderUsage],
['SortDropdown', SortDropdownUsage],
['Swatch', SwatchUsage],
Expand Down
30 changes: 0 additions & 30 deletions packages/host/app/components/adorn/selection-checkmark-icon.gts

This file was deleted.

Loading
Loading