+
+
+
+ Título *
+
+ setTitle(event.target.value)}
+ maxLength={160}
+ minLength={2}
+ placeholder={sheet ? 'Adicionar título *' : 'Ex.: Café com apoiadores'}
+ className={sheet ? `min-h-11 ${sheetFieldInputClass}` : 'min-h-11'}
+ autoComplete="off"
+ aria-invalid={Boolean(errorFor('title'))}
+ aria-describedby={errorFor('title') ? 'inline-title-error' : undefined}
+ />
+ {errorFor('title') ? (
+ {errorFor('title')}
+ ) : null}
+
+
+ {/* C104 — all-day toggle: hides the time part of Início/Término. */}
+
+ setAllDay(Boolean(next))}
+ aria-label="Todo o dia"
+ />
+ Todo o dia
+
+
+ {sheet ? (
+ startEndFields
+ ) : (
+
{startEndFields}
+ )}
+
+
+
+ Município *
+
+
+ {errorFor('municipality') ? (
+ {errorFor('municipality')}
+ ) : null}
+
+
+
+
+ Local (opcional)
+
+ setLocality(event.target.value)}
+ maxLength={160}
+ placeholder={sheet ? 'Local (opcional)' : 'Bairro, endereço ou referência'}
+ className={sheet ? `min-h-11 ${sheetFieldInputClass}` : 'min-h-11'}
+ />
+
+
+
+
+ Local (opcional)
+
+ setLocality(event.target.value)}
+ maxLength={160}
+ placeholder={sheet ? 'Local (opcional)' : 'Bairro, endereço ou referência'}
+ className={sheet ? `min-h-11 ${sheetFieldInputClass}` : 'min-h-11'}
+ />
+
+
+
+
+
+
+
+ {failedMessage ? (
+
+ {failedMessage}
+
) : null}
-
-
-
-
- setAllDay(Boolean(next))}
- aria-label="Todo o dia"
- />
- Todo o dia
-
-
-
-
-
- Início *
-
- {errorFor('startAt') ? (
- {errorFor('startAt')}
- ) : null}
-
-
- Término
-
- {errorFor('endAt') ? (
- {errorFor('endAt')}
- ) : null}
-
-
- Município *
-
- {errorFor('municipality') ? (
- {errorFor('municipality')}
- ) : null}
-
-
-
- Local (opcional)
- setLocality(event.target.value)}
- maxLength={160}
- placeholder="Bairro, endereço ou referência"
- className="min-h-11"
- />
-
-
-
- Tags
-
-
-
-
-
- {failedMessage ? (
-
- {failedMessage}
-
- ) : null}
-
-
+
Mais detalhes
@@ -349,26 +469,23 @@ export const ActivityInlineCreate = ({
}: ActivityInlineCreateProps) => {
if (!draft) return null
- const form = (
-
- )
-
if (isNarrow) {
return (
-
(next ? undefined : onClose())} showSwipeHandle>
-
+ (next ? undefined : onClose())} swipeDirection="up">
+
Nova atividade
{formatBahiaDateTimeLabel(draft.startAt)}
- {form}
+
)
@@ -395,7 +512,15 @@ export const ActivityInlineCreate = ({
sideOffset={8}
className="max-h-(--radix-popper-available-height) w-96 overflow-y-auto p-4"
>
- {form}
+
)
diff --git a/src/components/campaign/activity/ActivityTagInput.tsx b/src/components/campaign/activity/ActivityTagInput.tsx
index 9ec7a373..9378357a 100644
--- a/src/components/campaign/activity/ActivityTagInput.tsx
+++ b/src/components/campaign/activity/ActivityTagInput.tsx
@@ -20,11 +20,17 @@ export const ActivityTagInput = ({
knownTags = [],
error,
onChange,
+ compact = false,
+ placeholder,
}: {
initialTags?: string[]
knownTags?: string[]
error?: string
onChange?: (tags: string[]) => void
+ /** Label-less list-row variant (C103 mobile sheet): borderless input, no description. */
+ compact?: boolean
+ /** Input placeholder override (defaults to the C105 copy). */
+ placeholder?: string
}) => {
const [tags, setTags] = useState(initialTags)
const [input, setInput] = useState('')
@@ -88,8 +94,14 @@ export const ActivityTagInput = ({
}
}}
onBlur={() => addTag(input)}
- placeholder={tags.length === 0 ? 'Ex.: comício, imprensa…' : 'Adicionar tag…'}
- className="min-h-9 flex-1"
+ placeholder={
+ placeholder ?? (tags.length === 0 ? 'Ex.: comício, imprensa…' : 'Adicionar tag…')
+ }
+ className={
+ compact
+ ? 'min-h-11 flex-1 rounded-none border-0 bg-transparent px-0 focus-visible:ring-2 focus-visible:ring-primary/30'
+ : 'min-h-9 flex-1'
+ }
list={datalistId}
maxLength={MAX_ACTIVITY_TAG_LENGTH}
aria-label="Adicionar tag"
@@ -104,9 +116,11 @@ export const ActivityTagInput = ({
-
- Classificação livre do compromisso. Digite e pressione Enter ou vírgula para adicionar.
-
+ {compact ? null : (
+
+ Classificação livre do compromisso. Digite e pressione Enter ou vírgula para adicionar.
+
+ )}
{error ?
{error} : null}
)
diff --git a/src/components/campaign/shared/ResponsibleMultiSelect.tsx b/src/components/campaign/shared/ResponsibleMultiSelect.tsx
index df28089a..927d53ae 100644
--- a/src/components/campaign/shared/ResponsibleMultiSelect.tsx
+++ b/src/components/campaign/shared/ResponsibleMultiSelect.tsx
@@ -20,6 +20,7 @@ import {
MAX_ACTIVITY_RESPONSIBLES,
type ActivityResponsibleCollection,
} from '@/lib/schemas/activity'
+import { cn } from '@/lib/utils'
export type ResponsibleOption = {
relationTo: ActivityResponsibleCollection
@@ -35,6 +36,12 @@ type ResponsibleMultiSelectProps = {
value?: ResponsibleOption[]
error?: string
description?: string
+ /** Extra classes for the field label (e.g. `sr-only` on label-less sheets). */
+ labelClassName?: string
+ /** Trigger text while empty (replaces the "Nenhum" fallback). */
+ emptyText?: string
+ /** Extra classes for the trigger button (e.g. borderless list rows). */
+ triggerClassName?: string
search: (query: string) => Promise
}
@@ -49,6 +56,9 @@ export const ResponsibleMultiSelect = ({
value = [],
error,
description,
+ labelClassName,
+ emptyText,
+ triggerClassName,
search,
}: ResponsibleMultiSelectProps) => {
// Initial-only (like RelationMultiSelect's initialSelectedIDs): on the create
@@ -124,12 +134,12 @@ export const ResponsibleMultiSelect = ({
const selectedLabel =
selected.length === 0
- ? 'Nenhum'
+ ? (emptyText ?? 'Nenhum')
: `${selected.length} ${selected.length === 1 ? 'responsável' : 'responsáveis'}`
return (
- {label}
+ {label}
void
error?: string
placeholder?: string
+ /** Extra classes for the input group (e.g. label-less list rows). */
className?: string
}
diff --git a/tests/e2e/campaignActivity.e2e.spec.ts b/tests/e2e/campaignActivity.e2e.spec.ts
index 1ec983c7..cdc88810 100644
--- a/tests/e2e/campaignActivity.e2e.spec.ts
+++ b/tests/e2e/campaignActivity.e2e.spec.ts
@@ -428,3 +428,62 @@ test.describe('Agenda — calendário operacional', () => {
await expect(page.getByRole('grid').first()).toBeVisible()
})
})
+
+test.describe('Atividades — agenda mobile (C103)', () => {
+ test.setTimeout(90_000)
+ test.use({ viewport: { width: 390, height: 844 } })
+
+ test('cria compromisso inline pelo sheet do topo, rodapé fixo e seletor em bottom sheet', async ({
+ campaign,
+ page,
+ }) => {
+ const { fixtures } = campaign
+ const coordinator = await fixtures.createCampaignUser('coordinator')
+ const municipality = await fixtures.claimMunicipality()
+ const title = fixtures.value('Café mobile na agenda')
+
+ await campaign.login(page, coordinator.email!, coordinator.password)
+ await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
+
+ // FullCalendar renders the grid lazily and re-lays out on mount: wait for
+ // the load to finish before interacting (same pattern as the desktop test).
+ await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
+ const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
+ await expect(slotLocator).toBeVisible()
+ // Click the time slot itself — C104's all-day lane sits above the timed
+ // grid, so the old day-cell nth() offset no longer lands on the slot.
+ // `force` bypasses the timed-row content overlay; the FullCalendar grid
+ // still receives the click and resolves the 14:00 date from the position.
+ await slotLocator.scrollIntoViewIfNeeded()
+ await slotLocator.click({ force: true })
+
+ // C103 — the sheet opens from the TOP, hugging the usable viewport edge.
+ const sheet = page.getByRole('dialog', { name: /Nova atividade/ })
+ await expect(sheet).toBeVisible()
+ const sheetBox = await sheet.boundingBox()
+ if (!sheetBox) throw new Error('O sheet não expôs bounding box.')
+ expect(sheetBox.y).toBeLessThanOrEqual(1)
+
+ // Label-less form: the placeholder gives context, the sr-only label keeps
+ // the field addressable and accessible.
+ await page.getByLabel('Título *').fill(title)
+ await expect(page.getByLabel('Município *')).toHaveValue(municipality.name)
+
+ // The footer is FIXED: "Salvar" is visible without scrolling on a small
+ // viewport — the acceptance center of C103.
+ await expect(page.getByRole('button', { name: 'Salvar' })).toBeVisible()
+
+ // Date/time opens as a nested bottom sheet (no popover) with "Pronto".
+ await page.getByLabel('Início *').click()
+ const picker = page.getByRole('dialog', { name: /Início —/ })
+ await expect(picker).toBeVisible()
+ await expect(picker.getByRole('combobox', { name: 'Hora' })).toBeVisible()
+ await picker.getByRole('button', { name: 'Pronto' }).click()
+ await expect(picker).toBeHidden()
+
+ await page.getByRole('button', { name: 'Salvar' }).click()
+ await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 })
+ await expect(page.getByText('14:00 – 14:30', { exact: true })).toBeVisible()
+ await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
+ })
+})
diff --git a/tests/unit/activityInlineCreate.unit.spec.tsx b/tests/unit/activityInlineCreate.unit.spec.tsx
index ae663232..7ace0745 100644
--- a/tests/unit/activityInlineCreate.unit.spec.tsx
+++ b/tests/unit/activityInlineCreate.unit.spec.tsx
@@ -278,100 +278,39 @@ describe('ActivityInlineCreate — overlay de criação inline', () => {
expect(container.firstChild).toBeNull()
})
- it('renderiza o mesmo conteúdo no bottom sheet mobile', () => {
+ it('renderiza o sheet do topo mobile com labels ocultos e rodapé fixo', () => {
renderOverlay({ agendaState: { municipality: 12 }, isNarrow: true })
+ // C103 — labels stay in the a11y tree (sr-only), placeholders give context
+ // and required fields keep the asterisk.
expect(screen.getByLabelText('Título *')).toBeTruthy()
- expect(screen.getByLabelText('Início *').textContent).toBe('07/08/2026 às 13:00')
+ expect((screen.getByLabelText('Título *') as HTMLInputElement).placeholder).toBe(
+ 'Adicionar título *',
+ )
+ expect(screen.getByLabelText('Início *').textContent).toMatch(/07\/08\/2026 às 13:00/)
+ expect(screen.getByLabelText('Início *').textContent).toContain('*')
+ expect((screen.getByLabelText('Município *') as HTMLInputElement).placeholder).toBe(
+ 'Município *',
+ )
+ expect((screen.getByLabelText('Local (opcional)') as HTMLInputElement).placeholder).toBe(
+ 'Local (opcional)',
+ )
+ expect(
+ screen.getByRole('button', { name: 'Responsáveis: Adicionar responsáveis' }),
+ ).toBeTruthy()
expect(screen.getByRole('button', { name: 'Salvar' })).toBeTruthy()
})
- it('alterna "Todo o dia" e esconde os seletores de horário (C104)', () => {
- renderOverlay({ agendaState: { municipality: 12 } })
-
- fireEvent.click(screen.getByLabelText('Todo o dia'))
-
- expect(screen.getByLabelText('Início *').textContent).toBe('07/08/2026')
- expect(screen.getByLabelText('Término').textContent).toBe('07/08/2026')
+ it('abre o seletor de data/hora como bottom sheet no mobile, com Pronto', () => {
+ renderOverlay({ agendaState: { municipality: 12 }, isNarrow: true })
fireEvent.click(screen.getByLabelText('Início *'))
const dialogs = screen.getAllByRole('dialog')
const picker = within(dialogs[dialogs.length - 1])
- expect(picker.queryByRole('combobox', { name: 'Hora' })).toBeNull()
- expect(picker.queryByRole('combobox', { name: 'Minuto' })).toBeNull()
+ expect(picker.getByRole('combobox', { name: 'Hora' })).toBeTruthy()
+ expect(picker.getByRole('button', { name: 'Pronto' })).toBeTruthy()
- fireEvent.click(screen.getByLabelText('Todo o dia'))
- expect(screen.getByLabelText('Início *').textContent).toBe('07/08/2026 às 13:00')
- })
-
- it('salva dia inteiro com instantes de meia-noite baiana (C104)', async () => {
- const onCreated = vi.fn()
- mocks.createInline.mockResolvedValue({ ok: true })
- renderOverlay({ agendaState: { municipality: 12 }, onCreated })
-
- fireEvent.change(screen.getByLabelText('Título *'), {
- target: { value: 'Café com apoiadores' },
- })
- fireEvent.click(screen.getByLabelText('Todo o dia'))
-
- fireEvent.click(screen.getByLabelText('Início *'))
- const dialogs = screen.getAllByRole('dialog')
- const startPicker = within(dialogs[dialogs.length - 1])
- fireEvent.click(startPicker.getByRole('button', { name: /15 de agosto de 2026/ }))
-
- fireEvent.click(screen.getByLabelText('Término'))
- const endPicker = within(
- screen.getAllByRole('dialog')[screen.getAllByRole('dialog').length - 1],
- )
- fireEvent.click(endPicker.getByRole('button', { name: /17 de agosto de 2026/ }))
-
- fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
-
- await waitFor(() => expect(mocks.createInline).toHaveBeenCalledTimes(1))
- expect(mocks.createInline).toHaveBeenCalledWith({
- title: 'Café com apoiadores',
- municipality: 12,
- allDay: true,
- startAt: '2026-08-15T03:00:00.000Z',
- endAt: '2026-08-17T03:00:00.000Z',
- })
- expect(onCreated).toHaveBeenCalledTimes(1)
- })
-
- it('bloqueia dia inteiro com término anterior ao início (C104)', async () => {
- mocks.createInline.mockResolvedValue({ ok: true })
- renderOverlay({ agendaState: { municipality: 12 } })
-
- fireEvent.change(screen.getByLabelText('Título *'), {
- target: { value: 'Café com apoiadores' },
- })
- fireEvent.click(screen.getByLabelText('Todo o dia'))
-
- fireEvent.click(screen.getByLabelText('Início *'))
- const startPicker = within(screen.getAllByRole('dialog').at(-1) as HTMLElement)
- fireEvent.click(startPicker.getByRole('button', { name: /15 de agosto de 2026/ }))
-
- fireEvent.click(screen.getByLabelText('Término'))
- const endPicker = within(screen.getAllByRole('dialog').at(-1) as HTMLElement)
- fireEvent.click(endPicker.getByRole('button', { name: /10 de agosto de 2026/ }))
-
- fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
-
- await waitFor(() =>
- expect(
- screen.getByText('A data de término deve ser igual ou posterior à de início.'),
- ).toBeTruthy(),
- )
- expect(mocks.createInline).not.toHaveBeenCalled()
- })
-
- it('passa "Todo o dia" e as datas para "Mais detalhes" (C104)', () => {
- renderOverlay({ agendaState: { municipality: 12 } })
-
- fireEvent.click(screen.getByLabelText('Todo o dia'))
- const link = screen.getByRole('link', { name: 'Mais detalhes' })
- expect(link.getAttribute('href')).toBe(
- '/campanha/atividades/nova?allDay=1&startAt=2026-08-07&endAt=2026-08-07&municipality=12&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12',
- )
+ fireEvent.click(picker.getByRole('button', { name: 'Pronto' }))
+ expect(screen.queryByRole('button', { name: 'Pronto' })).toBeNull()
})
})