@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.
- package/css/generated/shared.css +1 -1
- package/css/theme.css +20 -0
- package/dist/accordion.js +1 -0
- package/dist/alert.js +3 -3
- package/dist/badge.js +5 -0
- 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/price-tag.d.ts +80 -12
- package/dist/price-tag.js +98 -57
- package/dist/property-listing-card.js +1 -1
- package/dist/scroll-spy.d.ts +76 -0
- package/dist/scroll-spy.js +318 -0
- package/dist/sonar.d.ts +51 -0
- package/dist/sonar.js +117 -0
- package/dist/styles.css +106 -9
- package/dist/tabs.d.ts +13 -1
- package/dist/tabs.js +9 -2
- package/dist/toast.js +1 -0
- package/package.json +9 -1
|
@@ -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 };
|
package/dist/sonar.d.ts
ADDED
|
@@ -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 };
|