torch-glare 2.5.4 → 2.5.5

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.
Files changed (34) hide show
  1. package/apps/lib/components/BadgeField.tsx +70 -66
  2. package/apps/lib/components/Card.tsx +2 -1
  3. package/apps/lib/components/ContextMenu.tsx +65 -22
  4. package/apps/lib/components/DataViews/context.ts +2 -2
  5. package/apps/lib/components/DataViews/data-views.tsx +3 -3
  6. package/apps/lib/components/DataViews/filters/filters.tsx +0 -2
  7. package/apps/lib/components/DataViews/views/table-view.tsx +184 -176
  8. package/apps/lib/components/DropdownMenu.tsx +65 -22
  9. package/apps/lib/components/FormBuilder/fields/FieldShell.tsx +19 -5
  10. package/apps/lib/components/FormBuilder/types.ts +16 -0
  11. package/apps/lib/components/FormRenderer/form-renderer.tsx +16 -5
  12. package/apps/lib/components/HeaderBar.tsx +51 -53
  13. package/apps/lib/components/InputField.tsx +46 -47
  14. package/apps/lib/components/Popover.tsx +23 -9
  15. package/apps/lib/components/SearchableSelect.tsx +10 -6
  16. package/apps/lib/components/SearchableTree.tsx +23 -6
  17. package/apps/lib/components/SearchableTreeDialog.tsx +11 -1
  18. package/apps/lib/components/Select.tsx +56 -48
  19. package/apps/lib/components/SlideDatePicker.tsx +5 -7
  20. package/apps/lib/components/TabSwitch.tsx +18 -12
  21. package/apps/lib/layouts/FieldSection.tsx +28 -2
  22. package/apps/lib/registry.json +1 -2
  23. package/docs/components/badge-field.md +4 -4
  24. package/docs/components/context-menu.md +3 -1
  25. package/docs/components/data-views/examples/filters.md +0 -1
  26. package/docs/components/data-views/index.md +1 -17
  27. package/docs/components/dropdown-menu.md +3 -0
  28. package/docs/components/form-builder.md +27 -1
  29. package/docs/components/header-bar.md +3 -2
  30. package/docs/components/input-field.md +3 -3
  31. package/docs/components/select.md +1 -1
  32. package/docs/migration/changelog.md +13 -0
  33. package/package.json +1 -1
  34. package/apps/lib/components/DataViews/filters/summary.tsx +0 -65
@@ -174,11 +174,22 @@ function FormRendererRoot<T extends FieldValues = FieldValues>({
174
174
  // and the rail still has room to be legible (a plain `minmax(0,…)` let it collapse to 0 in a
175
175
  // narrow preview frame). Past that floor the labels truncate instead of pushing.
176
176
  const bodyInner = isStepper ? (
177
- <div className="grid w-full grid-cols-[minmax(180px,1fr)_minmax(0,1100px)_minmax(180px,1fr)] gap-8">
178
- <StepperNav control={formInstance.control as Control<FieldValues>} />
179
- {fieldsColumn}
180
- {/* Empty third column — balances the rail's gutter so the middle column is centred. */}
181
- <div />
177
+ // `@container` on the wrapper, not on the grid: an element cannot query its own size, so the
178
+ // measured box has to be an ancestor. It sits here rather than on the scroll body because
179
+ // `bodyInner` is also used directly when there is no header, and this way both paths get a
180
+ // container. The form then reacts to the width it is actually given — a rail, a drawer, a
181
+ // narrow preview frame — instead of the viewport's.
182
+ <div className="@container w-full">
183
+ {/* Below `@lg` the balancing column is dropped: it is empty, and in a narrow container its
184
+ 180px floor plus a 32px gap is width the fields column needs more than the centring.
185
+ Both halves are required — a `display:none` child is not a grid item, but the third
186
+ track would still be reserved if the template kept describing it. */}
187
+ <div className="grid w-full grid-cols-[minmax(180px,1fr)_minmax(0,1100px)] gap-8 @lg:grid-cols-[minmax(180px,1fr)_minmax(0,1100px)_minmax(180px,1fr)]">
188
+ <StepperNav control={formInstance.control as Control<FieldValues>} />
189
+ {fieldsColumn}
190
+ {/* Empty third column — balances the rail's gutter so the middle column is centred. */}
191
+ <div className="hidden @lg:block" />
192
+ </div>
182
193
  </div>
183
194
  ) : (
184
195
  fieldsColumn
@@ -22,13 +22,18 @@ import { Themes } from "../utils/types";
22
22
  * (renders plain "sales iNVOICE" on the LEFT, colored "de-344" on the RIGHT)
23
23
  */
24
24
 
25
- // Inner row: order is reversed for "detail" so the badge ends up on the right.
26
- const rowStyles = cva(["flex", "items-center"], {
25
+ // 28px / weight 510 — the design's `Font/Size/Display/Medium`, which is exactly what this class
26
+ // carries. Shared by the badge and the plain title so the two pieces cannot drift apart.
27
+ const headerTextStyles = "typography-display-medium-medium uppercase [font-feature-settings:'cv05'_on]";
28
+
29
+ // Colored pill holding `label`. 32px tall, 4px side padding, 8px radius — Figma `Header.Badge`.
30
+ const badgeStyles = cva(["flex", "h-8", "items-center", "justify-center", "rounded-lg", "px-1"], {
27
31
  variants: {
28
32
  variant: {
29
- new: "flex-row",
30
- edit: "flex-row",
31
- detail: "flex-row-reverse",
33
+ new: "bg-blue-sparkle-alpha-50",
34
+ edit: "bg-orange-alpha-50",
35
+ // `White Alpha/15`, not /30 — the detail chip is the faintest of the three.
36
+ detail: "bg-white-alpha-15",
32
37
  },
33
38
  },
34
39
  defaultVariants: {
@@ -36,49 +41,22 @@ const rowStyles = cva(["flex", "items-center"], {
36
41
  },
37
42
  });
38
43
 
39
- // Colored pill holding `label`.
40
- const badgeStyles = cva(
41
- ["flex", "h-8", "items-center", "justify-center", "gap-2.5", "rounded-lg", "px-1"],
42
- {
43
- variants: {
44
- variant: {
45
- new: "bg-blue-sparkle-alpha-50",
46
- edit: "bg-orange-alpha-50",
47
- detail: "bg-white-alpha-30",
48
- },
49
- },
50
- defaultVariants: {
51
- variant: "new",
52
- },
53
- },
54
- );
55
-
56
44
  // Text inside the colored pill.
57
- const badgeTextStyles = cva(
58
- [
59
- "font-sans",
60
- "text-[28px]",
61
- "font-[510]",
62
- "leading-normal",
63
- "uppercase",
64
- "[font-feature-settings:'cv05'_on]",
65
- ],
66
- {
67
- variants: {
68
- variant: {
69
- new: "text-blue-sparkle-200",
70
- edit: "text-orange-200",
71
- detail: "text-white-00",
72
- },
73
- },
74
- defaultVariants: {
75
- variant: "new",
45
+ const badgeTextStyles = cva([headerTextStyles], {
46
+ variants: {
47
+ variant: {
48
+ new: "text-blue-sparkle-200",
49
+ edit: "text-orange-200",
50
+ detail: "text-white-00",
76
51
  },
77
52
  },
78
- );
53
+ defaultVariants: {
54
+ variant: "new",
55
+ },
56
+ });
79
57
 
80
58
  interface HeaderBarProps
81
- extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof rowStyles> {
59
+ extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeStyles> {
82
60
  theme?: Themes;
83
61
  /** The colored emphasis pill text. */
84
62
  label: string;
@@ -88,25 +66,45 @@ interface HeaderBarProps
88
66
 
89
67
  const HeaderBar = forwardRef<HTMLDivElement, HeaderBarProps>(
90
68
  ({ variant = "new", label, title, theme, className, ...props }, ref) => {
69
+ const badge = (
70
+ <div className={cn(badgeStyles({ variant }))}>
71
+ <p className={cn(badgeTextStyles({ variant }))}>{label}</p>
72
+ </div>
73
+ );
74
+
75
+ // The plain title sits in its own 32px box with 6px side padding — that padding, not a row
76
+ // `gap`, is what separates it from the badge (matching the design's `padding` frame).
77
+ const plain = (
78
+ <div className="flex h-8 items-center justify-center px-1.5">
79
+ <p className={cn(headerTextStyles, "text-white-00")}>{title}</p>
80
+ </div>
81
+ );
82
+
91
83
  return (
92
84
  <div
93
85
  ref={ref}
94
86
  data-theme={theme}
95
87
  className={cn(
96
- "inline-flex flex-col items-start rounded-[14px] border border-black-600 bg-black-1000 p-1.5 shadow-[0_0_32px_2px_rgba(0,0,0,0.05),0_0_32px_2px_rgba(0,0,0,0.05)]",
88
+ "inline-flex flex-col items-start overflow-hidden rounded-[14px] border border-black-600 bg-black-1000 p-1.5 shadow-[0_0_32px_2px_rgba(0,0,0,0.05),0_0_32px_2px_rgba(0,0,0,0.05)]",
97
89
  className,
98
90
  )}
99
91
  {...props}
100
92
  >
101
- <div className={cn(rowStyles({ variant }))}>
102
- <div className={cn(badgeStyles({ variant }))}>
103
- <p className={cn(badgeTextStyles({ variant }))}>{label}</p>
104
- </div>
105
- <div className="flex h-8 items-center justify-center px-1.5">
106
- <p className="font-sans text-[28px] font-[510] leading-normal uppercase text-white-00 [font-feature-settings:'cv05'_on]">
107
- {title}
108
- </p>
109
- </div>
93
+ {/* `detail` genuinely swaps the two children rather than using `flex-row-reverse`. The
94
+ visual result is the same in LTR, but reversing the row would flip the pair the wrong
95
+ way under `dir="rtl"` and would read out of order to a screen reader. */}
96
+ <div className="flex items-center">
97
+ {variant === "detail" ? (
98
+ <>
99
+ {plain}
100
+ {badge}
101
+ </>
102
+ ) : (
103
+ <>
104
+ {badge}
105
+ {plain}
106
+ </>
107
+ )}
110
108
  </div>
111
109
  </div>
112
110
  );
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { forwardRef, InputHTMLAttributes, ReactNode, useEffect, useRef, useState } from "react";
3
3
  import { cn } from "../utils/cn";
4
- import { Tooltip, ToolTipSide } from "./Tooltip";
4
+ import { ToolTipSide } from "./Tooltip";
5
5
  import { Popover, PopoverContent, PopoverTrigger } from "./Popover";
6
6
  import { ActionButton } from "./ActionButton";
7
7
  import { Themes } from "../utils/types";
@@ -13,8 +13,13 @@ export interface Props extends Omit<InputHTMLAttributes<HTMLInputElement>, "size
13
13
  icon?: ReactNode; // to add left side icon if you pass it
14
14
  childrenSide?: ReactNode; // to add action button to the end of the input
15
15
  popoverChildren?: ReactNode; // to add drop down list if you pass it
16
- errorMessage?: string; // to show tooltip component when error_message not null
16
+ /** Marks the field invalid: any non-undefined value turns on the negative border. */
17
+ errorMessage?: string;
17
18
  onTable?: boolean; // to change the border style of the component when it is on table
19
+ /**
20
+ * @deprecated Ignored. The error tooltip was removed — an invalid field is shown by its negative
21
+ * border alone. Kept so existing call sites keep compiling; it will go in a future major.
22
+ */
18
23
  toolTipSide?: ToolTipSide;
19
24
  theme?: Themes;
20
25
  }
@@ -29,6 +34,7 @@ export const InputField = forwardRef<HTMLInputElement, Props>(
29
34
  errorMessage,
30
35
  onTable,
31
36
  variant = "PresentationStyle",
37
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- deprecated no-op, destructured to keep it out of the {...props} spread
32
38
  toolTipSide,
33
39
  theme,
34
40
  className,
@@ -49,54 +55,47 @@ export const InputField = forwardRef<HTMLInputElement, Props>(
49
55
  // TODO: make the user input visible when input is focused
50
56
  return (
51
57
  <Popover onOpenChange={setIsPopoverOpen}>
52
- <Tooltip
53
- theme={theme}
54
- toolTipSide={toolTipSide}
55
- open={errorMessage !== undefined}
56
- text={errorMessage}
57
- >
58
- <PopoverTrigger ref={triggerRef} asChild>
59
- <Group
60
- error={errorMessage !== undefined}
61
- onTable={onTable}
62
- size={size}
63
- variant={variant}
64
- data-theme={theme}
58
+ <PopoverTrigger ref={triggerRef} asChild>
59
+ <Group
60
+ error={errorMessage !== undefined}
61
+ onTable={onTable}
62
+ size={size}
63
+ variant={variant}
64
+ data-theme={theme}
65
+ onFocus={(e) => {
66
+ setPopoverWidth(e.currentTarget.offsetWidth);
67
+ setIsPopoverOpen(!isPopoverOpen);
68
+ inputRef.current?.focus();
69
+ }}
70
+ className={className}
71
+ >
72
+ {icon && <Icon>{icon}</Icon>}
73
+ <Input
74
+ {...props}
65
75
  onFocus={(e) => {
66
- setPopoverWidth(e.currentTarget.offsetWidth);
67
- setIsPopoverOpen(!isPopoverOpen);
68
- inputRef.current?.focus();
76
+ setIsPopoverOpen(true);
77
+ props.onFocus?.(e);
78
+ }}
79
+ onBlur={(e) => {
80
+ setIsPopoverOpen(false);
81
+ props.onBlur?.(e);
69
82
  }}
70
- className={className}
71
- >
72
- {icon && <Icon>{icon}</Icon>}
73
- <Input
74
- {...props}
75
- onFocus={(e) => {
76
- setIsPopoverOpen(true);
77
- props.onFocus?.(e);
78
- }}
79
- onBlur={(e) => {
80
- setIsPopoverOpen(false);
81
- props.onBlur?.(e);
82
- }}
83
- ref={inputRef}
84
- />
83
+ ref={inputRef}
84
+ />
85
85
 
86
- <Trilling>
87
- {childrenSide}
88
- {popoverChildren && (
89
- <PopoverActionButton
90
- size={size}
91
- variant={variant}
92
- isPopoverOpen={isPopoverOpen}
93
- disabled={props.disabled}
94
- />
95
- )}
96
- </Trilling>
97
- </Group>
98
- </PopoverTrigger>
99
- </Tooltip>
86
+ <Trilling>
87
+ {childrenSide}
88
+ {popoverChildren && (
89
+ <PopoverActionButton
90
+ size={size}
91
+ variant={variant}
92
+ isPopoverOpen={isPopoverOpen}
93
+ disabled={props.disabled}
94
+ />
95
+ )}
96
+ </Trilling>
97
+ </Group>
98
+ </PopoverTrigger>
100
99
 
101
100
  {popoverChildren && !props.disabled && (
102
101
  <PopoverContent
@@ -141,10 +141,9 @@ export { Popover, PopoverTrigger, PopoverContent, PopoverItem };
141
141
 
142
142
 
143
143
  const PopoverItemStyles = cva(
144
- // Ported from `MenuItemStyles` (ContextMenu/DropdownMenu) so a popover row and a menu row are the
145
- // same object, minus the menu's grey `rgba(184,192,204,0.36)` container — a popover row sits
146
- // directly on the panel. The inner `<div>` is still the row that lights up on hover/focus, and
147
- // `PopoverItem` always renders it, including through `asChild`.
144
+ // `MenuItemStyles` (ContextMenu/DropdownMenu) minus its grey wrapper fill: same 2px gutter, same
145
+ // inner `<div>` that lights up on hover/focus, but the row itself stays transparent on the panel.
146
+ // `PopoverItem` always renders that inner div, including through `asChild`.
148
147
  [
149
148
  "text-content-presentation-global-primary-light typography-body-medium-regular",
150
149
  "outline-none",
@@ -158,6 +157,8 @@ const PopoverItemStyles = cva(
158
157
  "overflow-hidden",
159
158
  "p-[2px]",
160
159
  "transition-all",
160
+ // No `bg-[rgba(184,192,204,0.36)]` here, unlike the menu's item: a popover row sits directly on
161
+ // the panel with no grey container behind it. Everything else is the menu's rule set.
161
162
  "ease-in-out",
162
163
  "duration-300",
163
164
  "[&>div]:flex",
@@ -172,8 +173,9 @@ const PopoverItemStyles = cva(
172
173
  {
173
174
  variants: {
174
175
  variant: {
175
- // The menu uses Radix's `data-highlighted`; a PopoverItem is a plain button, so the
176
- // keyboard-highlight equivalent here is `:focus`.
176
+ // The menu marks its keyboard highlight with Radix's `data-highlighted` and its disabled
177
+ // rows with `data-disabled`; a PopoverItem is a plain <button>, so those become `:focus`
178
+ // and `:disabled` here. Everything else is the menu's rule set verbatim.
177
179
  Default: [
178
180
  "text-content-presentation-global-primary-light",
179
181
  "[&>div]:hover:bg-white-50 [&>div]:hover:shadow-[0_0_16px_0_rgba(0,0,0,0.36)]",
@@ -182,6 +184,7 @@ const PopoverItemStyles = cva(
182
184
  "[&:focus>div]:text-black-1000",
183
185
  "[&:disabled>div]:text-content-presentation-global-primary-light",
184
186
  "[&:disabled>div]:opacity-50",
187
+ "[&:disabled>div]:hover:text-content-presentation-global-primary-light",
185
188
  "[&:disabled>div]:hover:bg-transparent",
186
189
  "[&:disabled>div]:hover:shadow-none",
187
190
  ],
@@ -191,6 +194,11 @@ const PopoverItemStyles = cva(
191
194
  "[&>div]:hover:text-blue-sparkle-700",
192
195
  "[&:focus>div]:bg-white-alpha-75",
193
196
  "[&:focus>div]:text-blue-sparkle-700",
197
+ "[&:disabled>div]:text-content-presentation-global-primary-light",
198
+ "[&:disabled>div]:opacity-50",
199
+ "[&:disabled>div]:hover:text-content-presentation-global-primary-light",
200
+ "[&:disabled>div]:hover:bg-transparent",
201
+ "[&:disabled>div]:hover:shadow-none",
194
202
  ],
195
203
  Negative: [
196
204
  "text-medium-red-200",
@@ -198,6 +206,11 @@ const PopoverItemStyles = cva(
198
206
  "[&>div]:hover:text-medium-red-600",
199
207
  "[&:focus>div]:bg-white-alpha-75",
200
208
  "[&:focus>div]:text-medium-red-600",
209
+ "[&:disabled>div]:text-content-presentation-global-primary-light",
210
+ "[&:disabled>div]:opacity-50",
211
+ "[&:disabled>div]:hover:text-content-presentation-global-primary-light",
212
+ "[&:disabled>div]:hover:bg-transparent",
213
+ "[&:disabled>div]:hover:shadow-none",
201
214
  ],
202
215
  SystemStyle: [
203
216
  "bg-background-system-body-primary",
@@ -247,7 +260,10 @@ const PopoverItemStyles = cva(
247
260
 
248
261
  const popoverStyles = cva(
249
262
  [
250
- "p-1 max-h-[200px] z-[1000] shrink-0",
263
+ // Cap at 200px but never exceed the room Radix actually has after collision handling, so a
264
+ // popover opened near a screen edge shrinks and scrolls instead of being cut off. Consumers that
265
+ // supply their own inner scroller override this with an inline `maxHeight`, which beats the class.
266
+ "p-1 max-h-[min(200px,var(--radix-popover-content-available-height,100vh))] z-[1000] shrink-0",
251
267
  "rounded-[8px]",
252
268
  "border",
253
269
  "min-w-[240px]",
@@ -281,8 +297,6 @@ const popoverStyles = cva(
281
297
  // translucent: over the previous opaque `form-base` the backdrop-blur painted nothing.
282
298
  "bg-[rgba(61,64,69,0.72)]",
283
299
  "shadow-[0_0_32px_2px_rgba(0,0,0,0.20),0_0_48px_2px_rgba(0,0,0,0.05)]",
284
- // Figma's group-container stacks its rows with a 4px gutter.
285
- "flex flex-col gap-1",
286
300
  ],
287
301
  },
288
302
  overlayBlur: {
@@ -249,19 +249,23 @@ export function SearchableSelect({
249
249
  data-theme={theme}
250
250
  ref={popoverContentRef}
251
251
  variant={variant}
252
- style={{ width: dropdownWidth || undefined }}
253
252
  onOpenAutoFocus={(e) => e.preventDefault()}
254
253
  onWheel={(e) => e.stopPropagation()}
255
254
  className={cn(menuContentStyles({ variant }))}
255
+ // The panel carries the cap — `maxVisibleItems` rows, but never more than the room Radix has
256
+ // on screen. Inline beats the `max-h-[…]` class popoverStyles contributes.
257
+ style={{
258
+ width: dropdownWidth || undefined,
259
+ maxHeight: `min(${maxVisibleItems * ROW_HEIGHT}px, var(--radix-popover-content-available-height, 100vh))`,
260
+ }}
256
261
  >
257
- {/* Dedicated scroll viewport: caps height to ~maxVisibleItems rows and
258
- scrolls the rest. Keeping it separate from the popover padding avoids
259
- flex/overflow conflicts that block scrolling. */}
262
+ {/* Dedicated scroll viewport. The height cap lives on the panel above; this just fills what is
263
+ left and scrolls. `min-h-0` is required — a flex item will not shrink below its content, so
264
+ without it the list grew past the panel and was clipped instead of scrolling. */}
260
265
  <div
261
266
  ref={scrollRef}
262
267
  onScroll={handleScroll}
263
- className="overflow-y-auto overflow-x-hidden rounded-[10px] scrollbar-hide"
264
- style={{ maxHeight: maxVisibleItems * ROW_HEIGHT }}
268
+ className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden rounded-[10px] scrollbar-hide"
265
269
  >
266
270
  {filteredOptions.length > 0 && (
267
271
  // Boxed group container — matches the DropdownMenu's auto-grouped look.
@@ -72,6 +72,8 @@ interface Props<T> {
72
72
  */
73
73
  selectableFolders?: boolean;
74
74
  /** Max height (px) of the scrollable tree body before it scrolls (default 320). */
75
+ /** Height cap for the scrolling body, in px. The panel never exceeds this, nor the room Radix
76
+ * has on screen — past it the tree scrolls inside the panel. Default 200. */
75
77
  maxBodyHeight?: number;
76
78
 
77
79
  // --- Async (optional; static `nodes` still works) ---
@@ -112,7 +114,7 @@ export function SearchableTree<T>({
112
114
  className,
113
115
  defaultExpanded = true,
114
116
  selectableFolders = false,
115
- maxBodyHeight = 320,
117
+ maxBodyHeight = 200,
116
118
  filterClientSide = true,
117
119
  onSearchChange,
118
120
  searchDebounceMs = 300,
@@ -299,16 +301,31 @@ export function SearchableTree<T>({
299
301
  variant={variant}
300
302
  align="start"
301
303
  sideOffset={4}
302
- style={{ width: dropdownWidth || undefined }}
304
+ // The panel owns the height: `maxBodyHeight` at most, and never more than the room Radix has
305
+ // on screen. Inline beats popoverStyles' `max-h-[…]` class.
306
+ style={{
307
+ width: dropdownWidth || undefined,
308
+ maxHeight: `min(${maxBodyHeight}px, var(--radix-popover-content-available-height, 100vh))`,
309
+ }}
303
310
  onOpenAutoFocus={(e) => e.preventDefault()}
304
311
  onWheel={(e) => e.stopPropagation()}
305
- className="p-0 border-0 bg-transparent shadow-none rounded-[14px] flex flex-col gap-0 overflow-visible"
312
+ // No `bg-transparent`/`shadow-none`: PopoverContent IS the frosted surface. Forcing it
313
+ // transparent and moving the glass to the inner div double-blurred (an element with
314
+ // backdrop-filter forms a backdrop root for its descendants, so the inner one sampled
315
+ // nothing). `overflow-hidden`, not `overflow-visible` — the list must stay inside the
316
+ // frosted panel rather than spilling rows over bare page content.
317
+ className="p-0 border-0 rounded-[14px] flex flex-col gap-0 overflow-hidden"
306
318
  >
307
319
  {/* SearchResult body — frosted surface holding the "Tree" label + rows. */}
308
- <div className="flex w-full items-start">
320
+ {/* `flex-1 min-h-0 items-stretch`: this wrapper hands the panel's height down to the body.
321
+ With `items-start` the body sized to its own content and escaped the panel instead. */}
322
+ <div className="flex w-full flex-1 min-h-0 items-stretch">
309
323
  <div
310
- className="flex-1 min-w-px overflow-auto scrollbar-hide rounded-[14px] py-[8px] px-[4px] bg-[rgba(61,64,69,0.72)] backdrop-blur-[21px] shadow-[0_0_32px_2px_rgba(0,0,0,0.20),0_0_48px_2px_rgba(0,0,0,0.05)]"
311
- style={{ maxHeight: maxBodyHeight }}
324
+ // Transparent: the frosted surface is the PopoverContent behind it. A second
325
+ // backdrop-filter here would sample nothing anyway.
326
+ // The cap now lives on the panel; this fills what is left and scrolls, which needs
327
+ // `min-h-0` — a flex item will not shrink below its content without it.
328
+ className="flex-1 min-w-px min-h-0 overflow-auto scrollbar-hide rounded-[14px] p-[4px]"
312
329
  >
313
330
  {visibleTree.length > 0 ? (
314
331
  <div className="flex flex-col gap-[4px]">
@@ -59,6 +59,9 @@ interface Props<T> {
59
59
  searchPlaceholder?: string;
60
60
  /** Label shown on the dialog's search field. */
61
61
  title?: string;
62
+ /** Height cap for the scrolling body, in px. Also bounded by 55vh so it shrinks on short
63
+ * screens. Past it the tree scrolls inside the dialog. Default 200. */
64
+ maxBodyHeight?: number;
62
65
 
63
66
  size?: "XS" | "S" | "M";
64
67
  variant?: "SystemStyle" | "PresentationStyle";
@@ -109,6 +112,7 @@ export function SearchableTreeDialog<T>({
109
112
  placeholder = "Select…",
110
113
  searchPlaceholder = "Search…",
111
114
  title = "Select an item",
115
+ maxBodyHeight = 200,
112
116
  size = "M",
113
117
  variant = "PresentationStyle",
114
118
  icon,
@@ -283,7 +287,13 @@ export function SearchableTreeDialog<T>({
283
287
  {/* SearchResult.Container — inset 12px each side, tucked under the bar (z-1). */}
284
288
  <div className="flex w-full items-start px-[12px]">
285
289
  {/* Body — frosted surface that connects to the bar with rounded-bottom. */}
286
- <div className="flex-1 min-w-px max-h-[55vh] overflow-auto scrollbar-hide rounded-b-[14px] pt-[8px] pb-[4px] px-[4px] bg-[rgba(61,64,69,0.72)] backdrop-blur-[21px] shadow-[0_0_32px_2px_rgba(0,0,0,0.20),0_0_48px_2px_rgba(0,0,0,0.05)]">
290
+ <div
291
+ // `max-h-[55vh]` alone never engaged: at a 900px viewport that is 495px, far more than
292
+ // the tree needs, so the body simply grew to fit and there was nothing to scroll.
293
+ // `maxBodyHeight` is the real cap; 55vh still bounds it on short screens.
294
+ style={{ maxHeight: `min(${maxBodyHeight}px, 55vh)` }}
295
+ className="flex-1 min-w-px min-h-0 overflow-auto scrollbar-hide rounded-b-[14px] pt-[8px] pb-[4px] px-[4px] bg-[rgba(61,64,69,0.72)] backdrop-blur-[21px] shadow-[0_0_32px_2px_rgba(0,0,0,0.20),0_0_48px_2px_rgba(0,0,0,0.05)]"
296
+ >
287
297
  {visibleTree.length > 0 ? (
288
298
  <div className="flex flex-col gap-[4px]">
289
299
  {/* "Tree" section label. */}
@@ -4,7 +4,6 @@ import * as SelectPrimitive from "@radix-ui/react-select";
4
4
  import { cn } from "../utils/cn";
5
5
  import { cva, VariantProps } from "class-variance-authority";
6
6
  import { ActionButton } from "./ActionButton";
7
- import { Tooltip } from "./Tooltip";
8
7
  import { MenuItemStyles } from "./DropdownMenu";
9
8
  import { Themes } from "../utils/types";
10
9
 
@@ -18,6 +17,7 @@ const SelectTrigger = React.forwardRef<
18
17
  React.ElementRef<typeof SelectPrimitive.Trigger>,
19
18
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> &
20
19
  VariantProps<typeof PopoverTriggerStyles> & {
20
+ /** Marks the trigger invalid: any non-undefined value turns on the negative border. */
21
21
  errors?: string;
22
22
  icon?: string;
23
23
  theme?: Themes;
@@ -38,52 +38,50 @@ const SelectTrigger = React.forwardRef<
38
38
  ref,
39
39
  ) => {
40
40
  return (
41
- <Tooltip toolTipSide={"top"} open={errors !== undefined} text={errors}>
42
- <SelectPrimitive.Trigger
43
- data-theme={theme}
44
- ref={ref}
45
- className={cn(
46
- PopoverTriggerStyles({
47
- size,
48
- variant,
49
- error: errors !== undefined,
50
- onTable,
51
- }),
52
- className,
53
- )}
54
- {...props}
41
+ <SelectPrimitive.Trigger
42
+ data-theme={theme}
43
+ ref={ref}
44
+ className={cn(
45
+ PopoverTriggerStyles({
46
+ size,
47
+ variant,
48
+ error: errors !== undefined,
49
+ onTable,
50
+ }),
51
+ className,
52
+ )}
53
+ {...props}
54
+ >
55
+ <p
56
+ className={cn({
57
+ "[&_span]:text-[#A0A0A0]": !props.value,
58
+ })}
55
59
  >
56
- <p
57
- className={cn({
58
- "[&_span]:text-[#A0A0A0]": !props.value,
59
- })}
60
- >
61
- {children}
62
- </p>
60
+ {children}
61
+ </p>
63
62
 
64
- {/* The shared in-field action button. Its box tracks the trigger height the same way
65
- InputField's does — a 30px field takes the 22px button, a 40px field the 32px one. */}
66
- <ActionButton
67
- as={"span"}
68
- size={size === "XL" ? "M" : size === "S" ? "XS" : "S"}
69
- className={cn([
70
- "group-aria-expanded:bg-background-presentation-action-hover",
71
- "group-aria-expanded:text-white",
72
- ])}
73
- >
74
- <i
75
- className={cn(
76
- "ri-arrow-down-s-line transition-all duration-100 ease-in-out group-aria-expanded:rotate-180",
77
- { "!text-[12px]": size === "S" },
78
- { "!text-[16px]": size === "M" },
79
- { "!text-[18px]": size === "L" },
80
- { "!text-[26px]": size === "XL" },
81
- { icon: icon },
82
- )}
83
- />
84
- </ActionButton>
85
- </SelectPrimitive.Trigger>
86
- </Tooltip>
63
+ {/* The shared in-field action button. Its box tracks the trigger height the same way
64
+ InputField's does — a 30px field takes the 22px button, a 40px field the 32px one. */}
65
+ <ActionButton
66
+ as={"span"}
67
+ size={size === "XL" ? "M" : size === "S" ? "XS" : "S"}
68
+ className={cn([
69
+ "group-aria-expanded:bg-background-presentation-action-hover",
70
+ "group-aria-expanded:text-white",
71
+ ])}
72
+ >
73
+ <i
74
+ className={cn(
75
+ "ri-arrow-down-s-line transition-all duration-100 ease-in-out group-aria-expanded:rotate-180",
76
+ { "!text-[12px]": size === "S" },
77
+ { "!text-[16px]": size === "M" },
78
+ { "!text-[18px]": size === "L" },
79
+ { "!text-[26px]": size === "XL" },
80
+ { icon: icon },
81
+ )}
82
+ />
83
+ </ActionButton>
84
+ </SelectPrimitive.Trigger>
87
85
  );
88
86
  },
89
87
  );
@@ -140,10 +138,19 @@ const SelectContent = React.forwardRef<
140
138
  ref={ref}
141
139
  className={cn(SelectContentStyles({ variant }), className)}
142
140
  position={position}
141
+ // Never taller than 368px, and never taller than the space Radix has on screen. Valid because
142
+ // `position` defaults to "popper", which is what publishes the variable.
143
+ style={{
144
+ maxHeight: "min(368px, var(--radix-select-content-available-height, 100vh))",
145
+ ...props.style,
146
+ }}
143
147
  {...props}
144
148
  >
145
- {/* Dedicated scroll viewport + boxed group, matching SearchableSelect's menu surface. */}
146
- <SelectPrimitive.Viewport className="overflow-y-auto overflow-x-hidden rounded-[10px] scrollbar-hide">
149
+ {/* Dedicated scroll viewport + boxed group, matching SearchableSelect's menu surface.
150
+ `flex-1 min-h-0` is what makes it scroll: without `min-h-0` a flex item refuses to shrink
151
+ below its content, so the list grew past the panel and `overflow-hidden` simply clipped
152
+ the rows off with no scrollbar. */}
153
+ <SelectPrimitive.Viewport className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden rounded-[10px] scrollbar-hide">
147
154
  <div className="flex flex-col gap-[1px] overflow-hidden rounded-[10px]">{children}</div>
148
155
  </SelectPrimitive.Viewport>
149
156
  </SelectPrimitive.Content>
@@ -231,7 +238,8 @@ const SelectContentStyles = cva(
231
238
  "data-[state=open]:animate-in",
232
239
  "data-[state=open]:fade-in-0",
233
240
  "z-[1000]",
234
- "max-h-[368px]",
241
+ // Panel height is set inline below from `min(368px, available-height)`; the class is kept off
242
+ // deliberately so the inline value governs.
235
243
  ],
236
244
  {
237
245
  variants: {