From 6336dfa24c714c8c624dbf67d620b28650366f1a Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Wed, 4 Nov 2026 21:22:47 -0300 Subject: [PATCH] =?UTF-8?q?Adiciona=20biblioteca=20de=20componentes=20base?= =?UTF-8?q?=20do=20tema=20Sert=C3=A3o=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tema DaisyUI mapeado aos tokens --ph-*, 18 componentes Ph* acessíveis (botão, campos, badge, card, abas, tabela, modal, painel, avisos...), catálogo em /_dev/componentes, testes de componente, de contraste e e2e de teclado/foco, e docs/UI-KIT.md. --- front-admin/CLAUDE.md | 2 + .../app/application/locales/pt-br/index.ts | 2 + .../app/application/locales/pt-br/ui.ts | 20 +++ .../shared/components/ui/Badge.test.ts | 21 +++ .../shared/components/ui/Badge.vue | 33 ++++ .../shared/components/ui/Button.test.ts | 46 +++++ .../shared/components/ui/Button.vue | 49 ++++++ .../application/shared/components/ui/Card.vue | 17 ++ .../shared/components/ui/Checkbox.test.ts | 23 +++ .../shared/components/ui/Checkbox.vue | 36 ++++ .../shared/components/ui/Chip.test.ts | 16 ++ .../application/shared/components/ui/Chip.vue | 19 ++ .../shared/components/ui/Drawer.test.ts | 21 +++ .../shared/components/ui/Drawer.vue | 38 ++++ .../shared/components/ui/EmptyState.vue | 19 ++ .../shared/components/ui/Input.test.ts | 52 ++++++ .../shared/components/ui/Input.vue | 54 ++++++ .../application/shared/components/ui/Kpi.vue | 13 ++ .../shared/components/ui/Modal.test.ts | 35 ++++ .../shared/components/ui/Modal.vue | 38 ++++ .../shared/components/ui/Pagination.test.ts | 26 +++ .../shared/components/ui/Pagination.vue | 53 ++++++ .../shared/components/ui/Select.test.ts | 30 ++++ .../shared/components/ui/Select.vue | 60 +++++++ .../shared/components/ui/Skeleton.vue | 19 ++ .../shared/components/ui/Stripe.vue | 7 + .../shared/components/ui/Table.vue | 13 ++ .../shared/components/ui/Tabs.test.ts | 57 ++++++ .../application/shared/components/ui/Tabs.vue | 79 +++++++++ .../shared/components/ui/Textarea.test.ts | 23 +++ .../shared/components/ui/Textarea.vue | 52 ++++++ .../shared/components/ui/ToastHost.test.ts | 59 +++++++ .../shared/components/ui/ToastHost.vue | 45 +++++ .../shared/components/ui/misc.test.ts | 74 ++++++++ .../shared/components/ui/tokens.test.ts | 50 ++++++ .../shared/composables/useDialog.ts | 23 +++ .../shared/composables/useToast.ts | 14 ++ front-admin/app/assets/css/main.css | 51 +++++- front-admin/app/layouts/default.vue | 1 + front-admin/app/pages/_dev/componentes.vue | 165 ++++++++++++++++++ front-admin/app/stores/toastStore.ts | 52 ++++++ front-admin/docs/UI-KIT.md | 35 ++++ front-admin/e2e/ui-catalog.spec.ts | 68 ++++++++ front-admin/nuxt.config.ts | 9 +- front-admin/playwright.config.ts | 3 + 45 files changed, 1620 insertions(+), 2 deletions(-) create mode 100644 front-admin/app/application/locales/pt-br/ui.ts create mode 100644 front-admin/app/application/shared/components/ui/Badge.test.ts create mode 100644 front-admin/app/application/shared/components/ui/Badge.vue create mode 100644 front-admin/app/application/shared/components/ui/Button.test.ts create mode 100644 front-admin/app/application/shared/components/ui/Button.vue create mode 100644 front-admin/app/application/shared/components/ui/Card.vue create mode 100644 front-admin/app/application/shared/components/ui/Checkbox.test.ts create mode 100644 front-admin/app/application/shared/components/ui/Checkbox.vue create mode 100644 front-admin/app/application/shared/components/ui/Chip.test.ts create mode 100644 front-admin/app/application/shared/components/ui/Chip.vue create mode 100644 front-admin/app/application/shared/components/ui/Drawer.test.ts create mode 100644 front-admin/app/application/shared/components/ui/Drawer.vue create mode 100644 front-admin/app/application/shared/components/ui/EmptyState.vue create mode 100644 front-admin/app/application/shared/components/ui/Input.test.ts create mode 100644 front-admin/app/application/shared/components/ui/Input.vue create mode 100644 front-admin/app/application/shared/components/ui/Kpi.vue create mode 100644 front-admin/app/application/shared/components/ui/Modal.test.ts create mode 100644 front-admin/app/application/shared/components/ui/Modal.vue create mode 100644 front-admin/app/application/shared/components/ui/Pagination.test.ts create mode 100644 front-admin/app/application/shared/components/ui/Pagination.vue create mode 100644 front-admin/app/application/shared/components/ui/Select.test.ts create mode 100644 front-admin/app/application/shared/components/ui/Select.vue create mode 100644 front-admin/app/application/shared/components/ui/Skeleton.vue create mode 100644 front-admin/app/application/shared/components/ui/Stripe.vue create mode 100644 front-admin/app/application/shared/components/ui/Table.vue create mode 100644 front-admin/app/application/shared/components/ui/Tabs.test.ts create mode 100644 front-admin/app/application/shared/components/ui/Tabs.vue create mode 100644 front-admin/app/application/shared/components/ui/Textarea.test.ts create mode 100644 front-admin/app/application/shared/components/ui/Textarea.vue create mode 100644 front-admin/app/application/shared/components/ui/ToastHost.test.ts create mode 100644 front-admin/app/application/shared/components/ui/ToastHost.vue create mode 100644 front-admin/app/application/shared/components/ui/misc.test.ts create mode 100644 front-admin/app/application/shared/components/ui/tokens.test.ts create mode 100644 front-admin/app/application/shared/composables/useDialog.ts create mode 100644 front-admin/app/application/shared/composables/useToast.ts create mode 100644 front-admin/app/pages/_dev/componentes.vue create mode 100644 front-admin/app/stores/toastStore.ts create mode 100644 front-admin/docs/UI-KIT.md create mode 100644 front-admin/e2e/ui-catalog.spec.ts diff --git a/front-admin/CLAUDE.md b/front-admin/CLAUDE.md index 8579377..d32ba98 100644 --- a/front-admin/CLAUDE.md +++ b/front-admin/CLAUDE.md @@ -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. diff --git a/front-admin/app/application/locales/pt-br/index.ts b/front-admin/app/application/locales/pt-br/index.ts index db21bc8..f09b385 100644 --- a/front-admin/app/application/locales/pt-br/index.ts +++ b/front-admin/app/application/locales/pt-br/index.ts @@ -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, } diff --git a/front-admin/app/application/locales/pt-br/ui.ts b/front-admin/app/application/locales/pt-br/ui.ts new file mode 100644 index 0000000..bf194ff --- /dev/null +++ b/front-admin/app/application/locales/pt-br/ui.ts @@ -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', + }, +} diff --git a/front-admin/app/application/shared/components/ui/Badge.test.ts b/front-admin/app/application/shared/components/ui/Badge.test.ts new file mode 100644 index 0000000..fbd20ed --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Badge.test.ts @@ -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) + }) +}) diff --git a/front-admin/app/application/shared/components/ui/Badge.vue b/front-admin/app/application/shared/components/ui/Badge.vue new file mode 100644 index 0000000..5c87bf0 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Badge.vue @@ -0,0 +1,33 @@ + + + diff --git a/front-admin/app/application/shared/components/ui/Button.test.ts b/front-admin/app/application/shared/components/ui/Button.test.ts new file mode 100644 index 0000000..bd6bf7e --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Button.test.ts @@ -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') + }) +}) diff --git a/front-admin/app/application/shared/components/ui/Button.vue b/front-admin/app/application/shared/components/ui/Button.vue new file mode 100644 index 0000000..3a1f1c4 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Button.vue @@ -0,0 +1,49 @@ + + + diff --git a/front-admin/app/application/shared/components/ui/Card.vue b/front-admin/app/application/shared/components/ui/Card.vue new file mode 100644 index 0000000..2752dd9 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Card.vue @@ -0,0 +1,17 @@ + + + diff --git a/front-admin/app/application/shared/components/ui/Checkbox.test.ts b/front-admin/app/application/shared/components/ui/Checkbox.test.ts new file mode 100644 index 0000000..d6a0575 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Checkbox.test.ts @@ -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.') + }) +}) diff --git a/front-admin/app/application/shared/components/ui/Checkbox.vue b/front-admin/app/application/shared/components/ui/Checkbox.vue new file mode 100644 index 0000000..679c564 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Checkbox.vue @@ -0,0 +1,36 @@ + + + diff --git a/front-admin/app/application/shared/components/ui/Chip.test.ts b/front-admin/app/application/shared/components/ui/Chip.test.ts new file mode 100644 index 0000000..cd1b700 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Chip.test.ts @@ -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]) + }) +}) diff --git a/front-admin/app/application/shared/components/ui/Chip.vue b/front-admin/app/application/shared/components/ui/Chip.vue new file mode 100644 index 0000000..f290a08 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Chip.vue @@ -0,0 +1,19 @@ + + + diff --git a/front-admin/app/application/shared/components/ui/Drawer.test.ts b/front-admin/app/application/shared/components/ui/Drawer.test.ts new file mode 100644 index 0000000..b46bc99 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Drawer.test.ts @@ -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]) + }) +}) diff --git a/front-admin/app/application/shared/components/ui/Drawer.vue b/front-admin/app/application/shared/components/ui/Drawer.vue new file mode 100644 index 0000000..ad4920a --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Drawer.vue @@ -0,0 +1,38 @@ + + + diff --git a/front-admin/app/application/shared/components/ui/EmptyState.vue b/front-admin/app/application/shared/components/ui/EmptyState.vue new file mode 100644 index 0000000..de9c377 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/EmptyState.vue @@ -0,0 +1,19 @@ + + + diff --git a/front-admin/app/application/shared/components/ui/Input.test.ts b/front-admin/app/application/shared/components/ui/Input.test.ts new file mode 100644 index 0000000..a177a92 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Input.test.ts @@ -0,0 +1,52 @@ +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { describe, expect, it } from 'vitest' +import Input from './Input.vue' + +describe('PhInput', () => { + it('associa o rótulo ao campo pelo id', async () => { + const wrapper = await mountSuspended(Input, { props: { label: 'Nome da edição' } }) + const input = wrapper.find('input') + expect(wrapper.find('label').attributes('for')).toBe(input.attributes('id')) + }) + + it('atualiza o v-model ao digitar', async () => { + const wrapper = await mountSuspended(Input, { + props: { label: 'Nome', modelValue: '', 'onUpdate:modelValue': (v: string | number) => wrapper.setProps({ modelValue: v }) }, + }) + await wrapper.find('input').setValue('PHPeste 2027') + expect(wrapper.props('modelValue')).toBe('PHPeste 2027') + }) + + it('mostra a dica ligada ao campo por aria-describedby', async () => { + const wrapper = await mountSuspended(Input, { props: { label: 'Nome', hint: 'Aparece no site.' } }) + const describedBy = wrapper.find('input').attributes('aria-describedby') + expect(describedBy).toBeTruthy() + expect(wrapper.find(`#${describedBy}`).text()).toBe('Aparece no site.') + }) + + it('erro: marca aria-invalid, anuncia como alerta e esconde a dica', async () => { + const wrapper = await mountSuspended(Input, { + props: { label: 'E-mail', hint: 'dica', error: 'E-mail inválido' }, + }) + const input = wrapper.find('input') + expect(input.attributes('aria-invalid')).toBe('true') + expect(input.classes()).toContain('input-error') + expect(wrapper.find('[role="alert"]').text()).toBe('E-mail inválido') + expect(wrapper.text()).not.toContain('dica') + }) + + it('desabilitado: o campo fica com o atributo disabled', async () => { + const wrapper = await mountSuspended(Input, { props: { label: 'Nome', disabled: true } }) + expect(wrapper.find('input').attributes('disabled')).toBeDefined() + }) + + it.each(['date', 'time', 'email'] as const)('aceita o tipo %s', async (type) => { + const wrapper = await mountSuspended(Input, { props: { label: 'Campo', type } }) + expect(wrapper.find('input').attributes('type')).toBe(type) + }) + + it('obrigatório: indica no rótulo, sem depender só de cor', async () => { + const wrapper = await mountSuspended(Input, { props: { label: 'Data', required: true } }) + expect(wrapper.find('label').text()).toContain('obrigatório') + }) +}) diff --git a/front-admin/app/application/shared/components/ui/Input.vue b/front-admin/app/application/shared/components/ui/Input.vue new file mode 100644 index 0000000..dea37b3 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Input.vue @@ -0,0 +1,54 @@ + + + diff --git a/front-admin/app/application/shared/components/ui/Kpi.vue b/front-admin/app/application/shared/components/ui/Kpi.vue new file mode 100644 index 0000000..fb858e9 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Kpi.vue @@ -0,0 +1,13 @@ + + + diff --git a/front-admin/app/application/shared/components/ui/Modal.test.ts b/front-admin/app/application/shared/components/ui/Modal.test.ts new file mode 100644 index 0000000..6a4adb2 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Modal.test.ts @@ -0,0 +1,35 @@ +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { describe, expect, it } from 'vitest' +import Modal from './Modal.vue' + +describe('PhModal', () => { + it('fica fechado por padrão e abre com v-model', async () => { + const wrapper = await mountSuspended(Modal, { props: { title: 'Reembolsar', modelValue: false }, slots: { default: 'texto' } }) + const dialog = wrapper.find('dialog').element as HTMLDialogElement + expect(dialog.open).toBe(false) + await wrapper.setProps({ modelValue: true }) + expect(dialog.open).toBe(true) + }) + + it('nomeia o diálogo pelo título', async () => { + const wrapper = await mountSuspended(Modal, { props: { title: 'Reembolsar', modelValue: true } }) + const labelledBy = wrapper.find('dialog').attributes('aria-labelledby') + expect(wrapper.find(`#${labelledBy}`).text()).toBe('Reembolsar') + }) + + it('o botão de fechar avisa quem usa o componente', async () => { + const wrapper = await mountSuspended(Modal, { props: { title: 'Reembolsar', modelValue: true } }) + await wrapper.find('button').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false]) + }) + + it('fecha ao clicar fora (no fundo) e mostra o rodapé quando existe', async () => { + const wrapper = await mountSuspended(Modal, { + props: { title: 'Reembolsar', modelValue: true }, + slots: { footer: '' }, + }) + expect(wrapper.text()).toContain('Confirmar') + await wrapper.find('dialog').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false]) + }) +}) diff --git a/front-admin/app/application/shared/components/ui/Modal.vue b/front-admin/app/application/shared/components/ui/Modal.vue new file mode 100644 index 0000000..82c998c --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Modal.vue @@ -0,0 +1,38 @@ + + + diff --git a/front-admin/app/application/shared/components/ui/Pagination.test.ts b/front-admin/app/application/shared/components/ui/Pagination.test.ts new file mode 100644 index 0000000..2c5c672 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Pagination.test.ts @@ -0,0 +1,26 @@ +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { describe, expect, it } from 'vitest' +import Pagination from './Pagination.vue' + +describe('PhPagination', () => { + it('marca a página atual com aria-current', async () => { + const wrapper = await mountSuspended(Pagination, { props: { page: 2, pageCount: 9 } }) + expect(wrapper.find('[aria-current="page"]').text()).toBe('2') + expect(wrapper.find('nav').attributes('aria-label')).toBe('Paginação') + }) + + it('emite a página escolhida e respeita os limites', async () => { + const wrapper = await mountSuspended(Pagination, { props: { page: 1, pageCount: 3 } }) + const buttons = wrapper.findAll('button') + await buttons[buttons.length - 1]!.trigger('click') + expect(wrapper.emitted('update:page')?.at(-1)).toEqual([2]) + }) + + it('desabilita "anterior" na primeira página e "próxima" na última', async () => { + const first = await mountSuspended(Pagination, { props: { page: 1, pageCount: 3 } }) + expect(first.findAll('button')[0]!.attributes('disabled')).toBeDefined() + const last = await mountSuspended(Pagination, { props: { page: 3, pageCount: 3 } }) + const buttons = last.findAll('button') + expect(buttons[buttons.length - 1]!.attributes('disabled')).toBeDefined() + }) +}) diff --git a/front-admin/app/application/shared/components/ui/Pagination.vue b/front-admin/app/application/shared/components/ui/Pagination.vue new file mode 100644 index 0000000..9f6967f --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Pagination.vue @@ -0,0 +1,53 @@ + + + diff --git a/front-admin/app/application/shared/components/ui/Select.test.ts b/front-admin/app/application/shared/components/ui/Select.test.ts new file mode 100644 index 0000000..c6d26f2 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Select.test.ts @@ -0,0 +1,30 @@ +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { describe, expect, it } from 'vitest' +import Select from './Select.vue' + +const options = [ + { value: 'cfp', label: 'CFP aberto' }, + { value: 'vendas', label: 'Vendas abertas' }, +] + +describe('PhSelect', () => { + it('lista as opções e o texto de seleção', async () => { + const wrapper = await mountSuspended(Select, { props: { label: 'Etapa', options, placeholder: 'Selecione…' } }) + const texts = wrapper.findAll('option').map((o) => o.text()) + expect(texts).toEqual(['Selecione…', 'CFP aberto', 'Vendas abertas']) + expect(wrapper.find('label').attributes('for')).toBe(wrapper.find('select').attributes('id')) + }) + + it('emite a opção escolhida', async () => { + const wrapper = await mountSuspended(Select, { props: { label: 'Etapa', options } }) + await wrapper.find('select').setValue('vendas') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['vendas']) + }) + + it('erro e desabilitado', async () => { + const wrapper = await mountSuspended(Select, { props: { label: 'Etapa', options, error: 'Escolha uma etapa', disabled: true } }) + expect(wrapper.find('select').attributes('aria-invalid')).toBe('true') + expect(wrapper.find('select').attributes('disabled')).toBeDefined() + expect(wrapper.find('[role="alert"]').text()).toBe('Escolha uma etapa') + }) +}) diff --git a/front-admin/app/application/shared/components/ui/Select.vue b/front-admin/app/application/shared/components/ui/Select.vue new file mode 100644 index 0000000..76333aa --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Select.vue @@ -0,0 +1,60 @@ + + + diff --git a/front-admin/app/application/shared/components/ui/Skeleton.vue b/front-admin/app/application/shared/components/ui/Skeleton.vue new file mode 100644 index 0000000..4755061 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Skeleton.vue @@ -0,0 +1,19 @@ + + + diff --git a/front-admin/app/application/shared/components/ui/Stripe.vue b/front-admin/app/application/shared/components/ui/Stripe.vue new file mode 100644 index 0000000..142abb9 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Stripe.vue @@ -0,0 +1,7 @@ + diff --git a/front-admin/app/application/shared/components/ui/Table.vue b/front-admin/app/application/shared/components/ui/Table.vue new file mode 100644 index 0000000..0402c64 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Table.vue @@ -0,0 +1,13 @@ + + + diff --git a/front-admin/app/application/shared/components/ui/Tabs.test.ts b/front-admin/app/application/shared/components/ui/Tabs.test.ts new file mode 100644 index 0000000..30a3c5a --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Tabs.test.ts @@ -0,0 +1,57 @@ +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { describe, expect, it } from 'vitest' +import Tabs from './Tabs.vue' + +const items = [ + { id: 'geral', label: 'Geral' }, + { id: 'trilhas', label: 'Trilhas' }, + { id: 'criterios', label: 'Critérios' }, +] + +async function mountTabs(active = 'geral') { + const wrapper = await mountSuspended(Tabs, { + props: { items, label: 'Seções', modelValue: active, 'onUpdate:modelValue': (v: string) => wrapper.setProps({ modelValue: v }) }, + slots: { default: '

conteúdo

' }, + }) + return wrapper +} + +describe('PhTabs', () => { + it('usa os papéis tablist, tab e tabpanel e liga painel e aba', async () => { + const wrapper = await mountTabs() + expect(wrapper.find('[role="tablist"]').attributes('aria-label')).toBe('Seções') + const tabs = wrapper.findAll('[role="tab"]') + expect(tabs).toHaveLength(3) + expect(tabs[0]!.attributes('aria-selected')).toBe('true') + const panel = wrapper.find('[role="tabpanel"]') + expect(panel.attributes('aria-labelledby')).toBe(tabs[0]!.attributes('id')) + expect(tabs[0]!.attributes('aria-controls')).toBe(panel.attributes('id')) + }) + + it('só a aba ativa entra na ordem de Tab (roving tabindex)', async () => { + const wrapper = await mountTabs() + const tabindexes = wrapper.findAll('[role="tab"]').map((t) => t.attributes('tabindex')) + expect(tabindexes).toEqual(['0', '-1', '-1']) + }) + + it('setas, Home e End trocam de aba', async () => { + const wrapper = await mountTabs() + const list = wrapper.find('[role="tablist"]') + await list.trigger('keydown', { key: 'ArrowRight' }) + expect(wrapper.props('modelValue')).toBe('trilhas') + await list.trigger('keydown', { key: 'End' }) + expect(wrapper.props('modelValue')).toBe('criterios') + await list.trigger('keydown', { key: 'ArrowRight' }) + expect(wrapper.props('modelValue')).toBe('geral') + await list.trigger('keydown', { key: 'ArrowLeft' }) + expect(wrapper.props('modelValue')).toBe('criterios') + await list.trigger('keydown', { key: 'Home' }) + expect(wrapper.props('modelValue')).toBe('geral') + }) + + it('clicar numa aba a ativa', async () => { + const wrapper = await mountTabs() + await wrapper.findAll('[role="tab"]')[1]!.trigger('click') + expect(wrapper.props('modelValue')).toBe('trilhas') + }) +}) diff --git a/front-admin/app/application/shared/components/ui/Tabs.vue b/front-admin/app/application/shared/components/ui/Tabs.vue new file mode 100644 index 0000000..b7f1635 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Tabs.vue @@ -0,0 +1,79 @@ + + + diff --git a/front-admin/app/application/shared/components/ui/Textarea.test.ts b/front-admin/app/application/shared/components/ui/Textarea.test.ts new file mode 100644 index 0000000..b7da064 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Textarea.test.ts @@ -0,0 +1,23 @@ +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { describe, expect, it } from 'vitest' +import Textarea from './Textarea.vue' + +describe('PhTextarea', () => { + it('associa rótulo e campo e mostra a dica', async () => { + const wrapper = await mountSuspended(Textarea, { props: { label: 'Mini bio', hint: 'Até 400 caracteres.' } }) + const area = wrapper.find('textarea') + expect(wrapper.find('label').attributes('for')).toBe(area.attributes('id')) + expect(wrapper.text()).toContain('Até 400 caracteres.') + }) + + it('erro: aria-invalid e mensagem como alerta', async () => { + const wrapper = await mountSuspended(Textarea, { props: { label: 'Bio', error: 'Muito longo' } }) + expect(wrapper.find('textarea').attributes('aria-invalid')).toBe('true') + expect(wrapper.find('[role="alert"]').text()).toBe('Muito longo') + }) + + it('desabilitado', async () => { + const wrapper = await mountSuspended(Textarea, { props: { label: 'Bio', disabled: true } }) + expect(wrapper.find('textarea').attributes('disabled')).toBeDefined() + }) +}) diff --git a/front-admin/app/application/shared/components/ui/Textarea.vue b/front-admin/app/application/shared/components/ui/Textarea.vue new file mode 100644 index 0000000..063d831 --- /dev/null +++ b/front-admin/app/application/shared/components/ui/Textarea.vue @@ -0,0 +1,52 @@ + + +