torch-glare 2.5.4 → 2.5.6

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 (60) hide show
  1. package/apps/lib/components/BadgeField.tsx +138 -69
  2. package/apps/lib/components/Button.tsx +10 -2
  3. package/apps/lib/components/Card.tsx +2 -1
  4. package/apps/lib/components/ContextMenu.tsx +65 -22
  5. package/apps/lib/components/DataViews/context.ts +2 -2
  6. package/apps/lib/components/DataViews/data-views.tsx +20 -8
  7. package/apps/lib/components/DataViews/filters/filters.tsx +0 -2
  8. package/apps/lib/components/DataViews/index.ts +8 -4
  9. package/apps/lib/components/DataViews/slots.ts +9 -0
  10. package/apps/lib/components/DataViews/states.tsx +43 -8
  11. package/apps/lib/components/DataViews/views/table-view.tsx +184 -176
  12. package/apps/lib/components/Drawer.tsx +70 -39
  13. package/apps/lib/components/DropdownMenu.tsx +79 -22
  14. package/apps/lib/components/FormBuilder/context.ts +12 -0
  15. package/apps/lib/components/FormBuilder/fields/FieldShell.tsx +38 -19
  16. package/apps/lib/components/FormBuilder/fields/SelectField.tsx +31 -8
  17. package/apps/lib/components/FormBuilder/submit.tsx +21 -1
  18. package/apps/lib/components/FormBuilder/types.ts +21 -0
  19. package/apps/lib/components/FormRenderer/FormDrawer.tsx +139 -17
  20. package/apps/lib/components/FormRenderer/detail.tsx +57 -8
  21. package/apps/lib/components/FormRenderer/form-renderer.tsx +82 -10
  22. package/apps/lib/components/FormRenderer/index.ts +2 -0
  23. package/apps/lib/components/FormRenderer/notch-action.tsx +64 -0
  24. package/apps/lib/components/FormRenderer/stepper.tsx +56 -2
  25. package/apps/lib/components/FormRenderer/types.ts +37 -0
  26. package/apps/lib/components/HeaderBar.tsx +51 -53
  27. package/apps/lib/components/InputField.tsx +46 -47
  28. package/apps/lib/components/Popover.tsx +23 -9
  29. package/apps/lib/components/SearchableSelect.tsx +10 -6
  30. package/apps/lib/components/SearchableTree.tsx +23 -6
  31. package/apps/lib/components/SearchableTreeDialog.tsx +11 -1
  32. package/apps/lib/components/SectionBlock.tsx +24 -3
  33. package/apps/lib/components/Select.tsx +64 -56
  34. package/apps/lib/components/SlideDatePicker.tsx +5 -5
  35. package/apps/lib/components/TabSwitch.tsx +18 -12
  36. package/apps/lib/components/Table.tsx +15 -28
  37. package/apps/lib/hooks/useActiveTreeItem.ts +4 -1
  38. package/apps/lib/hooks/useHtmlDir.ts +31 -0
  39. package/apps/lib/hooks/useTagSelection.ts +95 -9
  40. package/apps/lib/layouts/FieldSection.tsx +28 -2
  41. package/apps/lib/registry.json +20 -5
  42. package/apps/lib/utils/scroller.ts +26 -0
  43. package/docs/components/badge-field.md +30 -4
  44. package/docs/components/context-menu.md +3 -1
  45. package/docs/components/data-views/examples/filters.md +0 -1
  46. package/docs/components/data-views/index.md +32 -22
  47. package/docs/components/data-views/migration.md +7 -5
  48. package/docs/components/drawer.md +5 -5
  49. package/docs/components/dropdown-menu.md +3 -0
  50. package/docs/components/form-builder.md +36 -2
  51. package/docs/components/form-renderer.md +71 -1
  52. package/docs/components/header-bar.md +3 -2
  53. package/docs/components/input-field.md +3 -3
  54. package/docs/components/section-block.md +6 -0
  55. package/docs/components/select.md +1 -1
  56. package/docs/migration/changelog.md +19 -0
  57. package/docs/reference/hooks.md +23 -0
  58. package/docs/reference/utilities.md +22 -0
  59. package/package.json +1 -1
  60. package/apps/lib/components/DataViews/filters/summary.tsx +0 -65
@@ -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. */}
@@ -1,9 +1,13 @@
1
1
  import { forwardRef, HTMLAttributes, ReactNode } from "react";
2
2
  import { cva, type VariantProps } from "class-variance-authority";
3
3
  import { cn } from "../utils/cn";
4
+ import { horizontalScrollerStyles } from "../utils/scroller";
4
5
 
6
+ // LOCAL PATCH (Contact Center): the pill's side padding is asymmetric (16 leading / 22 trailing),
7
+ // so it must be logical — `ps`/`pe` rather than `pl`/`pr`. This pill heads every section card, so
8
+ // mirrored the wrong way it reads as a systematic misalignment across the whole page.
5
9
  const titleBadge = cva(
6
- "flex pt-2 pb-2 pl-[16px] pr-[22px] justify-center items-center gap-[6px] rounded-[10px] self-start typography-headers-medium-medium text-[#F4F4F4]",
10
+ "flex pt-2 pb-2 ps-[16px] pe-[22px] justify-center items-center gap-[6px] rounded-[10px] self-start typography-headers-medium-medium text-[#F4F4F4]",
7
11
  {
8
12
  variants: {
9
13
  color: {
@@ -56,12 +60,29 @@ const header = cva("flex px-[6px] justify-between gap-3", {
56
60
  defaultVariants: { variant: "Default" },
57
61
  });
58
62
 
59
- const body = cva("flex w-full flex-col", {
63
+ // LOCAL PATCH (Contact Center): the body is the section's horizontal scrollport.
64
+ //
65
+ // `Table` renders `overflow-visible w-auto` (so its sticky header can reach a real scrollport), so
66
+ // a table wider than its card does not clip or scroll itself — it widens the card, and then the
67
+ // page. Only one call site in the app wraps its table in `TableScroller`; the rest drop a bare
68
+ // `<Table>` straight into a section. Owning the scroll here contains all of them at once, and
69
+ // replaces the `Table` variant's old `overflow-hidden`, which truncated instead of scrolling.
70
+ //
71
+ // `overflow-y-hidden` is required, not decorative: CSS computes `overflow-y: visible` to `auto`
72
+ // whenever `overflow-x` is not `visible`, so without it every section grows a spurious vertical
73
+ // scrollbar. The body is content-height, so nothing is clipped vertically. Same reasoning, and the
74
+ // same scrollbar styling, as `TableScroller`.
75
+ //
76
+ // The cost: a scrollport is the containing block for `position: sticky`, so a `<Table>`'s sticky
77
+ // header inside a section now pins to a box that never scrolls vertically — i.e. it stops
78
+ // sticking. Sections hold short tables, and in `variant="Table"` those headers were already inert
79
+ // under the old `overflow-hidden`.
80
+ const body = cva(`flex w-full flex-col ${horizontalScrollerStyles}`, {
60
81
  variants: {
61
82
  variant: {
62
83
  Default: "px-[42px] gap-[2px]",
63
84
  // Full bleed, with the rule that separates the header from the table.
64
- Table: "mt-[6px] border-t border-border-presentation-global-primary overflow-hidden",
85
+ Table: "mt-[6px] border-t border-border-presentation-global-primary",
65
86
  },
66
87
  },
67
88
  defaultVariants: { variant: "Default" },
@@ -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
 
@@ -17,11 +16,12 @@ const SelectValue = SelectPrimitive.Value;
17
16
  const SelectTrigger = React.forwardRef<
18
17
  React.ElementRef<typeof SelectPrimitive.Trigger>,
19
18
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> &
20
- VariantProps<typeof PopoverTriggerStyles> & {
21
- errors?: string;
22
- icon?: string;
23
- theme?: Themes;
24
- }
19
+ VariantProps<typeof PopoverTriggerStyles> & {
20
+ /** Marks the trigger invalid: any non-undefined value turns on the negative border. */
21
+ errors?: string;
22
+ icon?: string;
23
+ theme?: Themes;
24
+ }
25
25
  >(
26
26
  (
27
27
  {
@@ -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
  );
@@ -126,9 +124,9 @@ SelectScrollDownButton.displayName = "SelectScrollDownButton";
126
124
  const SelectContent = React.forwardRef<
127
125
  React.ElementRef<typeof SelectPrimitive.Content>,
128
126
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> &
129
- VariantProps<typeof SelectContentStyles> & {
130
- theme?: Themes;
131
- }
127
+ VariantProps<typeof SelectContentStyles> & {
128
+ theme?: Themes;
129
+ }
132
130
  >(
133
131
  (
134
132
  { className, children, variant = "PresentationStyle", position = "popper", theme, ...props },
@@ -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: {
@@ -149,11 +149,11 @@ export const SlideDatePicker = forwardRef<HTMLInputElement, SlideDatePickerProps
149
149
 
150
150
  return (
151
151
  <Popover onOpenChange={setIsOpen}>
152
- {/* The theme goes to the field as a prop, not as `data-theme` on the trigger. `asChild`
153
- merges trigger props onto the child, which lands the attribute on the bare <input> — the
154
- text then resolves dark tokens while the field around it follows the page, and on a light
155
- page the value is white on white. Passing `theme` through means the whole field is dark
156
- together, so it stays dark *and* readable. */}
152
+ {/* `theme` is deliberately NOT passed to the field: only the picker panel below is pinned
153
+ dark. The field follows the surrounding page instead, so it matches the other inputs
154
+ next to it. Note it must not be handed down as `data-theme` either — `asChild` merges
155
+ trigger props onto the child, which would land the attribute on the bare <input> and
156
+ leave its text resolving dark tokens inside a light field (white on white). */}
157
157
  <PopoverTrigger ref={triggerRef} asChild className="w-full flex-1">
158
158
  {isValidElement(children) ? (
159
159
  cloneElement(children as React.ReactElement<HTMLInputElement & { theme?: string }>, {
@@ -31,13 +31,16 @@ const trackStyles = cva(
31
31
  "rounded-[10px]",
32
32
  "bg-background-presentation-body-primary",
33
33
  "shadow-[inset_0_0_4px_0_rgba(0,0,0,0.08)]",
34
+ // Contains the active pill's drop-shadow: it is clipped at the track's rounded edge instead
35
+ // of bleeding onto whatever sits behind the switcher.
36
+ "overflow-hidden",
34
37
  ],
35
38
  {
36
39
  variants: {
37
40
  size: {
38
- S: ["gap-[2px] p-[2px]"],
39
- M: ["gap-[2px] p-[2px]"],
40
- L: ["gap-[3px] p-[3px]"],
41
+ S: ["p-[2px]"],
42
+ M: ["p-[2px]"],
43
+ L: ["p-[3px] rounded-[12px]"],
41
44
  },
42
45
  },
43
46
  defaultVariants: { size: "M" },
@@ -48,7 +51,7 @@ const trackStyles = cva(
48
51
  // transparent and lighten on hover.
49
52
  const optionStyles = cva(
50
53
  [
51
- "flex items-center justify-center gap-[4px]",
54
+ "flex items-center justify-center",
52
55
  "rounded-[8px]",
53
56
  "font-[510] leading-none",
54
57
  "transition-all duration-200 ease-in-out",
@@ -61,7 +64,7 @@ const optionStyles = cva(
61
64
  size: {
62
65
  S: ["h-5 px-2 text-[12px]", "[&_svg]:h-3 [&_svg]:w-3", "[&_i]:text-[12px]"],
63
66
  M: ["h-6 px-3 text-[14px]", "[&_svg]:h-[14px] [&_svg]:w-[14px]", "[&_i]:text-[14px]"],
64
- L: ["h-8 px-4 text-[16px]", "[&_svg]:h-4 [&_svg]:w-4", "[&_i]:text-[16px]"],
67
+ L: ["h-8 px-4 text-[16px] rounded-[10px]", "[&_svg]:h-4 [&_svg]:w-4", "[&_i]:text-[16px]"],
65
68
  },
66
69
  active: {
67
70
  // Active option = a solid raised WHITE pill with dark text, in every
@@ -78,6 +81,8 @@ const optionStyles = cva(
78
81
  // keeps both states the same width — without it, selecting a tab resizes the pill and
79
82
  // shoves its neighbours. The design's own 4px gap absorbs the 1px.
80
83
  "border border-black/5",
84
+ // The pill casts outward to look raised; the track clips it (`overflow-hidden` on
85
+ // `trackStyles`) so the shadow never spills past the container.
81
86
  "drop-shadow-[0px_0px_5px_rgba(0,0,0,0.25)]",
82
87
  ],
83
88
  false: [
@@ -116,22 +121,23 @@ function TabSwitchInner<T extends string = string>(
116
121
  >
117
122
  {options.map((option, idx) => {
118
123
  const active = option.value === value;
124
+ const isDisabled = disabled || option.disabled;
125
+ // A divider is drawn only between two INACTIVE options — the raised pill is never flanked
126
+ // by a rule, so the two dividers touching it fade out.
119
127
  const prevActive = idx > 0 && options[idx - 1].value === value;
120
- // A divider sits between two inactive options only — the active pill is
121
- // never flanked by one.
122
128
  const showDivider = idx > 0 && !active && !prevActive;
123
- const isDisabled = disabled || option.disabled;
124
129
 
125
130
  return (
126
131
  <div key={option.value} className="flex items-center">
127
132
  {idx > 0 && (
128
133
  <div
129
134
  aria-hidden
135
+ // The slot is ALWAYS rendered and only its ink changes. Unmounting it instead cost
136
+ // the track 7px (`w-px` + `mx-[3px]`) every time one went away, so picking a tab
137
+ // resized the track and shoved its neighbours — with three options, selecting the
138
+ // middle one dropped both dividers and moved the track 14px.
130
139
  className={cn(
131
- // The slot is always here, and only its ink changes. Unmounting it instead cost
132
- // the track 7px every time the rule below hid one, so picking a tab resized the
133
- // track and shoved its neighbours sideways.
134
- "mx-[3px] h-3 w-px",
140
+ "h-3 w-px",
135
141
  showDivider ? "bg-border-presentation-action-disabled" : "bg-transparent",
136
142
  )}
137
143
  />