# Complex Selector

## Usage

`ComplexSelector` is a field whose popup content you draw yourself. It owns the label, the trigger, the popover, focus return, and `changeAction`. The default slot owns the content: it receives `value`, `commit`, `close`, and `state`.

`commit` writes the value and runs `changeAction` when one was provided. While that action is pending the trigger shows a spinner and the slot sees the value that was just committed. Arrow Down on the trigger opens the popup.

```vue
<script setup lang="ts">
import { ComplexSelector } from '@astryx-vue/core/ComplexSelector'
import { ref } from 'vue'

const value = ref('Today')
</script>

<template>
  <ComplexSelector v-model="value" label="Deadline" :trigger-label="value">
    <template #default="{ commit, close }">
      <button type="button" @click="commit('Next week'); close()">
        Next week
      </button>
    </template>
  </ComplexSelector>
</template>
```

The popup stays open until the slot calls `commit`. A tree can use `TreeList`. `status`, `isDisabled`, `isRequired`, and `description` are the same field props as the other inputs.

`alignment` places the popup along its axis. `startIcon` draws an icon in the trigger. `variant="ghost"` drops the border so the trigger matches a toolbar button. `renderTrigger`, or the `trigger` slot, replaces the field: spread the bindings onto your own control. `handleRef` and a template `ref` both expose `open`, `close`, `toggle`, and `isOpen`.

```vue
<script setup lang="ts">
import type { ComplexSelectorHandle } from '@astryx-vue/core/ComplexSelector'
import { ComplexSelector } from '@astryx-vue/core/ComplexSelector'
import { ref, shallowRef } from 'vue'

const value = ref('Today')
const selector = shallowRef<ComplexSelectorHandle | null>(null)
</script>

<template>
  <button type="button" @click="selector?.open()">
    Open
  </button>
  <ComplexSelector
    ref="selector"
    v-model="value"
    label="Deadline"
    start-icon="calendar"
    :trigger-label="value"
    variant="ghost"
  >
    <template #default="{ commit, close }">
      <button type="button" @click="commit('Next week'); close()">
        Next week
      </button>
    </template>
  </ComplexSelector>
</template>
```

### Anatomy

- **Field**: Field shell that provides the label and optional supporting field content.
- **Trigger**: Control that displays the current value or placeholder and opens the popup.
- **Icon-rendered start icon**: Optional leading semantic icon or icon component rendered through Icon.
- **Caller-rendered start content**: Optional arbitrary slot content rendered directly at the start of the trigger.
- **Indicator icon**: Trailing chevron that rotates to reflect whether the popup is open.
- **Popup**: Mounted dialog surface that is painted and shown while open and hidden while closed.

## Examples

### Deadline picker

Today, Tomorrow, or Next week, or a custom date and time. Apply commits the value and closes the popup.

```vue
<script setup lang="ts">
import { Button } from '@astryx-vue/core/Button'
import { ComplexSelector } from '@astryx-vue/core/ComplexSelector'
import { DateInput } from '@astryx-vue/core/DateInput'
import { RadioList, RadioListItem } from '@astryx-vue/core/RadioList'
import { VStack } from '@astryx-vue/core/Stack'
import { TimeInput } from '@astryx-vue/core/TimeInput'
import { ref } from 'vue'

interface Deadline {
  preset: 'today' | 'next-week' | 'custom'
  date: Date
  time: string
}

const labels = {
  'today': 'Today',
  'next-week': 'Next week',
  'custom': 'Custom date',
} as const

const value = ref<Deadline>({
  preset: 'today',
  date: new Date(2026, 3, 6),
  time: '17:00',
})

function iso(date: Date): string {
  const month = String(date.getMonth() + 1).padStart(2, '0')
  const day = String(date.getDate()).padStart(2, '0')
  return `${date.getFullYear()}-${month}-${day}`
}

function formatDeadline(item: Deadline): string {
  if (item.preset === 'custom')
    return `${iso(item.date)} at ${item.time}`
  return labels[item.preset]
}
</script>

<template>
  <div style="width: 320px; max-width: 100%">
    <ComplexSelector
      v-model="value"
      label="Deadline"
      description="Choose a preset or set a custom date and time."
      :trigger-label="formatDeadline(value)"
      width="100%"
    >
      <template #default="{ value: selected, commit, close }">
        <VStack :gap="4" style="width: 320px">
          <RadioList
            label="When is it due?"
            :model-value="selected.preset"
            @update:model-value="preset => commit({ ...selected, preset: preset as Deadline['preset'] })"
          >
            <RadioListItem label="Today" value="today" />
            <RadioListItem label="Next week" value="next-week" />
            <RadioListItem label="Custom date" value="custom" />
          </RadioList>
          <VStack v-if="selected.preset === 'custom'" :gap="3">
            <DateInput
              label="Date"
              :model-value="selected.date"
              @update:model-value="value => value && commit({ ...selected, date: value })"
            />
            <TimeInput
              label="Time"
              :model-value="selected.time"
              @update:model-value="next => { if (typeof next === 'string' && next) commit({ ...selected, time: next }) }"
            />
          </VStack>
          <Button label="Apply" variant="primary" @click="close" />
        </VStack>
      </template>
    </ComplexSelector>
  </div>
</template>
```
### Tree search

Search folders under Teams and Workspace. Choosing a folder closes the popup.

```vue
<script setup lang="ts">
import type { TreeListItemData } from '@astryx-vue/core/TreeList'
import { ComplexSelector } from '@astryx-vue/core/ComplexSelector'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'
import { TextInput } from '@astryx-vue/core/TextInput'
import { TreeList } from '@astryx-vue/core/TreeList'
import { computed, ref } from 'vue'

interface Destination {
  id: string
  label: string
  path: string
  children?: Destination[]
}

const tree: Destination[] = [
  {
    id: 'workspace',
    label: 'Workspace',
    path: '/Workspace',
    children: [
      {
        id: 'workspace-research',
        label: 'Research',
        path: '/Workspace/Research',
        children: [
          { id: 'workspace-research-field-notes', label: 'Field notes', path: '/Workspace/Research/Field notes' },
          { id: 'workspace-research-interviews', label: 'Interviews', path: '/Workspace/Research/Interviews' },
        ],
      },
      { id: 'workspace-roadmap', label: 'Roadmap', path: '/Workspace/Roadmap' },
    ],
  },
  {
    id: 'teams',
    label: 'Teams',
    path: '/Teams',
    children: [
      {
        id: 'teams-design-systems',
        label: 'Design systems',
        path: '/Teams/Design systems',
        children: [
          { id: 'teams-design-systems-components', label: 'Components', path: '/Teams/Design systems/Components' },
          { id: 'teams-design-systems-accessibility', label: 'Accessibility', path: '/Teams/Design systems/Accessibility' },
        ],
      },
      { id: 'teams-growth', label: 'Growth', path: '/Teams/Growth' },
    ],
  },
  {
    id: 'archive',
    label: 'Archive',
    path: '/Archive',
    children: [
      { id: 'archive-2025', label: '2025 projects', path: '/Archive/2025 projects' },
    ],
  },
]

const value = ref<Destination>({
  id: 'teams-design-systems-accessibility',
  label: 'Accessibility',
  path: '/Teams/Design systems/Accessibility',
})
const query = ref('')

function filterTree(nodes: Destination[], term: string): Destination[] {
  const normalized = term.trim().toLowerCase()
  if (normalized === '')
    return nodes
  return nodes.flatMap((node) => {
    const children = node.children ? filterTree(node.children, normalized) : []
    if (node.label.toLowerCase().includes(normalized) || children.length > 0)
      return [{ ...node, children }]
    return []
  })
}

function toItems(nodes: Destination[], selectedId: string, pick: (node: Destination) => void): TreeListItemData[] {
  return nodes.map(node => ({
    id: node.id,
    label: node.label,
    isExpanded: true,
    isSelected: node.id === selectedId,
    onClick: () => pick(node),
    children: node.children ? toItems(node.children, selectedId, pick) : undefined,
  }))
}

const visible = computed(() => filterTree(tree, query.value))

function items(selectedId: string, pick: (node: Destination) => void) {
  return toItems(visible.value, selectedId, pick)
}
</script>

<template>
  <ComplexSelector
    v-model="value"
    description="Search and browse nested folders."
    label="Destination"
    :trigger-label="value.path"
    width="100%"
  >
    <template #default="{ value: selected, commit, close }">
      <VStack :gap="3">
        <TextInput
          v-model="query"
          has-clear
          is-label-hidden
          label="Search destinations"
          placeholder="Search folders or teams"
        />
        <TreeList
          v-if="visible.length > 0"
          density="compact"
          :items="items(selected.id, (node) => { commit(node); close() })"
        />
        <Text v-else color="secondary" type="supporting">
          No matching destinations.
        </Text>
      </VStack>
    </template>
  </ComplexSelector>
</template>
```

## Props

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` | - | Field label. It also names the popup. |
| `modelValue` | `Value` | - | Current value. Bind with `v-model`. |
| `triggerLabel` | `string` | - | Text on the trigger. Falls back to `placeholder`. |
| `placeholder` | `string` | Select… | Shown when `triggerLabel` is empty. |
| `isLabelHidden` | `boolean` | `false` | Hide the visible label. The accessible name stays. |
| `description` | `string` | - | Supporting text under the label. |
| `isOptional` | `boolean` | `false` | Marks the field optional. |
| `isRequired` | `boolean` | `false` | Marks the field required. |
| `isDisabled` | `boolean` | `false` | The trigger does not open. |
| `isLoading` | `boolean` | `false` | Shows a spinner on the trigger. |
| `status` | `FieldStatusInput` | - | Error, warning, or success message. |
| `statusVariant` | `'attached' \| 'detached' \| 'tooltip'` | `'attached'` | Where the status message sits. |
| `labelTooltip` | `string` | - | Tooltip on the label. |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Trigger size. |
| `width` | `number \| string` | - | Field width. Numbers are pixels. |
| `placement` | `PopoverPlacement` | `'below'` | Where the popup opens. |
| `alignment` | `'start' \| 'center' \| 'end'` | `'start'` | Popup alignment along the placement axis. |
| `startIcon` | `string \| Component` | - | Icon at the start of the built-in trigger. |
| `variant` | `'input' \| 'secondary' \| 'ghost'` | `'input'` | `input` is the bordered field. `secondary` matches a secondary button. `ghost` matches a toolbar button. On the latter two, an attached status becomes detached. |
| `containerClass` / `controlClass` | `class` | — | The shell takes `container*` and the root class. The built-in button takes `control*`. A custom trigger ignores both and warns in development. |
| `renderTrigger` | `(props) => VNode` | - | Replaces the field and the built-in trigger. Spread `props` onto your control. The `trigger` slot does the same job. |
| `handleRef` | `Ref \| (handle) => void` | - | Receives `open`, `close`, `toggle`, and `isOpen`. A template `ref` exposes the same methods. |
| `changeAction` | `(value) => void \| Promise<void>` | - | Runs after the value changes. While it is pending the trigger shows a spinner. |

## Events

| Name | Payload | Description |
| --- | --- | --- |
| `update:modelValue` | `Value` | The value passed to `commit`. |
| `change` | `Value` | Emitted with `update:modelValue`. |
| `click` | `event: MouseEvent` | Emitted for a click on the trigger, before the popup opens. Call `event.preventDefault()` to keep it closed. With the `trigger` slot or `renderTrigger` it runs after the toggle and cannot cancel it. |
| `keydown` | `event: KeyboardEvent` | Emitted for keys on the trigger after the built-in handling, and receives every key, including the ArrowDown that opens the popup. With a custom trigger, keys the built-in handling cancels are not emitted. |
| `openChange` | `isOpen: boolean` | Emitted whenever the surface opens or closes, however it happened: trigger, keyboard, light dismiss, Escape, `close()`, or the imperative handle. |

## Slot

The default slot receives `value`, `commit`, `close`, and `state`. `state` is `{ isOpen, isBusy, triggerId, contentId }`. `value` includes a pending optimistic value.
