Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import Feedback from './feedback'
import History from './history'
import IconButton from './icon-button'
import { Layout, LayoutAsideToggle, LayoutProxyScrollbar } from './layout'
import ModelSelector from './model-selector'
import { Prompt, Prompts } from './prompts'
import Sender from './sender'
import SenderCompat from './sender-compat'
Expand Down Expand Up @@ -45,6 +46,7 @@ export * from './feedback/index.type'
export * from './history/index.type'
export * from './icon-button/index.type'
export * from './layout/index.type'
export * from './model-selector/index.type'
export * from './prompts/index.type'
export * from './sender/index.type'
export * from './sender-actions/index.type'
Expand Down Expand Up @@ -89,6 +91,7 @@ const components = [
Layout,
LayoutProxyScrollbar,
LayoutAsideToggle,
ModelSelector,
Prompt,
Prompts,
Sender,
Expand Down Expand Up @@ -149,6 +152,8 @@ export {
LayoutProxyScrollbar as TrLayoutProxyScrollbar,
LayoutAsideToggle,
LayoutAsideToggle as TrLayoutAsideToggle,
ModelSelector,
ModelSelector as TrModelSelector,
Prompt,
Prompt as TrPrompt,
Prompts,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { ModelSelectorReasoningEffortOption } from '../index.type'

defineOptions({ name: 'TrModelSelectorEffort' })

defineProps<{
options: readonly ModelSelectorReasoningEffortOption[]
value: string | null
label: string
groupAriaLabel: string
disabled: boolean
}>()

defineEmits<{
select: [value: string | null]
}>()
</script>

<template>
<div class="tr-model-selector__effort">
<span class="tr-model-selector__effort-label">{{ label }}</span>
<div class="tr-model-selector__effort-options" role="group" :aria-label="groupAriaLabel">
<button
v-for="option in options"
:key="option.value"
type="button"
class="tr-model-selector__effort-option"
:class="{ 'is-active': option.value === value }"
:disabled="disabled || option.disabled"
:aria-pressed="option.value === value"
:data-model-selector-effort-value="option.value"
@click="$emit('select', option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed, type VNode } from 'vue'
import type { ModelSelectorGroupLabelSlotProps, ModelSelectorItemSlotProps } from '../index.type'
import type { ModelSelectorOptionGroup } from '../internal.type'
import ModelSelectorItem from './ModelSelectorItem.vue'

defineOptions({ name: 'TrModelSelectorGroup' })

const props = defineProps<{
group: ModelSelectorOptionGroup
selectedValue: string | null
highlightedKey: string | null
optionIdPrefix: string
}>()

defineEmits<{
hover: [key: string]
select: [key: string]
}>()

defineSlots<{
item?: (props: ModelSelectorItemSlotProps) => VNode | VNode[]
'group-label'?: (props: ModelSelectorGroupLabelSlotProps) => VNode | VNode[]
}>()

const groupModels = computed(() => props.group.items.map((option) => option.raw))
</script>

<template>
<div
class="tr-model-selector__group"
:role="group.label || $slots['group-label'] ? 'group' : 'presentation'"
:aria-labelledby="group.label || $slots['group-label'] ? `${optionIdPrefix}-group-${group.index}` : undefined"
>
<div
v-if="group.label || $slots['group-label']"
:id="`${optionIdPrefix}-group-${group.index}`"
class="tr-model-selector__group-title"
>
<slot name="group-label" :group="group.group" :label="group.label" :models="groupModels">
{{ group.label }}
</slot>
</div>

<ModelSelectorItem
v-for="option in group.items"
:key="option.key"
:option="option"
:selected="selectedValue === option.value"
:highlighted="highlightedKey === option.key"
:option-id="`${optionIdPrefix}-option-${option.index}`"
:description-id="`${optionIdPrefix}-option-${option.index}-description`"
@hover="$emit('hover', $event)"
@select="$emit('select', $event)"
>
<template v-if="$slots.item" #item="slotProps">
<slot name="item" v-bind="slotProps" />
</template>
</ModelSelectorItem>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { IconCheck } from '@opentiny/tiny-robot-svgs'
import type { VNode } from 'vue'
import type { ModelSelectorItemSlotProps } from '../index.type'
import type { NormalizedModelSelectorOption } from '../internal.type'

defineOptions({ name: 'TrModelSelectorItem' })

const props = defineProps<{
option: NormalizedModelSelectorOption
selected: boolean
highlighted: boolean
optionId: string
descriptionId: string
}>()

const emit = defineEmits<{
hover: [key: string]
select: [key: string]
}>()

defineSlots<{
item?: (props: ModelSelectorItemSlotProps) => VNode | VNode[]
}>()

function handleMouseEnter() {
if (!props.option.disabled) {
emit('hover', props.option.key)
}
}

function handleSelect() {
if (!props.option.disabled) {
emit('select', props.option.key)
}
}
</script>

<template>
<div
:id="optionId"
class="tr-model-selector__option"
:class="{
'is-selected': selected,
'is-highlighted': highlighted,
'is-disabled': option.disabled,
}"
:data-model-selector-option-key="option.key"
role="option"
:aria-selected="selected"
:aria-disabled="option.disabled || undefined"
:aria-describedby="option.description && !$slots.item ? descriptionId : undefined"
@mouseenter="handleMouseEnter"
@mousedown.prevent
@click="handleSelect"
>
<slot name="item" :option="option.raw" :selected="selected" :highlighted="highlighted" :disabled="option.disabled">
<span class="tr-model-selector__option-main">
<component
:is="option.icon"
v-if="option.icon"
class="tr-model-selector__option-icon"
aria-hidden="true"
focusable="false"
/>
<span class="tr-model-selector__option-copy">
<span class="tr-model-selector__option-label" :title="option.label">{{ option.label }}</span>
<span
v-if="option.description"
:id="descriptionId"
class="tr-model-selector__option-description"
:title="option.description"
>
{{ option.description }}
</span>
</span>
</span>
<IconCheck v-if="selected" class="tr-model-selector__option-check" aria-hidden="true" focusable="false" />
</slot>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
<script setup lang="ts">
import { IconNoData, IconSearch } from '@opentiny/tiny-robot-svgs'
import { shallowRef, type StyleValue, type VNode } from 'vue'
import type {
ModelSelectorContentClass,
ModelSelectorEmptySlotProps,
ModelSelectorReasoningEffortOption,
ModelSelectorGroupLabelSlotProps,
ModelSelectorItemSlotProps,
ModelSelectorSize,
} from '../index.type'
import type { ModelSelectorOptionGroup } from '../internal.type'
import ModelSelectorEffort from './ModelSelectorEffort.vue'
import ModelSelectorGroup from './ModelSelectorGroup.vue'

defineOptions({ name: 'TrModelSelectorPanel' })

const props = defineProps<{
query: string
searchable: boolean
groups: readonly ModelSelectorOptionGroup[]
isEmpty: boolean
selectedValue: string | null
highlightedKey: string | null
activeDescendantId?: string
listboxId: string
optionIdPrefix: string
searchPlaceholder: string
emptyText: string
searchAriaLabel: string
listAriaLabel: string
effortOptions: readonly ModelSelectorReasoningEffortOption[]
effortValue: string | null
effortLabel: string
effortAriaLabel: string
effortDisabled: boolean
size: ModelSelectorSize
contentClass?: ModelSelectorContentClass
contentStyle?: StyleValue
}>()

const emit = defineEmits<{
'update:query': [value: string]
hover: [key: string]
select: [key: string]
'select-effort': [value: string | null]
keydown: [event: KeyboardEvent]
focusout: [event: FocusEvent]
}>()

defineSlots<{
item?: (props: ModelSelectorItemSlotProps) => VNode | VNode[]
'group-label'?: (props: ModelSelectorGroupLabelSlotProps) => VNode | VNode[]
empty?: (props: ModelSelectorEmptySlotProps) => VNode | VNode[]
'panel-header'?: () => VNode | VNode[]
footer?: () => VNode | VNode[]
}>()

const searchInputEl = shallowRef<HTMLInputElement | null>(null)
const listboxEl = shallowRef<HTMLElement | null>(null)

function handleInput(event: Event) {
emit('update:query', (event.target as HTMLInputElement).value)
}

function focusPrimary() {
const focusTarget = props.searchable ? searchInputEl.value : listboxEl.value
focusTarget?.focus({ preventScroll: true })
}

defineExpose({ focusPrimary })
</script>

<template>
<div
class="tr-model-selector__panel"
:class="[`tr-model-selector__panel--${size}`, contentClass]"
:style="contentStyle"
@keydown="$emit('keydown', $event)"
@focusout="$emit('focusout', $event)"
>
<div v-if="$slots['panel-header']" class="tr-model-selector__panel-header">
<slot name="panel-header" />
</div>

<div v-if="searchable" class="tr-model-selector__search">
<IconSearch class="tr-model-selector__search-icon" aria-hidden="true" focusable="false" />
<input
ref="searchInputEl"
type="text"
class="tr-model-selector__search-input"
data-model-selector-search-input
role="combobox"
:value="query"
:placeholder="searchPlaceholder"
:aria-label="searchAriaLabel"
:aria-controls="listboxId"
:aria-activedescendant="activeDescendantId"
aria-autocomplete="list"
aria-expanded="true"
aria-haspopup="listbox"
autocomplete="off"
:spellcheck="false"
@input="handleInput"
/>
</div>

<div v-if="isEmpty" class="tr-model-selector__empty" role="status" aria-live="polite">
<slot name="empty" :query="query">
<IconNoData class="tr-model-selector__empty-icon" aria-hidden="true" focusable="false" />
<span>{{ emptyText }}</span>
</slot>
</div>

<div
:id="listboxId"
ref="listboxEl"
class="tr-model-selector__content"
:class="{ 'is-empty': isEmpty }"
role="listbox"
tabindex="-1"
:aria-label="listAriaLabel"
:aria-activedescendant="searchable ? undefined : activeDescendantId"
>
<ModelSelectorGroup
v-for="group in groups"
:key="group.key"
:group="group"
:selected-value="selectedValue"
:highlighted-key="highlightedKey"
:option-id-prefix="optionIdPrefix"
@hover="$emit('hover', $event)"
@select="$emit('select', $event)"
>
<template v-if="$slots.item" #item="slotProps">
<slot name="item" v-bind="slotProps" />
</template>
<template v-if="$slots['group-label']" #group-label="slotProps">
<slot name="group-label" v-bind="slotProps" />
</template>
</ModelSelectorGroup>
</div>

<div v-if="$slots.footer || effortOptions.length > 0" class="tr-model-selector__footer">
<slot name="footer">
<ModelSelectorEffort
:options="effortOptions"
:value="effortValue"
:label="effortLabel"
:group-aria-label="effortAriaLabel"
:disabled="effortDisabled"
@select="$emit('select-effort', $event)"
/>
</slot>
</div>
</div>
</template>
Loading
Loading