Skip to content
Merged
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
2 changes: 2 additions & 0 deletions front-admin/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Próximos módulos a construir, nesta ordem (ver `../docs/produto.md`): edição

Design/protótipo das telas: [claude.ai/artifact/3B7ZWqjBQPDFZ5vhGgNs4N](https://claude.ai/artifact/3B7ZWqjBQPDFZ5vhGgNs4N#page-dcb81aee8d83).

Kit de UI do tema Sertão (componentes `Ph*`, tokens, acessibilidade): [`docs/UI-KIT.md`](docs/UI-KIT.md). Use-o antes de criar markup próprio.

Full layer rules live in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — read that first, it's the actual convention document, not a summary. `.claude/rules/` covers what's specific to this being a template (which module is the reference, why `HttpClient` is shaped the way it is, the two different reasons something is "shared").

`app/modules/login/` is the reference module — a complete, working example of every layer, with tests for each. Imitate its shape when creating a new module.
Expand Down
2 changes: 2 additions & 0 deletions front-admin/app/application/locales/pt-br/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import login from '../../../modules/login/locales/pt-br'
import ui from './ui'

// Every module's locale is registered here — see docs/ARCHITECTURE.md,
// "Registro obrigatório". Add one import + one key per new module.
export default {
login,
ui,
}
20 changes: 20 additions & 0 deletions front-admin/app/application/locales/pt-br/ui.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Textos do kit de UI compartilhado (app/application/shared/components/ui).
export default {
loading: 'Carregando…',
required: 'obrigatório',
close: 'Fechar',
dismiss: 'Dispensar aviso',
pagination: 'Paginação',
previousPage: 'Página anterior',
nextPage: 'Próxima página',
page: (n: number) => `Página ${n}`,
selectPlaceholder: 'Selecione…',
notifications: 'Avisos',
statusLabels: {
ok: 'Tudo certo',
warn: 'Atenção',
danger: 'Problema',
info: 'Informação',
neutral: 'Neutro',
},
}
21 changes: 21 additions & 0 deletions front-admin/app/application/shared/components/ui/Badge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { describe, expect, it } from 'vitest'
import Badge from './Badge.vue'

describe('PhBadge', () => {
it.each(['ok', 'warn', 'danger', 'info', 'neutral'] as const)('renderiza o estado %s com texto', async (status) => {
const wrapper = await mountSuspended(Badge, { props: { status }, slots: { default: 'Pago' } })
expect(wrapper.attributes('data-status')).toBe(status)
expect(wrapper.text()).toBe('Pago')
})

it('mostra o ponto por padrão e o esconde do leitor de tela', async () => {
const wrapper = await mountSuspended(Badge, { slots: { default: 'Pago' } })
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})

it('pode ocultar o ponto', async () => {
const wrapper = await mountSuspended(Badge, { props: { dot: false }, slots: { default: 'Pago' } })
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
})
})
33 changes: 33 additions & 0 deletions front-admin/app/application/shared/components/ui/Badge.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'

export type BadgeStatus = 'ok' | 'warn' | 'danger' | 'info' | 'neutral'

const props = withDefaults(defineProps<{ status?: BadgeStatus, dot?: boolean }>(), {
status: 'neutral',
dot: true,
})

// Classes completas (não montadas por string) para o Tailwind enxergá-las.
const tone = computed(
() =>
({
ok: 'bg-(--ph-ok-bg) text-(--ph-ok)',
warn: 'bg-(--ph-warn-bg) text-(--ph-warn)',
danger: 'bg-(--ph-danger-bg) text-(--ph-danger)',
info: 'bg-(--ph-info-bg) text-(--ph-info)',
neutral: 'bg-(--ph-surface-2) text-(--ph-ink-2)',
})[props.status],
)
</script>

<template>
<span
class="inline-flex h-6.5 items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 text-xs font-medium"
:class="tone"
:data-status="status"
>
<span v-if="dot" class="size-1.5 rounded-full bg-current" aria-hidden="true" />
<slot />
</span>
</template>
46 changes: 46 additions & 0 deletions front-admin/app/application/shared/components/ui/Button.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { describe, expect, it } from 'vitest'
import Button from './Button.vue'

describe('PhButton', () => {
it('renderiza como botão do tipo "button" com a variante primária', async () => {
const wrapper = await mountSuspended(Button, { slots: { default: 'Salvar' } })
const el = wrapper.find('button')
expect(el.text()).toBe('Salvar')
expect(el.attributes('type')).toBe('button')
expect(el.classes()).toContain('btn-primary')
})

it.each([
['secondary', 'btn-neutral'],
['ghost', 'btn-ghost'],
['danger', 'btn-error'],
] as const)('aplica a variante %s', async (variant, cls) => {
const wrapper = await mountSuspended(Button, { props: { variant } })
expect(wrapper.find('button').classes()).toContain(cls)
})

it('desabilitado: não dispara clique e fica com o atributo disabled', async () => {
const wrapper = await mountSuspended(Button, { props: { disabled: true } })
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
})

it('carregando: desabilita, marca aria-busy e avisa leitores de tela', async () => {
const wrapper = await mountSuspended(Button, { props: { loading: true } })
const el = wrapper.find('button')
expect(el.attributes('disabled')).toBeDefined()
expect(el.attributes('aria-busy')).toBe('true')
expect(el.text()).toContain('Carregando')
})

it('com "to" renderiza um link e não um botão', async () => {
const wrapper = await mountSuspended(Button, { props: { to: '/login' }, slots: { default: 'Entrar' } })
expect(wrapper.find('a').attributes('href')).toBe('/login')
expect(wrapper.find('button').exists()).toBe(false)
})

it('tem altura mínima de 44 px garantida pelo tema', async () => {
const wrapper = await mountSuspended(Button)
expect(wrapper.find('button').classes()).toContain('btn')
})
})
49 changes: 49 additions & 0 deletions front-admin/app/application/shared/components/ui/Button.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed, resolveComponent } from 'vue'
import locale from '@/application/locales/pt-br/ui'

type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'

const props = withDefaults(
defineProps<{
variant?: Variant
type?: 'button' | 'submit' | 'reset'
loading?: boolean
disabled?: boolean
block?: boolean
to?: string
}>(),
{ variant: 'primary', type: 'button', loading: false, disabled: false, block: false, to: undefined },
)

const classes = computed(() => [
'btn',
{
primary: 'btn-primary',
secondary: 'btn-neutral btn-soft border-base-300',
ghost: 'btn-ghost border-base-300',
danger: 'btn-error',
}[props.variant],
{ 'w-full': props.block },
])

const isLink = computed(() => !!props.to)
const tag = computed(() => (isLink.value ? resolveComponent('NuxtLink') : 'button'))
const blocked = computed(() => props.disabled || props.loading)
</script>

<template>
<component
:is="tag"
:to="isLink ? to : undefined"
:type="isLink ? undefined : type"
:class="classes"
:disabled="isLink ? undefined : blocked"
:aria-disabled="isLink && blocked ? 'true' : undefined"
:aria-busy="loading ? 'true' : undefined"
>
<span v-if="loading" class="loading loading-spinner loading-sm" aria-hidden="true" />
<span v-if="loading" class="sr-only">{{ locale.loading }}</span>
<slot />
</component>
</template>
17 changes: 17 additions & 0 deletions front-admin/app/application/shared/components/ui/Card.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script setup lang="ts">
defineProps<{ title?: string }>()
</script>

<template>
<section class="card border border-base-300 bg-base-100">
<div class="card-body gap-4 p-6">
<header v-if="title || $slots.actions" class="flex flex-wrap items-center justify-between gap-3">
<h2 v-if="title" class="text-lg font-semibold">{{ title }}</h2>
<div v-if="$slots.actions" class="flex items-center gap-2">
<slot name="actions" />
</div>
</header>
<slot />
</div>
</section>
</template>
23 changes: 23 additions & 0 deletions front-admin/app/application/shared/components/ui/Checkbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { describe, expect, it } from 'vitest'
import Checkbox from './Checkbox.vue'

describe('PhCheckbox', () => {
it('rótulo clicável ligado à caixa', async () => {
const wrapper = await mountSuspended(Checkbox, { props: { label: 'Mostrar no site' } })
expect(wrapper.find('label').attributes('for')).toBe(wrapper.find('input').attributes('id'))
})

it('emite true ao marcar', async () => {
const wrapper = await mountSuspended(Checkbox, { props: { label: 'Mostrar', modelValue: false } })
await wrapper.find('input').setValue(true)
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([true])
})

it('desabilitado, com dica descrevendo o campo', async () => {
const wrapper = await mountSuspended(Checkbox, { props: { label: 'Mostrar', hint: 'Pode mudar depois.', disabled: true } })
const input = wrapper.find('input')
expect(input.attributes('disabled')).toBeDefined()
expect(wrapper.find(`#${input.attributes('aria-describedby')}`).text()).toBe('Pode mudar depois.')
})
})
36 changes: 36 additions & 0 deletions front-admin/app/application/shared/components/ui/Checkbox.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed, useId } from 'vue'

const props = withDefaults(
defineProps<{
label: string
hint?: string
disabled?: boolean
id?: string
}>(),
{ hint: undefined, disabled: false, id: undefined },
)

const model = defineModel<boolean>({ default: false })

const autoId = useId()
const fieldId = computed(() => props.id ?? `ph-${autoId}`)
const hintId = computed(() => `${fieldId.value}-hint`)
</script>

<template>
<div class="flex min-h-11 items-start gap-3 py-2.5">
<input
:id="fieldId"
v-model="model"
type="checkbox"
class="checkbox checkbox-primary mt-0.5"
:disabled="disabled"
:aria-describedby="hint ? hintId : undefined"
>
<div class="flex flex-col">
<label :for="fieldId" class="cursor-pointer text-sm">{{ label }}</label>
<p v-if="hint" :id="hintId" class="text-xs text-(--ph-ink-3)">{{ hint }}</p>
</div>
</div>
</template>
16 changes: 16 additions & 0 deletions front-admin/app/application/shared/components/ui/Chip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { describe, expect, it } from 'vitest'
import Chip from './Chip.vue'

describe('PhChip', () => {
it('expõe o estado selecionado por aria-pressed', async () => {
const wrapper = await mountSuspended(Chip, { props: { modelValue: true }, slots: { default: 'Todos' } })
expect(wrapper.find('button').attributes('aria-pressed')).toBe('true')
})

it('alterna ao clicar', async () => {
const wrapper = await mountSuspended(Chip, { props: { modelValue: false }, slots: { default: 'Pagos' } })
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([true])
})
})
19 changes: 19 additions & 0 deletions front-admin/app/application/shared/components/ui/Chip.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script setup lang="ts">
const model = defineModel<boolean>({ default: false })
</script>

<template>
<button
type="button"
:aria-pressed="model"
class="inline-flex min-h-9 items-center rounded-full border px-3.5 text-sm font-medium"
:class="
model
? 'border-primary bg-primary text-primary-content'
: 'border-base-300 bg-base-100 text-(--ph-ink-2) hover:bg-base-200'
"
@click="model = !model"
>
<slot />
</button>
</template>
21 changes: 21 additions & 0 deletions front-admin/app/application/shared/components/ui/Drawer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { describe, expect, it } from 'vitest'
import Drawer from './Drawer.vue'

describe('PhDrawer', () => {
it('abre e fecha com v-model e fica ancorado à direita', async () => {
const wrapper = await mountSuspended(Drawer, { props: { title: 'Editar sessão', modelValue: false } })
const dialog = wrapper.find('dialog')
expect(dialog.classes()).toContain('modal-end')
await wrapper.setProps({ modelValue: true })
expect((dialog.element as HTMLDialogElement).open).toBe(true)
})

it('tem título como nome acessível e botão de fechar', async () => {
const wrapper = await mountSuspended(Drawer, { props: { title: 'Editar sessão', modelValue: true } })
const labelledBy = wrapper.find('dialog').attributes('aria-labelledby')
expect(wrapper.find(`#${labelledBy}`).text()).toBe('Editar sessão')
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false])
})
})
38 changes: 38 additions & 0 deletions front-admin/app/application/shared/components/ui/Drawer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { ref, useId } from 'vue'
import { useDialog } from '@/application/shared/composables/useDialog'
import locale from '@/application/locales/pt-br/ui'

defineProps<{ title: string }>()
const open = defineModel<boolean>({ default: false })

const dialog = ref<HTMLDialogElement | null>(null)
const titleId = `ph-${useId()}-title`
useDialog(dialog, open)
</script>

<template>
<dialog
ref="dialog"
class="modal modal-end"
:aria-labelledby="titleId"
@close="open = false"
@click.self="open = false"
>
<div class="modal-box flex h-full max-h-none w-full max-w-md flex-col gap-4 rounded-none bg-base-100">
<div class="flex items-start justify-between gap-4">
<h2 :id="titleId" class="text-lg font-semibold">{{ title }}</h2>
<button type="button" class="btn btn-ghost btn-square btn-sm" @click="open = false">
<span aria-hidden="true">✕</span>
<span class="sr-only">{{ locale.close }}</span>
</button>
</div>
<div class="grow overflow-y-auto text-sm">
<slot />
</div>
<div v-if="$slots.footer" class="flex flex-wrap justify-end gap-2">
<slot name="footer" />
</div>
</div>
</dialog>
</template>
19 changes: 19 additions & 0 deletions front-admin/app/application/shared/components/ui/EmptyState.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script setup lang="ts">
defineProps<{ title: string, description?: string }>()
</script>

<template>
<div class="flex flex-col items-center gap-3 px-4 py-10 text-center">
<div
v-if="$slots.icon"
class="flex size-14 items-center justify-center rounded-full bg-(--ph-surface-2) text-primary"
>
<slot name="icon" />
</div>
<h3 class="text-base font-semibold">{{ title }}</h3>
<p v-if="description" class="max-w-sm text-sm text-(--ph-ink-2)">{{ description }}</p>
<div v-if="$slots.default" class="mt-1">
<slot />
</div>
</div>
</template>
Loading
Loading