@payglocal_ui/flux-ui 0.3.2 → 0.3.4

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.
@@ -4,6 +4,7 @@ import * as React from "react";
4
4
  import * as PopoverPrimitive from "@radix-ui/react-popover";
5
5
  import { Check, ChevronDown, Search } from "lucide-react";
6
6
  import { cn } from "./utils";
7
+ import { ScrollLockTakeover } from "./scroll-lock";
7
8
 
8
9
  export interface Country {
9
10
  code: string;
@@ -142,96 +143,100 @@ const CountrySelect = React.forwardRef<HTMLButtonElement, CountrySelectProps>(
142
143
  </PopoverPrimitive.Trigger>
143
144
 
144
145
  <PopoverPrimitive.Portal>
145
- <PopoverPrimitive.Content
146
- align="start"
147
- sideOffset={6}
148
- className={cn(
149
- "z-[120] w-[var(--radix-popover-trigger-width)] min-w-[260px] rounded-xl border border-border bg-popover text-popover-foreground shadow-lg outline-none",
150
- "data-[state=open]:opacity-100 data-[state=closed]:opacity-0 transition-opacity duration-150"
151
- )}
152
- onOpenAutoFocus={(e) => e.preventDefault()}
153
- >
154
- {/* Search */}
155
- <div className="flex items-center gap-2 border-b border-border px-3 py-2.5">
156
- <Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
157
- <input
158
- ref={searchRef}
159
- type="text"
160
- value={search}
161
- onChange={(e) => setSearch(e.target.value)}
162
- placeholder="Search countries…"
163
- className={cn(
164
- "flex-1 bg-transparent text-[14px] text-foreground placeholder:text-muted-foreground",
165
- "focus-visible:outline-none"
166
- )}
167
- aria-label="Search countries"
168
- />
169
- {search && (
170
- <button
171
- type="button"
172
- onClick={() => setSearch("")}
173
- className="shrink-0 text-muted-foreground hover:text-foreground transition-colors duration-pg-fast ease-pg-standard"
174
- aria-label="Clear search"
175
- >
176
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
177
- <path d="M10.5 3.5L3.5 10.5M3.5 3.5L10.5 10.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
178
- </svg>
179
- </button>
146
+ {/* Takes over the scroll lock when this opens inside a Dialog or
147
+ Drawer, so the list can be scrolled. See `scroll-lock.tsx`. */}
148
+ <ScrollLockTakeover>
149
+ <PopoverPrimitive.Content
150
+ align="start"
151
+ sideOffset={6}
152
+ className={cn(
153
+ "z-[120] w-[var(--radix-popover-trigger-width)] min-w-[260px] rounded-xl border border-border bg-popover text-popover-foreground shadow-lg outline-none",
154
+ "data-[state=open]:opacity-100 data-[state=closed]:opacity-0 transition-opacity duration-150"
180
155
  )}
181
- </div>
182
-
183
- {/* List */}
184
- <div
185
- role="listbox"
186
- aria-label="Countries"
187
- className="max-h-64 overflow-y-auto p-1"
156
+ onOpenAutoFocus={(e) => e.preventDefault()}
188
157
  >
189
- {filtered.length === 0 ? (
190
- <div className="py-6 text-center text-sm text-muted-foreground">
191
- No countries found
192
- </div>
193
- ) : (
194
- filtered.map((country) => {
195
- const isSelected = country.code === value;
196
- return (
197
- <button
198
- key={country.code}
199
- role="option"
200
- aria-selected={isSelected}
201
- type="button"
202
- onClick={() => {
203
- onValueChange?.(country.code);
204
- setOpen(false);
205
- }}
206
- className={cn(
207
- "flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm",
208
- "transition-colors duration-pg-fast ease-pg-standard",
209
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35",
210
- "hover:bg-muted",
211
- isSelected && "bg-muted/60"
212
- )}
213
- >
214
- <span className="text-base leading-none" aria-hidden="true">
215
- {country.flag}
216
- </span>
217
- <span className="flex-1 truncate text-foreground">
218
- {country.name}
219
- </span>
220
- <span className="shrink-0 text-xs text-muted-foreground">
221
- {country.dialCode}
222
- </span>
223
- {isSelected && (
224
- <Check
225
- className="h-3.5 w-3.5 shrink-0 text-primary"
226
- aria-hidden="true"
227
- />
228
- )}
229
- </button>
230
- );
231
- })
232
- )}
233
- </div>
234
- </PopoverPrimitive.Content>
158
+ {/* Search */}
159
+ <div className="flex items-center gap-2 border-b border-border px-3 py-2.5">
160
+ <Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
161
+ <input
162
+ ref={searchRef}
163
+ type="text"
164
+ value={search}
165
+ onChange={(e) => setSearch(e.target.value)}
166
+ placeholder="Search countries…"
167
+ className={cn(
168
+ "flex-1 bg-transparent text-[14px] text-foreground placeholder:text-muted-foreground",
169
+ "focus-visible:outline-none"
170
+ )}
171
+ aria-label="Search countries"
172
+ />
173
+ {search && (
174
+ <button
175
+ type="button"
176
+ onClick={() => setSearch("")}
177
+ className="shrink-0 text-muted-foreground hover:text-foreground transition-colors duration-pg-fast ease-pg-standard"
178
+ aria-label="Clear search"
179
+ >
180
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
181
+ <path d="M10.5 3.5L3.5 10.5M3.5 3.5L10.5 10.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
182
+ </svg>
183
+ </button>
184
+ )}
185
+ </div>
186
+
187
+ {/* List */}
188
+ <div
189
+ role="listbox"
190
+ aria-label="Countries"
191
+ className="max-h-64 overflow-y-auto p-1"
192
+ >
193
+ {filtered.length === 0 ? (
194
+ <div className="py-6 text-center text-sm text-muted-foreground">
195
+ No countries found
196
+ </div>
197
+ ) : (
198
+ filtered.map((country) => {
199
+ const isSelected = country.code === value;
200
+ return (
201
+ <button
202
+ key={country.code}
203
+ role="option"
204
+ aria-selected={isSelected}
205
+ type="button"
206
+ onClick={() => {
207
+ onValueChange?.(country.code);
208
+ setOpen(false);
209
+ }}
210
+ className={cn(
211
+ "flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm",
212
+ "transition-colors duration-pg-fast ease-pg-standard",
213
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35",
214
+ "hover:bg-muted",
215
+ isSelected && "bg-muted/60"
216
+ )}
217
+ >
218
+ <span className="text-base leading-none" aria-hidden="true">
219
+ {country.flag}
220
+ </span>
221
+ <span className="flex-1 truncate text-foreground">
222
+ {country.name}
223
+ </span>
224
+ <span className="shrink-0 text-xs text-muted-foreground">
225
+ {country.dialCode}
226
+ </span>
227
+ {isSelected && (
228
+ <Check
229
+ className="h-3.5 w-3.5 shrink-0 text-primary"
230
+ aria-hidden="true"
231
+ />
232
+ )}
233
+ </button>
234
+ );
235
+ })
236
+ )}
237
+ </div>
238
+ </PopoverPrimitive.Content>
239
+ </ScrollLockTakeover>
235
240
  </PopoverPrimitive.Portal>
236
241
  </PopoverPrimitive.Root>
237
242
  );
@@ -6,6 +6,7 @@ import { RemoveScroll } from "react-remove-scroll";
6
6
  import { ChevronLeft, ChevronRight, ChevronDown, CalendarDays } from "lucide-react";
7
7
  import { AnimatePresence, motion } from "framer-motion";
8
8
  import { cn } from "./utils";
9
+ import { ScrollLockTakeover } from "./scroll-lock";
9
10
 
10
11
  /* ─── Constants ─────────────────────────────────────────────────────────── */
11
12
  const MONTHS = ["January","February","March","April","May","June",
@@ -655,8 +656,11 @@ export function DatePicker({ value, onChange, placeholder = "Select date", class
655
656
  <ChevronDown className={cn("size-[1.125rem] shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
656
657
  </button>
657
658
 
658
- {/* Portal */}
659
- {mounted && createPortal(panel, document.body)}
659
+ {/* Portal. Wrapped so the panel takes over the scroll lock when it opens
660
+ inside a Dialog or Drawer — without it the year/month lists and the
661
+ time columns cannot be scrolled with the wheel. See `scroll-lock.tsx`. */}
662
+ {mounted &&
663
+ createPortal(<ScrollLockTakeover>{panel}</ScrollLockTakeover>, document.body)}
660
664
  </div>
661
665
  );
662
666
  }
package/src/index.ts CHANGED
@@ -20,6 +20,11 @@ export { OtpInput } from "./otp-input";
20
20
  export type { OtpInputProps } from "./otp-input";
21
21
  export { CheckboxSelect } from "./checkbox-select";
22
22
  export type { CheckboxSelectProps, CheckboxSelectOption } from "./checkbox-select";
23
+ export { SingleSelect } from "./single-select";
24
+ export type { SingleSelectProps, SingleSelectOption } from "./single-select";
25
+ export { defaultOptionFilter } from "./option-filter";
26
+ export { isScrollLocked, ScrollLockTakeover } from "./scroll-lock";
27
+ export type { OptionFilter, FilterableOption } from "./option-filter";
23
28
  export { Textarea } from "./textarea";
24
29
  export { Label } from "./label";
25
30
  export { Checkbox } from "./checkbox";
@@ -4,6 +4,7 @@ import * as React from "react";
4
4
  import * as PopoverPrimitive from "@radix-ui/react-popover";
5
5
  import { X } from "lucide-react";
6
6
  import { cn } from "./utils";
7
+ import { ScrollLockTakeover } from "./scroll-lock";
7
8
 
8
9
  // ---------------------------------------------------------------------------
9
10
  // Types
@@ -103,44 +104,48 @@ const InlineDialogContent = React.forwardRef<
103
104
  ref
104
105
  ) => (
105
106
  <PopoverPrimitive.Portal>
106
- <PopoverPrimitive.Content
107
- ref={ref}
108
- side={side}
109
- sideOffset={sideOffset}
110
- className={cn(
111
- // Base card
112
- "relative z-[120] max-w-xs overflow-visible",
113
- "rounded-xl border border-border bg-card p-4 text-foreground shadow-lg",
114
- // Animation
115
- "data-[state=open]:opacity-100 data-[state=closed]:opacity-0",
116
- "transition-opacity duration-pg-fast ease-pg-standard",
117
- "outline-none",
118
- className
119
- )}
120
- {...props}
121
- >
122
- {/* Arrow pointer */}
123
- <span className={getArrowClasses(side)} aria-hidden="true" />
124
-
125
- {/* Close button */}
126
- {!hideClose && (
127
- <PopoverPrimitive.Close
128
- className={cn(
129
- "absolute right-2 top-2 z-10",
130
- "inline-flex size-6 items-center justify-center rounded-md",
131
- "text-muted-foreground hover:text-foreground",
132
- "transition-colors duration-pg-fast ease-pg-standard",
133
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35",
134
- "disabled:cursor-not-allowed disabled:opacity-50"
135
- )}
136
- aria-label="Close"
137
- >
138
- <X className="size-3.5" />
139
- </PopoverPrimitive.Close>
140
- )}
141
-
142
- {children}
143
- </PopoverPrimitive.Content>
107
+ {/* Scrollable when opened from inside a Dialog or Drawer. See
108
+ `scroll-lock.tsx`. */}
109
+ <ScrollLockTakeover>
110
+ <PopoverPrimitive.Content
111
+ ref={ref}
112
+ side={side}
113
+ sideOffset={sideOffset}
114
+ className={cn(
115
+ // Base card
116
+ "relative z-[120] max-w-xs overflow-visible",
117
+ "rounded-xl border border-border bg-card p-4 text-foreground shadow-lg",
118
+ // Animation
119
+ "data-[state=open]:opacity-100 data-[state=closed]:opacity-0",
120
+ "transition-opacity duration-pg-fast ease-pg-standard",
121
+ "outline-none",
122
+ className
123
+ )}
124
+ {...props}
125
+ >
126
+ {/* Arrow pointer */}
127
+ <span className={getArrowClasses(side)} aria-hidden="true" />
128
+
129
+ {/* Close button */}
130
+ {!hideClose && (
131
+ <PopoverPrimitive.Close
132
+ className={cn(
133
+ "absolute right-2 top-2 z-10",
134
+ "inline-flex size-6 items-center justify-center rounded-md",
135
+ "text-muted-foreground hover:text-foreground",
136
+ "transition-colors duration-pg-fast ease-pg-standard",
137
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35",
138
+ "disabled:cursor-not-allowed disabled:opacity-50"
139
+ )}
140
+ aria-label="Close"
141
+ >
142
+ <X className="size-3.5" />
143
+ </PopoverPrimitive.Close>
144
+ )}
145
+
146
+ {children}
147
+ </PopoverPrimitive.Content>
148
+ </ScrollLockTakeover>
144
149
  </PopoverPrimitive.Portal>
145
150
  )
146
151
  );
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared option-search behaviour for the select family.
3
+ *
4
+ * Every picker that can be searched matches the same way, so a user who learns
5
+ * that typing a raw code works in one dropdown can rely on it in the next.
6
+ */
7
+
8
+ export interface FilterableOption {
9
+ value: string;
10
+ label: string;
11
+ }
12
+
13
+ /**
14
+ * A custom match. Return true to keep the option in the list.
15
+ *
16
+ * The `query` arrives trimmed but otherwise untouched, so a predicate that
17
+ * cares about case can have it.
18
+ */
19
+ export type OptionFilter<T extends FilterableOption = FilterableOption> = (
20
+ option: T,
21
+ query: string
22
+ ) => boolean;
23
+
24
+ /**
25
+ * The default: case-insensitive against the label **and** the value, so "nz"
26
+ * finds New Zealand and a raw status code finds its prettified row. Someone who
27
+ * thinks in codes should not have to know the display name.
28
+ *
29
+ * This is the rule `SelectFilterChip` already applied to its list; the form
30
+ * fields now share it rather than each picker inventing its own.
31
+ */
32
+ export function defaultOptionFilter<T extends FilterableOption>(option: T, query: string): boolean {
33
+ const q = query.toLowerCase();
34
+ return option.label.toLowerCase().includes(q) || option.value.toLowerCase().includes(q);
35
+ }
36
+
37
+ /** Applies `filter` (or the default) to `options`, with an empty query passing everything. */
38
+ export function filterOptions<T extends FilterableOption>(
39
+ options: T[],
40
+ query: string,
41
+ filter?: OptionFilter<T>
42
+ ): T[] {
43
+ const q = query.trim();
44
+ if (!q) return options;
45
+ const match = filter ?? defaultOptionFilter;
46
+ return options.filter((o) => match(o, q));
47
+ }
package/src/popover.tsx CHANGED
@@ -3,6 +3,7 @@
3
3
  import * as React from "react";
4
4
  import * as PopoverPrimitive from "@radix-ui/react-popover";
5
5
  import { cn } from "./utils";
6
+ import { ScrollLockTakeover } from "./scroll-lock";
6
7
 
7
8
  const Popover = PopoverPrimitive.Root;
8
9
  const PopoverTrigger = PopoverPrimitive.Trigger;
@@ -11,12 +12,16 @@ const PopoverAnchor = PopoverPrimitive.Anchor;
11
12
  const PopoverContent = React.forwardRef<
12
13
  React.ElementRef<typeof PopoverPrimitive.Content>,
13
14
  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
14
- >(
15
- (
16
- { className, align = "center", sideOffset = 6, collisionPadding = 12, ...props },
17
- ref
18
- ) => (
19
- <PopoverPrimitive.Portal>
15
+ >(({ className, align = "center", sideOffset = 6, collisionPadding = 12, ...props }, ref) => (
16
+ <PopoverPrimitive.Portal>
17
+ {/* Every popover panel is portalled to `document.body`, which puts it
18
+ outside a Dialog's or Drawer's scroll lock — and that lock cancels the
19
+ wheel for everything outside itself, so a scrollable panel silently
20
+ refuses to scroll. The takeover is a no-op when no lock is held, so a
21
+ popover on a plain page behaves exactly as before, and unlike making the
22
+ popover `modal` it changes nothing about focus or outside clicks. See
23
+ `scroll-lock.tsx`. */}
24
+ <ScrollLockTakeover>
20
25
  <PopoverPrimitive.Content
21
26
  ref={ref}
22
27
  align={align}
@@ -32,9 +37,9 @@ const PopoverContent = React.forwardRef<
32
37
  )}
33
38
  {...props}
34
39
  />
35
- </PopoverPrimitive.Portal>
36
- )
37
- );
40
+ </ScrollLockTakeover>
41
+ </PopoverPrimitive.Portal>
42
+ ));
38
43
  PopoverContent.displayName = PopoverPrimitive.Content.displayName;
39
44
 
40
45
  export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { RemoveScroll } from "react-remove-scroll";
5
+
6
+ /**
7
+ * Scrolling inside an overlay that sits on top of another overlay.
8
+ *
9
+ * ## The defect this exists to prevent
10
+ *
11
+ * A modal `Dialog` or `Drawer` installs a `react-remove-scroll` lock. That lock
12
+ * listens for `wheel` and `touchmove` on `document` and, for any event whose
13
+ * target is **outside** the locked subtree, calls `preventDefault()` — the
14
+ * "outside or shard event" branch of its `SideEffect`. Radix passes only the
15
+ * dialog's own content as a shard.
16
+ *
17
+ * Every panel a picker opens is portalled to `document.body`, which puts it
18
+ * outside that subtree. So the panel renders, its list has `overflow-y-auto`,
19
+ * and the wheel does nothing. It is invisible in code review, because the
20
+ * component is correct on its own; it only misbehaves under an overlay.
21
+ *
22
+ * ## Why a lock, not an exception
23
+ *
24
+ * The same `SideEffect` returns early unless its own lock is the last one
25
+ * pushed. So the fix is not to poke a hole in the dialog's lock — it is for the
26
+ * panel to push a lock of its own while it is open, which suspends the one
27
+ * underneath and makes the panel's subtree the locked one. When it closes, its
28
+ * lock pops and the dialog's resumes.
29
+ *
30
+ * ## Why not just make the popover `modal`
31
+ *
32
+ * Radix installs a lock of its own for a `modal` popover, which would also fix
33
+ * the wheel — but it comes with a focus trap and with outside clicks being
34
+ * swallowed by the dismiss layer. Every popover already living inside a dialog
35
+ * would start behaving differently for the sake of a scrolling fix. So the lock
36
+ * is pushed directly, by {@link ScrollLockTakeover}, and modality is left
37
+ * alone. `PopoverContent` wraps every panel in it, which covers the pickers and
38
+ * anything an app composes; the hand-rolled `createPortal` panels
39
+ * (`DatePicker`, `TimePicker`) wrap theirs the same way.
40
+ */
41
+
42
+ /**
43
+ * Whether a `react-remove-scroll` lock is currently held — by a modal Dialog,
44
+ * Drawer, AlertDialog, or another picker.
45
+ *
46
+ * `data-scroll-locked` is set on `<body>` by `react-remove-scroll-bar`, which
47
+ * every such lock goes through, so this is the library's own signal rather
48
+ * than a guess about which overlay is open.
49
+ */
50
+ export function isScrollLocked(): boolean {
51
+ return typeof document !== "undefined" && document.body.hasAttribute("data-scroll-locked");
52
+ }
53
+
54
+ /**
55
+ * The same takeover for a panel that portals itself instead of going through
56
+ * Radix. Renders children untouched when no lock is held, so a picker on a
57
+ * plain page behaves exactly as before.
58
+ *
59
+ * `removeScrollBar` is off: the page's scrollbar is already gone, and a second
60
+ * `RemoveScrollBar` re-measures a gap that is now zero and writes it back over
61
+ * the dialog's, shifting the layout while the panel is open. The consequence is
62
+ * that this takeover does **not** bump `data-scroll-locked`; the wrapper's
63
+ * `data-scroll-lock-takeover` is the marker instead, in the DOM for debugging
64
+ * and for tests.
65
+ */
66
+ export function ScrollLockTakeover({ children }: { children: React.ReactNode }) {
67
+ // Read once, when the panel mounts — that is the moment it opens.
68
+ const [active] = React.useState(isScrollLocked);
69
+ if (!active) return <>{children}</>;
70
+ return (
71
+ <RemoveScroll removeScrollBar={false} allowPinchZoom data-scroll-lock-takeover="">
72
+ {children}
73
+ </RemoveScroll>
74
+ );
75
+ }