Skip to content
Open
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 @@ -9,11 +9,12 @@ import { PiniaColada } from '@pinia/colada'
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
import { BasicContentEditable } from '@proj-airi/ui'
import { createPinia } from 'pinia'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-vue'
import { userEvent } from 'vitest/browser'
import { nextTick } from 'vue'
import { defineComponent, nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { createMemoryHistory, createRouter } from 'vue-router'

Expand All @@ -32,6 +33,24 @@ function createTestI18n() {
})
}

const ContentEditableHarness = defineComponent({
components: { BasicContentEditable },
setup() {
const defaultHeight = ref('32px')
const message = ref('')

return { defaultHeight, message }
},
template: `
<button type="button" @click="defaultHeight = '48px'">Expand editor</button>
<BasicContentEditable
v-model="message"
:default-height="defaultHeight"
placeholder="Write a message"
/>
`,
})

async function renderArea(component: Component = InteractiveArea) {
const sessionB: ChatSessionMeta = {
sessionId: 'session-b',
Expand Down Expand Up @@ -315,14 +334,99 @@ describe('interactive area synchronized state', () => {
}))
})

it('opts the mobile composer out of browser form assistance', async () => {
it('keeps the mobile composer outside Safari form controls', async () => {
// ROOT CAUSE:
//
// Safari displays Form Assistant above its keyboard for a textarea, even
// when autocomplete and text-correction attributes are disabled.
//
// The mobile chat composer uses a plain-text contenteditable element.
// It remains a keyboard target without becoming a Safari form control.
const { screen } = await renderArea(MobileInteractiveArea)
const input = screen.getByRole('textbox').element() as HTMLTextAreaElement
const input = screen.getByRole('textbox').element()

expect(input.getAttribute('autocomplete')).toBe('off')
expect(input.tagName).toBe('DIV')
expect(input.getAttribute('contenteditable')).toBe('plaintext-only')
expect(input.getAttribute('aria-multiline')).toBe('true')
expect(input.getAttribute('autocapitalize')).toBe('off')
expect(input.getAttribute('autocorrect')).toBe('off')
expect(input.spellcheck).toBe(false)
expect(input.getAttribute('spellcheck')).toBe('false')
})

// https://github.com/moeru-ai/airi/pull/2461#discussion_r3932375161
it('restores the mobile placeholder after a draft is cleared', async () => {
const { screen } = await renderArea(MobileInteractiveArea)
const input = screen.getByRole('textbox').element()

await userEvent.fill(input, 'draft')
await userEvent.clear(input)

await vi.waitFor(() => expect(input.getAttribute('data-empty')).toBe(''))
})

// https://github.com/moeru-ai/airi/pull/2461#discussion_r3932701283
// https://github.com/moeru-ai/airi/pull/2461#discussion_r3932882973
it('normalizes rich input and reacts to default height changes', async () => {
const screen = await render(ContentEditableHarness)
const input = screen.getByRole('textbox').element()

input.innerHTML = '<strong>formatted</strong>'
input.dispatchEvent(new Event('input', { bubbles: true }))

await vi.waitFor(() => expect(input.innerHTML).toBe('formatted'))
await vi.waitFor(() => expect(input.style.height).toBe('32px'))

await userEvent.click(screen.getByRole('button', { name: 'Expand editor' }))

await vi.waitFor(() => expect(input.style.height).toBe('48px'))
})

// https://github.com/moeru-ai/airi/pull/2461#discussion_r3935363246
it('does not serialize the Shift+Enter caret filler as another line', async () => {
const screen = await render(ContentEditableHarness)
const input = screen.getByRole('textbox').element()

input.innerHTML = 'draft<br><br>'
input.dispatchEvent(new Event('input', { bubbles: true }))

await vi.waitFor(() => expect(input.textContent).toBe('draft\n'))
})

it('keeps a one-line mobile draft at the composer height', async () => {
// ROOT CAUSE:
//
// An empty contenteditable has different intrinsic sizing from a typed
// contenteditable. On a phone, that made the composer visibly taller after
// the first character.
//
// The shared editor measures overflow against its explicit empty height. A
// one-line draft must retain the empty composer height.
const { screen } = await renderArea(MobileInteractiveArea)
const input = screen.getByRole('textbox').element()
const emptyHeight = input.getBoundingClientRect().height

await userEvent.fill(input, 'hi')

await vi.waitFor(() => expect(input.style.height).toMatch(/px$/))
expect(input.getBoundingClientRect().height).toBe(emptyHeight)
})

it('keeps a one-line mobile draft at the composer width', async () => {
// ROOT CAUSE:
//
// The mobile composer expanded when its editor received focus. A one-line
// draft must keep the resting composer width.
const { screen } = await renderArea(MobileInteractiveArea)
const inputBubble = screen.getByTestId('mobile-input-bubble').element()
const input = screen.getByRole('textbox')
const emptyWidth = inputBubble.getBoundingClientRect().width

expect(emptyWidth).toBeGreaterThan(0)

await userEvent.fill(input, 'hi')
await new Promise(resolve => setTimeout(resolve, 400))

expect(inputBubble.getBoundingClientRect().width).toBe(emptyWidth)
})

// https://github.com/moeru-ai/airi/pull/2086#discussion_r3755530944
Expand All @@ -341,7 +445,7 @@ describe('interactive area synchronized state', () => {
chatSession.activeSessionId = 'session-a'
rejectSend?.(new Error('send failed'))

await expect.element(input).toHaveValue('')
await expect.element(input).toHaveTextContent('')
})

it('does not restore a deleted-session draft in the shared chat widget', async () => {
Expand Down
13 changes: 13 additions & 0 deletions docs/ai/context/ui-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,19 @@ Auto-resizing textarea with submit and paste-file events.
**v-model**: `input: string`
**Emits**: `submit(message: string)`, `pasteFile(files: File[])`

### BasicContentEditable

Plain-text multiline contenteditable control with submit and paste-file events. It preserves browser undo for plain-text paste and is for text entry that must not use browser form controls.

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `defaultHeight` | `string?` | — | Reactive height when empty |
| `placeholder` | `string?` | — | Placeholder text |
| `submitOnEnter` | `boolean?` | `true` | Submit on Enter (Shift+Enter for newline) |

**v-model**: `input: string`
**Emits**: `submit(message: string)`, `pasteFile(files: File[])`

### Textarea

Styled textarea wrapping `BasicTextarea`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store
import { useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { BasicTextarea, useTheme } from '@proj-airi/ui'
import { BasicContentEditable, useTheme } from '@proj-airi/ui'
import { onLongPress, useEventListener, usePointerSwipe } from '@vueuse/core'
import { animate, spring } from 'animejs'
import { storeToRefs } from 'pinia'
Expand Down Expand Up @@ -204,7 +204,7 @@ async function setInputBubbleDocked(docked: boolean) {
const startY = source.top - destination.top
const endX = target.left + target.width / 2 - destination.left - destination.width / 2
const endY = target.top + target.height / 2 - destination.top - destination.height / 2
const messageInput = bubble.querySelector<HTMLTextAreaElement>('textarea')!
const messageInput = bubble.querySelector<HTMLElement>('[contenteditable]')!

await Promise.all([
animate(bubble, {
Expand Down Expand Up @@ -274,18 +274,18 @@ function handleInputBubbleLongPress() {

suppressNextInputBubbleClick = true
inputBubbleDragging.value = true
inputBubble.value!.querySelector<HTMLTextAreaElement>('textarea')!.blur()
inputBubble.value!.querySelector<HTMLElement>('[contenteditable]')!.blur()
inputBubble.value!.style.transform = 'translate3d(0, 0, 0) scale(.98)'
}

function handleInputBubblePointerDown(event: PointerEvent) {
suppressNextInputBubbleClick = false

const messageInput = inputBubble.value!.querySelector<HTMLTextAreaElement>('textarea')!
const messageInput = inputBubble.value!.querySelector<HTMLElement>('[contenteditable]')!

// NOTICE:
// The focused textarea must suppress native text selection before a dock drag starts.
// A blurred textarea must keep native activation so Safari can cancel an active keyboard dismissal.
// The focused editor must suppress native text selection before a dock drag starts.
// A blurred editor must keep native activation so Safari can cancel an active keyboard dismissal.
// See the closing-focus regression in adaptive-input.test.ts.
// Remove this branch when Safari exposes a keyboard lifecycle that can cancel an active dismissal.
if (document.activeElement === messageInput)
Expand Down Expand Up @@ -314,7 +314,7 @@ async function handleInputBubbleClick() {
return
}

inputBubble.value!.querySelector<HTMLTextAreaElement>('textarea')!.focus()
inputBubble.value!.querySelector<HTMLElement>('[contenteditable]')!.focus()
}

async function handleInputBubblePointerCancel() {
Expand Down Expand Up @@ -555,33 +555,33 @@ onUnmounted(() => {
'h-10 max-w-10 w-10 cursor-pointer rounded-xl border-2 border-solid backdrop-blur-md',
'border-neutral-100/60 bg-neutral-50/70 dark:border-neutral-800/30 dark:bg-neutral-800/70',
]
: 'max-w-[70%] w-full focus-within:max-w-full',
: 'max-w-[70%] w-full',
]"
@click="handleInputBubbleClick"
@contextmenu="handleInputBubbleContextMenu"
@pointerdown="handleInputBubblePointerDown"
>
<!-- Android handles touch from the scrollable textarea, so it needs touch-none to keep the bubble drag active. -->
<BasicTextarea
<!-- Android handles touch from the scrollable editor, so it needs touch-none to keep the bubble drag active. -->
<BasicContentEditable
v-model="messageInput"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
:spellcheck="false"
default-height="calc(1lh + 4px + 4px)"
:placeholder="t('stage.message')"
Comment thread
luoling8192 marked this conversation as resolved.
:class="[
'font-cute',
'max-h-[10lh] min-h-[calc(1lh+4px+4px)] w-full touch-none resize-none overflow-y-scroll scrollbar-none',
'max-h-[10lh] min-h-[calc(1lh+4px+4px)] w-full touch-none overflow-y-scroll scrollbar-none',
'border-2 border-solid px-4 py-0.5 outline-none backdrop-blur-md',
'text-neutral-500 dark:text-neutral-100',
'rounded-[1lh] border-neutral-200/60 bg-neutral-100/80 dark:border-neutral-700/60 dark:bg-neutral-950/80',
'transition-colors duration-250 ease-in-out hover:text-neutral-600 dark:hover:text-neutral-200',
'placeholder:text-[14px] placeholder:vertical-middle placeholder:leading-6 placeholder:text-neutral-400',
'placeholder:transition-all placeholder:duration-250 placeholder:ease-in-out placeholder:hover:text-neutral-500 dark:placeholder:text-neutral-500 dark:placeholder:hover:text-neutral-400',
'data-[empty]:before:text-[14px] data-[empty]:before:leading-6 data-[empty]:before:text-neutral-400',
'data-[empty]:before:transition-all data-[empty]:before:duration-250 data-[empty]:before:ease-in-out data-[empty]:hover:before:text-neutral-500 dark:data-[empty]:before:text-neutral-500 dark:data-[empty]:hover:before:text-neutral-400',
messageInputPointerEventsClass,
themeColorsHueDynamic ? 'transition-colors-none placeholder:transition-colors-none' : undefined,
themeColorsHueDynamic ? 'transition-colors-none data-[empty]:before:transition-colors-none' : undefined,
]"
default-height="1lh"
@submit="handleSubmit"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
Expand Down
24 changes: 24 additions & 0 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,30 @@ import { Button } from '@proj-airi/ui'
* [Range](src/components/Form/Range)
* [ComboboxSelect](src/components/Form/Select)
* [Textarea](src/components/Form/Textarea)
* [ContentEditable](src/components/form/content-editable)

### BasicContentEditable

Use `BasicContentEditable` for a plain-text multiline keyboard target that must not be a browser form control. It submits on Enter and adds a line on Shift+Enter.

Do not use it for a standard form field. Use `Textarea` when browser form behavior is required.

```vue
<script setup lang="ts">
import { BasicContentEditable } from '@proj-airi/ui'
import { ref } from 'vue'

const message = ref('')

function sendMessage(value: string) {
console.info(value)
}
</script>

<template>
<BasicContentEditable v-model="message" placeholder="Write a message" @submit="sendMessage" />
</template>
```

## License

Expand Down
Loading
Loading