@giddaa-housing/ui 3.2.0 → 3.4.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.
@@ -0,0 +1,318 @@
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 used inside \`ScrollSpy\`.`);
41
+ return context;
42
+ }
43
+ /**
44
+ * Read and drive the spy from anywhere inside a `ScrollSpy` — an overflow
45
+ * "More" menu, a floating progress rail, a heading that echoes the section
46
+ * being read.
47
+ *
48
+ * Context reaches through portals, so a consumer inside an open `DropdownMenu`
49
+ * or `Popover` works without threading anything down by hand. `registerSection`
50
+ * is deliberately not exposed: sections are `ScrollSpyContent`'s to own.
51
+ */
52
+ function useScrollSpy() {
53
+ const { activeValue, getSectionId, getTriggerId, scrollToSection } = useScrollSpyContext("useScrollSpy");
54
+ return React.useMemo(() => ({
55
+ activeValue,
56
+ getSectionId,
57
+ getTriggerId,
58
+ scrollToSection
59
+ }), [
60
+ activeValue,
61
+ getSectionId,
62
+ getTriggerId,
63
+ scrollToSection
64
+ ]);
65
+ }
66
+ /**
67
+ * `Tabs` for a page that shows everything at once: every section stays mounted
68
+ * and visible, the triggers scroll to their section, and the active trigger
69
+ * tracks whichever section the reader is currently on.
70
+ *
71
+ * The parts mirror the `Tabs` API — `ScrollSpy` / `ScrollSpyList` /
72
+ * `ScrollSpyTrigger` / `ScrollSpyContent`, matched by `value` — and the list
73
+ * takes the same `variant` and `size` as `TabsList`, so the two read as one
74
+ * family. Overflow ("More" dropdowns, per-breakpoint trigger counts) is the
75
+ * consuming app's job, exactly as it is with `Tabs`;
76
+ * `scrollSpyTriggerClassName` is exported so an overflow button can match the
77
+ * real triggers.
78
+ */
79
+ function ScrollSpy({ className, orientation = "horizontal", value, defaultValue, onValueChange, scrollOffset = 0, children, ...props }) {
80
+ const baseId = React.useId();
81
+ const reducedMotion = useReducedMotion();
82
+ const [activeValue, setActiveValue] = useUncontrolled({
83
+ value,
84
+ defaultValue,
85
+ onChange: (next) => {
86
+ if (next !== void 0) onValueChange?.(next);
87
+ }
88
+ });
89
+ const sectionsRef = React.useRef(/* @__PURE__ */ new Map());
90
+ const [sectionsVersion, setSectionsVersion] = React.useState(0);
91
+ const activeRef = React.useRef(activeValue);
92
+ const commitRef = React.useRef(setActiveValue);
93
+ React.useEffect(() => {
94
+ activeRef.current = activeValue;
95
+ commitRef.current = setActiveValue;
96
+ });
97
+ const registerSection = React.useCallback((sectionValue, element) => {
98
+ if (element) sectionsRef.current.set(sectionValue, element);
99
+ else sectionsRef.current.delete(sectionValue);
100
+ setSectionsVersion((version) => version + 1);
101
+ }, []);
102
+ React.useEffect(() => {
103
+ let frame = 0;
104
+ const [firstSection] = sectionsRef.current.values();
105
+ const scrollport = getScrollport(firstSection ?? null);
106
+ const isPage = scrollport === document.scrollingElement;
107
+ const measure = () => {
108
+ frame = 0;
109
+ const sections = [...sectionsRef.current.entries()].map(([sectionValue, element]) => ({
110
+ value: sectionValue,
111
+ top: element.getBoundingClientRect().top
112
+ })).sort((a, b) => a.top - b.top);
113
+ const first = sections[0];
114
+ const last = sections.at(-1);
115
+ if (!(first && last)) return;
116
+ const originTop = scrollport && !isPage ? scrollport.getBoundingClientRect().top : 0;
117
+ const atBottom = scrollport ? scrollport.scrollTop + scrollport.clientHeight >= scrollport.scrollHeight - 2 : false;
118
+ let next = first.value;
119
+ if (atBottom) next = last.value;
120
+ else for (const section of sections) if (section.top - originTop <= scrollOffset + 2) next = section.value;
121
+ if (next !== activeRef.current) {
122
+ activeRef.current = next;
123
+ commitRef.current(next);
124
+ }
125
+ };
126
+ const schedule = () => {
127
+ if (!frame) frame = requestAnimationFrame(measure);
128
+ };
129
+ measure();
130
+ const scrollTarget = isPage ? window : scrollport;
131
+ scrollTarget?.addEventListener("scroll", schedule, { passive: true });
132
+ window.addEventListener("resize", schedule, { passive: true });
133
+ return () => {
134
+ if (frame) cancelAnimationFrame(frame);
135
+ scrollTarget?.removeEventListener("scroll", schedule);
136
+ window.removeEventListener("resize", schedule);
137
+ };
138
+ }, [scrollOffset, sectionsVersion]);
139
+ const scrollToSection = React.useCallback((sectionValue) => {
140
+ const element = sectionsRef.current.get(sectionValue);
141
+ if (!element) return;
142
+ activeRef.current = sectionValue;
143
+ commitRef.current(sectionValue);
144
+ element.scrollIntoView({
145
+ behavior: reducedMotion ? "auto" : "smooth",
146
+ block: "start"
147
+ });
148
+ element.focus({ preventScroll: true });
149
+ }, [reducedMotion]);
150
+ const context = React.useMemo(() => ({
151
+ activeValue,
152
+ scrollOffset,
153
+ registerSection,
154
+ scrollToSection,
155
+ getTriggerId: (sectionValue) => `${baseId}-trigger-${sectionValue}`,
156
+ getSectionId: (sectionValue) => `${baseId}-section-${sectionValue}`
157
+ }), [
158
+ activeValue,
159
+ baseId,
160
+ registerSection,
161
+ scrollOffset,
162
+ scrollToSection
163
+ ]);
164
+ return /* @__PURE__ */ jsx(ScrollSpyContext.Provider, {
165
+ value: context,
166
+ children: /* @__PURE__ */ jsx("div", {
167
+ "data-slot": "scroll-spy",
168
+ className: cn("group/tabs flex gap-3 data-[orientation=horizontal]:flex-col", className),
169
+ "data-orientation": orientation,
170
+ ...props,
171
+ children
172
+ })
173
+ });
174
+ }
175
+ /**
176
+ * Keeps `element` inside `list`'s own scrollport, on whichever axis actually
177
+ * overflows. Deliberately not `scrollIntoView`: that walks every scrollable
178
+ * ancestor, and the page is the very thing driving the active section here —
179
+ * nudging it back would fight the reader mid-scroll. This touches only the
180
+ * list's `scrollLeft`/`scrollTop`.
181
+ *
182
+ * "Nearest" semantics with a margin: an already-visible trigger doesn't move,
183
+ * and one scrolled to leaves a sliver of its neighbour showing rather than
184
+ * sitting flush against the edge.
185
+ */
186
+ const FOLLOW_MARGIN = 24;
187
+ function followActiveTrigger(list, element, behavior) {
188
+ const axes = [{
189
+ overflows: list.scrollWidth > list.clientWidth,
190
+ start: element.offsetLeft,
191
+ size: element.offsetWidth,
192
+ view: list.scrollLeft,
193
+ viewSize: list.clientWidth,
194
+ key: "left"
195
+ }, {
196
+ overflows: list.scrollHeight > list.clientHeight,
197
+ start: element.offsetTop,
198
+ size: element.offsetHeight,
199
+ view: list.scrollTop,
200
+ viewSize: list.clientHeight,
201
+ key: "top"
202
+ }];
203
+ const options = { behavior };
204
+ for (const axis of axes) {
205
+ if (!axis.overflows) continue;
206
+ const end = axis.start + axis.size;
207
+ const viewEnd = axis.view + axis.viewSize;
208
+ if (axis.start < axis.view) options[axis.key] = Math.max(0, axis.start - FOLLOW_MARGIN);
209
+ else if (end > viewEnd) options[axis.key] = end - axis.viewSize + FOLLOW_MARGIN;
210
+ }
211
+ if (options.left !== void 0 || options.top !== void 0) list.scrollTo(options);
212
+ }
213
+ function ScrollSpyList({ className, variant = "underline", size, children, scrollActiveIntoView = false, "aria-label": ariaLabel = "Section navigation", ...props }) {
214
+ const { activeValue } = useScrollSpyContext("ScrollSpyList");
215
+ const reducedMotion = useReducedMotion();
216
+ const hasFollowedRef = React.useRef(false);
217
+ const resolvedVariant = resolveTabsListVariant(variant);
218
+ const resolvedSize = useComponentSize(size);
219
+ const listRef = React.useRef(null);
220
+ const [indicator, setIndicator] = React.useState(null);
221
+ React.useEffect(() => {
222
+ const list = listRef.current;
223
+ if (!list) return;
224
+ const measure = () => {
225
+ const active = list.querySelector("[data-slot=\"scroll-spy-trigger\"][data-active]");
226
+ const next = active ? {
227
+ left: active.offsetLeft,
228
+ top: active.offsetTop,
229
+ width: active.offsetWidth,
230
+ height: active.offsetHeight
231
+ } : null;
232
+ setIndicator((current) => current?.left === next?.left && current?.top === next?.top && current?.width === next?.width && current?.height === next?.height ? current : next);
233
+ };
234
+ measure();
235
+ if (typeof ResizeObserver === "undefined") return;
236
+ const observer = new ResizeObserver(measure);
237
+ observer.observe(list);
238
+ for (const trigger of list.querySelectorAll("[data-slot=\"scroll-spy-trigger\"]")) observer.observe(trigger);
239
+ return () => observer.disconnect();
240
+ }, [activeValue]);
241
+ React.useEffect(() => {
242
+ const list = listRef.current;
243
+ if (!(scrollActiveIntoView && list)) return;
244
+ const active = list.querySelector("[data-slot=\"scroll-spy-trigger\"][data-active]");
245
+ if (!active) return;
246
+ const behavior = reducedMotion || !hasFollowedRef.current ? "auto" : "smooth";
247
+ hasFollowedRef.current = true;
248
+ followActiveTrigger(list, active, behavior);
249
+ }, [
250
+ activeValue,
251
+ reducedMotion,
252
+ scrollActiveIntoView
253
+ ]);
254
+ return /* @__PURE__ */ jsxs("nav", {
255
+ ref: listRef,
256
+ "data-slot": "scroll-spy-list",
257
+ "data-variant": resolvedVariant,
258
+ "data-size": resolvedSize,
259
+ "aria-label": ariaLabel,
260
+ className: cn(tabsListVariants({ variant: resolvedVariant }), className),
261
+ style: indicator ? {
262
+ "--active-tab-left": `${indicator.left}px`,
263
+ "--active-tab-top": `${indicator.top}px`,
264
+ "--active-tab-width": `${indicator.width}px`,
265
+ "--active-tab-height": `${indicator.height}px`
266
+ } : void 0,
267
+ ...props,
268
+ children: [children, indicator ? /* @__PURE__ */ jsx("span", {
269
+ "aria-hidden": true,
270
+ className: tabsIndicatorClassName
271
+ }) : null]
272
+ });
273
+ }
274
+ /**
275
+ * Trigger styling, exported so an overflow "More" button outside the list can
276
+ * render the same look — the `Tabs` trigger classes verbatim, so the two
277
+ * components stay pixel-identical.
278
+ */
279
+ const scrollSpyTriggerClassName = tabsTriggerClassName;
280
+ function ScrollSpyTrigger({ className, value, type = "button", onClick, ...props }) {
281
+ const { activeValue, getSectionId, getTriggerId, scrollToSection } = useScrollSpyContext("ScrollSpyTrigger");
282
+ const isActive = activeValue === value;
283
+ return /* @__PURE__ */ jsx("button", {
284
+ "data-slot": "scroll-spy-trigger",
285
+ id: getTriggerId(value),
286
+ type,
287
+ "aria-controls": getSectionId(value),
288
+ "aria-current": isActive ? "location" : void 0,
289
+ "data-active": isActive ? "" : void 0,
290
+ className: cn(scrollSpyTriggerClassName, "cursor-pointer", className),
291
+ onClick: (event) => {
292
+ onClick?.(event);
293
+ if (!event.defaultPrevented) scrollToSection(value);
294
+ },
295
+ ...props
296
+ });
297
+ }
298
+ function ScrollSpyContent({ className, value, style, tabIndex = -1, ...props }) {
299
+ const { activeValue, getSectionId, getTriggerId, registerSection, scrollOffset } = useScrollSpyContext("ScrollSpyContent");
300
+ return /* @__PURE__ */ jsx("section", {
301
+ ref: React.useCallback((element) => {
302
+ registerSection(value, element);
303
+ }, [registerSection, value]),
304
+ "data-slot": "scroll-spy-content",
305
+ "data-active": activeValue === value ? "" : void 0,
306
+ id: getSectionId(value),
307
+ "aria-labelledby": getTriggerId(value),
308
+ tabIndex,
309
+ className: cn("flex-1 text-sm outline-none", className),
310
+ style: {
311
+ scrollMarginTop: scrollOffset,
312
+ ...style
313
+ },
314
+ ...props
315
+ });
316
+ }
317
+ //#endregion
318
+ export { ScrollSpy, ScrollSpyContent, ScrollSpyList, ScrollSpyTrigger, scrollSpyTriggerClassName, useScrollSpy };
@@ -0,0 +1,51 @@
1
+ import { t as ComponentSize } from "./size-context-BX6kAdVj.js";
2
+ import { VariantProps } from "class-variance-authority";
3
+ import * as React from "react";
4
+ //#region src/sonar.d.ts
5
+ /**
6
+ * Kept in step with `--animate-sonar-wave` in `css/theme.css`; the waves are
7
+ * staggered by a fraction of one cycle, which only reads as a continuous sonar
8
+ * if this matches the keyframe's duration. `sonar.test.tsx` asserts they agree.
9
+ */
10
+ declare const SONAR_DURATION_MS = 1500;
11
+ /**
12
+ * Colour rides on `currentColor` so the keyframe stays tone-agnostic: one set
13
+ * of keyframes, one class per tone.
14
+ */
15
+ declare const sonarWaveVariants: (props?: ({
16
+ tone?: "brand" | "danger" | "info" | "success" | "warning" | null | undefined;
17
+ } & import("class-variance-authority/types").ClassProp) | undefined) => string;
18
+ type SonarProps = React.ComponentProps<"span"> & VariantProps<typeof sonarWaveVariants> & {
19
+ /** Stop pulsing without unmounting — the child renders on its own. */
20
+ active?: boolean;
21
+ /** Overlapping rings. More reads as a faster, more urgent sonar. */
22
+ waves?: 1 | 2 | 3;
23
+ /** How far each ring travels. Inherits from `SizeProvider`. */
24
+ size?: ComponentSize;
25
+ /**
26
+ * Skip measurement and use this `border-radius` verbatim. Only needed
27
+ * when the child's shape can't be read off the DOM — an SVG using
28
+ * geometry rather than CSS, say.
29
+ */
30
+ radius?: string;
31
+ };
32
+ /**
33
+ * Draws attention to a dot, badge or button by pulsing rings outward from it.
34
+ *
35
+ * The rings trace the child's own shape: `Sonar` reads the child's computed
36
+ * `border-radius` and hands it to the rings, so a pill badge pulses a pill and
37
+ * a rounded button pulses a rounded rectangle, with nothing to keep in sync by
38
+ * hand. That matters because radii in this library are often size-dependent —
39
+ * `Tag` alone moves through `rounded-md`/`lg`/`xl` across its three sizes.
40
+ *
41
+ * Purely decorative: the rings are `aria-hidden` and the child is untouched, so
42
+ * whatever the child announces is what assistive tech hears. A pulse is not a
43
+ * label — if the attention it draws carries meaning, put that meaning in the
44
+ * child.
45
+ *
46
+ * The rings are painted with `box-shadow` spread, which lives outside the
47
+ * element's box, so an ancestor with `overflow: hidden` will clip them.
48
+ */
49
+ declare function Sonar({ className, children, active, waves, size, tone, radius, style, ...props }: SonarProps): React.JSX.Element;
50
+ //#endregion
51
+ export { SONAR_DURATION_MS, Sonar, type SonarProps, sonarWaveVariants };
package/dist/sonar.js ADDED
@@ -0,0 +1,117 @@
1
+ "use client";
2
+ import { t as cn } from "./cn-BI_4DMBf.js";
3
+ import { useComponentSize } from "./size-context.js";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { cva } from "class-variance-authority";
6
+ import * as React from "react";
7
+ //#region src/sonar.tsx
8
+ /**
9
+ * Kept in step with `--animate-sonar-wave` in `css/theme.css`; the waves are
10
+ * staggered by a fraction of one cycle, which only reads as a continuous sonar
11
+ * if this matches the keyframe's duration. `sonar.test.tsx` asserts they agree.
12
+ */
13
+ const SONAR_DURATION_MS = 1500;
14
+ const SQUARE = {
15
+ borderTopLeftRadius: "0px",
16
+ borderTopRightRadius: "0px",
17
+ borderBottomRightRadius: "0px",
18
+ borderBottomLeftRadius: "0px"
19
+ };
20
+ function sameCorners(a, b) {
21
+ return a !== null && a.borderTopLeftRadius === b.borderTopLeftRadius && a.borderTopRightRadius === b.borderTopRightRadius && a.borderBottomRightRadius === b.borderBottomRightRadius && a.borderBottomLeftRadius === b.borderBottomLeftRadius;
22
+ }
23
+ /**
24
+ * Colour rides on `currentColor` so the keyframe stays tone-agnostic: one set
25
+ * of keyframes, one class per tone.
26
+ */
27
+ const sonarWaveVariants = cva(cn("pointer-events-none absolute inset-0 animate-sonar-wave", "motion-reduce:animate-none motion-reduce:opacity-40 motion-reduce:shadow-[0_0_0_2px_currentColor]"), {
28
+ variants: { tone: {
29
+ brand: "text-fg-brand",
30
+ info: "text-status-info",
31
+ success: "text-status-success",
32
+ warning: "text-status-warning",
33
+ danger: "text-status-danger"
34
+ } },
35
+ defaultVariants: { tone: "brand" }
36
+ });
37
+ /**
38
+ * Rings evenly spaced across one cycle, so the last finishes just as the first
39
+ * comes round again. Doubles as each ring's key — the offsets are distinct by
40
+ * construction, which an index would only pretend to be.
41
+ */
42
+ function waveDelays(waves) {
43
+ return Array.from({ length: waves }, (_, index) => Math.round(index * SONAR_DURATION_MS / waves));
44
+ }
45
+ /** How far the ring travels before it fades out. */
46
+ const sonarSpread = {
47
+ sm: "[--sonar-spread:0.375rem]",
48
+ md: "[--sonar-spread:0.625rem]",
49
+ lg: "[--sonar-spread:0.875rem]"
50
+ };
51
+ /**
52
+ * Draws attention to a dot, badge or button by pulsing rings outward from it.
53
+ *
54
+ * The rings trace the child's own shape: `Sonar` reads the child's computed
55
+ * `border-radius` and hands it to the rings, so a pill badge pulses a pill and
56
+ * a rounded button pulses a rounded rectangle, with nothing to keep in sync by
57
+ * hand. That matters because radii in this library are often size-dependent —
58
+ * `Tag` alone moves through `rounded-md`/`lg`/`xl` across its three sizes.
59
+ *
60
+ * Purely decorative: the rings are `aria-hidden` and the child is untouched, so
61
+ * whatever the child announces is what assistive tech hears. A pulse is not a
62
+ * label — if the attention it draws carries meaning, put that meaning in the
63
+ * child.
64
+ *
65
+ * The rings are painted with `box-shadow` spread, which lives outside the
66
+ * element's box, so an ancestor with `overflow: hidden` will clip them.
67
+ */
68
+ function Sonar({ className, children, active = true, waves = 2, size, tone, radius, style, ...props }) {
69
+ const resolvedSize = useComponentSize(size);
70
+ const ref = React.useRef(null);
71
+ const [corners, setCorners] = React.useState(null);
72
+ React.useEffect(() => {
73
+ if (radius !== void 0 || !active) return;
74
+ const target = ref.current?.querySelector(":scope > :not([data-slot=\"sonar-wave\"])");
75
+ if (!target) {
76
+ setCorners(SQUARE);
77
+ return;
78
+ }
79
+ const measure = () => {
80
+ const computed = getComputedStyle(target);
81
+ const next = {
82
+ borderTopLeftRadius: computed.borderTopLeftRadius,
83
+ borderTopRightRadius: computed.borderTopRightRadius,
84
+ borderBottomRightRadius: computed.borderBottomRightRadius,
85
+ borderBottomLeftRadius: computed.borderBottomLeftRadius
86
+ };
87
+ setCorners((current) => sameCorners(current, next) ? current : next);
88
+ };
89
+ measure();
90
+ if (typeof ResizeObserver === "undefined") return;
91
+ const observer = new ResizeObserver(measure);
92
+ observer.observe(target);
93
+ return () => observer.disconnect();
94
+ }, [active, radius]);
95
+ const shape = radius !== void 0 ? { borderRadius: radius } : corners;
96
+ return /* @__PURE__ */ jsxs("span", {
97
+ ref,
98
+ "data-slot": "sonar",
99
+ "data-tone": tone ?? "brand",
100
+ "data-size": resolvedSize,
101
+ "data-active": active ? "" : void 0,
102
+ className: cn("relative inline-flex w-fit shrink-0", sonarSpread[resolvedSize], className),
103
+ style,
104
+ ...props,
105
+ children: [children, active && shape ? waveDelays(waves).map((delay) => /* @__PURE__ */ jsx("span", {
106
+ "aria-hidden": "true",
107
+ "data-slot": "sonar-wave",
108
+ style: {
109
+ ...shape,
110
+ animationDelay: delay === 0 ? void 0 : `${delay}ms`
111
+ },
112
+ className: cn(sonarWaveVariants({ tone }), delay > 0 && "motion-reduce:hidden")
113
+ }, `sonar-wave-${delay}`)) : null]
114
+ });
115
+ }
116
+ //#endregion
117
+ export { SONAR_DURATION_MS, Sonar, sonarWaveVariants };