feat: community publish modals -- focus traps, aria-live, plan + outcome forms

This commit is contained in:
pyr0ball 2026-04-13 11:45:32 -07:00
parent 730445e479
commit 9603d421b6
4 changed files with 723 additions and 0 deletions

View file

@ -15,6 +15,17 @@
>{{ f.label }}</button> >{{ f.label }}</button>
</div> </div>
<!-- Share a plan action row -->
<div class="action-row flex-between mb-sm">
<button
class="btn btn-secondary btn-sm share-plan-btn"
aria-haspopup="dialog"
@click="showPublishPlan = true"
>
Share a plan
</button>
</div>
<!-- Loading skeletons --> <!-- Loading skeletons -->
<div <div
v-if="store.loading" v-if="store.loading"
@ -84,6 +95,14 @@
</div> </div>
</Transition> </Transition>
<!-- Publish plan modal -->
<PublishPlanModal
v-if="showPublishPlan"
:plan="null"
@close="showPublishPlan = false"
@published="onPlanPublished"
/>
</div> </div>
</template> </template>
@ -91,6 +110,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useCommunityStore } from '../stores/community' import { useCommunityStore } from '../stores/community'
import CommunityPostCard from './CommunityPostCard.vue' import CommunityPostCard from './CommunityPostCard.vue'
import PublishPlanModal from './PublishPlanModal.vue'
const emit = defineEmits<{ const emit = defineEmits<{
'plan-forked': [payload: { plan_id: number; week_start: string }] 'plan-forked': [payload: { plan_id: number; week_start: string }]
@ -99,6 +119,7 @@ const emit = defineEmits<{
const store = useCommunityStore() const store = useCommunityStore()
const activeFilter = ref('all') const activeFilter = ref('all')
const showPublishPlan = ref(false)
const filters = [ const filters = [
{ id: 'all', label: 'All' }, { id: 'all', label: 'All' },
@ -160,6 +181,11 @@ async function handleFork(slug: string) {
} }
} }
function onPlanPublished(_payload: { slug: string }) {
showPublishPlan.value = false
store.fetchPosts(activeFilter.value === 'all' ? undefined : activeFilter.value)
}
onMounted(async () => { onMounted(async () => {
if (store.posts.length === 0) { if (store.posts.length === 0) {
await store.fetchPosts() await store.fetchPosts()
@ -182,6 +208,14 @@ onMounted(async () => {
border-bottom: none; border-bottom: none;
} }
.action-row {
padding: var(--spacing-xs) 0;
}
.share-plan-btn {
font-size: var(--font-size-xs);
}
/* Loading skeletons */ /* Loading skeletons */
.skeleton-card { .skeleton-card {
background: var(--color-bg-card); background: var(--color-bg-card);

View file

@ -0,0 +1,363 @@
<template>
<Teleport to="body">
<div
class="modal-overlay"
@click.self="$emit('close')"
>
<div
ref="dialogRef"
class="modal-panel card"
role="dialog"
aria-modal="true"
aria-labelledby="publish-outcome-title"
tabindex="-1"
>
<!-- Header -->
<div class="flex-between mb-md">
<h2 id="publish-outcome-title" class="section-title">
Share a recipe story
<span v-if="recipeName" class="recipe-name-hint text-sm text-muted">
-- {{ recipeName }}
</span>
</h2>
<button
class="btn-close"
aria-label="Close"
@click="$emit('close')"
>&#x2715;</button>
</div>
<!-- Post type selector -->
<div class="form-group">
<fieldset class="type-fieldset">
<legend class="form-label">What kind of story is this?</legend>
<div class="type-toggle flex gap-sm">
<button
:class="['btn', 'type-btn', postType === 'recipe_success' ? 'type-btn-active' : 'btn-secondary']"
:aria-pressed="postType === 'recipe_success'"
@click="postType = 'recipe_success'"
>
Success
</button>
<button
:class="['btn', 'type-btn', postType === 'recipe_blooper' ? 'type-btn-active type-btn-blooper' : 'btn-secondary']"
:aria-pressed="postType === 'recipe_blooper'"
@click="postType = 'recipe_blooper'"
>
Blooper
</button>
</div>
</fieldset>
</div>
<!-- Title field -->
<div class="form-group">
<label class="form-label" for="outcome-title">
Title <span class="required-mark" aria-hidden="true">*</span>
</label>
<input
id="outcome-title"
ref="firstFocusRef"
v-model="title"
class="form-input"
type="text"
maxlength="200"
placeholder="e.g. Perfect crust on the first try"
autocomplete="off"
required
/>
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
{{ title.length }}/200
</span>
</div>
<!-- Outcome notes field -->
<div class="form-group">
<label class="form-label" for="outcome-notes">
What happened? <span class="optional-mark">(optional)</span>
</label>
<textarea
id="outcome-notes"
v-model="outcomeNotes"
class="form-input form-textarea"
maxlength="2000"
rows="4"
placeholder="Describe what you tried, what worked, or what went sideways."
/>
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
{{ outcomeNotes.length }}/2000
</span>
</div>
<!-- Pseudonym field -->
<div class="form-group">
<label class="form-label" for="outcome-pseudonym">
Community name <span class="optional-mark">(optional)</span>
</label>
<input
id="outcome-pseudonym"
v-model="pseudonymName"
class="form-input"
type="text"
maxlength="40"
placeholder="Leave blank to use your existing handle"
autocomplete="nickname"
/>
<span class="form-hint">How you appear on posts -- not your real name or email.</span>
</div>
<!-- Submission feedback (aria-live region, always rendered) -->
<div
class="feedback-region"
aria-live="polite"
aria-atomic="true"
>
<p v-if="submitError" class="feedback-error text-sm" role="alert">{{ submitError }}</p>
<p v-if="submitSuccess" class="feedback-success text-sm">{{ submitSuccess }}</p>
</div>
<!-- Footer actions -->
<div class="modal-footer flex gap-sm">
<button
class="btn btn-primary"
:disabled="submitting || !title.trim()"
:aria-busy="submitting"
@click="onSubmit"
>
<span v-if="submitting" class="spinner spinner-sm" aria-hidden="true"></span>
{{ submitting ? 'Publishing...' : 'Publish' }}
</button>
<button class="btn btn-secondary" @click="$emit('close')">
Cancel
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useCommunityStore } from '../stores/community'
import type { PublishPayload } from '../stores/community'
const props = defineProps<{
recipeId: number | null
recipeName: string | null
visible?: boolean
}>()
const emit = defineEmits<{
close: []
published: [payload: { slug: string }]
}>()
const store = useCommunityStore()
const postType = ref<'recipe_success' | 'recipe_blooper'>('recipe_success')
const title = ref('')
const outcomeNotes = ref('')
const pseudonymName = ref('')
const submitting = ref(false)
const submitError = ref<string | null>(null)
const submitSuccess = ref<string | null>(null)
const dialogRef = ref<HTMLElement | null>(null)
const firstFocusRef = ref<HTMLInputElement | null>(null)
let previousFocus: HTMLElement | null = null
function getFocusables(): HTMLElement[] {
if (!dialogRef.value) return []
return Array.from(
dialogRef.value.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
emit('close')
return
}
if (e.key !== 'Tab') return
const focusables = getFocusables()
if (focusables.length === 0) return
const first = focusables[0]!
const last = focusables[focusables.length - 1]!
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
onMounted(() => {
previousFocus = document.activeElement as HTMLElement
document.addEventListener('keydown', handleKeydown)
nextTick(() => {
(firstFocusRef.value ?? dialogRef.value)?.focus()
})
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
previousFocus?.focus()
})
async function onSubmit() {
submitError.value = null
submitSuccess.value = null
if (!title.value.trim()) return
const payload: PublishPayload = {
post_type: postType.value,
title: title.value.trim(),
}
if (outcomeNotes.value.trim()) payload.outcome_notes = outcomeNotes.value.trim()
if (pseudonymName.value.trim()) payload.pseudonym_name = pseudonymName.value.trim()
if (props.recipeId != null) payload.recipe_id = props.recipeId
submitting.value = true
try {
const result = await store.publishPost(payload)
submitSuccess.value = 'Your story has been posted.'
nextTick(() => {
emit('published', { slug: result.slug })
})
} catch (err: unknown) {
submitError.value = err instanceof Error ? err.message : 'Could not publish. Please try again.'
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 400;
padding: var(--spacing-md);
}
.modal-panel {
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.btn-close {
background: none;
border: none;
font-size: 1.1rem;
cursor: pointer;
color: var(--color-text-secondary);
padding: var(--spacing-xs);
line-height: 1;
border-radius: var(--radius-sm);
}
.btn-close:hover {
color: var(--color-text-primary);
}
.recipe-name-hint {
font-style: italic;
}
.required-mark {
color: var(--color-error);
margin-left: 2px;
}
.optional-mark {
font-weight: 400;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.type-fieldset {
border: none;
padding: 0;
margin: 0;
}
.type-toggle {
flex-wrap: wrap;
}
.type-btn {
min-width: 100px;
}
.type-btn-active {
background: var(--color-success);
color: white;
border-color: var(--color-success);
font-weight: 700;
}
.type-btn-active.type-btn-blooper {
background: var(--color-warning);
border-color: var(--color-warning);
color: var(--color-text-primary);
}
.char-counter {
text-align: right;
display: block;
margin-top: var(--spacing-xs);
}
.feedback-region {
min-height: 1.4rem;
margin-bottom: var(--spacing-xs);
}
.feedback-error {
color: var(--color-error);
margin: 0;
}
.feedback-success {
color: var(--color-success);
margin: 0;
}
.modal-footer {
justify-content: flex-start;
padding-top: var(--spacing-md);
border-top: 1px solid var(--color-border);
margin-top: var(--spacing-md);
flex-wrap: wrap;
}
@media (max-width: 480px) {
.modal-panel {
max-height: 95vh;
}
.modal-footer {
flex-direction: column-reverse;
}
.modal-footer .btn {
width: 100%;
}
}
</style>

View file

@ -0,0 +1,306 @@
<template>
<Teleport to="body">
<div
class="modal-overlay"
@click.self="$emit('close')"
>
<div
ref="dialogRef"
class="modal-panel card"
role="dialog"
aria-modal="true"
aria-labelledby="publish-plan-title"
tabindex="-1"
>
<!-- Header -->
<div class="flex-between mb-md">
<h2 id="publish-plan-title" class="section-title">Share this week's plan</h2>
<button
class="btn-close"
aria-label="Close"
@click="$emit('close')"
>&#x2715;</button>
</div>
<!-- Title field -->
<div class="form-group">
<label class="form-label" for="plan-pub-title">
Title <span class="required-mark" aria-hidden="true">*</span>
</label>
<input
id="plan-pub-title"
ref="firstFocusRef"
v-model="title"
class="form-input"
type="text"
maxlength="200"
placeholder="e.g. Mediterranean Week"
autocomplete="off"
required
/>
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
{{ title.length }}/200
</span>
</div>
<!-- Description field -->
<div class="form-group">
<label class="form-label" for="plan-pub-desc">
Description <span class="optional-mark">(optional)</span>
</label>
<textarea
id="plan-pub-desc"
v-model="description"
class="form-input form-textarea"
maxlength="2000"
rows="3"
placeholder="What makes this week worth sharing?"
/>
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
{{ description.length }}/2000
</span>
</div>
<!-- Pseudonym field -->
<div class="form-group">
<label class="form-label" for="plan-pub-pseudonym">
Community name <span class="optional-mark">(optional)</span>
</label>
<input
id="plan-pub-pseudonym"
v-model="pseudonymName"
class="form-input"
type="text"
maxlength="40"
placeholder="Leave blank to use your existing handle"
autocomplete="nickname"
/>
<span class="form-hint">How you appear on posts -- not your real name or email.</span>
</div>
<!-- Submission feedback (aria-live region, always rendered) -->
<div
class="feedback-region"
aria-live="polite"
aria-atomic="true"
>
<p v-if="submitError" class="feedback-error text-sm" role="alert">{{ submitError }}</p>
<p v-if="submitSuccess" class="feedback-success text-sm">{{ submitSuccess }}</p>
</div>
<!-- Footer actions -->
<div class="modal-footer flex gap-sm">
<button
class="btn btn-primary"
:disabled="submitting || !title.trim()"
:aria-busy="submitting"
@click="onSubmit"
>
<span v-if="submitting" class="spinner spinner-sm" aria-hidden="true"></span>
{{ submitting ? 'Publishing...' : 'Publish' }}
</button>
<button class="btn btn-secondary" @click="$emit('close')">
Cancel
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useCommunityStore } from '../stores/community'
import type { PublishPayload } from '../stores/community'
const props = defineProps<{
plan?: {
id: number
week_start: string
slots: Array<{ day: string; meal_type: string; recipe_id: number; recipe_name: string }>
} | null
visible?: boolean
}>()
const emit = defineEmits<{
close: []
published: [payload: { slug: string }]
}>()
const store = useCommunityStore()
const title = ref('')
const description = ref('')
const pseudonymName = ref('')
const submitting = ref(false)
const submitError = ref<string | null>(null)
const submitSuccess = ref<string | null>(null)
const dialogRef = ref<HTMLElement | null>(null)
const firstFocusRef = ref<HTMLInputElement | null>(null)
let previousFocus: HTMLElement | null = null
function getFocusables(): HTMLElement[] {
if (!dialogRef.value) return []
return Array.from(
dialogRef.value.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
emit('close')
return
}
if (e.key !== 'Tab') return
const focusables = getFocusables()
if (focusables.length === 0) return
const first = focusables[0]!
const last = focusables[focusables.length - 1]!
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
onMounted(() => {
previousFocus = document.activeElement as HTMLElement
document.addEventListener('keydown', handleKeydown)
nextTick(() => {
(firstFocusRef.value ?? dialogRef.value)?.focus()
})
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
previousFocus?.focus()
})
async function onSubmit() {
submitError.value = null
submitSuccess.value = null
if (!title.value.trim()) return
const payload: PublishPayload = {
post_type: 'plan',
title: title.value.trim(),
}
if (description.value.trim()) payload.description = description.value.trim()
if (pseudonymName.value.trim()) payload.pseudonym_name = pseudonymName.value.trim()
if (props.plan?.id != null) payload.plan_id = props.plan.id
submitting.value = true
try {
const result = await store.publishPost(payload)
submitSuccess.value = 'Plan published to the community feed.'
nextTick(() => {
emit('published', { slug: result.slug })
})
} catch (err: unknown) {
submitError.value = err instanceof Error ? err.message : 'Could not publish. Please try again.'
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 400;
padding: var(--spacing-md);
}
.modal-panel {
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.btn-close {
background: none;
border: none;
font-size: 1.1rem;
cursor: pointer;
color: var(--color-text-secondary);
padding: var(--spacing-xs);
line-height: 1;
border-radius: var(--radius-sm);
}
.btn-close:hover {
color: var(--color-text-primary);
}
.required-mark {
color: var(--color-error);
margin-left: 2px;
}
.optional-mark {
font-weight: 400;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.char-counter {
text-align: right;
display: block;
margin-top: var(--spacing-xs);
}
.feedback-region {
min-height: 1.4rem;
margin-bottom: var(--spacing-xs);
}
.feedback-error {
color: var(--color-error);
margin: 0;
}
.feedback-success {
color: var(--color-success);
margin: 0;
}
.modal-footer {
justify-content: flex-start;
padding-top: var(--spacing-md);
border-top: 1px solid var(--color-border);
margin-top: var(--spacing-md);
flex-wrap: wrap;
}
@media (max-width: 480px) {
.modal-panel {
max-height: 95vh;
}
.modal-footer {
flex-direction: column-reverse;
}
.modal-footer .btn {
width: 100%;
}
}
</style>

View file

@ -55,6 +55,20 @@ export interface ForkResult {
forked_from: string forked_from: string
} }
export interface PublishPayload {
post_type: 'plan' | 'recipe_success' | 'recipe_blooper'
title: string
description?: string
pseudonym_name?: string
plan_id?: number
recipe_id?: number
outcome_notes?: string
}
export interface PublishResult {
slug: string
}
// ========== Store ========== // ========== Store ==========
export const useCommunityStore = defineStore('community', () => { export const useCommunityStore = defineStore('community', () => {
@ -87,6 +101,11 @@ export const useCommunityStore = defineStore('community', () => {
return response.data return response.data
} }
async function publishPost(payload: PublishPayload): Promise<PublishResult> {
const response = await api.post<PublishResult>('/community/posts', payload)
return response.data
}
return { return {
posts, posts,
loading, loading,
@ -94,5 +113,6 @@ export const useCommunityStore = defineStore('community', () => {
currentFilter, currentFilter,
fetchPosts, fetchPosts,
forkPost, forkPost,
publishPost,
} }
}) })