@motor-hero/ui-kit 0.44.0 → 0.45.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.44.0",
3
+ "version": "0.45.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",
@@ -93,11 +93,26 @@ export function Combobox({
93
93
  const [search, setSearch] = React.useState("");
94
94
  const isDesktop = useIsDesktop();
95
95
  const isServer = onSearchChange !== undefined;
96
- const selected =
96
+ // Mesmo cache do multi-combobox: em modo servidor `options` é só a página
97
+ // carregada, então o rótulo do valor escolhido precisa sobreviver à troca de
98
+ // página — senão o gatilho volta ao placeholder com o valor ainda setado.
99
+ const labelCache = React.useRef(new Map<string, ComboboxOption>());
100
+ const fromPage =
97
101
  selectedOption?.value === value
98
102
  ? selectedOption
99
- : (options.find((option) => option.value === value) ??
100
- (creatable && value ? { value, label: value } : undefined));
103
+ : options.find((option) => option.value === value);
104
+
105
+ // No efeito, nunca no render — mutar a ref durante o render é impuro.
106
+ React.useEffect(() => {
107
+ for (const option of options) labelCache.current.set(option.value, option);
108
+ if (selectedOption)
109
+ labelCache.current.set(selectedOption.value, selectedOption);
110
+ });
111
+
112
+ const selected =
113
+ fromPage ??
114
+ (value ? labelCache.current.get(value) : undefined) ??
115
+ (creatable && value ? { value, label: value } : undefined);
101
116
 
102
117
  const handleOpenChange = (next: boolean) => {
103
118
  setOpen(next);
@@ -1,6 +1,6 @@
1
1
  import * as PopoverPrimitive from "@radix-ui/react-popover"
2
2
  import { InputMask } from "@react-input/mask"
3
- import { endOfYear, format, isValid, parse, subYears } from "date-fns"
3
+ import { addYears, endOfYear, format, isValid, parse, subYears } from "date-fns"
4
4
  import { ptBR } from "date-fns/locale"
5
5
  import { CalendarIcon, ChevronDown, ChevronLeft, ChevronRight, Clock } from "lucide-react"
6
6
  import * as React from "react"
@@ -171,12 +171,15 @@ function DatePickerImpl({
171
171
  const committedDate = withTime ? dateTimeToDate(value) : dateOnlyToDate(value)
172
172
  const displayValue = displayFor(value, withTime)
173
173
  const matchers = toMatchers(minDate, maxDate, disabledDates)
174
- // Alcance do dropdown de ano: minDate/maxDate quando informados, senão o mesmo
175
- // padrão da lib (100 anos atrás -> fim do ano atual) — calculado explícito nos dois
176
- // lados porque passar só um dos dois via prop faz a lib truncar o outro (visto na
177
- // prática: só uma opção de ano no lado sem prop), em vez de cair no padrão dela.
174
+ // Alcance do dropdown de ano: minDate/maxDate quando informados; senão,
175
+ // calculado explícito nos dois lados porque passar só um dos dois via prop
176
+ // faz a lib truncar o outro em vez de cair no próprio padrão dela (visto na
177
+ // prática: só uma opção de ano no lado sem prop). Do lado do fim, não usamos
178
+ // o padrão da lib (fim do ano corrente) — vencimento de campanha, validade
179
+ // de premiação e agendamentos legitimamente caem anos à frente; +20 anos
180
+ // evita recriar esse teto sem abrir um intervalo sem sentido.
178
181
  const startMonth = dateOnlyToDate(minDate) ?? subYears(new Date(), 100)
179
- const endMonth = dateOnlyToDate(maxDate) ?? endOfYear(new Date())
182
+ const endMonth = dateOnlyToDate(maxDate) ?? endOfYear(addYears(new Date(), 20))
180
183
 
181
184
  const setOpen = (next: boolean) => {
182
185
  setOpenRaw(next)
@@ -73,14 +73,30 @@ export function MultiCombobox({
73
73
  const [open, setOpen] = React.useState(false)
74
74
  const isDesktop = useIsDesktop()
75
75
  const isServer = onSearchChange !== undefined
76
- // Chips a partir de selectedOptions (rótulos salvos) + página atual, dedup
77
- // por value (primeira ocorrência vence → o rótulo salvo prevalece).
78
- const seen = new Set<string>()
79
- const selected = [...(selectedOptions ?? []), ...options].filter((option) => {
80
- if (!value.includes(option.value) || seen.has(option.value)) return false
81
- seen.add(option.value)
82
- return true
76
+ // Em modo servidor `options` é só a página carregada no momento, então um
77
+ // rótulo visto uma vez precisa sobreviver à troca de página: sem esse cache,
78
+ // buscar (ou paginar) fazia o chip de um valor ainda selecionado sumir do
79
+ // gatilho, como se nada estivesse escolhido. Prioridade de rótulo:
80
+ // `selectedOptions` (o salvo) → página atual → cache.
81
+ const labelCache = React.useRef(new Map<string, ComboboxOption>())
82
+ const byValue = new Map<string, ComboboxOption>()
83
+ for (const option of options) byValue.set(option.value, option)
84
+ for (const option of selectedOptions ?? []) byValue.set(option.value, option)
85
+
86
+ // Escrita no efeito, nunca no render — mutar a ref durante o render é impuro.
87
+ // Não pisca: no render em que a opção fica selecionável ela ainda está em
88
+ // `options`, e este efeito a grava antes de `options` mudar.
89
+ React.useEffect(() => {
90
+ for (const [key, option] of byValue) labelCache.current.set(key, option)
83
91
  })
92
+
93
+ // Ordem de seleção (`value`), que é estável — a antiga vinha da ordem da
94
+ // página carregada e mudava sozinha conforme as páginas chegavam. Um valor
95
+ // sem rótulo conhecido (edição sem `selectedOptions`) segue sem chip.
96
+ const selected = value
97
+ .map((v) => byValue.get(v) ?? labelCache.current.get(v))
98
+ .filter((option): option is ComboboxOption => option !== undefined)
99
+
84
100
  const { listRef, sentinelRef } = useInfiniteScrollSentinel({
85
101
  enabled: isServer && !!hasMore,
86
102
  loading,
@@ -1,10 +1,19 @@
1
1
  import * as React from "react"
2
2
 
3
3
  /**
4
- * Dispara `onLoadMore` quando o `sentinelRef` entra na viewport do `listRef`
5
- * (raiz = a lista rolável, não a janela — a lista rola por dentro do Popover/
6
- * Drawer). Usa refs "latest" para `loading`/`onLoadMore` e evita reassinar o
7
- * observer a cada render.
4
+ * Dispara `onLoadMore` quando o sentinela entra na viewport da lista rolável
5
+ * (raiz = a lista, não a janela — ela rola por dentro do Popover/Drawer). Usa
6
+ * refs "latest" para `loading`/`onLoadMore` e não reassina o observer a cada
7
+ * render.
8
+ *
9
+ * Os nós entram como **estado** (callback refs), não como `useRef`: lista e
10
+ * sentinela vivem dentro do Portal do Popover/Drawer e só existem no DOM com o
11
+ * dropdown aberto, o que acontece bem depois de `enabled` virar `true`. Com
12
+ * `useRef` + deps `[enabled]` o efeito rodava uma única vez, com o dropdown
13
+ * fechado e os dois refs `null`, e o observer nunca chegava a ser registrado —
14
+ * a paginação por scroll simplesmente não acontecia. Guardar os nós em estado
15
+ * também refaz a assinatura quando o sentinela desmonta e remonta (`hasMore`
16
+ * oscilando), em vez de deixar o observer preso a um nó já destacado.
8
17
  */
9
18
  export function useInfiniteScrollSentinel({
10
19
  enabled,
@@ -15,21 +24,29 @@ export function useInfiniteScrollSentinel({
15
24
  loading?: boolean
16
25
  onLoadMore?: () => void
17
26
  }) {
18
- const listRef = React.useRef<HTMLDivElement | null>(null)
19
- const sentinelRef = React.useRef<HTMLDivElement | null>(null)
27
+ const [listNode, setListNode] = React.useState<HTMLDivElement | null>(null)
28
+ const [sentinelNode, setSentinelNode] = React.useState<HTMLDivElement | null>(
29
+ null,
30
+ )
20
31
  const loadingRef = React.useRef(loading)
21
32
  const onLoadMoreRef = React.useRef(onLoadMore)
22
33
 
34
+ // Memoizadas de propósito: uma callback ref recriada a cada render é chamada
35
+ // com `null` e de novo com o nó em *todo* render, realimentando o estado.
36
+ const listRef = React.useCallback((node: HTMLDivElement | null) => {
37
+ setListNode(node)
38
+ }, [])
39
+ const sentinelRef = React.useCallback((node: HTMLDivElement | null) => {
40
+ setSentinelNode(node)
41
+ }, [])
42
+
23
43
  React.useEffect(() => {
24
44
  loadingRef.current = loading
25
45
  onLoadMoreRef.current = onLoadMore
26
46
  })
27
47
 
28
48
  React.useEffect(() => {
29
- if (!enabled) return
30
- const root = listRef.current
31
- const sentinel = sentinelRef.current
32
- if (!root || !sentinel) return
49
+ if (!enabled || !listNode || !sentinelNode) return
33
50
 
34
51
  const observer = new IntersectionObserver(
35
52
  (entries) => {
@@ -37,11 +54,11 @@ export function useInfiniteScrollSentinel({
37
54
  onLoadMoreRef.current?.()
38
55
  }
39
56
  },
40
- { root, rootMargin: "0px 0px 100px 0px" },
57
+ { root: listNode, rootMargin: "0px 0px 100px 0px" },
41
58
  )
42
- observer.observe(sentinel)
59
+ observer.observe(sentinelNode)
43
60
  return () => observer.disconnect()
44
- }, [enabled])
61
+ }, [enabled, listNode, sentinelNode])
45
62
 
46
63
  return { listRef, sentinelRef }
47
64
  }