@spear-ai/spectral 1.19.1 → 1.20.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.
@@ -1,10 +1,17 @@
1
1
  'use client';
2
- import { CSSProperties, ReactNode, Ref } from "react";
2
+ import { CSSProperties, ReactNode, Ref, RefObject } from "react";
3
3
  import * as _$react_jsx_runtime0 from "react/jsx-runtime";
4
4
 
5
5
  //#region src/components/DirectionalColorWheel/DirectionalColorWheelDisclosure.d.ts
6
6
  type DisclosureAlign = 'start' | 'center' | 'end';
7
7
  type DisclosureSide = 'top' | 'right' | 'bottom' | 'left';
8
+ /** Pixel offset of the draggable disclosure from where it is mounted (default `{ x: 0, y: 0 }`). */
9
+ interface DirectionalColorWheelDisclosurePosition {
10
+ /** Horizontal offset in px (positive moves right). */
11
+ x: number;
12
+ /** Vertical offset in px (positive moves down). */
13
+ y: number;
14
+ }
8
15
  interface DirectionalColorWheelDisclosureProps {
9
16
  /** Accessible name for the collapsed trigger button (`aria-expanded` conveys the open state). */
10
17
  accessibleName?: string;
@@ -22,9 +29,23 @@ interface DirectionalColorWheelDisclosureProps {
22
29
  /** Min distance (px) from the viewport edge before the panel flips/shifts. */
23
30
  collisionPadding?: number;
24
31
  dataTestId?: string;
32
+ /**
33
+ * Element the drag is clamped to so the glyph can't be parked off the feed. Pass a ref to the
34
+ * container the disclosure is mounted over (e.g. the plot surface). `undefined`/`null` = no clamp
35
+ * (free drag). Only used when `draggable`.
36
+ */
37
+ dragBoundsRef?: RefObject<HTMLElement | null> | null;
25
38
  /** Uncontrolled initial expanded state. */
26
39
  defaultExpanded?: boolean;
40
+ /** Uncontrolled initial position offset (px) from the mount point. Defaults to the corner it is mounted at. */
41
+ defaultPosition?: DirectionalColorWheelDisclosurePosition;
27
42
  disabled?: boolean;
43
+ /**
44
+ * When `true`, the operator can drag the collapsed glyph to reposition the whole widget (the
45
+ * expanded panel reflows with it in real time). A tap still toggles; only a drag past a small
46
+ * slop repositions. Defaults to `false` (fixed at its mount point).
47
+ */
48
+ draggable?: boolean;
28
49
  /**
29
50
  * When `false`, the panel stays open during outside interaction and Esc — it toggles only via the
30
51
  * trigger (or a controlled `expanded`). Use this for a feed-mounted wheel so clicking/scrubbing the
@@ -38,6 +59,13 @@ interface DirectionalColorWheelDisclosureProps {
38
59
  expanded?: boolean | null;
39
60
  /** Called whenever the disclosure expands or collapses (click, Esc, or outside dismiss). */
40
61
  onExpandedChange?: (expanded: boolean) => void;
62
+ /** Called as the glyph is dragged with the next position offset (px). Pair with `position` to persist it. */
63
+ onPositionChange?: (position: DirectionalColorWheelDisclosurePosition) => void;
64
+ /**
65
+ * Controlled position offset (px) from the mount point. `undefined`/`null` leaves it uncontrolled,
66
+ * so Horizon can gate the feature with a single nullable value (PAT-028). Requires `draggable` to move.
67
+ */
68
+ position?: DirectionalColorWheelDisclosurePosition | null;
41
69
  ref?: Ref<HTMLButtonElement>;
42
70
  /** Which side of the trigger the panel grows toward. Defaults to `top` (icon at a bottom corner). */
43
71
  side?: DisclosureSide;
@@ -63,10 +91,15 @@ declare function DirectionalColorWheelDisclosure({
63
91
  collisionPadding,
64
92
  dataTestId,
65
93
  defaultExpanded,
94
+ defaultPosition,
66
95
  disabled,
67
96
  dismissOnInteractOutside,
97
+ draggable,
98
+ dragBoundsRef,
68
99
  expanded,
69
100
  onExpandedChange,
101
+ onPositionChange,
102
+ position,
70
103
  ref,
71
104
  side,
72
105
  sideOffset,
@@ -78,5 +111,5 @@ declare namespace DirectionalColorWheelDisclosure {
78
111
  var displayName: string;
79
112
  }
80
113
  //#endregion
81
- export { DirectionalColorWheelDisclosure, DirectionalColorWheelDisclosureProps };
114
+ export { DirectionalColorWheelDisclosure, DirectionalColorWheelDisclosurePosition, DirectionalColorWheelDisclosureProps };
82
115
  //# sourceMappingURL=DirectionalColorWheelDisclosure.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"DirectionalColorWheelDisclosure.d.ts","names":[],"sources":["../../src/components/DirectionalColorWheel/DirectionalColorWheelDisclosure.tsx"],"mappings":";;;;;KAMK,eAAA;AAAA,KACA,cAAA;AAAA,UAEY,oCAAA;EAHZ;EAKH,cAAA;;EAEA,KAAA,GAAQ,eAAA;EAPU;EASlB,QAAA,EAAU,SAAA;EARO;EAUjB,SAAA;EAViB;;AAEnB;;EAaE,aAAA,GAAgB,SAAA;EATR;EAWR,gBAAA;EACA,UAAA;EAiBU;EAfV,eAAA;EACA,QAAA;EAwBQ;;;;;EAlBR,wBAAA;EAnBA;;;;EAwBA,QAAA;EAfA;EAiBA,gBAAA,IAAoB,QAAA;EACpB,GAAA,GAAM,GAAA,CAAI,iBAAA;EAdV;EAgBA,IAAA,GAAO,cAAA;EALP;EAOA,UAAA;EALoB;;;;;EAWpB,KAAA,GAAQ,aAAA;EANR;EAQA,gBAAA;EAFQ;EAIR,KAAA;AAAA;AAAA;EAcA,cAAA;EACA,KAAA;EACA,QAAA;EACA,SAAA;EACA,aAAA;EACA,gBAAA;EACA,UAAA;EACA,eAAA;EACA,QAAA;EACA,wBAAA;EACA,QAAA;EACA,gBAAA;EACA,GAAA;EACA,IAAA;EACA,UAAA;EACA,KAAA;EACA,gBAAA;EACA;AAAA,GACC,oCAAA,GAAoC,oBAAA,CAAA,GAAA,CAAA,OAAA;AAAA"}
1
+ {"version":3,"file":"DirectionalColorWheelDisclosure.d.ts","names":[],"sources":["../../src/components/DirectionalColorWheel/DirectionalColorWheelDisclosure.tsx"],"mappings":";;;;;KAOK,eAAA;AAAA,KACA,cAAA;;UAGY,uCAAA;EAJG;EAMlB,CAAA;EANkB;EAQlB,CAAA;AAAA;AAAA,UAOe,oCAAA;;EAEf,cAAA;EAhBiB;EAkBjB,KAAA,GAAQ,eAAA;EAf8C;EAiBtD,QAAA,EAAU,SAAA;EAfV;EAiBA,SAAA;EARe;;;;EAaf,aAAA,GAAgB,SAAA;EAAA;EAEhB,gBAAA;EACA,UAAA;EAUkB;;;;;EAJlB,aAAA,GAAgB,SAAA,CAAU,WAAA;EA0ClB;EAxCR,eAAA;EAwCqB;EAtCrB,eAAA,GAAkB,uCAAA;EAClB,QAAA;EAvBQ;;;;;EA6BR,SAAA;EAlBA;;;;;EAwBA,wBAAA;EAbA;;;;EAkBA,QAAA;EAAA;EAEA,gBAAA,IAAoB,QAAA;EAAA;EAEpB,gBAAA,IAAoB,QAAA,EAAU,uCAAA;EAAA;;;;EAK9B,QAAA,GAAW,uCAAA;EACX,GAAA,GAAM,GAAA,CAAI,iBAAA;EAAA;EAEV,IAAA,GAAO,cAAA;EAAA;EAEP,UAAA;EAMA;;;;;EAAA,KAAA,GAAQ,aAAA;;EAER,gBAAA;;EAEA,KAAA;AAAA;AAAA;EAoBA,cAAA;EACA,KAAA;EACA,QAAA;EACA,SAAA;EACA,aAAA;EACA,gBAAA;EACA,UAAA;EACA,eAAA;EACA,eAAA;EACA,QAAA;EACA,wBAAA;EACA,SAAA;EACA,aAAA;EACA,QAAA;EACA,gBAAA;EACA,gBAAA;EACA,QAAA;EACA,GAAA;EACA,IAAA;EACA,UAAA;EACA,KAAA;EACA,gBAAA;EACA;AAAA,GACC,oCAAA,GAAoC,oBAAA,CAAA,GAAA,CAAA,OAAA;AAAA"}
@@ -1,12 +1,18 @@
1
1
  'use client';
2
2
  import { cn } from "../utils/twUtils.js";
3
+ import { useUncontrolledState } from "../hooks/useUncontrolledState.js";
3
4
  import { Card } from "../DataCard/Card.js";
4
5
  import { Popover, PopoverContent, PopoverTrigger } from "../Popover.js";
5
6
  import { DirectionalColorWheelGlyph } from "./DirectionalColorWheelGlyph.js";
6
- import { useRef } from "react";
7
+ import { useCallback, useRef, useState } from "react";
7
8
  import { jsx, jsxs } from "react/jsx-runtime";
8
9
 
9
10
  //#region src/components/DirectionalColorWheel/DirectionalColorWheelDisclosure.tsx
11
+ const DRAG_SLOP_PX = 4;
12
+ const ORIGIN = {
13
+ x: 0,
14
+ y: 0
15
+ };
10
16
  /**
11
17
  * Collapse/expand affordance for the {@link DirectionalColorWheel}, built on the Spectral
12
18
  * {@link Popover} primitive so it inherits corner anchoring, click-outside/Esc dismissal, focus
@@ -16,10 +22,83 @@ import { jsx, jsxs } from "react/jsx-runtime";
16
22
  * panel, growing from the icon with a `transform-origin`-anchored zoom+fade that is automatically
17
23
  * skipped under `prefers-reduced-motion`. `className` extends the Card panel's classes and `style`
18
24
  * applies inline overrides to it (background, border, shadow, …).
25
+ *
26
+ * Pass `draggable` to let the operator drag the glyph to reposition the whole widget on a feed; the
27
+ * expanded panel reflows with it in real time, and `dragBoundsRef` clamps it inside its container so
28
+ * it can't be parked off the feed. Position is controlled+uncontrolled via
29
+ * `position` / `defaultPosition` / `onPositionChange` (a nullable px offset from the mount point,
30
+ * PAT-028) so a consumer can persist where the operator parked it.
19
31
  */
20
- const DirectionalColorWheelDisclosure = ({ accessibleName = "Directional color legend", align = "start", children, className, collapsedIcon, collisionPadding = 8, dataTestId = "spectral-directional-color-wheel-disclosure", defaultExpanded, disabled = false, dismissOnInteractOutside = true, expanded, onExpandedChange, ref, side = "top", sideOffset = 8, style, triggerClassName, width = "fit-content" }) => {
32
+ const DirectionalColorWheelDisclosure = ({ accessibleName = "Directional color legend", align = "start", children, className, collapsedIcon, collisionPadding = 8, dataTestId = "spectral-directional-color-wheel-disclosure", defaultExpanded, defaultPosition, disabled = false, dismissOnInteractOutside = true, draggable = false, dragBoundsRef, expanded, onExpandedChange, onPositionChange, position, ref, side = "top", sideOffset = 8, style, triggerClassName, width = "fit-content" }) => {
21
33
  const panelRef = useRef(null);
22
34
  const panelWidth = typeof width === "number" ? `${width}px` : width;
35
+ const [positionValue, setPositionValue] = useUncontrolledState({
36
+ defaultValue: defaultPosition ?? ORIGIN,
37
+ onChange: onPositionChange,
38
+ value: position ?? void 0
39
+ });
40
+ const [dragging, setDragging] = useState(false);
41
+ const dragRef = useRef(null);
42
+ const suppressClickRef = useRef(false);
43
+ const handleTriggerPointerDown = useCallback((event) => {
44
+ suppressClickRef.current = false;
45
+ if (!draggable || disabled) return;
46
+ try {
47
+ event.currentTarget.setPointerCapture(event.pointerId);
48
+ } catch {}
49
+ const triggerRect = event.currentTarget.getBoundingClientRect();
50
+ dragRef.current = {
51
+ baseX: positionValue.x,
52
+ baseY: positionValue.y,
53
+ bounds: dragBoundsRef?.current?.getBoundingClientRect() ?? null,
54
+ height: triggerRect.height,
55
+ homeLeft: triggerRect.left - positionValue.x,
56
+ homeTop: triggerRect.top - positionValue.y,
57
+ moved: false,
58
+ pointerId: event.pointerId,
59
+ startX: event.clientX,
60
+ startY: event.clientY,
61
+ width: triggerRect.width
62
+ };
63
+ }, [
64
+ disabled,
65
+ draggable,
66
+ dragBoundsRef,
67
+ positionValue.x,
68
+ positionValue.y
69
+ ]);
70
+ const handleTriggerPointerMove = useCallback((event) => {
71
+ const drag = dragRef.current;
72
+ if (drag === null || drag.pointerId !== event.pointerId) return;
73
+ const deltaX = event.clientX - drag.startX;
74
+ const deltaY = event.clientY - drag.startY;
75
+ if (!drag.moved) {
76
+ if (Math.hypot(deltaX, deltaY) <= DRAG_SLOP_PX) return;
77
+ drag.moved = true;
78
+ suppressClickRef.current = true;
79
+ setDragging(true);
80
+ }
81
+ let nextX = drag.baseX + deltaX;
82
+ let nextY = drag.baseY + deltaY;
83
+ if (drag.bounds !== null) {
84
+ nextX = Math.min(Math.max(nextX, drag.bounds.left - drag.homeLeft), drag.bounds.right - drag.homeLeft - drag.width);
85
+ nextY = Math.min(Math.max(nextY, drag.bounds.top - drag.homeTop), drag.bounds.bottom - drag.homeTop - drag.height);
86
+ }
87
+ setPositionValue({
88
+ x: nextX,
89
+ y: nextY
90
+ });
91
+ }, [setPositionValue]);
92
+ const endDrag = useCallback(() => {
93
+ dragRef.current = null;
94
+ setDragging(false);
95
+ }, []);
96
+ const handleTriggerClick = useCallback((event) => {
97
+ if (suppressClickRef.current) {
98
+ suppressClickRef.current = false;
99
+ event.preventDefault();
100
+ }
101
+ }, []);
23
102
  return /* @__PURE__ */ jsxs(Popover, {
24
103
  defaultOpen: defaultExpanded,
25
104
  onOpenChange: onExpandedChange,
@@ -28,11 +107,20 @@ const DirectionalColorWheelDisclosure = ({ accessibleName = "Directional color l
28
107
  asChild: true,
29
108
  children: /* @__PURE__ */ jsx("button", {
30
109
  "aria-label": accessibleName,
31
- className: cn("inline-flex items-center justify-center rounded-full text-text-primary outline-none hover:opacity-80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:pointer-events-none disabled:opacity-50 motion-safe:transition-opacity motion-safe:duration-150", triggerClassName),
110
+ className: cn("inline-flex items-center justify-center rounded-full text-text-primary outline-none hover:opacity-80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:pointer-events-none disabled:opacity-50 motion-safe:transition-opacity motion-safe:duration-150", draggable && "touch-none", draggable && (dragging ? "cursor-grabbing" : "cursor-grab"), triggerClassName),
32
111
  "data-slot": "directional-color-wheel-disclosure-trigger",
33
112
  "data-testid": `${dataTestId}-trigger`,
34
113
  disabled,
114
+ onClick: handleTriggerClick,
115
+ onLostPointerCapture: endDrag,
116
+ onPointerDown: handleTriggerPointerDown,
117
+ onPointerMove: handleTriggerPointerMove,
118
+ onPointerUp: endDrag,
35
119
  ref,
120
+ style: draggable ? {
121
+ transform: `translate(${positionValue.x}px, ${positionValue.y}px)`,
122
+ willChange: dragging ? "transform" : void 0
123
+ } : void 0,
36
124
  type: "button",
37
125
  children: collapsedIcon ?? /* @__PURE__ */ jsx(DirectionalColorWheelGlyph, {})
38
126
  })
@@ -50,6 +138,7 @@ const DirectionalColorWheelDisclosure = ({ accessibleName = "Directional color l
50
138
  },
51
139
  side,
52
140
  sideOffset,
141
+ updatePositionStrategy: dragging ? "always" : "optimized",
53
142
  width: "fit-content",
54
143
  children: /* @__PURE__ */ jsx(Card, {
55
144
  className: cn("outline-none", className),
@@ -1 +1 @@
1
- {"version":3,"file":"DirectionalColorWheelDisclosure.js","names":[],"sources":["../../src/components/DirectionalColorWheel/DirectionalColorWheelDisclosure.tsx"],"sourcesContent":["import { Card } from '@components/DataCard/Card'\nimport { Popover, PopoverContent, PopoverTrigger } from '@components/Popover/Popover'\nimport { cn } from '@utils/twUtils'\nimport { useRef, type CSSProperties, type ReactNode, type Ref } from 'react'\nimport { DirectionalColorWheelGlyph } from './DirectionalColorWheelGlyph'\n\ntype DisclosureAlign = 'start' | 'center' | 'end'\ntype DisclosureSide = 'top' | 'right' | 'bottom' | 'left'\n\nexport interface DirectionalColorWheelDisclosureProps {\n /** Accessible name for the collapsed trigger button (`aria-expanded` conveys the open state). */\n accessibleName?: string\n /** How the panel aligns to the trigger. Defaults to `start` for a corner-anchored feed widget. */\n align?: DisclosureAlign\n /** The expandable content: the bare wheel, or the wheel framed by the GRAMS controls. */\n children: ReactNode\n /** Layout-only class extension for the expanded panel. */\n className?: string\n /**\n * Override the collapsed affordance. Defaults to a {@link DirectionalColorWheelGlyph} mini\n * compass; pass a palette-matched glyph (or any node) to mirror the expanded wheel's colours.\n */\n collapsedIcon?: ReactNode\n /** Min distance (px) from the viewport edge before the panel flips/shifts. */\n collisionPadding?: number\n dataTestId?: string\n /** Uncontrolled initial expanded state. */\n defaultExpanded?: boolean\n disabled?: boolean\n /**\n * When `false`, the panel stays open during outside interaction and Esc — it toggles only via the\n * trigger (or a controlled `expanded`). Use this for a feed-mounted wheel so clicking/scrubbing the\n * feed underneath doesn't dismiss the legend. Defaults to `true` (standard popover dismissal).\n */\n dismissOnInteractOutside?: boolean\n /**\n * Controlled expanded state. `undefined`/`null` leaves the disclosure uncontrolled, so Horizon\n * can gate the feature with a single nullable value (PAT-028).\n */\n expanded?: boolean | null\n /** Called whenever the disclosure expands or collapses (click, Esc, or outside dismiss). */\n onExpandedChange?: (expanded: boolean) => void\n ref?: Ref<HTMLButtonElement>\n /** Which side of the trigger the panel grows toward. Defaults to `top` (icon at a bottom corner). */\n side?: DisclosureSide\n /** Gap (px) between the trigger and the panel. */\n sideOffset?: number\n /**\n * Inline styles merged onto the expanded panel (the {@link Card}). Use this for visual overrides\n * — background, border, shadow — that the `card-effects` base class doesn't expose to `className`\n * utilities. The default panel width is applied first, so a `width` set here wins over the `width` prop.\n */\n style?: CSSProperties\n /** Layout-only class extension for the collapsed trigger button. */\n triggerClassName?: string\n /** Panel width: a pixel number or a CSS width string. Defaults to `'fit-content'` (hugs content). */\n width?: number | string\n}\n\n/**\n * Collapse/expand affordance for the {@link DirectionalColorWheel}, built on the Spectral\n * {@link Popover} primitive so it inherits corner anchoring, click-outside/Esc dismissal, focus\n * management, and `aria-expanded`/`aria-controls` for free. Collapsed it is a small compass glyph\n * (a real `<button>`); expanded it reveals whatever `children` it wraps — the bare wheel, or the\n * wheel plus the GRAMS Color Angle / Threshold / Sectors controls — inside a Spectral {@link Card}\n * panel, growing from the icon with a `transform-origin`-anchored zoom+fade that is automatically\n * skipped under `prefers-reduced-motion`. `className` extends the Card panel's classes and `style`\n * applies inline overrides to it (background, border, shadow, …).\n */\nexport const DirectionalColorWheelDisclosure = ({\n accessibleName = 'Directional color legend',\n align = 'start',\n children,\n className,\n collapsedIcon,\n collisionPadding = 8,\n dataTestId = 'spectral-directional-color-wheel-disclosure',\n defaultExpanded,\n disabled = false,\n dismissOnInteractOutside = true,\n expanded,\n onExpandedChange,\n ref,\n side = 'top',\n sideOffset = 8,\n style,\n triggerClassName,\n width = 'fit-content',\n}: DirectionalColorWheelDisclosureProps) => {\n const panelRef = useRef<HTMLDivElement>(null)\n // `width` sizes the Card panel; the popover hugs it (fit-content) so there is no empty gap.\n const panelWidth = typeof width === 'number' ? `${width}px` : width\n\n return (\n <Popover\n defaultOpen={defaultExpanded}\n onOpenChange={onExpandedChange}\n open={expanded ?? undefined}\n >\n <PopoverTrigger asChild>\n <button\n aria-label={accessibleName}\n className={cn(\n 'inline-flex items-center justify-center rounded-full text-text-primary outline-none hover:opacity-80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:pointer-events-none disabled:opacity-50 motion-safe:transition-opacity motion-safe:duration-150',\n triggerClassName,\n )}\n data-slot='directional-color-wheel-disclosure-trigger'\n data-testid={`${dataTestId}-trigger`}\n disabled={disabled}\n ref={ref}\n type='button'\n >\n {collapsedIcon ?? <DirectionalColorWheelGlyph />}\n </button>\n </PopoverTrigger>\n <PopoverContent\n align={align}\n // Chrome-less positioning/animation layer; the visible panel is the Spectral Card below.\n className='p-0 flex items-center justify-center overflow-visible border-none bg-transparent shadow-none'\n collisionPadding={collisionPadding}\n data-slot='directional-color-wheel-disclosure-content'\n data-testid={`${dataTestId}-content`}\n // When dismissal is off, swallow Radix's outside-pointer/Esc dismiss so the panel stays open\n // while the user interacts with whatever sits under it (e.g. the feed); the trigger still toggles.\n onEscapeKeyDown={dismissOnInteractOutside ? undefined : (event) => event.preventDefault()}\n onInteractOutside={dismissOnInteractOutside ? undefined : (event) => event.preventDefault()}\n onOpenAutoFocus={(event) => {\n // Focus the panel, not the first sector wedge (which would paint an accent ring on a sector).\n event.preventDefault()\n panelRef.current?.focus()\n }}\n side={side}\n sideOffset={sideOffset}\n width='fit-content'\n >\n <Card\n className={cn('outline-none', className)}\n data-slot='directional-color-wheel-disclosure-panel'\n ref={panelRef}\n // The default width is applied first so a caller-supplied `style.width` (or any other\n // visual override the `card-effects` base class locks down) takes precedence.\n style={{ width: panelWidth, ...style }}\n tabIndex={-1}\n >\n {children}\n </Card>\n </PopoverContent>\n </Popover>\n )\n}\n\nDirectionalColorWheelDisclosure.displayName = 'DirectionalColorWheelDisclosure'\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAqEA,MAAa,mCAAmC,EAC9C,iBAAiB,4BACjB,QAAQ,SACR,UACA,WACA,eACA,mBAAmB,GACnB,aAAa,+CACb,iBACA,WAAW,OACX,2BAA2B,MAC3B,UACA,kBACA,KACA,OAAO,OACP,aAAa,GACb,OACA,kBACA,QAAQ,oBACkC;CAC1C,MAAM,WAAW,OAAuB,KAAK;CAE7C,MAAM,aAAa,OAAO,UAAU,WAAW,GAAG,MAAM,MAAM;AAE9D,QACE,qBAAC,SAAD;EACE,aAAa;EACb,cAAc;EACd,MAAM,YAAY;YAHpB,CAKE,oBAAC,gBAAD;GAAgB;aACd,oBAAC,UAAD;IACE,cAAY;IACZ,WAAW,GACT,qSACA,iBACD;IACD,aAAU;IACV,eAAa,GAAG,WAAW;IACjB;IACL;IACL,MAAK;cAEJ,iBAAiB,oBAAC,4BAAD,EAA8B;IACzC;GACM,GACjB,oBAAC,gBAAD;GACS;GAEP,WAAU;GACQ;GAClB,aAAU;GACV,eAAa,GAAG,WAAW;GAG3B,iBAAiB,2BAA2B,UAAa,UAAU,MAAM,gBAAgB;GACzF,mBAAmB,2BAA2B,UAAa,UAAU,MAAM,gBAAgB;GAC3F,kBAAkB,UAAU;AAE1B,UAAM,gBAAgB;AACtB,aAAS,SAAS,OAAO;;GAErB;GACM;GACZ,OAAM;aAEN,oBAAC,MAAD;IACE,WAAW,GAAG,gBAAgB,UAAU;IACxC,aAAU;IACV,KAAK;IAGL,OAAO;KAAE,OAAO;KAAY,GAAG;KAAO;IACtC,UAAU;IAET;IACI;GACQ,EACT;;;AAId,gCAAgC,cAAc"}
1
+ {"version":3,"file":"DirectionalColorWheelDisclosure.js","names":[],"sources":["../../src/components/DirectionalColorWheel/DirectionalColorWheelDisclosure.tsx"],"sourcesContent":["import { Card } from '@components/DataCard/Card'\nimport { Popover, PopoverContent, PopoverTrigger } from '@components/Popover/Popover'\nimport { useUncontrolledState } from '@hooks/useUncontrolledState'\nimport { cn } from '@utils/twUtils'\nimport { useCallback, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type Ref, type RefObject } from 'react'\nimport { DirectionalColorWheelGlyph } from './DirectionalColorWheelGlyph'\n\ntype DisclosureAlign = 'start' | 'center' | 'end'\ntype DisclosureSide = 'top' | 'right' | 'bottom' | 'left'\n\n/** Pixel offset of the draggable disclosure from where it is mounted (default `{ x: 0, y: 0 }`). */\nexport interface DirectionalColorWheelDisclosurePosition {\n /** Horizontal offset in px (positive moves right). */\n x: number\n /** Vertical offset in px (positive moves down). */\n y: number\n}\n\n// Drag distance (px) below which a press on the glyph is a tap (toggle), not a reposition drag.\nconst DRAG_SLOP_PX = 4\nconst ORIGIN: DirectionalColorWheelDisclosurePosition = { x: 0, y: 0 }\n\nexport interface DirectionalColorWheelDisclosureProps {\n /** Accessible name for the collapsed trigger button (`aria-expanded` conveys the open state). */\n accessibleName?: string\n /** How the panel aligns to the trigger. Defaults to `start` for a corner-anchored feed widget. */\n align?: DisclosureAlign\n /** The expandable content: the bare wheel, or the wheel framed by the GRAMS controls. */\n children: ReactNode\n /** Layout-only class extension for the expanded panel. */\n className?: string\n /**\n * Override the collapsed affordance. Defaults to a {@link DirectionalColorWheelGlyph} mini\n * compass; pass a palette-matched glyph (or any node) to mirror the expanded wheel's colours.\n */\n collapsedIcon?: ReactNode\n /** Min distance (px) from the viewport edge before the panel flips/shifts. */\n collisionPadding?: number\n dataTestId?: string\n /**\n * Element the drag is clamped to so the glyph can't be parked off the feed. Pass a ref to the\n * container the disclosure is mounted over (e.g. the plot surface). `undefined`/`null` = no clamp\n * (free drag). Only used when `draggable`.\n */\n dragBoundsRef?: RefObject<HTMLElement | null> | null\n /** Uncontrolled initial expanded state. */\n defaultExpanded?: boolean\n /** Uncontrolled initial position offset (px) from the mount point. Defaults to the corner it is mounted at. */\n defaultPosition?: DirectionalColorWheelDisclosurePosition\n disabled?: boolean\n /**\n * When `true`, the operator can drag the collapsed glyph to reposition the whole widget (the\n * expanded panel reflows with it in real time). A tap still toggles; only a drag past a small\n * slop repositions. Defaults to `false` (fixed at its mount point).\n */\n draggable?: boolean\n /**\n * When `false`, the panel stays open during outside interaction and Esc — it toggles only via the\n * trigger (or a controlled `expanded`). Use this for a feed-mounted wheel so clicking/scrubbing the\n * feed underneath doesn't dismiss the legend. Defaults to `true` (standard popover dismissal).\n */\n dismissOnInteractOutside?: boolean\n /**\n * Controlled expanded state. `undefined`/`null` leaves the disclosure uncontrolled, so Horizon\n * can gate the feature with a single nullable value (PAT-028).\n */\n expanded?: boolean | null\n /** Called whenever the disclosure expands or collapses (click, Esc, or outside dismiss). */\n onExpandedChange?: (expanded: boolean) => void\n /** Called as the glyph is dragged with the next position offset (px). Pair with `position` to persist it. */\n onPositionChange?: (position: DirectionalColorWheelDisclosurePosition) => void\n /**\n * Controlled position offset (px) from the mount point. `undefined`/`null` leaves it uncontrolled,\n * so Horizon can gate the feature with a single nullable value (PAT-028). Requires `draggable` to move.\n */\n position?: DirectionalColorWheelDisclosurePosition | null\n ref?: Ref<HTMLButtonElement>\n /** Which side of the trigger the panel grows toward. Defaults to `top` (icon at a bottom corner). */\n side?: DisclosureSide\n /** Gap (px) between the trigger and the panel. */\n sideOffset?: number\n /**\n * Inline styles merged onto the expanded panel (the {@link Card}). Use this for visual overrides\n * — background, border, shadow — that the `card-effects` base class doesn't expose to `className`\n * utilities. The default panel width is applied first, so a `width` set here wins over the `width` prop.\n */\n style?: CSSProperties\n /** Layout-only class extension for the collapsed trigger button. */\n triggerClassName?: string\n /** Panel width: a pixel number or a CSS width string. Defaults to `'fit-content'` (hugs content). */\n width?: number | string\n}\n\n/**\n * Collapse/expand affordance for the {@link DirectionalColorWheel}, built on the Spectral\n * {@link Popover} primitive so it inherits corner anchoring, click-outside/Esc dismissal, focus\n * management, and `aria-expanded`/`aria-controls` for free. Collapsed it is a small compass glyph\n * (a real `<button>`); expanded it reveals whatever `children` it wraps — the bare wheel, or the\n * wheel plus the GRAMS Color Angle / Threshold / Sectors controls — inside a Spectral {@link Card}\n * panel, growing from the icon with a `transform-origin`-anchored zoom+fade that is automatically\n * skipped under `prefers-reduced-motion`. `className` extends the Card panel's classes and `style`\n * applies inline overrides to it (background, border, shadow, …).\n *\n * Pass `draggable` to let the operator drag the glyph to reposition the whole widget on a feed; the\n * expanded panel reflows with it in real time, and `dragBoundsRef` clamps it inside its container so\n * it can't be parked off the feed. Position is controlled+uncontrolled via\n * `position` / `defaultPosition` / `onPositionChange` (a nullable px offset from the mount point,\n * PAT-028) so a consumer can persist where the operator parked it.\n */\nexport const DirectionalColorWheelDisclosure = ({\n accessibleName = 'Directional color legend',\n align = 'start',\n children,\n className,\n collapsedIcon,\n collisionPadding = 8,\n dataTestId = 'spectral-directional-color-wheel-disclosure',\n defaultExpanded,\n defaultPosition,\n disabled = false,\n dismissOnInteractOutside = true,\n draggable = false,\n dragBoundsRef,\n expanded,\n onExpandedChange,\n onPositionChange,\n position,\n ref,\n side = 'top',\n sideOffset = 8,\n style,\n triggerClassName,\n width = 'fit-content',\n}: DirectionalColorWheelDisclosureProps) => {\n const panelRef = useRef<HTMLDivElement>(null)\n // `width` sizes the Card panel; the popover hugs it (fit-content) so there is no empty gap.\n const panelWidth = typeof width === 'number' ? `${width}px` : width\n\n const [positionValue, setPositionValue] = useUncontrolledState<DirectionalColorWheelDisclosurePosition>({ defaultValue: defaultPosition ?? ORIGIN, onChange: onPositionChange, value: position ?? undefined })\n const [dragging, setDragging] = useState(false)\n // Live reposition drag (a ref so rapid moves don't hinge on re-render timing). `bounds` is the\n // clamp rect (the dragBoundsRef element); `homeLeft`/`homeTop` are the trigger's viewport position\n // at translate 0, so the allowed offset range keeps the glyph fully inside `bounds`.\n const dragRef = useRef<{ baseX: number; baseY: number; bounds: DOMRect | null; height: number; homeLeft: number; homeTop: number; moved: boolean; pointerId: number; startX: number; startY: number; width: number } | null>(null)\n // Set when a drag passes the slop so the trailing click is swallowed (preventDefault → Radix's\n // composed trigger toggle bails) instead of also expanding/collapsing the panel.\n const suppressClickRef = useRef(false)\n\n const handleTriggerPointerDown = useCallback(\n (event: ReactPointerEvent<HTMLButtonElement>) => {\n suppressClickRef.current = false\n if (!draggable || disabled) return\n try {\n event.currentTarget.setPointerCapture(event.pointerId)\n } catch {\n // setPointerCapture can throw without an active pointer (e.g. synthetic test events).\n }\n const triggerRect = event.currentTarget.getBoundingClientRect()\n dragRef.current = {\n baseX: positionValue.x,\n baseY: positionValue.y,\n bounds: dragBoundsRef?.current?.getBoundingClientRect() ?? null,\n height: triggerRect.height,\n // Subtract the active translate to recover the trigger's home (translate-0) position.\n homeLeft: triggerRect.left - positionValue.x,\n homeTop: triggerRect.top - positionValue.y,\n moved: false,\n pointerId: event.pointerId,\n startX: event.clientX,\n startY: event.clientY,\n width: triggerRect.width,\n }\n },\n [disabled, draggable, dragBoundsRef, positionValue.x, positionValue.y],\n )\n\n const handleTriggerPointerMove = useCallback(\n (event: ReactPointerEvent<HTMLButtonElement>) => {\n const drag = dragRef.current\n if (drag === null || drag.pointerId !== event.pointerId) return\n const deltaX = event.clientX - drag.startX\n const deltaY = event.clientY - drag.startY\n if (!drag.moved) {\n if (Math.hypot(deltaX, deltaY) <= DRAG_SLOP_PX) return\n drag.moved = true\n suppressClickRef.current = true\n // Promote the trigger to its own compositor layer and poll the popover position every frame\n // (`updatePositionStrategy='always'`) so the panel reflows with the glyph rather than snapping.\n setDragging(true)\n }\n let nextX = drag.baseX + deltaX\n let nextY = drag.baseY + deltaY\n // Clamp the offset so the glyph stays fully inside the bounds element (can't be lost off the feed).\n if (drag.bounds !== null) {\n nextX = Math.min(Math.max(nextX, drag.bounds.left - drag.homeLeft), drag.bounds.right - drag.homeLeft - drag.width)\n nextY = Math.min(Math.max(nextY, drag.bounds.top - drag.homeTop), drag.bounds.bottom - drag.homeTop - drag.height)\n }\n setPositionValue({ x: nextX, y: nextY })\n },\n [setPositionValue],\n )\n\n const endDrag = useCallback(() => {\n dragRef.current = null\n setDragging(false)\n }, [])\n\n const handleTriggerClick = useCallback((event: ReactMouseEvent<HTMLButtonElement>) => {\n if (suppressClickRef.current) {\n suppressClickRef.current = false\n event.preventDefault()\n }\n }, [])\n\n return (\n <Popover\n defaultOpen={defaultExpanded}\n onOpenChange={onExpandedChange}\n open={expanded ?? undefined}\n >\n <PopoverTrigger asChild>\n <button\n aria-label={accessibleName}\n className={cn(\n 'inline-flex items-center justify-center rounded-full text-text-primary outline-none hover:opacity-80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:pointer-events-none disabled:opacity-50 motion-safe:transition-opacity motion-safe:duration-150',\n draggable && 'touch-none',\n draggable && (dragging ? 'cursor-grabbing' : 'cursor-grab'),\n triggerClassName,\n )}\n data-slot='directional-color-wheel-disclosure-trigger'\n data-testid={`${dataTestId}-trigger`}\n disabled={disabled}\n onClick={handleTriggerClick}\n onLostPointerCapture={endDrag}\n onPointerDown={handleTriggerPointerDown}\n onPointerMove={handleTriggerPointerMove}\n onPointerUp={endDrag}\n ref={ref}\n // A pure translate (no left/top) keeps the reposition on the compositor; `will-change`\n // promotes it for the duration of the drag.\n style={draggable ? { transform: `translate(${positionValue.x}px, ${positionValue.y}px)`, willChange: dragging ? 'transform' : undefined } : undefined}\n type='button'\n >\n {collapsedIcon ?? <DirectionalColorWheelGlyph />}\n </button>\n </PopoverTrigger>\n <PopoverContent\n align={align}\n // Chrome-less positioning/animation layer; the visible panel is the Spectral Card below.\n className='p-0 flex items-center justify-center overflow-visible border-none bg-transparent shadow-none'\n collisionPadding={collisionPadding}\n data-slot='directional-color-wheel-disclosure-content'\n data-testid={`${dataTestId}-content`}\n // When dismissal is off, swallow Radix's outside-pointer/Esc dismiss so the panel stays open\n // while the user interacts with whatever sits under it (e.g. the feed); the trigger still toggles.\n onEscapeKeyDown={dismissOnInteractOutside ? undefined : (event) => event.preventDefault()}\n onInteractOutside={dismissOnInteractOutside ? undefined : (event) => event.preventDefault()}\n onOpenAutoFocus={(event) => {\n // Focus the panel, not the first sector wedge (which would paint an accent ring on a sector).\n event.preventDefault()\n panelRef.current?.focus()\n }}\n side={side}\n sideOffset={sideOffset}\n // While dragging, poll the anchor position every animation frame so the panel tracks the\n // glyph in real time; back to the cheaper 'optimized' strategy at rest.\n updatePositionStrategy={dragging ? 'always' : 'optimized'}\n width='fit-content'\n >\n <Card\n className={cn('outline-none', className)}\n data-slot='directional-color-wheel-disclosure-panel'\n ref={panelRef}\n // The default width is applied first so a caller-supplied `style.width` (or any other\n // visual override the `card-effects` base class locks down) takes precedence.\n style={{ width: panelWidth, ...style }}\n tabIndex={-1}\n >\n {children}\n </Card>\n </PopoverContent>\n </Popover>\n )\n}\n\nDirectionalColorWheelDisclosure.displayName = 'DirectionalColorWheelDisclosure'\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"DirectionalColorWheel.d.ts","names":[],"sources":["../src/components/DirectionalColorWheel/DirectionalColorWheel.tsx"],"mappings":";;;;;;UAKiB,0BAAA;;EAEf,cAAA;EAFe;;;;;;;EAUf,OAAA;EAuCoC;EArCpC,SAAA;EAFA;;;;;;;EAUA,UAAA;EAWA;;;;;EALA,eAAA,KAAoB,cAAA;EAcE;EAZtB,UAAA,GAAa,oBAAA;EACb,UAAA;EAeA;EAbA,iBAAA;EAauC;EAXvC,sBAAA;EAaqB;EAXrB,gBAAA;EACA,QAAA;EAWU;EATV,eAAA;EAWc;EATd,kBAAA,IAAsB,UAAA;EAiBtB;EAfA,uBAAA,IAA2B,eAAA;EAiBd;EAfb,cAAA,IAAkB,WAAA,UAAqB,YAAA;;EAEvC,iBAAA,IAAqB,SAAA;EACrB,GAAA,GAAM,GAAA,CAAI,cAAA;EA+FV;EA7FA,WAAA,GAAc,sBAAA;EA+Fd;EA7FA,IAAA;EA+FA;;;;;EAzFA,SAAA;EA+FA;EA7FA,aAAA;AAAA;AAAA;EAmFA,cAAA;EACA,OAAA;EACA,SAAA;EACA,UAAA;EACA,eAAA;EACA,UAAA;EACA,UAAA;EACA,iBAAA;EACA,sBAAA;EACA,gBAAA;EACA,QAAA;EACA,eAAA;EACA,kBAAA;EACA,uBAAA;EACA,cAAA;EACA,iBAAA;EACA,GAAA;EACA,WAAA;EACA,IAAA;EACA,SAAA;EACA;AAAA,GACC,0BAAA,GAA0B,oBAAA,CAAA,GAAA,CAAA,OAAA;AAAA"}
1
+ {"version":3,"file":"DirectionalColorWheel.d.ts","names":[],"sources":["../src/components/DirectionalColorWheel/DirectionalColorWheel.tsx"],"mappings":";;;;;;UAKiB,0BAAA;;EAEf,cAAA;EAFe;;;;;;;EAUf,OAAA;EAuCoC;EArCpC,SAAA;EAFA;;;;;;;EAUA,UAAA;EAWA;;;;;EALA,eAAA,KAAoB,cAAA;EAcE;EAZtB,UAAA,GAAa,oBAAA;EACb,UAAA;EAeA;EAbA,iBAAA;EAauC;EAXvC,sBAAA;EAaqB;EAXrB,gBAAA;EACA,QAAA;EAWU;EATV,eAAA;EAWc;EATd,kBAAA,IAAsB,UAAA;EAiBtB;EAfA,uBAAA,IAA2B,eAAA;EAiBd;EAfb,cAAA,IAAkB,WAAA,UAAqB,YAAA;;EAEvC,iBAAA,IAAqB,SAAA;EACrB,GAAA,GAAM,GAAA,CAAI,cAAA;EAkGV;EAhGA,WAAA,GAAc,sBAAA;EAkGd;EAhGA,IAAA;EAkGA;;;;;EA5FA,SAAA;EAkGA;EAhGA,aAAA;AAAA;AAAA;EAsFA,cAAA;EACA,OAAA;EACA,SAAA;EACA,UAAA;EACA,eAAA;EACA,UAAA;EACA,UAAA;EACA,iBAAA;EACA,sBAAA;EACA,gBAAA;EACA,QAAA;EACA,eAAA;EACA,kBAAA;EACA,uBAAA;EACA,cAAA;EACA,iBAAA;EACA,GAAA;EACA,WAAA;EACA,IAAA;EACA,SAAA;EACA;AAAA,GACC,0BAAA,GAA0B,oBAAA,CAAA,GAAA,CAAA,OAAA;AAAA"}
@@ -60,6 +60,7 @@ const pointerBearing = (clientX, clientY, rect) => {
60
60
  const deltaY = clientY - (rect.top + rect.height / 2);
61
61
  return normalizeBearing(Math.atan2(deltaX, -deltaY) * 180 / Math.PI);
62
62
  };
63
+ const sectorIndexForBearing = (bearing, count) => Math.floor(normalizeBearing(bearing) / (360 / count)) % count;
63
64
  const DirectionalColorWheel = ({ accessibleName = "Directional bearing color wheel", bearing, className, colorAngle, colorForBearing, colorStops, dataTestId = "spectral-directional-color-wheel", defaultColorAngle, defaultDisabledSectors, defaultThreshold = 0, disabled = false, disabledSectors, onColorAngleChange, onDisabledSectorsChange, onSectorToggle, onThresholdChange, ref, sectorCount = 12, size = 240, threshold, thresholdStep = .05 }) => {
64
65
  const rootRef = useRef(null);
65
66
  const wedgeRefs = useRef([]);
@@ -87,7 +88,10 @@ const DirectionalColorWheel = ({ accessibleName = "Directional bearing color whe
87
88
  const [draggingDial, setDraggingDial] = useState(false);
88
89
  const [dialFocused, setDialFocused] = useState(false);
89
90
  const [rotatingRing, setRotatingRing] = useState(false);
91
+ const [brushing, setBrushing] = useState(false);
92
+ const [brushedIndices, setBrushedIndices] = useState([]);
90
93
  const rotationRef = useRef(null);
94
+ const brushRef = useRef(null);
91
95
  const suppressClickRef = useRef(false);
92
96
  const canRotate = !disabled && (colorAngle === void 0 || onColorAngleChange !== void 0);
93
97
  const disabledSet = useMemo(() => new Set(disabledValue.filter((index) => index >= 0 && index < sectorCount)), [disabledValue, sectorCount]);
@@ -121,11 +125,74 @@ const DirectionalColorWheel = ({ accessibleName = "Directional bearing color whe
121
125
  sectorCount,
122
126
  setDisabledValue
123
127
  ]);
124
- const handleRingPointerDown = useCallback((event) => {
128
+ const commitDisabledSet = useCallback((next) => {
129
+ setDisabledValue([...next].filter((value) => value >= 0 && value < sectorCount).sort((first, second) => first - second));
130
+ }, [sectorCount, setDisabledValue]);
131
+ const setSectorDisabled = useCallback((index, nextDisabled) => {
132
+ if (disabled || disabledSet.has(index) === nextDisabled) return;
133
+ const next = new Set(disabledSet);
134
+ if (nextDisabled) next.add(index);
135
+ else next.delete(index);
136
+ commitDisabledSet(next);
137
+ onSectorToggle?.(index, nextDisabled);
138
+ }, [
139
+ commitDisabledSet,
140
+ disabled,
141
+ disabledSet,
142
+ onSectorToggle
143
+ ]);
144
+ const paintBrushSector = useCallback((brush, index) => {
145
+ brush.brushed.add(index);
146
+ if (brush.disabled.has(index) === brush.targetDisabled) return;
147
+ if (brush.targetDisabled) brush.disabled.add(index);
148
+ else brush.disabled.delete(index);
149
+ commitDisabledSet(brush.disabled);
150
+ onSectorToggle?.(index, brush.targetDisabled);
151
+ }, [commitDisabledSet, onSectorToggle]);
152
+ const startBrush = useCallback((event, index, rect) => {
153
+ if (disabled) return;
154
+ try {
155
+ event.currentTarget.setPointerCapture(event.pointerId);
156
+ } catch {}
157
+ const targetDisabled = !disabledSet.has(index);
158
+ const working = new Set(disabledSet);
159
+ if (targetDisabled) working.add(index);
160
+ else working.delete(index);
161
+ brushRef.current = {
162
+ brushed: new Set([index]),
163
+ disabled: working,
164
+ lastBearing: pointerBearing(event.clientX, event.clientY, rect),
165
+ pointerId: event.pointerId,
166
+ rect,
167
+ targetDisabled
168
+ };
169
+ suppressClickRef.current = true;
170
+ setBrushing(true);
171
+ setBrushedIndices([index]);
172
+ commitDisabledSet(working);
173
+ onSectorToggle?.(index, targetDisabled);
174
+ }, [
175
+ commitDisabledSet,
176
+ disabled,
177
+ disabledSet,
178
+ onSectorToggle
179
+ ]);
180
+ const endBrush = useCallback(() => {
181
+ if (brushRef.current === null) return;
182
+ brushRef.current = null;
183
+ setBrushing(false);
184
+ setBrushedIndices([]);
185
+ setFocusedSectorIndex(null);
186
+ }, []);
187
+ const handleSectorPointerDown = useCallback((event, index) => {
125
188
  suppressClickRef.current = false;
126
- if (!canRotate) return;
127
189
  const rect = rootRef.current?.getBoundingClientRect();
128
190
  if (!rect) return;
191
+ if (event.shiftKey) {
192
+ startBrush(event, index, rect);
193
+ return;
194
+ }
195
+ if (!canRotate) return;
129
196
  try {
130
197
  event.currentTarget.setPointerCapture(event.pointerId);
131
198
  } catch {}
@@ -139,8 +206,30 @@ const DirectionalColorWheel = ({ accessibleName = "Directional bearing color whe
139
206
  startY: event.clientY,
140
207
  wedge: event.currentTarget
141
208
  };
142
- }, [canRotate, colorAngleValue]);
209
+ }, [
210
+ canRotate,
211
+ colorAngleValue,
212
+ startBrush
213
+ ]);
143
214
  const handleRingPointerMove = useCallback((event) => {
215
+ const brush = brushRef.current;
216
+ if (brush !== null) {
217
+ if (brush.pointerId !== event.pointerId) return;
218
+ const bearingNow = pointerBearing(event.clientX, event.clientY, brush.rect);
219
+ let delta = bearingNow - brush.lastBearing;
220
+ if (delta > 180) delta -= 360;
221
+ if (delta < -180) delta += 360;
222
+ const step = delta >= 0 ? 1 : -1;
223
+ const toIndex = sectorIndexForBearing(bearingNow, sectorCount);
224
+ let index = sectorIndexForBearing(brush.lastBearing, sectorCount);
225
+ while (index !== toIndex) {
226
+ index = (index + step + sectorCount) % sectorCount;
227
+ paintBrushSector(brush, index);
228
+ }
229
+ brush.lastBearing = bearingNow;
230
+ setBrushedIndices([...brush.brushed]);
231
+ return;
232
+ }
144
233
  const drag = rotationRef.current;
145
234
  if (drag === null || drag.pointerId !== event.pointerId) return;
146
235
  const bearingNow = pointerBearing(event.clientX, event.clientY, drag.rect);
@@ -157,7 +246,11 @@ const DirectionalColorWheel = ({ accessibleName = "Directional bearing color whe
157
246
  drag.wedge.blur();
158
247
  }
159
248
  setColorAngleValue(drag.angle);
160
- }, [setColorAngleValue]);
249
+ }, [
250
+ paintBrushSector,
251
+ sectorCount,
252
+ setColorAngleValue
253
+ ]);
161
254
  const endRingRotation = useCallback(() => {
162
255
  rotationRef.current = null;
163
256
  setRotatingRing(false);
@@ -168,16 +261,20 @@ const DirectionalColorWheel = ({ accessibleName = "Directional bearing color whe
168
261
  }, []);
169
262
  const handleWedgeKeyDown = useCallback((event, index) => {
170
263
  if (disabled) return;
264
+ const moveFocus = (next) => {
265
+ if (event.shiftKey) setSectorDisabled(next, disabledSet.has(index));
266
+ focusSector(next);
267
+ };
171
268
  switch (event.key) {
172
269
  case "ArrowRight":
173
270
  case "ArrowDown":
174
271
  event.preventDefault();
175
- focusSector((index + 1) % sectorCount);
272
+ moveFocus((index + 1) % sectorCount);
176
273
  break;
177
274
  case "ArrowLeft":
178
275
  case "ArrowUp":
179
276
  event.preventDefault();
180
- focusSector((index - 1 + sectorCount) % sectorCount);
277
+ moveFocus((index - 1 + sectorCount) % sectorCount);
181
278
  break;
182
279
  case "Home":
183
280
  event.preventDefault();
@@ -191,8 +288,10 @@ const DirectionalColorWheel = ({ accessibleName = "Directional bearing color whe
191
288
  }
192
289
  }, [
193
290
  disabled,
291
+ disabledSet,
194
292
  focusSector,
195
- sectorCount
293
+ sectorCount,
294
+ setSectorDisabled
196
295
  ]);
197
296
  const setThresholdClamped = useCallback((next) => {
198
297
  setThresholdValue(clampUnit(next));
@@ -256,6 +355,7 @@ const DirectionalColorWheel = ({ accessibleName = "Directional bearing color whe
256
355
  const ringDiameterPercent = clampedThreshold * 100 * THRESHOLD_TRACK_FRACTION;
257
356
  const dotTopPercent = 50 - clampedThreshold * 50 * THRESHOLD_TRACK_FRACTION;
258
357
  const hoverHighlightIndex = !rotatingRing && hoveredSectorIndex !== null && hoveredSectorIndex !== focusedSectorIndex ? hoveredSectorIndex : null;
358
+ const ringIndices = brushing ? brushedIndices : focusedSectorIndex !== null && !rotatingRing ? [focusedSectorIndex] : [];
259
359
  const safeActiveIndex = Math.min(activeSectorIndex, sectorCount - 1);
260
360
  const saturation = thresholdToSaturation(clampedThreshold);
261
361
  const hasNeedle = typeof bearing === "number" && Number.isFinite(bearing);
@@ -280,6 +380,7 @@ const DirectionalColorWheel = ({ accessibleName = "Directional bearing color whe
280
380
  children: [
281
381
  /* @__PURE__ */ jsx("div", {
282
382
  className: "col-start-1 row-start-1 grid size-full",
383
+ "data-brushing": brushing || void 0,
283
384
  "data-rotatable": canRotate || void 0,
284
385
  "data-rotating": rotatingRing || void 0,
285
386
  "data-slot": "directional-color-wheel-sectors",
@@ -290,7 +391,7 @@ const DirectionalColorWheel = ({ accessibleName = "Directional bearing color whe
290
391
  return /* @__PURE__ */ jsx("button", {
291
392
  "aria-label": `Bearing sector ${Math.round(sector.startBearing)}° to ${Math.round(sector.endBearing)}°`,
292
393
  "aria-pressed": isEnabled,
293
- className: cn("col-start-1 row-start-1 size-full cursor-pointer outline-none motion-safe:transition-opacity motion-safe:duration-150", !isEnabled && "opacity-25", canRotate && "touch-none", canRotate && (rotatingRing ? "cursor-grabbing" : "cursor-grab")),
394
+ className: cn("col-start-1 row-start-1 size-full cursor-pointer outline-none motion-safe:transition-opacity motion-safe:duration-150", !isEnabled && "opacity-25", canRotate && "touch-none", canRotate && (rotatingRing ? "cursor-grabbing" : "cursor-grab"), brushing && "cursor-crosshair"),
294
395
  "data-sector-id": sector.id,
295
396
  "data-slot": "directional-color-wheel-sector",
296
397
  "data-testid": `${dataTestId}-sector-${sector.index}`,
@@ -312,8 +413,13 @@ const DirectionalColorWheel = ({ accessibleName = "Directional bearing color whe
312
413
  onKeyDown: (event) => {
313
414
  handleWedgeKeyDown(event, sector.index);
314
415
  },
315
- onLostPointerCapture: endRingRotation,
316
- onPointerDown: handleRingPointerDown,
416
+ onLostPointerCapture: () => {
417
+ endRingRotation();
418
+ endBrush();
419
+ },
420
+ onPointerDown: (event) => {
421
+ handleSectorPointerDown(event, sector.index);
422
+ },
317
423
  onPointerEnter: () => {
318
424
  setHoveredSectorIndex(sector.index);
319
425
  },
@@ -321,7 +427,10 @@ const DirectionalColorWheel = ({ accessibleName = "Directional bearing color whe
321
427
  setHoveredSectorIndex((current) => current === sector.index ? null : current);
322
428
  },
323
429
  onPointerMove: handleRingPointerMove,
324
- onPointerUp: endRingRotation,
430
+ onPointerUp: () => {
431
+ endRingRotation();
432
+ endBrush();
433
+ },
325
434
  ref: (node) => {
326
435
  wedgeRefs.current[sector.index] = node;
327
436
  },
@@ -342,21 +451,21 @@ const DirectionalColorWheel = ({ accessibleName = "Directional bearing color whe
342
451
  viewBox: "0 0 100 100",
343
452
  children: [
344
453
  /* @__PURE__ */ jsx("circle", {
345
- className: "fill-none stroke-border-primary",
454
+ className: "fill-none stroke-border-secondary",
346
455
  cx: CENTER,
347
456
  cy: CENTER,
348
457
  r: OUTER_RADIUS,
349
- strokeWidth: .6
458
+ strokeWidth: .7
350
459
  }),
351
460
  /* @__PURE__ */ jsx("circle", {
352
- className: "fill-none stroke-border-primary",
461
+ className: "fill-none stroke-border-secondary",
353
462
  cx: CENTER,
354
463
  cy: CENTER,
355
464
  r: INNER_RADIUS,
356
- strokeWidth: .6
465
+ strokeWidth: .7
357
466
  }),
358
467
  /* @__PURE__ */ jsx("circle", {
359
- className: "fill-none stroke-border-primary/50",
468
+ className: "fill-none stroke-border-secondary/60",
360
469
  cx: CENTER,
361
470
  cy: CENTER,
362
471
  r: TICK_OUTER_RADIUS,
@@ -365,9 +474,9 @@ const DirectionalColorWheel = ({ accessibleName = "Directional bearing color whe
365
474
  /* @__PURE__ */ jsx("g", {
366
475
  "data-testid": `${dataTestId}-degree-ticks`,
367
476
  children: DEGREE_TICKS.map((tick) => /* @__PURE__ */ jsx("line", {
368
- className: tick.isMajor ? "stroke-text-secondary" : "stroke-border-primary/80",
477
+ className: tick.isMajor ? "stroke-text-secondary" : "stroke-text-disabled",
369
478
  strokeLinecap: "round",
370
- strokeWidth: tick.isMajor ? .8 : .4,
479
+ strokeWidth: tick.isMajor ? .8 : .5,
371
480
  x1: tick.inner.x,
372
481
  x2: tick.outer.x,
373
482
  y1: tick.inner.y,
@@ -378,12 +487,12 @@ const DirectionalColorWheel = ({ accessibleName = "Directional bearing color whe
378
487
  className: "fill-text-primary/15",
379
488
  d: annularSectorPath(CENTER, CENTER, INNER_RADIUS, OUTER_RADIUS, sectors[hoverHighlightIndex].startBearing, sectors[hoverHighlightIndex].endBearing)
380
489
  }) : null,
381
- focusedSectorIndex !== null && !rotatingRing && sectors[focusedSectorIndex] ? /* @__PURE__ */ jsx("path", {
490
+ ringIndices.map((ringIndex) => sectors[ringIndex] ? /* @__PURE__ */ jsx("path", {
382
491
  className: "fill-none stroke-accent",
383
- d: annularSectorPath(CENTER, CENTER, INNER_RADIUS + FOCUS_RADIAL_INSET, OUTER_RADIUS - FOCUS_RADIAL_INSET, sectors[focusedSectorIndex].startBearing + FOCUS_ANGULAR_INSET, sectors[focusedSectorIndex].endBearing - FOCUS_ANGULAR_INSET),
492
+ d: annularSectorPath(CENTER, CENTER, INNER_RADIUS + FOCUS_RADIAL_INSET, OUTER_RADIUS - FOCUS_RADIAL_INSET, sectors[ringIndex].startBearing + FOCUS_ANGULAR_INSET, sectors[ringIndex].endBearing - FOCUS_ANGULAR_INSET),
384
493
  strokeLinejoin: "round",
385
494
  strokeWidth: FOCUS_STROKE_WIDTH
386
- }) : null,
495
+ }, ringIndex) : null),
387
496
  BEZEL_LABELS.map((label) => /* @__PURE__ */ jsx("text", {
388
497
  className: label.isCardinal ? "font-medium fill-text-secondary text-[6px]" : "fill-text-secondary text-[4px] tabular-nums",
389
498
  dominantBaseline: "central",