@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payglocal_ui/flux-ui",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Flux UI primitives — inputs, fields, dialog, data table, charts, calendar, and more (Tailwind v4 + Radix).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -138,3 +138,40 @@ describe("pickers inside a modal drawer", () => {
138
138
  await waitFor(() => expect(onChange).toHaveBeenCalledWith("2026-09-10 14:45"));
139
139
  });
140
140
  });
141
+
142
+ /**
143
+ * The other half of the same problem. Pointer events are what stopped clicks;
144
+ * the scroll lock is what stopped the wheel — the drawer's lock cancels
145
+ * wheel events for anything portalled outside it, which is every picker panel.
146
+ * A panel opened under a lock takes over with one of its own, and only the
147
+ * topmost lock acts, so its scrollable regions work again.
148
+ */
149
+ describe("pickers take over the scroll lock inside a drawer", () => {
150
+ it("DatePicker's panel pushes its own lock", async () => {
151
+ render(<DateHarness onChange={() => {}} />);
152
+ expect(document.body.getAttribute("data-scroll-locked")).toBe("1");
153
+
154
+ fireEvent.click(screen.getByText("10 Sep 2026"));
155
+
156
+ const day = await screen.findByRole("button", { name: "17" });
157
+ expect(day.closest("[data-scroll-lock-takeover]")).not.toBeNull();
158
+ });
159
+
160
+ it("TimePicker's panel pushes its own lock", async () => {
161
+ render(<TimeHarness onChange={() => {}} />);
162
+ expect(document.body.getAttribute("data-scroll-locked")).toBe("1");
163
+
164
+ fireEvent.click(screen.getByText("09:30 AM"));
165
+
166
+ const hours = await screen.findByText("Hr");
167
+ expect(hours.closest("[data-scroll-lock-takeover]")).not.toBeNull();
168
+ });
169
+
170
+ it("does not wrap the panel when there is no lock to take over", async () => {
171
+ render(<DatePicker value="2026-09-10" onChange={() => {}} />);
172
+ fireEvent.click(screen.getByText("10 Sep 2026"));
173
+
174
+ const day = await screen.findByRole("button", { name: "17" });
175
+ expect(day.closest("[data-scroll-lock-takeover]")).toBeNull();
176
+ });
177
+ });
@@ -0,0 +1,223 @@
1
+ import { describe, expect, it, beforeAll } from "vitest";
2
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3
+ import { useState } from "react";
4
+ import { Dialog, DialogContent } from "../dialog";
5
+ import { SingleSelect } from "../single-select";
6
+ import { CheckboxSelect } from "../checkbox-select";
7
+ import { defaultOptionFilter } from "../option-filter";
8
+
9
+ beforeAll(() => {
10
+ if (!(globalThis as any).PointerEvent) {
11
+ class PE extends MouseEvent {
12
+ pointerType: string;
13
+ constructor(type: string, props: any = {}) {
14
+ super(type, props);
15
+ this.pointerType = props.pointerType ?? "mouse";
16
+ }
17
+ }
18
+ (globalThis as any).PointerEvent = PE;
19
+ }
20
+ // Radix measures the trigger to position the popover.
21
+ if (!(globalThis as any).ResizeObserver) {
22
+ (globalThis as any).ResizeObserver = class {
23
+ observe() {}
24
+ unobserve() {}
25
+ disconnect() {}
26
+ };
27
+ }
28
+ });
29
+
30
+ const CURRENCIES = [
31
+ { value: "INR", label: "₹ - Indian Rupee" },
32
+ { value: "USD", label: "$ - US Dollar" },
33
+ { value: "GBP", label: "£ - Pound Sterling" },
34
+ ];
35
+
36
+ function Single({ showSearch = true, filterOption }: any) {
37
+ const [value, setValue] = useState("");
38
+ return (
39
+ <>
40
+ <SingleSelect
41
+ options={CURRENCIES}
42
+ value={value}
43
+ onChange={setValue}
44
+ placeholder="Select currency"
45
+ showSearch={showSearch}
46
+ searchPlaceholder="Search currency"
47
+ filterOption={filterOption}
48
+ />
49
+ <output data-testid="value">{value}</output>
50
+ </>
51
+ );
52
+ }
53
+
54
+ describe("SingleSelect", () => {
55
+ it("picks a value and reports it once", () => {
56
+ render(<Single />);
57
+ fireEvent.click(screen.getByRole("button", { name: /select currency/i }));
58
+ fireEvent.click(screen.getByText("$ - US Dollar"));
59
+ expect(screen.getByTestId("value").textContent).toBe("USD");
60
+ });
61
+
62
+ it("searches the value as well as the label, so a raw code finds its row", () => {
63
+ render(<Single />);
64
+ fireEvent.click(screen.getByRole("button", { name: /select currency/i }));
65
+ // "GBP" appears only in the value; the label shows the symbol and name.
66
+ fireEvent.change(screen.getByLabelText("Search currency"), { target: { value: "gbp" } });
67
+ expect(screen.getByText("£ - Pound Sterling")).toBeDefined();
68
+ expect(screen.queryByText("₹ - Indian Rupee")).toBeNull();
69
+ });
70
+
71
+ it("honours a custom filterOption", () => {
72
+ // Matches nothing but INR, whatever is typed.
73
+ render(<Single filterOption={(o: any) => o.value === "INR"} />);
74
+ fireEvent.click(screen.getByRole("button", { name: /select currency/i }));
75
+ fireEvent.change(screen.getByLabelText("Search currency"), { target: { value: "dollar" } });
76
+ expect(screen.getByText("₹ - Indian Rupee")).toBeDefined();
77
+ expect(screen.queryByText("$ - US Dollar")).toBeNull();
78
+ });
79
+
80
+ it("shows the empty line when nothing matches", () => {
81
+ render(<Single />);
82
+ fireEvent.click(screen.getByRole("button", { name: /select currency/i }));
83
+ fireEvent.change(screen.getByLabelText("Search currency"), { target: { value: "zzz" } });
84
+ expect(screen.getByText("No options found.")).toBeDefined();
85
+ });
86
+ });
87
+
88
+ describe("CheckboxSelect search", () => {
89
+ it("matches the value too, not only the label", () => {
90
+ render(
91
+ <CheckboxSelect
92
+ options={CURRENCIES}
93
+ value={[]}
94
+ onChange={() => {}}
95
+ placeholder="Select currencies"
96
+ showSearch
97
+ searchPlaceholder="Search currency"
98
+ />
99
+ );
100
+ fireEvent.click(screen.getByRole("button", { name: /select currencies/i }));
101
+ fireEvent.change(screen.getByLabelText("Search currency"), { target: { value: "usd" } });
102
+ expect(screen.getByText("$ - US Dollar")).toBeDefined();
103
+ expect(screen.queryByText("₹ - Indian Rupee")).toBeNull();
104
+ });
105
+ });
106
+
107
+ describe("defaultOptionFilter", () => {
108
+ it("is case-insensitive across label and value", () => {
109
+ const o = { value: "INR", label: "₹ - Indian Rupee" };
110
+ expect(defaultOptionFilter(o, "inr")).toBe(true);
111
+ expect(defaultOptionFilter(o, "RUPEE")).toBe(true);
112
+ expect(defaultOptionFilter(o, "dollar")).toBe(false);
113
+ });
114
+ });
115
+
116
+ /**
117
+ * A picker inside a modal dialog: `react-remove-scroll` cancels wheel and
118
+ * touch-move for anything outside the locked subtree, and a portalled popover
119
+ * is outside it — so the list rendered but would not scroll. Only the topmost
120
+ * lock acts, so the panel has to bring its own.
121
+ *
122
+ * It does that with `ScrollLockTakeover` rather than by going `modal`: the
123
+ * modal route would also trap focus and swallow outside clicks, which would
124
+ * change how every existing popover in a dialog behaves for a fix that is only
125
+ * about the wheel.
126
+ */
127
+ describe("SingleSelect inside a modal dialog", () => {
128
+ it("takes over the scroll lock so its list can scroll", async () => {
129
+ render(
130
+ <Dialog open onOpenChange={() => {}}>
131
+ <DialogContent>
132
+ <Single />
133
+ </DialogContent>
134
+ </Dialog>
135
+ );
136
+
137
+ expect(document.body.getAttribute("data-scroll-locked")).toBe("1");
138
+
139
+ fireEvent.click(screen.getByRole("button", { name: /select currency/i }));
140
+
141
+ const option = await screen.findByText("$ - US Dollar");
142
+ expect(option.closest("[data-scroll-lock-takeover]")).not.toBeNull();
143
+ });
144
+
145
+ it("stays modal-free: the dialog keeps the only lock counted on the body", async () => {
146
+ render(
147
+ <Dialog open onOpenChange={() => {}}>
148
+ <DialogContent>
149
+ <Single />
150
+ </DialogContent>
151
+ </Dialog>
152
+ );
153
+
154
+ fireEvent.click(screen.getByRole("button", { name: /select currency/i }));
155
+
156
+ await screen.findByText("$ - US Dollar");
157
+ // A `modal` popover would have pushed a second RemoveScrollBar here.
158
+ expect(document.body.getAttribute("data-scroll-locked")).toBe("1");
159
+ });
160
+
161
+ it("does not wrap the panel when there is no dialog around it", async () => {
162
+ render(<Single />);
163
+ fireEvent.click(screen.getByRole("button", { name: /select currency/i }));
164
+
165
+ const option = await screen.findByText("$ - US Dollar");
166
+ expect(option.closest("[data-scroll-lock-takeover]")).toBeNull();
167
+ });
168
+ });
169
+
170
+ describe("SingleSelect search threshold", () => {
171
+ const many = Array.from({ length: 8 }, (_, i) => ({
172
+ value: `v${i}`,
173
+ label: `Option ${i}`,
174
+ }));
175
+
176
+ function Harness({ options, ...rest }: any) {
177
+ const [value, setValue] = useState("");
178
+ return (
179
+ <SingleSelect
180
+ options={options}
181
+ value={value}
182
+ onChange={setValue}
183
+ placeholder="Pick one"
184
+ searchPlaceholder="Search options"
185
+ {...rest}
186
+ />
187
+ );
188
+ }
189
+
190
+ it("shows the box on its own once the list is long enough", () => {
191
+ render(<Harness options={many} />);
192
+ fireEvent.click(screen.getByRole("button", { name: /pick one/i }));
193
+ expect(screen.getByLabelText("Search options")).toBeDefined();
194
+ });
195
+
196
+ it("leaves a short list alone", () => {
197
+ render(<Harness options={many.slice(0, 3)} />);
198
+ fireEvent.click(screen.getByRole("button", { name: /pick one/i }));
199
+ expect(screen.queryByLabelText("Search options")).toBeNull();
200
+ });
201
+
202
+ it("takes an explicit showSearch either way", () => {
203
+ const { unmount } = render(<Harness options={many.slice(0, 3)} showSearch />);
204
+ fireEvent.click(screen.getByRole("button", { name: /pick one/i }));
205
+ expect(screen.getByLabelText("Search options")).toBeDefined();
206
+ unmount();
207
+
208
+ render(<Harness options={many} showSearch={false} />);
209
+ fireEvent.click(screen.getByRole("button", { name: /pick one/i }));
210
+ expect(screen.queryByLabelText("Search options")).toBeNull();
211
+ });
212
+
213
+ it("puts focus in the search box on open, and takes an id for its label", async () => {
214
+ render(<Harness options={many} id="currency-field" />);
215
+ const trigger = screen.getByRole("button", { name: /pick one/i });
216
+ expect(trigger.id).toBe("currency-field");
217
+
218
+ fireEvent.click(trigger);
219
+ await waitFor(() =>
220
+ expect(document.activeElement).toBe(screen.getByLabelText("Search options"))
221
+ );
222
+ });
223
+ });
package/src/button.tsx CHANGED
@@ -55,6 +55,12 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
55
55
  disabled={disabled || isLoading}
56
56
  className={cn(
57
57
  "inline-flex items-center justify-center font-medium transition-colors duration-pg-fast ease-pg-standard",
58
+ // A button's label never wraps. Without this, a narrow button breaks
59
+ // the line between an icon and its text — and an icon passed as a
60
+ // child rather than through `leftIcon` sits inside the same span, so
61
+ // it wraps with the words. Pass `whitespace-normal` in `className`
62
+ // for the rare button that really should wrap.
63
+ "whitespace-nowrap",
58
64
  variant !== "link" && "disabled:cursor-not-allowed disabled:opacity-50",
59
65
  variant === "link" && "justify-center",
60
66
  variant !== "link" && "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35",
@@ -5,6 +5,8 @@ import * as PopoverPrimitive from "@radix-ui/react-popover";
5
5
  import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
6
6
  import { Check, ChevronDown, Search } from "lucide-react";
7
7
  import { cn } from "./utils";
8
+ import { filterOptions, type OptionFilter } from "./option-filter";
9
+ import { ScrollLockTakeover } from "./scroll-lock";
8
10
 
9
11
  export interface CheckboxSelectOption {
10
12
  value: string;
@@ -18,6 +20,13 @@ export interface CheckboxSelectProps {
18
20
  onChange: (values: string[]) => void;
19
21
  placeholder?: string;
20
22
  showSearch?: boolean;
23
+ /** Placeholder inside the search box. Default "Search...". */
24
+ searchPlaceholder?: string;
25
+ /**
26
+ * Replaces the default match (label or value, case-insensitive) — for a list
27
+ * that has to be findable by something the row does not display.
28
+ */
29
+ filterOption?: OptionFilter<CheckboxSelectOption>;
21
30
  disabled?: boolean;
22
31
  maxDisplay?: number;
23
32
  className?: string;
@@ -48,6 +57,8 @@ const CheckboxSelect = React.forwardRef<HTMLButtonElement, CheckboxSelectProps>(
48
57
  onChange,
49
58
  placeholder = "Select options",
50
59
  showSearch = false,
60
+ searchPlaceholder = "Search...",
61
+ filterOption,
51
62
  disabled = false,
52
63
  maxDisplay = 2,
53
64
  className,
@@ -57,11 +68,12 @@ const CheckboxSelect = React.forwardRef<HTMLButtonElement, CheckboxSelectProps>(
57
68
  const [open, setOpen] = React.useState(false);
58
69
  const [search, setSearch] = React.useState("");
59
70
 
60
- const filtered = React.useMemo(() => {
61
- if (!search.trim()) return options;
62
- const lower = search.toLowerCase();
63
- return options.filter((o) => o.label.toLowerCase().includes(lower));
64
- }, [options, search]);
71
+ // Shared with SingleSelect and SelectFilterChip: the value matches as well
72
+ // as the label, so a raw code finds its prettified row.
73
+ const filtered = React.useMemo(
74
+ () => filterOptions(options, search, filterOption),
75
+ [options, search, filterOption]
76
+ );
65
77
 
66
78
  const allFilteredValues = filtered.filter((o) => !o.disabled).map((o) => o.value);
67
79
  const allFilteredSelected =
@@ -121,113 +133,121 @@ const CheckboxSelect = React.forwardRef<HTMLButtonElement, CheckboxSelectProps>(
121
133
  </PopoverPrimitive.Trigger>
122
134
 
123
135
  <PopoverPrimitive.Portal>
124
- <PopoverPrimitive.Content
125
- align="start"
126
- sideOffset={6}
127
- className={cn(
128
- "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",
129
- "data-[state=open]:opacity-100 data-[state=closed]:opacity-0 transition-opacity duration-150"
130
- )}
131
- >
132
- {showSearch && (
133
- <div className="relative mb-1 px-1 pt-1">
134
- <Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
135
- <input
136
- type="text"
137
- value={search}
138
- onChange={(e) => setSearch(e.target.value)}
139
- placeholder="Search..."
140
- className={cn(
141
- "flex h-9 w-full rounded-md border border-border bg-card pl-8 pr-3 text-sm shadow-sm placeholder:text-muted-foreground",
142
- "transition-colors duration-pg-fast ease-pg-standard",
143
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35"
144
- )}
145
- />
146
- </div>
147
- )}
136
+ {/* Takes over the scroll lock when this opens inside a Dialog or
137
+ Drawer, so the list can be scrolled. See `scroll-lock.tsx`. */}
138
+ <ScrollLockTakeover>
139
+ <PopoverPrimitive.Content
140
+ align="start"
141
+ sideOffset={6}
142
+ collisionPadding={8}
143
+ className={cn(
144
+ "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",
145
+ "data-[state=open]:opacity-100 data-[state=closed]:opacity-0 transition-opacity duration-150"
146
+ )}
147
+ >
148
+ {showSearch && (
149
+ <div className="relative mb-1 px-1 pt-1">
150
+ <Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
151
+ <input
152
+ type="text"
153
+ value={search}
154
+ onChange={(e) => setSearch(e.target.value)}
155
+ placeholder={searchPlaceholder}
156
+ aria-label={searchPlaceholder}
157
+ className={cn(
158
+ "flex h-9 w-full rounded-md border border-border bg-card pl-8 pr-3 text-sm shadow-sm placeholder:text-muted-foreground",
159
+ "transition-colors duration-pg-fast ease-pg-standard",
160
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35"
161
+ )}
162
+ />
163
+ </div>
164
+ )}
148
165
 
149
- <div className="flex items-center justify-between px-2 py-1.5">
150
- <button
151
- type="button"
152
- onClick={handleSelectAll}
153
- className={cn(
154
- "text-xs font-medium transition-colors duration-pg-fast ease-pg-standard",
155
- allFilteredSelected
156
- ? "text-primary hover:text-primary/80"
157
- : someFilteredSelected
158
- ? "text-primary hover:text-primary/80"
159
- : "text-muted-foreground hover:text-foreground"
160
- )}
161
- >
162
- {allFilteredSelected ? "Deselect all" : "Select all"}
163
- </button>
164
- {value.length > 0 && (
166
+ <div className="flex items-center justify-between px-2 py-1.5">
165
167
  <button
166
168
  type="button"
167
- onClick={handleClearAll}
168
- className="text-xs font-medium text-muted-foreground transition-colors duration-pg-fast ease-pg-standard hover:text-foreground"
169
+ onClick={handleSelectAll}
170
+ className={cn(
171
+ "text-xs font-medium transition-colors duration-pg-fast ease-pg-standard",
172
+ allFilteredSelected
173
+ ? "text-primary hover:text-primary/80"
174
+ : someFilteredSelected
175
+ ? "text-primary hover:text-primary/80"
176
+ : "text-muted-foreground hover:text-foreground"
177
+ )}
169
178
  >
170
- Clear
179
+ {allFilteredSelected ? "Deselect all" : "Select all"}
171
180
  </button>
172
- )}
173
- </div>
181
+ {value.length > 0 && (
182
+ <button
183
+ type="button"
184
+ onClick={handleClearAll}
185
+ className="text-xs font-medium text-muted-foreground transition-colors duration-pg-fast ease-pg-standard hover:text-foreground"
186
+ >
187
+ Clear
188
+ </button>
189
+ )}
190
+ </div>
174
191
 
175
- <div className="my-0.5 h-px bg-border mx-1" />
192
+ <div className="my-0.5 h-px bg-border mx-1" />
176
193
 
177
- <div className="max-h-60 overflow-y-auto py-0.5">
178
- {filtered.length === 0 ? (
179
- <div className="px-3 py-6 text-center text-sm text-muted-foreground">
180
- No options found.
181
- </div>
182
- ) : (
183
- filtered.map((option) => {
184
- const checked = value.includes(option.value);
185
- return (
186
- <div
187
- key={option.value}
188
- role="option"
189
- aria-selected={checked}
190
- aria-disabled={option.disabled}
191
- onClick={() => !option.disabled && handleToggle(option.value)}
192
- className={cn(
193
- "flex items-center gap-2.5 px-3 py-2 rounded-md text-sm select-none",
194
- "transition-colors duration-pg-fast ease-pg-standard",
195
- option.disabled
196
- ? "cursor-not-allowed opacity-50"
197
- : "cursor-pointer hover:bg-muted"
198
- )}
199
- >
200
- <CheckboxPrimitive.Root
201
- checked={checked}
202
- disabled={option.disabled}
203
- onCheckedChange={() => !option.disabled && handleToggle(option.value)}
204
- onClick={(e) => e.stopPropagation()}
194
+ {/* Capped by the room the popover has, so a long list near the
195
+ bottom of a dialog scrolls rather than running off-screen. */}
196
+ <div className="overflow-y-auto overscroll-contain py-0.5 max-h-[min(15rem,var(--radix-popover-content-available-height,15rem))]">
197
+ {filtered.length === 0 ? (
198
+ <div className="px-3 py-6 text-center text-sm text-muted-foreground">
199
+ No options found.
200
+ </div>
201
+ ) : (
202
+ filtered.map((option) => {
203
+ const checked = value.includes(option.value);
204
+ return (
205
+ <div
206
+ key={option.value}
207
+ role="option"
208
+ aria-selected={checked}
209
+ aria-disabled={option.disabled}
210
+ onClick={() => !option.disabled && handleToggle(option.value)}
205
211
  className={cn(
206
- "peer h-4 w-4 shrink-0 rounded-md border border-border bg-card shadow-sm",
212
+ "flex items-center gap-2.5 px-3 py-2 rounded-md text-sm select-none",
207
213
  "transition-colors duration-pg-fast ease-pg-standard",
208
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35",
209
- "disabled:cursor-not-allowed disabled:opacity-50",
210
- "data-[state=checked]:bg-primary data-[state=checked]:border-primary"
214
+ option.disabled
215
+ ? "cursor-not-allowed opacity-50"
216
+ : "cursor-pointer hover:bg-muted"
211
217
  )}
212
218
  >
213
- <CheckboxPrimitive.Indicator className="flex items-center justify-center text-primary-foreground">
214
- <Check className="size-3" strokeWidth={3} />
215
- </CheckboxPrimitive.Indicator>
216
- </CheckboxPrimitive.Root>
217
- <span
218
- className={cn(
219
- "leading-none",
220
- checked ? "text-foreground" : "text-foreground"
221
- )}
222
- >
223
- {option.label}
224
- </span>
225
- </div>
226
- );
227
- })
228
- )}
229
- </div>
230
- </PopoverPrimitive.Content>
219
+ <CheckboxPrimitive.Root
220
+ checked={checked}
221
+ disabled={option.disabled}
222
+ onCheckedChange={() => !option.disabled && handleToggle(option.value)}
223
+ onClick={(e) => e.stopPropagation()}
224
+ className={cn(
225
+ "peer h-4 w-4 shrink-0 rounded-md border border-border bg-card shadow-sm",
226
+ "transition-colors duration-pg-fast ease-pg-standard",
227
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35",
228
+ "disabled:cursor-not-allowed disabled:opacity-50",
229
+ "data-[state=checked]:bg-primary data-[state=checked]:border-primary"
230
+ )}
231
+ >
232
+ <CheckboxPrimitive.Indicator className="flex items-center justify-center text-primary-foreground">
233
+ <Check className="size-3" strokeWidth={3} />
234
+ </CheckboxPrimitive.Indicator>
235
+ </CheckboxPrimitive.Root>
236
+ <span
237
+ className={cn(
238
+ "leading-none",
239
+ checked ? "text-foreground" : "text-foreground"
240
+ )}
241
+ >
242
+ {option.label}
243
+ </span>
244
+ </div>
245
+ );
246
+ })
247
+ )}
248
+ </div>
249
+ </PopoverPrimitive.Content>
250
+ </ScrollLockTakeover>
231
251
  </PopoverPrimitive.Portal>
232
252
  </PopoverPrimitive.Root>
233
253
  );
package/src/code.tsx CHANGED
@@ -41,6 +41,16 @@ export interface CodeBlockProps extends HTMLAttributes<HTMLDivElement> {
41
41
  language?: string;
42
42
  /** Hide the copy button. Defaults to false. */
43
43
  hideCopy?: boolean;
44
+ /**
45
+ * Wrap long lines instead of scrolling them sideways.
46
+ *
47
+ * Worth turning on wherever the code is something to read and copy rather
48
+ * than to study — a snippet in a dialog, say, where a horizontal scrollbar
49
+ * hides the end of the only line that matters and no one thinks to drag it.
50
+ * Leave it off for real source, where wrapping would break the indentation
51
+ * that carries the structure.
52
+ */
53
+ wrap?: boolean;
44
54
  }
45
55
 
46
56
  export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
@@ -50,6 +60,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
50
60
  filename,
51
61
  language,
52
62
  hideCopy = false,
63
+ wrap = false,
53
64
  className,
54
65
  ...props
55
66
  },
@@ -103,7 +114,16 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
103
114
 
104
115
  {/* Code area */}
105
116
  <div className="relative">
106
- <pre className="overflow-x-auto p-4 font-mono text-[13px] leading-relaxed">
117
+ <pre
118
+ className={cn(
119
+ "p-4 font-mono text-[13px] leading-relaxed",
120
+ wrap ? "whitespace-pre-wrap break-words" : "overflow-x-auto",
121
+ // Room for the floating copy button, which a wrapped line would
122
+ // otherwise run underneath. Only when wrapping: a scrolling block
123
+ // is unchanged from before this prop existed.
124
+ wrap && !hasHeader && !hideCopy && "pr-12"
125
+ )}
126
+ >
107
127
  <code>{code}</code>
108
128
  </pre>
109
129