@motor-hero/ui-kit 0.17.0 → 0.18.0

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.
@@ -1,6 +1,8 @@
1
1
  import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
2
2
  import { MoreVertical } from "lucide-react"
3
- import type { ReactNode } from "react"
3
+ import { type ReactNode, useState } from "react"
4
+ import { Drawer as DrawerPrimitive } from "vaul"
5
+ import { useIsDesktop } from "../hooks/use-is-desktop"
4
6
  import { cn } from "../lib/utils"
5
7
  import type { RenderLink } from "./types"
6
8
  import { defaultRenderLink } from "./types"
@@ -22,8 +24,14 @@ export interface RowActionsMenuProps {
22
24
  renderLink?: RenderLink
23
25
  }
24
26
 
27
+ const triggerClass =
28
+ "inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
29
+
30
+ // Desktop: itens compactos do dropdown. Mobile: alvos de toque maiores.
25
31
  const itemClass =
26
32
  "flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent focus:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
33
+ const sheetItemClass =
34
+ "flex w-full cursor-pointer select-none items-center gap-3 rounded-md px-3 py-3 text-base outline-none transition-colors hover:bg-accent active:bg-accent disabled:pointer-events-none disabled:opacity-50"
27
35
 
28
36
  export function RowActionsMenu({
29
37
  actions,
@@ -32,55 +40,124 @@ export function RowActionsMenu({
32
40
  label = "Ações",
33
41
  renderLink = defaultRenderLink,
34
42
  }: RowActionsMenuProps) {
43
+ const isDesktop = useIsDesktop()
44
+ const [open, setOpen] = useState(false)
45
+
35
46
  if (actions.length === 0) return null
36
47
 
48
+ const trigger = (
49
+ <button
50
+ type="button"
51
+ disabled={disabled}
52
+ aria-label={label}
53
+ className={triggerClass}
54
+ >
55
+ <MoreVertical className="h-4 w-4" />
56
+ </button>
57
+ )
58
+
59
+ if (isDesktop) {
60
+ return (
61
+ <DropdownMenu.Root open={open} onOpenChange={setOpen}>
62
+ <DropdownMenu.Trigger asChild>{trigger}</DropdownMenu.Trigger>
63
+ <DropdownMenu.Portal>
64
+ <DropdownMenu.Content
65
+ align={align}
66
+ sideOffset={6}
67
+ className="z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
68
+ >
69
+ {actions.map((action) => {
70
+ const cls = cn(
71
+ itemClass,
72
+ action.destructive &&
73
+ "text-destructive focus:bg-destructive/10",
74
+ )
75
+ return action.href ? (
76
+ <DropdownMenu.Item
77
+ key={action.label}
78
+ asChild
79
+ disabled={action.disabled}
80
+ >
81
+ {renderLink({
82
+ href: action.href,
83
+ className: cls,
84
+ children: (
85
+ <>
86
+ {action.icon}
87
+ {action.label}
88
+ </>
89
+ ),
90
+ })}
91
+ </DropdownMenu.Item>
92
+ ) : (
93
+ <DropdownMenu.Item
94
+ key={action.label}
95
+ className={cls}
96
+ disabled={action.disabled}
97
+ onClick={action.onClick}
98
+ >
99
+ {action.icon}
100
+ {action.label}
101
+ </DropdownMenu.Item>
102
+ )
103
+ })}
104
+ </DropdownMenu.Content>
105
+ </DropdownMenu.Portal>
106
+ </DropdownMenu.Root>
107
+ )
108
+ }
109
+
110
+ // Mobile: action sheet (Drawer)
37
111
  return (
38
- <DropdownMenu.Root>
39
- <DropdownMenu.Trigger asChild>
40
- <button
41
- type="button"
42
- disabled={disabled}
43
- aria-label={label}
44
- className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
45
- >
46
- <MoreVertical className="h-4 w-4" />
47
- </button>
48
- </DropdownMenu.Trigger>
49
- <DropdownMenu.Portal>
50
- <DropdownMenu.Content
51
- align={align}
52
- sideOffset={6}
53
- className="z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
54
- >
112
+ <DrawerPrimitive.Root open={open} onOpenChange={setOpen}>
113
+ <DrawerPrimitive.Trigger asChild>{trigger}</DrawerPrimitive.Trigger>
114
+ <DrawerPrimitive.Portal>
115
+ <DrawerPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80" />
116
+ <DrawerPrimitive.Content className="fixed inset-x-0 bottom-0 z-50 flex flex-col rounded-t-2xl border bg-popover p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] text-popover-foreground outline-none">
117
+ <DrawerPrimitive.Title className="sr-only">
118
+ {label}
119
+ </DrawerPrimitive.Title>
120
+ <div className="mx-auto my-3 h-1.5 w-12 shrink-0 rounded-full bg-muted" />
55
121
  {actions.map((action) => {
56
- const cls = cn(itemClass, action.destructive && "text-destructive focus:bg-destructive/10")
122
+ const cls = cn(
123
+ sheetItemClass,
124
+ action.destructive && "text-destructive",
125
+ )
126
+ const inner = (
127
+ <>
128
+ {action.icon}
129
+ {action.label}
130
+ </>
131
+ )
57
132
  return action.href ? (
58
- <DropdownMenu.Item key={action.label} asChild disabled={action.disabled}>
133
+ <span
134
+ key={action.label}
135
+ onClick={() => setOpen(false)}
136
+ onKeyDown={() => setOpen(false)}
137
+ >
59
138
  {renderLink({
60
139
  href: action.href,
61
140
  className: cls,
62
- children: (
63
- <>
64
- {action.icon}
65
- {action.label}
66
- </>
67
- ),
141
+ children: inner,
68
142
  })}
69
- </DropdownMenu.Item>
143
+ </span>
70
144
  ) : (
71
- <DropdownMenu.Item
145
+ <button
72
146
  key={action.label}
73
- className={cls}
147
+ type="button"
74
148
  disabled={action.disabled}
75
- onClick={action.onClick}
149
+ className={cls}
150
+ onClick={() => {
151
+ action.onClick?.()
152
+ setOpen(false)
153
+ }}
76
154
  >
77
- {action.icon}
78
- {action.label}
79
- </DropdownMenu.Item>
155
+ {inner}
156
+ </button>
80
157
  )
81
158
  })}
82
- </DropdownMenu.Content>
83
- </DropdownMenu.Portal>
84
- </DropdownMenu.Root>
159
+ </DrawerPrimitive.Content>
160
+ </DrawerPrimitive.Portal>
161
+ </DrawerPrimitive.Root>
85
162
  )
86
163
  }
@@ -1,6 +1,8 @@
1
1
  import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
2
2
  import { LogOut, User } from "lucide-react"
3
- import type { ReactNode } from "react"
3
+ import { type ReactNode, useState } from "react"
4
+ import { Drawer as DrawerPrimitive } from "vaul"
5
+ import { useIsDesktop } from "../hooks/use-is-desktop"
4
6
  import { cn } from "../lib/utils"
5
7
  import type { RenderLink } from "./types"
6
8
  import { defaultRenderLink } from "./types"
@@ -37,6 +39,8 @@ function initials(user?: AppUser): string {
37
39
 
38
40
  const itemClass =
39
41
  "flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent focus:bg-accent"
42
+ const sheetItemClass =
43
+ "flex w-full cursor-pointer select-none items-center gap-3 rounded-md px-3 py-3 text-base outline-none transition-colors hover:bg-accent active:bg-accent"
40
44
 
41
45
  export function UserMenu({
42
46
  user,
@@ -46,71 +50,176 @@ export function UserMenu({
46
50
  renderLink = defaultRenderLink,
47
51
  align = "end",
48
52
  }: UserMenuProps) {
53
+ const isDesktop = useIsDesktop()
54
+ const [open, setOpen] = useState(false)
49
55
  const label = initials(user)
50
56
 
57
+ const trigger = (
58
+ <button
59
+ type="button"
60
+ className="inline-flex h-9 w-9 items-center justify-center overflow-hidden rounded-full border border-input bg-background text-xs font-semibold shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
61
+ aria-label="Menu do usuário"
62
+ data-testid="user-menu"
63
+ >
64
+ {user?.avatarUrl ? (
65
+ <img
66
+ src={user.avatarUrl}
67
+ alt={user.name ?? user.email ?? "Avatar"}
68
+ className="h-full w-full object-cover"
69
+ />
70
+ ) : label ? (
71
+ <span>{label}</span>
72
+ ) : (
73
+ <User className="h-4 w-4" />
74
+ )}
75
+ </button>
76
+ )
77
+
78
+ const userInfo = (user?.name || user?.email) && (
79
+ <div className="px-2 py-1.5">
80
+ {user.name && (
81
+ <p className="truncate text-sm font-medium">{user.name}</p>
82
+ )}
83
+ {user.email && (
84
+ <p className="truncate text-xs text-muted-foreground">{user.email}</p>
85
+ )}
86
+ </div>
87
+ )
88
+
89
+ if (isDesktop) {
90
+ return (
91
+ <DropdownMenu.Root open={open} onOpenChange={setOpen}>
92
+ <DropdownMenu.Trigger asChild>{trigger}</DropdownMenu.Trigger>
93
+ <DropdownMenu.Portal>
94
+ <DropdownMenu.Content
95
+ align={align}
96
+ sideOffset={6}
97
+ className="z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
98
+ >
99
+ {userInfo && (
100
+ <>
101
+ {userInfo}
102
+ <DropdownMenu.Separator className="-mx-1 my-1 h-px bg-border" />
103
+ </>
104
+ )}
105
+
106
+ {items.map((item) =>
107
+ item.href ? (
108
+ <DropdownMenu.Item key={item.label} asChild>
109
+ {renderLink({
110
+ href: item.href,
111
+ className: itemClass,
112
+ children: (
113
+ <>
114
+ {item.icon}
115
+ {item.label}
116
+ </>
117
+ ),
118
+ })}
119
+ </DropdownMenu.Item>
120
+ ) : (
121
+ <DropdownMenu.Item
122
+ key={item.label}
123
+ className={itemClass}
124
+ onClick={item.onClick}
125
+ >
126
+ {item.icon}
127
+ {item.label}
128
+ </DropdownMenu.Item>
129
+ ),
130
+ )}
131
+
132
+ {onLogout && (
133
+ <>
134
+ {items.length > 0 && (
135
+ <DropdownMenu.Separator className="-mx-1 my-1 h-px bg-border" />
136
+ )}
137
+ <DropdownMenu.Item
138
+ className={cn(
139
+ itemClass,
140
+ "text-destructive focus:bg-destructive/10",
141
+ )}
142
+ onClick={onLogout}
143
+ >
144
+ <LogOut className="h-4 w-4" />
145
+ {logoutLabel}
146
+ </DropdownMenu.Item>
147
+ </>
148
+ )}
149
+ </DropdownMenu.Content>
150
+ </DropdownMenu.Portal>
151
+ </DropdownMenu.Root>
152
+ )
153
+ }
154
+
155
+ // Mobile: bottom sheet (Drawer)
51
156
  return (
52
- <DropdownMenu.Root>
53
- <DropdownMenu.Trigger asChild>
54
- <button
55
- type="button"
56
- className="inline-flex h-9 w-9 items-center justify-center overflow-hidden rounded-full border border-input bg-background text-xs font-semibold shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
57
- aria-label="Menu do usuário"
58
- data-testid="user-menu"
59
- >
60
- {user?.avatarUrl ? (
61
- <img src={user.avatarUrl} alt={user.name ?? user.email ?? "Avatar"} className="h-full w-full object-cover" />
62
- ) : label ? (
63
- <span>{label}</span>
64
- ) : (
65
- <User className="h-4 w-4" />
66
- )}
67
- </button>
68
- </DropdownMenu.Trigger>
69
- <DropdownMenu.Portal>
70
- <DropdownMenu.Content
71
- align={align}
72
- sideOffset={6}
73
- className="z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
74
- >
75
- {(user?.name || user?.email) && (
157
+ <DrawerPrimitive.Root open={open} onOpenChange={setOpen}>
158
+ <DrawerPrimitive.Trigger asChild>{trigger}</DrawerPrimitive.Trigger>
159
+ <DrawerPrimitive.Portal>
160
+ <DrawerPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80" />
161
+ <DrawerPrimitive.Content className="fixed inset-x-0 bottom-0 z-50 flex flex-col rounded-t-2xl border bg-popover p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] text-popover-foreground outline-none">
162
+ <DrawerPrimitive.Title className="sr-only">
163
+ Menu do usuário
164
+ </DrawerPrimitive.Title>
165
+ <div className="mx-auto my-3 h-1.5 w-12 shrink-0 rounded-full bg-muted" />
166
+ {userInfo && (
76
167
  <>
77
- <div className="px-2 py-1.5">
78
- {user.name && <p className="truncate text-sm font-medium">{user.name}</p>}
79
- {user.email && <p className="truncate text-xs text-muted-foreground">{user.email}</p>}
80
- </div>
81
- <DropdownMenu.Separator className="-mx-1 my-1 h-px bg-border" />
168
+ {userInfo}
169
+ <div className="-mx-1 my-1 h-px bg-border" />
82
170
  </>
83
171
  )}
84
-
85
172
  {items.map((item) =>
86
173
  item.href ? (
87
- <DropdownMenu.Item key={item.label} asChild>
88
- {renderLink({ href: item.href, className: itemClass, children: (
89
- <>
90
- {item.icon}
91
- {item.label}
92
- </>
93
- ) })}
94
- </DropdownMenu.Item>
174
+ <span
175
+ key={item.label}
176
+ onClick={() => setOpen(false)}
177
+ onKeyDown={() => setOpen(false)}
178
+ >
179
+ {renderLink({
180
+ href: item.href,
181
+ className: sheetItemClass,
182
+ children: (
183
+ <>
184
+ {item.icon}
185
+ {item.label}
186
+ </>
187
+ ),
188
+ })}
189
+ </span>
95
190
  ) : (
96
- <DropdownMenu.Item key={item.label} className={itemClass} onClick={item.onClick}>
191
+ <button
192
+ key={item.label}
193
+ type="button"
194
+ className={sheetItemClass}
195
+ onClick={() => {
196
+ item.onClick?.()
197
+ setOpen(false)
198
+ }}
199
+ >
97
200
  {item.icon}
98
201
  {item.label}
99
- </DropdownMenu.Item>
202
+ </button>
100
203
  ),
101
204
  )}
102
-
103
205
  {onLogout && (
104
206
  <>
105
- {items.length > 0 && <DropdownMenu.Separator className="-mx-1 my-1 h-px bg-border" />}
106
- <DropdownMenu.Item className={cn(itemClass, "text-destructive focus:bg-destructive/10")} onClick={onLogout}>
207
+ {items.length > 0 && <div className="-mx-1 my-1 h-px bg-border" />}
208
+ <button
209
+ type="button"
210
+ className={cn(sheetItemClass, "text-destructive")}
211
+ onClick={() => {
212
+ onLogout()
213
+ setOpen(false)
214
+ }}
215
+ >
107
216
  <LogOut className="h-4 w-4" />
108
217
  {logoutLabel}
109
- </DropdownMenu.Item>
218
+ </button>
110
219
  </>
111
220
  )}
112
- </DropdownMenu.Content>
113
- </DropdownMenu.Portal>
114
- </DropdownMenu.Root>
221
+ </DrawerPrimitive.Content>
222
+ </DrawerPrimitive.Portal>
223
+ </DrawerPrimitive.Root>
115
224
  )
116
225
  }
@@ -0,0 +1,26 @@
1
+ import * as React from "react"
2
+
3
+ const DESKTOP_QUERY = "(min-width: 640px)"
4
+
5
+ /**
6
+ * Retorna `true` no desktop (>= 640px, breakpoint `sm`) e `false` no mobile.
7
+ * Usado para alternar Popover/DropdownMenu (desktop) por Drawer/bottom sheet
8
+ * (mobile) em componentes responsivos como Combobox e os menus de ação.
9
+ */
10
+ export function useIsDesktop(): boolean {
11
+ const [isDesktop, setIsDesktop] = React.useState(() =>
12
+ typeof window !== "undefined"
13
+ ? window.matchMedia(DESKTOP_QUERY).matches
14
+ : true,
15
+ )
16
+
17
+ React.useEffect(() => {
18
+ const mql = window.matchMedia(DESKTOP_QUERY)
19
+ const onChange = () => setIsDesktop(mql.matches)
20
+ onChange()
21
+ mql.addEventListener("change", onChange)
22
+ return () => mql.removeEventListener("change", onChange)
23
+ }, [])
24
+
25
+ return isDesktop
26
+ }
package/src/index.ts CHANGED
@@ -45,4 +45,5 @@ export { extractApiError } from "./lib/api-error"
45
45
 
46
46
  // Hooks
47
47
  export { useDisclosure } from "./hooks/use-disclosure"
48
+ export { useIsDesktop } from "./hooks/use-is-desktop"
48
49
  export { useCustomToast, toast } from "./hooks/use-toast"