@bearmenu/ui 0.8.23 → 0.8.24

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.
@@ -1,6 +1,7 @@
1
+ import { MotionPlayPause } from './chunk-4OF4T4AI.js';
1
2
  import { cn } from './chunk-FEK5XGZW.js';
2
3
  import { useRef, useState, useEffect } from 'react';
3
- import { jsx, jsxs } from 'react/jsx-runtime';
4
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
4
5
 
5
6
  var DRIFT_RAIL_DEFAULT_DURATION_SECONDS = 42;
6
7
  var DRIFT_RAIL_RESUME_DELAY_MS = 3e3;
@@ -9,6 +10,7 @@ function DriftRail({
9
10
  reverse = false,
10
11
  durationSeconds = DRIFT_RAIL_DEFAULT_DURATION_SECONDS,
11
12
  pauseOnHover = true,
13
+ labels,
12
14
  className
13
15
  }) {
14
16
  const scrollerRef = useRef(null);
@@ -16,6 +18,8 @@ function DriftRail({
16
18
  const [isHovered, setIsHovered] = useState(false);
17
19
  const [inView, setInView] = useState(true);
18
20
  const [reducedMotion, setReducedMotion] = useState(false);
21
+ const [isManuallyPaused, setIsManuallyPaused] = useState(false);
22
+ const [playPauseReplayToken, setPlayPauseReplayToken] = useState(0);
19
23
  const heldUntil = useRef(0);
20
24
  const mouseDrag = useRef(null);
21
25
  const position = useRef(0);
@@ -37,14 +41,14 @@ function DriftRail({
37
41
  return () => observer.disconnect();
38
42
  }, []);
39
43
  useEffect(() => {
40
- const scroller = scrollerRef.current;
44
+ const scroller2 = scrollerRef.current;
41
45
  const track = trackRef.current;
42
- if (!scroller || !track) return void 0;
46
+ if (!scroller2 || !track) return void 0;
43
47
  let frame = 0;
44
48
  let last = performance.now();
45
- const drifting = !reducedMotion && inView && !(pauseOnHover && isHovered);
46
- if (reverse && scroller.scrollLeft === 0) scroller.scrollLeft = track.scrollWidth / 2;
47
- position.current = scroller.scrollLeft;
49
+ const drifting = !reducedMotion && !isManuallyPaused && inView && !(pauseOnHover && isHovered);
50
+ if (reverse && scroller2.scrollLeft === 0) scroller2.scrollLeft = track.scrollWidth / 2;
51
+ position.current = scroller2.scrollLeft;
48
52
  const tick = (now) => {
49
53
  const half = track.scrollWidth / 2;
50
54
  if (half > 0) {
@@ -54,7 +58,7 @@ function DriftRail({
54
58
  const delta = perSecond * (now - last) / 1e3;
55
59
  position.current += reverse ? -delta : delta;
56
60
  } else {
57
- position.current = scroller.scrollLeft;
61
+ position.current = scroller2.scrollLeft;
58
62
  }
59
63
  let wrapped = false;
60
64
  if (position.current >= half) {
@@ -64,14 +68,14 @@ function DriftRail({
64
68
  position.current += half;
65
69
  wrapped = true;
66
70
  }
67
- if (driftingNow || wrapped) scroller.scrollLeft = position.current;
71
+ if (driftingNow || wrapped) scroller2.scrollLeft = position.current;
68
72
  }
69
73
  last = now;
70
74
  frame = requestAnimationFrame(tick);
71
75
  };
72
76
  frame = requestAnimationFrame(tick);
73
77
  return () => cancelAnimationFrame(frame);
74
- }, [durationSeconds, inView, isHovered, pauseOnHover, reducedMotion, reverse]);
78
+ }, [durationSeconds, inView, isHovered, isManuallyPaused, pauseOnHover, reducedMotion, reverse]);
75
79
  const hold = () => {
76
80
  heldUntil.current = Number.POSITIVE_INFINITY;
77
81
  };
@@ -114,7 +118,7 @@ function DriftRail({
114
118
  event.stopPropagation();
115
119
  }
116
120
  };
117
- return /* @__PURE__ */ jsx(
121
+ const scroller = /* @__PURE__ */ jsx(
118
122
  "div",
119
123
  {
120
124
  ref: scrollerRef,
@@ -136,10 +140,33 @@ function DriftRail({
136
140
  ),
137
141
  children: /* @__PURE__ */ jsxs("div", { ref: trackRef, className: "flex w-max items-stretch gap-3.5", children: [
138
142
  children,
139
- /* @__PURE__ */ jsx("div", { className: "contents", "aria-hidden": "true", children })
143
+ /* @__PURE__ */ jsx("div", { className: "contents", "aria-hidden": "true", inert: true, children })
140
144
  ] })
141
145
  }
142
146
  );
147
+ if (!labels || reducedMotion) return scroller;
148
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
149
+ scroller,
150
+ /* @__PURE__ */ jsx("div", { className: "flex justify-end", children: /* @__PURE__ */ jsx(
151
+ "button",
152
+ {
153
+ type: "button",
154
+ onClick: () => setIsManuallyPaused((current) => !current),
155
+ onPointerDown: () => setPlayPauseReplayToken((n) => n + 1),
156
+ "aria-label": isManuallyPaused ? labels.play : labels.pause,
157
+ "aria-pressed": isManuallyPaused,
158
+ className: "flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none",
159
+ children: /* @__PURE__ */ jsx(
160
+ MotionPlayPause,
161
+ {
162
+ active: !isManuallyPaused,
163
+ replayToken: playPauseReplayToken,
164
+ className: "size-4"
165
+ }
166
+ )
167
+ }
168
+ ) })
169
+ ] });
143
170
  }
144
171
 
145
172
  export { DriftRail };
@@ -3,10 +3,10 @@ import * as React from 'react';
3
3
  import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const alertVariants: (props?: ({
6
- variant?: "default" | "destructive" | "success" | "warning" | null | undefined;
6
+ variant?: "default" | "destructive" | "warning" | "success" | null | undefined;
7
7
  } & class_variance_authority_types.ClassProp) | undefined) => string;
8
8
  declare const Alert: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ({
9
- variant?: "default" | "destructive" | "success" | "warning" | null | undefined;
9
+ variant?: "default" | "destructive" | "warning" | "success" | null | undefined;
10
10
  } & class_variance_authority_types.ClassProp) | undefined) => string> & React.RefAttributes<HTMLDivElement>>;
11
11
  declare const AlertTitle: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLHeadingElement> & React.RefAttributes<HTMLParagraphElement>>;
12
12
  declare const AlertDescription: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLParagraphElement> & React.RefAttributes<HTMLParagraphElement>>;
@@ -4,7 +4,7 @@ import * as React from 'react';
4
4
  import { VariantProps } from 'class-variance-authority';
5
5
 
6
6
  declare const badgeVariants: (props?: ({
7
- variant?: "default" | "secondary" | "destructive" | "outline" | "overlay" | "glass" | "success" | "warning" | "brown" | "onPrimary" | null | undefined;
7
+ variant?: "default" | "destructive" | "outline" | "secondary" | "glass" | "overlay" | "brown" | "warning" | "success" | "onPrimary" | null | undefined;
8
8
  live?: boolean | null | undefined;
9
9
  } & class_variance_authority_types.ClassProp) | undefined) => string;
10
10
  interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {
@@ -3,7 +3,7 @@ import * as React from 'react';
3
3
  import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const buttonVariants: (props?: ({
6
- variant?: "default" | "secondary" | "destructive" | "outline" | "overlay" | "glass" | "warning" | "brown" | "link" | "ghost" | "ink" | null | undefined;
6
+ variant?: "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | "glass" | "overlay" | "ink" | "brown" | "warning" | null | undefined;
7
7
  size?: "default" | "xs" | "sm" | "lg" | "pill" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | undefined;
8
8
  fullWidth?: boolean | null | undefined;
9
9
  } & class_variance_authority_types.ClassProp) | undefined) => string;
@@ -8,7 +8,7 @@ declare const Command: React.ForwardRefExoticComponent<Omit<{
8
8
  ref?: React.Ref<HTMLDivElement>;
9
9
  } & {
10
10
  asChild?: boolean;
11
- }, "key" | "asChild" | keyof React.HTMLAttributes<HTMLDivElement>> & {
11
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild"> & {
12
12
  label?: string;
13
13
  shouldFilter?: boolean;
14
14
  filter?: (value: string, search: string, keywords?: string[]) => number;
@@ -38,7 +38,7 @@ declare const CommandList: React.ForwardRefExoticComponent<Omit<{
38
38
  ref?: React.Ref<HTMLDivElement>;
39
39
  } & {
40
40
  asChild?: boolean;
41
- }, "key" | "asChild" | keyof React.HTMLAttributes<HTMLDivElement>> & {
41
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild"> & {
42
42
  label?: string;
43
43
  } & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
44
44
  declare const CommandEmpty: React.ForwardRefExoticComponent<Omit<{
@@ -47,14 +47,14 @@ declare const CommandEmpty: React.ForwardRefExoticComponent<Omit<{
47
47
  ref?: React.Ref<HTMLDivElement>;
48
48
  } & {
49
49
  asChild?: boolean;
50
- }, "key" | "asChild" | keyof React.HTMLAttributes<HTMLDivElement>> & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
50
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild"> & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
51
51
  declare const CommandLoading: React.ForwardRefExoticComponent<Omit<{
52
52
  children?: React.ReactNode;
53
53
  } & Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
54
54
  ref?: React.Ref<HTMLDivElement>;
55
55
  } & {
56
56
  asChild?: boolean;
57
- }, "key" | "asChild" | keyof React.HTMLAttributes<HTMLDivElement>> & {
57
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild"> & {
58
58
  progress?: number;
59
59
  label?: string;
60
60
  } & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
@@ -64,7 +64,7 @@ declare const CommandGroup: React.ForwardRefExoticComponent<Omit<{
64
64
  ref?: React.Ref<HTMLDivElement>;
65
65
  } & {
66
66
  asChild?: boolean;
67
- }, "key" | "asChild" | keyof React.HTMLAttributes<HTMLDivElement>>, "heading" | "value"> & {
67
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild">, "heading" | "value"> & {
68
68
  heading?: React.ReactNode;
69
69
  value?: string;
70
70
  forceMount?: boolean;
@@ -73,7 +73,7 @@ declare const CommandSeparator: React.ForwardRefExoticComponent<Omit<Pick<Pick<R
73
73
  ref?: React.Ref<HTMLDivElement>;
74
74
  } & {
75
75
  asChild?: boolean;
76
- }, "key" | "asChild" | keyof React.HTMLAttributes<HTMLDivElement>> & {
76
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild"> & {
77
77
  alwaysRender?: boolean;
78
78
  } & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
79
79
  declare const CommandItem: React.ForwardRefExoticComponent<Omit<{
@@ -82,7 +82,7 @@ declare const CommandItem: React.ForwardRefExoticComponent<Omit<{
82
82
  ref?: React.Ref<HTMLDivElement>;
83
83
  } & {
84
84
  asChild?: boolean;
85
- }, "key" | "asChild" | keyof React.HTMLAttributes<HTMLDivElement>>, "onSelect" | "disabled" | "value"> & {
85
+ }, "key" | keyof React.HTMLAttributes<HTMLDivElement> | "asChild">, "onSelect" | "disabled" | "value"> & {
86
86
  disabled?: boolean;
87
87
  onSelect?: (value: string) => void;
88
88
  value?: string;
@@ -26,7 +26,28 @@ import * as react_jsx_runtime from 'react/jsx-runtime';
26
26
  * work nobody can see. `prefers-reduced-motion` removes the drift entirely;
27
27
  * the rail stays scrollable by hand, which is more than the static strip it
28
28
  * used to collapse to.
29
+ *
30
+ * THE READER CAN STOP IT FOR GOOD. Hover and touch are holds; a rail that
31
+ * drifts for longer than five seconds also needs a control the reader can
32
+ * press (WCAG 2.2.2), and a low-vision reader who cannot follow the motion
33
+ * needs it to stay stopped. Pass `labels` and the rail renders a pause/play
34
+ * toggle under its right edge — the same latch, glyph and 44px target as
35
+ * `WeekendPanel`'s, so the hub's rails all answer to one control. It is not
36
+ * rendered under `prefers-reduced-motion`, where there is nothing to stop.
37
+ *
38
+ * THE DUPLICATE IS INERT. The second copy is what makes the loop seamless,
39
+ * but its links are the same links again: `aria-hidden` kept them out of the
40
+ * accessibility tree and left them in the tab order, so a keyboard reader
41
+ * tabbed through every item twice. `inert` removes them from focus and
42
+ * hit-testing as well.
29
43
  */
44
+ /** Accessible names for the pause/play toggle, injected so the DS owns no copy. */
45
+ type DriftRailLabels = {
46
+ /** Name announced while the rail drifts (the button pauses it). */
47
+ pause: string;
48
+ /** Name announced while the rail is paused (the button resumes it). */
49
+ play: string;
50
+ };
30
51
  type DriftRailProps = {
31
52
  children: React.ReactNode;
32
53
  /** Runs the loop right-to-left instead of left-to-right. */
@@ -35,8 +56,14 @@ type DriftRailProps = {
35
56
  durationSeconds?: number;
36
57
  /** Pauses the loop while the pointer hovers the rail. */
37
58
  pauseOnHover?: boolean;
59
+ /**
60
+ * Accessible names for the pause/play toggle. When given, the toggle
61
+ * renders whenever the rail drifts (never under `prefers-reduced-motion`);
62
+ * without it there is no control, as before.
63
+ */
64
+ labels?: DriftRailLabels;
38
65
  className?: string;
39
66
  };
40
- declare function DriftRail({ children, reverse, durationSeconds, pauseOnHover, className, }: DriftRailProps): react_jsx_runtime.JSX.Element;
67
+ declare function DriftRail({ children, reverse, durationSeconds, pauseOnHover, labels, className, }: DriftRailProps): react_jsx_runtime.JSX.Element;
41
68
 
42
- export { DriftRail, type DriftRailProps };
69
+ export { DriftRail, type DriftRailLabels, type DriftRailProps };
@@ -1,3 +1,5 @@
1
- export { DriftRail } from '../chunk-AHL45DD2.js';
1
+ export { DriftRail } from '../chunk-I4RUOAU7.js';
2
+ import '../chunk-4OF4T4AI.js';
3
+ import '../chunk-L3VEMP5A.js';
2
4
  import '../chunk-FEK5XGZW.js';
3
5
  import '../chunk-MMUBH76A.js';
@@ -1,5 +1,6 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { ReactNode } from 'react';
3
+ import { DriftRailLabels } from './drift-rail.js';
3
4
 
4
5
  /**
5
6
  * PosterTape — the closing marquee: small 4:5 posters drifting past at a
@@ -12,8 +13,18 @@ import { ReactNode } from 'react';
12
13
  type PosterTapeItem = {
13
14
  id: string;
14
15
  title: string;
15
- /** Caller-composed, e.g. "Sat 5 · 20:00". Rendered as mono, small. */
16
+ /**
17
+ * Caller-composed, e.g. "Sat 5" — or "Sat 5 · 20:00" when `time` is not
18
+ * given. Rendered as mono, small, and truncated at the tape's width.
19
+ */
16
20
  meta: string;
21
+ /**
22
+ * The time, e.g. "20:00", rendered after `meta` and never truncated. The
23
+ * tape is 132px wide and a long localised date ("sâm., 5 sept. · 17:00")
24
+ * used to clip to "17:…" — the one figure the reader came for. Split it out
25
+ * and the date is what gives way.
26
+ */
27
+ time?: string;
17
28
  posterUrl?: string;
18
29
  href?: string;
19
30
  /** Rendered instead of the poster when it's absent or failed to load. Defaults to `<EventPosterFallback />`. */
@@ -29,8 +40,10 @@ type PosterTapeProps = {
29
40
  /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
30
41
  linkComponent?: React.ComponentType<PosterTapeLinkProps>;
31
42
  durationSeconds?: number;
43
+ /** Forwarded to `<DriftRail>`: names for its pause/play toggle, which renders only when given. */
44
+ labels?: DriftRailLabels;
32
45
  className?: string;
33
46
  };
34
- declare function PosterTape({ items, linkComponent: LinkComponent, durationSeconds, className, }: PosterTapeProps): react_jsx_runtime.JSX.Element;
47
+ declare function PosterTape({ items, linkComponent: LinkComponent, durationSeconds, labels, className, }: PosterTapeProps): react_jsx_runtime.JSX.Element;
35
48
 
36
49
  export { PosterTape, type PosterTapeItem, type PosterTapeLinkProps, type PosterTapeProps };
@@ -1,4 +1,6 @@
1
- import { DriftRail } from '../chunk-AHL45DD2.js';
1
+ import { DriftRail } from '../chunk-I4RUOAU7.js';
2
+ import '../chunk-4OF4T4AI.js';
3
+ import '../chunk-L3VEMP5A.js';
2
4
  import { EventPosterFallback } from '../chunk-FDITZ2VM.js';
3
5
  import { cn } from '../chunk-FEK5XGZW.js';
4
6
  import { __objRest, __spreadValues } from '../chunk-MMUBH76A.js';
@@ -34,7 +36,13 @@ function PosterTapeItemCard({
34
36
  )
35
37
  ) : (_a = item.fallback) != null ? _a : /* @__PURE__ */ jsx(EventPosterFallback, {}) }),
36
38
  /* @__PURE__ */ jsx("div", { className: "mt-1.5 text-[11px] font-semibold leading-[1.3] sm:mt-[7px] sm:text-[11.5px]", children: item.title }),
37
- /* @__PURE__ */ jsx("div", { className: "mt-0.5 truncate font-mono text-[10px] text-muted-foreground sm:text-[10.5px]", children: item.meta })
39
+ /* @__PURE__ */ jsxs("div", { className: "mt-0.5 flex gap-1 font-mono text-[10px] text-muted-foreground sm:text-[10.5px]", children: [
40
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 truncate", children: item.meta }),
41
+ item.time && /* @__PURE__ */ jsxs("span", { className: "shrink-0", children: [
42
+ "\xB7 ",
43
+ item.time
44
+ ] })
45
+ ] })
38
46
  ] });
39
47
  const itemClasses = "block w-[132px] shrink-0";
40
48
  if (item.href) {
@@ -56,9 +64,10 @@ function PosterTape({
56
64
  items,
57
65
  linkComponent: LinkComponent = DefaultLink,
58
66
  durationSeconds = POSTER_TAPE_DEFAULT_DURATION_SECONDS,
67
+ labels,
59
68
  className
60
69
  }) {
61
- return /* @__PURE__ */ jsx(DriftRail, { durationSeconds, className, children: items.map((item) => /* @__PURE__ */ jsx(PosterTapeItemCard, { item, LinkComponent }, item.id)) });
70
+ return /* @__PURE__ */ jsx(DriftRail, { durationSeconds, labels, className, children: items.map((item) => /* @__PURE__ */ jsx(PosterTapeItemCard, { item, LinkComponent }, item.id)) });
62
71
  }
63
72
 
64
73
  export { PosterTape };
@@ -4,11 +4,11 @@ import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const spinnerVariants: (props?: ({
6
6
  size?: "default" | "xs" | "sm" | "lg" | "xl" | null | undefined;
7
- tone?: "default" | "destructive" | "inherit" | "primary" | null | undefined;
7
+ tone?: "inherit" | "default" | "destructive" | "primary" | null | undefined;
8
8
  } & class_variance_authority_types.ClassProp) | undefined) => string;
9
9
  declare const Spinner: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ({
10
10
  size?: "default" | "xs" | "sm" | "lg" | "xl" | null | undefined;
11
- tone?: "default" | "destructive" | "inherit" | "primary" | null | undefined;
11
+ tone?: "inherit" | "default" | "destructive" | "primary" | null | undefined;
12
12
  } & class_variance_authority_types.ClassProp) | undefined) => string> & {
13
13
  /** Visually-hidden label for screen readers. Defaults to "Loading". */
14
14
  label?: string;
@@ -3,11 +3,11 @@ import * as React from 'react';
3
3
  import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const tagVariants: (props?: ({
6
- variant?: "default" | "destructive" | "outline" | "success" | "warning" | "primary" | "quiet" | null | undefined;
6
+ variant?: "default" | "destructive" | "outline" | "warning" | "success" | "primary" | "quiet" | null | undefined;
7
7
  size?: "default" | "sm" | "lg" | null | undefined;
8
8
  } & class_variance_authority_types.ClassProp) | undefined) => string;
9
9
  declare const Tag: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ({
10
- variant?: "default" | "destructive" | "outline" | "success" | "warning" | "primary" | "quiet" | null | undefined;
10
+ variant?: "default" | "destructive" | "outline" | "warning" | "success" | "primary" | "quiet" | null | undefined;
11
11
  size?: "default" | "sm" | "lg" | null | undefined;
12
12
  } & class_variance_authority_types.ClassProp) | undefined) => string> & {
13
13
  onRemove?: () => void;
@@ -22,9 +22,9 @@ import { VariantProps } from 'class-variance-authority';
22
22
  * Weights are 400 / 600 / 700. 500 is not in the system.
23
23
  */
24
24
  declare const textVariants: (props?: ({
25
- variant?: "small" | "display" | "h2" | "h3" | "label" | "body" | "caption" | "h1" | "h4" | "body-lg" | "body-sm" | "overline" | null | undefined;
26
- color?: "default" | "destructive" | "inherit" | "muted" | "primary" | "primary-foreground" | null | undefined;
27
- weight?: "medium" | "bold" | "normal" | "semibold" | null | undefined;
25
+ variant?: "h2" | "h3" | "label" | "body" | "caption" | "h1" | "h4" | "small" | "display" | "body-lg" | "body-sm" | "overline" | null | undefined;
26
+ color?: "inherit" | "default" | "destructive" | "muted" | "primary" | "primary-foreground" | null | undefined;
27
+ weight?: "bold" | "medium" | "normal" | "semibold" | null | undefined;
28
28
  leading?: "none" | "normal" | "tight" | "snug" | "relaxed" | "loose" | null | undefined;
29
29
  tracking?: "normal" | "tight" | "wide" | "wider" | null | undefined;
30
30
  wrap?: "balance" | "pretty" | "nowrap" | null | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.23",
3
+ "version": "0.8.24",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -32,7 +32,7 @@ const meta: Meta<typeof DriftRail> = {
32
32
  docs: {
33
33
  description: {
34
34
  component:
35
- "An infinite horizontal marquee. Duplicates its children once so a `translateX(-50%)` loop is seamless — pass a fixed, ordered list. Pauses on hover, pauses when scrolled off-screen (`IntersectionObserver`), and stops outright under `prefers-reduced-motion`.",
35
+ "An infinite horizontal marquee the reader can take hold of. Duplicates its children once (the copy is `inert`) so the scroll loop is seamless — pass a fixed, ordered list. Pauses on hover, pauses when scrolled off-screen (`IntersectionObserver`), stops outright under `prefers-reduced-motion`, and with `labels` renders a pause/play latch (WCAG 2.2.2).",
36
36
  },
37
37
  },
38
38
  },
@@ -90,3 +90,31 @@ export const WithFeaturedItems: Story = {
90
90
  </div>
91
91
  ),
92
92
  };
93
+
94
+ /**
95
+ * With `labels`, a pause/play toggle renders under the rail's right edge —
96
+ * the same latch, glyph and 44px target as `WeekendPanel`'s (WCAG 2.2.2).
97
+ * Pressing it holds the rail still until play is pressed; hover and touch
98
+ * still pause it on their own. The control is not rendered under
99
+ * `prefers-reduced-motion`, where nothing drifts. The duplicate pass is
100
+ * `inert`, so Tab visits each item once.
101
+ */
102
+ export const WithPauseControl: Story = {
103
+ render: () => (
104
+ <div className="max-w-[420px]">
105
+ <DriftRail durationSeconds={30} labels={{ pause: "Pause the rail", play: "Play the rail" }}>
106
+ {["Warehouse Rave", "Jazz on the Rooftop", "Open Mic Night", "Street Food Market"].map(
107
+ (title) => (
108
+ <FeaturedRailItem
109
+ key={title}
110
+ title={title}
111
+ venue="Atelier Café"
112
+ note="Sat 5 · 20:00"
113
+ href={`/events/${title.toLowerCase().replace(/\s+/g, "-")}`}
114
+ />
115
+ )
116
+ )}
117
+ </DriftRail>
118
+ </div>
119
+ ),
120
+ };
@@ -0,0 +1,125 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { act, fireEvent, render, screen } from "@testing-library/react";
3
+ import { DriftRail } from "./drift-rail";
4
+
5
+ const labels = { pause: "Pause the rail", play: "Play the rail" };
6
+
7
+ /**
8
+ * jsdom lays nothing out, so the track's `scrollWidth` is 0 and the loop's
9
+ * `half > 0` guard skips every frame. The tests give the track a width and
10
+ * the scroller a writable `scrollLeft`, capture the rAF callbacks, and drive
11
+ * the frames by hand with a clock that moves in whole seconds.
12
+ */
13
+ const TRACK_WIDTH = 2000;
14
+
15
+ let frames = new Map<number, FrameRequestCallback>();
16
+ let nextFrameId = 1;
17
+ let now = 0;
18
+
19
+ function runFrame(elapsedMs: number) {
20
+ now += elapsedMs;
21
+ const pending = [...frames.values()];
22
+ frames = new Map();
23
+ act(() => {
24
+ for (const frame of pending) frame(now);
25
+ });
26
+ }
27
+
28
+ function renderRail(props: Partial<React.ComponentProps<typeof DriftRail>> = {}) {
29
+ const view = render(
30
+ <DriftRail durationSeconds={10} {...props}>
31
+ <a href="/a">A</a>
32
+ <a href="/b">B</a>
33
+ </DriftRail>
34
+ );
35
+ const track = screen.getAllByText("A")[0].parentElement as HTMLElement;
36
+ const scroller = track.parentElement as HTMLElement;
37
+ Object.defineProperty(track, "scrollWidth", { value: TRACK_WIDTH, configurable: true });
38
+ let scrollLeft = 0;
39
+ Object.defineProperty(scroller, "scrollLeft", {
40
+ get: () => scrollLeft,
41
+ set: (value: number) => {
42
+ scrollLeft = value;
43
+ },
44
+ configurable: true,
45
+ });
46
+ return { ...view, scroller, track };
47
+ }
48
+
49
+ beforeEach(() => {
50
+ frames = new Map();
51
+ nextFrameId = 1;
52
+ now = 0;
53
+ vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
54
+ const id = nextFrameId++;
55
+ frames.set(id, callback);
56
+ return id;
57
+ });
58
+ vi.spyOn(window, "cancelAnimationFrame").mockImplementation((id) => {
59
+ frames.delete(id);
60
+ });
61
+ vi.spyOn(performance, "now").mockImplementation(() => now);
62
+ });
63
+
64
+ afterEach(() => {
65
+ vi.restoreAllMocks();
66
+ });
67
+
68
+ describe("DriftRail", () => {
69
+ it("advances the scroller by width / duration per second", () => {
70
+ const { scroller } = renderRail();
71
+ runFrame(0);
72
+ runFrame(1000);
73
+ // One copy is half the track; 1000px over 10s is 100px per second.
74
+ expect(scroller.scrollLeft).toBeCloseTo(100, 5);
75
+ });
76
+
77
+ it("renders no control without labels", () => {
78
+ renderRail();
79
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
80
+ });
81
+
82
+ it("stops advancing when the reader presses pause, and stays stopped until play", () => {
83
+ const { scroller } = renderRail({ labels });
84
+ runFrame(0);
85
+ runFrame(1000);
86
+ const advanced = scroller.scrollLeft;
87
+ expect(advanced).toBeGreaterThan(0);
88
+
89
+ const toggle = screen.getByRole("button", { name: labels.pause });
90
+ expect(toggle).toHaveAttribute("aria-pressed", "false");
91
+ fireEvent.click(toggle);
92
+
93
+ expect(toggle).toHaveAttribute("aria-pressed", "true");
94
+ expect(toggle).toHaveAccessibleName(labels.play);
95
+
96
+ runFrame(0);
97
+ runFrame(1000);
98
+ runFrame(1000);
99
+ expect(scroller.scrollLeft).toBe(advanced);
100
+
101
+ // The latch survives the hover hold lifting: nothing but play resumes it.
102
+ fireEvent.mouseEnter(scroller);
103
+ fireEvent.mouseLeave(scroller);
104
+ runFrame(0);
105
+ runFrame(1000);
106
+ expect(scroller.scrollLeft).toBe(advanced);
107
+
108
+ fireEvent.click(toggle);
109
+ expect(toggle).toHaveAttribute("aria-pressed", "false");
110
+ runFrame(0);
111
+ runFrame(1000);
112
+ expect(scroller.scrollLeft).toBeGreaterThan(advanced);
113
+ });
114
+
115
+ it("makes the duplicate pass inert as well as aria-hidden", () => {
116
+ const { track } = renderRail();
117
+ const copies = screen.getAllByText("A");
118
+ expect(copies).toHaveLength(2);
119
+ const clone = copies[1].parentElement as HTMLElement;
120
+ expect(clone.parentElement).toBe(track);
121
+ expect(clone).toHaveAttribute("aria-hidden", "true");
122
+ expect(clone).toHaveAttribute("inert");
123
+ expect(copies[0].parentElement).toBe(track);
124
+ });
125
+ });
@@ -3,6 +3,7 @@
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import type { PointerEvent as ReactPointerEvent } from "react";
5
5
  import { cn } from "../lib/utils";
6
+ import { MotionPlayPause } from "./motion-icon-controls";
6
7
 
7
8
  /**
8
9
  * DriftRail — an infinite horizontal marquee for logos, tag chips or small
@@ -30,7 +31,29 @@ import { cn } from "../lib/utils";
30
31
  * work nobody can see. `prefers-reduced-motion` removes the drift entirely;
31
32
  * the rail stays scrollable by hand, which is more than the static strip it
32
33
  * used to collapse to.
34
+ *
35
+ * THE READER CAN STOP IT FOR GOOD. Hover and touch are holds; a rail that
36
+ * drifts for longer than five seconds also needs a control the reader can
37
+ * press (WCAG 2.2.2), and a low-vision reader who cannot follow the motion
38
+ * needs it to stay stopped. Pass `labels` and the rail renders a pause/play
39
+ * toggle under its right edge — the same latch, glyph and 44px target as
40
+ * `WeekendPanel`'s, so the hub's rails all answer to one control. It is not
41
+ * rendered under `prefers-reduced-motion`, where there is nothing to stop.
42
+ *
43
+ * THE DUPLICATE IS INERT. The second copy is what makes the loop seamless,
44
+ * but its links are the same links again: `aria-hidden` kept them out of the
45
+ * accessibility tree and left them in the tab order, so a keyboard reader
46
+ * tabbed through every item twice. `inert` removes them from focus and
47
+ * hit-testing as well.
33
48
  */
49
+ /** Accessible names for the pause/play toggle, injected so the DS owns no copy. */
50
+ export type DriftRailLabels = {
51
+ /** Name announced while the rail drifts (the button pauses it). */
52
+ pause: string;
53
+ /** Name announced while the rail is paused (the button resumes it). */
54
+ play: string;
55
+ };
56
+
34
57
  export type DriftRailProps = {
35
58
  children: React.ReactNode;
36
59
  /** Runs the loop right-to-left instead of left-to-right. */
@@ -39,6 +62,12 @@ export type DriftRailProps = {
39
62
  durationSeconds?: number;
40
63
  /** Pauses the loop while the pointer hovers the rail. */
41
64
  pauseOnHover?: boolean;
65
+ /**
66
+ * Accessible names for the pause/play toggle. When given, the toggle
67
+ * renders whenever the rail drifts (never under `prefers-reduced-motion`);
68
+ * without it there is no control, as before.
69
+ */
70
+ labels?: DriftRailLabels;
42
71
  className?: string;
43
72
  };
44
73
 
@@ -51,6 +80,7 @@ export function DriftRail({
51
80
  reverse = false,
52
81
  durationSeconds = DRIFT_RAIL_DEFAULT_DURATION_SECONDS,
53
82
  pauseOnHover = true,
83
+ labels,
54
84
  className,
55
85
  }: DriftRailProps) {
56
86
  const scrollerRef = useRef<HTMLDivElement>(null);
@@ -58,6 +88,9 @@ export function DriftRail({
58
88
  const [isHovered, setIsHovered] = useState(false);
59
89
  const [inView, setInView] = useState(true);
60
90
  const [reducedMotion, setReducedMotion] = useState(false);
91
+ // A latch, not a hold: it only clears when the reader presses play.
92
+ const [isManuallyPaused, setIsManuallyPaused] = useState(false);
93
+ const [playPauseReplayToken, setPlayPauseReplayToken] = useState(0);
61
94
  // `heldUntil` is a timestamp rather than a boolean so a native touch scroll
62
95
  // that keeps going after the finger lifts is not fought by the drift.
63
96
  const heldUntil = useRef(0);
@@ -98,7 +131,8 @@ export function DriftRail({
98
131
 
99
132
  let frame = 0;
100
133
  let last = performance.now();
101
- const drifting = !reducedMotion && inView && !(pauseOnHover && isHovered);
134
+ const drifting =
135
+ !reducedMotion && !isManuallyPaused && inView && !(pauseOnHover && isHovered);
102
136
 
103
137
  // Start the reverse loop inside the second copy so it has room to travel.
104
138
  if (reverse && scroller.scrollLeft === 0) scroller.scrollLeft = track.scrollWidth / 2;
@@ -132,7 +166,7 @@ export function DriftRail({
132
166
  };
133
167
  frame = requestAnimationFrame(tick);
134
168
  return () => cancelAnimationFrame(frame);
135
- }, [durationSeconds, inView, isHovered, pauseOnHover, reducedMotion, reverse]);
169
+ }, [durationSeconds, inView, isHovered, isManuallyPaused, pauseOnHover, reducedMotion, reverse]);
136
170
 
137
171
  const hold = () => {
138
172
  heldUntil.current = Number.POSITIVE_INFINITY;
@@ -178,7 +212,7 @@ export function DriftRail({
178
212
  }
179
213
  };
180
214
 
181
- return (
215
+ const scroller = (
182
216
  <div
183
217
  ref={scrollerRef}
184
218
  onMouseEnter={() => setIsHovered(true)}
@@ -202,10 +236,43 @@ export function DriftRail({
202
236
  >
203
237
  <div ref={trackRef} className="flex w-max items-stretch gap-3.5">
204
238
  {children}
205
- <div className="contents" aria-hidden="true">
239
+ {/* `inert` as well as `aria-hidden`: the copy's links must leave the
240
+ tab order too, or a keyboard reader tabs every item twice. */}
241
+ <div className="contents" aria-hidden="true" inert>
206
242
  {children}
207
243
  </div>
208
244
  </div>
209
245
  </div>
210
246
  );
247
+
248
+ // The toggle exists to stop a drift; under reduced motion there is none.
249
+ // A sibling row, not an overlay: a button floated over the rail's corner
250
+ // would sit on an item and intercept the grab that starts there.
251
+ if (!labels || reducedMotion) return scroller;
252
+
253
+ return (
254
+ <>
255
+ {scroller}
256
+ <div className="flex justify-end">
257
+ {/* `MotionPlayPause`, as in `WeekendPanel`: the triangle folding into
258
+ two bars IS the state change. `active` is the bars, so it is
259
+ `!isManuallyPaused`; `replayToken` bumps on pointer down so the
260
+ gesture answers the touch. */}
261
+ <button
262
+ type="button"
263
+ onClick={() => setIsManuallyPaused((current) => !current)}
264
+ onPointerDown={() => setPlayPauseReplayToken((n) => n + 1)}
265
+ aria-label={isManuallyPaused ? labels.play : labels.pause}
266
+ aria-pressed={isManuallyPaused}
267
+ className="flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none"
268
+ >
269
+ <MotionPlayPause
270
+ active={!isManuallyPaused}
271
+ replayToken={playPauseReplayToken}
272
+ className="size-4"
273
+ />
274
+ </button>
275
+ </div>
276
+ </>
277
+ );
211
278
  }
@@ -68,3 +68,32 @@ export const LongTitles: Story = {
68
68
  ],
69
69
  },
70
70
  };
71
+
72
+ /**
73
+ * A long localised date with the time split out into `time`. The date
74
+ * truncates ("sâm., 5 sept." becomes "sâm., 5 se…" if it must); the time is
75
+ * `shrink-0` outside the truncating span and is always whole. Before this the
76
+ * whole line was one truncating string and clipped to "17:…".
77
+ */
78
+ export const LocalisedDateAndTime: Story = {
79
+ args: {
80
+ items: [
81
+ { id: "1", title: "Warehouse Rave", meta: "sâmbătă, 5 septembrie", time: "17:00" },
82
+ { id: "2", title: "Jazz on the Rooftop", meta: "vineri, 4 septembrie", time: "19:30" },
83
+ { id: "3", title: "Open Mic Night", meta: "joi, 3 sept.", time: "21:00" },
84
+ { id: "4", title: "Street Food Market", meta: "duminică, 6 septembrie", time: "12:00" },
85
+ ],
86
+ },
87
+ };
88
+
89
+ /**
90
+ * With `labels`, a pause/play toggle renders under the tape's right edge —
91
+ * `<DriftRail>`'s control, forwarded. Pressing it latches the tape still
92
+ * until play is pressed again. It does not render under
93
+ * `prefers-reduced-motion`, where nothing drifts.
94
+ */
95
+ export const WithPauseControl: Story = {
96
+ args: {
97
+ labels: { pause: "Pause the tape", play: "Play the tape" },
98
+ },
99
+ };
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { PosterTape } from "./poster-tape";
4
+
5
+ describe("PosterTape", () => {
6
+ it("renders meta alone when no time is given", () => {
7
+ render(<PosterTape items={[{ id: "1", title: "Warehouse Rave", meta: "SAT 20:00" }]} />);
8
+ const [meta] = screen.getAllByText("SAT 20:00");
9
+ expect(meta).toHaveClass("truncate");
10
+ expect(meta.parentElement?.childElementCount).toBe(1);
11
+ });
12
+
13
+ it("renders the time outside the truncating span so it never clips", () => {
14
+ render(
15
+ <PosterTape
16
+ items={[{ id: "1", title: "Warehouse Rave", meta: "sâm., 5 sept.", time: "17:00" }]}
17
+ />
18
+ );
19
+ const [meta] = screen.getAllByText("sâm., 5 sept.");
20
+ const [time] = screen.getAllByText("· 17:00");
21
+
22
+ expect(meta).toHaveClass("truncate");
23
+ expect(meta.contains(time)).toBe(false);
24
+ expect(time).toHaveClass("shrink-0");
25
+ expect(time).not.toHaveClass("truncate");
26
+ expect(time.parentElement).toBe(meta.parentElement);
27
+ expect(meta.parentElement).not.toHaveClass("truncate");
28
+ });
29
+
30
+ it("forwards labels to the rail's pause control", () => {
31
+ render(
32
+ <PosterTape
33
+ items={[{ id: "1", title: "Warehouse Rave", meta: "SAT 20:00" }]}
34
+ labels={{ pause: "Pause the tape", play: "Play the tape" }}
35
+ />
36
+ );
37
+ expect(screen.getByRole("button", { name: "Pause the tape" })).toHaveAttribute(
38
+ "aria-pressed",
39
+ "false"
40
+ );
41
+ });
42
+ });
@@ -3,7 +3,7 @@
3
3
  import { useState } from "react";
4
4
  import type { ReactNode } from "react";
5
5
  import { cn } from "../lib/utils";
6
- import { DriftRail } from "./drift-rail";
6
+ import { DriftRail, type DriftRailLabels } from "./drift-rail";
7
7
  import { EventPosterFallback } from "./event-poster-fallback";
8
8
 
9
9
  /**
@@ -17,8 +17,18 @@ import { EventPosterFallback } from "./event-poster-fallback";
17
17
  export type PosterTapeItem = {
18
18
  id: string;
19
19
  title: string;
20
- /** Caller-composed, e.g. "Sat 5 · 20:00". Rendered as mono, small. */
20
+ /**
21
+ * Caller-composed, e.g. "Sat 5" — or "Sat 5 · 20:00" when `time` is not
22
+ * given. Rendered as mono, small, and truncated at the tape's width.
23
+ */
21
24
  meta: string;
25
+ /**
26
+ * The time, e.g. "20:00", rendered after `meta` and never truncated. The
27
+ * tape is 132px wide and a long localised date ("sâm., 5 sept. · 17:00")
28
+ * used to clip to "17:…" — the one figure the reader came for. Split it out
29
+ * and the date is what gives way.
30
+ */
31
+ time?: string;
22
32
  posterUrl?: string;
23
33
  href?: string;
24
34
  /** Rendered instead of the poster when it's absent or failed to load. Defaults to `<EventPosterFallback />`. */
@@ -40,6 +50,8 @@ export type PosterTapeProps = {
40
50
  /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
41
51
  linkComponent?: React.ComponentType<PosterTapeLinkProps>;
42
52
  durationSeconds?: number;
53
+ /** Forwarded to `<DriftRail>`: names for its pause/play toggle, which renders only when given. */
54
+ labels?: DriftRailLabels;
43
55
  className?: string;
44
56
  };
45
57
 
@@ -81,8 +93,11 @@ function PosterTapeItemCard({
81
93
  <div className="mt-1.5 text-[11px] font-semibold leading-[1.3] sm:mt-[7px] sm:text-[11.5px]">
82
94
  {item.title}
83
95
  </div>
84
- <div className="mt-0.5 truncate font-mono text-[10px] text-muted-foreground sm:text-[10.5px]">
85
- {item.meta}
96
+ {/* The date truncates; the time is `shrink-0` outside the truncating
97
+ span, so it is always whole. */}
98
+ <div className="mt-0.5 flex gap-1 font-mono text-[10px] text-muted-foreground sm:text-[10.5px]">
99
+ <span className="min-w-0 truncate">{item.meta}</span>
100
+ {item.time && <span className="shrink-0">· {item.time}</span>}
86
101
  </div>
87
102
  </>
88
103
  );
@@ -110,10 +125,11 @@ export function PosterTape({
110
125
  items,
111
126
  linkComponent: LinkComponent = DefaultLink,
112
127
  durationSeconds = POSTER_TAPE_DEFAULT_DURATION_SECONDS,
128
+ labels,
113
129
  className,
114
130
  }: PosterTapeProps) {
115
131
  return (
116
- <DriftRail durationSeconds={durationSeconds} className={className}>
132
+ <DriftRail durationSeconds={durationSeconds} labels={labels} className={className}>
117
133
  {items.map((item) => (
118
134
  <PosterTapeItemCard key={item.id} item={item} LinkComponent={LinkComponent} />
119
135
  ))}