@motor-hero/ui-kit 0.16.0 → 0.17.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motor-hero/ui-kit",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Componentes React reutilizáveis para o ecossistema MotorHero — shadcn/ui + Tailwind CSS v4",
5
5
  "author": "MotorHero",
6
6
  "license": "UNLICENSED",
@@ -62,7 +62,8 @@
62
62
  "@radix-ui/react-slot": "^1.1.0",
63
63
  "class-variance-authority": "^0.7.0",
64
64
  "cmdk": "^1.1.1",
65
- "sonner": "^2.0.7"
65
+ "sonner": "^2.0.7",
66
+ "vaul": "^1.1.2"
66
67
  },
67
68
  "devDependencies": {
68
69
  "@commitlint/cli": "^20.5.3",
@@ -2,6 +2,7 @@ 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"
5
6
  import { cn } from "../lib/utils"
6
7
 
7
8
  export interface ComboboxOption {
@@ -13,9 +14,12 @@ export interface ComboboxOption {
13
14
  export interface ComboboxProps {
14
15
  options: ComboboxOption[]
15
16
  value?: string
16
- onChange: (value: string) => void
17
- placeholder?: string
18
- searchPlaceholder?: string
17
+ onChange: (value: string) => void
18
+ /** Texto do gatilho quando nada está selecionado */
19
+ placeholder?: string
20
+ /** Placeholder do campo de busca */
21
+ searchPlaceholder?: string
22
+ /** Mensagem quando a busca não retorna nada */
19
23
  emptyMessage?: string
20
24
  disabled?: boolean
21
25
  id?: string
@@ -24,6 +28,36 @@ export interface ComboboxProps {
24
28
  "aria-describedby"?: string
25
29
  }
26
30
 
31
+ const DESKTOP_QUERY = "(min-width: 640px)"
32
+
33
+ function useIsDesktop() {
34
+ const [isDesktop, setIsDesktop] = React.useState(() =>
35
+ typeof window !== "undefined"
36
+ ? window.matchMedia(DESKTOP_QUERY).matches
37
+ : true,
38
+ )
39
+
40
+ React.useEffect(() => {
41
+ const mql = window.matchMedia(DESKTOP_QUERY)
42
+ const onChange = () => setIsDesktop(mql.matches)
43
+ onChange()
44
+ mql.addEventListener("change", onChange)
45
+ return () => mql.removeEventListener("change", onChange)
46
+ }, [])
47
+
48
+ return isDesktop
49
+ }
50
+
51
+ /**
52
+ * Select com busca (combobox) acessível e responsivo.
53
+ *
54
+ * - Desktop: Popover (modal) + cmdk — o dropdown acompanha a largura do gatilho.
55
+ * - Mobile: Drawer (bottom sheet) + cmdk — evita os problemas de foco/clique de
56
+ * Popover dentro de modal no touch, então a busca funciona no celular.
57
+ *
58
+ * Aceita `aria-invalid`/`aria-describedby` (encaminhados pelo FormField em
59
+ * estado de erro) e fica com a borda vermelha quando inválido.
60
+ */
27
61
  export function Combobox({
28
62
  options,
29
63
  value,
@@ -38,75 +72,98 @@ export function Combobox({
38
72
  "aria-describedby": ariaDescribedby,
39
73
  }: ComboboxProps) {
40
74
  const [open, setOpen] = React.useState(false)
75
+ const isDesktop = useIsDesktop()
41
76
  const selected = options.find((option) => option.value === value)
42
77
 
43
- return (
44
- <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
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")}
78
+ const trigger = (
79
+ <button
80
+ type="button"
81
+ id={id}
82
+ role="combobox"
83
+ aria-expanded={open}
84
+ aria-invalid={ariaInvalid}
85
+ aria-describedby={ariaDescribedby}
86
+ disabled={disabled}
87
+ className={cn(
88
+ "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",
89
+ className,
90
+ )}
91
+ >
92
+ <span className={cn("truncate", !selected && "text-muted-foreground")}>
93
+ {selected ? selected.label : placeholder}
94
+ </span>
95
+ <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
96
+ </button>
97
+ )
98
+
99
+ const command = (
100
+ <CommandPrimitive className="flex h-full w-full flex-col overflow-hidden">
101
+ <div className="flex items-center border-b px-3">
102
+ <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
103
+ <CommandPrimitive.Input
104
+ placeholder={searchPlaceholder}
105
+ className="flex h-11 w-full bg-transparent py-2 text-base outline-none placeholder:text-muted-foreground sm:h-9 sm:text-sm"
106
+ />
107
+ </div>
108
+ <CommandPrimitive.List className="max-h-[50vh] overflow-y-auto overflow-x-hidden p-1 sm:max-h-60">
109
+ <CommandPrimitive.Empty className="py-6 text-center text-sm text-muted-foreground">
110
+ {emptyMessage}
111
+ </CommandPrimitive.Empty>
112
+ {options.map((option) => (
113
+ <CommandPrimitive.Item
114
+ key={option.value}
115
+ value={option.label}
116
+ disabled={option.disabled}
117
+ onSelect={() => {
118
+ onChange(option.value)
119
+ setOpen(false)
120
+ }}
121
+ 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"
61
122
  >
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>
123
+ <Check
124
+ className={cn(
125
+ "h-4 w-4 shrink-0",
126
+ value === option.value ? "opacity-100" : "opacity-0",
127
+ )}
128
+ />
129
+ <span className="truncate">{option.label}</span>
130
+ </CommandPrimitive.Item>
131
+ ))}
132
+ </CommandPrimitive.List>
133
+ </CommandPrimitive>
134
+ )
135
+
136
+ if (isDesktop) {
137
+ return (
138
+ <PopoverPrimitive.Root open={open} onOpenChange={setOpen} modal>
139
+ <PopoverPrimitive.Trigger asChild>{trigger}</PopoverPrimitive.Trigger>
140
+ <PopoverPrimitive.Portal>
141
+ <PopoverPrimitive.Content
142
+ align="start"
143
+ sideOffset={4}
144
+ className="z-50 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-md"
145
+ style={{ width: "var(--radix-popover-trigger-width)" }}
146
+ >
147
+ {command}
148
+ </PopoverPrimitive.Content>
149
+ </PopoverPrimitive.Portal>
150
+ </PopoverPrimitive.Root>
151
+ )
152
+ }
153
+
154
+ return (
155
+ <DrawerPrimitive.Root open={open} onOpenChange={setOpen}>
156
+ <DrawerPrimitive.Trigger asChild>{trigger}</DrawerPrimitive.Trigger>
157
+ <DrawerPrimitive.Portal>
158
+ <DrawerPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80" />
159
+ <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">
160
+ <DrawerPrimitive.Title className="sr-only">
161
+ {placeholder}
162
+ </DrawerPrimitive.Title>
163
+ <div className="mx-auto my-3 h-1.5 w-12 shrink-0 rounded-full bg-muted" />
164
+ {command}
165
+ </DrawerPrimitive.Content>
166
+ </DrawerPrimitive.Portal>
167
+ </DrawerPrimitive.Root>
111
168
  )
112
169
  }