Skip to content
Open
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
105 changes: 105 additions & 0 deletions packages/host/app/components/card-chooser/mini/index.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { action } from '@ember/object';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';

import type { Filter } from '@cardstack/runtime-common';

import SearchPanel from '@cardstack/host/components/card-search/panel';
import {
removeFileExtension,
type NewCardArgs,
} from '@cardstack/host/utils/card-search/types';

interface Signature {
Element: HTMLDivElement;
Args: {
initialSearchKey?: string;
baseFilter?: Filter;
onSelect: (url: string) => void;
// URL of the currently selected card — the matching row gets the teal
// selection treatment + checkmark. Omit for a chooser that surfaces
// matches but doesn't persist a pinned selection.
selected?: string;
};
}

export default class MiniCardChooser extends Component<Signature> {
@tracked private searchKey: string = this.args.initialSearchKey ?? '';

// SearchContent expects an array (it's the multi-select API plumbing).
// Wrap the single-select `@selected` so the existing isCardSelected check
// in SearchResultSection picks it up without any other changes.
private get selectedCards(): string[] | undefined {
return this.args.selected ? [this.args.selected] : undefined;
}

@action
private setSearchKey(value: string) {
this.searchKey = value;
}

@action
private handleSelect(selection: string | NewCardArgs) {
if (typeof selection !== 'string') {
return;
}
let normalized = removeFileExtension(selection);
if (normalized) {
this.args.onSelect(normalized);
}
}

<template>
<div class='mini-card-chooser' data-test-mini-card-chooser ...attributes>
<SearchPanel
@searchKey={{this.searchKey}}
@baseFilter={{@baseFilter}}
as |Bar Content|
>
<header class='mini-card-chooser__header'>
<Bar
@onInput={{this.setSearchKey}}
@placeholder='Search for a card'
@hidePickers={{true}}
/>
</header>
<div class='mini-card-chooser__results'>
<Content
@isCompact={{false}}
@handleSelect={{this.handleSelect}}
@showHeader={{true}}
@variant='mini'
@selectedCards={{this.selectedCards}}
/>
</div>
</SearchPanel>
</div>
<style scoped>
.mini-card-chooser {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
background-color: var(--boxel-light);
}
.mini-card-chooser__header {
flex: 0 0 auto;
padding: var(--boxel-sp-xs) var(--boxel-sp-xs) 0;
}
/* Pill-shaped, design-matched bar height. SearchBar's defaults are
tuned for the full search-sheet (50px tall, generous focus ring);
in the mini envelope we want a tighter pill. */
.mini-card-chooser__header :deep(.search-sheet__search-bar) {
min-height: 2.5rem;
border-radius: 999px;
}
.mini-card-chooser__results {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
</style>
</template>
}
71 changes: 71 additions & 0 deletions packages/host/app/components/card-chooser/mini/usage.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { action } from '@ember/object';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';

import FreestyleUsage from 'ember-freestyle/components/freestyle/usage';

import MiniCardChooser from './index';

export default class MiniCardChooserUsage extends Component {
@tracked selectedUrl: string | undefined;

@action onSelect(url: string) {
this.selectedUrl = url;
}

<template>
<FreestyleUsage @name='MiniCardChooser'>
<:description>
Compact, inline card picker for side-by-side layouts. Wraps
<code>SearchPanel</code>
with a fluid 100%-of-parent envelope and renders the mini visual variant
of
<code>SearchContent</code>: single-line rows, no per-section &quot;show
only&quot; toggle, results count visible on every section, and a
pill-shaped show-more button. The hosting container owns dismissal —
this primitive only fires
<code>onSelect</code>
with the selected card URL.
</:description>
<:example>
<div class='example-container'>
<MiniCardChooser @onSelect={{this.onSelect}} />
</div>
{{#if this.selectedUrl}}
<p class='selection-readout' data-test-mini-card-chooser-selection>
Selected:
<code>{{this.selectedUrl}}</code>
</p>
{{/if}}
</:example>
<:api as |Args|>
<Args.Action
@name='onSelect'
@description='Called with the selected card URL (no .json suffix).'
@required={{true}}
/>
<Args.String
@name='initialSearchKey'
@description='Optional initial search term. Read once at mount; subsequent parent updates are ignored.'
/>
<Args.String
@name='selected'
@description='URL of the currently selected card. The matching row gets the teal selection fill + checkmark.'
/>
</:api>
Comment on lines +47 to +55
</FreestyleUsage>
<style scoped>
.example-container {
width: 360px;
height: 480px;
border: 1px solid var(--boxel-border-color, var(--boxel-300));
border-radius: var(--boxel-border-radius);
overflow: hidden;
}
.selection-readout {
margin-top: var(--boxel-sp-xs);
font: var(--boxel-font-sm);
}
</style>
</template>
}
5 changes: 5 additions & 0 deletions packages/host/app/components/card-search/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export const VIEW_OPTIONS: ViewOption[] = [
{ id: 'strip', icon: StripViewIcon },
];

// 'mini' is an internal-only view id that opt-in consumers (e.g.
// MiniCardChooser) request via @variant='mini'. It is deliberately
// not in VIEW_OPTIONS so the user-facing view picker stays grid/strip.
export type SectionViewOption = 'grid' | 'strip' | 'mini';

/** Initial display limit for sections when not focused */
export const SECTION_DISPLAY_LIMIT_UNFOCUSED = 5;

Expand Down
35 changes: 34 additions & 1 deletion packages/host/app/components/card-search/item-button.gts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { modifier } from 'ember-modifier';

import { Button } from '@cardstack/boxel-ui/components';
import { and, cn, not } from '@cardstack/boxel-ui/helpers';
import { IconPlus } from '@cardstack/boxel-ui/icons';
import { CheckMark, IconPlus } from '@cardstack/boxel-ui/icons';

import {
cardTypeDisplayName,
Expand Down Expand Up @@ -124,6 +124,11 @@ interface Signature {
Element: HTMLElement;
Args: { Positional: [cardEl: HTMLElement | undefined] };
}>;
// When true, render a right-aligned <CheckMark> inside the row whenever
// @isSelected is set and the row is not in multi-select mode. Used by
// the mini-chooser variant whose selection treatment is a teal fill +
// checkmark rather than the legacy border-color shift.
showSelectedCheckmark?: boolean;
};
}

Expand Down Expand Up @@ -364,6 +369,22 @@ export default class ItemButton extends Component<Signature> {
</div>
{{/if}}
{{/if}}
{{#if
(and
@showSelectedCheckmark
@isSelected
(not @multiSelect)
(not this.isNewCard)
)
}}
<CheckMark
class='selected-checkmark'
width='16'
height='16'
aria-hidden='true'
data-test-item-button-selected-checkmark
/>
{{/if}}
{{#if this.isNewCard}}
<IconPlus
class='plus-icon'
Expand Down Expand Up @@ -504,6 +525,18 @@ export default class ItemButton extends Component<Signature> {
pointer-events: none;
z-index: 1;
}
/* Right-aligned check icon for single-select rows (mini chooser).
Vertically centered on the row; the row-level background is
supplied by the variant's parent scope. */
.selected-checkmark {
position: absolute;
top: 50%;
right: var(--boxel-sp);
transform: translateY(-50%);
color: var(--boxel-dark);
pointer-events: none;
z-index: 1;
}
</style>
</template>
}
26 changes: 16 additions & 10 deletions packages/host/app/components/card-search/search-bar.gts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ interface Signature {
bottomTreatment?: BoxelInputBottomTreatments;
state?: 'none' | 'valid' | 'invalid' | 'loading' | 'initial';
id?: string;
hidePickers?: boolean;
};
Blocks: {};
}
Expand All @@ -66,21 +67,26 @@ export default class SearchBar extends Component<Signature> {
height='18'
/>
</div>
<div class='search-sheet__search-bar-picker'>
<RealmPicker
@filter={{@realmFilter}}
@destination={{@pickerDestination}}
/>
</div>
{{#unless @typeFilter.skipTypeFiltering}}
{{#unless @hidePickers}}
<div class='search-sheet__search-bar-picker'>
<TypePicker
@filter={{@typeFilter}}
<RealmPicker
@filter={{@realmFilter}}
@destination={{@pickerDestination}}
/>
</div>
{{#unless @typeFilter.skipTypeFiltering}}
<div class='search-sheet__search-bar-picker'>
<TypePicker
@filter={{@typeFilter}}
@destination={{@pickerDestination}}
/>
</div>
{{/unless}}
<div
class='search-sheet__search-bar-separator'
aria-hidden='true'
></div>
{{/unless}}
<div class='search-sheet__search-bar-separator' aria-hidden='true'></div>
{{! template-lint-disable no-invalid-interactive }}
<div
class='search-sheet__search-bar-input-wrap'
Expand Down
45 changes: 43 additions & 2 deletions packages/host/app/components/card-search/search-content.gts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ interface Signature {
// When true, search-result cards render the Adorn visual treatment
// (teal hover type-label tab + teal selection chip).
adorn?: boolean;
// Opt-in visual variant. 'mini' forces single-line rows, suppresses the
// per-section "show only" toggle, hides the grid/strip view picker, and
// un-hides the recents count — used by MiniCardChooser.
variant?: 'default' | 'mini';
};
Blocks: {};
}
Expand All @@ -171,6 +175,13 @@ export default class SearchContent extends Component<Signature> {
@tracked activeViewId = 'grid';
private pagination = new SectionPagination(this.args.initialFocusedSection);

// Under @variant='mini', the displayed viewId is forced to the internal
// 'mini' literal regardless of activeViewId, so the user-facing view picker
// (which the mini variant also hides) cannot fight the consumer.
private get displayedViewId(): string {
return this.args.variant === 'mini' ? 'mini' : this.activeViewId;
}

@consume(GetCardContextName) declare private getCard: getCard;
@consume(GetCardCollectionContextName)
declare private getCardCollection: getCardCollection;
Expand Down Expand Up @@ -371,6 +382,13 @@ export default class SearchContent extends Component<Signature> {
const total = this.searchEntries.meta.page?.total ?? 0;
const realms = this.realms;

// Mini variant compresses the summary to "X results" — the design puts
// it next to the Sort dropdown on a single row, so there's no room for
// the across-realms qualifier.
if (this.args.variant === 'mini') {
return pluralize('result', total, true);
}

// Default: all results across all realms
return `${pluralize('result', total, true)} across ${pluralize('realm', realms.length, true)}`;
}
Expand Down Expand Up @@ -522,7 +540,11 @@ export default class SearchContent extends Component<Signature> {
focusedSectionSid=this.pagination.focusedSection
sectionSelector='[data-section-sid]'
}}
class={{cn 'search-sheet-content' compact=@isCompact}}
class={{cn
'search-sheet-content'
compact=@isCompact
mini=(eq @variant 'mini')
}}
...attributes
>
{{! AdornContext aligns with this search-sheet-content div as
Expand All @@ -548,6 +570,7 @@ export default class SearchContent extends Component<Signature> {
@allCards={{this.allCards}}
@onSelectAll={{@onSelectAll}}
@onDeselectAll={{@onDeselectAll}}
@hideViewSelector={{eq @variant 'mini'}}
/>
{{/unless}}
{{/if}}
Expand Down Expand Up @@ -581,7 +604,8 @@ export default class SearchContent extends Component<Signature> {
{{#each this.sections key='sid' as |section i|}}
<SearchResultSection
@section={{section}}
@viewOption={{this.activeViewId}}
@viewOption={{this.displayedViewId}}
@variant={{@variant}}
@handleSelect={{@handleSelect}}
@isFocused={{eq this.pagination.focusedSection section.sid}}
@isCollapsed={{this.isSectionCollapsed section.sid}}
Expand Down Expand Up @@ -650,6 +674,23 @@ export default class SearchContent extends Component<Signature> {
.search-sheet-content.compact :deep(.search-result-block) {
margin-bottom: 0;
}
/* Mini variant — tighten the layout so the chooser fits into a
narrow side-by-side envelope and matches the design references
(`.context/attachments/MXpUf9/...` and `.../BMmJkV/...`). */
.search-sheet-content.mini {
Comment on lines +677 to +680
padding-block: var(--boxel-sp-xs);
}
.search-sheet-content.mini :deep(.search-result-header) {
padding-block: var(--boxel-sp-xs);
}
/* Summary + Sort sit on one row, with the Sort dropdown shrunk to
fit its label rather than padded to a comfortable touch target. */
.search-sheet-content.mini :deep(.search-result-header .controls) {
gap: var(--boxel-sp-xs);
}
.search-sheet-content.mini :deep(.search-result-header .sort-button) {
min-width: 0;
}
.empty-state {
padding-block: var(--boxel-sp);
}
Expand Down
Loading
Loading