From 798b51f5955982e6875d341bf6d5dfc2053bf4e4 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 26 Jan 2026 15:23:47 +0700 Subject: [PATCH 1/8] Introduce picker component --- docs/research.md | 491 ++++++++++++++++++ packages/boxel-ui/addon/src/components.ts | 3 + .../src/components/multi-select/index.gts | 15 +- .../picker/before-options-with-search.gts | 87 ++++ .../addon/src/components/picker/index.gts | 182 +++++++ .../src/components/picker/option-row.gts | 189 +++++++ .../src/components/picker/selected-item.gts | 188 +++++++ .../src/components/picker/trigger-labeled.gts | 158 ++++++ .../addon/src/components/picker/usage.gts | 146 ++++++ packages/boxel-ui/addon/src/helpers.ts | 2 + .../addon/src/helpers/add-class-to-svg.ts | 8 + packages/boxel-ui/addon/src/usage.ts | 2 + .../integration/components/picker-test.gts | 195 +++++++ 13 files changed, 1664 insertions(+), 2 deletions(-) create mode 100644 docs/research.md create mode 100644 packages/boxel-ui/addon/src/components/picker/before-options-with-search.gts create mode 100644 packages/boxel-ui/addon/src/components/picker/index.gts create mode 100644 packages/boxel-ui/addon/src/components/picker/option-row.gts create mode 100644 packages/boxel-ui/addon/src/components/picker/selected-item.gts create mode 100644 packages/boxel-ui/addon/src/components/picker/trigger-labeled.gts create mode 100644 packages/boxel-ui/addon/src/components/picker/usage.gts create mode 100644 packages/boxel-ui/addon/src/helpers/add-class-to-svg.ts create mode 100644 packages/boxel-ui/test-app/tests/integration/components/picker-test.gts diff --git a/docs/research.md b/docs/research.md new file mode 100644 index 00000000000..ce492cc7ecd --- /dev/null +++ b/docs/research.md @@ -0,0 +1,491 @@ +# Multi-Select Trigger + Options Research + +## Goal +Design a reusable multi-select UI that matches the provided design: +- Trigger has two parts: label + selected items (customizable content like icon + text). +- Options list items contain checkbox + icon + text. +- Options are grouped into "selected" (top) and "unselected" (below), separated by a divider. +- Component is used in `search-sheet` and `card-catalog` modal, positioned immediately before the search input. + +## Current codebase signals + +### Existing multi-select base +- `packages/boxel-ui/addon/src/components/multi-select/index.gts` wraps `ember-power-select-multiple` and already supports: + - custom `@triggerComponent` + - custom `@selectedItemComponent` + - custom `@beforeOptionsComponent` / `@afterOptionsComponent` + - `@options`, `@selected`, `@onChange` +- This is the best place to build a reusable trigger and options layout while keeping power-select plumbing centralized. + - Usage examples show **custom option rows via the yielded block**, e.g. in `packages/boxel-ui/addon/src/components/multi-select/usage.gts` the options are rendered with a custom “pill” component: + - `AssigneePill` renders checkbox + icon + text + meta, and is invoked in the `BoxelMultiSelect` block: `as |option| ` + - This matches our desired option-row layout (checkbox + icon + text), so we can follow the same pattern for realm options. + - The labeled trigger + grouped options example now lives in `packages/boxel-ui/addon/src/components/multi-select/usage.gts`. + +### Card catalog realm filter +- `packages/host/app/components/card-catalog/filters.gts` is currently a simple `BoxelDropdown` with a `Menu`. +- It already owns realm selection state via `@selectedRealmUrls` and selection handlers. +- The new multi-select can replace this filter UI while keeping the same selection logic. + +### Search sheet +- `packages/host/app/components/search-sheet/index.gts` renders a `BoxelInput` and no realm picker yet. +- The new component can be inserted directly before the input, sharing realm data with card-catalog filters or realm service data. + +## Where should the component live? + +### Recommendation +Place the reusable UI in **Boxel UI** so it can be used by multiple host components: +- `packages/boxel-ui/addon/src/components/multi-select/trigger-labeled.gts` (or a more generic name) +- `packages/boxel-ui/addon/src/components/multi-select/grouped-options.gts` +- Re-export via `packages/boxel-ui/addon/src/components.ts` + +Rationale: +- Both `search-sheet` and `card-catalog` are in host, but the behavior and visuals are generic multi-select patterns. +- Boxel UI already owns the base multi-select integration with ember-power-select. +- Keeping triggers and options in Boxel UI avoids duplication and makes future pickers (e.g., Type) easy. + +Alternative (host-only): +- If this is intended to be specific to realm picking only, we could place a host-only wrapper component (e.g., `packages/host/app/components/realm-multi-select.gts`) that composes Boxel UI internals. But the visuals look generally useful, so default to Boxel UI. + +## Proposed component structure + +### 1) Trigger component (label + selected items) +**Purpose**: shows label on the left and a customizable selected-items area on the right. + +**Suggested file**: +`packages/boxel-ui/addon/src/components/multi-select/trigger-labeled.gts` + +**Args** (trigger): +- `@label`: string (e.g., "Realm") +- `@hasSelection`: boolean (for placeholder/empty state) +- `@placeholder`: string (optional; shown when no selection) +- `@selectedItems`: array (optional convenience) +- `@renderSelectedItem`: block (yields each item to render icon + text) +- `@disabled`: boolean +- `@isOpen`: boolean (to flip caret) + +**Block usage**: +```gts + + <:default as |option|> + + + +``` + +Note: Power-select trigger components receive `@extra` (already supported). We can pass `label` via `@extra` or add a small wrapper that maps explicit args into `@extra`. + +### 2) Grouped options list (selected first + divider) +**Purpose**: render selected options at the top, then a divider, then remaining options. + +**Suggested file**: +`packages/boxel-ui/addon/src/components/multi-select/grouped-options.gts` + +**Args**: +- `@options`: full list +- `@selected`: selected list +- `@isSelected`: function (or infer by identity) +- `@renderOption`: block or component for option row +- `@showDivider`: boolean (default true) + +**Behavior**: +- Group selected options at the top. +- Divider appears only if both groups are non-empty. +- Unselected list follows. + +This can be used inside the `BoxelMultiSelect` block to render `option` rows in the desired order. +We should explicitly implement this grouping for the realm picker; it is not provided by default in the current multi-select usage. + +### 3) Option row (checkbox + icon + text) +**Purpose**: consistent row layout with checkbox + icon + label. + +**Suggested file**: +`packages/boxel-ui/addon/src/components/multi-select/option-row.gts` + +**Args**: +- `@iconURL` +- `@label` +- `@checked` +- `@disabled` + +This can be host-specific if realm icon source is host-only; otherwise keep in Boxel UI. + +## Suggested public API for host usage + +### Host-level wrapper (optional) +Create a thin host wrapper to map realm info to the generic Boxel UI multi-select: + +`packages/host/app/components/realm-multi-select.gts` (optional) +- Inputs: + - `@availableRealms: Record` + - `@selectedRealmUrls: string[]` + - `@onSelectRealm(url)` + - `@onDeselectRealm(url)` + - `@disabled` +- Maps realm info to options with `{ id, name, iconURL }` +- Handles `@onChange` to call select/deselect +- Supplies trigger label ("Realm") and `selectedItemComponent` (icon + text) + +### Minimal args for reusability +If we keep it purely in Boxel UI: + +**Multi-select wrapper args** +- `@options: ItemT[]` +- `@selected: ItemT[]` +- `@onChange: (newSelection: ItemT[]) => void` +- `@label?: string` (via `@extra` or wrapper) +- `@placeholder?: string` +- `@renderSelectedItem?: ComponentLike` +- `@renderOptionRow?: ComponentLike` +- `@groupSelected?: boolean` (default true) + +## How to make it reusable for card-catalog and search-sheet + +### Shared realm picker +- Build a `RealmMultiSelect` host component that wraps the Boxel UI multi-select. +- Use it in: + - `packages/host/app/components/card-catalog/filters.gts` (replace current dropdown) + - `packages/host/app/components/search-sheet/index.gts` (add trigger before `BoxelInput`) +- Both locations already have access to realm data or can access realm services: + - Card catalog already has `availableRealms` + selection handlers. + - Search sheet can access `realmServer.availableRealmURLs` and `realm.info(url)` or reuse shared state from card catalog if available. + +### State and selection +- Keep selection state owned by the parent (host): + - `selectedRealmUrls` array + - `onSelectRealm(url)` and `onDeselectRealm(url)` +- The multi-select receives `@selected` array and calls `@onChange`. +- In host wrapper, implement `@onChange` to diff arrays and call select/deselect. + +### Placement +- Trigger sits inline with the search input; apply a compact size and rounded container to match the design. +- Use a layout container that wraps the trigger + input in one row (e.g., flex row). + +## Open decisions / questions +- **Single vs multi-select**: design shows checkboxes and “Select All”. Confirm if multi-select is required in both contexts. +- **“Select All” row**: should it exist for all uses or only realm? If generic, add it as an optional pre-options row via `@beforeOptionsComponent`. +- **Search input inside options**: do we want `ember-power-select` search enabled or a custom search field in `beforeOptions` to match the design? +- **Icon source**: confirm if realm icons are always present or fallback to default icon. + +## File touch list (expected) +- `packages/boxel-ui/addon/src/components/multi-select/trigger-labeled.gts` (new) +- `packages/boxel-ui/addon/src/components/multi-select/grouped-options.gts` (new) +- `packages/boxel-ui/addon/src/components/multi-select/option-row.gts` (new or host-specific) +- `packages/boxel-ui/addon/src/components.ts` (export) +- `packages/host/app/components/card-catalog/filters.gts` (replace dropdown with new multi-select) +- `packages/host/app/components/search-sheet/index.gts` (add trigger before search input) + +--- + +## Comprehensive Implementation Research + +### Component Architecture Deep Dive + +#### How ember-power-select-multiple works +1. **Trigger Component**: Receives `@select` (Select object with `selected`, `isOpen`, `actions`, etc.) and `@placeholder`. Can access `@extra` for custom data. +2. **Selected Item Component**: Used inside the trigger to render each selected item. Receives `@option` and `@select`. +3. **Before Options Component**: Renders content before the options list. Receives `@select` and can access `@extra`. +4. **Options Block**: The default block yields each option from `@options` array. This is where we render the option rows. +5. **After Options Component**: Renders content after the options list. Receives `@select`. + +#### Trigger Component Pattern +Looking at `packages/boxel-ui/addon/src/components/multi-select/trigger.gts`: +- Uses `BoxelTriggerWrapper` which provides consistent styling +- Has access to `@select.selected` (array of selected items) +- Can yield to `:default` and `:icon` blocks +- Receives `@selectedItemComponent` to render each selected item +- Can access `@extra` for custom data like label + +**Key insight**: The trigger component receives `@select` which contains: +- `select.selected`: array of selected items +- `select.isOpen`: boolean +- `select.actions.select(newSelection)`: function to update selection +- `select.actions.open()` / `select.actions.close()`: functions to control dropdown + +#### Selected Item Component Pattern +Looking at `packages/boxel-ui/addon/src/components/multi-select/selected-item.gts`: +- Receives `@option` (the selected item) and `@select` +- Uses `Pill` component for styling +- Has a remove button that calls `select.actions.remove(item)` +- Yields `@option` and `@select` to allow custom rendering + +#### Before Options Component Pattern +Looking at `packages/host/app/components/operator-mode/code-submode/playground/instance-chooser-dropdown.gts`: +- Simple template-only component +- Receives `@select` and can access `@extra` +- Can render search input, "Select All" button, or any other UI +- Renders before the options list + +#### Option Rendering Pattern +From `usage.gts`, options are rendered in the default block: +```gts + + + +``` +- Each option is yielded to the block +- Can check if option is selected using `includes(selected, option)` +- Custom components can render checkbox + icon + text + +#### Icon Type +From `packages/boxel-ui/addon/src/icons/types.ts`: +- `Icon` is `ComponentLike` where Signature has `Element: SVGElement` +- Icons can be passed as components (e.g., `IconSearch`) or as strings (URLs) +- For realm icons, we'll likely use string URLs from `realmInfo.iconURL` +- For type icons, might use Icon components or string URLs + +### Detailed Implementation Strategy + +#### 1) Labeled Trigger Component +**File**: `packages/boxel-ui/addon/src/components/multi-select/trigger-labeled.gts` + +**Purpose**: Shows label on left, selected items (with optional icons) on right, caret on far right. + +**Signature**: +```ts +interface TriggerLabeledSignature { + Args: { + placeholder?: string; + select: Select; + selectedItemComponent?: ComponentLike>; + extra?: { + label?: string; + renderSelectedItem?: (item: ItemT) => ComponentLike | string | undefined; // For icon + getItemText?: (item: ItemT) => string; + }; + }; + Blocks: { + default: [ItemT, Select]; + }; + Element: HTMLElement; +} +``` + +**Implementation notes**: +- Use `BoxelTriggerWrapper` for base styling (like `BoxelMultiSelectDefaultTrigger` does) +- Display `extra.label` on the left +- Show selected items using `selectedItemComponent` or default `BoxelSelectedItem` +- For each selected item, if `extra.renderSelectedItem` exists, use it to get icon +- Show caret in `:icon` block, rotate when `select.isOpen` is true +- Handle empty state: show placeholder when `select.selected.length === 0` + +**Visual structure**: +``` +[Label] [SelectedItem1] [SelectedItem2] ... [Caret] +``` + +#### 2) Grouped Options Rendering +**Challenge**: ember-power-select yields options one at a time in the default block, so we can't easily control the order. + +**Chosen Approach: Option A - Pre-sort options array** + +**Implementation**: +```ts +get sortedOptions() { + const selected = this.args.options.filter(o => + this.args.selected.includes(o) + ); + const unselected = this.args.options.filter(o => + !this.args.selected.includes(o) + ); + return [...selected, ...unselected]; +} +``` + +**Divider Logic**: +In the options block, show divider after the last selected item: +```gts + + + {{#if (and + (includes @selected option) + (eq option (last @selected)) + (gt (sub @options.length @selected.length) 0) + )}} + + {{/if}} + +``` + +**Notes**: +- Pre-sorting ensures selected items appear first +- Divider only shows if: + 1. Current option is selected + 2. Current option is the last selected item + 3. There are unselected items remaining +- This approach is simple and doesn't require filtering in the block + +**Alternative approaches considered** (not chosen): +- **Option B**: Use beforeOptionsComponent for selected group (would duplicate rendering) +- **Option C**: Custom helper that yields grouped options (more complex, less flexible) + +#### 3) Option Row Component +**File**: `packages/boxel-ui/addon/src/components/multi-select/option-row.gts` + +**Purpose**: Consistent row with checkbox + optional icon + text. + +**Signature**: +```ts +interface OptionRowSignature { + Args: { + option: ItemT; + isSelected: boolean; + getIcon?: (item: ItemT) => Icon | string | undefined; + getLabel: (item: ItemT) => string; + onToggle?: (item: ItemT, isSelected: boolean) => void; + }; + Element: HTMLElement; +} +``` + +**Implementation**: +- Checkbox (checked when `isSelected`) +- Icon (if `getIcon` provided and returns value) - handle both Icon component and string URL +- Text label +- Click handler to toggle selection + +#### 4) Before Options Component (Search + Select All) +**File**: `packages/boxel-ui/addon/src/components/multi-select/before-options-with-search.gts` + +**Purpose**: Search input + "Select All" option. + +**Signature**: +```ts +interface BeforeOptionsWithSearchSignature { + Args: { + select: Select; + extra?: { + searchPlaceholder?: string; + showSelectAll?: boolean; + getSelectAllLabel?: (count: number) => string; + filterOptions?: (options: ItemT[], searchTerm: string) => ItemT[]; + }; + }; +} +``` + +**Implementation**: +- Search input (controlled, filters options) +- "Select All (N)" option with checkbox +- When "Select All" clicked, select all filtered options +- Note: This requires managing filtered options state, which might conflict with ember-power-select's built-in search + +**Alternative**: Use ember-power-select's `@searchEnabled` and `@searchField`, then add "Select All" as a special option or in beforeOptions. + +#### 5) Reusable Picker Component +**File**: `packages/host/app/components/picker/index.gts` (or `packages/boxel-ui/addon/src/components/picker/index.gts`) + +**Purpose**: High-level reusable picker that combines all pieces. + +**Signature**: +```ts +interface PickerSignature { + Args: { + // Data + options: ItemT[]; + selected: ItemT[]; + onChange: (selected: ItemT[]) => void; + + // Display + label: string; + placeholder?: string; + + // Item rendering + getItemIcon?: (item: ItemT) => Icon | string | undefined; + getItemText: (item: ItemT) => string; + getItemId?: (item: ItemT) => string | number; // For comparison + + // Features + searchEnabled?: boolean; + searchPlaceholder?: string; + showSelectAll?: boolean; + groupSelected?: boolean; // Show selected items first + + // State + disabled?: boolean; + }; +} +``` + +**Implementation**: +- Uses `BoxelMultiSelect` with `trigger-labeled` +- Uses `before-options-with-search` if search enabled +- Sorts options if `groupSelected` is true +- Uses `option-row` in default block +- Handles icon/text extraction via `getItemIcon`/`getItemText` + +### Icon Handling Details + +Icons can be `Icon` (component) or `string` (URL): +- For realms: `realmInfo.iconURL` (string) +- For types: might be Icon component or string URL +- In `option-row`, check type and render accordingly: + ```gts + {{#if (eq (type-of icon) "string")}} + + {{else}} + + {{/if}} + ``` + +### Search Implementation Options + +**Option 1: Use ember-power-select's built-in search** +- Set `@searchEnabled={{true}}` and `@searchField="name"` +- Simpler but less control over UI placement/styling +- Search appears at top of dropdown automatically + +**Option 2: Custom search in beforeOptionsComponent** +- More control over UI +- Requires managing filtered state +- Need to filter options before passing to multi-select + +**Recommendation**: Start with Option 1 (built-in search), customize if needed. + +### "Select All" Implementation + +- Add as first item in `beforeOptionsComponent` +- Checkbox state: checked if all visible options are selected +- Click handler: if all selected, deselect all; otherwise select all visible options +- Count shows number of visible options: `(count filteredOptions)` +- Note: "Select All" should only select currently visible/filtered options, not all options + +### Updated File Touch List + +#### Boxel UI (generic components) +- `packages/boxel-ui/addon/src/components/multi-select/trigger-labeled.gts` (new) +- `packages/boxel-ui/addon/src/components/multi-select/option-row.gts` (new) +- `packages/boxel-ui/addon/src/components/multi-select/before-options-with-search.gts` (new) +- `packages/boxel-ui/addon/src/components.ts` (export new components) + +#### Host (domain-specific) +- `packages/host/app/components/realm-picker/index.gts` (new, wraps picker for realms) +- `packages/host/app/components/type-picker/index.gts` (new, wraps picker for types) +- `packages/host/app/components/card-catalog/filters.gts` (replace dropdown with realm-picker) +- `packages/host/app/components/search-sheet/index.gts` (add realm-picker before search input) + +### Resolved Decisions + +- **Single vs multi-select**: Design shows checkboxes and "Select All", confirming multi-select is required. +- **"Select All" row**: Should exist for all uses. Make it optional via `@showSelectAll` arg. +- **Search input**: Use ember-power-select's built-in search (`@searchEnabled`) initially, can customize later if needed. +- **Icon source**: Support both Icon components and string URLs. Realm icons are strings, type icons TBD. +- **Option grouping**: Pre-sort options array (recommended approach). +- **Component location**: Generic components in Boxel UI, domain-specific wrappers in host. + +### Next Steps + +1. Create `trigger-labeled.gts` in Boxel UI +2. Create `option-row.gts` in Boxel UI +3. Create `before-options-with-search.gts` in Boxel UI +4. Create `realm-picker.gts` in host +5. Integrate into `card-catalog/filters.gts` and `search-sheet/index.gts` +6. Test with realm data +7. Create `type-picker.gts` following same pattern diff --git a/packages/boxel-ui/addon/src/components.ts b/packages/boxel-ui/addon/src/components.ts index bd9264bd469..f3e343eec82 100644 --- a/packages/boxel-ui/addon/src/components.ts +++ b/packages/boxel-ui/addon/src/components.ts @@ -47,6 +47,7 @@ import BoxelMultiSelect, { BoxelMultiSelectBasic, } from './components/multi-select/index.gts'; import PhoneInput from './components/phone-input/index.gts'; +import Picker, { type PickerOption } from './components/picker/index.gts'; import Pill from './components/pill/index.gts'; import ProgressBar from './components/progress-bar/index.gts'; import ProgressRadial from './components/progress-radial/index.gts'; @@ -123,6 +124,8 @@ export { Message, Modal, PhoneInput, + Picker, + PickerOption, Pill, ProgressBar, ProgressRadial, diff --git a/packages/boxel-ui/addon/src/components/multi-select/index.gts b/packages/boxel-ui/addon/src/components/multi-select/index.gts index a8ae196e448..9be1eac9bd6 100644 --- a/packages/boxel-ui/addon/src/components/multi-select/index.gts +++ b/packages/boxel-ui/addon/src/components/multi-select/index.gts @@ -18,9 +18,12 @@ import BoxelMultiSelectDefaultTrigger, { } from './trigger.gts'; export interface BoxelMultiSelectArgs extends PowerSelectArgs { + afterOptionsComponent?: ComponentLike; ariaLabel?: string; + beforeOptionsComponent?: ComponentLike; closeOnSelect?: boolean; disabled?: boolean; + dropdownClass?: string; extra?: any; matchTriggerWidth?: boolean; onBlur?: (select: Select, e: Event) => boolean | undefined; @@ -75,7 +78,11 @@ export class BoxelMultiSelectBasic extends Component> { @registerAPI={{@registerAPI}} @initiallyOpened={{@initiallyOpened}} @extra={{@extra}} - @dropdownClass='boxel-multi-select__dropdown' + @dropdownClass={{if + @dropdownClass + @dropdownClass + 'boxel-multi-select__dropdown' + }} {{! actions }} @onOpen={{@onOpen}} @onClose={{@onClose}} @@ -84,7 +91,11 @@ export class BoxelMultiSelectBasic extends Component> { @selectedItemComponent={{@selectedItemComponent}} @triggerComponent={{@triggerComponent}} @afterOptionsComponent={{@afterOptionsComponent}} - @beforeOptionsComponent={{component BeforeOptions}} + @beforeOptionsComponent={{if + @beforeOptionsComponent + (component @beforeOptionsComponent) + (component BeforeOptions) + }} ...attributes as |option| > diff --git a/packages/boxel-ui/addon/src/components/picker/before-options-with-search.gts b/packages/boxel-ui/addon/src/components/picker/before-options-with-search.gts new file mode 100644 index 00000000000..a7f1c2129bd --- /dev/null +++ b/packages/boxel-ui/addon/src/components/picker/before-options-with-search.gts @@ -0,0 +1,87 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import type { Select } from 'ember-power-select/components/power-select'; + +import BoxelInput from '../input/index.gts'; +import type { PickerOption } from './index.gts'; + +export interface BeforeOptionsWithSearchSignature { + Args: { + extra?: { + filterOptions?: ( + options: PickerOption[], + searchTerm: string, + ) => PickerOption[]; + onSearchTermChange?: (term: string) => void; + searchPlaceholder?: string; + searchTerm?: string; + }; + select: Select; + }; +} + +export default class PickerBeforeOptionsWithSearch extends Component { + get searchTerm() { + return this.args.extra?.searchTerm || ''; + } + + get searchPlaceholder() { + return this.args.extra?.searchPlaceholder || 'search for a realm'; + } + + @action + updateSearchTerm(value: string) { + this.args.extra?.onSearchTermChange?.(value); + } + + +} diff --git a/packages/boxel-ui/addon/src/components/picker/index.gts b/packages/boxel-ui/addon/src/components/picker/index.gts new file mode 100644 index 00000000000..65111dabcfc --- /dev/null +++ b/packages/boxel-ui/addon/src/components/picker/index.gts @@ -0,0 +1,182 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import type { Select } from 'ember-power-select/components/power-select'; +import { includes } from 'lodash'; + +import type { Icon } from '../../icons/types.ts'; +import { BoxelMultiSelectBasic } from '../multi-select/index.gts'; +import PickerBeforeOptionsWithSearch from './before-options-with-search.gts'; +import PickerOptionRow from './option-row.gts'; +import PickerLabeledTrigger from './trigger-labeled.gts'; + +export type PickerOption = { + icon?: Icon | string; + id: string; + name: string; + type?: 'select-all' | 'option'; +}; + +export interface PickerSignature { + Args: { + // State + disabled?: boolean; + // Display + label: string; + matchTriggerWidth?: boolean; + + onChange: (selected: PickerOption[]) => void; + // Data + options: PickerOption[]; + + placeholder?: string; + + renderInPlace?: boolean; + searchPlaceholder?: string; + selected: PickerOption[]; + }; + Blocks: { + default: [PickerOption, Select]; + }; + Element: HTMLElement; +} + +export default class Picker extends Component { + @tracked searchTerm = ''; + + // When there is a search term: + // - Always keep any "select-all" (search-all) option at the very top + // - Then list already-selected options (so they stay visible even if they don't match the term) + // - Then list unselected options that match the search term, in their original order + get filteredOptions(): PickerOption[] { + if (!this.searchTerm) { + return this.args.options; + } + + const selectAll = this.args.options.filter((o) => o.type === 'select-all'); + const selectedOptions = this.args.options.filter( + (o) => this.args.selected.includes(o) && o.type !== 'select-all', + ); + const unselectedOptions = this.args.options.filter( + (o) => !this.args.selected.includes(o) && o.type !== 'select-all', + ); + + const term = this.searchTerm.toLowerCase(); + return [ + ...selectAll, + ...selectedOptions, + ...unselectedOptions.filter((option) => { + const text = option.name.toLowerCase(); + return text.includes(term); + }), + ]; + } + + // Reorders the already-filtered options so that: + // - "select-all" (search-all) options are always first + // - Selected regular options come next + // - Unselected regular options are listed last + get sortedOptions(): PickerOption[] { + const options = this.filteredOptions; + const selected = options.filter( + (o) => this.args.selected.includes(o) && o.type !== 'select-all', + ); + const unselected = options.filter( + (o) => !this.args.selected.includes(o) && o.type !== 'select-all', + ); + const selectAll = options.filter((o) => o.type === 'select-all'); + return [...selectAll, ...selected, ...unselected]; + } + + get selectedInSortedOptions(): PickerOption[] { + return this.sortedOptions.filter((o) => this.args.selected.includes(o)); + } + + get isSelected() { + return (option: PickerOption) => includes(this.args.selected, option); + } + + isLastSelected = (option: PickerOption) => { + const selectedInSorted = this.selectedInSortedOptions; + const lastSelected = selectedInSorted[selectedInSorted.length - 1]; + return lastSelected === option; + }; + + get hasUnselected() { + const unselected = this.sortedOptions.filter( + (o) => !this.args.selected.includes(o), + ); + return unselected.length > 0; + } + + get triggerComponent() { + return PickerLabeledTrigger; + } + + onSearchTermChange = (term: string) => { + this.searchTerm = term; + }; + + get extra() { + return { + label: this.args.label, + searchTerm: this.searchTerm, + searchPlaceholder: this.args.searchPlaceholder, + onSearchTermChange: this.onSearchTermChange, + }; + } + + displayDivider = (option: PickerOption) => { + return ( + (this.isLastSelected(option) && this.hasUnselected) || + (option.type === 'select-all' && + this.selectedInSortedOptions.length === 0) + ); + }; + + +} diff --git a/packages/boxel-ui/addon/src/components/picker/option-row.gts b/packages/boxel-ui/addon/src/components/picker/option-row.gts new file mode 100644 index 00000000000..10044469994 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/picker/option-row.gts @@ -0,0 +1,189 @@ +//import { on } from '@ember/modifier'; +import { htmlSafe } from '@ember/template'; +import Component from '@glimmer/component'; +import type { Select } from 'ember-power-select/components/power-select'; + +import { cn } from '../../helpers.ts'; +import CheckMark from '../../icons/check-mark.gts'; +import type { Icon } from '../../icons/types.ts'; +import type { PickerOption } from './index.gts'; + +export interface OptionRowSignature { + Args: { + currentSelected?: PickerOption[]; + isSelected: boolean; + option: PickerOption; + select?: Select; + }; + Element: HTMLElement; +} + +export default class PickerOptionRow extends Component { + get icon() { + return this.args.option.icon; + } + + get label() { + return this.args.option.name; + } + + get isIconString() { + return typeof this.icon === 'string'; + } + + get isIconURL() { + return this.isIconString && (this.icon as string).startsWith('http'); + } + + get isIconSVG() { + return this.isIconString && !this.isIconURL; + } + + get iconString() { + return this.isIconString ? (this.icon as string) : undefined; + } + + get iconComponent() { + return !this.isIconString ? (this.icon as Icon | undefined) : undefined; + } + + +} + +function addClassToSVG(svgString: string, className: string) { + return svgString + .replace(/]*)\sclass="([^"]*)"/, `]*)>/, + ``, + ); +} diff --git a/packages/boxel-ui/addon/src/components/picker/selected-item.gts b/packages/boxel-ui/addon/src/components/picker/selected-item.gts new file mode 100644 index 00000000000..448bfb9d999 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/picker/selected-item.gts @@ -0,0 +1,188 @@ +import { addClassToSVG } from '@cardstack/boxel-ui/helpers'; +import { fn } from '@ember/helper'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import { htmlSafe } from '@ember/template'; +import Component from '@glimmer/component'; +import type { Select } from 'ember-power-select/components/power-select'; + +import IconX from '../../icons/icon-x.gts'; +import type { Icon } from '../../icons/types.ts'; +import Pill from '../pill/index.gts'; +import type { PickerOption } from './index.gts'; + +export interface PickerSelectedItemSignature { + Args: { + extra?: { + getItemIcon?: (item: PickerOption) => Icon | string | undefined; + getItemText?: (item: PickerOption) => string; + }; + option: PickerOption; + select: Select & { + actions: { + remove: (item: PickerOption) => void; + }; + }; + }; + Blocks: { + default: [PickerOption, Select]; + }; + Element: HTMLDivElement; +} + +export default class PickerSelectedItem extends Component { + get icon() { + return this.args.option.icon; + } + + get text() { + return this.args.option.name; + } + + get isIconString() { + return typeof this.icon === 'string'; + } + + get isIconURL() { + return this.isIconString && (this.icon as string).startsWith('http'); + } + + get isIconSVG() { + return this.isIconString && !this.isIconURL; + } + + get iconString() { + return this.isIconString ? (this.icon as string) : undefined; + } + + get iconComponent() { + return !this.isIconString ? (this.icon as Icon | undefined) : undefined; + } + + @action + remove(item: PickerOption, event: MouseEvent) { + // Do not remove these event methods + // This is to ensure that the close/click event from selected item does not bubble up to the trigger + // and cause the dropdown to close + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + if (typeof this.args.select.actions.remove === 'function') { + this.args.select.actions.remove(item); + } else { + console.warn('Remove action is not available'); + } + } + + +} diff --git a/packages/boxel-ui/addon/src/components/picker/trigger-labeled.gts b/packages/boxel-ui/addon/src/components/picker/trigger-labeled.gts new file mode 100644 index 00000000000..6474d79c3fd --- /dev/null +++ b/packages/boxel-ui/addon/src/components/picker/trigger-labeled.gts @@ -0,0 +1,158 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import type { ComponentLike } from '@glint/template'; +import type { Select } from 'ember-power-select/components/power-select'; + +import { cn } from '../../helpers.ts'; +import { not } from '../../helpers/truth-helpers.ts'; +import CaretDown from '../../icons/caret-down.gts'; +import type { PickerOption } from './index.gts'; +import PickerSelectedItem, { + type PickerSelectedItemSignature, +} from './selected-item.gts'; + +export interface TriggerLabeledSignature { + Args: { + extra?: { + label?: string; + }; + placeholder?: string; + select: Select; + selectedItemComponent?: ComponentLike; + }; + Blocks: { + default: [PickerOption, Select]; + }; + Element: HTMLElement; +} + +type ExtendedSelect = Select & { + actions: { + remove: (item: PickerOption) => void; + } & Select['actions']; +}; + +export default class PickerLabeledTrigger extends Component { + get showPlaceholder() { + return this.args.placeholder && this.args.select.selected.length === 0; + } + + get label() { + return this.args.extra?.label || ''; + } + + @action + removeItem(item: any, event?: MouseEvent) { + event?.stopPropagation(); + const newSelected = this.args.select.selected.filter( + (i: any) => i !== item, + ); + this.args.select.selected = [...newSelected]; + this.args.select.actions.select(newSelected); + } + + get select(): ExtendedSelect { + return { + ...this.args.select, + actions: { + remove: this.removeItem as (item: PickerOption) => void, + ...this.args.select.actions, + }, + }; + } + + +} diff --git a/packages/boxel-ui/addon/src/components/picker/usage.gts b/packages/boxel-ui/addon/src/components/picker/usage.gts new file mode 100644 index 00000000000..009f89b5a67 --- /dev/null +++ b/packages/boxel-ui/addon/src/components/picker/usage.gts @@ -0,0 +1,146 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import FreestyleUsage from 'ember-freestyle/components/freestyle/usage'; + +import { + Card, + Field, + File, + IconCircle, + IconGlobe, + IconHexagon, +} from '../../icons.gts'; +import Picker, { type PickerOption } from './index.gts'; + +export default class PickerUsage extends Component { + selectAllOption: PickerOption = { + id: 'select-all', + name: 'Select All', + type: 'select-all', + }; + anyTypeOption: PickerOption = { + id: 'any-type', + name: 'Any Type', + type: 'select-all', + }; + @tracked selectedRealms: PickerOption[] = [this.selectAllOption]; + @tracked selectedTypes: PickerOption[] = [this.anyTypeOption]; + + @tracked realmOptions: PickerOption[] = [ + this.selectAllOption, + { id: '1', name: 'Boxel Catalog', icon: IconGlobe }, + { id: '2', name: 'Buffalo Exchange', icon: IconHexagon }, + { id: '3', name: 'Burritos Inc.', icon: IconCircle }, + { id: '4', name: 'Buzzsaw Club', icon: File }, + { id: '5', name: 'Canole Bros.', icon: IconGlobe }, + { id: '6', name: 'Capybara Mania', icon: IconHexagon }, + { id: '7', name: 'Cat Fancy Blog', icon: IconCircle }, + ]; + + @tracked typeOptions: PickerOption[] = [ + this.anyTypeOption, + { id: '1', name: 'Card', icon: Card }, + { id: '2', name: 'Field', icon: Field }, + { id: '3', name: 'Component', icon: File }, + { id: '4', name: 'Template', icon: File }, + ]; + + @action + onRealmChange(selected: PickerOption[]) { + this.selectedRealms = selected; + } + + @action + onTypeChange(selected: PickerOption[]) { + this.selectedTypes = selected; + } + + +} diff --git a/packages/boxel-ui/addon/src/helpers.ts b/packages/boxel-ui/addon/src/helpers.ts index b3ce0d4ed3a..b63211d3813 100644 --- a/packages/boxel-ui/addon/src/helpers.ts +++ b/packages/boxel-ui/addon/src/helpers.ts @@ -1,3 +1,4 @@ +import { addClassToSVG } from './helpers/add-class-to-svg.ts'; import { copyCardURLToClipboard } from './helpers/clipboard.ts'; import cn from './helpers/cn.ts'; import compact from './helpers/compact.ts'; @@ -64,6 +65,7 @@ export * from './helpers/color-tools.ts'; export { add, + addClassToSVG, and, bool, buildCssGroups, diff --git a/packages/boxel-ui/addon/src/helpers/add-class-to-svg.ts b/packages/boxel-ui/addon/src/helpers/add-class-to-svg.ts new file mode 100644 index 00000000000..28ea6435b84 --- /dev/null +++ b/packages/boxel-ui/addon/src/helpers/add-class-to-svg.ts @@ -0,0 +1,8 @@ +export function addClassToSVG(svgString: string, className: string) { + return svgString + .replace(/]*)\sclass="([^"]*)"/, `]*)>/, + ``, + ); +} diff --git a/packages/boxel-ui/addon/src/usage.ts b/packages/boxel-ui/addon/src/usage.ts index 00195fff54d..0d2f7453846 100644 --- a/packages/boxel-ui/addon/src/usage.ts +++ b/packages/boxel-ui/addon/src/usage.ts @@ -35,6 +35,7 @@ import MessageUsage from './components/message/usage.gts'; import ModalUsage from './components/modal/usage.gts'; import MultiSelectUsage from './components/multi-select/usage.gts'; import PhoneInputUsage from './components/phone-input/usage.gts'; +import PickerUsage from './components/picker/usage.gts'; import PillUsage from './components/pill/usage.gts'; import ProgressBarUsage from './components/progress-bar/usage.gts'; import ProgressRadialUsage from './components/progress-radial/usage.gts'; @@ -87,6 +88,7 @@ export const ALL_USAGE_COMPONENTS = [ ['Modal', ModalUsage], ['MultiSelect', MultiSelectUsage], ['PhoneInput', PhoneInputUsage], + ['Picker', PickerUsage], ['Pill', PillUsage], ['ProgressBar', ProgressBarUsage], ['ProgressRadial', ProgressRadialUsage], diff --git a/packages/boxel-ui/test-app/tests/integration/components/picker-test.gts b/packages/boxel-ui/test-app/tests/integration/components/picker-test.gts new file mode 100644 index 00000000000..5c4eee5a0f9 --- /dev/null +++ b/packages/boxel-ui/test-app/tests/integration/components/picker-test.gts @@ -0,0 +1,195 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'test-app/tests/helpers'; +import { click, render, waitFor, fillIn } from '@ember/test-helpers'; +import { tracked } from '@glimmer/tracking'; +import { Picker, type PickerOption } from '@cardstack/boxel-ui/components'; + +function noop() {} + +module('Integration | Component | picker', function (hooks) { + setupRenderingTest(hooks); + + const testOptions: PickerOption[] = [ + { id: '1', name: 'Option 1', icon: 'https://via.placeholder.com/20' }, + { id: '2', name: 'Option 2', icon: 'https://via.placeholder.com/20' }, + { id: '3', name: 'Option 3' }, + { id: '4', name: 'Option 4' }, + ]; + + const emptyArray: PickerOption[] = []; + + test('picker renders with label and placeholder', async function (assert) { + await render( + , + ); + + assert.dom('[data-test-boxel-picker-trigger-label]').hasText('Test Label'); + assert.dom('[data-test-boxel-picker-trigger-placeholder]').hasText('Select items'); + }); + + test('picker shows selected items in trigger', async function (assert) { + const selected = [testOptions[0], testOptions[1]]; + + await render( + , + ); + + // Check that selected items are displayed (they should be in pills) + await click('[data-test-boxel-picker-trigger]'); + assert.dom('[data-test-boxel-picker-selected-item]').exists({ count: 2 }); + }); + + test('picker opens dropdown when clicked', async function (assert) { + await render( + , + ); + + await click('[data-test-boxel-picker-trigger]'); + await waitFor('[data-test-boxel-picker-option-row]'); + + assert.dom('[data-test-boxel-picker-option-row]').exists({ count: 4 }); + }); + + test('picker groups selected items first when groupSelected is true', async function (assert) { + const selected = [testOptions[2], testOptions[3]]; + + await render( + , + ); + + await click('[data-test-boxel-picker-trigger]'); + await waitFor('[data-test-boxel-picker-option-row]'); + + // First two should be selected + assert + .dom(`[data-test-boxel-picker-option-row="2"][data-test-boxel-picker-option-selected]`).exists(); + assert + .dom(`[data-test-boxel-picker-option-row="3"][data-test-boxel-picker-option-selected]`).exists(); + + // Check for divider after selected items + const divider = document.querySelector('[data-test-boxel-picker-divider]'); + assert.dom(divider).exists('Divider should exist between selected and unselected'); + }); + + test('picker toggles selection when option is clicked', async function (assert) { + class SelectionController { + @tracked selected: PickerOption[] = []; + } + + const controller = new SelectionController(); + + const onChange = (newSelected: PickerOption[]) => { + controller.selected = newSelected; + }; + + await render( + , + ); + + await click('[data-test-boxel-picker-trigger]'); + await waitFor('[data-test-boxel-picker-option-row]'); + + // Click first option + const firstOption = document.querySelectorAll('[data-test-boxel-picker-option-row]')[0]; + await click(firstOption as HTMLElement); + + assert.strictEqual(controller.selected.length, 1, 'Should have one selected item'); + assert.strictEqual(controller.selected[0].id, '1', 'Should have selected first option'); + + // Click again to deselect + await click(firstOption as HTMLElement); + assert.strictEqual(controller.selected.length, 0, 'Should have no selected items'); + }); + + test('picker shows search input when searchEnabled is true', async function (assert) { + await render( + , + ); + + await click('[data-test-boxel-picker-trigger]'); + await waitFor('[data-test-boxel-picker-before-options]'); + + assert.dom('[data-test-boxel-picker-search]').exists(); + assert.dom('[data-test-boxel-picker-search] input').hasAttribute('placeholder', 'Search...'); + }); + + test('picker keeps select-all and selected options first when searching', async function (assert) { + const optionsWithSelectAll: PickerOption[] = [ + { id: 'select-all', name: 'All options', type: 'select-all' }, + ...testOptions, + ]; + const selected = [optionsWithSelectAll[2]]; // Option 2 + + await render( + , + ); + + await click('[data-test-boxel-picker-trigger]'); + await waitFor('[data-test-boxel-picker-before-options]'); + + await fillIn('[data-test-boxel-picker-search] input', '3'); + await waitFor('[data-test-boxel-picker-option-row]'); + + const optionIds = Array.from( + document.querySelectorAll('[data-test-boxel-picker-option-row]'), + ).map((el) => (el as HTMLElement).getAttribute('data-test-boxel-picker-option-row')); + + assert.deepEqual( + optionIds, + ['select-all', '2', '3'], + 'select-all stays first, then selected option, then matching unselected option', + ); + }); +}); From 11b1e12ffd763ddd25151e7d9543de56ee27875a Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Tue, 27 Jan 2026 17:58:23 +0700 Subject: [PATCH 2/8] Update usage --- packages/boxel-ui/addon/src/components/picker/usage.gts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/boxel-ui/addon/src/components/picker/usage.gts b/packages/boxel-ui/addon/src/components/picker/usage.gts index 009f89b5a67..036bee92723 100644 --- a/packages/boxel-ui/addon/src/components/picker/usage.gts +++ b/packages/boxel-ui/addon/src/components/picker/usage.gts @@ -98,7 +98,7 @@ export default class PickerUsage extends Component { <:example>
-

Realm Picker (with icons and search)

+

Realm Picker (with icons)

-

Type Picker (no icons, no search)

+

Type Picker (no icons)

Date: Tue, 27 Jan 2026 18:02:01 +0700 Subject: [PATCH 3/8] Update css --- packages/boxel-ui/addon/src/components/picker/index.gts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/boxel-ui/addon/src/components/picker/index.gts b/packages/boxel-ui/addon/src/components/picker/index.gts index 65111dabcfc..04ee4b17f9c 100644 --- a/packages/boxel-ui/addon/src/components/picker/index.gts +++ b/packages/boxel-ui/addon/src/components/picker/index.gts @@ -162,7 +162,7 @@ export default class Picker extends Component { {{/if}} - } From c1ad1b169e3e410b209812b39f94a60e3c7f56f5 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 29 Jan 2026 13:53:35 +0700 Subject: [PATCH 8/8] Address feedback --- .../addon/src/components/picker/index.gts | 41 ++- .../src/components/picker/selected-item.gts | 4 +- .../addon/src/components/picker/usage.gts | 23 +- .../integration/components/picker-test.gts | 263 ++++++++++++++---- 4 files changed, 272 insertions(+), 59 deletions(-) diff --git a/packages/boxel-ui/addon/src/components/picker/index.gts b/packages/boxel-ui/addon/src/components/picker/index.gts index ae870797b09..c571af850e3 100644 --- a/packages/boxel-ui/addon/src/components/picker/index.gts +++ b/packages/boxel-ui/addon/src/components/picker/index.gts @@ -1,3 +1,5 @@ +import type Owner from '@ember/owner'; +import { scheduleOnce } from '@ember/runloop'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import type { Select } from 'ember-power-select/components/power-select'; @@ -43,6 +45,34 @@ export interface PickerSignature { export default class Picker extends Component { @tracked searchTerm = ''; + constructor(owner: Owner, args: PickerSignature['Args']) { + super(owner, args); + this.validateSelectAllOption(); + scheduleOnce('afterRender', this, this.ensureDefaultSelection); + } + + private validateSelectAllOption() { + const hasSelectAll = this.args.options.some( + (option) => option.type === 'select-all', + ); + if (!hasSelectAll) { + throw new Error( + 'Picker requires a select-all option in @options (type: "select-all").', + ); + } + } + + private ensureDefaultSelection() { + if (this.args.selected.length === 0) { + const selectAllOptions = this.args.options.filter( + (option) => option.type === 'select-all', + ); + if (selectAllOptions.length > 0) { + this.args.onChange(selectAllOptions); + } + } + } + // When there is a search term: // - Always keep any "select-all" (search-all) option at the very top // - Then list already-selected options (so they stay visible even if they don't match the term) @@ -132,6 +162,9 @@ export default class Picker extends Component { const nonSelectAllOptions = selected.filter((option) => { return option.type !== 'select-all'; }); + const previouslyHadSelectAll = this.args.selected.some( + (option) => option.type === 'select-all', + ); const allSelectAllOptions = this.args.options.filter( (option) => option.type === 'select-all', ); @@ -141,7 +174,11 @@ export default class Picker extends Component { // Deselect select-all if there are other options selected if (selectAllOptions.length > 0 && nonSelectAllOptions.length > 0) { - this.args.onChange(nonSelectAllOptions); + if (previouslyHadSelectAll) { + this.args.onChange(nonSelectAllOptions); + return; + } + this.args.onChange(selectAllOptions); return; } @@ -200,7 +237,7 @@ export default class Picker extends Component { {{/if}} -