From 8e1c56cd39385cf485c1765a4050a18c9177b658 Mon Sep 17 00:00:00 2001 From: Andressa Rocha de Sias Date: Thu, 16 Jul 2026 14:19:59 -0300 Subject: [PATCH 1/2] feat: notificacoes (#1419070) --- .storybook/docs/storyDocs.tsx | 4 +- src/components/Notification/Notification.scss | 346 +++++++ .../Notification/Notification.stories.tsx | 889 ++++++++++++++++++ src/components/Notification/Notification.tsx | 333 +++++++ src/components/index.ts | 1 + 5 files changed, 1572 insertions(+), 1 deletion(-) create mode 100644 src/components/Notification/Notification.scss create mode 100644 src/components/Notification/Notification.stories.tsx create mode 100644 src/components/Notification/Notification.tsx diff --git a/.storybook/docs/storyDocs.tsx b/.storybook/docs/storyDocs.tsx index 75f9e40..cab6491 100644 --- a/.storybook/docs/storyDocs.tsx +++ b/.storybook/docs/storyDocs.tsx @@ -218,6 +218,7 @@ type StorySandboxExampleProps = { code: string notes?: string[] children: ReactNode + allowOverflow?: boolean } type StoryPreviewCardProps = { @@ -312,11 +313,12 @@ export function StorySandboxExample({ code, notes, children, + allowOverflow = false, }: StorySandboxExampleProps) { const [activeTab, setActiveTab] = useState<'preview' | 'code'>('preview') return ( -
+
{title} diff --git a/src/components/Notification/Notification.scss b/src/components/Notification/Notification.scss new file mode 100644 index 0000000..aba2ec7 --- /dev/null +++ b/src/components/Notification/Notification.scss @@ -0,0 +1,346 @@ +.govrs-notification { + position: relative; + display: inline-block; +} + +/* Painel ancorado no botão */ +.govrs-notification__modal { + position: absolute; + top: calc(100% + var(--govrs-space-2, 0.5rem)); + z-index: var(--govrs-z-popover, 1200); + width: 400px; + border: none; + border-radius: 0; + background-color: var(--govrs-color-background, #ffffff); + box-shadow: 0 3px 6px 0 rgba(0, 0, 0, 0.25); + padding: 0; + display: flex; + flex-direction: column; +} + +.govrs-notification__modal--left { + left: 0; + right: auto; +} + +.govrs-notification__modal--right { + left: auto; + right: 0; +} + +/* Em previews da documentação, abre para cima para evitar recorte no card do exemplo. */ +.govrs-notification--docs-inline .govrs-notification__modal { + position: static; + width: min(32rem, 100%); + margin-top: var(--govrs-space-3, 0.75rem); + z-index: 9999; +} + +/* Container interno */ +.govrs-notification__container { + display: flex; + flex-direction: column; + overflow: visible; + position: relative; +} + +/* Botão fechar */ +.govrs-notification__close-button { + position: absolute; + top: 0.875rem; + right: 0.875rem; + background: none; + border: none; + font-size: 1.125rem; + color: var(--govrs-color-brand-primary, #1a7235); + cursor: pointer; + padding: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0; + transition: all var(--govrs-duration-fast, 0.2s) var(--govrs-easing-standard, ease); + z-index: 2; + + &:hover { + background-color: transparent; + color: var(--govrs-color-brand-primary-dark, #155d2b); + } + + &:focus { + outline: none; + } + + &:focus-visible { + outline: 2px solid var(--govrs-color-focus, #ffcd07); + outline-offset: 2px; + } +} + +/* Área do usuário */ +.govrs-notification__user-area { + padding: 1rem 1rem 0.875rem; + border-bottom: 1px solid var(--govrs-color-border-divider, #e5e5e5); + flex-shrink: 0; +} + +.govrs-notification__user-area--with-close { + padding-right: 2.75rem; +} + +.govrs-notification__user-info { + display: flex; + flex-direction: column; + gap: var(--govrs-space-1, 0.25rem); +} + +.govrs-notification__user-name { + margin: 0; + font-size: var(--govrs-font-size-body-md, 1rem); + color: var(--govrs-color-text-default, #222222); + line-height: 1.3; +} + +.govrs-notification__user-email { + margin: 0; + font-size: var(--govrs-font-size-body-sm, 0.875rem); + color: var(--govrs-color-brand-primary, #1a7235); + line-height: 1.4; +} + +/* Tabs */ +.govrs-notification__tabs { + border-bottom: 1px solid var(--govrs-color-border-divider, #e5e5e5); + flex-shrink: 0; + overflow-x: auto; + background: var(--govrs-color-background-secondary, #f5f5f5); +} + +.govrs-notification__tabs--with-close { + padding-top: 2.125rem; +} + +.govrs-notification__tab-list { + display: flex; + list-style: none; + margin: 0; + padding: 0 0.25rem; + gap: 0; +} + +.govrs-notification__tab { + flex: 1 1 0; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.875rem 0.5rem 0.75rem; + border: none; + background: none; + cursor: pointer; + font-size: var(--govrs-font-size-body-lg, 1rem); + font-weight: var(--govrs-font-weight-regular, 400); + color: var(--govrs-color-text-default, #222222); + border-bottom: 4px solid transparent; + transition: all var(--govrs-duration-fast, 0.2s) var(--govrs-easing-standard, ease); + position: relative; + white-space: nowrap; + flex-shrink: 1; + + &:hover { + color: var(--govrs-color-text-default, #1a1a1a); + } + + &:focus { + outline: none; + } + + &:focus-visible { + outline: 2px solid var(--govrs-color-focus, #ffcd07); + outline-offset: -5px; + } + + &--active { + color: var(--govrs-color-brand-primary, #1a7235); + border-bottom-color: var(--govrs-color-brand-primary, #1a7235); + font-weight: var(--govrs-font-weight-medium, 500); + } + + &--icon-only { + padding: 0.875rem 0.5rem 0.75rem; + } +} + +.govrs-notification__tab-icon { + margin-right: 0; +} + +/* Conteúdo principal */ +.govrs-notification__content { + overflow: visible; + padding: 0; +} + +.govrs-notification__content--with-close { + padding-top: 2.5rem; +} + +/* Lista de notificações */ +.govrs-notification__items { + display: flex; + flex-direction: column; + gap: 0; + list-style: none; + margin: 0; + padding: 0; +} + +/* Item de notificação */ +.govrs-notification__item { + padding: 1rem; + border: 0; + border-bottom: 1px solid var(--govrs-color-border-divider, #d9d9d9); + border-radius: 0; + background-color: var(--govrs-color-background, #ffffff); + cursor: pointer; + transition: all var(--govrs-duration-fast, 0.2s) var(--govrs-easing-standard, ease); + + &:hover { + background-color: var(--govrs-color-background-secondary, #f3f3f3); + } + + &:active { + background-color: #adadad; + } + + &:focus { + outline: none; + } + + &:focus-visible { + outline: 2px solid var(--govrs-color-focus, #ffcd07); + outline-offset: 2px; + } + + &:last-child { + border-bottom: 0; + } + + &--disabled { + opacity: 0.45; + cursor: not-allowed; + pointer-events: none; + } +} + +/* Header do item */ +.govrs-notification__item-header { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.1875rem; + margin-bottom: 0.625rem; +} + +/* Título do item */ +.govrs-notification__item-title { + margin: 0; + font-size: var(--govrs-font-size-body-md, 1rem); + font-weight: var(--govrs-font-weight-regular, 400); + color: var(--govrs-color-text-default, #222222); + line-height: 1.35; + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.govrs-notification__item-new-indicator { + width: 0.625rem; + height: 0.625rem; + border-radius: 50%; + background: #ffd100; + flex-shrink: 0; +} + +/* Tempo/Data */ +.govrs-notification__item-time { + font-size: var(--govrs-font-size-12, 0.75rem); + color: #333; + white-space: nowrap; + flex-shrink: 0; +} + +/* Descrição do item */ +.govrs-notification__item-description { + margin: 0; + font-size: var(--govrs-font-size-body-sm, 0.875rem); + color: var(--govrs-color-text-default, #222222); + line-height: 1.4; +} + +/* Estado vazio */ +.govrs-notification__empty-state { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + color: var(--govrs-color-text-secondary, #666666); + font-size: var(--govrs-font-size-body-md, 1rem); + text-align: center; + + p { + margin: 0; + } +} + +/* Responsividade */ +@media (max-width: 768px) { + .govrs-notification__modal { + position: fixed; + top: auto; + right: var(--govrs-space-4, 1rem); + left: var(--govrs-space-4, 1rem); + bottom: var(--govrs-space-4, 1rem); + width: auto; + } + + .govrs-notification__modal--left, + .govrs-notification__modal--right { + left: var(--govrs-space-4, 1rem); + right: var(--govrs-space-4, 1rem); + } + + .govrs-notification--docs-inline .govrs-notification__modal { + position: static; + width: min(32rem, 100%); + max-height: none; + margin-top: var(--govrs-space-3, 0.75rem); + left: auto; + right: auto; + bottom: auto; + top: auto; + } + + .govrs-notification__tab { + font-size: 1rem; + padding: 0.75rem 0.375rem 0.625rem; + } + + .govrs-notification__item { + padding: 0.875rem; + } + + .govrs-notification__item-title { + font-size: 1.125rem; + } + + .govrs-notification__item-time, + .govrs-notification__item-description, + .govrs-notification__user-email { + font-size: 0.875rem; + } + + .govrs-notification__user-name { + font-size: 1.125rem; + } +} diff --git a/src/components/Notification/Notification.stories.tsx b/src/components/Notification/Notification.stories.tsx new file mode 100644 index 0000000..29db6e4 --- /dev/null +++ b/src/components/Notification/Notification.stories.tsx @@ -0,0 +1,889 @@ +import { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { + faBell, + faStar, + faRefresh, + faEnvelope, + faTriangleExclamation, +} from '@fortawesome/free-solid-svg-icons' + +import { + DocsHero, + DocsStoryLayout, + SandboxExample, + SectionCard, + StoryPreviewCard, + storyDocsStyles, +} from '../../../.storybook/docs/storyDocs' +import { Notification } from './Notification' +import './Notification.scss' +import '../../foundations/styles/index.scss' + +const docsHeroStats = [ + { + title: 'Quando usar', + text: 'Use para informar eventos e atualizações do sistema sem tirar o usuário do fluxo principal.', + }, + { + title: 'Melhor exploração', + text: "Use a página 'Interativo' para testar combinações de botão, usuário, abas e estado vazio.", + }, + { + title: 'Comportamento', + text: 'Abre como painel ancorado ao botão, fecha com clique fora, tecla Esc e botão de fechar.', + }, +] + +const tabsSingleCode = `const tabs = [ + { + id: 'all', + label: 'Todas', + items: [ + { + id: 'n1', + title: 'Seu documento foi aprovado', + description: 'Seu processo avançou para a próxima etapa.', + time: 'Há 5 min', + }, + ], + }, +] + +` + +const tabsMultipleCode = `const tabs = [ + { + id: 'important', + label: 'Importantes', + items: [{ id: 'n1', title: 'Prazo vencendo', description: 'Faltam 3 dias', time: 'Agora' }], + }, + { + id: 'updates', + label: 'Atualizações', + items: [{ id: 'n2', title: 'Sistema atualizado', description: 'Nova funcionalidade liberada', time: 'Há 1h' }], + }, + { + id: 'messages', + label: 'Mensagens', + items: [{ id: 'n3', title: 'Nova mensagem', description: 'Você recebeu uma nova mensagem', time: 'Há 2h' }], + }, +] + +` +const tabsWithIconAndTextCode = `import { faBell, faStar, faEnvelope } from '@fortawesome/free-solid-svg-icons' + +const tabs = [ + { + id: 'all', + label: 'Todas', + icon: faBell, + items: [...], + }, + { + id: 'important', + label: 'Importantes', + icon: faStar, + items: [...], + }, + { + id: 'messages', + label: 'Mensagens', + icon: faEnvelope, + items: [...], + }, +] + +` + +const tabsIconOnlyCode = `import { faBell, faTriangleExclamation, faRefresh } from '@fortawesome/free-solid-svg-icons' + +const tabs = [ + { + id: 'all', + // sem label = somente \u00edcone na aba + icon: faBell, + items: [...], + }, + { + id: 'important', + icon: faTriangleExclamation, + items: [...], + }, + { + id: 'updates', + icon: faRefresh, + items: [...], + }, +] + +` +const basicExampleCode = `` + +const userAreaExampleCode = `` + +const tabsExampleCode = `` + +const iconOnlyExampleCode = `` + +const emptyStateExampleCode = `` + +const noCloseButtonExampleCode = `` + +const triggerStylesExampleCode = `
+ + + + + +
` + +const interactiveSnippetCode = `const tabs = [ + { + id: 'all', + label: 'Todas', + items: [ + { id: 'n1', title: 'Seu documento foi aprovado', description: 'Atualização do processo', time: 'Há 00 min', isNew: true }, + ], + }, +] + +` + +const tabsSingle: Notification.Tab[] = [ + { + id: 'all', + label: 'Todas', + items: [ + { + id: 'notif-1', + title: 'Seu documento foi aprovado', + description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum mattis nisl eget mauris dignissim.', + time: 'Há 00 min', + isNew: true, + }, + { + id: 'notif-2', + title: 'Nova solicitação disponível', + description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum mattis nisl eget mauris dignissim.', + time: 'Há 1 hora', + }, + { + id: 'notif-3', + title: 'Atualização do sistema', + description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum mattis nisl eget mauris dignissim.', + time: 'Há 2 horas', + }, + ], + }, +] + +const tabsMultiple: Notification.Tab[] = [ + { + id: 'important', + label: 'Importantes', + items: [ + { + id: 'notif-1', + title: 'Seu documento foi aprovado', + description: 'Ação importante: seu documento foi revisado e aprovado. Clique para mais detalhes.', + time: 'Há 00 min', + }, + { + id: 'notif-2', + title: 'Aviso: Prazo vencendo', + description: 'Seu prazo para enviar documentação vence em 3 dias. Não perca!', + time: 'Há 1 hora', + }, + ], + }, + { + id: 'updates', + label: 'Atualizações', + items: [ + { + id: 'notif-3', + title: 'Atualização do sistema', + description: 'O sistema foi atualizado com novas funcionalidades.', + time: 'Há 2 horas', + }, + { + id: 'notif-4', + title: 'Nova versão disponível', + description: 'Uma nova versão da plataforma está disponível.', + time: 'Há 5 horas', + }, + ], + }, + { + id: 'messages', + label: 'Mensagens', + items: [ + { + id: 'notif-5', + title: 'Você tem uma nova mensagem', + description: 'João Silva enviou uma mensagem para você. Verifique seu inbox.', + time: 'Há 30 min', + }, + ], + }, +] + +const tabsWithIconAndText: Notification.Tab[] = [ + { id: 'all', label: 'Todas', icon: faBell, items: tabsSingle[0].items }, + { id: 'important', label: 'Importantes', icon: faStar, items: tabsMultiple[0].items }, + { id: 'messages', label: 'Mensagens', icon: faEnvelope, items: tabsMultiple[2].items }, +] + +const tabsIconOnly: Notification.Tab[] = [ + { id: 'all', icon: faBell, items: tabsSingle[0].items }, + { id: 'important', icon: faTriangleExclamation, items: tabsMultiple[0].items }, + { id: 'updates', icon: faRefresh, items: tabsMultiple[1].items }, +] + +// Conjuntos de dados para o Interativo +const presetDefault: Notification.Tab[] = [ + { + id: 'all', + label: 'Todas', + items: [ + { id: 'p1', title: 'Documento aprovado', description: 'Seu processo avançou para a próxima etapa.', time: 'Há 5 min', isNew: true }, + { id: 'p2', title: 'Nova solicitação disponível', description: 'Uma solicitação foi atribuída a você.', time: 'Há 1 hora', isNew: true }, + { id: 'p3', title: 'Atualização do sistema', description: 'O sistema foi atualizado com melhorias de desempenho.', time: 'Há 3 horas' }, + ], + }, +] + +const presetAllNew: Notification.Tab[] = [ + { + id: 'all', + label: 'Todas', + items: [ + { id: 'n1', title: 'Prazo vencendo amanhã', description: 'Você tem um prazo crítico para amanhã às 18h.', time: 'Agora', isNew: true }, + { id: 'n2', title: 'Nova mensagem recebida', description: 'Maria Souza enviou uma mensagem para você.', time: 'Há 2 min', isNew: true }, + { id: 'n3', title: 'Aprovação pendente', description: 'Um documento aguarda sua aprovação.', time: 'Há 10 min', isNew: true }, + { id: 'n4', title: 'Relatório gerado', description: 'O relatório mensal está pronto para download.', time: 'Há 30 min', isNew: true }, + ], + }, +] + +const presetWithDisabled: Notification.Tab[] = [ + { + id: 'all', + label: 'Todas', + items: [ + { id: 'd1', title: 'Solicitação em análise', description: 'Aguarde, sua solicitação está sendo analisada.', time: 'Há 1 hora', isNew: true }, + { id: 'd2', title: 'Acesso restrito', description: 'Você não tem permissão para esta ação.', time: 'Há 2 horas', disabled: true }, + { id: 'd3', title: 'Notificação expirada', description: 'Este aviso não está mais disponível.', time: 'Há 5 horas', disabled: true }, + { id: 'd4', title: 'Cadastro atualizado', description: 'Seus dados foram atualizados com sucesso.', time: 'Há 1 dia' }, + ], + }, +] + +const presetAllRead: Notification.Tab[] = [ + { + id: 'all', + label: 'Todas', + items: [ + { id: 'r1', title: 'Sistema restaurado', description: 'A manutenção foi concluída e o sistema está normal.', time: 'Há 2 dias' }, + { id: 'r2', title: 'Relatório de agosto', description: 'O relatório de agosto está disponível no portal.', time: 'Há 3 dias' }, + { id: 'r3', title: 'Senha alterada', description: 'Sua senha foi alterada com sucesso.', time: 'Há 5 dias' }, + ], + }, +] + +const presetSingle: Notification.Tab[] = [ + { + id: 'all', + label: 'Todas', + items: [ + { id: 's1', title: 'Único aviso importante', description: 'Verifique as novas diretrizes publicadas nesta semana.', time: 'Agora', isNew: true }, + ], + }, +] + +const dataPresets: Record = { + default: presetDefault, + allNew: presetAllNew, + withDisabled: presetWithDisabled, + allRead: presetAllRead, + single: presetSingle, +} + +const meta = { + title: 'Feedback/Notificações', + component: Notification, + parameters: { + layout: 'padded', + controls: { + expanded: true, + sort: 'requiredFirst', + }, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +type InteractivePreviewProps = { + buttonLabel?: string + useBellIcon?: boolean + buttonVariant?: 'primary' | 'secondary' | 'tertiary' + buttonSize?: 'small' | 'medium' | 'large' + tabMode?: 'text' | 'iconAndText' | 'iconOnly' | 'single' + showUserArea?: boolean + iconOnly?: boolean + emptyState?: boolean + dataPreset?: 'default' | 'allNew' | 'withDisabled' | 'allRead' | 'single' +} + +function BasicPreview() { + return ( +
+ +
+ ) +} + +function UserAreaPreview() { + return ( +
+ +
+ ) +} + +function TabsPreview() { + return ( +
+ +
+ ) +} + +function TabsIconAndTextPreview() { + return ( +
+ +
+ ) +} + +function TabsIconOnlyPreview() { + return ( +
+ +
+ ) +} + +function IconOnlyPreview() { + return ( +
+ +
+ ) +} + +function EmptyStatePreview() { + return ( +
+ +
+ ) +} + +function NoCloseButtonPreview() { + return ( +
+ +
+ ) +} + +function TriggerStylesPreview() { + return ( +
+
+ + + +
+
+ ) +} + +export const Documentation: Story = { + name: 'Documentação', + args: { + tabs: tabsSingle, + }, + parameters: { + docsOnly: true, + }, + render: () => ( + + Notificações} + description="Componente para exibir notificações em painel ancorado ao botão, com suporte a usuário, abas e estado vazio." + stats={docsHeroStats} + /> + + +
+ +
+              {`type NotificationItem = {
+  id: string | number
+  title: ReactNode
+  description: ReactNode
+  time?: ReactNode
+}
+
+type NotificationTab = {
+  id: string | number
+  label?: ReactNode   // op\u00e7\u00f5es: s\u00f3 texto (label), s\u00f3 \u00edcone (icon), \u00edcone + texto (icon + label)
+  icon?: IconDefinition
+  items: NotificationItem[]
+}`}
+            
+
+ +
+ +

+ Para não exibir navegação de abas, envie apenas 1 item no array de tabs. +

+
+ + +

+ Para exibir o seletor de abas, envie 2 ou mais grupos no array de tabs. +

+
+
+
+ + + + + + + + + + + + + + + + +
+ + +
+ +

+ Array de grupos de notificações. Controla conteúdo e, com 2+ grupos, exibe navegação por abas. +

+
+ +

+ Texto do botão disparador. Se vazio, o botão mostra apenas o ícone (modo circular). +

+
+ +

+ Exibe a área do usuário no topo do painel. user aceita name e email (ambos opcionais). +

+
+ +

+ Permite combinar estilo e tamanho do botão disparador com o restante da interface. +

+
+ +

+ Callback chamado ao clicar em um item da lista, recebendo o id da notificação. +

+
+ +

+ Callback chamado quando o painel é fechado por botão fechar, clique fora ou tecla Esc. +

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ), +} + +function InteractivePreview(args: InteractivePreviewProps) { + const [selectedNotification, setSelectedNotification] = useState(null) + + const resolvedTabs = args.emptyState + ? [{ id: 'all', label: 'Todas', items: [] }] + : args.tabMode === 'iconAndText' + ? tabsWithIconAndText + : args.tabMode === 'iconOnly' + ? tabsIconOnly + : args.tabMode === 'text' + ? tabsMultiple + : dataPresets[args.dataPreset ?? 'default'] ?? presetDefault + + const resolvedButtonLabel = args.iconOnly ? '' : args.buttonLabel + + return ( +
+ setSelectedNotification(id)} + /> + + {selectedNotification ? ( +

+ Última notificação clicada: {selectedNotification} +

+ ) : null} + +
+

+ Código para copiar e colar: +

+
+          {interactiveSnippetCode}
+        
+
+
+ ) +} + +export const Interactive: StoryObj = { + name: 'Interativo', + render: (args) => , + args: { + buttonLabel: 'Notificações (3)', + useBellIcon: true, + buttonVariant: 'secondary', + buttonSize: 'medium', + tabMode: 'text', + showUserArea: true, + iconOnly: false, + emptyState: false, + dataPreset: 'default', + }, + argTypes: { + buttonLabel: { + control: 'text', + description: 'Texto do botão disparador.', + table: { type: { summary: 'string' } }, + }, + useBellIcon: { + control: 'boolean', + table: { defaultValue: { summary: 'true' } }, + }, + buttonVariant: { + control: 'select', + options: ['primary', 'secondary', 'tertiary'], + }, + buttonSize: { + control: 'select', + options: ['small', 'medium', 'large'], + }, + tabMode: { + control: 'select', + options: ['single', 'text', 'iconAndText', 'iconOnly'], + description: + 'Modo das abas: uma única lista (single), só texto (text), ícone + texto (iconAndText) ou só ícone (iconOnly).', + table: { defaultValue: { summary: 'text' } }, + }, + showUserArea: { + control: 'boolean', + }, + iconOnly: { + control: 'boolean', + description: 'Quando true, ignora buttonLabel e mostra somente o sino.', + }, + emptyState: { + control: 'boolean', + description: 'Mostra estado vazio de notificações.', + }, + dataPreset: { + control: 'select', + options: ['default', 'allNew', 'withDisabled', 'allRead', 'single'], + description: 'Conjunto de dados das notificações. Ativo apenas quando tabMode é "single".', + table: { + defaultValue: { summary: 'default' }, + type: { + summary: 'string', + detail: 'default — mix de novas e lidas\nallNew — todas novas\nwithDisabled — com itens desabilitados\nallRead — todas lidas\nsingle — apenas uma notificação', + }, + }, + }, + }, +} diff --git a/src/components/Notification/Notification.tsx b/src/components/Notification/Notification.tsx new file mode 100644 index 0000000..56ca59e --- /dev/null +++ b/src/components/Notification/Notification.tsx @@ -0,0 +1,333 @@ +import { useEffect, useId, useRef, useState, type ReactNode } from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faBell, faXmark } from '@fortawesome/free-solid-svg-icons' +import type { IconDefinition } from '@fortawesome/fontawesome-svg-core' +import { Button } from '../Button/Button' +import './Notification.scss' + +export type NotificationItem = { + id: string | number + title: ReactNode + description: ReactNode + time?: ReactNode + isNew?: boolean + disabled?: boolean +} + +export type NotificationTab = { + id: string | number + /** + * Conteúdo textual/JSX da aba. + * Combinações suportadas: + * - só texto: label + * - só ícone: icon + * - ícone e texto: icon + label + */ + label?: ReactNode + /** Ícone Font Awesome para a aba */ + icon?: IconDefinition + items: NotificationItem[] +} + +export type NotificationProps = { + /** Rótulo do botão que dispara o modal (se vazio, mostra apenas o ícone) */ + buttonLabel?: ReactNode + /** Usar ícone de sininho no botão */ + useBellIcon?: boolean + /** Variante do botão */ + buttonVariant?: 'primary' | 'secondary' | 'tertiary' + /** Tamanho do botão */ + buttonSize?: 'small' | 'medium' | 'large' + /** Abas de notificações */ + tabs: NotificationTab[] + /** Mostrar seção de usuário */ + showUserArea?: boolean + /** Dados do usuário (nome e email) */ + user?: { + name?: ReactNode + email?: ReactNode + } + /** Callback quando fechar o modal */ + onClose?: () => void + /** Exibir botão de fechar no painel (padrão: true) */ + showCloseButton?: boolean + /** Callback quando clicar em uma notificação */ + onNotificationClick?: (notificationId: string | number) => void + /** Classe customizada para o container */ + className?: string +} + +export function Notification({ + buttonLabel = 'Notificações', + useBellIcon = true, + buttonVariant = 'secondary', + buttonSize = 'medium', + tabs, + showUserArea = true, + user, + onClose, + showCloseButton = true, + onNotificationClick, + className, +}: NotificationProps) { + const validTabs = tabs && tabs.length > 0 ? tabs : [] + const [isOpen, setIsOpen] = useState(false) + const [activeTabId, setActiveTabId] = useState(validTabs[0]?.id) + const [panelAlignment, setPanelAlignment] = useState<'left' | 'right'>('left') + const containerRef = useRef(null) + const triggerRef = useRef(null) + const panelRef = useRef(null) + const panelId = useId() + + const handleTogglePanel = () => { + setIsOpen((currentState) => !currentState) + } + + const handleClosePanel = () => { + setIsOpen(false) + onClose?.() + } + + useEffect(() => { + if (!isOpen) { + return undefined + } + + function handleOutsideClick(event: MouseEvent | TouchEvent) { + const eventTarget = event.target + + if (!(eventTarget instanceof Node)) { + return + } + + if (!containerRef.current?.contains(eventTarget)) { + handleClosePanel() + } + } + + function handleEscape(event: KeyboardEvent) { + if (event.key === 'Escape') { + handleClosePanel() + } + } + + document.addEventListener('mousedown', handleOutsideClick) + document.addEventListener('touchstart', handleOutsideClick) + document.addEventListener('keydown', handleEscape) + + return () => { + document.removeEventListener('mousedown', handleOutsideClick) + document.removeEventListener('touchstart', handleOutsideClick) + document.removeEventListener('keydown', handleEscape) + } + }, [isOpen]) + + useEffect(() => { + if (!isOpen) { + return undefined + } + + function updatePanelAlignment() { + const triggerRect = triggerRef.current?.getBoundingClientRect() + const panelRect = panelRef.current?.getBoundingClientRect() + + if (!triggerRect || !panelRect) { + return + } + + const viewportWidth = window.innerWidth + const horizontalPadding = 16 + const wouldOverflowRight = triggerRect.left + panelRect.width > viewportWidth - horizontalPadding + + setPanelAlignment(wouldOverflowRight ? 'right' : 'left') + } + + updatePanelAlignment() + window.addEventListener('resize', updatePanelAlignment) + + return () => { + window.removeEventListener('resize', updatePanelAlignment) + } + }, [isOpen]) + + const handleNotificationClick = (notificationId: string | number) => { + onNotificationClick?.(notificationId) + } + + const hasUserArea = showUserArea && Boolean(user) + const hasVisualTabs = validTabs.length > 1 + const shouldReserveContentTopSpace = showCloseButton && !hasUserArea && !hasVisualTabs + + const activeTab = validTabs.find((tab) => tab.id === activeTabId) + + return ( +
+ {/* Botão para abrir modal */} +
+ +
+ + {/* Painel de notificações */} + {isOpen && ( + + )} +
+ ) +} + +export namespace Notification { + export type Props = NotificationProps + export type Tab = NotificationTab + export type Item = NotificationItem + export type TabIcon = IconDefinition +} + +export default Notification diff --git a/src/components/index.ts b/src/components/index.ts index 0932043..80518b8 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -18,6 +18,7 @@ export { Loader } from './Loader/Loader'; export { List } from './List/List'; export { MenuHamburger } from './MenuHamburger/MenuHamburger'; export { Modal } from './Modal/Modal'; +export { Notification } from './Notification/Notification'; export { Radio } from './Radio/Radio'; export { Search } from './Search/Search'; export { Select } from './Select/Select'; From 871d728f279005d7869c6dde040cda87e5e4100f Mon Sep 17 00:00:00 2001 From: Andressa Rocha de Sias Date: Thu, 16 Jul 2026 14:29:01 -0300 Subject: [PATCH 2/2] feat: notificacoes (#1419070) --- src/components/Notification/Notification.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Notification/Notification.stories.tsx b/src/components/Notification/Notification.stories.tsx index 29db6e4..c9c156d 100644 --- a/src/components/Notification/Notification.stories.tsx +++ b/src/components/Notification/Notification.stories.tsx @@ -378,7 +378,7 @@ const dataPresets: Record = { } const meta = { - title: 'Feedback/Notificações', + title: 'Feedback/Notifications', component: Notification, parameters: { layout: 'padded',