@motor-hero/ui-kit 0.16.1 → 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.
@@ -2,6 +2,8 @@ import * as PopoverPrimitive from "@radix-ui/react-popover"
2
2
  import { Command as CommandPrimitive } from "cmdk"
3
3
  import { Check, ChevronsUpDown, Search } from "lucide-react"
4
4
  import * as React from "react"
5
+ import { Drawer as DrawerPrimitive } from "vaul"
6
+ import { useIsDesktop } from "../hooks/use-is-desktop"
5
7
  import { cn } from "../lib/utils"
6
8
 
7
9
  export interface ComboboxOption {
@@ -13,9 +15,12 @@ export interface ComboboxOption {
13
15
  export interface ComboboxProps {
14
16
  options: ComboboxOption[]
15
17
  value?: string
16
- onChange: (value: string) => void
17
- placeholder?: string
18
- searchPlaceholder?: string
18
+ onChange: (value: string) => void
19
+ /** Texto do gatilho quando nada está selecionado */
20
+ placeholder?: string
21
+ /** Placeholder do campo de busca */
22
+ searchPlaceholder?: string
23
+ /** Mensagem quando a busca não retorna nada */
19
24
  emptyMessage?: string
20
25
  disabled?: boolean
21
26
  id?: string
@@ -24,6 +29,16 @@ export interface ComboboxProps {
24
29
  "aria-describedby"?: string
25
30
  }
26
31
 
32
+ /**
33
+ * Select com busca (combobox) acessível e responsivo.
34
+ *
35
+ * - Desktop: Popover (modal) + cmdk — o dropdown acompanha a largura do gatilho.
36
+ * - Mobile: Drawer (bottom sheet) + cmdk — evita os problemas de foco/clique de
37
+ * Popover dentro de modal no touch, então a busca funciona no celular.
38
+ *
39
+ * Aceita `aria-invalid`/`aria-describedby` (encaminhados pelo FormField em
40
+ * estado de erro) e fica com a borda vermelha quando inválido.
41
+ */
27
42
  export function Combobox({
28
43
  options,
29
44
  value,
@@ -38,75 +53,98 @@ export function Combobox({
38
53
  "aria-describedby": ariaDescribedby,
39
54
  }: ComboboxProps) {
40
55
  const [open, setOpen] = React.useState(false)
56
+ const isDesktop = useIsDesktop()
41
57
  const selected = options.find((option) => option.value === value)
42
58
 
43
- return (
44
- <PopoverPrimitive.Root open={open} onOpenChange={setOpen} modal>
45
- <PopoverPrimitive.Trigger asChild>
46
- <button
47
- type="button"
48
- id={id}
49
- role="combobox"
50
- aria-expanded={open}
51
- aria-invalid={ariaInvalid}
52
- aria-describedby={ariaDescribedby}
53
- disabled={disabled}
54
- className={cn(
55
- "flex h-9 w-full cursor-pointer items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:focus-visible:ring-destructive",
56
- className,
57
- )}
58
- >
59
- <span
60
- className={cn("truncate", !selected && "text-muted-foreground")}
59
+ const trigger = (
60
+ <button
61
+ type="button"
62
+ id={id}
63
+ role="combobox"
64
+ aria-expanded={open}
65
+ aria-invalid={ariaInvalid}
66
+ aria-describedby={ariaDescribedby}
67
+ disabled={disabled}
68
+ className={cn(
69
+ "flex h-9 w-full cursor-pointer items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:focus-visible:ring-destructive",
70
+ className,
71
+ )}
72
+ >
73
+ <span className={cn("truncate", !selected && "text-muted-foreground")}>
74
+ {selected ? selected.label : placeholder}
75
+ </span>
76
+ <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
77
+ </button>
78
+ )
79
+
80
+ const command = (
81
+ <CommandPrimitive className="flex h-full w-full flex-col overflow-hidden">
82
+ <div className="flex items-center border-b px-3">
83
+ <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
84
+ <CommandPrimitive.Input
85
+ placeholder={searchPlaceholder}
86
+ className="flex h-11 w-full bg-transparent py-2 text-base outline-none placeholder:text-muted-foreground sm:h-9 sm:text-sm"
87
+ />
88
+ </div>
89
+ <CommandPrimitive.List className="max-h-[50vh] overflow-y-auto overflow-x-hidden p-1 sm:max-h-60">
90
+ <CommandPrimitive.Empty className="py-6 text-center text-sm text-muted-foreground">
91
+ {emptyMessage}
92
+ </CommandPrimitive.Empty>
93
+ {options.map((option) => (
94
+ <CommandPrimitive.Item
95
+ key={option.value}
96
+ value={option.label}
97
+ disabled={option.disabled}
98
+ onSelect={() => {
99
+ onChange(option.value)
100
+ setOpen(false)
101
+ }}
102
+ className="relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-2.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 sm:py-1.5"
103
+ >
104
+ <Check
105
+ className={cn(
106
+ "h-4 w-4 shrink-0",
107
+ value === option.value ? "opacity-100" : "opacity-0",
108
+ )}
109
+ />
110
+ <span className="truncate">{option.label}</span>
111
+ </CommandPrimitive.Item>
112
+ ))}
113
+ </CommandPrimitive.List>
114
+ </CommandPrimitive>
115
+ )
116
+
117
+ if (isDesktop) {
118
+ return (
119
+ <PopoverPrimitive.Root open={open} onOpenChange={setOpen} modal>
120
+ <PopoverPrimitive.Trigger asChild>{trigger}</PopoverPrimitive.Trigger>
121
+ <PopoverPrimitive.Portal>
122
+ <PopoverPrimitive.Content
123
+ align="start"
124
+ sideOffset={4}
125
+ className="z-50 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-md"
126
+ style={{ width: "var(--radix-popover-trigger-width)" }}
61
127
  >
62
- {selected ? selected.label : placeholder}
63
- </span>
64
- <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
65
- </button>
66
- </PopoverPrimitive.Trigger>
67
- <PopoverPrimitive.Portal>
68
- <PopoverPrimitive.Content
69
- align="start"
70
- sideOffset={4}
71
- className="z-50 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-md"
72
- style={{ width: "var(--radix-popover-trigger-width)" }}
73
- >
74
- <CommandPrimitive className="flex h-full w-full flex-col overflow-hidden">
75
- <div className="flex items-center border-b px-3">
76
- <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
77
- <CommandPrimitive.Input
78
- placeholder={searchPlaceholder}
79
- className="flex h-9 w-full bg-transparent py-2 text-sm outline-none placeholder:text-muted-foreground"
80
- />
81
- </div>
82
- <CommandPrimitive.List className="max-h-60 overflow-y-auto overflow-x-hidden p-1">
83
- <CommandPrimitive.Empty className="py-6 text-center text-sm text-muted-foreground">
84
- {emptyMessage}
85
- </CommandPrimitive.Empty>
86
- {options.map((option) => (
87
- <CommandPrimitive.Item
88
- key={option.value}
89
- value={option.label}
90
- disabled={option.disabled}
91
- onSelect={() => {
92
- onChange(option.value)
93
- setOpen(false)
94
- }}
95
- className="relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50"
96
- >
97
- <Check
98
- className={cn(
99
- "h-4 w-4 shrink-0",
100
- value === option.value ? "opacity-100" : "opacity-0",
101
- )}
102
- />
103
- <span className="truncate">{option.label}</span>
104
- </CommandPrimitive.Item>
105
- ))}
106
- </CommandPrimitive.List>
107
- </CommandPrimitive>
108
- </PopoverPrimitive.Content>
109
- </PopoverPrimitive.Portal>
110
- </PopoverPrimitive.Root>
128
+ {command}
129
+ </PopoverPrimitive.Content>
130
+ </PopoverPrimitive.Portal>
131
+ </PopoverPrimitive.Root>
132
+ )
133
+ }
134
+
135
+ return (
136
+ <DrawerPrimitive.Root open={open} onOpenChange={setOpen}>
137
+ <DrawerPrimitive.Trigger asChild>{trigger}</DrawerPrimitive.Trigger>
138
+ <DrawerPrimitive.Portal>
139
+ <DrawerPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80" />
140
+ <DrawerPrimitive.Content className="fixed inset-x-0 bottom-0 z-50 mt-24 flex max-h-[85vh] flex-col rounded-t-2xl border bg-popover text-popover-foreground outline-none">
141
+ <DrawerPrimitive.Title className="sr-only">
142
+ {placeholder}
143
+ </DrawerPrimitive.Title>
144
+ <div className="mx-auto my-3 h-1.5 w-12 shrink-0 rounded-full bg-muted" />
145
+ {command}
146
+ </DrawerPrimitive.Content>
147
+ </DrawerPrimitive.Portal>
148
+ </DrawerPrimitive.Root>
111
149
  )
112
150
  }
@@ -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"