@giddaa-housing/ui 3.9.0 → 3.10.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.
@@ -17,6 +17,12 @@ type MediaGalleryItem = {
17
17
  type MediaGalleryImagesProps = Omit<React.ComponentProps<"div">, "children"> & VariantProps<typeof mediaGalleryImagesVariants> & {
18
18
  images: MediaGalleryItem[];
19
19
  currentMedia?: number;
20
+ /**
21
+ * @deprecated No longer read. The mobile carousel counter counts its own
22
+ * slides, since the carousel carries every image. Pass a larger library
23
+ * figure to `MediaGalleryIndicator` instead, where it labels the link into
24
+ * the full gallery.
25
+ */
20
26
  totalMediaCount?: number;
21
27
  showMobileIndicator?: boolean;
22
28
  onMediaClick?: (index: number) => void;
@@ -50,7 +56,7 @@ declare function MediaGalleryTile({ className, image, index, isDesktopTile, onMe
50
56
  onMediaClick?: (index: number) => void;
51
57
  renderMedia?: (image: MediaGalleryItem, index: number) => React.ReactNode;
52
58
  }): React.JSX.Element;
53
- declare function MediaGalleryImages({ className, currentMedia, imageCount, images, onMediaClick, renderMedia, showMobileIndicator, totalMediaCount, ...props }: MediaGalleryImagesProps): React.JSX.Element;
59
+ declare function MediaGalleryImages({ className, currentMedia, imageCount, images, onMediaClick, renderMedia, showMobileIndicator, totalMediaCount: _totalMediaCount, ...props }: MediaGalleryImagesProps): React.JSX.Element;
54
60
  declare function MediaGalleryVideo({ alt, autoPlay, className, controls, label, loop, muted, onPlayClick, playsInline, poster, renderMedia, src, ...props }: MediaGalleryVideoProps): React.JSX.Element;
55
61
  declare function MediaGalleryReview({ bordered, className, size, ...props }: MediaGalleryReviewProps): React.JSX.Element;
56
62
  declare function MediaGalleryIndicator({ "aria-label": ariaLabel, className, label, onClick, size, totalMediaCount, ...props }: MediaGalleryIndicatorProps): React.JSX.Element;
@@ -39,8 +39,13 @@ function getTileClassName(index, imageCount) {
39
39
  if (imageCount === 4 && index === 2) return "md:col-start-3 md:row-start-1";
40
40
  return "md:col-start-3 md:row-start-2";
41
41
  }
42
- function getMediaKey(image) {
43
- return image.id ?? image.src ?? image.alt ?? "placeholder";
42
+ /**
43
+ * `id` is the only field guaranteed unique, so everything else falls back to a
44
+ * positional key. A listing that reuses one photo twice, or has entries still
45
+ * waiting on a `src`, would otherwise hand React duplicate keys.
46
+ */
47
+ function getMediaKey(image, index) {
48
+ return image.id ?? `${image.src ?? image.alt ?? "media"}-${index}`;
44
49
  }
45
50
  function MediaGalleryTile({ className, image, index, isDesktopTile, onMediaClick, renderMedia }) {
46
51
  const content = renderMedia?.(image, index) ?? (image.src ? /* @__PURE__ */ jsx(Image, {
@@ -68,6 +73,13 @@ function MediaGalleryTile({ className, image, index, isDesktopTile, onMediaClick
68
73
  children: content
69
74
  });
70
75
  }
76
+ /**
77
+ * Counts slides, not library size. The carousel carries every image it is
78
+ * given — unlike the desktop grid, which shows at most `imageCount` — so the
79
+ * denominator here is the number of slides and nothing else. A larger
80
+ * "View All N Photos" figure belongs on `MediaGalleryIndicator`, where it
81
+ * labels a link into the full gallery rather than a position in this one.
82
+ */
71
83
  function MobileGalleryCounter({ total }) {
72
84
  const { selectedIndex } = useCarousel();
73
85
  return /* @__PURE__ */ jsx(GalleryIndicator, {
@@ -77,7 +89,12 @@ function MobileGalleryCounter({ total }) {
77
89
  totalPhotos: total
78
90
  });
79
91
  }
80
- function MobileMediaCarousel({ currentMedia, images, mediaTotal, onMediaClick, renderMedia, showMobileIndicator }) {
92
+ /**
93
+ * The mobile view of the hero. Unlike the desktop bento grid, which arranges a
94
+ * fixed 1–4 tiles, this carries every image it is given — `imageCount` caps the
95
+ * grid's layout, never this.
96
+ */
97
+ function MobileMediaCarousel({ currentMedia, images, onMediaClick, renderMedia, showMobileIndicator }) {
81
98
  const carouselImages = images.length > 0 ? images : [{}];
82
99
  return /* @__PURE__ */ jsxs(Carousel, {
83
100
  className: "absolute inset-0 z-0 block size-full rounded-none md:hidden",
@@ -95,20 +112,18 @@ function MobileMediaCarousel({ currentMedia, images, mediaTotal, onMediaClick, r
95
112
  onMediaClick,
96
113
  renderMedia
97
114
  })
98
- }, getMediaKey(image))) }), showMobileIndicator ? /* @__PURE__ */ jsx("div", {
115
+ }, getMediaKey(image, index))) }), showMobileIndicator ? /* @__PURE__ */ jsx("div", {
99
116
  className: "absolute right-4 bottom-4 z-10",
100
- children: /* @__PURE__ */ jsx(MobileGalleryCounter, { total: mediaTotal })
117
+ children: /* @__PURE__ */ jsx(MobileGalleryCounter, { total: carouselImages.length })
101
118
  }) : null]
102
119
  });
103
120
  }
104
- function MediaGalleryImages({ className, currentMedia = 1, imageCount = 4, images, onMediaClick, renderMedia, showMobileIndicator = true, totalMediaCount, ...props }) {
121
+ function MediaGalleryImages({ className, currentMedia = 1, imageCount = 4, images, onMediaClick, renderMedia, showMobileIndicator = true, totalMediaCount: _totalMediaCount, ...props }) {
105
122
  const resolvedImageCount = imageCount ?? 4;
106
- const mediaTotal = totalMediaCount ?? Math.max(images.length, 1);
107
123
  const visibleImages = getVisibleImages(images, resolvedImageCount);
108
124
  return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(MobileMediaCarousel, {
109
125
  currentMedia,
110
126
  images,
111
- mediaTotal,
112
127
  onMediaClick,
113
128
  renderMedia,
114
129
  showMobileIndicator
@@ -124,7 +139,7 @@ function MediaGalleryImages({ className, currentMedia = 1, imageCount = 4, image
124
139
  isDesktopTile: true,
125
140
  onMediaClick,
126
141
  renderMedia
127
- }, getMediaKey(image)))
142
+ }, getMediaKey(image, index)))
128
143
  })] });
129
144
  }
130
145
  function MediaGalleryVideo({ alt = "", autoPlay, className, controls, label = "Play Video", loop, muted, onPlayClick, playsInline = true, poster, renderMedia, src, ...props }) {
@@ -36,6 +36,31 @@ function setRef(ref, value) {
36
36
  }
37
37
  if (ref) ref.current = value;
38
38
  }
39
+ /**
40
+ * Stops playback when the player leaves the page.
41
+ *
42
+ * Removing a media element from the document does not reliably end playback:
43
+ * the spec only forcibly pauses it once nothing references the element, and a
44
+ * Picture-in-Picture window outlives its element entirely — so a route change
45
+ * can leave audio running with no UI to stop it. `media-chrome` only unsets the
46
+ * element from its store on unmount, so the pause has to happen here.
47
+ *
48
+ * The node is captured on attach rather than read back from the ref: React
49
+ * detaches refs before passive effect cleanups run, so by unmount time the
50
+ * component's own ref is already null.
51
+ */
52
+ function useStopOnUnmount() {
53
+ const nodeRef = React.useRef(null);
54
+ React.useEffect(() => {
55
+ return () => {
56
+ const node = nodeRef.current;
57
+ if (!node || node.isConnected) return;
58
+ node.pause();
59
+ if (document.pictureInPictureElement === node) document.exitPictureInPicture().catch(() => {});
60
+ };
61
+ }, []);
62
+ return nodeRef;
63
+ }
39
64
  function formatRate(value) {
40
65
  if (!value || value === 1) return "1x";
41
66
  return `${Number.parseFloat(value.toFixed(2))}x`;
@@ -238,6 +263,7 @@ function MediaPlayerRoot({ audio, children, className, onBlurCapture, onFocusCap
238
263
  },
239
264
  onPointerLeave: (event) => {
240
265
  onPointerLeave?.(event);
266
+ if (event.pointerType === "touch") return;
241
267
  setHoveringControls(false);
242
268
  if (!alwaysVisibleRef.current) {
243
269
  window.clearTimeout(hideTimerRef.current);
@@ -247,6 +273,7 @@ function MediaPlayerRoot({ audio, children, className, onBlurCapture, onFocusCap
247
273
  onPointerMove: (event) => {
248
274
  onPointerMove?.(event);
249
275
  wake();
276
+ if (event.pointerType === "touch") return;
250
277
  const rect = event.currentTarget.querySelector("[data-slot='media-player-controls']")?.getBoundingClientRect();
251
278
  setHoveringControls(!!rect && event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom);
252
279
  },
@@ -258,11 +285,13 @@ function MediaPlayerVideo({ className, ref, ...props }) {
258
285
  const mediaRef = useMediaRef();
259
286
  const mediaRefRef = React.useRef(mediaRef);
260
287
  mediaRefRef.current = mediaRef;
288
+ const playingNodeRef = useStopOnUnmount();
261
289
  return /* @__PURE__ */ jsx("video", {
262
290
  ref: React.useCallback((node) => {
291
+ if (node) playingNodeRef.current = node;
263
292
  mediaRefRef.current(node);
264
293
  setRef(ref, node);
265
- }, [ref]),
294
+ }, [playingNodeRef, ref]),
266
295
  "data-slot": "media-player-video",
267
296
  className: cn("block aspect-video size-full object-cover", "group-data-[fullscreen]/media-player:aspect-auto group-data-[fullscreen]/media-player:object-contain", className),
268
297
  playsInline: true,
@@ -273,11 +302,13 @@ function MediaPlayerAudio({ className, ref, ...props }) {
273
302
  const mediaRef = useMediaRef();
274
303
  const mediaRefRef = React.useRef(mediaRef);
275
304
  mediaRefRef.current = mediaRef;
305
+ const playingNodeRef = useStopOnUnmount();
276
306
  return /* @__PURE__ */ jsx("audio", {
277
307
  ref: React.useCallback((node) => {
308
+ if (node) playingNodeRef.current = node;
278
309
  mediaRefRef.current(node);
279
310
  setRef(ref, node);
280
- }, [ref]),
311
+ }, [playingNodeRef, ref]),
281
312
  "data-slot": "media-player-audio",
282
313
  className: cn("sr-only", className),
283
314
  ...props
@@ -1,6 +1,6 @@
1
1
  import "./input-size-context-BMhves-J.js";
2
2
  import { r as PopoverContent } from "./popover-BMTZp3AQ.js";
3
- import "./icons-C9JXR5Xv.js";
3
+ import "./icons-e71GaFK-.js";
4
4
  import * as React from "react";
5
5
  //#region src/lib/picker.d.ts
6
6
  /**
package/dist/rating.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { n as ComponentSizeValue } from "./size-context-BVhHduLi.js";
2
- import { j as IconProps } from "./icons-C9JXR5Xv.js";
2
+ import { M as IconProps } from "./icons-e71GaFK-.js";
3
3
  import * as React from "react";
4
4
  //#region src/rating.d.ts
5
5
  /** How finely a rating can be *set*. Display always honours the exact value. */
@@ -1,5 +1,5 @@
1
1
  import { t as ComponentSize } from "./size-context-BVhHduLi.js";
2
- import { TabsListVariant } from "./tabs.js";
2
+ import { TabsCountProps, TabsListVariant } from "./tabs.js";
3
3
  import * as React from "react";
4
4
  //#region src/scroll-spy.d.ts
5
5
  /**
@@ -43,8 +43,9 @@ type ScrollSpyProps = Omit<React.ComponentProps<"div">, "onChange"> & {
43
43
  * and visible, the triggers scroll to their section, and the active trigger
44
44
  * tracks whichever section the reader is currently on.
45
45
  *
46
- * The parts mirror the `Tabs` API — `ScrollSpy` / `ScrollSpyList` /
47
- * `ScrollSpyTrigger` / `ScrollSpyContent`, matched by `value` — and the list
46
+ * The parts mirror the `Tabs` API one for one — `ScrollSpy` /
47
+ * `ScrollSpyListContainer` / `ScrollSpyList` / `ScrollSpyTrigger` /
48
+ * `ScrollSpyCount` / `ScrollSpyContent`, matched by `value` — and the list
48
49
  * takes the same `variant` and `size` as `TabsList`, so the two read as one
49
50
  * family. Overflow ("More" dropdowns, per-breakpoint trigger counts) is the
50
51
  * consuming app's job, exactly as it is with `Tabs`;
@@ -52,6 +53,21 @@ type ScrollSpyProps = Omit<React.ComponentProps<"div">, "onChange"> & {
52
53
  * real triggers.
53
54
  */
54
55
  declare function ScrollSpy({ className, orientation, value, defaultValue, onValueChange, scrollOffset, children, ...props }: ScrollSpyProps): React.JSX.Element;
56
+ /**
57
+ * Container styling — `TabsListContainer`'s, matched to this component's own
58
+ * list slot so the rule tracks `ScrollSpyList`'s variant.
59
+ */
60
+ declare const scrollSpyListContainerClassName: string;
61
+ /**
62
+ * Optional wrapper around `ScrollSpyList`, matching `TabsListContainer`. Use it
63
+ * when the bar needs padding the triggers shouldn't inherit, or when something
64
+ * other than a trigger has to sit in it.
65
+ *
66
+ * It takes over the `nav` landmark — the list inside drops its own role — so
67
+ * the region stays a single landmark however many controls join it. Label the
68
+ * container rather than the list.
69
+ */
70
+ declare function ScrollSpyListContainer({ className, children, "aria-label": ariaLabel, ...props }: React.ComponentProps<"nav">): React.JSX.Element;
55
71
  type ScrollSpyListProps = React.ComponentProps<"nav"> & {
56
72
  variant?: TabsListVariant;
57
73
  size?: ComponentSize;
@@ -74,9 +90,16 @@ type ScrollSpyTriggerProps = Omit<React.ComponentProps<"button">, "value"> & {
74
90
  value: string;
75
91
  };
76
92
  declare function ScrollSpyTrigger({ className, value, type, onClick, ...props }: ScrollSpyTriggerProps): React.JSX.Element;
93
+ /**
94
+ * Count badge — `TabsCount` under this family's slot name, so a spy trigger and
95
+ * a tab trigger carry the identical chip and track the same variant and size.
96
+ * Place it inside a `ScrollSpyTrigger`, after the label.
97
+ */
98
+ type ScrollSpyCountProps = TabsCountProps;
99
+ declare function ScrollSpyCount({ "data-slot": dataSlot, ...props }: TabsCountProps): React.JSX.Element;
77
100
  type ScrollSpyContentProps = Omit<React.ComponentProps<"section">, "value"> & {
78
101
  value: string;
79
102
  };
80
103
  declare function ScrollSpyContent({ className, value, style, tabIndex, ...props }: ScrollSpyContentProps): React.JSX.Element;
81
104
  //#endregion
82
- export { ScrollSpy, ScrollSpyContent, type ScrollSpyContentProps, ScrollSpyList, type ScrollSpyListProps, type ScrollSpyProps, ScrollSpyTrigger, type ScrollSpyTriggerProps, scrollSpyTriggerClassName, useOptionalScrollSpy, useScrollSpy };
105
+ export { ScrollSpy, ScrollSpyContent, type ScrollSpyContentProps, ScrollSpyCount, type ScrollSpyCountProps, ScrollSpyList, ScrollSpyListContainer, type ScrollSpyListProps, type ScrollSpyProps, ScrollSpyTrigger, type ScrollSpyTriggerProps, scrollSpyListContainerClassName, scrollSpyTriggerClassName, useOptionalScrollSpy, useScrollSpy };
@@ -3,7 +3,7 @@ import { cn } from "./utils/cn.js";
3
3
  import { useComponentSize } from "./size-context.js";
4
4
  import { useReducedMotion } from "./utils/use-reduced-motion.js";
5
5
  import { useUncontrolled } from "./utils/use-uncontrolled.js";
6
- import { resolveTabsListVariant, tabsIndicatorClassName, tabsListVariants, tabsTriggerClassName } from "./tabs.js";
6
+ import { TabsCount, TabsListContainerContext, resolveTabsListVariant, tabsIndicatorClassName, tabsListContainerBaseClassName, tabsListVariants, tabsTriggerClassName, useInTabsListContainer } from "./tabs.js";
7
7
  import { jsx, jsxs } from "react/jsx-runtime";
8
8
  import * as React from "react";
9
9
  //#region src/scroll-spy.tsx
@@ -68,8 +68,9 @@ function useScrollSpy() {
68
68
  * and visible, the triggers scroll to their section, and the active trigger
69
69
  * tracks whichever section the reader is currently on.
70
70
  *
71
- * The parts mirror the `Tabs` API — `ScrollSpy` / `ScrollSpyList` /
72
- * `ScrollSpyTrigger` / `ScrollSpyContent`, matched by `value` — and the list
71
+ * The parts mirror the `Tabs` API one for one — `ScrollSpy` /
72
+ * `ScrollSpyListContainer` / `ScrollSpyList` / `ScrollSpyTrigger` /
73
+ * `ScrollSpyCount` / `ScrollSpyContent`, matched by `value` — and the list
73
74
  * takes the same `variant` and `size` as `TabsList`, so the two read as one
74
75
  * family. Overflow ("More" dropdowns, per-breakpoint trigger counts) is the
75
76
  * consuming app's job, exactly as it is with `Tabs`;
@@ -210,12 +211,39 @@ function followActiveTrigger(list, element, behavior) {
210
211
  }
211
212
  if (options.left !== void 0 || options.top !== void 0) list.scrollTo(options);
212
213
  }
213
- function ScrollSpyList({ className, variant = "underline", size, children, scrollActiveIntoView = false, "aria-label": ariaLabel = "Section navigation", ...props }) {
214
+ /**
215
+ * Container styling — `TabsListContainer`'s, matched to this component's own
216
+ * list slot so the rule tracks `ScrollSpyList`'s variant.
217
+ */
218
+ const scrollSpyListContainerClassName = cn(tabsListContainerBaseClassName, "group-data-[orientation=horizontal]/tabs:has-[[data-slot=scroll-spy-list][data-variant=underline]]:border-b group-data-[orientation=horizontal]/tabs:has-[[data-slot=scroll-spy-list][data-variant=underline]]:border-line-subtle", "group-data-[orientation=vertical]/tabs:has-[[data-slot=scroll-spy-list][data-variant=underline]]:border-r group-data-[orientation=vertical]/tabs:has-[[data-slot=scroll-spy-list][data-variant=underline]]:border-line-subtle");
219
+ /**
220
+ * Optional wrapper around `ScrollSpyList`, matching `TabsListContainer`. Use it
221
+ * when the bar needs padding the triggers shouldn't inherit, or when something
222
+ * other than a trigger has to sit in it.
223
+ *
224
+ * It takes over the `nav` landmark — the list inside drops its own role — so
225
+ * the region stays a single landmark however many controls join it. Label the
226
+ * container rather than the list.
227
+ */
228
+ function ScrollSpyListContainer({ className, children, "aria-label": ariaLabel = "Section navigation", ...props }) {
229
+ return /* @__PURE__ */ jsx(TabsListContainerContext.Provider, {
230
+ value: true,
231
+ children: /* @__PURE__ */ jsx("nav", {
232
+ "data-slot": "scroll-spy-list-container",
233
+ "aria-label": ariaLabel,
234
+ className: cn(scrollSpyListContainerClassName, className),
235
+ ...props,
236
+ children
237
+ })
238
+ });
239
+ }
240
+ function ScrollSpyList({ className, variant = "underline", size, children, scrollActiveIntoView = false, "aria-label": ariaLabel, ...props }) {
214
241
  const { activeValue } = useScrollSpyContext("ScrollSpyList");
215
242
  const reducedMotion = useReducedMotion();
216
243
  const hasFollowedRef = React.useRef(false);
217
244
  const resolvedVariant = resolveTabsListVariant(variant);
218
245
  const resolvedSize = useComponentSize(size);
246
+ const inContainer = useInTabsListContainer();
219
247
  const listRef = React.useRef(null);
220
248
  const [indicator, setIndicator] = React.useState(null);
221
249
  React.useEffect(() => {
@@ -256,8 +284,12 @@ function ScrollSpyList({ className, variant = "underline", size, children, scrol
256
284
  "data-slot": "scroll-spy-list",
257
285
  "data-variant": resolvedVariant,
258
286
  "data-size": resolvedSize,
259
- "aria-label": ariaLabel,
260
- className: cn(tabsListVariants({ variant: resolvedVariant }), className),
287
+ role: inContainer ? "none" : void 0,
288
+ "aria-label": inContainer ? ariaLabel : ariaLabel ?? "Section navigation",
289
+ className: cn(tabsListVariants({
290
+ variant: resolvedVariant,
291
+ standalone: !inContainer
292
+ }), className),
261
293
  style: indicator ? {
262
294
  "--active-tab-left": `${indicator.left}px`,
263
295
  "--active-tab-top": `${indicator.top}px`,
@@ -295,6 +327,12 @@ function ScrollSpyTrigger({ className, value, type = "button", onClick, ...props
295
327
  ...props
296
328
  });
297
329
  }
330
+ function ScrollSpyCount({ "data-slot": dataSlot = "scroll-spy-count", ...props }) {
331
+ return /* @__PURE__ */ jsx(TabsCount, {
332
+ "data-slot": dataSlot,
333
+ ...props
334
+ });
335
+ }
298
336
  function ScrollSpyContent({ className, value, style, tabIndex = -1, ...props }) {
299
337
  const { activeValue, getSectionId, getTriggerId, registerSection, scrollOffset } = useScrollSpyContext("ScrollSpyContent");
300
338
  return /* @__PURE__ */ jsx("section", {
@@ -315,4 +353,4 @@ function ScrollSpyContent({ className, value, style, tabIndex = -1, ...props })
315
353
  });
316
354
  }
317
355
  //#endregion
318
- export { ScrollSpy, ScrollSpyContent, ScrollSpyList, ScrollSpyTrigger, scrollSpyTriggerClassName, useOptionalScrollSpy, useScrollSpy };
356
+ export { ScrollSpy, ScrollSpyContent, ScrollSpyCount, ScrollSpyList, ScrollSpyListContainer, ScrollSpyTrigger, scrollSpyListContainerClassName, scrollSpyTriggerClassName, useOptionalScrollSpy, useScrollSpy };
@@ -23,9 +23,15 @@ type SectionJumperProps = Omit<React.ComponentProps<"div">, "children"> & {
23
23
  defaultOpen?: boolean;
24
24
  onOpenChange?: (open: boolean) => void;
25
25
  mobileBreakpoint?: number;
26
+ /**
27
+ * Most dots the resting rail draws before it windows around the active
28
+ * section. Defaults to 12 on desktop and 7 on mobile, where the dots share
29
+ * a row with the section label. The menu always lists every section.
30
+ */
31
+ maxDots?: number;
26
32
  /** Sticky-header offset used by standalone target-id mode. */
27
33
  scrollOffset?: number;
28
34
  };
29
- declare function SectionJumper({ className, items: itemsProp, label, open, defaultOpen, onOpenChange, mobileBreakpoint, scrollOffset: scrollOffsetProp, ...props }: SectionJumperProps): React.JSX.Element | null;
35
+ declare function SectionJumper({ className, items: itemsProp, label, open, defaultOpen, onOpenChange, mobileBreakpoint, maxDots, scrollOffset: scrollOffsetProp, ...props }: SectionJumperProps): React.JSX.Element | null;
30
36
  //#endregion
31
37
  export { SectionJumper, type SectionJumperItem, type SectionJumperProps, SectionJumperProvider, type SectionJumperProviderProps };
@@ -153,17 +153,78 @@ function useStandaloneSectionJumper(items, scrollOffset, enabled) {
153
153
  scrollToSection
154
154
  ]);
155
155
  }
156
- function SectionJumperDot({ active, variant }) {
156
+ /**
157
+ * Most sections a rail can show before it stops being a glanceable indicator
158
+ * and starts being a list — at which point it is windowed. Mobile gets the
159
+ * smaller budget: its dots run across the screen rather than down it, sharing
160
+ * the row with the section label.
161
+ */
162
+ const DEFAULT_MAX_DOTS = {
163
+ desktop: 12,
164
+ mobile: 7
165
+ };
166
+ /**
167
+ * The slice of sections the rail draws. Everything fits until there are more
168
+ * than `max`; past that the window slides to keep the active section centred,
169
+ * pinning at either end so the first and last sections stay reachable.
170
+ */
171
+ function getDotWindow(count, activeIndex, max) {
172
+ if (count <= max) return {
173
+ start: 0,
174
+ end: count
175
+ };
176
+ const start = Math.max(0, Math.min(activeIndex - Math.floor(max / 2), count - max));
177
+ return {
178
+ start,
179
+ end: start + max
180
+ };
181
+ }
182
+ function SectionJumperDot({ active, edge, variant }) {
157
183
  return /* @__PURE__ */ jsx("span", {
158
184
  "aria-hidden": "true",
159
- className: cn("block shrink-0 bg-line transition-[width,height,background-color] duration-(--duration-motion-surface) ease-gdt-out motion-reduce:transition-none", !active && "size-2 rounded-full", active && variant === "desktop" && "h-6 w-2 rounded-sm bg-surface-brand", active && variant === "menu" && "size-2 rounded-full bg-surface-brand", active && variant === "mobile" && "h-2 w-6 rounded-sm bg-surface-brand")
185
+ className: cn("block shrink-0 bg-line transition-[width,height,background-color] duration-(--duration-motion-surface) ease-gdt-out motion-reduce:transition-none", !active && (edge ? "size-1 rounded-full" : "size-2 rounded-full"), active && variant === "desktop" && "h-6 w-2 rounded-sm bg-surface-brand", active && variant === "menu" && "size-2 rounded-full bg-surface-brand", active && variant === "mobile" && "h-2 w-6 rounded-sm bg-surface-brand")
160
186
  });
161
187
  }
188
+ /**
189
+ * The windowed rail, shared by both triggers — the dots are the only part of
190
+ * the two that differ by more than layout.
191
+ */
192
+ function SectionJumperDots({ items, activeValue, max, variant }) {
193
+ const activeIndex = items.findIndex((item) => item.value === activeValue);
194
+ const { start, end } = getDotWindow(items.length, activeIndex < 0 ? 0 : activeIndex, max);
195
+ return items.slice(start, end).map((item, index) => /* @__PURE__ */ jsx(SectionJumperDot, {
196
+ active: item.value === activeValue,
197
+ edge: index === 0 && start > 0 || index === end - start - 1 && end < items.length,
198
+ variant
199
+ }, item.value));
200
+ }
162
201
  function SectionJumperMenu({ items, onSelect, controller }) {
163
202
  const { activeValue, getSectionId, getTriggerId } = controller;
203
+ const listRef = React.useRef(null);
204
+ React.useEffect(() => {
205
+ const list = listRef.current;
206
+ if (!list) return;
207
+ let positioned = false;
208
+ const position = () => {
209
+ if (positioned || list.scrollHeight <= list.clientHeight) return;
210
+ const active = list.querySelector("[data-active]");
211
+ if (!active) return;
212
+ positioned = true;
213
+ const top = active.offsetTop;
214
+ const bottom = top + active.offsetHeight;
215
+ if (top < list.scrollTop) list.scrollTop = Math.max(0, top - 8);
216
+ else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight + 8;
217
+ };
218
+ position();
219
+ if (typeof ResizeObserver === "undefined") return;
220
+ const observer = new ResizeObserver(position);
221
+ observer.observe(list);
222
+ return () => observer.disconnect();
223
+ }, []);
164
224
  return /* @__PURE__ */ jsx("nav", {
225
+ ref: listRef,
165
226
  "aria-label": "Page sections",
166
- className: "flex flex-col gap-0.5",
227
+ className: "flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto overscroll-contain pt-2",
167
228
  children: items.map((item) => {
168
229
  const active = item.value === activeValue;
169
230
  return /* @__PURE__ */ jsxs("button", {
@@ -185,7 +246,7 @@ function SectionJumperMenu({ items, onSelect, controller }) {
185
246
  })
186
247
  });
187
248
  }
188
- function SectionJumper({ className, items: itemsProp, label = "On this page", open, defaultOpen = false, onOpenChange, mobileBreakpoint = 1024, scrollOffset: scrollOffsetProp, ...props }) {
249
+ function SectionJumper({ className, items: itemsProp, label = "On this page", open, defaultOpen = false, onOpenChange, mobileBreakpoint = 1024, maxDots, scrollOffset: scrollOffsetProp, ...props }) {
189
250
  const provider = React.useContext(SectionJumperProviderContext);
190
251
  const items = itemsProp ?? provider?.items ?? [];
191
252
  const scrollOffset = scrollOffsetProp ?? provider?.scrollOffset ?? 0;
@@ -211,32 +272,35 @@ function SectionJumper({ className, items: itemsProp, label = "On this page", op
211
272
  type: "button",
212
273
  "aria-label": `${label}${activeItem ? `, current section: ${String(activeItem.label)}` : ""}`,
213
274
  className: "flex w-fit cursor-pointer flex-col items-center gap-3.5 rounded-2xl border border-transparent p-2 outline-none focus-visible:shadow-[0_0_0_2px_var(--color-line-focus)]",
214
- children: items.map((item) => {
215
- return /* @__PURE__ */ jsx(SectionJumperDot, {
216
- active: item.value === activeValue,
217
- variant: "desktop"
218
- }, item.value);
275
+ children: /* @__PURE__ */ jsx(SectionJumperDots, {
276
+ items,
277
+ activeValue,
278
+ max: maxDots ?? DEFAULT_MAX_DOTS.desktop,
279
+ variant: "desktop"
219
280
  })
220
281
  });
221
282
  const mobileTrigger = /* @__PURE__ */ jsxs("button", {
222
283
  type: "button",
223
- className: "flex cursor-pointer items-center gap-3 rounded-full border border-line-subtle bg-surface-overlay px-4 py-1 text-gdt-xs font-medium text-fg-brand shadow-2 outline-none focus-visible:shadow-[0_0_0_2px_var(--color-line-focus)]",
284
+ "aria-label": `${label}${activeItem ? `, current section: ${String(activeItem.label)}` : ""}`,
285
+ className: "flex max-w-[min(22rem,calc(100vw-2rem))] cursor-pointer items-center gap-3 rounded-full border border-line-subtle bg-surface-overlay px-4 py-1 text-gdt-xs font-medium text-fg-brand shadow-2 outline-none focus-visible:shadow-[0_0_0_2px_var(--color-line-focus)]",
224
286
  children: [
225
287
  /* @__PURE__ */ jsx("span", {
226
288
  "aria-hidden": true,
227
- className: "flex items-center gap-1",
228
- children: items.map((item) => /* @__PURE__ */ jsx(SectionJumperDot, {
229
- active: item.value === activeValue,
289
+ className: "flex shrink-0 items-center gap-1",
290
+ children: /* @__PURE__ */ jsx(SectionJumperDots, {
291
+ items,
292
+ activeValue,
293
+ max: maxDots ?? DEFAULT_MAX_DOTS.mobile,
230
294
  variant: "mobile"
231
- }, item.value))
295
+ })
232
296
  }),
233
297
  /* @__PURE__ */ jsx("span", {
234
- className: "max-w-32 truncate",
298
+ className: "min-w-0 flex-1 truncate text-left",
235
299
  children: activeItem?.label ?? label
236
300
  }),
237
301
  /* @__PURE__ */ jsx(ChevronDown, {
238
302
  "aria-hidden": true,
239
- className: cn("size-3 transition-transform", resolvedOpen && "rotate-180")
303
+ className: cn("size-3 shrink-0 transition-transform", resolvedOpen && "rotate-180")
240
304
  })
241
305
  ]
242
306
  });
@@ -256,17 +320,14 @@ function SectionJumper({ className, items: itemsProp, label = "On this page", op
256
320
  side: isMobile ? "top" : "left",
257
321
  sideOffset: 8,
258
322
  showArrow: false,
259
- className: cn("border border-line-subtle shadow-2 px-4 pb-4 pt-3", isMobile ? "w-90 rounded-2xl" : "w-60 rounded-2xl"),
323
+ className: cn("max-h-(--available-height) rounded-2xl border border-line-subtle px-4 pb-4 pt-3 shadow-2", isMobile ? "w-90" : "w-max min-w-60 max-w-[min(20rem,calc(100vw-2rem))]"),
260
324
  children: [/* @__PURE__ */ jsx("p", {
261
- className: cn("border-b border-line-subtle pb-2 font-semibold text-fg-primary", isMobile ? "text-gdt-md" : "text-gdt-sm"),
325
+ className: cn("shrink-0 border-b border-line-subtle pb-2 font-semibold text-fg-primary", isMobile ? "text-gdt-md" : "text-gdt-sm"),
262
326
  children: label
263
- }), /* @__PURE__ */ jsx("div", {
264
- className: "pt-2",
265
- children: /* @__PURE__ */ jsx(SectionJumperMenu, {
266
- items,
267
- onSelect: select,
268
- controller
269
- })
327
+ }), /* @__PURE__ */ jsx(SectionJumperMenu, {
328
+ items,
329
+ onSelect: select,
330
+ controller
270
331
  })]
271
332
  })]
272
333
  })
package/dist/select.js CHANGED
@@ -29,7 +29,7 @@ const selectTriggerVariants = cva("group/select-trigger flex w-fit min-w-0 items
29
29
  } },
30
30
  defaultVariants: { size: "md" }
31
31
  });
32
- const selectContentVariants = cva("relative isolate z-50 flex max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) flex-col overflow-hidden border border-line-subtle bg-canvas text-fg-primary shadow-(--elevation-e1-shadow) transition-[opacity,scale,translate] duration-[var(--duration-motion-popup)] ease-gdt-out data-[align-trigger=true]:transition-none not-data-[align-trigger=true]:data-starting-style:opacity-0 not-data-[align-trigger=true]:data-starting-style:scale-95 not-data-[align-trigger=true]:data-ending-style:opacity-0 not-data-[align-trigger=true]:data-ending-style:scale-95 not-data-[align-trigger=true]:data-[side=bottom]:data-starting-style:translate-y-2 not-data-[align-trigger=true]:data-[side=bottom]:data-ending-style:translate-y-2 not-data-[align-trigger=true]:data-[side=top]:data-starting-style:-translate-y-2 not-data-[align-trigger=true]:data-[side=top]:data-ending-style:-translate-y-2 not-data-[align-trigger=true]:data-[side=left]:data-starting-style:translate-x-2 not-data-[align-trigger=true]:data-[side=left]:data-ending-style:translate-x-2 not-data-[align-trigger=true]:data-[side=right]:data-starting-style:-translate-x-2 not-data-[align-trigger=true]:data-[side=right]:data-ending-style:-translate-x-2 not-data-[align-trigger=true]:data-[side=inline-end]:data-starting-style:-translate-x-2 not-data-[align-trigger=true]:data-[side=inline-end]:data-ending-style:-translate-x-2 not-data-[align-trigger=true]:data-[side=inline-start]:data-starting-style:translate-x-2 not-data-[align-trigger=true]:data-[side=inline-start]:data-ending-style:translate-x-2 motion-reduce:not-data-[align-trigger=true]:data-starting-style:scale-100 motion-reduce:not-data-[align-trigger=true]:data-ending-style:scale-100 motion-reduce:not-data-[align-trigger=true]:data-[side=bottom]:data-starting-style:translate-y-0 motion-reduce:not-data-[align-trigger=true]:data-[side=bottom]:data-ending-style:translate-y-0 motion-reduce:not-data-[align-trigger=true]:data-[side=top]:data-starting-style:translate-y-0 motion-reduce:not-data-[align-trigger=true]:data-[side=top]:data-ending-style:translate-y-0 motion-reduce:not-data-[align-trigger=true]:data-[side=left]:data-starting-style:translate-x-0 motion-reduce:not-data-[align-trigger=true]:data-[side=left]:data-ending-style:translate-x-0 motion-reduce:not-data-[align-trigger=true]:data-[side=right]:data-starting-style:translate-x-0 motion-reduce:not-data-[align-trigger=true]:data-[side=right]:data-ending-style:translate-x-0 motion-reduce:not-data-[align-trigger=true]:data-[side=inline-end]:data-starting-style:translate-x-0 motion-reduce:not-data-[align-trigger=true]:data-[side=inline-end]:data-ending-style:translate-x-0 motion-reduce:not-data-[align-trigger=true]:data-[side=inline-start]:data-starting-style:translate-x-0 motion-reduce:not-data-[align-trigger=true]:data-[side=inline-start]:data-ending-style:translate-x-0", {
32
+ const selectContentVariants = cva("relative isolate z-50 flex max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) flex-col overflow-hidden border border-line-subtle bg-canvas text-fg-primary shadow-(--elevation-e1-shadow) transition-[opacity,scale,translate] duration-[var(--duration-motion-popup)] ease-gdt-out data-[align-trigger=true]:transition-none not-data-[align-trigger=true]:data-starting-style:opacity-0 not-data-[align-trigger=true]:data-starting-style:scale-95 not-data-[align-trigger=true]:data-ending-style:opacity-0 not-data-[align-trigger=true]:data-ending-style:scale-95 not-data-[align-trigger=true]:data-[side=bottom]:data-starting-style:translate-y-2 not-data-[align-trigger=true]:data-[side=bottom]:data-ending-style:translate-y-2 not-data-[align-trigger=true]:data-[side=top]:data-starting-style:-translate-y-2 not-data-[align-trigger=true]:data-[side=top]:data-ending-style:-translate-y-2 not-data-[align-trigger=true]:data-[side=left]:data-starting-style:translate-x-2 not-data-[align-trigger=true]:data-[side=left]:data-ending-style:translate-x-2 not-data-[align-trigger=true]:data-[side=right]:data-starting-style:-translate-x-2 not-data-[align-trigger=true]:data-[side=right]:data-ending-style:-translate-x-2 not-data-[align-trigger=true]:data-[side=inline-end]:data-starting-style:-translate-x-2 not-data-[align-trigger=true]:data-[side=inline-end]:data-ending-style:-translate-x-2 not-data-[align-trigger=true]:data-[side=inline-start]:data-starting-style:translate-x-2 not-data-[align-trigger=true]:data-[side=inline-start]:data-ending-style:translate-x-2 motion-reduce:not-data-[align-trigger=true]:data-starting-style:scale-100 motion-reduce:not-data-[align-trigger=true]:data-ending-style:scale-100 motion-reduce:not-data-[align-trigger=true]:data-[side=bottom]:data-starting-style:translate-y-0 motion-reduce:not-data-[align-trigger=true]:data-[side=bottom]:data-ending-style:translate-y-0 motion-reduce:not-data-[align-trigger=true]:data-[side=top]:data-starting-style:translate-y-0 motion-reduce:not-data-[align-trigger=true]:data-[side=top]:data-ending-style:translate-y-0 motion-reduce:not-data-[align-trigger=true]:data-[side=left]:data-starting-style:translate-x-0 motion-reduce:not-data-[align-trigger=true]:data-[side=left]:data-ending-style:translate-x-0 motion-reduce:not-data-[align-trigger=true]:data-[side=right]:data-starting-style:translate-x-0 motion-reduce:not-data-[align-trigger=true]:data-[side=right]:data-ending-style:translate-x-0 motion-reduce:not-data-[align-trigger=true]:data-[side=inline-end]:data-starting-style:translate-x-0 motion-reduce:not-data-[align-trigger=true]:data-[side=inline-end]:data-ending-style:translate-x-0 motion-reduce:not-data-[align-trigger=true]:data-[side=inline-start]:data-starting-style:translate-x-0 motion-reduce:not-data-[align-trigger=true]:data-[side=inline-start]:data-ending-style:translate-x-0", {
33
33
  variants: { size: {
34
34
  sm: "rounded-lg",
35
35
  md: "rounded-xl",