@payglocal_ui/flux-ui 0.3.2 → 0.3.3

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.
@@ -0,0 +1,237 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as PopoverPrimitive from "@radix-ui/react-popover";
5
+ import { Check, ChevronDown, Search } from "lucide-react";
6
+ import { cn } from "./utils";
7
+ import { filterOptions, type OptionFilter } from "./option-filter";
8
+ import { ScrollLockTakeover } from "./scroll-lock";
9
+
10
+ export interface SingleSelectOption {
11
+ value: string;
12
+ label: string;
13
+ disabled?: boolean;
14
+ /** Optional leading glyph — a flag, a brand mark, a status dot. */
15
+ icon?: React.ReactNode;
16
+ }
17
+
18
+ export interface SingleSelectProps {
19
+ /** Put on the trigger, so a `FieldLabel`'s `htmlFor` can point at it. */
20
+ id?: string;
21
+ options: SingleSelectOption[];
22
+ /** The chosen value, or "" for none. */
23
+ value: string;
24
+ onChange: (value: string) => void;
25
+ placeholder?: string;
26
+ /**
27
+ * Show a search box above the list. Left unset it appears once there are
28
+ * `searchThreshold` options — a search field over three items is noise, and
29
+ * remembering to pass the flag is how two lists of the same length end up
30
+ * behaving differently.
31
+ */
32
+ showSearch?: boolean;
33
+ /** How many options before the search box appears on its own. Default 8. */
34
+ searchThreshold?: number;
35
+ /** Placeholder inside that search box. Default "Search...". */
36
+ searchPlaceholder?: string;
37
+ /**
38
+ * Replaces the default match (label or value, case-insensitive) — for a list
39
+ * that has to be findable by something the row does not display, such as a
40
+ * currency's full name behind a symbol.
41
+ */
42
+ filterOption?: OptionFilter<SingleSelectOption>;
43
+ /** Adds a "Clear" row so a chosen value can be taken back to "". */
44
+ clearable?: boolean;
45
+ /** Line shown when the list is empty. Default "No options found.". */
46
+ emptyText?: string;
47
+ disabled?: boolean;
48
+ /** Marks the field as failing validation, matching `Input`'s aria-invalid styling. */
49
+ invalid?: boolean;
50
+ className?: string;
51
+ }
52
+
53
+ /**
54
+ * One-of-many as a **form field** — the single-value counterpart to
55
+ * {@link CheckboxSelect}, with the same trigger, popover and search.
56
+ *
57
+ * Radix `Select` cannot host a text input (its own typeahead owns the
58
+ * keystrokes), so a searchable single select has to be a popover over a
59
+ * listbox. Before this existed, every screen needing one built that popover
60
+ * itself, which is how a design system ends up with four dropdowns that filter
61
+ * differently. `SingleSelectFilterChip` remains the toolbar form of the same
62
+ * idea; this is the one that sits in a form, under a `FieldLabel`.
63
+ */
64
+ const SingleSelect = React.forwardRef<HTMLButtonElement, SingleSelectProps>(
65
+ (
66
+ {
67
+ id,
68
+ options,
69
+ value,
70
+ onChange,
71
+ placeholder = "Select an option",
72
+ showSearch,
73
+ searchThreshold = 8,
74
+ searchPlaceholder = "Search...",
75
+ filterOption,
76
+ clearable = false,
77
+ emptyText = "No options found.",
78
+ disabled = false,
79
+ invalid = false,
80
+ className,
81
+ },
82
+ ref
83
+ ) => {
84
+ const [open, setOpen] = React.useState(false);
85
+ const [search, setSearch] = React.useState("");
86
+ const searchRef = React.useRef<HTMLInputElement>(null);
87
+
88
+ const searchable = showSearch ?? options.length >= searchThreshold;
89
+
90
+ const filtered = React.useMemo(
91
+ () => (searchable ? filterOptions(options, search, filterOption) : options),
92
+ [options, search, filterOption, searchable]
93
+ );
94
+
95
+ const selected = options.find((o) => o.value === value);
96
+
97
+ function close() {
98
+ setOpen(false);
99
+ // Dropped on close so the next open starts from the whole list rather
100
+ // than yesterday's query.
101
+ setSearch("");
102
+ }
103
+
104
+ function pick(next: string) {
105
+ onChange(next);
106
+ close();
107
+ }
108
+
109
+ return (
110
+ <PopoverPrimitive.Root
111
+ open={open}
112
+ onOpenChange={(next) => (next ? setOpen(true) : close())}
113
+ >
114
+ <PopoverPrimitive.Trigger asChild>
115
+ <button
116
+ ref={ref}
117
+ id={id}
118
+ type="button"
119
+ disabled={disabled}
120
+ aria-expanded={open}
121
+ aria-haspopup="listbox"
122
+ aria-invalid={invalid || undefined}
123
+ className={cn(
124
+ "flex h-11 w-full items-center justify-between gap-2.5 rounded-lg border border-border bg-card px-4 py-2 text-[15px] shadow-sm outline-none",
125
+ "transition-colors duration-pg-fast ease-pg-standard",
126
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35",
127
+ "aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 dark:aria-invalid:ring-destructive/40",
128
+ "disabled:cursor-not-allowed disabled:opacity-50",
129
+ selected ? "text-foreground" : "text-muted-foreground",
130
+ className
131
+ )}
132
+ >
133
+ <span className="flex min-w-0 items-center gap-2 truncate">
134
+ {selected?.icon}
135
+ <span className="truncate">{selected?.label ?? placeholder}</span>
136
+ </span>
137
+ <ChevronDown
138
+ className={cn(
139
+ "h-4 w-4 shrink-0 text-muted-foreground opacity-70 transition-transform duration-pg-fast ease-pg-standard",
140
+ open && "rotate-180"
141
+ )}
142
+ />
143
+ </button>
144
+ </PopoverPrimitive.Trigger>
145
+
146
+ <PopoverPrimitive.Portal>
147
+ {/* Takes over the scroll lock when this opens inside a Dialog or
148
+ Drawer, so the list can be scrolled. See `scroll-lock.tsx`. */}
149
+ <ScrollLockTakeover>
150
+ <PopoverPrimitive.Content
151
+ align="start"
152
+ sideOffset={6}
153
+ collisionPadding={8}
154
+ className={cn(
155
+ "z-[120] min-w-[var(--radix-popover-trigger-width)] w-full rounded-xl border border-border bg-popover text-popover-foreground shadow-lg outline-none p-1",
156
+ "data-[state=open]:opacity-100 data-[state=closed]:opacity-0 transition-opacity duration-150"
157
+ )}
158
+ >
159
+ {searchable && (
160
+ <div className="relative mb-1 px-1 pt-1">
161
+ <Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
162
+ <input
163
+ type="text"
164
+ value={search}
165
+ onChange={(e) => setSearch(e.target.value)}
166
+ placeholder={searchPlaceholder}
167
+ aria-label={searchPlaceholder}
168
+ className={cn(
169
+ "flex h-9 w-full rounded-md border border-border bg-card pl-8 pr-3 text-sm shadow-sm placeholder:text-muted-foreground",
170
+ "transition-colors duration-pg-fast ease-pg-standard",
171
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35"
172
+ )}
173
+ />
174
+ </div>
175
+ )}
176
+
177
+ {clearable && value !== "" && (
178
+ <>
179
+ <button
180
+ type="button"
181
+ onClick={() => pick("")}
182
+ className="w-full rounded-md px-3 py-1.5 text-left text-xs font-medium text-muted-foreground transition-colors duration-pg-fast ease-pg-standard hover:bg-muted hover:text-foreground"
183
+ >
184
+ Clear
185
+ </button>
186
+ <div className="my-0.5 h-px bg-border mx-1" />
187
+ </>
188
+ )}
189
+
190
+ {/* Capped by the room the popover has, so a long list near the
191
+ bottom of a dialog scrolls rather than running off-screen. */}
192
+ <div
193
+ role="listbox"
194
+ className="overflow-y-auto overscroll-contain py-0.5 max-h-[min(15rem,var(--radix-popover-content-available-height,15rem))]"
195
+ >
196
+ {filtered.length === 0 ? (
197
+ <div className="px-3 py-6 text-center text-sm text-muted-foreground">
198
+ {emptyText}
199
+ </div>
200
+ ) : (
201
+ filtered.map((option) => {
202
+ const isSelected = option.value === value;
203
+ return (
204
+ <div
205
+ key={option.value}
206
+ role="option"
207
+ aria-selected={isSelected}
208
+ aria-disabled={option.disabled}
209
+ onClick={() => !option.disabled && pick(option.value)}
210
+ className={cn(
211
+ "flex items-center gap-2.5 px-3 py-2 rounded-md text-sm select-none",
212
+ "transition-colors duration-pg-fast ease-pg-standard",
213
+ option.disabled
214
+ ? "cursor-not-allowed opacity-50"
215
+ : "cursor-pointer hover:bg-muted",
216
+ isSelected && "bg-muted"
217
+ )}
218
+ >
219
+ {option.icon}
220
+ <span className="min-w-0 flex-1 truncate leading-none">{option.label}</span>
221
+ {isSelected && <Check className="size-3.5 shrink-0 text-primary" strokeWidth={3} />}
222
+ </div>
223
+ );
224
+ })
225
+ )}
226
+ </div>
227
+ </PopoverPrimitive.Content>
228
+ </ScrollLockTakeover>
229
+ </PopoverPrimitive.Portal>
230
+ </PopoverPrimitive.Root>
231
+ );
232
+ }
233
+ );
234
+
235
+ SingleSelect.displayName = "SingleSelect";
236
+
237
+ export { SingleSelect };
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
5
5
  import { RemoveScroll } from "react-remove-scroll";
6
6
  import { Clock, ChevronDown, X } from "lucide-react";
7
7
  import { cn } from "./utils";
8
+ import { ScrollLockTakeover } from "./scroll-lock";
8
9
 
9
10
  /* ─── Types ──────────────────────────────────────────────────────────────── */
10
11
 
@@ -463,7 +464,11 @@ export const TimePicker = React.forwardRef<HTMLButtonElement, TimePickerProps>(
463
464
  )}
464
465
  </button>
465
466
 
466
- {mounted && panel && createPortal(panel, document.body)}
467
+ {/* Wrapped so the hour/minute columns can be scrolled when the picker
468
+ opens inside a Dialog or Drawer. See `scroll-lock.tsx`. */}
469
+ {mounted &&
470
+ panel &&
471
+ createPortal(<ScrollLockTakeover>{panel}</ScrollLockTakeover>, document.body)}
467
472
  </div>
468
473
  );
469
474
  }