Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ interface SelectorComboboxProps {
onOptionChange?: (value: string) => void
allowSearch?: boolean
missingOptionLabel?: string
/** When true, store an array of ids and render removable chips (e.g. channel filter). */
multiSelect?: boolean
}

export function SelectorCombobox({
Expand All @@ -43,17 +45,24 @@ export function SelectorCombobox({
onOptionChange,
allowSearch = true,
missingOptionLabel,
multiSelect = false,
}: SelectorComboboxProps) {
const activeSearchTarget = useActiveSearchTarget()
const [storeValueRaw, setStoreValue] = useSubBlockValue<string | null | undefined>(
const [storeValueRaw, setStoreValue] = useSubBlockValue<string | string[] | null | undefined>(
blockId,
subBlock.id
)
const storeValue = storeValueRaw ?? undefined
const previewedValue = previewValue ?? undefined
const activeValue: string | undefined = isPreview ? previewedValue : storeValue
const storeValue = typeof storeValueRaw === 'string' ? storeValueRaw : undefined
const previewedValue = typeof previewValue === 'string' ? previewValue : undefined
// Single-select active value; undefined in multi mode so detail/label hooks no-op.
const activeValue: string | undefined = multiSelect
? undefined
: isPreview
? previewedValue
: storeValue
const [searchTerm, setSearchTerm] = useState('')
const [isEditing, setIsEditing] = useState(false)
const [multiInput, setMultiInput] = useState('')
const {
data: options = [],
isLoading,
Expand Down Expand Up @@ -127,6 +136,30 @@ export function SelectorCombobox({
[setStoreValue, onOptionChange, readOnly, disabled]
)

const selectedValues = useMemo<string[]>(() => {
if (!multiSelect) return []
const source = isPreview ? previewValue : storeValueRaw
if (Array.isArray(source)) return source.map(String)
if (typeof source === 'string' && source.length > 0) {
return source
.split(',')
.map((v) => v.trim())
.filter(Boolean)
}
return []
}, [multiSelect, isPreview, previewValue, storeValueRaw])

const handleMultiChange = useCallback(
(values: string[]) => {
if (readOnly || disabled) return
setStoreValue(values)
// Reset the search box so the next channel is picked from the full list.
setMultiInput('')
setSearchTerm('')
},
[setStoreValue, readOnly, disabled]
)

const showClearButton = Boolean(activeValue) && !disabled && !readOnly
const displayValue = allowSearch ? inputValue : selectedLabel
const workflowSearchHighlight = getWorkflowSearchLabelHighlight({
Expand All @@ -137,6 +170,54 @@ export function SelectorCombobox({
label: displayValue,
})

if (multiSelect) {
return (
<div className='w-full'>
<EditableCombobox
options={comboboxOptions}
value={multiInput}
multiSelect
multiSelectValues={selectedValues}
onMultiSelectChange={handleMultiChange}
onChange={(newValue) => {
setMultiInput(newValue)
if (allowSearch) setSearchTerm(newValue)
}}
placeholder={placeholder || subBlock.placeholder || 'Select channels'}
disabled={disabled || readOnly}
editable={allowSearch}
filterOptions={allowSearch}
isLoading={isLoading}
error={error instanceof Error ? error.message : null}
/>
{selectedValues.length > 0 && (
<div className='mt-2 space-y-2'>
{selectedValues.map((id) => (
<div
key={id}
className='flex items-center justify-between gap-2 rounded-sm border border-[var(--border-1)] bg-[var(--surface-4)] px-2.5 py-[5px]'
>
<span className='block min-w-0 flex-1 truncate text-[var(--text-tertiary)] text-sm'>
{optionMap.get(id)?.label ?? id}
</span>
<Button
type='button'
variant='ghost'
className='h-auto shrink-0 p-0'
disabled={disabled || readOnly}
aria-label={`Remove ${optionMap.get(id)?.label ?? id}`}
onClick={() => handleMultiChange(selectedValues.filter((v) => v !== id))}
>
<X className='size-[14px] text-[var(--text-icon)]' />
</Button>
</div>
))}
</div>
)}
</div>
)
}

return (
<div className='w-full'>
<SubBlockInputController
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export function SelectorInput({
previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select resource'}
allowSearch={allowSearch}
multiSelect={subBlock.multiSelect}
onOptionChange={(value) => {
if (!isPreview) {
collaborativeSetSubblockValue(blockId, subBlock.id, value)
Expand Down
89 changes: 42 additions & 47 deletions packages/emcn/src/components/wizard/wizard.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,21 @@
'use client'

import * as React from 'react'
import { Button } from '../button/button'
import { cn } from '../../lib/cn'
import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
type ModalSize,
} from '../modal/modal'
ChipModal,
ChipModalBody,
ChipModalFooter,
ChipModalHeader,
} from '../chip-modal/chip-modal'
import { ModalDescription, type ModalSize } from '../modal/modal'

/**
* A multi-step modal wizard primitive.
*
* @remarks
* Wraps the emcn Modal with a shared Back / Next / Done footer and declarative
* `Wizard.Step` children. Step state is controlled
* Wraps the emcn ChipModal with a shared Back / Next / Done footer and
* declarative `Wizard.Step` children. Step state is controlled
* from the outside so the consumer can hydrate from persisted state, reset
* on close, or jump around imperatively.
*
Expand Down Expand Up @@ -160,42 +158,39 @@ const WizardRoot: React.FC<WizardProps> = ({
if (total === 0) return null

return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent size={size} className={height}>
<ModalHeader>
{title ? (
<span className='flex items-center gap-2'>
{Icon ? <Icon className='size-[18px]' /> : null}
{title}
</span>
) : (
activeStep?.props.title
)}
</ModalHeader>

<ModalBody>
<ModalDescription className='sr-only'>
{description ?? 'Multi-step wizard'}
</ModalDescription>
{activeStep}
</ModalBody>

<ModalFooter>
<Button variant='default' onClick={handleBack} disabled={clamped === 0}>
{backLabel}
</Button>
{isLast ? (
<Button variant='primary' onClick={handleDone}>
{doneLabel}
</Button>
) : (
<Button variant='primary' onClick={handleNext} disabled={!canAdvance}>
{nextLabel}
</Button>
)}
</ModalFooter>
</ModalContent>
</Modal>
<ChipModal
open={open}
onOpenChange={onOpenChange}
size={size}
srTitle={title ?? activeStep?.props.title ?? description ?? 'Multi-step wizard'}
// A fixed `height` sizes the whole dialog (matching the legacy Modal). The
// scoped `[&>div]` variants stretch ChipModal's inner content column so the
// body fills the fixed height instead of leaving a gap; only applied when a
// height is set, so other ChipModal consumers are untouched.
className={height ? cn(height, '[&>div]:min-h-0 [&>div]:flex-1') : undefined}
>
<ChipModalHeader icon={title ? (Icon ?? null) : null} onClose={() => onOpenChange(false)}>
{title ?? activeStep?.props.title}
</ChipModalHeader>

<ChipModalBody>
<ModalDescription className='sr-only'>
{description ?? 'Multi-step wizard'}
</ModalDescription>
{activeStep}
</ChipModalBody>
Comment thread
cursor[bot] marked this conversation as resolved.

<ChipModalFooter
onCancel={() => onOpenChange(false)}
hideCancel
primaryAdjacentAction={{ label: backLabel, onClick: handleBack, disabled: clamped === 0 }}
primaryAction={
isLast
? { label: doneLabel, onClick: handleDone }
: { label: nextLabel, onClick: handleNext, disabled: !canAdvance }
}
/>
</ChipModal>
)
}

Expand Down
Loading