@giddaa-housing/ui 3.2.0 → 3.3.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/css/generated/shared.css +1 -1
- package/dist/accordion.js +1 -0
- package/dist/alert.js +3 -3
- package/dist/button.js +6 -2
- package/dist/footer.d.ts +8 -3
- package/dist/footer.js +15 -4
- package/dist/media-gallery-hero.js +3 -3
- package/dist/property-listing-card.js +1 -1
- package/dist/scroll-spy.d.ts +61 -0
- package/dist/scroll-spy.js +295 -0
- package/dist/styles.css +63 -9
- package/dist/tabs.d.ts +13 -1
- package/dist/tabs.js +9 -2
- package/dist/toast.js +1 -0
- package/package.json +5 -1
package/dist/accordion.js
CHANGED
package/dist/alert.js
CHANGED
|
@@ -3,7 +3,7 @@ import { t as cn } from "./cn-BI_4DMBf.js";
|
|
|
3
3
|
import { jsx } from "react/jsx-runtime";
|
|
4
4
|
import { cva } from "class-variance-authority";
|
|
5
5
|
//#region src/alert.tsx
|
|
6
|
-
const alertVariants = cva("grid w-full grid-cols-[0_minmax(0,1fr)_auto] items-start gap-y-1 rounded-xl border-2 px-4 py-3.5 text-gdt-sm transition-[background-color,border-color,color,box-shadow] has-[>svg]:grid-cols-[1.25rem_minmax(0,1fr)_auto] has-[>svg]:gap-x-3 [&>svg]:col-start-1 [&>svg]:row-span-2 [&>svg]:mt-0.5 [&>svg]:size-5 [&>svg]:text-current", {
|
|
6
|
+
const alertVariants = cva("group/alert grid w-full grid-cols-[0_minmax(0,1fr)_auto] items-start gap-y-1 rounded-xl border-2 px-4 py-3.5 text-gdt-sm transition-[background-color,border-color,color,box-shadow] has-[>svg]:grid-cols-[1.25rem_minmax(0,1fr)_auto] has-[>svg]:gap-x-3 [&>svg]:col-start-1 [&>svg]:row-span-2 [&>svg]:mt-0.5 [&>svg]:size-5 [&>svg]:text-current", {
|
|
7
7
|
variants: { variant: {
|
|
8
8
|
success: "border-status-success bg-surface-brand-subtle text-status-success",
|
|
9
9
|
info: "border-status-info bg-surface text-status-info",
|
|
@@ -33,14 +33,14 @@ function AlertTitle({ className, ...props }) {
|
|
|
33
33
|
function AlertDescription({ className, ...props }) {
|
|
34
34
|
return /* @__PURE__ */ jsx("div", {
|
|
35
35
|
"data-slot": "alert-description",
|
|
36
|
-
className: cn("col-start-2 row-start-
|
|
36
|
+
className: cn("col-start-2 row-start-1 text-fg-secondary group-has-[[data-slot=alert-title]]/alert:row-start-2 [&_p]:leading-relaxed", className),
|
|
37
37
|
...props
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
40
|
function AlertActions({ className, ...props }) {
|
|
41
41
|
return /* @__PURE__ */ jsx("div", {
|
|
42
42
|
"data-slot": "alert-actions",
|
|
43
|
-
className: cn("col-start-2 row-start-
|
|
43
|
+
className: cn("col-start-2 row-start-2 mt-2 flex shrink-0 items-center gap-2 justify-self-start max-sm:group-has-[[data-slot=alert-title]]/alert:row-start-3 sm:col-start-3 sm:row-span-2 sm:row-start-1 sm:mt-[-0.125rem] sm:justify-self-end", className),
|
|
44
44
|
...props
|
|
45
45
|
});
|
|
46
46
|
}
|
package/dist/button.js
CHANGED
|
@@ -22,8 +22,8 @@ const buttonVariants = cva("group/button inline-flex shrink-0 items-center justi
|
|
|
22
22
|
danger: "bg-action-danger text-fg-on-brand hover:bg-action-danger-hover active:bg-action-danger-pressed data-focused:bg-action-danger-hover data-pressed:bg-action-danger-pressed",
|
|
23
23
|
whatsapp: "bg-whatsapp-green text-black [&_svg]:text-white hover:brightness-95 active:brightness-90 data-focused:brightness-95 data-pressed:brightness-90",
|
|
24
24
|
"glass-overlay": "bg-surface-glass-overlay-button text-fg-on-brand shadow-2 backdrop-blur-sm active:scale-[0.97] motion-reduce:active:scale-100",
|
|
25
|
-
"link-brand": "border-transparent bg-transparent
|
|
26
|
-
"link-neutral": "border-transparent bg-transparent
|
|
25
|
+
"link-brand": "border-transparent bg-transparent text-fg-brand hover:underline active:underline active:opacity-70",
|
|
26
|
+
"link-neutral": "border-transparent bg-transparent text-fg-secondary hover:underline active:underline active:opacity-70"
|
|
27
27
|
},
|
|
28
28
|
size: {
|
|
29
29
|
sm: "h-8 min-w-8 gap-2 px-3.5 text-gdt-xs [&_svg:not([class*='size-'])]:size-4",
|
|
@@ -37,6 +37,10 @@ const buttonVariants = cva("group/button inline-flex shrink-0 items-center justi
|
|
|
37
37
|
"icon-xl": "size-14 text-gdt-lg [&_svg:not([class*='size-'])]:size-5"
|
|
38
38
|
}
|
|
39
39
|
},
|
|
40
|
+
compoundVariants: [{
|
|
41
|
+
variant: ["link-brand", "link-neutral"],
|
|
42
|
+
class: "h-auto min-h-0 w-auto min-w-0 shrink justify-start rounded-none px-0 py-0 text-left whitespace-normal underline-offset-4 active:not-aria-[haspopup]:translate-y-0"
|
|
43
|
+
}],
|
|
40
44
|
defaultVariants: {
|
|
41
45
|
variant: "primary",
|
|
42
46
|
size: "md"
|
package/dist/footer.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { n as InputSize } from "./input-CfoEJ8A6.js";
|
|
2
2
|
import { useRender } from "@base-ui/react/use-render";
|
|
3
|
+
import { VariantProps } from "class-variance-authority";
|
|
3
4
|
import * as React from "react";
|
|
4
5
|
//#region src/footer.d.ts
|
|
5
6
|
type FooterRootProps = React.ComponentProps<"footer"> & {
|
|
@@ -20,10 +21,14 @@ declare function FooterSection({ className, ...props }: React.ComponentProps<"di
|
|
|
20
21
|
declare function FooterSectionTitle({ className, ...props }: React.ComponentProps<"h3">): React.JSX.Element;
|
|
21
22
|
declare function FooterLinkList({ className, ...props }: React.ComponentProps<"ul">): React.JSX.Element;
|
|
22
23
|
declare function FooterLinkItem({ className, ...props }: React.ComponentProps<"li">): React.JSX.Element;
|
|
23
|
-
|
|
24
|
+
declare const footerLinkVariants: (props?: ({
|
|
25
|
+
variant?: "brand" | "default" | null | undefined;
|
|
26
|
+
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
27
|
+
type FooterLinkVariant = NonNullable<VariantProps<typeof footerLinkVariants>["variant"]>;
|
|
28
|
+
type FooterLinkProps = useRender.ComponentProps<"a"> & VariantProps<typeof footerLinkVariants> & {
|
|
24
29
|
showArrow?: boolean;
|
|
25
30
|
};
|
|
26
|
-
declare function FooterLink({ className, children, render, showArrow, ...props }: FooterLinkProps): React.ReactElement<unknown, string | React.JSXElementConstructor<any>>;
|
|
31
|
+
declare function FooterLink({ className, children, render, showArrow, variant, ...props }: FooterLinkProps): React.ReactElement<unknown, string | React.JSXElementConstructor<any>>;
|
|
27
32
|
declare function FooterContactGrid({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
|
|
28
33
|
declare function FooterContact({ className, ...props }: React.ComponentProps<"address">): React.JSX.Element;
|
|
29
34
|
declare function FooterContactTitle({ className, ...props }: React.ComponentProps<"h3">): React.JSX.Element;
|
|
@@ -39,4 +44,4 @@ declare function FooterNewsletterControl({ className, children, size, ...props }
|
|
|
39
44
|
declare function FooterBottom({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
|
|
40
45
|
declare function FooterLegalLinks({ className, ...props }: React.ComponentProps<"nav">): React.JSX.Element;
|
|
41
46
|
//#endregion
|
|
42
|
-
export { FooterBody, FooterBottom, FooterContact, FooterContactGrid, FooterContactTitle, FooterCta, FooterCtaActions, FooterCtaContent, FooterCtaDescription, FooterCtaTitle, FooterDescription, FooterHeader, FooterLegalLinks, FooterLink, FooterLinkItem, FooterLinkList, type FooterLinkProps, FooterNav, FooterNewsletter, FooterNewsletterControl, FooterNewsletterDescription, FooterNewsletterTitle, FooterRoot, type FooterRootProps, FooterSection, FooterSectionTitle, FooterSocialItem, FooterSocialLink, FooterSocialList, FooterTitle };
|
|
47
|
+
export { FooterBody, FooterBottom, FooterContact, FooterContactGrid, FooterContactTitle, FooterCta, FooterCtaActions, FooterCtaContent, FooterCtaDescription, FooterCtaTitle, FooterDescription, FooterHeader, FooterLegalLinks, FooterLink, FooterLinkItem, FooterLinkList, type FooterLinkProps, type FooterLinkVariant, FooterNav, FooterNewsletter, FooterNewsletterControl, FooterNewsletterDescription, FooterNewsletterTitle, FooterRoot, type FooterRootProps, FooterSection, FooterSectionTitle, FooterSocialItem, FooterSocialLink, FooterSocialList, FooterTitle, footerLinkVariants };
|
package/dist/footer.js
CHANGED
|
@@ -6,6 +6,7 @@ import { Input } from "./input.js";
|
|
|
6
6
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
7
7
|
import { mergeProps } from "@base-ui/react/merge-props";
|
|
8
8
|
import { useRender } from "@base-ui/react/use-render";
|
|
9
|
+
import { cva } from "class-variance-authority";
|
|
9
10
|
//#region src/footer.tsx
|
|
10
11
|
function FooterRoot({ className, containerClassName, children, ...props }) {
|
|
11
12
|
return /* @__PURE__ */ jsx("footer", {
|
|
@@ -117,18 +118,28 @@ function FooterLinkItem({ className, ...props }) {
|
|
|
117
118
|
...props
|
|
118
119
|
});
|
|
119
120
|
}
|
|
120
|
-
|
|
121
|
+
const footerLinkVariants = cva("inline-flex items-center gap-gdt-sm underline-offset-4 transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-line-focus focus-visible:ring-offset-2", {
|
|
122
|
+
variants: { variant: {
|
|
123
|
+
default: "text-fg-secondary hover:text-fg-brand",
|
|
124
|
+
brand: "text-fg-brand"
|
|
125
|
+
} },
|
|
126
|
+
defaultVariants: { variant: "default" }
|
|
127
|
+
});
|
|
128
|
+
function FooterLink({ className, children, render, showArrow = false, variant = "default", ...props }) {
|
|
121
129
|
return useRender({
|
|
122
130
|
defaultTagName: "a",
|
|
123
131
|
props: mergeProps({
|
|
124
|
-
className: cn(
|
|
132
|
+
className: cn(footerLinkVariants({ variant }), className),
|
|
125
133
|
children: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", { children }), showArrow && /* @__PURE__ */ jsx(ChevronRight, {
|
|
126
134
|
"aria-hidden": "true",
|
|
127
135
|
className: "size-3.5"
|
|
128
136
|
})] })
|
|
129
137
|
}, props),
|
|
130
138
|
render,
|
|
131
|
-
state: {
|
|
139
|
+
state: {
|
|
140
|
+
slot: "footer-link",
|
|
141
|
+
variant
|
|
142
|
+
}
|
|
132
143
|
});
|
|
133
144
|
}
|
|
134
145
|
function FooterContactGrid({ className, ...props }) {
|
|
@@ -229,4 +240,4 @@ function FooterLegalLinks({ className, ...props }) {
|
|
|
229
240
|
});
|
|
230
241
|
}
|
|
231
242
|
//#endregion
|
|
232
|
-
export { FooterBody, FooterBottom, FooterContact, FooterContactGrid, FooterContactTitle, FooterCta, FooterCtaActions, FooterCtaContent, FooterCtaDescription, FooterCtaTitle, FooterDescription, FooterHeader, FooterLegalLinks, FooterLink, FooterLinkItem, FooterLinkList, FooterNav, FooterNewsletter, FooterNewsletterControl, FooterNewsletterDescription, FooterNewsletterTitle, FooterRoot, FooterSection, FooterSectionTitle, FooterSocialItem, FooterSocialLink, FooterSocialList, FooterTitle };
|
|
243
|
+
export { FooterBody, FooterBottom, FooterContact, FooterContactGrid, FooterContactTitle, FooterCta, FooterCtaActions, FooterCtaContent, FooterCtaDescription, FooterCtaTitle, FooterDescription, FooterHeader, FooterLegalLinks, FooterLink, FooterLinkItem, FooterLinkList, FooterNav, FooterNewsletter, FooterNewsletterControl, FooterNewsletterDescription, FooterNewsletterTitle, FooterRoot, FooterSection, FooterSectionTitle, FooterSocialItem, FooterSocialLink, FooterSocialList, FooterTitle, footerLinkVariants };
|
|
@@ -8,8 +8,8 @@ import { ReviewBlock } from "./review-block.js";
|
|
|
8
8
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
9
9
|
import { cva } from "class-variance-authority";
|
|
10
10
|
//#region src/media-gallery-hero.tsx
|
|
11
|
-
const mediaGalleryHeroVariants = cva("relative isolate h-[350px] w-full overflow-hidden rounded-[32px]
|
|
12
|
-
const mediaGalleryImagesVariants = cva("absolute inset-0 z-0 hidden size-full min-h-0 min-w-0
|
|
11
|
+
const mediaGalleryHeroVariants = cva("relative isolate h-[350px] w-full overflow-hidden rounded-[32px] md:h-[380px] md:rounded-[40px] lg:h-[480px]");
|
|
12
|
+
const mediaGalleryImagesVariants = cva("absolute inset-0 z-0 hidden size-full min-h-0 min-w-0 md:grid", {
|
|
13
13
|
variants: { imageCount: {
|
|
14
14
|
1: "",
|
|
15
15
|
2: "gap-2 grid-cols-[minmax(0,3fr)_minmax(0,2fr)]",
|
|
@@ -77,7 +77,7 @@ function MobileGalleryCounter({ total }) {
|
|
|
77
77
|
function MobileMediaCarousel({ currentMedia, images, mediaTotal, onMediaClick, renderMedia, showMobileIndicator }) {
|
|
78
78
|
const carouselImages = images.length > 0 ? images : [{}];
|
|
79
79
|
return /* @__PURE__ */ jsxs(Carousel, {
|
|
80
|
-
className: "absolute inset-0 z-0 block size-full rounded-[32px]
|
|
80
|
+
className: "absolute inset-0 z-0 block size-full rounded-[32px] md:hidden",
|
|
81
81
|
imageSize: "full",
|
|
82
82
|
opts: {
|
|
83
83
|
align: "start",
|
|
@@ -62,7 +62,7 @@ function PropertyListingCardMediaTop({ className, ...props }) {
|
|
|
62
62
|
function PropertyListingCardOptionsBar({ className, render, ...props }) {
|
|
63
63
|
return useRender({
|
|
64
64
|
defaultTagName: "a",
|
|
65
|
-
props: mergeProps({ className: cn("absolute inset-x-0 bottom-0 z-10 flex min-h-9.5 items-center justify-center gap-1 bg-black/
|
|
65
|
+
props: mergeProps({ className: cn("absolute inset-x-0 bottom-0 z-10 flex min-h-9.5 items-center justify-center gap-1 bg-black/70 px-4 py-1.5 text-center text-gdt-subtext font-extrabold tracking-wide text-white uppercase transition-[background-color] hover:bg-black/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-line-focus", "supports-backdrop-filter:bg-black/40 supports-backdrop-filter:backdrop-blur-[15px] supports-backdrop-filter:hover:bg-black/55", className) }, props),
|
|
66
66
|
render,
|
|
67
67
|
state: { slot: "property-listing-card-options-bar" }
|
|
68
68
|
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { t as ComponentSize } from "./size-context-BX6kAdVj.js";
|
|
2
|
+
import { TabsListVariant } from "./tabs.js";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
//#region src/scroll-spy.d.ts
|
|
5
|
+
type ScrollSpyProps = Omit<React.ComponentProps<"div">, "onChange"> & {
|
|
6
|
+
orientation?: "horizontal" | "vertical";
|
|
7
|
+
/** Controlled active section. */
|
|
8
|
+
value?: string;
|
|
9
|
+
/** Active section before the first scroll measurement lands. */
|
|
10
|
+
defaultValue?: string;
|
|
11
|
+
onValueChange?: (value: string) => void;
|
|
12
|
+
/**
|
|
13
|
+
* Distance in px between the top of the scrollport and the line a section
|
|
14
|
+
* has to cross to become active. Set it to the height of a sticky header —
|
|
15
|
+
* it also becomes each section's `scroll-margin-top`, so scrolling lands
|
|
16
|
+
* the heading below the header rather than under it.
|
|
17
|
+
*/
|
|
18
|
+
scrollOffset?: number;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* `Tabs` for a page that shows everything at once: every section stays mounted
|
|
22
|
+
* and visible, the triggers scroll to their section, and the active trigger
|
|
23
|
+
* tracks whichever section the reader is currently on.
|
|
24
|
+
*
|
|
25
|
+
* The parts mirror the `Tabs` API — `ScrollSpy` / `ScrollSpyList` /
|
|
26
|
+
* `ScrollSpyTrigger` / `ScrollSpyContent`, matched by `value` — and the list
|
|
27
|
+
* takes the same `variant` and `size` as `TabsList`, so the two read as one
|
|
28
|
+
* family. Overflow ("More" dropdowns, per-breakpoint trigger counts) is the
|
|
29
|
+
* consuming app's job, exactly as it is with `Tabs`;
|
|
30
|
+
* `scrollSpyTriggerClassName` is exported so an overflow button can match the
|
|
31
|
+
* real triggers.
|
|
32
|
+
*/
|
|
33
|
+
declare function ScrollSpy({ className, orientation, value, defaultValue, onValueChange, scrollOffset, children, ...props }: ScrollSpyProps): React.JSX.Element;
|
|
34
|
+
type ScrollSpyListProps = React.ComponentProps<"nav"> & {
|
|
35
|
+
variant?: TabsListVariant;
|
|
36
|
+
size?: ComponentSize;
|
|
37
|
+
/**
|
|
38
|
+
* Keep the active trigger visible as the reader scrolls. Opt in when the
|
|
39
|
+
* list itself scrolls — without it, a strip narrower than its triggers looks
|
|
40
|
+
* frozen once the active one passes out of view. No-op when nothing
|
|
41
|
+
* overflows, and it never scrolls anything but the list.
|
|
42
|
+
*/
|
|
43
|
+
scrollActiveIntoView?: boolean;
|
|
44
|
+
};
|
|
45
|
+
declare function ScrollSpyList({ className, variant, size, children, scrollActiveIntoView, "aria-label": ariaLabel, ...props }: ScrollSpyListProps): React.JSX.Element;
|
|
46
|
+
/**
|
|
47
|
+
* Trigger styling, exported so an overflow "More" button outside the list can
|
|
48
|
+
* render the same look — the `Tabs` trigger classes verbatim, so the two
|
|
49
|
+
* components stay pixel-identical.
|
|
50
|
+
*/
|
|
51
|
+
declare const scrollSpyTriggerClassName: string;
|
|
52
|
+
type ScrollSpyTriggerProps = Omit<React.ComponentProps<"button">, "value"> & {
|
|
53
|
+
value: string;
|
|
54
|
+
};
|
|
55
|
+
declare function ScrollSpyTrigger({ className, value, type, onClick, ...props }: ScrollSpyTriggerProps): React.JSX.Element;
|
|
56
|
+
type ScrollSpyContentProps = Omit<React.ComponentProps<"section">, "value"> & {
|
|
57
|
+
value: string;
|
|
58
|
+
};
|
|
59
|
+
declare function ScrollSpyContent({ className, value, style, tabIndex, ...props }: ScrollSpyContentProps): React.JSX.Element;
|
|
60
|
+
//#endregion
|
|
61
|
+
export { ScrollSpy, ScrollSpyContent, type ScrollSpyContentProps, ScrollSpyList, type ScrollSpyListProps, type ScrollSpyProps, ScrollSpyTrigger, type ScrollSpyTriggerProps, scrollSpyTriggerClassName };
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { t as cn } from "./cn-BI_4DMBf.js";
|
|
3
|
+
import { useComponentSize } from "./size-context.js";
|
|
4
|
+
import { t as useReducedMotion } from "./use-reduced-motion-BDvOK14x.js";
|
|
5
|
+
import { t as useUncontrolled } from "./use-uncontrolled-CLfM1XbB.js";
|
|
6
|
+
import { resolveTabsListVariant, tabsIndicatorClassName, tabsListVariants, tabsTriggerClassName } from "./tabs.js";
|
|
7
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
import * as React from "react";
|
|
9
|
+
//#region src/scroll-spy.tsx
|
|
10
|
+
const ScrollSpyContext = React.createContext(null);
|
|
11
|
+
/**
|
|
12
|
+
* The element this spy's sections actually scroll inside — a scrollable
|
|
13
|
+
* ancestor, or `document.scrollingElement` for the page. Everything is
|
|
14
|
+
* measured against it, so each instance answers only to its own scrollport:
|
|
15
|
+
* a spy in an open Sheet and a spy on the page behind it never see each
|
|
16
|
+
* other's scrolling.
|
|
17
|
+
*
|
|
18
|
+
* Returns `null` when nothing scrolls. A `position: fixed` ancestor with no
|
|
19
|
+
* scrollable element inside it — a short Dialog, say — is pinned to the
|
|
20
|
+
* viewport, so it neither moves with the page nor scrolls on its own. Falling
|
|
21
|
+
* back to the page there would read the page's scroll position and pin the
|
|
22
|
+
* last section active for a reader who never scrolled anything.
|
|
23
|
+
*
|
|
24
|
+
* Walked from a section rather than the root: the root often sits *outside*
|
|
25
|
+
* the scrolling region, as it does when a Sheet puts the list in its header
|
|
26
|
+
* and the sections in its scrolling body.
|
|
27
|
+
*/
|
|
28
|
+
function getScrollport(element) {
|
|
29
|
+
let node = element;
|
|
30
|
+
while (node && node !== document.documentElement) {
|
|
31
|
+
const { overflowY, position } = getComputedStyle(node);
|
|
32
|
+
if (overflowY === "auto" || overflowY === "scroll") return node;
|
|
33
|
+
if (position === "fixed") return null;
|
|
34
|
+
node = node.parentElement;
|
|
35
|
+
}
|
|
36
|
+
return document.scrollingElement;
|
|
37
|
+
}
|
|
38
|
+
function useScrollSpyContext(part) {
|
|
39
|
+
const context = React.useContext(ScrollSpyContext);
|
|
40
|
+
if (!context) throw new Error(`\`${part}\` must be rendered inside \`ScrollSpy\`.`);
|
|
41
|
+
return context;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* `Tabs` for a page that shows everything at once: every section stays mounted
|
|
45
|
+
* and visible, the triggers scroll to their section, and the active trigger
|
|
46
|
+
* tracks whichever section the reader is currently on.
|
|
47
|
+
*
|
|
48
|
+
* The parts mirror the `Tabs` API — `ScrollSpy` / `ScrollSpyList` /
|
|
49
|
+
* `ScrollSpyTrigger` / `ScrollSpyContent`, matched by `value` — and the list
|
|
50
|
+
* takes the same `variant` and `size` as `TabsList`, so the two read as one
|
|
51
|
+
* family. Overflow ("More" dropdowns, per-breakpoint trigger counts) is the
|
|
52
|
+
* consuming app's job, exactly as it is with `Tabs`;
|
|
53
|
+
* `scrollSpyTriggerClassName` is exported so an overflow button can match the
|
|
54
|
+
* real triggers.
|
|
55
|
+
*/
|
|
56
|
+
function ScrollSpy({ className, orientation = "horizontal", value, defaultValue, onValueChange, scrollOffset = 0, children, ...props }) {
|
|
57
|
+
const baseId = React.useId();
|
|
58
|
+
const reducedMotion = useReducedMotion();
|
|
59
|
+
const [activeValue, setActiveValue] = useUncontrolled({
|
|
60
|
+
value,
|
|
61
|
+
defaultValue,
|
|
62
|
+
onChange: (next) => {
|
|
63
|
+
if (next !== void 0) onValueChange?.(next);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
const sectionsRef = React.useRef(/* @__PURE__ */ new Map());
|
|
67
|
+
const [sectionsVersion, setSectionsVersion] = React.useState(0);
|
|
68
|
+
const activeRef = React.useRef(activeValue);
|
|
69
|
+
const commitRef = React.useRef(setActiveValue);
|
|
70
|
+
React.useEffect(() => {
|
|
71
|
+
activeRef.current = activeValue;
|
|
72
|
+
commitRef.current = setActiveValue;
|
|
73
|
+
});
|
|
74
|
+
const registerSection = React.useCallback((sectionValue, element) => {
|
|
75
|
+
if (element) sectionsRef.current.set(sectionValue, element);
|
|
76
|
+
else sectionsRef.current.delete(sectionValue);
|
|
77
|
+
setSectionsVersion((version) => version + 1);
|
|
78
|
+
}, []);
|
|
79
|
+
React.useEffect(() => {
|
|
80
|
+
let frame = 0;
|
|
81
|
+
const [firstSection] = sectionsRef.current.values();
|
|
82
|
+
const scrollport = getScrollport(firstSection ?? null);
|
|
83
|
+
const isPage = scrollport === document.scrollingElement;
|
|
84
|
+
const measure = () => {
|
|
85
|
+
frame = 0;
|
|
86
|
+
const sections = [...sectionsRef.current.entries()].map(([sectionValue, element]) => ({
|
|
87
|
+
value: sectionValue,
|
|
88
|
+
top: element.getBoundingClientRect().top
|
|
89
|
+
})).sort((a, b) => a.top - b.top);
|
|
90
|
+
const first = sections[0];
|
|
91
|
+
const last = sections.at(-1);
|
|
92
|
+
if (!(first && last)) return;
|
|
93
|
+
const originTop = scrollport && !isPage ? scrollport.getBoundingClientRect().top : 0;
|
|
94
|
+
const atBottom = scrollport ? scrollport.scrollTop + scrollport.clientHeight >= scrollport.scrollHeight - 2 : false;
|
|
95
|
+
let next = first.value;
|
|
96
|
+
if (atBottom) next = last.value;
|
|
97
|
+
else for (const section of sections) if (section.top - originTop <= scrollOffset + 2) next = section.value;
|
|
98
|
+
if (next !== activeRef.current) {
|
|
99
|
+
activeRef.current = next;
|
|
100
|
+
commitRef.current(next);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const schedule = () => {
|
|
104
|
+
if (!frame) frame = requestAnimationFrame(measure);
|
|
105
|
+
};
|
|
106
|
+
measure();
|
|
107
|
+
const scrollTarget = isPage ? window : scrollport;
|
|
108
|
+
scrollTarget?.addEventListener("scroll", schedule, { passive: true });
|
|
109
|
+
window.addEventListener("resize", schedule, { passive: true });
|
|
110
|
+
return () => {
|
|
111
|
+
if (frame) cancelAnimationFrame(frame);
|
|
112
|
+
scrollTarget?.removeEventListener("scroll", schedule);
|
|
113
|
+
window.removeEventListener("resize", schedule);
|
|
114
|
+
};
|
|
115
|
+
}, [scrollOffset, sectionsVersion]);
|
|
116
|
+
const scrollToSection = React.useCallback((sectionValue) => {
|
|
117
|
+
const element = sectionsRef.current.get(sectionValue);
|
|
118
|
+
if (!element) return;
|
|
119
|
+
activeRef.current = sectionValue;
|
|
120
|
+
commitRef.current(sectionValue);
|
|
121
|
+
element.scrollIntoView({
|
|
122
|
+
behavior: reducedMotion ? "auto" : "smooth",
|
|
123
|
+
block: "start"
|
|
124
|
+
});
|
|
125
|
+
element.focus({ preventScroll: true });
|
|
126
|
+
}, [reducedMotion]);
|
|
127
|
+
const context = React.useMemo(() => ({
|
|
128
|
+
activeValue,
|
|
129
|
+
scrollOffset,
|
|
130
|
+
registerSection,
|
|
131
|
+
scrollToSection,
|
|
132
|
+
getTriggerId: (sectionValue) => `${baseId}-trigger-${sectionValue}`,
|
|
133
|
+
getSectionId: (sectionValue) => `${baseId}-section-${sectionValue}`
|
|
134
|
+
}), [
|
|
135
|
+
activeValue,
|
|
136
|
+
baseId,
|
|
137
|
+
registerSection,
|
|
138
|
+
scrollOffset,
|
|
139
|
+
scrollToSection
|
|
140
|
+
]);
|
|
141
|
+
return /* @__PURE__ */ jsx(ScrollSpyContext.Provider, {
|
|
142
|
+
value: context,
|
|
143
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
144
|
+
"data-slot": "scroll-spy",
|
|
145
|
+
className: cn("group/tabs flex gap-3 data-[orientation=horizontal]:flex-col", className),
|
|
146
|
+
"data-orientation": orientation,
|
|
147
|
+
...props,
|
|
148
|
+
children
|
|
149
|
+
})
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Keeps `element` inside `list`'s own scrollport, on whichever axis actually
|
|
154
|
+
* overflows. Deliberately not `scrollIntoView`: that walks every scrollable
|
|
155
|
+
* ancestor, and the page is the very thing driving the active section here —
|
|
156
|
+
* nudging it back would fight the reader mid-scroll. This touches only the
|
|
157
|
+
* list's `scrollLeft`/`scrollTop`.
|
|
158
|
+
*
|
|
159
|
+
* "Nearest" semantics with a margin: an already-visible trigger doesn't move,
|
|
160
|
+
* and one scrolled to leaves a sliver of its neighbour showing rather than
|
|
161
|
+
* sitting flush against the edge.
|
|
162
|
+
*/
|
|
163
|
+
const FOLLOW_MARGIN = 24;
|
|
164
|
+
function followActiveTrigger(list, element, behavior) {
|
|
165
|
+
const axes = [{
|
|
166
|
+
overflows: list.scrollWidth > list.clientWidth,
|
|
167
|
+
start: element.offsetLeft,
|
|
168
|
+
size: element.offsetWidth,
|
|
169
|
+
view: list.scrollLeft,
|
|
170
|
+
viewSize: list.clientWidth,
|
|
171
|
+
key: "left"
|
|
172
|
+
}, {
|
|
173
|
+
overflows: list.scrollHeight > list.clientHeight,
|
|
174
|
+
start: element.offsetTop,
|
|
175
|
+
size: element.offsetHeight,
|
|
176
|
+
view: list.scrollTop,
|
|
177
|
+
viewSize: list.clientHeight,
|
|
178
|
+
key: "top"
|
|
179
|
+
}];
|
|
180
|
+
const options = { behavior };
|
|
181
|
+
for (const axis of axes) {
|
|
182
|
+
if (!axis.overflows) continue;
|
|
183
|
+
const end = axis.start + axis.size;
|
|
184
|
+
const viewEnd = axis.view + axis.viewSize;
|
|
185
|
+
if (axis.start < axis.view) options[axis.key] = Math.max(0, axis.start - FOLLOW_MARGIN);
|
|
186
|
+
else if (end > viewEnd) options[axis.key] = end - axis.viewSize + FOLLOW_MARGIN;
|
|
187
|
+
}
|
|
188
|
+
if (options.left !== void 0 || options.top !== void 0) list.scrollTo(options);
|
|
189
|
+
}
|
|
190
|
+
function ScrollSpyList({ className, variant = "underline", size, children, scrollActiveIntoView = false, "aria-label": ariaLabel = "Section navigation", ...props }) {
|
|
191
|
+
const { activeValue } = useScrollSpyContext("ScrollSpyList");
|
|
192
|
+
const reducedMotion = useReducedMotion();
|
|
193
|
+
const hasFollowedRef = React.useRef(false);
|
|
194
|
+
const resolvedVariant = resolveTabsListVariant(variant);
|
|
195
|
+
const resolvedSize = useComponentSize(size);
|
|
196
|
+
const listRef = React.useRef(null);
|
|
197
|
+
const [indicator, setIndicator] = React.useState(null);
|
|
198
|
+
React.useEffect(() => {
|
|
199
|
+
const list = listRef.current;
|
|
200
|
+
if (!list) return;
|
|
201
|
+
const measure = () => {
|
|
202
|
+
const active = list.querySelector("[data-slot=\"scroll-spy-trigger\"][data-active]");
|
|
203
|
+
const next = active ? {
|
|
204
|
+
left: active.offsetLeft,
|
|
205
|
+
top: active.offsetTop,
|
|
206
|
+
width: active.offsetWidth,
|
|
207
|
+
height: active.offsetHeight
|
|
208
|
+
} : null;
|
|
209
|
+
setIndicator((current) => current?.left === next?.left && current?.top === next?.top && current?.width === next?.width && current?.height === next?.height ? current : next);
|
|
210
|
+
};
|
|
211
|
+
measure();
|
|
212
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
213
|
+
const observer = new ResizeObserver(measure);
|
|
214
|
+
observer.observe(list);
|
|
215
|
+
for (const trigger of list.querySelectorAll("[data-slot=\"scroll-spy-trigger\"]")) observer.observe(trigger);
|
|
216
|
+
return () => observer.disconnect();
|
|
217
|
+
}, [activeValue]);
|
|
218
|
+
React.useEffect(() => {
|
|
219
|
+
const list = listRef.current;
|
|
220
|
+
if (!(scrollActiveIntoView && list)) return;
|
|
221
|
+
const active = list.querySelector("[data-slot=\"scroll-spy-trigger\"][data-active]");
|
|
222
|
+
if (!active) return;
|
|
223
|
+
const behavior = reducedMotion || !hasFollowedRef.current ? "auto" : "smooth";
|
|
224
|
+
hasFollowedRef.current = true;
|
|
225
|
+
followActiveTrigger(list, active, behavior);
|
|
226
|
+
}, [
|
|
227
|
+
activeValue,
|
|
228
|
+
reducedMotion,
|
|
229
|
+
scrollActiveIntoView
|
|
230
|
+
]);
|
|
231
|
+
return /* @__PURE__ */ jsxs("nav", {
|
|
232
|
+
ref: listRef,
|
|
233
|
+
"data-slot": "scroll-spy-list",
|
|
234
|
+
"data-variant": resolvedVariant,
|
|
235
|
+
"data-size": resolvedSize,
|
|
236
|
+
"aria-label": ariaLabel,
|
|
237
|
+
className: cn(tabsListVariants({ variant: resolvedVariant }), className),
|
|
238
|
+
style: indicator ? {
|
|
239
|
+
"--active-tab-left": `${indicator.left}px`,
|
|
240
|
+
"--active-tab-top": `${indicator.top}px`,
|
|
241
|
+
"--active-tab-width": `${indicator.width}px`,
|
|
242
|
+
"--active-tab-height": `${indicator.height}px`
|
|
243
|
+
} : void 0,
|
|
244
|
+
...props,
|
|
245
|
+
children: [children, indicator ? /* @__PURE__ */ jsx("span", {
|
|
246
|
+
"aria-hidden": true,
|
|
247
|
+
className: tabsIndicatorClassName
|
|
248
|
+
}) : null]
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Trigger styling, exported so an overflow "More" button outside the list can
|
|
253
|
+
* render the same look — the `Tabs` trigger classes verbatim, so the two
|
|
254
|
+
* components stay pixel-identical.
|
|
255
|
+
*/
|
|
256
|
+
const scrollSpyTriggerClassName = tabsTriggerClassName;
|
|
257
|
+
function ScrollSpyTrigger({ className, value, type = "button", onClick, ...props }) {
|
|
258
|
+
const { activeValue, getSectionId, getTriggerId, scrollToSection } = useScrollSpyContext("ScrollSpyTrigger");
|
|
259
|
+
const isActive = activeValue === value;
|
|
260
|
+
return /* @__PURE__ */ jsx("button", {
|
|
261
|
+
"data-slot": "scroll-spy-trigger",
|
|
262
|
+
id: getTriggerId(value),
|
|
263
|
+
type,
|
|
264
|
+
"aria-controls": getSectionId(value),
|
|
265
|
+
"aria-current": isActive ? "location" : void 0,
|
|
266
|
+
"data-active": isActive ? "" : void 0,
|
|
267
|
+
className: cn(scrollSpyTriggerClassName, "cursor-pointer", className),
|
|
268
|
+
onClick: (event) => {
|
|
269
|
+
onClick?.(event);
|
|
270
|
+
if (!event.defaultPrevented) scrollToSection(value);
|
|
271
|
+
},
|
|
272
|
+
...props
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
function ScrollSpyContent({ className, value, style, tabIndex = -1, ...props }) {
|
|
276
|
+
const { activeValue, getSectionId, getTriggerId, registerSection, scrollOffset } = useScrollSpyContext("ScrollSpyContent");
|
|
277
|
+
return /* @__PURE__ */ jsx("section", {
|
|
278
|
+
ref: React.useCallback((element) => {
|
|
279
|
+
registerSection(value, element);
|
|
280
|
+
}, [registerSection, value]),
|
|
281
|
+
"data-slot": "scroll-spy-content",
|
|
282
|
+
"data-active": activeValue === value ? "" : void 0,
|
|
283
|
+
id: getSectionId(value),
|
|
284
|
+
"aria-labelledby": getTriggerId(value),
|
|
285
|
+
tabIndex,
|
|
286
|
+
className: cn("flex-1 text-sm outline-none", className),
|
|
287
|
+
style: {
|
|
288
|
+
scrollMarginTop: scrollOffset,
|
|
289
|
+
...style
|
|
290
|
+
},
|
|
291
|
+
...props
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
//#endregion
|
|
295
|
+
export { ScrollSpy, ScrollSpyContent, ScrollSpyList, ScrollSpyTrigger, scrollSpyTriggerClassName };
|