@liguelead/design-system 0.0.34 → 0.0.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -55,6 +55,11 @@ Componente de alerta para comunicar mensagens de feedback ao usuário.
55
55
  control: 'text',
56
56
  description: 'URL do botão de ação',
57
57
  },
58
+ openNewTab: {
59
+ control: 'boolean',
60
+ description: 'Abre o link do botão em uma nova aba',
61
+ table: { defaultValue: { summary: 'false' } },
62
+ },
58
63
  },
59
64
  }
60
65
 
@@ -87,13 +92,14 @@ export const TodasVariantes: Story = {
87
92
  export const ComBotao: Story = {
88
93
  name: 'Com botão de ação',
89
94
  parameters: {
90
- docs: { description: { story: 'Use `hasButton` quando houver uma ação clara para o usuário tomar.' } },
95
+ docs: { description: { story: 'Use `hasButton` quando houver uma ação clara para o usuário tomar. Use `openNewTab` para abrir o link em nova aba.' } },
91
96
  },
92
97
  render: () => (
93
98
  <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
94
- <Alert variant="info" title="Nova versão disponível" description="Uma atualização está pronta para ser instalada." hasButton buttonLabel="Atualizar agora" href="#" />
95
- <Alert variant="warning" title="Sessão expirando" description="Sua sessão expira em 5 minutos." hasButton buttonLabel="Renovar sessão" href="#" />
96
- <Alert variant="danger" title="Falha no pagamento" description="Não foi possível processar seu pagamento." hasButton buttonLabel="Tentar novamente" href="#" />
99
+ <Alert variant="info" title="Nova versão disponível" description="Uma atualização está pronta para ser instalada." hasButton buttonLabel="Atualizar agora" href="#" openNewTab={false} />
100
+ <Alert variant="warning" title="Sessão expirando" description="Sua sessão expira em 5 minutos." hasButton buttonLabel="Renovar sessão" href="#" openNewTab={false} />
101
+ <Alert variant="danger" title="Falha no pagamento" description="Não foi possível processar seu pagamento." hasButton buttonLabel="Tentar novamente" href="#" openNewTab={false} />
102
+ <Alert variant="info" title="Documentação disponível" description="Acesse a documentação completa no portal." hasButton buttonLabel="Ver documentação" href="#" openNewTab />
97
103
  </div>
98
104
  ),
99
105
  }
@@ -17,6 +17,7 @@ const Alert = ({
17
17
  description,
18
18
  href,
19
19
  children,
20
+ openNewTab,
20
21
  title,
21
22
  hasButton
22
23
  }: TAlertProps) => {
@@ -45,7 +46,12 @@ const Alert = ({
45
46
  </AlertContent>
46
47
  {hasButton && (
47
48
  <AlertButtonContainer>
48
- <LinkButton href={href || ''}>{buttonLabel}</LinkButton>
49
+ <LinkButton
50
+ href={href || ''}
51
+ target={openNewTab ? '_blank' : undefined}
52
+ >
53
+ {buttonLabel}
54
+ </LinkButton>
49
55
  </AlertButtonContainer>
50
56
  )}
51
57
  </AlertContainer>
@@ -7,4 +7,5 @@ export type TAlertProps = {
7
7
  description?: string
8
8
  variant?: 'default' | 'danger' | 'warning' | 'info' | 'success'
9
9
  title: string
10
+ openNewTab?: boolean
10
11
  }
@@ -0,0 +1,126 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import Skeleton from './Skeleton'
3
+
4
+ const meta: Meta<typeof Skeleton> = {
5
+ title: 'Feedback/Skeleton',
6
+ component: Skeleton,
7
+ parameters: {
8
+ layout: 'centered',
9
+ docs: {
10
+ description: {
11
+ component: `
12
+ Componente de loading skeleton baseado em \`react-loading-skeleton\`.
13
+
14
+ **Quando usar:**
15
+ - Enquanto dados estão sendo carregados da API
16
+ - Para evitar layout shift e melhorar a percepção de performance
17
+ - Substitua o conteúdo real pelo Skeleton durante o estado de loading
18
+ `,
19
+ },
20
+ },
21
+ },
22
+ tags: ['autodocs'],
23
+ argTypes: {
24
+ width: {
25
+ control: 'text',
26
+ description: 'Largura do skeleton (px, %, rem...)',
27
+ },
28
+ height: {
29
+ control: 'text',
30
+ description: 'Altura do skeleton',
31
+ },
32
+ count: {
33
+ control: { type: 'number', min: 1, max: 10 },
34
+ description: 'Quantidade de linhas',
35
+ table: { defaultValue: { summary: '1' } },
36
+ },
37
+ circle: {
38
+ control: 'boolean',
39
+ description: 'Formato circular (avatar)',
40
+ table: { defaultValue: { summary: 'false' } },
41
+ },
42
+ borderRadius: {
43
+ control: 'text',
44
+ description: 'Border radius customizado',
45
+ },
46
+ inline: {
47
+ control: 'boolean',
48
+ description: 'Exibe inline',
49
+ table: { defaultValue: { summary: 'false' } },
50
+ },
51
+ },
52
+ }
53
+
54
+ export default meta
55
+ type Story = StoryObj<typeof meta>
56
+
57
+ export const Default: Story = {
58
+ args: {
59
+ width: 200,
60
+ height: 20,
61
+ },
62
+ }
63
+
64
+ export const MultiplaLinhas: Story = {
65
+ name: 'Múltiplas linhas',
66
+ parameters: {
67
+ docs: { description: { story: 'Use `count` para simular blocos de texto.' } },
68
+ },
69
+ render: () => (
70
+ <div style={{ width: 320 }}>
71
+ <Skeleton count={4} height={16} />
72
+ </div>
73
+ ),
74
+ }
75
+
76
+ export const Circular: Story = {
77
+ name: 'Avatar circular',
78
+ parameters: {
79
+ docs: { description: { story: 'Use `circle` para avatares.' } },
80
+ },
81
+ render: () => (
82
+ <div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
83
+ <Skeleton circle width={32} height={32} />
84
+ <Skeleton circle width={40} height={40} />
85
+ <Skeleton circle width={56} height={56} />
86
+ </div>
87
+ ),
88
+ }
89
+
90
+ export const Card: Story = {
91
+ name: 'Card de conteúdo',
92
+ parameters: {
93
+ docs: { description: { story: 'Exemplo de skeleton para um card com avatar + texto.' } },
94
+ },
95
+ render: () => (
96
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12, width: 320 }}>
97
+ <div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
98
+ <Skeleton circle width={40} height={40} />
99
+ <div style={{ flex: 1 }}>
100
+ <Skeleton height={14} width="60%" />
101
+ <Skeleton height={12} width="40%" />
102
+ </div>
103
+ </div>
104
+ <Skeleton height={120} borderRadius={8} />
105
+ <Skeleton count={3} height={14} />
106
+ </div>
107
+ ),
108
+ }
109
+
110
+ export const Tabela: Story = {
111
+ name: 'Linhas de tabela',
112
+ parameters: {
113
+ docs: { description: { story: 'Simula linhas de uma tabela carregando.' } },
114
+ },
115
+ render: () => (
116
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: 480 }}>
117
+ {Array.from({ length: 5 }).map((_, i) => (
118
+ <div key={i} style={{ display: 'flex', gap: 16 }}>
119
+ <Skeleton width={120} height={16} />
120
+ <Skeleton width={160} height={16} />
121
+ <Skeleton width={80} height={16} />
122
+ </div>
123
+ ))}
124
+ </div>
125
+ ),
126
+ }
@@ -0,0 +1,15 @@
1
+ import styled from 'styled-components'
2
+ import { radius } from '@liguelead/foundation'
3
+ import { parseColor } from '../../utils'
4
+
5
+ export const SkeletonWrapper = styled.div<{ $inline?: boolean }>`
6
+ display: ${({ $inline }) => ($inline ? 'inline-flex' : 'flex')};
7
+ flex-direction: column;
8
+ gap: 4px;
9
+ `
10
+
11
+ export const SkeletonThemeWrapper = styled.div`
12
+ --base-color: ${({ theme }) => parseColor(theme.colors.neutral200)};
13
+ --highlight-color: ${({ theme }) => parseColor(theme.colors.neutral100)};
14
+ border-radius: ${radius.radius4}px;
15
+ `
@@ -0,0 +1,38 @@
1
+ import ReactSkeleton, { SkeletonTheme } from 'react-loading-skeleton'
2
+ import 'react-loading-skeleton/dist/skeleton.css'
3
+ import { useTheme } from 'styled-components'
4
+ import { parseColor } from '../../utils'
5
+ import { SkeletonWrapper } from './Skeleton.styles'
6
+ import { SkeletonProps } from './Skeleton.types'
7
+
8
+ const Skeleton: React.FC<SkeletonProps> = ({
9
+ width,
10
+ height,
11
+ borderRadius,
12
+ circle = false,
13
+ count = 1,
14
+ className,
15
+ inline = false,
16
+ }) => {
17
+ const theme = useTheme()
18
+
19
+ return (
20
+ <SkeletonTheme
21
+ baseColor={parseColor(theme.colors.neutral200)}
22
+ highlightColor={parseColor(theme.colors.neutral100)}
23
+ >
24
+ <SkeletonWrapper $inline={inline} className={className}>
25
+ <ReactSkeleton
26
+ width={width}
27
+ height={height}
28
+ borderRadius={borderRadius}
29
+ circle={circle}
30
+ count={count}
31
+ inline={inline}
32
+ />
33
+ </SkeletonWrapper>
34
+ </SkeletonTheme>
35
+ )
36
+ }
37
+
38
+ export default Skeleton
@@ -0,0 +1,9 @@
1
+ export interface SkeletonProps {
2
+ width?: string | number
3
+ height?: string | number
4
+ borderRadius?: string | number
5
+ circle?: boolean
6
+ count?: number
7
+ className?: string
8
+ inline?: boolean
9
+ }
@@ -0,0 +1,2 @@
1
+ export { default as Skeleton } from './Skeleton'
2
+ export type { SkeletonProps } from './Skeleton.types'
@@ -0,0 +1,12 @@
1
+ import { spacing } from '@liguelead/foundation'
2
+ import { ButtonSizeTypes } from '../Button/Button.types'
3
+
4
+ export const SplitButtonTriggerSizes = (size: ButtonSizeTypes) => {
5
+ const sizes = {
6
+ sm: `width: ${spacing.spacing32}px; height: ${spacing.spacing32}px; padding: 0;`,
7
+ md: `width: ${spacing.spacing36}px; height: ${spacing.spacing36}px; padding: 0;`,
8
+ lg: `width: ${spacing.spacing40}px; height: ${spacing.spacing40}px; padding: 0;`
9
+ }
10
+
11
+ return sizes[size]
12
+ }
@@ -0,0 +1,221 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import {
3
+ ArchiveIcon,
4
+ ClockIcon,
5
+ PencilSimpleIcon,
6
+ TrashIcon
7
+ } from '@phosphor-icons/react'
8
+ import SplitButton from './SplitButton'
9
+
10
+ const meta: Meta<typeof SplitButton> = {
11
+ title: 'Form/SplitButton',
12
+ component: SplitButton,
13
+ parameters: {
14
+ layout: 'centered',
15
+ docs: {
16
+ description: {
17
+ component: `
18
+ Botão dividido com ação principal e menu de opções secundárias.
19
+
20
+ **Quando usar:**
21
+ - Quando há uma ação primária clara, mas também ações alternativas relacionadas
22
+ - Ex: "Publicar" com opções "Salvar rascunho" e "Agendar"
23
+
24
+ **Composição:**
25
+ - Lado esquerdo: botão de ação principal (\`onClick\`)
26
+ - Lado direito: trigger que abre um \`DropdownMenu\` com as \`options\`
27
+ `
28
+ }
29
+ }
30
+ },
31
+ tags: ['autodocs'],
32
+ argTypes: {
33
+ label: {
34
+ control: 'text',
35
+ description: 'Texto do botão principal'
36
+ },
37
+ variant: {
38
+ control: 'select',
39
+ options: ['solid', 'outline', 'ghost', 'neutralOutline', 'neutralGhost'],
40
+ description: 'Variante visual',
41
+ table: { defaultValue: { summary: 'neutralOutline' } }
42
+ },
43
+ size: {
44
+ control: 'select',
45
+ options: ['sm', 'md', 'lg'],
46
+ description: 'Tamanho do botão',
47
+ table: { defaultValue: { summary: 'sm' } }
48
+ },
49
+ disabled: {
50
+ control: 'boolean',
51
+ description: 'Desabilita ambos os botões',
52
+ table: { defaultValue: { summary: 'false' } }
53
+ }
54
+ }
55
+ }
56
+
57
+ export default meta
58
+ type Story = StoryObj<typeof meta>
59
+
60
+ const defaultOptions = [
61
+ {
62
+ label: 'Salvar rascunho',
63
+ icon: <PencilSimpleIcon size={16} />,
64
+ onClick: () => console.log('rascunho')
65
+ },
66
+ {
67
+ label: 'Agendar',
68
+ icon: <ClockIcon size={16} />,
69
+ onClick: () => console.log('agendar')
70
+ },
71
+ {
72
+ label: 'Arquivar',
73
+ icon: <ArchiveIcon size={16} />,
74
+ onClick: () => console.log('arquivar')
75
+ }
76
+ ]
77
+
78
+ export const Default: Story = {
79
+ args: {
80
+ label: 'Publicar',
81
+ onClick: () => console.log('publicar'),
82
+ options: defaultOptions
83
+ }
84
+ }
85
+
86
+ export const Tamanhos: Story = {
87
+ name: 'Tamanhos',
88
+ parameters: {
89
+ docs: {
90
+ description: { story: 'Três tamanhos disponíveis: `sm`, `md` e `lg`.' }
91
+ }
92
+ },
93
+ render: () => (
94
+ <div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
95
+ <SplitButton
96
+ label="Pequeno"
97
+ size="sm"
98
+ onClick={() => {}}
99
+ options={defaultOptions}
100
+ />
101
+ <SplitButton
102
+ label="Médio"
103
+ size="md"
104
+ onClick={() => {}}
105
+ options={defaultOptions}
106
+ />
107
+ <SplitButton
108
+ label="Grande"
109
+ size="lg"
110
+ onClick={() => {}}
111
+ options={defaultOptions}
112
+ />
113
+ </div>
114
+ )
115
+ }
116
+
117
+ export const Variantes: Story = {
118
+ name: 'Variantes',
119
+ parameters: {
120
+ docs: {
121
+ description: { story: 'Herda as mesmas variantes do Button.' }
122
+ }
123
+ },
124
+ render: () => (
125
+ <div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center' }}>
126
+ <SplitButton
127
+ label="Neutral Outline"
128
+ variant="neutralOutline"
129
+ onClick={() => {}}
130
+ options={defaultOptions}
131
+ />
132
+ <SplitButton
133
+ label="Solid"
134
+ variant="solid"
135
+ onClick={() => {}}
136
+ options={defaultOptions}
137
+ />
138
+ <SplitButton
139
+ label="Outline"
140
+ variant="outline"
141
+ onClick={() => {}}
142
+ options={defaultOptions}
143
+ />
144
+ <SplitButton
145
+ label="Ghost"
146
+ variant="ghost"
147
+ onClick={() => {}}
148
+ options={defaultOptions}
149
+ />
150
+ </div>
151
+ )
152
+ }
153
+
154
+ export const Desabilitado: Story = {
155
+ name: 'Estado desabilitado',
156
+ args: {
157
+ label: 'Publicar',
158
+ disabled: true,
159
+ onClick: () => {},
160
+ options: defaultOptions
161
+ }
162
+ }
163
+
164
+ export const SemIcones: Story = {
165
+ name: 'Opções sem ícone',
166
+ args: {
167
+ label: 'Ação',
168
+ onClick: () => {},
169
+ options: [
170
+ { label: 'Opção A', onClick: () => {} },
171
+ { label: 'Opção B', onClick: () => {} },
172
+ { label: 'Opção C (desabilitada)', onClick: () => {}, disabled: true }
173
+ ]
174
+ }
175
+ }
176
+
177
+ export const ExemploReal: Story = {
178
+ name: 'Exemplo: Publicação de conteúdo',
179
+ parameters: {
180
+ docs: {
181
+ description: {
182
+ story: 'Padrão comum em sistemas de CMS ou gestão de tarefas.'
183
+ }
184
+ }
185
+ },
186
+ render: () => (
187
+ <div
188
+ style={{
189
+ display: 'flex',
190
+ justifyContent: 'flex-end',
191
+ padding: 16,
192
+ border: '1px solid #e5e7eb',
193
+ borderRadius: 8,
194
+ width: 400,
195
+ gap: 8
196
+ }}
197
+ >
198
+ <SplitButton
199
+ label="Publicar"
200
+ onClick={() => console.log('publicar')}
201
+ options={[
202
+ {
203
+ label: 'Salvar rascunho',
204
+ icon: <PencilSimpleIcon size={16} />,
205
+ onClick: () => console.log('rascunho')
206
+ },
207
+ {
208
+ label: 'Agendar publicação',
209
+ icon: <ClockIcon size={16} />,
210
+ onClick: () => console.log('agendar')
211
+ },
212
+ {
213
+ label: 'Mover para lixeira',
214
+ icon: <TrashIcon size={16} />,
215
+ onClick: () => console.log('lixeira')
216
+ }
217
+ ]}
218
+ />
219
+ </div>
220
+ )
221
+ }
@@ -0,0 +1,106 @@
1
+ import styled from 'styled-components'
2
+ import { fontSize, fontWeight, lineHeight, radius, shadow, spacing } from '@liguelead/foundation'
3
+ import { parseColor } from '../../utils'
4
+ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
5
+
6
+ export const Wrapper = styled.div`
7
+ display: inline-flex;
8
+ align-items: stretch;
9
+ `
10
+
11
+ export const MainButton = styled.button<{ $variant: string; $size: string }>`
12
+ position: relative;
13
+ display: flex;
14
+ align-items: center;
15
+ justify-content: center;
16
+ overflow: hidden;
17
+ cursor: pointer;
18
+ outline: none;
19
+ transition: background-color 0.3s, box-shadow 0.3s;
20
+ ${({ $size }) => $size}
21
+ ${({ $variant }) => $variant}
22
+ border-radius: ${radius.radius4}px 0 0 ${radius.radius4}px;
23
+
24
+ &:focus-visible {
25
+ box-shadow: ${shadow.focusShadow};
26
+ }
27
+
28
+ &:disabled {
29
+ cursor: not-allowed;
30
+ }
31
+ `
32
+
33
+ export const TriggerButton = styled(DropdownMenu.Trigger)<{
34
+ $variant: string
35
+ $size: string
36
+ }>`
37
+ position: relative;
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: center;
41
+ overflow: hidden;
42
+ cursor: pointer;
43
+ outline: none;
44
+ transition: background-color 0.3s, box-shadow 0.3s;
45
+ ${({ $size }) => $size}
46
+ ${({ $variant }) => $variant}
47
+ border-radius: 0 ${radius.radius4}px ${radius.radius4}px 0;
48
+
49
+ &:focus-visible {
50
+ box-shadow: ${shadow.focusShadow};
51
+ }
52
+
53
+ &:disabled {
54
+ cursor: not-allowed;
55
+ }
56
+
57
+ & svg {
58
+ width: 10px;
59
+ height: 10px;
60
+ pointer-events: none;
61
+ }
62
+ `
63
+
64
+ export const StyledContent = styled(DropdownMenu.Content)`
65
+ width: max-content;
66
+ background: ${({ theme }) => parseColor(theme.colors.white)};
67
+ border: 1px solid ${({ theme }) => parseColor(theme.colors.neutral400)};
68
+ border-radius: ${radius.radius4}px;
69
+ box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.08);
70
+ overflow: hidden;
71
+ z-index: 9999;
72
+ padding: 0;
73
+ `
74
+
75
+ export const StyledItem = styled(DropdownMenu.Item)`
76
+ height: ${spacing.spacing36}px;
77
+ padding: 0 ${spacing.spacing12}px;
78
+ display: flex;
79
+ align-items: center;
80
+ gap: ${spacing.spacing8}px;
81
+ cursor: pointer;
82
+ outline: none;
83
+ font-size: ${fontSize.fontSize14}px;
84
+ font-weight: ${fontWeight.fontWeight400};
85
+ line-height: ${lineHeight.lineHeight20}px;
86
+ color: ${({ theme }) => parseColor(theme.colors.textDark)};
87
+ transition: background-color 0.2s ease;
88
+ user-select: none;
89
+ white-space: nowrap;
90
+
91
+ &[data-highlighted] {
92
+ background-color: ${({ theme }) => parseColor(theme.colors.primaryLight)};
93
+ }
94
+
95
+ &[data-disabled] {
96
+ opacity: 0.6;
97
+ cursor: not-allowed;
98
+ }
99
+
100
+ & svg {
101
+ width: 16px;
102
+ height: 16px;
103
+ flex-shrink: 0;
104
+ color: ${({ theme }) => parseColor(theme.colors.textMedium)};
105
+ }
106
+ `
@@ -0,0 +1,83 @@
1
+ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
2
+ import { CaretDownIcon } from '@phosphor-icons/react'
3
+ import { useTheme } from 'styled-components'
4
+ import { ButtonVariant } from '../Button/Button.appearance'
5
+ import { ButtonSizes } from '../Button/Button.sizes'
6
+ import { parseColor } from '../../utils'
7
+ import {
8
+ MainButton,
9
+ StyledContent,
10
+ StyledItem,
11
+ TriggerButton,
12
+ Wrapper
13
+ } from './SplitButton.styles'
14
+ import { SplitButtonTriggerSizes } from './SplitButton.sizes'
15
+ import { SplitButtonProps } from './SplitButton.types'
16
+
17
+ const SplitButton: React.FC<SplitButtonProps> = ({
18
+ label,
19
+ onClick,
20
+ options,
21
+ variant = 'neutralOutline',
22
+ color = 'primary',
23
+ size = 'sm',
24
+ disabled = false,
25
+ className
26
+ }) => {
27
+ const theme = useTheme()
28
+ const buttonVariant = ButtonVariant(color, variant)
29
+ const buttonSize = ButtonSizes(size)
30
+ const triggerSize = SplitButtonTriggerSizes(size)
31
+
32
+ const mainVariantOverride = `
33
+ ${buttonVariant}
34
+ border-right: none;
35
+ `
36
+
37
+ const triggerVariantOverride = `
38
+ ${buttonVariant}
39
+ border-left: 1px solid ${parseColor(theme.colors.neutral400)};
40
+ `
41
+
42
+ return (
43
+ <DropdownMenu.Root>
44
+ <Wrapper className={className}>
45
+ <MainButton
46
+ type="button"
47
+ disabled={disabled}
48
+ onClick={onClick}
49
+ $variant={mainVariantOverride}
50
+ $size={buttonSize}
51
+ >
52
+ {label}
53
+ </MainButton>
54
+
55
+ <TriggerButton
56
+ disabled={disabled}
57
+ $variant={triggerVariantOverride}
58
+ $size={triggerSize}
59
+ aria-label="Mais opções"
60
+ >
61
+ <CaretDownIcon weight="fill" />
62
+ </TriggerButton>
63
+ </Wrapper>
64
+
65
+ <DropdownMenu.Portal>
66
+ <StyledContent align="end" sideOffset={0}>
67
+ {options.map((option, index) => (
68
+ <StyledItem
69
+ key={index}
70
+ disabled={option.disabled}
71
+ onSelect={option.onClick}
72
+ >
73
+ {option.icon}
74
+ {option.label}
75
+ </StyledItem>
76
+ ))}
77
+ </StyledContent>
78
+ </DropdownMenu.Portal>
79
+ </DropdownMenu.Root>
80
+ )
81
+ }
82
+
83
+ export default SplitButton
@@ -0,0 +1,20 @@
1
+ import { ButtonSizeTypes, ButtonVariantTypes } from '../Button/Button.types'
2
+ import { colorType } from '../../types'
3
+
4
+ export interface SplitButtonOption {
5
+ label: string
6
+ icon?: React.ReactNode
7
+ onClick: () => void
8
+ disabled?: boolean
9
+ }
10
+
11
+ export interface SplitButtonProps {
12
+ label: string
13
+ onClick: () => void
14
+ options: SplitButtonOption[]
15
+ variant?: ButtonVariantTypes
16
+ color?: colorType
17
+ size?: ButtonSizeTypes
18
+ disabled?: boolean
19
+ className?: string
20
+ }
@@ -0,0 +1,2 @@
1
+ export { default } from './SplitButton'
2
+ export type { SplitButtonProps, SplitButtonOption } from './SplitButton.types'
@@ -98,6 +98,7 @@ function Table<TData>({
98
98
  isServerMode,
99
99
  controlled: datatableColumnFiltersValue,
100
100
  onChange: onDatatableColumnFiltersChange,
101
+ onClearSorting: (colId) => setSorting((prev) => prev.filter((s) => s.id !== colId)),
101
102
  })
102
103
 
103
104
  const queryState: TableQueryState = useMemo(() => ({
@@ -164,7 +165,7 @@ function Table<TData>({
164
165
  const currentPage = pagination.pageIndex + 1
165
166
 
166
167
  const hasActiveFilters = globalFilter !== '' || sorting.length > 0
167
- const hasDatatableActiveFilters = globalFilter !== '' || columnFilters.length > 0
168
+ const hasDatatableActiveFilters = globalFilter !== '' || columnFilters.length > 0 || sorting.length > 0
168
169
 
169
170
  const handleClearFilters = () => {
170
171
  setGlobalFilter('')
@@ -172,6 +173,10 @@ function Table<TData>({
172
173
  setPagination((prev) => ({ ...prev, pageIndex: 0 }))
173
174
  }
174
175
 
176
+ const handleNumberSort = (colId: string, direction: 'asc' | 'desc' | null) => {
177
+ setSorting(direction ? [{ id: colId, desc: direction === 'desc' }] : [])
178
+ }
179
+
175
180
  const handleExport = async () => {
176
181
  if (onExport) {
177
182
  await onExport(queryState)
@@ -204,7 +209,10 @@ function Table<TData>({
204
209
  enableColumnVisibility={datatableEnableColumnVisibility}
205
210
  enableClearFilters={datatableEnableClearFilters}
206
211
  hasActiveFilters={hasDatatableActiveFilters}
207
- onClearAll={() => handleClearAllFilters(() => setGlobalFilter(''))}
212
+ onClearAll={() => {
213
+ handleClearAllFilters(() => setGlobalFilter(''))
214
+ setSorting([])
215
+ }}
208
216
  searchPlaceholder={searchPlaceholder}
209
217
  searchWidth={searchWidth}
210
218
  clearFiltersLabel={clearFiltersLabel}
@@ -263,6 +271,7 @@ function Table<TData>({
263
271
  onFilterPopoverOpenChange={handlePopoverOpenChange}
264
272
  onFilterValueChange={handleFilterValueChange}
265
273
  onClearFilter={handleClearColumnFilter}
274
+ onNumberSort={handleNumberSort}
266
275
  labels={datatableFilterDropdownLabels}
267
276
  />
268
277
  <tbody>
@@ -8,6 +8,7 @@ export type ColumnFilterValue =
8
8
  | { type: 'text'; value: string }
9
9
  | { type: 'dateRange'; value: { from?: Date; to?: Date } }
10
10
  | { type: 'select'; value: string | number | null }
11
+ | { type: 'numberSort'; value: 'asc' | 'desc' | null }
11
12
 
12
13
  export type DatatableColumnFilters = Record<string, ColumnFilterValue | undefined>
13
14
 
@@ -33,7 +34,7 @@ export interface ExportButtonConfig {
33
34
  ariaLabel?: string
34
35
  }
35
36
 
36
- export type DatatableFilterType = 'text' | 'dateRange' | 'select'
37
+ export type DatatableFilterType = 'text' | 'dateRange' | 'select' | 'numberSort'
37
38
 
38
39
  export interface DatatableColumnMeta {
39
40
  filterType?: DatatableFilterType
@@ -118,3 +118,45 @@ export const ClearFilterButtonWrapper = styled.span`
118
118
  align-items: center;
119
119
  justify-content: end;
120
120
  `
121
+
122
+ export const NumberSortOptions = styled.div`
123
+ display: flex;
124
+ flex-direction: column;
125
+ gap: ${spacing.spacing4}px;
126
+ `
127
+
128
+ export const NumberSortButton = styled.button<{ $active?: boolean }>`
129
+ display: flex;
130
+ align-items: center;
131
+ gap: ${spacing.spacing8}px;
132
+ width: 100%;
133
+ padding: ${spacing.spacing8}px ${spacing.spacing12}px;
134
+ font-size: ${fontSize.fontSize14}px;
135
+ font-weight: ${fontWeight.fontWeight400};
136
+ border-radius: ${radius.radius4}px;
137
+ border: 1px solid ${({ theme, $active }) =>
138
+ $active ? parseColor(theme.colors.primary) : parseColor(theme.colors.neutral300)};
139
+ background: ${({ theme, $active }) =>
140
+ $active ? parseColor(theme.colors.primaryLighter) : parseColor(theme.colors.white)};
141
+ color: ${({ theme, $active }) =>
142
+ $active ? parseColor(theme.colors.primary) : parseColor(theme.colors.textDark)};
143
+ cursor: pointer;
144
+ transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
145
+ outline: none;
146
+
147
+ & svg {
148
+ flex-shrink: 0;
149
+ color: ${({ theme, $active }) =>
150
+ $active ? parseColor(theme.colors.primary) : parseColor(theme.colors.textMedium)};
151
+ }
152
+
153
+ &:hover:not([aria-pressed='true']) {
154
+ background: ${({ theme }) => parseColor(theme.colors.neutral100)};
155
+ border-color: ${({ theme }) => parseColor(theme.colors.neutral400)};
156
+ }
157
+
158
+ &:focus-visible {
159
+ outline: 2px solid ${({ theme }) => parseColor(theme.colors.primary)};
160
+ outline-offset: 1px;
161
+ }
162
+ `
@@ -1,5 +1,5 @@
1
1
  import * as PopoverPrimitive from '@radix-ui/react-popover'
2
- import { XIcon } from '@phosphor-icons/react'
2
+ import { ArrowDownIcon, ArrowUpIcon, XIcon } from '@phosphor-icons/react'
3
3
  import { DateRange } from 'react-day-picker'
4
4
  import DatePicker from '../../../DatePicker'
5
5
  import Select from '../../../Select'
@@ -19,6 +19,8 @@ import {
19
19
  FilterTriggerArea,
20
20
  FunnelActiveIcon,
21
21
  FunnelInactiveIcon,
22
+ NumberSortButton,
23
+ NumberSortOptions,
22
24
  StyledTextInput,
23
25
  } from './DatatableColumnFilterMenu.styles'
24
26
 
@@ -31,6 +33,7 @@ interface DatatableColumnFilterMenuProps {
31
33
  filterValue: ColumnFilterValue | undefined
32
34
  onFilterValueChange: (columnId: string, value: ColumnFilterValue | undefined) => void
33
35
  onClearFilter: (columnId: string) => void
36
+ onNumberSort: (colId: string, direction: 'asc' | 'desc' | null) => void
34
37
  labels?: DatatableFilterDropdownLabels
35
38
  }
36
39
 
@@ -44,6 +47,7 @@ function DatatableColumnFilterMenu({
44
47
  filterValue,
45
48
  onFilterValueChange,
46
49
  onClearFilter,
50
+ onNumberSort,
47
51
  labels = {},
48
52
  }: DatatableColumnFilterMenuProps) {
49
53
  const filterType = meta?.filterType
@@ -59,7 +63,8 @@ function DatatableColumnFilterMenu({
59
63
  ((filterValue.type === 'text' && filterValue.value !== '') ||
60
64
  (filterValue.type === 'select' && filterValue.value != null) ||
61
65
  (filterValue.type === 'dateRange' &&
62
- (filterValue.value.from != null || filterValue.value.to != null)))
66
+ (filterValue.value.from != null || filterValue.value.to != null)) ||
67
+ (filterValue.type === 'numberSort' && filterValue.value != null))
63
68
 
64
69
  const handleTriggerClick = (e: React.MouseEvent) => {
65
70
  e.stopPropagation()
@@ -92,6 +97,17 @@ function DatatableColumnFilterMenu({
92
97
  onOpenChange(false)
93
98
  }
94
99
 
100
+ const handleNumberSort = (direction: 'asc' | 'desc') => {
101
+ const current = filterValue?.type === 'numberSort' ? filterValue.value : null
102
+ const next = current === direction ? null : direction
103
+ onFilterValueChange(columnId, { type: 'numberSort', value: next })
104
+ onNumberSort(columnId, next)
105
+ onOpenChange(false)
106
+ }
107
+
108
+ const currentSortDirection =
109
+ filterValue?.type === 'numberSort' ? filterValue.value : null
110
+
95
111
  const rangeValue: DateRange | undefined =
96
112
  filterValue?.type === 'dateRange'
97
113
  ? { from: filterValue.value.from, to: filterValue.value.to }
@@ -204,6 +220,29 @@ function DatatableColumnFilterMenu({
204
220
  onValueChange={handleSelectChange}
205
221
  />
206
222
  )}
223
+
224
+ {filterType === 'numberSort' && (
225
+ <NumberSortOptions>
226
+ <NumberSortButton
227
+ type="button"
228
+ $active={currentSortDirection === 'asc'}
229
+ onClick={() => handleNumberSort('asc')}
230
+ aria-pressed={currentSortDirection === 'asc'}
231
+ >
232
+ <ArrowUpIcon size={14} />
233
+ Crescente
234
+ </NumberSortButton>
235
+ <NumberSortButton
236
+ type="button"
237
+ $active={currentSortDirection === 'desc'}
238
+ onClick={() => handleNumberSort('desc')}
239
+ aria-pressed={currentSortDirection === 'desc'}
240
+ >
241
+ <ArrowDownIcon size={14} />
242
+ Decrescente
243
+ </NumberSortButton>
244
+ </NumberSortOptions>
245
+ )}
207
246
  </FilterPopoverContent>
208
247
  </PopoverPrimitive.Content>
209
248
  </PopoverPrimitive.Portal>
@@ -12,6 +12,7 @@ interface TableHeaderProps<TData> {
12
12
  onFilterPopoverOpenChange: (colId: string, isOpen: boolean) => void
13
13
  onFilterValueChange: (colId: string, value: any) => void
14
14
  onClearFilter: (colId: string) => void
15
+ onNumberSort: (colId: string, direction: 'asc' | 'desc' | null) => void
15
16
  labels?: DatatableFilterDropdownLabels
16
17
  }
17
18
 
@@ -23,6 +24,7 @@ function TableHeader<TData>({
23
24
  onFilterPopoverOpenChange,
24
25
  onFilterValueChange,
25
26
  onClearFilter,
27
+ onNumberSort,
26
28
  labels = {},
27
29
  }: TableHeaderProps<TData>) {
28
30
  return (
@@ -39,7 +41,8 @@ function TableHeader<TData>({
39
41
  (colFilterValue.type === 'select' && colFilterValue.value != null) ||
40
42
  (colFilterValue.type === 'dateRange' && (
41
43
  colFilterValue.value.from != null || colFilterValue.value.to != null
42
- ))
44
+ )) ||
45
+ (colFilterValue.type === 'numberSort' && colFilterValue.value != null)
43
46
  )
44
47
 
45
48
  return (
@@ -73,6 +76,7 @@ function TableHeader<TData>({
73
76
  filterValue={datatableFilters[header.column.id]}
74
77
  onFilterValueChange={onFilterValueChange}
75
78
  onClearFilter={onClearFilter}
79
+ onNumberSort={onNumberSort}
76
80
  labels={labels}
77
81
  />
78
82
  ) : (
@@ -9,12 +9,14 @@ interface UseDatatableFiltersProps {
9
9
  isServerMode: boolean
10
10
  controlled?: DatatableColumnFilters
11
11
  onChange?: (next: DatatableColumnFilters) => void
12
+ onClearSorting?: (colId: string) => void
12
13
  }
13
14
 
14
15
  export function useDatatableFilters({
15
16
  isServerMode,
16
17
  controlled,
17
18
  onChange,
19
+ onClearSorting,
18
20
  }: UseDatatableFiltersProps) {
19
21
  const isControlled = controlled !== undefined
20
22
  const [internal, setInternal] = useState<DatatableColumnFilters>({})
@@ -42,18 +44,21 @@ export function useDatatableFilters({
42
44
  const { from, to } = value.value
43
45
  return from || to ? [...rest, { id: colId, value: { from, to } }] : rest
44
46
  }
47
+ // numberSort does not use columnFilters — handled via sorting state
45
48
  return rest
46
49
  })
47
50
  }, [filters, isServerMode, updateFilters])
48
51
 
49
52
  const handleClearColumnFilter = useCallback((colId: string) => {
53
+ const prev = filters[colId]
50
54
  const next = { ...filters }
51
55
  delete next[colId]
52
56
  updateFilters(next)
53
57
  setColumnFilters((prev) => prev.filter((f) => f.id !== colId))
54
58
  if (openFilterColumnId === colId) setOpenFilterColumnId(null)
55
- }, [filters, openFilterColumnId, updateFilters])
56
-
59
+ // if clearing a numberSort column, also clear the table sorting
60
+ if (prev?.type === 'numberSort') onClearSorting?.(colId)
61
+ }, [filters, openFilterColumnId, updateFilters, onClearSorting])
57
62
  const handleClearAllFilters = useCallback((onClearGlobal: () => void) => {
58
63
  onClearGlobal()
59
64
  setColumnFilters([])
@@ -71,6 +76,7 @@ export function useDatatableFilters({
71
76
  if (v.type === 'text') return v.value !== ''
72
77
  if (v.type === 'select') return v.value != null
73
78
  if (v.type === 'dateRange') return v.value.from != null || v.value.to != null
79
+ if (v.type === 'numberSort') return v.value != null
74
80
  return false
75
81
  }, [filters])
76
82
 
@@ -1,5 +1,6 @@
1
1
  import { ColumnDef } from '@tanstack/react-table'
2
2
  import { DatatableColumnMeta } from './Table.types'
3
+ import { currencySortingFn } from './utils'
3
4
 
4
5
  export type OrderRow = {
5
6
  id: string
@@ -82,10 +83,10 @@ export const DATATABLE_COLUMNS: ColumnDef<OrderRow, unknown>[] = [
82
83
  {
83
84
  header: 'Valor',
84
85
  accessorKey: 'amount',
86
+ sortingFn: currencySortingFn,
85
87
  meta: {
86
- filterType: 'text',
88
+ filterType: 'numberSort',
87
89
  filterLabel: 'valor',
88
- filterPlaceholder: 'Buscar pelo valor',
89
90
  } satisfies DatatableColumnMeta,
90
91
  },
91
92
  {
@@ -0,0 +1,20 @@
1
+ import { Row } from '@tanstack/react-table'
2
+
3
+ const parseNumericValue = (raw: unknown): number => {
4
+ if (typeof raw === 'number') return raw
5
+ if (typeof raw !== 'string') return 0
6
+ const cleaned = raw.replace(/[^\d,.-]/g, '').replace(/\.(?=\d{3})/g, '').replace(',', '.')
7
+ return parseFloat(cleaned) || 0
8
+ }
9
+
10
+ export const currencySortingFn = <TData>(
11
+ rowA: Row<TData>,
12
+ rowB: Row<TData>,
13
+ columnId: string
14
+ ): number => {
15
+ const a = parseNumericValue(rowA.getValue(columnId))
16
+ const b = parseNumericValue(rowB.getValue(columnId))
17
+ return a - b
18
+ }
19
+
20
+ currencySortingFn.autoRemove = (val: unknown) => val == null
@@ -1,2 +1,3 @@
1
1
  export { default as getPageNumbers } from './getPageNumbers'
2
2
  export { dateRangeFilterFn } from './dateRangeFilterFn'
3
+ export { currencySortingFn } from './currencySortingFn'
@@ -18,5 +18,8 @@ export { default as RadioButton } from './RadioButton'
18
18
  export { ToastProvider, Toaster } from './Toaster'
19
19
  export { default as Dialog } from './Dialog'
20
20
  export { Combobox } from './Combobox'
21
+ export { default as SplitButton } from './SplitButton'
21
22
  export { default as Table } from './Table'
22
23
  export { default as Tabs } from './Tabs'
24
+ export { Skeleton } from './Skeleton'
25
+ export type { SkeletonProps } from './Skeleton'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguelead/design-system",
3
- "version": "0.0.34",
3
+ "version": "0.0.36",
4
4
  "type": "module",
5
5
  "main": "components/index.ts",
6
6
  "publishConfig": {
@@ -25,7 +25,8 @@
25
25
  "react-toastify": "^11.0.5",
26
26
  "cmdk": "^1.1.1",
27
27
  "date-fns": "^2.30.0",
28
- "@tanstack/react-table": "^8.0.0"
28
+ "@tanstack/react-table": "^8.0.0",
29
+ "react-loading-skeleton": "^3.5.0"
29
30
  },
30
31
  "scripts": {
31
32
  "lint": "eslint ."