-
-
-
-
-
-
-
diff --git a/src/frontend/css/base/styles.css b/src/frontend/css/base/styles.css
index 3c1180f93..56e50cc41 100644
--- a/src/frontend/css/base/styles.css
+++ b/src/frontend/css/base/styles.css
@@ -3040,6 +3040,33 @@ Settings Page - Collapsible Sections
}
}
+/* Word edit / multi-word modal — keep reading visible behind it.
+ These reuse Bulma's `.modal`/`.modal-card` but, like `.word-popover` above,
+ must stay non-blocking: the opaque `.modal-background` and near-fullscreen
+ `.modal-card` otherwise cover the whole viewport and hide the text the
+ reader was just looking at. Drop the dimming and, on mobile, cap the card
+ to a bottom sheet so the top of the reading area stays visible. The edit
+ form itself is tabbed (one section at a time) precisely to fit this cap
+ without needing to scroll. */
+.reading-modal .modal-background {
+ background-color: transparent;
+}
+
+@media screen and (max-width: 768px) {
+ .reading-modal .modal-card {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ top: auto;
+ max-width: 100%;
+ min-width: 100%;
+ max-height: 50vh;
+ margin: 0;
+ border-radius: 12px 12px 0 0;
+ }
+}
+
/* Dark theme overrides for word popover */
@media (prefers-color-scheme: dark) {
.word-popover__translation {
diff --git a/src/frontend/js/modules/vocabulary/components/word_edit_form.ts b/src/frontend/js/modules/vocabulary/components/word_edit_form.ts
index 8259d684a..4ce81d4ab 100644
--- a/src/frontend/js/modules/vocabulary/components/word_edit_form.ts
+++ b/src/frontend/js/modules/vocabulary/components/word_edit_form.ts
@@ -29,6 +29,14 @@ interface StatusInfo {
type FormDataField = 'translation' | 'romanization' | 'sentence' | 'notes';
+/**
+ * Edit-form section shown at a time. Splitting the form into tabs (one
+ * section visible at once) keeps the modal short enough to leave the
+ * reading text visible behind it — see word_modal.php / styles.css
+ * `.reading-modal`.
+ */
+export type FormTab = 'translate' | 'example' | 'notes' | 'tags';
+
/**
* Status definitions matching word_modal.ts. Computed lazily so translations are loaded.
*/
@@ -66,6 +74,13 @@ export interface WordEditFormData {
readonly showRomanization: boolean;
readonly statuses: StatusInfo[];
+ // Section tabs — only one is shown at a time (issue: modal was hiding the
+ // reading text). Resets to 'translate' each time the form remounts, since
+ // it lives inside the parent's `x-if="viewMode === 'edit'"` template.
+ activeTab: FormTab;
+ setActiveTab(tab: FormTab): void;
+ tabHasError(tab: FormTab): boolean;
+
// CSP-safe proxy properties for x-model (avoids nested property assignments)
translation: string;
romanization: string;
@@ -113,6 +128,26 @@ export interface WordEditFormData {
*/
export function wordEditFormData(): WordEditFormData {
return {
+ // Section tabs
+ activeTab: 'translate' as FormTab,
+
+ setActiveTab(tab: FormTab): void {
+ this.activeTab = tab;
+ },
+
+ tabHasError(tab: FormTab): boolean {
+ switch (tab) {
+ case 'translate':
+ return this.hasFieldError('translation') || this.hasFieldError('romanization');
+ case 'example':
+ return this.hasFieldError('sentence');
+ case 'notes':
+ return this.hasFieldError('notes');
+ case 'tags':
+ return false;
+ }
+ },
+
// Tag input state
tagInput: '',
showTagSuggestions: false,
@@ -249,7 +284,15 @@ export function wordEditFormData(): WordEditFormData {
async save(): Promise
{
const result = await this.formStore.save();
- if (result.success && result.hex) {
+ if (!result.success) {
+ // Jump to the first tab holding the invalid field so it's visible.
+ const tabs: FormTab[] = ['translate', 'example', 'notes'];
+ const firstBadTab = tabs.find(tab => this.tabHasError(tab));
+ if (firstBadTab) this.activeTab = firstBadTab;
+ return;
+ }
+
+ if (result.hex) {
const hex = result.hex;
const status = this.formStore.formData.status;
const translation = this.formStore.formData.translation;
diff --git a/tests/frontend/vocabulary/components/word_edit_form.test.ts b/tests/frontend/vocabulary/components/word_edit_form.test.ts
index a4cd2227d..a8712f972 100644
--- a/tests/frontend/vocabulary/components/word_edit_form.test.ts
+++ b/tests/frontend/vocabulary/components/word_edit_form.test.ts
@@ -35,6 +35,14 @@ const mockFormStore = {
tags: [] as string[]
},
allTags: ['tag1', 'tag2', 'tag3', 'news', 'sports'],
+ errors: {
+ lemma: null,
+ translation: null,
+ romanization: null,
+ sentence: null,
+ notes: null,
+ general: null
+ } as Record,
shouldCloseModal: false,
shouldReturnToInfo: false,
validateField: vi.fn(),
@@ -71,6 +79,14 @@ describe('word_edit_form.ts', () => {
mockFormStore.showRomanization = false;
mockFormStore.formData.tags = [];
mockFormStore.allTags = ['tag1', 'tag2', 'tag3', 'news', 'sports'];
+ mockFormStore.errors = {
+ lemma: null,
+ translation: null,
+ romanization: null,
+ sentence: null,
+ notes: null,
+ general: null
+ };
mockFormStore.shouldCloseModal = false;
mockFormStore.shouldReturnToInfo = false;
});
@@ -270,6 +286,73 @@ describe('word_edit_form.ts', () => {
expect(mockWordStore.updateWordInStore).not.toHaveBeenCalled();
expect(updateWordStatusInDOM).not.toHaveBeenCalled();
});
+
+ it('jumps to the tab holding the invalid field on failure', async () => {
+ mockFormStore.save.mockResolvedValue({ success: false, error: 'Error' });
+ mockFormStore.errors.sentence = 'Sentence must be 1000 characters or less';
+
+ const component = wordEditFormData();
+ component.activeTab = 'tags';
+ await component.save();
+
+ expect(component.activeTab).toBe('example');
+ });
+
+ it('leaves activeTab untouched when save fails with no field errors', async () => {
+ mockFormStore.save.mockResolvedValue({ success: false, error: 'Failed to save term' });
+
+ const component = wordEditFormData();
+ component.activeTab = 'tags';
+ await component.save();
+
+ expect(component.activeTab).toBe('tags');
+ });
+ });
+
+ // ===========================================================================
+ // Tab Tests
+ // ===========================================================================
+
+ describe('tabs', () => {
+ it('defaults activeTab to translate', () => {
+ const component = wordEditFormData();
+ expect(component.activeTab).toBe('translate');
+ });
+
+ it('setActiveTab switches the active tab', () => {
+ const component = wordEditFormData();
+ component.setActiveTab('notes');
+ expect(component.activeTab).toBe('notes');
+ });
+
+ it('tabHasError reports translation/romanization errors on the translate tab', () => {
+ const component = wordEditFormData();
+ expect(component.tabHasError('translate')).toBe(false);
+
+ mockFormStore.errors.translation = 'Translation must be 500 characters or less';
+ expect(component.tabHasError('translate')).toBe(true);
+
+ mockFormStore.errors.translation = null;
+ mockFormStore.errors.romanization = 'Romanization must be 100 characters or less';
+ expect(component.tabHasError('translate')).toBe(true);
+ });
+
+ it('tabHasError reports sentence errors on the example tab', () => {
+ const component = wordEditFormData();
+ mockFormStore.errors.sentence = 'Sentence must be 1000 characters or less';
+ expect(component.tabHasError('example')).toBe(true);
+ });
+
+ it('tabHasError reports notes errors on the notes tab', () => {
+ const component = wordEditFormData();
+ mockFormStore.errors.notes = 'Notes must be 1000 characters or less';
+ expect(component.tabHasError('notes')).toBe(true);
+ });
+
+ it('tabHasError is always false for the tags tab', () => {
+ const component = wordEditFormData();
+ expect(component.tabHasError('tags')).toBe(false);
+ });
});
// ===========================================================================