@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
package/package.json
CHANGED
|
@@ -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",
|
package/src/checkbox-select.tsx
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
"
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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={
|
|
168
|
-
className=
|
|
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
|
-
|
|
179
|
+
{allFilteredSelected ? "Deselect all" : "Select all"}
|
|
171
180
|
</button>
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
192
|
+
<div className="my-0.5 h-px bg-border mx-1" />
|
|
176
193
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
214
|
+
option.disabled
|
|
215
|
+
? "cursor-not-allowed opacity-50"
|
|
216
|
+
: "cursor-pointer hover:bg-muted"
|
|
211
217
|
)}
|
|
212
218
|
>
|
|
213
|
-
<CheckboxPrimitive.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
|