@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.
- package/dist/index.cjs +1390 -1171
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +159 -1
- package/dist/index.d.ts +159 -1
- package/dist/index.js +1433 -1218
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/picker-in-dialog.test.tsx +37 -0
- package/src/__tests__/select-search.test.tsx +223 -0
- package/src/button.tsx +6 -0
- package/src/checkbox-select.tsx +121 -101
- package/src/code.tsx +21 -1
- package/src/country-select.tsx +93 -88
- package/src/date-picker.tsx +6 -2
- package/src/index.ts +5 -0
- package/src/inline-dialog.tsx +43 -38
- package/src/option-filter.ts +47 -0
- package/src/popover.tsx +14 -9
- package/src/scroll-lock.tsx +75 -0
- package/src/single-select.tsx +237 -0
- package/src/time-picker.tsx +6 -1
|
@@ -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 };
|
package/src/time-picker.tsx
CHANGED
|
@@ -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
|
-
{
|
|
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
|
}
|