# 复合选择器

## 使用方法

`ComplexSelector` 是一个弹层内容由你来画的字段。标签、触发器、弹出层、焦点归还和 `changeAction` 由它负责。默认插槽负责内容，参数是 `value`、`commit`、`close` 和 `state`。

`commit` 写入新值，并在提供了 `changeAction` 时执行它。动作还没结束时，触发器显示加载指示，插槽看到的是刚提交的值。在触发器上按 Arrow Down 会打开弹层。

```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>
```

弹层会一直开着，直到插槽调用 `commit`。树可以用 `TreeList`。`status`、`isDisabled`、`isRequired` 和 `description` 与其他输入框的字段属性相同。

`alignment` 决定弹层沿打开方向怎么对齐。`startIcon` 在触发器开头放一个图标。`variant="ghost"` 去掉边框，触发器看起来像工具栏按钮。`renderTrigger` 或 `trigger` 插槽会换掉整个字段：把参数展开到你自己的控件上。`handleRef` 和模板 `ref` 都能调用 `open`、`close`、`toggle` 和 `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>
```

### 结构

- **字段**: 提供标签和可选辅助内容的字段外壳。
- **触发器**: 显示当前值或占位，并打开弹层的控件。
- **图标渲染的起始图标**: 可选的前导语义图标或图标组件，通过 Icon 绘制。
- **使用方渲染的起始内容**: 可选的任意插槽内容，直接画在触发器开头。
- **指示图标**: 尾部箭头，随弹层开合转动。
- **弹层**: 打开时画出并显示、关闭时隐藏的对话框表面。

## 示例

### 截止日期

Today、Tomorrow 或 Next week，也可以自定义日期和时间。Apply 提交并关闭弹层。

```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>
```
### 树搜索

在 Teams 和 Workspace 里搜索文件夹。选中文件夹后弹层关闭。

```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>
```

## 属性

| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `label` | `string` | - | 字段标签，也是弹层的名称。 |
| `modelValue` | `Value` | - | 当前值。用 `v-model` 绑定。 |
| `triggerLabel` | `string` | - | 触发器上的文字。空的时候用 `placeholder`。 |
| `placeholder` | `string` | 选择… | `triggerLabel` 为空时显示。 |
| `isLabelHidden` | `boolean` | `false` | 隐藏可见标签。无障碍名称还在。 |
| `description` | `string` | - | 标签下面的说明。 |
| `isOptional` | `boolean` | `false` | 标成可选。 |
| `isRequired` | `boolean` | `false` | 标成必填。 |
| `isDisabled` | `boolean` | `false` | 触发器不再打开。 |
| `isLoading` | `boolean` | `false` | 触发器上显示加载指示。 |
| `status` | `FieldStatusInput` | - | 错误、警告或成功消息。 |
| `statusVariant` | `'attached' \| 'detached' \| 'tooltip'` | `'attached'` | 状态消息放在哪里。 |
| `labelTooltip` | `string` | - | 标签上的提示。 |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | 触发器尺寸。 |
| `width` | `number \| string` | - | 字段宽度。数字按像素。 |
| `placement` | `PopoverPlacement` | `'below'` | 弹层打开的位置。 |
| `alignment` | `'start' \| 'center' \| 'end'` | `'start'` | 弹层沿打开方向的对齐。 |
| `startIcon` | `string \| Component` | - | 内置触发器开头的图标。 |
| `variant` | `'input' \| 'secondary' \| 'ghost'` | `'input'` | `input` 是带边框的字段。`secondary` 对齐次要按钮。`ghost` 看起来像工具栏按钮。后两种上的 attached 状态会收成独立说明。 |
| `containerClass` / `controlClass` | `class` | — | 外壳接收 `container*` 和根上的 class。内置按钮接收 `control*`。自定义触发器上这两类样式不生效，开发环境会警告。 |
| `renderTrigger` | `(props) => VNode` | - | 换掉字段和内置触发器。把 `props` 展开到你的控件上。`trigger` 插槽做同一件事。 |
| `handleRef` | `Ref \| (handle) => void` | - | 拿到 `open`、`close`、`toggle` 和 `isOpen`。模板 `ref` 暴露同样的方法。 |
| `changeAction` | `(value) => void \| Promise<void>` | - | 值变化后执行。还没结束时触发器显示加载指示。 |

## 事件

| 名称 | 参数 | 说明 |
| --- | --- | --- |
| `update:modelValue` | `Value` | `commit` 写入的值。 |
| `change` | `Value` | 和 `update:modelValue` 一起发出。 |
| `click` | `event: MouseEvent` | 触发器上的点击，在弹层打开之前触发。调用 `event.preventDefault()` 可以让它不打开。用 `trigger` 插槽或 `renderTrigger` 自己画触发器时，它在开关之后触发，取消不了。 |
| `keydown` | `event: KeyboardEvent` | 触发器上的按键，在内置处理之后触发，每个键都会收到，包括打开弹层的 ArrowDown。换成自制触发器时，内置处理取消的按键不再透出。 |
| `openChange` | `isOpen: boolean` | 弹层每次打开或关闭都会触发：触发器、键盘、点外面、Escape、`close()` 或命令式 handle。 |

## 插槽

默认插槽收到 `value`、`commit`、`close` 和 `state`。`state` 是 `{ isOpen, isBusy, triggerId, contentId }`。`value` 包含还在提交中的乐观值。
