@giddaa-housing/ui 3.3.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/theme.css CHANGED
@@ -141,6 +141,7 @@
141
141
  --animate-accordion-up: accordion-up 0.2s ease-out;
142
142
  --animate-sonar-glow: sonar-glow 1.5s cubic-bezier(0, 0, 0.2, 1) infinite;
143
143
  --animate-round-sonar-glow: round-sonar-glow 1.5s ease-out infinite;
144
+ --animate-sonar-wave: sonar-wave 1.5s cubic-bezier(0, 0, 0.2, 1) infinite;
144
145
  --animate-spin-slow: spin 3s linear infinite;
145
146
  }
146
147
 
@@ -175,6 +176,7 @@
175
176
  --animate-bouncing-loader: none;
176
177
  --animate-sonar-glow: none;
177
178
  --animate-round-sonar-glow: none;
179
+ --animate-sonar-wave: none;
178
180
  --animate-spin-slow: none;
179
181
  }
180
182
  }
@@ -280,3 +282,21 @@
280
282
  opacity: 0;
281
283
  }
282
284
  }
285
+
286
+ /* Drives `Sonar`. Spread rather than `transform: scale()` on purpose: spread
287
+ grows the ring by the same number of pixels on every side and traces the
288
+ element's own border-radius, so one keyframe fits a 8px dot and a wide
289
+ button alike. Scaling would expand a wide element far more horizontally
290
+ than vertically — which is why sonar-glow above needs its per-axis
291
+ --sonar-glow-scale-x/y knobs. */
292
+ @keyframes sonar-wave {
293
+ from {
294
+ box-shadow: 0 0 0 0 currentColor;
295
+ opacity: 0.55;
296
+ }
297
+
298
+ to {
299
+ box-shadow: 0 0 0 var(--sonar-spread, 0.625rem) currentColor;
300
+ opacity: 0;
301
+ }
302
+ }
package/dist/badge.js CHANGED
@@ -36,6 +36,11 @@ const badgeVariants = cva("group/badge inline-flex w-fit shrink-0 items-center j
36
36
  false: ""
37
37
  }
38
38
  },
39
+ compoundVariants: [{
40
+ variant: "outline",
41
+ border: false,
42
+ class: "border-line"
43
+ }],
39
44
  defaultVariants: {
40
45
  variant: "default",
41
46
  size: "md",
@@ -3,32 +3,100 @@ import * as React from "react";
3
3
  //#region src/price-tag.d.ts
4
4
  declare const priceTagVariants: (props?: ({
5
5
  size?: "lg" | "md" | "sm" | "xs" | "xxs" | null | undefined;
6
+ hasDescription?: boolean | null | undefined;
6
7
  hasMonthlyPayment?: boolean | null | undefined;
7
8
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
8
9
  type PriceTagSize = NonNullable<VariantProps<typeof priceTagVariants>["size"]>;
10
+ /**
11
+ * A currency in the dropdown.
12
+ *
13
+ * `currency`, `value` and `description` are all PriceTag needs, and none of
14
+ * them are interpreted — `description` in particular is printed as given, so
15
+ * the wording is yours. The rest are legacy and go in the next major.
16
+ */
9
17
  type PriceTagCurrencyOption = {
10
- code: string;
11
- amount: string;
18
+ /** Shown at the leading edge, and matched against `selectedCurrency`. */
19
+ currency?: string;
20
+ /** The price, exactly as it should read. */
21
+ value?: React.ReactNode;
22
+ /** A second line under the price. Your words, printed as given. */
23
+ description?: React.ReactNode;
24
+ /** @deprecated Use `currency`. Removed in the next major. */
25
+ code?: string;
26
+ /** @deprecated Use `value`. Removed in the next major. */
27
+ amount?: string;
28
+ /** @deprecated Format before you pass it. Removed in the next major. */
12
29
  shortAmount?: string;
30
+ /** @deprecated Use `description`. Removed in the next major. */
13
31
  monthlyPayment?: string;
32
+ /** @deprecated Use `description`. Removed in the next major. */
14
33
  monthlyPaymentLabel?: string;
15
34
  };
35
+ /**
36
+ * A currency option after the legacy fields have been folded in — what
37
+ * `onCurrencyChange` and `renderMenuOption` receive. The original object is
38
+ * spread in, so code still reading `code` or `amount` off it keeps working.
39
+ *
40
+ * Generic over your own option type, which `PriceTag` infers from `currencies`.
41
+ * Anything extra you hang on an option — a rate, an id, a flag — arrives here
42
+ * typed, so `renderMenuOption` can use it without a cast.
43
+ */
44
+ type PriceTagMenuOption<TOption extends PriceTagCurrencyOption = PriceTagCurrencyOption> = TOption & {
45
+ currency: string;
46
+ value: React.ReactNode;
47
+ description?: React.ReactNode;
48
+ /** Whether this is the row `selectedCurrency` points at. */
49
+ selected: boolean;
50
+ };
51
+ /** @deprecated Format before you pass it. Removed in the next major. */
16
52
  type PriceTagFormat = "full" | "shortened";
17
- type PriceTagProps = Omit<React.ComponentProps<"button">, "children"> & {
18
- amount: string;
19
- shortAmount?: string;
20
- format?: PriceTagFormat;
53
+ type PriceTagProps<TOption extends PriceTagCurrencyOption = PriceTagCurrencyOption> = Omit<React.ComponentProps<"button">, "children" | "value"> & {
54
+ /** The price, exactly as it should read. */
55
+ value?: React.ReactNode;
56
+ /** A second line under the price. Your words, printed as given. */
57
+ description?: React.ReactNode;
21
58
  size?: PriceTagSize;
22
- monthlyPayment?: string;
23
- monthlyPaymentLabel?: string;
24
- currencies?: PriceTagCurrencyOption[];
59
+ /**
60
+ * Providing this turns the tag into a dropdown trigger. Options carry
61
+ * whatever else you need — `TOption` is inferred from here, and the
62
+ * callbacks below receive it intact.
63
+ */
64
+ currencies?: TOption[];
25
65
  selectedCurrency?: string;
26
- onCurrencyChange?: (currency: PriceTagCurrencyOption) => void;
66
+ onCurrencyChange?: (option: PriceTagMenuOption<NoInfer<TOption>>) => void;
67
+ /**
68
+ * Replace a row's contents. PriceTag keeps the button, its selected state
69
+ * and its keyboard behaviour, so a custom row can't quietly lose them.
70
+ */
71
+ renderMenuOption?: (option: PriceTagMenuOption<NoInfer<TOption>>) => React.ReactNode;
27
72
  open?: boolean;
28
73
  defaultOpen?: boolean;
29
74
  onOpenChange?: (open: boolean) => void;
30
75
  popoverClassName?: string;
76
+ /** @deprecated Use `value`. Removed in the next major. */
77
+ amount?: string;
78
+ /** @deprecated Format before you pass it. Removed in the next major. */
79
+ shortAmount?: string;
80
+ /** @deprecated Format before you pass it. Removed in the next major. */
81
+ format?: PriceTagFormat;
82
+ /** @deprecated Use `description`. Removed in the next major. */
83
+ monthlyPayment?: string;
84
+ /** @deprecated Use `description`. Removed in the next major. */
85
+ monthlyPaymentLabel?: string;
31
86
  };
32
- declare function PriceTag({ amount, shortAmount, format, size, monthlyPayment, monthlyPaymentLabel, currencies, selectedCurrency, onCurrencyChange, open, defaultOpen, onOpenChange, className, popoverClassName, type, ...props }: PriceTagProps): React.JSX.Element;
87
+ /**
88
+ * Pill-shaped price display, optionally a trigger for a currency dropdown.
89
+ *
90
+ * Display only: it prints `value` and `description` as given and never
91
+ * composes a sentence, picks a format, or decides that a line is too small to
92
+ * be worth showing. Wording, currency conversion and number formatting all
93
+ * belong to the app, which is the only place that knows the locale, the
94
+ * product's voice, and what the second line is actually for.
95
+ *
96
+ * The legacy `amount` / `shortAmount` / `format` / `monthlyPayment` props still
97
+ * work and keep their old behaviour exactly, including the size threshold that
98
+ * hid the monthly line below `md`. They go in the next major.
99
+ */
100
+ declare function PriceTag<TOption extends PriceTagCurrencyOption = PriceTagCurrencyOption>({ value, description, size, currencies, selectedCurrency, onCurrencyChange, renderMenuOption, open, defaultOpen, onOpenChange, className, popoverClassName, type, amount, shortAmount, format, monthlyPayment, monthlyPaymentLabel, ...props }: PriceTagProps<TOption>): React.JSX.Element;
33
101
  //#endregion
34
- export { PriceTag, type PriceTagCurrencyOption, type PriceTagFormat, type PriceTagProps, type PriceTagSize, priceTagVariants };
102
+ export { PriceTag, type PriceTagCurrencyOption, type PriceTagFormat, type PriceTagMenuOption, type PriceTagProps, type PriceTagSize, priceTagVariants };
package/dist/price-tag.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { Check, ChevronDown } from "./icons.js";
3
3
  import { t as cn } from "./cn-BI_4DMBf.js";
4
- import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
5
  import { cva } from "class-variance-authority";
6
6
  import { Popover } from "@base-ui/react/popover";
7
7
  //#region src/price-tag.tsx
@@ -14,6 +14,12 @@ const priceTagVariants = cva("group/price-tag inline-flex w-fit shrink-0 items-c
14
14
  md: "min-h-10.5 gap-1.5 px-5 py-2 text-gdt-md [&>svg]:size-4",
15
15
  lg: "min-h-13 gap-2 px-7 py-3 text-gdt-lg [&>svg]:size-4.5"
16
16
  },
17
+ /** Two lines sit top-aligned against the chevron rather than centred. */
18
+ hasDescription: {
19
+ true: "items-start",
20
+ false: ""
21
+ },
22
+ /** @deprecated Use `hasDescription`. Removed in the next major. */
17
23
  hasMonthlyPayment: {
18
24
  true: "items-start",
19
25
  false: ""
@@ -21,37 +27,102 @@ const priceTagVariants = cva("group/price-tag inline-flex w-fit shrink-0 items-c
21
27
  },
22
28
  defaultVariants: {
23
29
  size: "md",
24
- hasMonthlyPayment: false
30
+ hasDescription: false
25
31
  }
26
32
  });
33
+ /** The description is quieter than the price, and quietest at the largest size. */
34
+ const descriptionSizeClassName = {
35
+ xxs: "text-gdt-subtext",
36
+ xs: "text-gdt-subtext",
37
+ sm: "text-gdt-subtext",
38
+ md: "text-gdt-subtext",
39
+ lg: "text-gdt-xs"
40
+ };
27
41
  function getDisplayAmount({ amount, shortAmount, format }) {
28
42
  return format === "shortened" && shortAmount ? shortAmount : amount;
29
43
  }
30
- function PriceTag({ amount, shortAmount, format = "full", size = "md", monthlyPayment, monthlyPaymentLabel = "Low as", currencies, selectedCurrency, onCurrencyChange, open, defaultOpen, onOpenChange, className, popoverClassName, type = "button", ...props }) {
44
+ /**
45
+ * Folds the legacy fields into the current three. New fields win outright, so
46
+ * an option can be migrated one at a time.
47
+ */
48
+ function resolveMenuOption(option, format, selectedCurrency) {
49
+ const currency = option.currency ?? option.code ?? "";
50
+ const value = option.value ?? getDisplayAmount({
51
+ amount: option.amount,
52
+ shortAmount: option.shortAmount,
53
+ format
54
+ });
55
+ const description = option.description ?? (option.monthlyPayment ? `${option.monthlyPaymentLabel ?? "As low as"} ${option.monthlyPayment} per month` : void 0);
56
+ return {
57
+ ...option,
58
+ currency,
59
+ value,
60
+ description,
61
+ selected: currency === selectedCurrency
62
+ };
63
+ }
64
+ function DefaultMenuOption({ option }) {
65
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
66
+ /* @__PURE__ */ jsx("span", {
67
+ className: "min-w-10 text-gdt-sm font-semibold text-fg-brand",
68
+ children: option.currency
69
+ }),
70
+ /* @__PURE__ */ jsxs("span", {
71
+ className: "ml-auto flex min-w-0 flex-col items-end",
72
+ children: [/* @__PURE__ */ jsx("span", {
73
+ className: "text-gdt-sm font-bold text-fg-primary",
74
+ children: option.value
75
+ }), option.description ? /* @__PURE__ */ jsx("span", {
76
+ className: "text-gdt-xs text-fg-secondary",
77
+ children: option.description
78
+ }) : null]
79
+ }),
80
+ option.selected ? /* @__PURE__ */ jsx(Check, {
81
+ "aria-hidden": "true",
82
+ className: "size-4 shrink-0 text-fg-brand"
83
+ }) : null
84
+ ] });
85
+ }
86
+ /**
87
+ * Pill-shaped price display, optionally a trigger for a currency dropdown.
88
+ *
89
+ * Display only: it prints `value` and `description` as given and never
90
+ * composes a sentence, picks a format, or decides that a line is too small to
91
+ * be worth showing. Wording, currency conversion and number formatting all
92
+ * belong to the app, which is the only place that knows the locale, the
93
+ * product's voice, and what the second line is actually for.
94
+ *
95
+ * The legacy `amount` / `shortAmount` / `format` / `monthlyPayment` props still
96
+ * work and keep their old behaviour exactly, including the size threshold that
97
+ * hid the monthly line below `md`. They go in the next major.
98
+ */
99
+ function PriceTag({ value, description, size = "md", currencies, selectedCurrency, onCurrencyChange, renderMenuOption, open, defaultOpen, onOpenChange, className, popoverClassName, type = "button", amount, shortAmount, format = "full", monthlyPayment, monthlyPaymentLabel = "Low as", ...props }) {
31
100
  const hasDropdown = Boolean(currencies?.length);
32
- const displayMonthlyPayment = (size === "md" || size === "lg") && monthlyPayment ? `${monthlyPaymentLabel} ${monthlyPayment} monthly` : void 0;
101
+ const resolvedValue = value ?? getDisplayAmount({
102
+ amount,
103
+ shortAmount,
104
+ format
105
+ });
106
+ const legacyDescription = monthlyPayment && (size === "md" || size === "lg") ? `${monthlyPaymentLabel} ${monthlyPayment} monthly` : void 0;
107
+ const resolvedDescription = description ?? legacyDescription;
33
108
  const pill = /* @__PURE__ */ jsxs("span", {
34
109
  className: "flex min-w-0 flex-col gap-1",
35
- children: [/* @__PURE__ */ jsx("span", { children: getDisplayAmount({
36
- amount,
37
- shortAmount,
38
- format
39
- }) }), displayMonthlyPayment ? /* @__PURE__ */ jsx("span", {
40
- className: cn("hidden text-gdt-subtext font-medium leading-none opacity-90", {
41
- "inline-block": size === "md",
42
- "inline-block text-gdt-xs": size === "lg"
43
- }),
44
- children: displayMonthlyPayment
110
+ children: [/* @__PURE__ */ jsx("span", { children: resolvedValue }), resolvedDescription ? /* @__PURE__ */ jsx("span", {
111
+ className: cn("font-medium leading-none opacity-90", descriptionSizeClassName[size]),
112
+ children: resolvedDescription
45
113
  }) : null]
46
114
  });
115
+ const pillClassName = cn(priceTagVariants({
116
+ size,
117
+ hasDescription: Boolean(resolvedDescription)
118
+ }), className);
47
119
  if (!hasDropdown) return /* @__PURE__ */ jsx("span", {
48
120
  "data-slot": "price-tag",
49
- className: cn(priceTagVariants({
50
- size,
51
- hasMonthlyPayment: Boolean(displayMonthlyPayment)
52
- }), className),
121
+ className: pillClassName,
122
+ ...props,
53
123
  children: pill
54
124
  });
125
+ const options = (currencies ?? []).map((currency) => resolveMenuOption(currency, format, selectedCurrency));
55
126
  return /* @__PURE__ */ jsxs(Popover.Root, {
56
127
  open,
57
128
  defaultOpen,
@@ -60,10 +131,7 @@ function PriceTag({ amount, shortAmount, format = "full", size = "md", monthlyPa
60
131
  "data-slot": "price-tag",
61
132
  "data-interactive": "true",
62
133
  type,
63
- className: cn(priceTagVariants({
64
- size,
65
- hasMonthlyPayment: Boolean(displayMonthlyPayment)
66
- }), className),
134
+ className: pillClassName,
67
135
  ...props,
68
136
  children: [pill, /* @__PURE__ */ jsx(ChevronDown, {
69
137
  "aria-hidden": "true",
@@ -75,41 +143,14 @@ function PriceTag({ amount, shortAmount, format = "full", size = "md", monthlyPa
75
143
  children: /* @__PURE__ */ jsx(Popover.Popup, {
76
144
  "data-slot": "price-tag-popover",
77
145
  className: cn("z-50 flex w-[17.25rem] max-w-[calc(100vw-2rem)] origin-(--transform-origin) flex-col rounded-xl border border-line-subtle bg-surface-overlay p-2 text-fg-primary shadow-3 outline-hidden transition-[opacity,scale,translate] duration-[var(--duration-motion-popup)] ease-gdt-out data-starting-style:opacity-0 data-starting-style:scale-95 data-ending-style:opacity-0 data-ending-style:scale-95 data-[side=bottom]:data-starting-style:translate-y-2 data-[side=bottom]:data-ending-style:translate-y-2 data-[side=top]:data-starting-style:-translate-y-2 data-[side=top]:data-ending-style:-translate-y-2 motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100 motion-reduce:data-[side=bottom]:data-starting-style:translate-y-0 motion-reduce:data-[side=bottom]:data-ending-style:translate-y-0 motion-reduce:data-[side=top]:data-starting-style:translate-y-0 motion-reduce:data-[side=top]:data-ending-style:translate-y-0", popoverClassName),
78
- children: currencies?.map((currency) => {
79
- const active = currency.code === selectedCurrency;
80
- const optionAmount = getDisplayAmount({
81
- amount: currency.amount,
82
- shortAmount: currency.shortAmount,
83
- format
84
- });
85
- const optionMonthlyPayment = currency.monthlyPayment ? `${currency.monthlyPaymentLabel ?? "As low as"} ${currency.monthlyPayment} per month` : void 0;
86
- return /* @__PURE__ */ jsxs("button", {
87
- type: "button",
88
- "data-active": active ? "true" : void 0,
89
- className: "flex min-h-14 w-full items-center gap-3 rounded-lg px-3 text-start outline-none transition-colors hover:bg-surface-brand-subtle focus-visible:bg-surface-brand-subtle focus-visible:shadow-[0px_0px_0px_2px_var(--color-line-focus)] data-[active=true]:bg-surface-brand-subtle",
90
- onClick: () => onCurrencyChange?.(currency),
91
- children: [
92
- /* @__PURE__ */ jsx("span", {
93
- className: "min-w-10 text-gdt-sm font-semibold text-fg-brand",
94
- children: currency.code
95
- }),
96
- /* @__PURE__ */ jsxs("span", {
97
- className: "ml-auto flex min-w-0 flex-col items-end",
98
- children: [/* @__PURE__ */ jsx("span", {
99
- className: "text-gdt-sm font-bold text-fg-primary",
100
- children: optionAmount
101
- }), optionMonthlyPayment ? /* @__PURE__ */ jsx("span", {
102
- className: "text-gdt-xs text-fg-secondary",
103
- children: optionMonthlyPayment
104
- }) : null]
105
- }),
106
- active ? /* @__PURE__ */ jsx(Check, {
107
- "aria-hidden": "true",
108
- className: "size-4 shrink-0 text-fg-brand"
109
- }) : null
110
- ]
111
- }, currency.code);
112
- })
146
+ children: options.map((option) => /* @__PURE__ */ jsx("button", {
147
+ type: "button",
148
+ "data-slot": "price-tag-option",
149
+ "data-active": option.selected ? "true" : void 0,
150
+ className: "flex min-h-14 w-full items-center gap-3 rounded-lg px-3 text-start outline-none transition-colors hover:bg-surface-brand-subtle focus-visible:bg-surface-brand-subtle focus-visible:shadow-[0px_0px_0px_2px_var(--color-line-focus)] data-[active=true]:bg-surface-brand-subtle",
151
+ onClick: () => onCurrencyChange?.(option),
152
+ children: renderMenuOption ? renderMenuOption(option) : /* @__PURE__ */ jsx(DefaultMenuOption, { option })
153
+ }, option.currency))
113
154
  })
114
155
  }) })]
115
156
  });
@@ -2,6 +2,21 @@ import { t as ComponentSize } from "./size-context-BX6kAdVj.js";
2
2
  import { TabsListVariant } from "./tabs.js";
3
3
  import * as React from "react";
4
4
  //#region src/scroll-spy.d.ts
5
+ /**
6
+ * Read and drive the spy from anywhere inside a `ScrollSpy` — an overflow
7
+ * "More" menu, a floating progress rail, a heading that echoes the section
8
+ * being read.
9
+ *
10
+ * Context reaches through portals, so a consumer inside an open `DropdownMenu`
11
+ * or `Popover` works without threading anything down by hand. `registerSection`
12
+ * is deliberately not exposed: sections are `ScrollSpyContent`'s to own.
13
+ */
14
+ declare function useScrollSpy(): {
15
+ activeValue: string | undefined;
16
+ getSectionId: (value: string) => string;
17
+ getTriggerId: (value: string) => string;
18
+ scrollToSection: (value: string) => void;
19
+ };
5
20
  type ScrollSpyProps = Omit<React.ComponentProps<"div">, "onChange"> & {
6
21
  orientation?: "horizontal" | "vertical";
7
22
  /** Controlled active section. */
@@ -58,4 +73,4 @@ type ScrollSpyContentProps = Omit<React.ComponentProps<"section">, "value"> & {
58
73
  };
59
74
  declare function ScrollSpyContent({ className, value, style, tabIndex, ...props }: ScrollSpyContentProps): React.JSX.Element;
60
75
  //#endregion
61
- export { ScrollSpy, ScrollSpyContent, type ScrollSpyContentProps, ScrollSpyList, type ScrollSpyListProps, type ScrollSpyProps, ScrollSpyTrigger, type ScrollSpyTriggerProps, scrollSpyTriggerClassName };
76
+ export { ScrollSpy, ScrollSpyContent, type ScrollSpyContentProps, ScrollSpyList, type ScrollSpyListProps, type ScrollSpyProps, ScrollSpyTrigger, type ScrollSpyTriggerProps, scrollSpyTriggerClassName, useScrollSpy };
@@ -37,10 +37,33 @@ function getScrollport(element) {
37
37
  }
38
38
  function useScrollSpyContext(part) {
39
39
  const context = React.useContext(ScrollSpyContext);
40
- if (!context) throw new Error(`\`${part}\` must be rendered inside \`ScrollSpy\`.`);
40
+ if (!context) throw new Error(`\`${part}\` must be used inside \`ScrollSpy\`.`);
41
41
  return context;
42
42
  }
43
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
+ /**
44
67
  * `Tabs` for a page that shows everything at once: every section stays mounted
45
68
  * and visible, the triggers scroll to their section, and the active trigger
46
69
  * tracks whichever section the reader is currently on.
@@ -292,4 +315,4 @@ function ScrollSpyContent({ className, value, style, tabIndex = -1, ...props })
292
315
  });
293
316
  }
294
317
  //#endregion
295
- export { ScrollSpy, ScrollSpyContent, ScrollSpyList, ScrollSpyTrigger, scrollSpyTriggerClassName };
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 };