@estiva-app/ui 0.3.0 → 0.5.0

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.js CHANGED
@@ -75,16 +75,25 @@ function AppShell({ variant = "solid", menu, logo, search, identity, banner, nav
75
75
  ] })
76
76
  ] });
77
77
  }
78
- return /* @__PURE__ */ jsxs2("div", { className: "flex h-full min-h-0 flex-col bg-bg-base text-text-primary", children: [
79
- bar,
80
- /* @__PURE__ */ jsxs2("div", { className: "flex min-h-0 flex-1", children: [
81
- nav,
82
- /* @__PURE__ */ jsxs2("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col", children: [
83
- banner,
84
- /* @__PURE__ */ jsx2("main", { className: "min-w-0 flex-1 overflow-y-auto", children })
78
+ return (
79
+ /* `relative overflow-hidden` is the seal the floating manner already has,
80
+ and it takes both halves: an absolutely positioned descendant with no
81
+ positioned ancestor belongs to the *viewport*, so a scroll container
82
+ never clips it and the document itself gains its position as scroll
83
+ range — the whole page scrolls, navigation and top bar included.
84
+ `relative` claims such strays for the shell; `overflow-hidden` clips
85
+ them at its edge. Either alone seals nothing. */
86
+ /* @__PURE__ */ jsxs2("div", { className: "relative flex h-full min-h-0 flex-col overflow-hidden bg-bg-base text-text-primary", children: [
87
+ bar,
88
+ /* @__PURE__ */ jsxs2("div", { className: "flex min-h-0 flex-1", children: [
89
+ nav,
90
+ /* @__PURE__ */ jsxs2("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col", children: [
91
+ banner,
92
+ /* @__PURE__ */ jsx2("main", { className: "min-w-0 flex-1 overflow-y-auto", children })
93
+ ] })
85
94
  ] })
86
95
  ] })
87
- ] });
96
+ );
88
97
  }
89
98
 
90
99
  // src/Avatar.tsx
@@ -109,7 +118,7 @@ function Avatar({ src, name, alt = "", size = 36, className }) {
109
118
  return /* @__PURE__ */ jsx3("div", { className: cn("rounded-sm overflow-hidden shrink-0 bg-bg-inset", className), style: { width: size, height: size }, children: picture ? /* @__PURE__ */ jsx3("img", { src: picture, alt: alt || name || "", className: "w-full h-full object-cover", onError: () => setBroken(true) }) : label ? /* @__PURE__ */ jsx3(
110
119
  "div",
111
120
  {
112
- className: "w-full h-full flex items-center justify-center font-semibold",
121
+ className: "w-full h-full flex items-center justify-center font-semibold leading-none",
113
122
  style: {
114
123
  color: "#08121c",
115
124
  fontSize: Math.round(size * 0.36),
@@ -122,18 +131,25 @@ function Avatar({ src, name, alt = "", size = 36, className }) {
122
131
 
123
132
  // src/AvatarGroup.tsx
124
133
  import { jsx as jsx4 } from "react/jsx-runtime";
125
- function AvatarGroup({ members }) {
134
+ function AvatarGroup({ members, size = 24 }) {
126
135
  const visible = members.slice(0, 3);
127
- return /* @__PURE__ */ jsx4("div", { className: "flex items-center pr-2", children: visible.map((member, i) => /* @__PURE__ */ jsx4(
128
- Avatar,
129
- {
130
- size: 24,
131
- name: member.name,
132
- src: member.picture,
133
- alt: member.name,
134
- className: "-mr-2 relative border-2 border-bg-surface"
135
- },
136
- i
136
+ const overlap = Math.round(size / 3);
137
+ const ring = size / 12;
138
+ return /* @__PURE__ */ jsx4("div", { className: "flex items-center", style: { paddingRight: overlap }, children: visible.map((member, i) => (
139
+ /*
140
+ The wrapper carries the overlap and the ring; the face carries
141
+ neither. It must never clip — see the note at the top of the file —
142
+ so it has the avatar's own radius and no overflow of its own.
143
+ */
144
+ /* @__PURE__ */ jsx4(
145
+ "span",
146
+ {
147
+ className: "relative flex rounded-sm",
148
+ style: { marginRight: -overlap, boxShadow: `0 0 0 ${ring}px var(--bg-surface)` },
149
+ children: /* @__PURE__ */ jsx4(Avatar, { size, name: member.name, src: member.picture, alt: member.name })
150
+ },
151
+ i
152
+ )
137
153
  )) });
138
154
  }
139
155
 
@@ -238,12 +254,48 @@ function Chip({ type = "neutral", label, leadingIcon, trailingIcon, className })
238
254
  }
239
255
 
240
256
  // src/ChipInput.tsx
241
- import { useState as useState2, useRef as useRef2, useMemo, useEffect as useEffect2, useLayoutEffect } from "react";
257
+ import { useState as useState3, useRef as useRef2, useMemo, useEffect as useEffect2, useLayoutEffect as useLayoutEffect2 } from "react";
242
258
  import { createPortal as createPortal2 } from "react-dom";
243
259
  import { IconX } from "@tabler/icons-react";
244
260
 
261
+ // src/fit.ts
262
+ var MARGIN = 8;
263
+ var GAP = 4;
264
+ function fitMenu({
265
+ anchor,
266
+ menu,
267
+ viewport,
268
+ cap = Number.POSITIVE_INFINITY
269
+ }) {
270
+ const left = Math.max(MARGIN, Math.min(anchor.left, viewport.width - menu.width - MARGIN));
271
+ const below = viewport.height - anchor.bottom - GAP - MARGIN;
272
+ const above = anchor.top - GAP - MARGIN;
273
+ const openUp = below < Math.min(menu.contentHeight, cap) && above > below;
274
+ const maxHeight = Math.max(Math.min(cap, openUp ? above : below), 120);
275
+ return openUp ? { left, bottom: viewport.height - anchor.top + GAP, maxHeight } : { left, top: anchor.bottom + GAP, maxHeight };
276
+ }
277
+ function clampBox({
278
+ box,
279
+ viewport
280
+ }) {
281
+ const left = Math.max(MARGIN, Math.min(box.left, viewport.width - box.width - MARGIN));
282
+ const maxHeight = Math.max(Math.min(box.height, viewport.height - 2 * MARGIN), 120);
283
+ const top = Math.max(MARGIN, Math.min(box.top, viewport.height - maxHeight - MARGIN));
284
+ return { left, top, maxHeight };
285
+ }
286
+ function fitSubmenu({
287
+ row,
288
+ panel,
289
+ viewport
290
+ }) {
291
+ const fitsRight = row.right + GAP + panel.width <= viewport.width - MARGIN;
292
+ const left = Math.max(MARGIN, fitsRight ? row.right + GAP : row.left - GAP - panel.width);
293
+ const top = Math.max(MARGIN, Math.min(row.top, viewport.height - panel.height - MARGIN));
294
+ return { left, top };
295
+ }
296
+
245
297
  // src/Menu.tsx
246
- import { useEffect, useRef } from "react";
298
+ import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState as useState2 } from "react";
247
299
  import { IconChevronRight } from "@tabler/icons-react";
248
300
  import { createPortal } from "react-dom";
249
301
 
@@ -264,8 +316,19 @@ function SectionLabel({ children, className }) {
264
316
 
265
317
  // src/Menu.tsx
266
318
  import { jsx as jsx9, jsxs as jsxs5 } from "react/jsx-runtime";
267
- function Menu({ onClose, position, children, className }) {
319
+ var MenuHoverContext = createContext(null);
320
+ function Menu({ onClose, anchor, align = "left", position, closeOnLeave = false, children, className }) {
268
321
  const ref = useRef(null);
322
+ const leaveTimer = useRef(void 0);
323
+ const hold = useCallback(() => clearTimeout(leaveTimer.current), []);
324
+ const release = useCallback(() => {
325
+ if (!closeOnLeave) return;
326
+ clearTimeout(leaveTimer.current);
327
+ leaveTimer.current = setTimeout(onClose, 150);
328
+ }, [closeOnLeave, onClose]);
329
+ useEffect(() => () => clearTimeout(leaveTimer.current), []);
330
+ const portalled = Boolean(anchor || position);
331
+ const [placed, setPlaced] = useState2(null);
269
332
  useEffect(() => {
270
333
  const onDown = (event) => {
271
334
  if (!ref.current?.contains(event.target)) onClose();
@@ -280,8 +343,46 @@ function Menu({ onClose, position, children, className }) {
280
343
  document.removeEventListener("keydown", onKey);
281
344
  };
282
345
  }, [onClose]);
283
- const style = position ? { top: position.top, ..."left" in position ? { left: position.left } : { right: position.right } } : void 0;
284
- const node = /* @__PURE__ */ jsx9(
346
+ const posLeft = position && "left" in position ? position.left : void 0;
347
+ const posRight = position && "right" in position ? position.right : void 0;
348
+ const posTop = position?.top;
349
+ useLayoutEffect(() => {
350
+ if (!portalled || !ref.current) return;
351
+ const viewport = { width: window.innerWidth, height: window.innerHeight };
352
+ const menu = ref.current;
353
+ if (anchor) {
354
+ const rect = anchor instanceof Element ? anchor.getBoundingClientRect() : anchor;
355
+ const left = align === "right" ? rect.right - menu.offsetWidth : rect.left;
356
+ setPlaced(
357
+ fitMenu({
358
+ anchor: { left, top: rect.top, bottom: rect.bottom },
359
+ menu: { width: menu.offsetWidth, contentHeight: menu.scrollHeight },
360
+ viewport
361
+ })
362
+ );
363
+ } else if (posTop !== void 0) {
364
+ const left = posLeft ?? viewport.width - (posRight ?? 0) - menu.offsetWidth;
365
+ setPlaced(clampBox({ box: { left, top: posTop, width: menu.offsetWidth, height: menu.offsetHeight }, viewport }));
366
+ }
367
+ }, [portalled, anchor, align, posLeft, posRight, posTop]);
368
+ useEffect(() => {
369
+ if (!anchor) return;
370
+ const onScroll = (event) => {
371
+ if (event.target instanceof Node && ref.current?.contains(event.target)) return;
372
+ onClose();
373
+ };
374
+ window.addEventListener("resize", onClose);
375
+ window.addEventListener("scroll", onScroll, true);
376
+ return () => {
377
+ window.removeEventListener("resize", onClose);
378
+ window.removeEventListener("scroll", onScroll, true);
379
+ };
380
+ }, [anchor, onClose]);
381
+ const style = portalled ? placed ? { left: placed.left, top: placed.top, bottom: placed.bottom, maxHeight: placed.maxHeight } : (
382
+ // The provisional render: measured by the layout effect, never seen.
383
+ { left: 0, top: 0, visibility: "hidden" }
384
+ ) : void 0;
385
+ const node = /* @__PURE__ */ jsx9(MenuHoverContext.Provider, { value: { hold, release }, children: /* @__PURE__ */ jsx9(
285
386
  "div",
286
387
  {
287
388
  ref,
@@ -289,15 +390,68 @@ function Menu({ onClose, position, children, className }) {
289
390
  "data-interactive": true,
290
391
  className: cn(
291
392
  "z-50 flex min-w-[180px] flex-col rounded-lg border border-border-default bg-bg-elevated p-2 shadow-lg",
292
- position ? "fixed" : "absolute right-0 top-full mt-1",
393
+ portalled ? "fixed overflow-y-auto" : "absolute right-0 top-full mt-1",
293
394
  className
294
395
  ),
295
396
  style,
296
397
  onClick: (event) => event.stopPropagation(),
398
+ onMouseEnter: closeOnLeave ? hold : void 0,
399
+ onMouseLeave: closeOnLeave ? release : void 0,
297
400
  children
298
401
  }
299
- );
300
- return position ? createPortal(node, document.body) : node;
402
+ ) });
403
+ return portalled ? createPortal(node, document.body) : node;
404
+ }
405
+ function MenuSub({ label, leading, selected, children, className }) {
406
+ const [open, setOpen] = useState2(false);
407
+ const rowRef = useRef(null);
408
+ const panelRef = useRef(null);
409
+ const closeTimer = useRef(void 0);
410
+ const [placed, setPlaced] = useState2(null);
411
+ const menuHover = useContext(MenuHoverContext);
412
+ const enter = () => {
413
+ clearTimeout(closeTimer.current);
414
+ menuHover?.hold();
415
+ setOpen(true);
416
+ };
417
+ const leave = () => {
418
+ closeTimer.current = setTimeout(() => setOpen(false), 150);
419
+ menuHover?.release();
420
+ };
421
+ useEffect(() => () => clearTimeout(closeTimer.current), []);
422
+ useLayoutEffect(() => {
423
+ if (!open || !rowRef.current || !panelRef.current) {
424
+ setPlaced(null);
425
+ return;
426
+ }
427
+ const row = rowRef.current.getBoundingClientRect();
428
+ setPlaced(
429
+ fitSubmenu({
430
+ row: { left: row.left, right: row.right, top: row.top },
431
+ panel: { width: panelRef.current.offsetWidth, height: panelRef.current.offsetHeight },
432
+ viewport: { width: window.innerWidth, height: window.innerHeight }
433
+ })
434
+ );
435
+ }, [open]);
436
+ return /* @__PURE__ */ jsxs5("div", { ref: rowRef, onMouseEnter: enter, onMouseLeave: leave, children: [
437
+ /* @__PURE__ */ jsx9(MenuItem, { label, leading, selected, submenu: true }),
438
+ open && createPortal(
439
+ /* @__PURE__ */ jsx9(
440
+ "div",
441
+ {
442
+ ref: panelRef,
443
+ role: "menu",
444
+ "data-interactive": true,
445
+ className: cn("fixed z-50 flex w-[160px] flex-col rounded-lg border border-border-default bg-bg-elevated p-2 shadow-lg", className),
446
+ style: placed ?? { left: 0, top: 0, visibility: "hidden" },
447
+ onMouseEnter: enter,
448
+ onMouseLeave: leave,
449
+ children
450
+ }
451
+ ),
452
+ document.body
453
+ )
454
+ ] });
301
455
  }
302
456
  function MenuItem({ label, children, size = "default", description, leading, trailing, shortcut, submenu, destructive, selected, className, ...props }) {
303
457
  const edge = trailing ?? (shortcut ? /* @__PURE__ */ jsx9("kbd", { className: "inline-flex shrink-0 items-center justify-center rounded-sm border border-border-strong bg-bg-inset px-1 py-px text-caption text-text-secondary", children: shortcut }) : submenu ? /* @__PURE__ */ jsx9(IconChevronRight, { size: 16, stroke: 1.5, className: "shrink-0 text-text-muted" }) : null);
@@ -392,10 +546,10 @@ function ChipInput({
392
546
  chipLeading,
393
547
  rowLeading
394
548
  }) {
395
- const [query, setQuery] = useState2("");
396
- const [highlight, setHighlight] = useState2(0);
397
- const [isFocused, setIsFocused] = useState2(false);
398
- const [anchorRect, setAnchorRect] = useState2(null);
549
+ const [query, setQuery] = useState3("");
550
+ const [highlight, setHighlight] = useState3(0);
551
+ const [isFocused, setIsFocused] = useState3(false);
552
+ const [anchorRect, setAnchorRect] = useState3(null);
399
553
  const wrapperRef = useRef2(null);
400
554
  const inputRef = useRef2(null);
401
555
  const matches = useMemo(() => {
@@ -413,7 +567,7 @@ function ChipInput({
413
567
  setHighlight(0);
414
568
  }, [query, matches.length]);
415
569
  const showDropdown = isFocused && query.trim().length > 0 && matches.length > 0;
416
- useLayoutEffect(() => {
570
+ useLayoutEffect2(() => {
417
571
  if (!showDropdown) return;
418
572
  const update = () => {
419
573
  if (wrapperRef.current) setAnchorRect(wrapperRef.current.getBoundingClientRect());
@@ -496,10 +650,14 @@ function ChipInput({
496
650
  /* @__PURE__ */ jsx10(
497
651
  "div",
498
652
  {
499
- className: "fixed z-[60] max-h-[240px] overflow-y-auto bg-bg-elevated border border-border-default rounded-lg shadow-lg",
653
+ className: "fixed z-[60] overflow-y-auto bg-bg-elevated border border-border-default rounded-lg shadow-lg",
500
654
  style: {
501
- top: anchorRect.bottom + 4,
502
- left: anchorRect.left,
655
+ ...fitMenu({
656
+ anchor: { left: anchorRect.left, top: anchorRect.top, bottom: anchorRect.bottom },
657
+ menu: { width: anchorRect.width, contentHeight: matches.length * 48 },
658
+ viewport: { width: window.innerWidth, height: window.innerHeight },
659
+ cap: 240
660
+ }),
503
661
  width: anchorRect.width
504
662
  },
505
663
  children: matches.map((o, i) => /* @__PURE__ */ jsx10(
@@ -527,31 +685,34 @@ function ChipInput({
527
685
  }
528
686
 
529
687
  // src/Tooltip.tsx
530
- import { useCallback, useLayoutEffect as useLayoutEffect2, useRef as useRef3, useState as useState3 } from "react";
688
+ import { useCallback as useCallback2, useLayoutEffect as useLayoutEffect3, useRef as useRef3, useState as useState4 } from "react";
531
689
  import { createPortal as createPortal3 } from "react-dom";
532
690
  import { jsx as jsx11, jsxs as jsxs7 } from "react/jsx-runtime";
533
691
  function Tooltip({ label, className }) {
534
692
  return /* @__PURE__ */ jsx11("div", { role: "tooltip", className: cn("bg-bg-elevated border border-border-default rounded-lg h-[30px] flex items-center justify-center px-2 shadow-lg", className), children: /* @__PURE__ */ jsx11("span", { className: "text-caption text-text-primary whitespace-nowrap", children: label }) });
535
693
  }
536
- var GAP = 6;
694
+ var GAP2 = 6;
537
695
  var VIEWPORT_PAD = 8;
538
696
  function WithTooltip({ label, placement = "top", wrapperClassName, children }) {
539
- const [show, setShow] = useState3(false);
697
+ const [show, setShow] = useState4(false);
540
698
  const ref = useRef3(null);
541
699
  const tooltipRef = useRef3(null);
542
- const [style, setStyle] = useState3({ position: "fixed", zIndex: 9999, pointerEvents: "none", visibility: "hidden" });
543
- const reposition = useCallback(() => {
700
+ const [style, setStyle] = useState4({ position: "fixed", zIndex: 9999, pointerEvents: "none", visibility: "hidden" });
701
+ const reposition = useCallback2(() => {
544
702
  const trigger = ref.current;
545
703
  const tip = tooltipRef.current;
546
704
  if (!trigger || !tip) return;
547
705
  const triggerRect = trigger.getBoundingClientRect();
548
706
  const tipRect = tip.getBoundingClientRect();
549
- const top = placement === "bottom" ? triggerRect.bottom + GAP : triggerRect.top - tipRect.height - GAP;
707
+ let top = placement === "bottom" ? triggerRect.bottom + GAP2 : triggerRect.top - tipRect.height - GAP2;
708
+ if (placement === "top" && top < VIEWPORT_PAD) top = triggerRect.bottom + GAP2;
709
+ else if (placement === "bottom" && top + tipRect.height > window.innerHeight - VIEWPORT_PAD) top = triggerRect.top - tipRect.height - GAP2;
710
+ top = Math.max(VIEWPORT_PAD, Math.min(top, window.innerHeight - tipRect.height - VIEWPORT_PAD));
550
711
  let left = triggerRect.left + triggerRect.width / 2 - tipRect.width / 2;
551
712
  left = Math.max(VIEWPORT_PAD, Math.min(left, window.innerWidth - tipRect.width - VIEWPORT_PAD));
552
713
  setStyle({ position: "fixed", top, left, zIndex: 9999, pointerEvents: "none", visibility: "visible" });
553
714
  }, [placement]);
554
- useLayoutEffect2(() => {
715
+ useLayoutEffect3(() => {
555
716
  if (show) reposition();
556
717
  }, [show, reposition]);
557
718
  return /* @__PURE__ */ jsxs7("div", { ref, className: cn("inline-flex shrink-0", wrapperClassName), onMouseEnter: () => setShow(true), onMouseLeave: () => setShow(false), children: [
@@ -602,7 +763,7 @@ function IconButton({
602
763
  }
603
764
 
604
765
  // src/IdentityMenu.tsx
605
- import { useState as useState4 } from "react";
766
+ import { useRef as useRef4, useState as useState5 } from "react";
606
767
 
607
768
  // src/Divider.tsx
608
769
  import { jsx as jsx13 } from "react/jsx-runtime";
@@ -673,13 +834,13 @@ function PersonTrigger({ name, picture, fallback, size, open = false, compact =
673
834
 
674
835
  // src/IdentityMenu.tsx
675
836
  import { Fragment, jsx as jsx16, jsxs as jsxs10 } from "react/jsx-runtime";
676
- function IdentityPanel({ me, signedIn, relayUrl, idBase, onCopyKey, onSignOut, onClose, className, children }) {
837
+ function IdentityPanel({ me, signedIn, relayUrl, idBase, onCopyKey, onSignOut, onClose, anchor, className, children }) {
677
838
  const act = (action) => () => {
678
839
  onClose();
679
840
  action();
680
841
  };
681
842
  const appRows = typeof children === "function" ? children(onClose) : children;
682
- return /* @__PURE__ */ jsxs10(Menu, { onClose, className: cn("w-72", className), children: [
843
+ return /* @__PURE__ */ jsxs10(Menu, { onClose, anchor, align: "right", className: cn("w-72", className), children: [
683
844
  /* @__PURE__ */ jsxs10(MenuSection, { label: signedIn ? "Signed in as" : "Acting as", children: [
684
845
  /* @__PURE__ */ jsx16(MenuRow, { children: /* @__PURE__ */ jsx16(Person, { name: me.name, picture: me.picture, fallback: "Anonymous", size: 28, className: "text-body-2-strong" }) }),
685
846
  /* @__PURE__ */ jsx16(Note, { children: signedIn ? "Your Estiva ID. The same person you are in every Estiva app." : "A key held by this browser only. Nobody else knows who you are." }),
@@ -712,8 +873,9 @@ function IdentityPanel({ me, signedIn, relayUrl, idBase, onCopyKey, onSignOut, o
712
873
  ] });
713
874
  }
714
875
  function IdentityMenu({ me, signedIn, relayUrl, idBase, onCopyKey, onSignOut, compact = false, className, children }) {
715
- const [open, setOpen] = useState4(false);
716
- return /* @__PURE__ */ jsxs10("div", { className: cn("relative", className), children: [
876
+ const [open, setOpen] = useState5(false);
877
+ const anchorRef = useRef4(null);
878
+ return /* @__PURE__ */ jsxs10("div", { ref: anchorRef, className: cn("relative", className), children: [
717
879
  /* @__PURE__ */ jsx16(
718
880
  PersonTrigger,
719
881
  {
@@ -738,6 +900,7 @@ function IdentityMenu({ me, signedIn, relayUrl, idBase, onCopyKey, onSignOut, co
738
900
  onCopyKey,
739
901
  onSignOut,
740
902
  onClose: () => setOpen(false),
903
+ anchor: anchorRef.current,
741
904
  children
742
905
  }
743
906
  )
@@ -748,55 +911,55 @@ function Note({ children }) {
748
911
  }
749
912
 
750
913
  // src/Field.tsx
914
+ import { createContext as createContext2, useContext as useContext2, useId } from "react";
751
915
  import { jsx as jsx17, jsxs as jsxs11 } from "react/jsx-runtime";
752
- function Field({ label, required = false, children }) {
916
+ var FieldControlIdContext = createContext2(void 0);
917
+ function useFieldControlId(ownId) {
918
+ const fromField = useContext2(FieldControlIdContext);
919
+ return fromField ?? ownId;
920
+ }
921
+ function Field({ label, required = false, htmlFor, children }) {
922
+ const generated = useId();
923
+ const id = htmlFor ?? generated;
753
924
  return /* @__PURE__ */ jsxs11("div", { className: "flex flex-col gap-2", children: [
754
- /* @__PURE__ */ jsxs11("label", { className: `text-input-label text-text-primary${required ? " flex items-center" : ""}`, children: [
755
- label,
756
- required && /* @__PURE__ */ jsx17("span", { className: "text-error-default ml-0.5", children: "*" })
757
- ] }),
758
- children
925
+ /* @__PURE__ */ jsxs11(
926
+ "label",
927
+ {
928
+ htmlFor: id,
929
+ className: `text-input-label text-text-primary${required ? " flex items-center" : ""}`,
930
+ children: [
931
+ label,
932
+ required && /* @__PURE__ */ jsx17("span", { className: "text-error-default ml-0.5", children: "*" })
933
+ ]
934
+ }
935
+ ),
936
+ /* @__PURE__ */ jsx17(FieldControlIdContext.Provider, { value: id, children })
759
937
  ] });
760
938
  }
761
939
 
762
940
  // src/Select.tsx
763
941
  import { IconCheck as IconCheck2, IconChevronDown as IconChevronDown2 } from "@tabler/icons-react";
764
- import { useCallback as useCallback2, useEffect as useEffect3, useId, useLayoutEffect as useLayoutEffect3, useMemo as useMemo2, useRef as useRef4, useState as useState5 } from "react";
942
+ import { useCallback as useCallback3, useEffect as useEffect3, useId as useId2, useLayoutEffect as useLayoutEffect4, useMemo as useMemo2, useRef as useRef5, useState as useState6 } from "react";
765
943
  import { createPortal as createPortal4 } from "react-dom";
766
944
  import { Fragment as Fragment2, jsx as jsx18, jsxs as jsxs12 } from "react/jsx-runtime";
767
- function fitMenu({
768
- anchor,
769
- menu,
770
- viewport
771
- }) {
772
- const MARGIN = 8;
773
- const GAP2 = 4;
774
- const CAP = 288;
775
- const left = Math.max(MARGIN, Math.min(anchor.left, viewport.width - menu.width - MARGIN));
776
- const below = viewport.height - anchor.bottom - GAP2 - MARGIN;
777
- const above = anchor.top - GAP2 - MARGIN;
778
- const openUp = below < Math.min(menu.contentHeight, CAP) && above > below;
779
- const maxHeight = Math.max(Math.min(CAP, openUp ? above : below), 120);
780
- return openUp ? { left, bottom: viewport.height - anchor.top + GAP2, maxHeight } : { left, top: anchor.bottom + GAP2, maxHeight };
781
- }
782
945
  function Select({ value, onChange, options, size = "default", ariaLabel, placeholder = "Select\u2026", disabled, className }) {
783
- const id = useId();
784
- const triggerRef = useRef4(null);
785
- const menuRef = useRef4(null);
786
- const [rect, setRect] = useState5(null);
787
- const [placement, setPlacement] = useState5(null);
946
+ const id = useId2();
947
+ const triggerRef = useRef5(null);
948
+ const menuRef = useRef5(null);
949
+ const [rect, setRect] = useState6(null);
950
+ const [placement, setPlacement] = useState6(null);
788
951
  const open = rect !== null;
789
952
  const selectedIndex = useMemo2(() => Math.max(0, options.findIndex((o) => o.value === value)), [options, value]);
790
- const [activeIndex, setActiveIndex] = useState5(selectedIndex);
791
- const close = useCallback2((refocus) => {
953
+ const [activeIndex, setActiveIndex] = useState6(selectedIndex);
954
+ const close = useCallback3((refocus) => {
792
955
  setRect(null);
793
956
  if (refocus) triggerRef.current?.focus();
794
957
  }, []);
795
- const openMenu = useCallback2(() => {
958
+ const openMenu = useCallback3(() => {
796
959
  setActiveIndex(selectedIndex);
797
960
  setRect(triggerRef.current?.getBoundingClientRect() ?? null);
798
961
  }, [selectedIndex]);
799
- useLayoutEffect3(() => {
962
+ useLayoutEffect4(() => {
800
963
  if (!rect || !menuRef.current) {
801
964
  setPlacement(null);
802
965
  return;
@@ -805,7 +968,10 @@ function Select({ value, onChange, options, size = "default", ariaLabel, placeho
805
968
  fitMenu({
806
969
  anchor: { left: rect.left, top: rect.top, bottom: rect.bottom },
807
970
  menu: { width: menuRef.current.offsetWidth, contentHeight: menuRef.current.scrollHeight },
808
- viewport: { width: window.innerWidth, height: window.innerHeight }
971
+ viewport: { width: window.innerWidth, height: window.innerHeight },
972
+ // The option list keeps its classic height (the old max-h-72); a menu
973
+ // panel passes no cap and stands as tall as the room it opens into.
974
+ cap: 288
809
975
  })
810
976
  );
811
977
  }, [rect, options.length]);
@@ -974,11 +1140,13 @@ function Select({ value, onChange, options, size = "default", ariaLabel, placeho
974
1140
  // src/TextInput.tsx
975
1141
  import { forwardRef } from "react";
976
1142
  import { jsx as jsx19 } from "react/jsx-runtime";
977
- var TextInput = forwardRef(function TextInput2({ className, type = "text", ...props }, ref) {
1143
+ var TextInput = forwardRef(function TextInput2({ className, type = "text", id, ...props }, ref) {
1144
+ const controlId = useFieldControlId(id);
978
1145
  return /* @__PURE__ */ jsx19(
979
1146
  "input",
980
1147
  {
981
1148
  ref,
1149
+ id: controlId,
982
1150
  type,
983
1151
  className: cn(
984
1152
  "bg-bg-inset border border-border-default focus:border-border-focus rounded-lg px-3 py-2",
@@ -996,11 +1164,13 @@ var TextInput = forwardRef(function TextInput2({ className, type = "text", ...pr
996
1164
  // src/Textarea.tsx
997
1165
  import { forwardRef as forwardRef2 } from "react";
998
1166
  import { jsx as jsx20 } from "react/jsx-runtime";
999
- var Textarea = forwardRef2(function Textarea2({ className, ...props }, ref) {
1167
+ var Textarea = forwardRef2(function Textarea2({ className, id, ...props }, ref) {
1168
+ const controlId = useFieldControlId(id);
1000
1169
  return /* @__PURE__ */ jsx20(
1001
1170
  "textarea",
1002
1171
  {
1003
1172
  ref,
1173
+ id: controlId,
1004
1174
  className: cn(
1005
1175
  "bg-bg-inset border border-border-default focus:border-border-focus rounded-lg px-3 py-2",
1006
1176
  "text-[14px] leading-[1.4] font-normal text-text-primary placeholder:text-text-muted",
@@ -1015,7 +1185,7 @@ var Textarea = forwardRef2(function Textarea2({ className, ...props }, ref) {
1015
1185
  });
1016
1186
 
1017
1187
  // src/ConfirmDialog.tsx
1018
- import { useState as useState6 } from "react";
1188
+ import { useState as useState7 } from "react";
1019
1189
 
1020
1190
  // src/DialogShell.tsx
1021
1191
  import { useEffect as useEffect4 } from "react";
@@ -1059,7 +1229,7 @@ function DialogShell({ title, onClose, headerContent, footer, children, bodyClas
1059
1229
  // src/ConfirmDialog.tsx
1060
1230
  import { Fragment as Fragment4, jsx as jsx22, jsxs as jsxs14 } from "react/jsx-runtime";
1061
1231
  function ConfirmDialog({ title, children, confirmLabel, destructive = false, onConfirm, onClose }) {
1062
- const [busy, setBusy] = useState6(false);
1232
+ const [busy, setBusy] = useState7(false);
1063
1233
  const confirm = async () => {
1064
1234
  setBusy(true);
1065
1235
  try {
@@ -1085,13 +1255,13 @@ function ConfirmDialog({ title, children, confirmLabel, destructive = false, onC
1085
1255
  }
1086
1256
 
1087
1257
  // src/EditableText.tsx
1088
- import { useEffect as useEffect5, useRef as useRef5, useState as useState7 } from "react";
1258
+ import { useEffect as useEffect5, useRef as useRef6, useState as useState8 } from "react";
1089
1259
  import { jsx as jsx23 } from "react/jsx-runtime";
1090
1260
  function EditableText({ value, display, displayNode, placeholder, onCommit, multiline = false, className, label, readOnly = false }) {
1091
- const [editing, setEditing] = useState7(false);
1092
- const [draft, setDraft] = useState7(value);
1093
- const [busy, setBusy] = useState7(false);
1094
- const fieldRef = useRef5(null);
1261
+ const [editing, setEditing] = useState8(false);
1262
+ const [draft, setDraft] = useState8(value);
1263
+ const [busy, setBusy] = useState8(false);
1264
+ const fieldRef = useRef6(null);
1095
1265
  useEffect5(() => {
1096
1266
  if (editing) {
1097
1267
  fieldRef.current?.focus();
@@ -1219,20 +1389,20 @@ function SkeletonList({ rows = 8, className }) {
1219
1389
  }
1220
1390
 
1221
1391
  // src/Breadcrumb.tsx
1222
- import { Fragment as Fragment5, useCallback as useCallback3, useLayoutEffect as useLayoutEffect4, useRef as useRef6, useState as useState8 } from "react";
1392
+ import { Fragment as Fragment5, useCallback as useCallback4, useLayoutEffect as useLayoutEffect5, useRef as useRef7, useState as useState9 } from "react";
1223
1393
  import { jsx as jsx26, jsxs as jsxs17 } from "react/jsx-runtime";
1224
1394
  function Breadcrumb({ items, className }) {
1225
- const navRef = useRef6(null);
1226
- const labelRefs = useRef6([]);
1227
- const [truncated, setTruncated] = useState8(/* @__PURE__ */ new Set());
1228
- const measure = useCallback3(() => {
1395
+ const navRef = useRef7(null);
1396
+ const labelRefs = useRef7([]);
1397
+ const [truncated, setTruncated] = useState9(/* @__PURE__ */ new Set());
1398
+ const measure = useCallback4(() => {
1229
1399
  const next = /* @__PURE__ */ new Set();
1230
1400
  labelRefs.current.forEach((el, index) => {
1231
1401
  if (el && el.scrollWidth > el.clientWidth) next.add(index);
1232
1402
  });
1233
1403
  setTruncated((prev) => prev.size === next.size && [...next].every((i) => prev.has(i)) ? prev : next);
1234
1404
  }, []);
1235
- useLayoutEffect4(() => {
1405
+ useLayoutEffect5(() => {
1236
1406
  measure();
1237
1407
  const nav = navRef.current;
1238
1408
  if (!nav || typeof ResizeObserver === "undefined") return;
@@ -1399,11 +1569,11 @@ function SearchInput({ shortcut, className, placeholder = "Search\u2026", ...pro
1399
1569
  }
1400
1570
 
1401
1571
  // src/SectionHeader.tsx
1402
- import { useState as useState9 } from "react";
1572
+ import { useState as useState10 } from "react";
1403
1573
  import { IconChevronRight as IconChevronRight2 } from "@tabler/icons-react";
1404
1574
  import { jsx as jsx33, jsxs as jsxs22 } from "react/jsx-runtime";
1405
1575
  function SectionHeader({ title, chevron = false, isExpanded = true, onToggle, actions, showActions = "hover", className }) {
1406
- const [isHovered, setIsHovered] = useState9(false);
1576
+ const [isHovered, setIsHovered] = useState10(false);
1407
1577
  return /* @__PURE__ */ jsxs22(
1408
1578
  "div",
1409
1579
  {
@@ -1479,7 +1649,7 @@ function Tabs({ tabs, active, onChange, size = "default", className }) {
1479
1649
  }
1480
1650
 
1481
1651
  // src/Toast.tsx
1482
- import { createContext, useCallback as useCallback4, useContext, useEffect as useEffect6, useMemo as useMemo3, useState as useState10 } from "react";
1652
+ import { createContext as createContext3, useCallback as useCallback5, useContext as useContext3, useEffect as useEffect6, useMemo as useMemo3, useState as useState11 } from "react";
1483
1653
  import { createPortal as createPortal6 } from "react-dom";
1484
1654
  import { IconCircleCheck } from "@tabler/icons-react";
1485
1655
  import { jsx as jsx35, jsxs as jsxs24 } from "react/jsx-runtime";
@@ -1533,14 +1703,14 @@ function Toast({ label, type = "neutral", leadingIcon = true, actionLabel, onAct
1533
1703
  }
1534
1704
  );
1535
1705
  }
1536
- var ToastContext = createContext(null);
1706
+ var ToastContext = createContext3(null);
1537
1707
  var toastSeq = 0;
1538
1708
  function ToastProvider({ children }) {
1539
- const [toast, setToast] = useState10(null);
1540
- const showToast = useCallback4((opts) => {
1709
+ const [toast, setToast] = useState11(null);
1710
+ const showToast = useCallback5((opts) => {
1541
1711
  setToast({ ...opts, id: ++toastSeq });
1542
1712
  }, []);
1543
- const dismissToast = useCallback4(() => {
1713
+ const dismissToast = useCallback5(() => {
1544
1714
  setToast(null);
1545
1715
  }, []);
1546
1716
  useEffect6(() => {
@@ -1575,7 +1745,7 @@ function ToastProvider({ children }) {
1575
1745
  ] });
1576
1746
  }
1577
1747
  function useToast() {
1578
- const ctx = useContext(ToastContext);
1748
+ const ctx = useContext3(ToastContext);
1579
1749
  if (!ctx) throw new Error("useToast must be used within ToastProvider");
1580
1750
  return ctx;
1581
1751
  }
@@ -1604,6 +1774,7 @@ export {
1604
1774
  MenuItem,
1605
1775
  MenuRow,
1606
1776
  MenuSection,
1777
+ MenuSub,
1607
1778
  NavItem,
1608
1779
  Person,
1609
1780
  PersonTrigger,
@@ -1626,9 +1797,13 @@ export {
1626
1797
  Tooltip,
1627
1798
  TopBar,
1628
1799
  WithTooltip,
1800
+ clampBox,
1629
1801
  cn,
1802
+ fitMenu,
1803
+ fitSubmenu,
1630
1804
  hueFor,
1631
1805
  initialsFor,
1806
+ useFieldControlId,
1632
1807
  useToast
1633
1808
  };
1634
1809
  //# sourceMappingURL=index.js.map