feat: SftCorrectionArea — inline correction text area component
This commit is contained in:
parent
137a9dbb8e
commit
2d939b77f9
2 changed files with 192 additions and 0 deletions
62
web/src/components/SftCorrectionArea.test.ts
Normal file
62
web/src/components/SftCorrectionArea.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { mount } from '@vue/test-utils'
|
||||
import SftCorrectionArea from './SftCorrectionArea.vue'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
describe('SftCorrectionArea', () => {
|
||||
it('renders a textarea', () => {
|
||||
const w = mount(SftCorrectionArea)
|
||||
expect(w.find('textarea').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('submit button is disabled when textarea is empty', () => {
|
||||
const w = mount(SftCorrectionArea)
|
||||
const btn = w.find('[data-testid="submit-btn"]')
|
||||
expect((btn.element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('submit button is disabled when textarea is whitespace only', async () => {
|
||||
const w = mount(SftCorrectionArea)
|
||||
await w.find('textarea').setValue(' ')
|
||||
const btn = w.find('[data-testid="submit-btn"]')
|
||||
expect((btn.element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('submit button is enabled when textarea has content', async () => {
|
||||
const w = mount(SftCorrectionArea)
|
||||
await w.find('textarea').setValue('def add(a, b): return a + b')
|
||||
const btn = w.find('[data-testid="submit-btn"]')
|
||||
expect((btn.element as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('clicking submit emits submit with trimmed text', async () => {
|
||||
const w = mount(SftCorrectionArea)
|
||||
await w.find('textarea').setValue(' def add(a, b): return a + b ')
|
||||
await w.find('[data-testid="submit-btn"]').trigger('click')
|
||||
expect(w.emitted('submit')?.[0]).toEqual(['def add(a, b): return a + b'])
|
||||
})
|
||||
|
||||
it('clicking cancel emits cancel', async () => {
|
||||
const w = mount(SftCorrectionArea)
|
||||
await w.find('[data-testid="cancel-btn"]').trigger('click')
|
||||
expect(w.emitted('cancel')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Escape key emits cancel', async () => {
|
||||
const w = mount(SftCorrectionArea)
|
||||
await w.find('textarea').trigger('keydown', { key: 'Escape' })
|
||||
expect(w.emitted('cancel')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Ctrl+Enter emits submit when text is non-empty', async () => {
|
||||
const w = mount(SftCorrectionArea)
|
||||
await w.find('textarea').setValue('correct answer')
|
||||
await w.find('textarea').trigger('keydown', { key: 'Enter', ctrlKey: true })
|
||||
expect(w.emitted('submit')?.[0]).toEqual(['correct answer'])
|
||||
})
|
||||
|
||||
it('Ctrl+Enter does not emit submit when text is empty', async () => {
|
||||
const w = mount(SftCorrectionArea)
|
||||
await w.find('textarea').trigger('keydown', { key: 'Enter', ctrlKey: true })
|
||||
expect(w.emitted('submit')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
130
web/src/components/SftCorrectionArea.vue
Normal file
130
web/src/components/SftCorrectionArea.vue
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<template>
|
||||
<div class="correction-area">
|
||||
<label class="correction-label" for="correction-textarea">
|
||||
Write the corrected response:
|
||||
</label>
|
||||
<textarea
|
||||
id="correction-textarea"
|
||||
ref="textareaEl"
|
||||
v-model="text"
|
||||
class="correction-textarea"
|
||||
aria-label="Write corrected response"
|
||||
aria-required="true"
|
||||
:aria-describedby="describedBy"
|
||||
placeholder="Write the response this model should have given..."
|
||||
rows="4"
|
||||
@keydown.escape="$emit('cancel')"
|
||||
@keydown.enter.ctrl.prevent="submitIfValid"
|
||||
@keydown.enter.meta.prevent="submitIfValid"
|
||||
/>
|
||||
<div class="correction-actions">
|
||||
<button
|
||||
data-testid="submit-btn"
|
||||
class="btn-submit"
|
||||
:disabled="!isValid"
|
||||
@click="submitIfValid"
|
||||
>
|
||||
Submit correction
|
||||
</button>
|
||||
<button data-testid="cancel-btn" class="btn-cancel" @click="$emit('cancel')">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{ describedBy?: string }>(), { describedBy: undefined })
|
||||
|
||||
const emit = defineEmits<{ submit: [text: string]; cancel: [] }>()
|
||||
|
||||
const text = ref('')
|
||||
const textareaEl = ref<HTMLTextAreaElement | null>(null)
|
||||
const isValid = computed(() => text.value.trim().length > 0)
|
||||
|
||||
onMounted(() => textareaEl.value?.focus())
|
||||
|
||||
function submitIfValid() {
|
||||
if (isValid.value) emit('submit', text.value.trim())
|
||||
}
|
||||
|
||||
function reset() {
|
||||
text.value = ''
|
||||
}
|
||||
|
||||
defineExpose({ reset })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.correction-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface-alt, var(--color-surface));
|
||||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||
}
|
||||
|
||||
.correction-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.correction-textarea {
|
||||
width: 100%;
|
||||
min-height: 7rem;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.correction-textarea:focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.correction-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse, #fff);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-submit:not(:disabled):hover {
|
||||
background: var(--color-primary-hover, var(--color-primary));
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue