@caratlogic/design-system 0.1.43 → 0.1.44

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/dist/index.cjs.js CHANGED
@@ -22680,6 +22680,20 @@ function distanceBetween(p1, p2) {
22680
22680
  return Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
22681
22681
  }
22682
22682
 
22683
+ function getRelativeTransformOrigin(event, rect) {
22684
+ const eventCoordinates = getEventCoordinates(event);
22685
+
22686
+ if (!eventCoordinates) {
22687
+ return '0 0';
22688
+ }
22689
+
22690
+ const transformOrigin = {
22691
+ x: (eventCoordinates.x - rect.left) / rect.width * 100,
22692
+ y: (eventCoordinates.y - rect.top) / rect.height * 100
22693
+ };
22694
+ return transformOrigin.x + "% " + transformOrigin.y + "%";
22695
+ }
22696
+
22683
22697
  /**
22684
22698
  * Sort collisions from smallest to greatest value
22685
22699
  */
@@ -25964,6 +25978,396 @@ function useDroppable(_ref) {
25964
25978
  };
25965
25979
  }
25966
25980
 
25981
+ function AnimationManager(_ref) {
25982
+ let {
25983
+ animation,
25984
+ children
25985
+ } = _ref;
25986
+ const [clonedChildren, setClonedChildren] = React.useState(null);
25987
+ const [element, setElement] = React.useState(null);
25988
+ const previousChildren = usePrevious(children);
25989
+
25990
+ if (!children && !clonedChildren && previousChildren) {
25991
+ setClonedChildren(previousChildren);
25992
+ }
25993
+
25994
+ useIsomorphicLayoutEffect$1(() => {
25995
+ if (!element) {
25996
+ return;
25997
+ }
25998
+
25999
+ const key = clonedChildren == null ? void 0 : clonedChildren.key;
26000
+ const id = clonedChildren == null ? void 0 : clonedChildren.props.id;
26001
+
26002
+ if (key == null || id == null) {
26003
+ setClonedChildren(null);
26004
+ return;
26005
+ }
26006
+
26007
+ Promise.resolve(animation(id, element)).then(() => {
26008
+ setClonedChildren(null);
26009
+ });
26010
+ }, [animation, clonedChildren, element]);
26011
+ return React.createElement(React.Fragment, null, children, clonedChildren ? React.cloneElement(clonedChildren, {
26012
+ ref: setElement
26013
+ }) : null);
26014
+ }
26015
+
26016
+ const defaultTransform = {
26017
+ x: 0,
26018
+ y: 0,
26019
+ scaleX: 1,
26020
+ scaleY: 1
26021
+ };
26022
+ function NullifiedContextProvider(_ref) {
26023
+ let {
26024
+ children
26025
+ } = _ref;
26026
+ return React.createElement(InternalContext.Provider, {
26027
+ value: defaultInternalContext
26028
+ }, React.createElement(ActiveDraggableContext.Provider, {
26029
+ value: defaultTransform
26030
+ }, children));
26031
+ }
26032
+
26033
+ const baseStyles = {
26034
+ position: 'fixed',
26035
+ touchAction: 'none'
26036
+ };
26037
+
26038
+ const defaultTransition$1 = activatorEvent => {
26039
+ const isKeyboardActivator = isKeyboardEvent(activatorEvent);
26040
+ return isKeyboardActivator ? 'transform 250ms ease' : undefined;
26041
+ };
26042
+
26043
+ const PositionedOverlay = /*#__PURE__*/React.forwardRef((_ref, ref) => {
26044
+ let {
26045
+ as,
26046
+ activatorEvent,
26047
+ adjustScale,
26048
+ children,
26049
+ className,
26050
+ rect,
26051
+ style,
26052
+ transform,
26053
+ transition = defaultTransition$1
26054
+ } = _ref;
26055
+
26056
+ if (!rect) {
26057
+ return null;
26058
+ }
26059
+
26060
+ const scaleAdjustedTransform = adjustScale ? transform : { ...transform,
26061
+ scaleX: 1,
26062
+ scaleY: 1
26063
+ };
26064
+ const styles = { ...baseStyles,
26065
+ width: rect.width,
26066
+ height: rect.height,
26067
+ top: rect.top,
26068
+ left: rect.left,
26069
+ transform: CSS$1.Transform.toString(scaleAdjustedTransform),
26070
+ transformOrigin: adjustScale && activatorEvent ? getRelativeTransformOrigin(activatorEvent, rect) : undefined,
26071
+ transition: typeof transition === 'function' ? transition(activatorEvent) : transition,
26072
+ ...style
26073
+ };
26074
+ return React.createElement(as, {
26075
+ className,
26076
+ style: styles,
26077
+ ref
26078
+ }, children);
26079
+ });
26080
+
26081
+ const defaultDropAnimationSideEffects = options => _ref => {
26082
+ let {
26083
+ active,
26084
+ dragOverlay
26085
+ } = _ref;
26086
+ const originalStyles = {};
26087
+ const {
26088
+ styles,
26089
+ className
26090
+ } = options;
26091
+
26092
+ if (styles != null && styles.active) {
26093
+ for (const [key, value] of Object.entries(styles.active)) {
26094
+ if (value === undefined) {
26095
+ continue;
26096
+ }
26097
+
26098
+ originalStyles[key] = active.node.style.getPropertyValue(key);
26099
+ active.node.style.setProperty(key, value);
26100
+ }
26101
+ }
26102
+
26103
+ if (styles != null && styles.dragOverlay) {
26104
+ for (const [key, value] of Object.entries(styles.dragOverlay)) {
26105
+ if (value === undefined) {
26106
+ continue;
26107
+ }
26108
+
26109
+ dragOverlay.node.style.setProperty(key, value);
26110
+ }
26111
+ }
26112
+
26113
+ if (className != null && className.active) {
26114
+ active.node.classList.add(className.active);
26115
+ }
26116
+
26117
+ if (className != null && className.dragOverlay) {
26118
+ dragOverlay.node.classList.add(className.dragOverlay);
26119
+ }
26120
+
26121
+ return function cleanup() {
26122
+ for (const [key, value] of Object.entries(originalStyles)) {
26123
+ active.node.style.setProperty(key, value);
26124
+ }
26125
+
26126
+ if (className != null && className.active) {
26127
+ active.node.classList.remove(className.active);
26128
+ }
26129
+ };
26130
+ };
26131
+
26132
+ const defaultKeyframeResolver = _ref2 => {
26133
+ let {
26134
+ transform: {
26135
+ initial,
26136
+ final
26137
+ }
26138
+ } = _ref2;
26139
+ return [{
26140
+ transform: CSS$1.Transform.toString(initial)
26141
+ }, {
26142
+ transform: CSS$1.Transform.toString(final)
26143
+ }];
26144
+ };
26145
+
26146
+ const defaultDropAnimationConfiguration = {
26147
+ duration: 250,
26148
+ easing: 'ease',
26149
+ keyframes: defaultKeyframeResolver,
26150
+ sideEffects: /*#__PURE__*/defaultDropAnimationSideEffects({
26151
+ styles: {
26152
+ active: {
26153
+ opacity: '0'
26154
+ }
26155
+ }
26156
+ })
26157
+ };
26158
+ function useDropAnimation(_ref3) {
26159
+ let {
26160
+ config,
26161
+ draggableNodes,
26162
+ droppableContainers,
26163
+ measuringConfiguration
26164
+ } = _ref3;
26165
+ return useEvent((id, node) => {
26166
+ if (config === null) {
26167
+ return;
26168
+ }
26169
+
26170
+ const activeDraggable = draggableNodes.get(id);
26171
+
26172
+ if (!activeDraggable) {
26173
+ return;
26174
+ }
26175
+
26176
+ const activeNode = activeDraggable.node.current;
26177
+
26178
+ if (!activeNode) {
26179
+ return;
26180
+ }
26181
+
26182
+ const measurableNode = getMeasurableNode(node);
26183
+
26184
+ if (!measurableNode) {
26185
+ return;
26186
+ }
26187
+
26188
+ const {
26189
+ transform
26190
+ } = getWindow$1(node).getComputedStyle(node);
26191
+ const parsedTransform = parseTransform(transform);
26192
+
26193
+ if (!parsedTransform) {
26194
+ return;
26195
+ }
26196
+
26197
+ const animation = typeof config === 'function' ? config : createDefaultDropAnimation(config);
26198
+ scrollIntoViewIfNeeded(activeNode, measuringConfiguration.draggable.measure);
26199
+ return animation({
26200
+ active: {
26201
+ id,
26202
+ data: activeDraggable.data,
26203
+ node: activeNode,
26204
+ rect: measuringConfiguration.draggable.measure(activeNode)
26205
+ },
26206
+ draggableNodes,
26207
+ dragOverlay: {
26208
+ node,
26209
+ rect: measuringConfiguration.dragOverlay.measure(measurableNode)
26210
+ },
26211
+ droppableContainers,
26212
+ measuringConfiguration,
26213
+ transform: parsedTransform
26214
+ });
26215
+ });
26216
+ }
26217
+
26218
+ function createDefaultDropAnimation(options) {
26219
+ const {
26220
+ duration,
26221
+ easing,
26222
+ sideEffects,
26223
+ keyframes
26224
+ } = { ...defaultDropAnimationConfiguration,
26225
+ ...options
26226
+ };
26227
+ return _ref4 => {
26228
+ let {
26229
+ active,
26230
+ dragOverlay,
26231
+ transform,
26232
+ ...rest
26233
+ } = _ref4;
26234
+
26235
+ if (!duration) {
26236
+ // Do not animate if animation duration is zero.
26237
+ return;
26238
+ }
26239
+
26240
+ const delta = {
26241
+ x: dragOverlay.rect.left - active.rect.left,
26242
+ y: dragOverlay.rect.top - active.rect.top
26243
+ };
26244
+ const scale = {
26245
+ scaleX: transform.scaleX !== 1 ? active.rect.width * transform.scaleX / dragOverlay.rect.width : 1,
26246
+ scaleY: transform.scaleY !== 1 ? active.rect.height * transform.scaleY / dragOverlay.rect.height : 1
26247
+ };
26248
+ const finalTransform = {
26249
+ x: transform.x - delta.x,
26250
+ y: transform.y - delta.y,
26251
+ ...scale
26252
+ };
26253
+ const animationKeyframes = keyframes({ ...rest,
26254
+ active,
26255
+ dragOverlay,
26256
+ transform: {
26257
+ initial: transform,
26258
+ final: finalTransform
26259
+ }
26260
+ });
26261
+ const [firstKeyframe] = animationKeyframes;
26262
+ const lastKeyframe = animationKeyframes[animationKeyframes.length - 1];
26263
+
26264
+ if (JSON.stringify(firstKeyframe) === JSON.stringify(lastKeyframe)) {
26265
+ // The start and end keyframes are the same, infer that there is no animation needed.
26266
+ return;
26267
+ }
26268
+
26269
+ const cleanup = sideEffects == null ? void 0 : sideEffects({
26270
+ active,
26271
+ dragOverlay,
26272
+ ...rest
26273
+ });
26274
+ const animation = dragOverlay.node.animate(animationKeyframes, {
26275
+ duration,
26276
+ easing,
26277
+ fill: 'forwards'
26278
+ });
26279
+ return new Promise(resolve => {
26280
+ animation.onfinish = () => {
26281
+ cleanup == null ? void 0 : cleanup();
26282
+ resolve();
26283
+ };
26284
+ });
26285
+ };
26286
+ }
26287
+
26288
+ let key = 0;
26289
+ function useKey(id) {
26290
+ return React.useMemo(() => {
26291
+ if (id == null) {
26292
+ return;
26293
+ }
26294
+
26295
+ key++;
26296
+ return key;
26297
+ }, [id]);
26298
+ }
26299
+
26300
+ const DragOverlay = /*#__PURE__*/React.memo(_ref => {
26301
+ let {
26302
+ adjustScale = false,
26303
+ children,
26304
+ dropAnimation: dropAnimationConfig,
26305
+ style,
26306
+ transition,
26307
+ modifiers,
26308
+ wrapperElement = 'div',
26309
+ className,
26310
+ zIndex = 999
26311
+ } = _ref;
26312
+ const {
26313
+ activatorEvent,
26314
+ active,
26315
+ activeNodeRect,
26316
+ containerNodeRect,
26317
+ draggableNodes,
26318
+ droppableContainers,
26319
+ dragOverlay,
26320
+ over,
26321
+ measuringConfiguration,
26322
+ scrollableAncestors,
26323
+ scrollableAncestorRects,
26324
+ windowRect
26325
+ } = useDndContext();
26326
+ const transform = React.useContext(ActiveDraggableContext);
26327
+ const key = useKey(active == null ? void 0 : active.id);
26328
+ const modifiedTransform = applyModifiers(modifiers, {
26329
+ activatorEvent,
26330
+ active,
26331
+ activeNodeRect,
26332
+ containerNodeRect,
26333
+ draggingNodeRect: dragOverlay.rect,
26334
+ over,
26335
+ overlayNodeRect: dragOverlay.rect,
26336
+ scrollableAncestors,
26337
+ scrollableAncestorRects,
26338
+ transform,
26339
+ windowRect
26340
+ });
26341
+ const initialRect = useInitialValue(activeNodeRect);
26342
+ const dropAnimation = useDropAnimation({
26343
+ config: dropAnimationConfig,
26344
+ draggableNodes,
26345
+ droppableContainers,
26346
+ measuringConfiguration
26347
+ }); // We need to wait for the active node to be measured before connecting the drag overlay ref
26348
+ // otherwise collisions can be computed against a mispositioned drag overlay
26349
+
26350
+ const ref = initialRect ? dragOverlay.setRef : undefined;
26351
+ return React.createElement(NullifiedContextProvider, null, React.createElement(AnimationManager, {
26352
+ animation: dropAnimation
26353
+ }, active && key ? React.createElement(PositionedOverlay, {
26354
+ key: key,
26355
+ id: active.id,
26356
+ ref: ref,
26357
+ as: wrapperElement,
26358
+ activatorEvent: activatorEvent,
26359
+ adjustScale: adjustScale,
26360
+ className: className,
26361
+ transition: transition,
26362
+ rect: initialRect,
26363
+ style: {
26364
+ zIndex,
26365
+ ...style
26366
+ },
26367
+ transform: modifiedTransform
26368
+ }, children) : null));
26369
+ });
26370
+
25967
26371
  const PERMISSIONS = {
25968
26372
  NONE: 0, // no access
25969
26373
  READ: 2, // can only read
@@ -77486,7 +77890,25 @@ const LOADING_LABEL = 'Loading filters';
77486
77890
  const ERROR_TITLE = "Couldn't load filters";
77487
77891
  const DEFAULT_ERROR_MESSAGE = 'Something went wrong while loading the filters. Check your connection and try again.';
77488
77892
  const RETRY_LABEL = 'Try again';
77893
+ const CARD_REORDER_HINT = 'Drag to rearrange';
77894
+ const getCardGripLabel = (card) => `Move ${card}`;
77895
+ const EMPTY_GROUP_LABEL = 'Drag filter cards here';
77489
77896
 
77897
+ /** Popovers, dialogs, menus and listboxes opened on top of the surface. */
77898
+ const NESTED_LAYER_SELECTOR = '[data-slot="popover-content"], [role="dialog"], [role="menu"], [role="listbox"]';
77899
+ /**
77900
+ * Closes the open popover or menu (not one that holds `from`) as Escape would. Radix closes a popover on
77901
+ * the click after an outside press, which a card drag never makes, so a grab would leave it open.
77902
+ */
77903
+ function dismissOpenLayer(from) {
77904
+ const layer = Array.from(from.ownerDocument.querySelectorAll(NESTED_LAYER_SELECTOR)).find((node) => !node.contains(from));
77905
+ // Sent from inside the layer, so the surface's own Escape handler leaves it alone.
77906
+ layer?.dispatchEvent(new KeyboardEvent('keydown', {
77907
+ key: 'Escape',
77908
+ bubbles: true,
77909
+ cancelable: true,
77910
+ }));
77911
+ }
77490
77912
  /** Placeholder cards while the filters load; roughly the first rows of a card grid. */
77491
77913
  const SKELETON_CARD_COUNT = 8;
77492
77914
  const SKELETON_GRID_CLASS_NAME = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4';
@@ -77496,8 +77918,82 @@ const MASONRY_ROW_PX = 4;
77496
77918
  const getMasonrySpan = (height) => height > 0 ? Math.ceil(height / MASONRY_ROW_PX) : 0;
77497
77919
  /** Dense 4px rows; the vertical gap comes from each item's bottom padding. */
77498
77920
  const MASONRY_GRID_CLASS_NAME = 'grid-flow-row-dense auto-rows-[4px] gap-x-3';
77921
+ // Card drag: the DOM marks each card, group grid and "More filters" toggle, so a pointer hit is found by attribute.
77922
+ const CARD_ID_ATTR = 'data-filter-card-id';
77923
+ const GROUP_ID_ATTR = 'data-filter-group-id';
77924
+ const MORE_TOGGLE_ATTR = 'data-filter-more-toggle';
77925
+ const CARD_BODY_ATTR = 'data-filter-card-body';
77926
+ /** The group holding `id`, if any. */
77927
+ function findCardGroup(layout, id) {
77928
+ return Object.keys(layout).find((group) => layout[group].includes(id));
77929
+ }
77930
+ /** `layout` with `id` taken out of its group and put into `target.group`, beside `target.key` or at the end. */
77931
+ function moveCard(layout, id, target) {
77932
+ const rest = (layout[target.group] ?? []).filter((key) => key !== id);
77933
+ let at = rest.length;
77934
+ if (target.key !== undefined) {
77935
+ const index = rest.indexOf(target.key);
77936
+ if (index === -1)
77937
+ return layout;
77938
+ at = index + (target.side === 'after' ? 1 : 0);
77939
+ }
77940
+ const next = Object.fromEntries(Object.entries(layout).map(([group, ids]) => [
77941
+ group,
77942
+ ids.filter((key) => key !== id),
77943
+ ]));
77944
+ next[target.group] = [...rest.slice(0, at), id, ...rest.slice(at)];
77945
+ return next;
77946
+ }
77947
+ function isSameLayout(a, b) {
77948
+ const groups = Object.keys(a);
77949
+ return (groups.length === Object.keys(b).length &&
77950
+ groups.every((group) => b[group]?.length === a[group].length &&
77951
+ a[group].every((id, index) => b[group][index] === id)));
77952
+ }
77953
+ /**
77954
+ * Where a card lands when dropped on `hit` at height `y`. In its own group it takes the hit card's place
77955
+ * (after it when moving forward), the way a sortable list swaps; from another group the pointer's half
77956
+ * of the hit card decides.
77957
+ */
77958
+ function getCardDropTarget(layout, activeId, hit, y) {
77959
+ const ids = layout[hit.group] ?? [];
77960
+ const from = ids.indexOf(activeId);
77961
+ const side = from === -1
77962
+ ? y > hit.rect.top + hit.rect.height / 2
77963
+ ? 'after'
77964
+ : 'before'
77965
+ : from < ids.indexOf(hit.id)
77966
+ ? 'after'
77967
+ : 'before';
77968
+ return { group: hit.group, key: hit.id, side };
77969
+ }
77970
+ /** The card, group or "More filters" toggle at a viewport point. */
77971
+ function getDropHit(x, y) {
77972
+ const element = document.elementFromPoint(x, y);
77973
+ if (!element)
77974
+ return null;
77975
+ const group = element.closest(`[${GROUP_ID_ATTR}]`);
77976
+ const groupId = group?.getAttribute(GROUP_ID_ATTR);
77977
+ const card = element.closest(`[${CARD_ID_ATTR}]`);
77978
+ const cardId = card?.getAttribute(CARD_ID_ATTR);
77979
+ if (card && cardId && groupId)
77980
+ return {
77981
+ kind: 'card',
77982
+ id: cardId,
77983
+ group: groupId,
77984
+ rect: card.getBoundingClientRect(),
77985
+ };
77986
+ if (groupId)
77987
+ return { kind: 'group', group: groupId };
77988
+ const toggle = element.closest(`[${MORE_TOGGLE_ATTR}]`);
77989
+ return toggle ? { kind: 'more', toggle } : null;
77990
+ }
77991
+ /** The dropped card's copy settles into its slot at the speed the other cards slide. */
77992
+ const CARD_DROP_ANIMATION = {
77993
+ duration: 200,
77994
+ easing: 'ease',
77995
+ };
77499
77996
 
77500
- const NESTED_LAYER_SELECTOR = '[data-slot="popover-content"], [role="dialog"], [role="menu"], [role="listbox"]';
77501
77997
  function useCloseOnEscape(onClose) {
77502
77998
  React.useEffect(() => {
77503
77999
  const handleKeyDown = (event) => {
@@ -77515,12 +78011,27 @@ function useCloseOnEscape(onClose) {
77515
78011
  }
77516
78012
  function useFilterSurfaceMore(defaultExpanded = false) {
77517
78013
  const [expanded, setExpanded] = React.useState(defaultExpanded);
77518
- return { expanded, toggle: () => setExpanded((current) => !current) };
78014
+ const [toggleNode, setToggleNode] = React.useState(null);
78015
+ const registerExpander = React.useContext(FilterSurfaceSortableContext)?.registerExpander;
78016
+ // Inside a sortable surface, a dragged card resting on the toggle opens the section.
78017
+ React.useEffect(() => {
78018
+ if (!toggleNode || !registerExpander)
78019
+ return;
78020
+ return registerExpander(toggleNode, () => setExpanded(true));
78021
+ }, [toggleNode, registerExpander]);
78022
+ return {
78023
+ expanded,
78024
+ toggle: () => setExpanded((current) => !current),
78025
+ toggleRef: setToggleNode,
78026
+ };
77519
78027
  }
77520
- /** Measures a masonry card and keeps its row span in step with its height (content opening, wrapping…). */
77521
- function useMasonrySpan() {
78028
+ /**
78029
+ * Measures a masonry card and keeps its row span in step with its height (content opening, wrapping…).
78030
+ * `initialSpan` lets a remounted card take its known height on the first render.
78031
+ */
78032
+ function useMasonrySpan(initialSpan = 0) {
77522
78033
  const [node, setNode] = React.useState(null);
77523
- const [span, setSpan] = React.useState(0);
78034
+ const [span, setSpan] = React.useState(initialSpan);
77524
78035
  React.useEffect(() => {
77525
78036
  if (!node)
77526
78037
  return;
@@ -77535,6 +78046,308 @@ function useMasonrySpan() {
77535
78046
  }, [node]);
77536
78047
  return { ref: React.useCallback((next) => setNode(next), []), span };
77537
78048
  }
78049
+ const FilterSurfaceSortableContext = React.createContext(null);
78050
+ /** The surrounding `FilterSurfaceSortable`, or null outside one. */
78051
+ function useFilterSurfaceSortable() {
78052
+ return React.useContext(FilterSurfaceSortableContext);
78053
+ }
78054
+ /** A sortable card's drag wiring, masonry span and registration for the slide animation. */
78055
+ function useSortableFilterCard(id) {
78056
+ const sortable = React.useContext(FilterSurfaceSortableContext);
78057
+ if (!sortable)
78058
+ throw new Error('SortableFilterCard must be inside FilterSurfaceSortable');
78059
+ const { spans, registerCard, unregisterCard } = sortable;
78060
+ const { setNodeRef, setActivatorNodeRef, listeners } = useDraggable({ id });
78061
+ const { ref: spanRef, span } = useMasonrySpan(spans.get(id));
78062
+ const nodeRef = React.useRef(null);
78063
+ React.useEffect(() => {
78064
+ if (span > 0)
78065
+ spans.set(id, span);
78066
+ }, [spans, id, span]);
78067
+ const cardRef = React.useCallback((node) => {
78068
+ setNodeRef(node);
78069
+ if (node)
78070
+ registerCard(id, node);
78071
+ else
78072
+ unregisterCard(id, nodeRef.current);
78073
+ nodeRef.current = node;
78074
+ }, [id, setNodeRef, registerCard, unregisterCard]);
78075
+ return {
78076
+ ref: { card: cardRef, span: spanRef },
78077
+ span,
78078
+ isActive: sortable.activeId === id,
78079
+ dragging: sortable.activeId !== null,
78080
+ label: sortable.cardLabel(id),
78081
+ setActivatorNodeRef,
78082
+ listeners,
78083
+ };
78084
+ }
78085
+ const FLIP_MS = 200;
78086
+ const FLIP_TRANSITION = `transform ${FLIP_MS}ms ease`;
78087
+ // After a move the cards repack and a different card can end up under a still pointer; the pointer must
78088
+ // travel this far before the next move, so cards never rock back and forth on their own.
78089
+ const MOVE_TRAVEL_PX = 32;
78090
+ // Long enough that sweeping past "More filters" doesn't open it.
78091
+ const EXPAND_DELAY_MS = 400;
78092
+ function prefersReducedMotion() {
78093
+ return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
78094
+ }
78095
+ /** Slides each card from where it was (`from`) to where the new layout put it. */
78096
+ function playFlip(nodes, from) {
78097
+ nodes.forEach((node, id) => {
78098
+ const before = from.get(id);
78099
+ if (!before)
78100
+ return;
78101
+ const after = node.getBoundingClientRect();
78102
+ const dx = before.left - after.left;
78103
+ const dy = before.top - after.top;
78104
+ if (!dx && !dy)
78105
+ return;
78106
+ node.style.transition = 'none';
78107
+ node.style.transform = `translate(${dx}px, ${dy}px)`;
78108
+ // Commit the offset before transitioning it away.
78109
+ void node.offsetWidth;
78110
+ node.style.transition = FLIP_TRANSITION;
78111
+ node.style.transform = '';
78112
+ node.addEventListener('transitionend', () => {
78113
+ node.style.transition = '';
78114
+ }, { once: true });
78115
+ });
78116
+ }
78117
+ /** A copy of the card for the drag overlay, without ids that would clash with the original. */
78118
+ function cloneCardBody(card) {
78119
+ const body = card?.querySelector(`[${CARD_BODY_ATTR}]`);
78120
+ if (!body)
78121
+ return null;
78122
+ const clone = body.cloneNode(true);
78123
+ if (!(clone instanceof HTMLElement))
78124
+ return null;
78125
+ clone.removeAttribute(CARD_BODY_ATTR);
78126
+ clone.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
78127
+ return clone;
78128
+ }
78129
+ /**
78130
+ * Card drag-to-reorder across a surface's groups. The live order is a draft: cards reflow as the pointer
78131
+ * crosses them and slide (FLIP) from their old spots, and the draft is handed over on drop. The pointer is
78132
+ * hit-tested against what is on screen, so masonry cards of any height land where they look.
78133
+ */
78134
+ function useFilterCardReorder({ layout, onLayoutChange, }) {
78135
+ const [draft, setDraft] = React.useState(null);
78136
+ const [activeId, setActiveId] = React.useState(null);
78137
+ const [overlay, setOverlay] = React.useState(null);
78138
+ const shown = draft ?? layout;
78139
+ const shownRef = React.useRef(shown);
78140
+ const activeRef = React.useRef(null);
78141
+ const nodesRef = React.useRef(new Map());
78142
+ const spansRef = React.useRef(new Map());
78143
+ const flipFromRef = React.useRef(null);
78144
+ const expandersRef = React.useRef(new Map());
78145
+ React.useLayoutEffect(() => {
78146
+ shownRef.current = shown;
78147
+ }, [shown]);
78148
+ // A new layout from the consumer (usually the one just dropped) replaces the draft.
78149
+ React.useEffect(() => {
78150
+ if (activeRef.current === null)
78151
+ setDraft(null);
78152
+ }, [layout]);
78153
+ const applyDraft = React.useCallback((next) => {
78154
+ if (!prefersReducedMotion()) {
78155
+ const rects = new Map();
78156
+ nodesRef.current.forEach((node, id) => rects.set(id, node.getBoundingClientRect()));
78157
+ flipFromRef.current = rects;
78158
+ }
78159
+ setDraft(next);
78160
+ }, []);
78161
+ React.useLayoutEffect(() => {
78162
+ const from = flipFromRef.current;
78163
+ if (!from)
78164
+ return;
78165
+ flipFromRef.current = null;
78166
+ playFlip(nodesRef.current, from);
78167
+ }, [draft]);
78168
+ useCardDragTracking(activeId, shownRef, applyDraft, expandersRef.current);
78169
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
78170
+ const registerCard = React.useCallback((id, node) => {
78171
+ nodesRef.current.set(id, node);
78172
+ }, []);
78173
+ // A card moving between groups remounts; only forget it if the new node hasn't registered already.
78174
+ const unregisterCard = React.useCallback((id, node) => {
78175
+ if (nodesRef.current.get(id) === node)
78176
+ nodesRef.current.delete(id);
78177
+ }, []);
78178
+ const registerExpander = React.useCallback((toggle, expand) => {
78179
+ const expanders = expandersRef.current;
78180
+ expanders.set(toggle, expand);
78181
+ return () => {
78182
+ expanders.delete(toggle);
78183
+ };
78184
+ }, []);
78185
+ const onDragStart = React.useCallback((event) => {
78186
+ const id = String(event.active.id);
78187
+ activeRef.current = id;
78188
+ setOverlay(cloneCardBody(nodesRef.current.get(id)));
78189
+ setActiveId(id);
78190
+ }, []);
78191
+ const finish = React.useCallback((commit) => {
78192
+ activeRef.current = null;
78193
+ setActiveId(null);
78194
+ setOverlay(null);
78195
+ const next = shownRef.current;
78196
+ if (commit && !isSameLayout(next, layout)) {
78197
+ // The draft stays on screen until the consumer passes the saved layout back.
78198
+ onLayoutChange(next);
78199
+ return;
78200
+ }
78201
+ applyDraft(null);
78202
+ }, [layout, onLayoutChange, applyDraft]);
78203
+ const onDragEnd = React.useCallback(() => finish(true), [finish]);
78204
+ const onDragCancel = React.useCallback(() => finish(false), [finish]);
78205
+ return {
78206
+ layout: shown,
78207
+ activeId,
78208
+ overlay,
78209
+ sensors,
78210
+ spans: spansRef.current,
78211
+ registerCard,
78212
+ unregisterCard,
78213
+ registerExpander,
78214
+ onDragStart,
78215
+ onDragEnd,
78216
+ onDragCancel,
78217
+ };
78218
+ }
78219
+ /**
78220
+ * While a card drags: follows the pointer, moves the card in the draft as it crosses other cards or an
78221
+ * empty group, and opens a collapsed "More filters" the pointer rests on.
78222
+ */
78223
+ function useCardDragTracking(activeId, shownRef, applyDraft, expanders) {
78224
+ React.useEffect(() => {
78225
+ if (activeId === null)
78226
+ return;
78227
+ let point = null;
78228
+ let frame = null;
78229
+ // Cards are mid-slide until then; hit-testing them would chase a moving target.
78230
+ let settledAt = 0;
78231
+ let settleTimer;
78232
+ // The card last moved beside: staying over it does nothing, or two cards of different heights would swap forever.
78233
+ let lastOver = null;
78234
+ // Where the pointer was at the last move.
78235
+ let movedAt = null;
78236
+ const hasTravelled = (x, y) => !movedAt || Math.hypot(x - movedAt.x, y - movedAt.y) >= MOVE_TRAVEL_PX;
78237
+ let expandTimer;
78238
+ let expandToggle = null;
78239
+ const stopExpand = () => {
78240
+ window.clearTimeout(expandTimer);
78241
+ expandToggle = null;
78242
+ };
78243
+ const handleHit = (hit, x, y) => {
78244
+ if (hit?.kind !== 'more')
78245
+ stopExpand();
78246
+ if (!hit)
78247
+ return;
78248
+ const layout = shownRef.current;
78249
+ if (hit.kind === 'more') {
78250
+ if (hit.toggle === expandToggle)
78251
+ return;
78252
+ stopExpand();
78253
+ const expand = expanders.get(hit.toggle);
78254
+ if (!expand || hit.toggle.getAttribute('aria-expanded') !== 'false')
78255
+ return;
78256
+ expandToggle = hit.toggle;
78257
+ // Not a click: the drag swallows clicks until the drop.
78258
+ expandTimer = window.setTimeout(expand, EXPAND_DELAY_MS);
78259
+ return;
78260
+ }
78261
+ if (!hasTravelled(x, y))
78262
+ return;
78263
+ let next;
78264
+ if (hit.kind === 'group') {
78265
+ // Only an empty group takes a drop here; between cards of a full one the pointer is in a gap.
78266
+ if ((layout[hit.group] ?? []).some((id) => id !== activeId))
78267
+ return;
78268
+ if (findCardGroup(layout, activeId) === hit.group)
78269
+ return;
78270
+ next = moveCard(layout, activeId, { group: hit.group });
78271
+ }
78272
+ else {
78273
+ if (hit.id === activeId) {
78274
+ lastOver = null;
78275
+ return;
78276
+ }
78277
+ if (hit.id === lastOver)
78278
+ return;
78279
+ lastOver = hit.id;
78280
+ next = moveCard(layout, activeId, getCardDropTarget(layout, activeId, hit, y));
78281
+ }
78282
+ if (isSameLayout(next, layout))
78283
+ return;
78284
+ settledAt = performance.now() + FLIP_MS;
78285
+ movedAt = { x, y };
78286
+ applyDraft(next);
78287
+ };
78288
+ const check = () => {
78289
+ frame = null;
78290
+ if (!point)
78291
+ return;
78292
+ const wait = settledAt - performance.now();
78293
+ if (wait > 0) {
78294
+ window.clearTimeout(settleTimer);
78295
+ settleTimer = window.setTimeout(schedule, wait);
78296
+ return;
78297
+ }
78298
+ handleHit(getDropHit(point.x, point.y), point.x, point.y);
78299
+ };
78300
+ const schedule = () => {
78301
+ if (frame === null)
78302
+ frame = requestAnimationFrame(check);
78303
+ };
78304
+ const onPointerMove = (event) => {
78305
+ point = { x: event.clientX, y: event.clientY };
78306
+ schedule();
78307
+ };
78308
+ // Escape cancels the drag; marked handled so the surface doesn't also close.
78309
+ const onKeyDown = (event) => {
78310
+ if (event.key === 'Escape')
78311
+ event.preventDefault();
78312
+ };
78313
+ window.addEventListener('pointermove', onPointerMove);
78314
+ // Auto-scroll moves the cards under a still pointer.
78315
+ window.addEventListener('scroll', schedule, true);
78316
+ window.addEventListener('keydown', onKeyDown, true);
78317
+ return () => {
78318
+ window.removeEventListener('pointermove', onPointerMove);
78319
+ window.removeEventListener('scroll', schedule, true);
78320
+ window.removeEventListener('keydown', onKeyDown, true);
78321
+ if (frame !== null)
78322
+ cancelAnimationFrame(frame);
78323
+ window.clearTimeout(settleTimer);
78324
+ stopExpand();
78325
+ };
78326
+ }, [activeId, shownRef, applyDraft, expanders]);
78327
+ }
78328
+ /** The context the sortable groups and cards read. */
78329
+ function useSortableContextValue(reorder, renderCard, cardLabel) {
78330
+ const { layout, activeId, spans, registerCard, unregisterCard, registerExpander, } = reorder;
78331
+ return React.useMemo(() => ({
78332
+ layout,
78333
+ activeId,
78334
+ spans,
78335
+ renderCard,
78336
+ cardLabel: cardLabel ?? ((id) => id),
78337
+ registerCard,
78338
+ unregisterCard,
78339
+ registerExpander,
78340
+ }), [
78341
+ layout,
78342
+ activeId,
78343
+ spans,
78344
+ renderCard,
78345
+ cardLabel,
78346
+ registerCard,
78347
+ unregisterCard,
78348
+ registerExpander,
78349
+ ]);
78350
+ }
77538
78351
 
77539
78352
  const DEFAULT_SKELETON_CARDS = Array.from({ length: SKELETON_CARD_COUNT }, (_, index) => `card-${index}`);
77540
78353
  /** Loading placeholder: the page's own skeleton, or generic list cards. */
@@ -77550,6 +78363,40 @@ function MasonryItem({ children }) {
77550
78363
  const { ref, span } = useMasonrySpan();
77551
78364
  return (jsxRuntime.jsx("div", { style: span > 0 ? { gridRowEnd: `span ${span}` } : undefined, children: jsxRuntime.jsx("div", { ref: ref, className: "pb-3", children: children }) }));
77552
78365
  }
78366
+ /**
78367
+ * A grab tab on the middle of the card's top edge, always visible so the cards read as movable, and clear
78368
+ * of the header's title and actions. It lights up with its card on hover. The only place a card drag starts.
78369
+ */
78370
+ function CardReorderGrip({ label, dragging, setActivatorNodeRef, listeners, }) {
78371
+ return (jsxRuntime.jsx(TooltipProvider, { delayDuration: 500, children: jsxRuntime.jsxs(Tooltip, { open: dragging ? false : undefined, children: [jsxRuntime.jsx(TooltipTrigger, { asChild: true, children: jsxRuntime.jsx("button", { ref: setActivatorNodeRef, type: "button",
78372
+ // Mouse and touch only, like column reorder.
78373
+ tabIndex: -1, "aria-label": getCardGripLabel(label), className: cn(
78374
+ // The ::before pads the tab's hit area beyond what it shows.
78375
+ 'absolute left-1/2 top-0 z-10 flex h-4 w-10 -translate-x-1/2 -translate-y-1/2 cursor-grab touch-none select-none items-center justify-center rounded-full border border-border bg-card text-foreground/50 shadow-sm transition-colors before:absolute before:-inset-2 before:content-[""] active:cursor-grabbing', dragging
78376
+ ? 'pointer-events-none'
78377
+ : 'group-hover/card:border-primary/50 group-hover/card:text-primary group-hover/card:hover:border-primary group-hover/card:hover:bg-primary group-hover/card:hover:text-primary-foreground'), onClick: (event) => event.stopPropagation(), ...listeners,
78378
+ // A grab closes any open dropdown first, so it doesn't stay open over the drag.
78379
+ onPointerDown: (event) => {
78380
+ dismissOpenLayer(event.currentTarget);
78381
+ listeners?.onPointerDown?.(event);
78382
+ }, children: jsxRuntime.jsx(GripHorizontal, { "aria-hidden": "true", className: "size-3.5", strokeWidth: 2.5 }) }) }), jsxRuntime.jsx(TooltipContent, { side: "top", children: CARD_REORDER_HINT })] }) }));
78383
+ }
78384
+ /** A card in a sortable group: draggable from its grip, and a dashed slot where it will land while it drags. */
78385
+ function SortableFilterCard({ id, masonry, children, }) {
78386
+ const { ref, span, isActive, dragging, label, setActivatorNodeRef, listeners, } = useSortableFilterCard(id);
78387
+ return (jsxRuntime.jsx("div", { ref: ref.card, "data-filter-card-id": id, className: "group/card", style: masonry && span > 0 ? { gridRowEnd: `span ${span}` } : undefined, children: jsxRuntime.jsx("div", { ref: masonry ? ref.span : undefined, className: cn(masonry && 'pb-3'), children: jsxRuntime.jsxs("div", { className: cn('relative rounded-xl transition-shadow', !dragging &&
78388
+ 'group-hover/card:ring-2 group-hover/card:ring-primary/20'), children: [jsxRuntime.jsx("div", { "data-filter-card-body": "", className: cn(isActive && 'invisible'), children: children }), isActive ? (jsxRuntime.jsx("div", { className: "absolute inset-0 rounded-xl border-2 border-dashed border-primary/40 bg-primary/5" })) : (jsxRuntime.jsx(CardReorderGrip, { label: label, dragging: dragging, setActivatorNodeRef: setActivatorNodeRef, listeners: listeners }))] }) }) }));
78389
+ }
78390
+ /** Where a group with no cards takes a drop. */
78391
+ function EmptyGroupDropZone({ groupId, }) {
78392
+ return (jsxRuntime.jsx("div", { "data-filter-group-id": groupId, className: "flex min-h-24 items-center justify-center rounded-xl border-2 border-dashed border-border text-sm text-muted-foreground", children: EMPTY_GROUP_LABEL }));
78393
+ }
78394
+ /** The copy of the dragged card under the pointer. */
78395
+ function FilterCardOverlay({ node, }) {
78396
+ return (jsxRuntime.jsxs("div", { "aria-hidden": "true", className: "relative cursor-grabbing rounded-xl shadow-lg ring-2 ring-primary/20", children: [jsxRuntime.jsx("div", { ref: (element) => {
78397
+ element?.replaceChildren(node);
78398
+ } }), jsxRuntime.jsx("span", { className: "absolute left-1/2 top-0 flex h-4 w-10 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-primary bg-primary text-primary-foreground shadow-sm", children: jsxRuntime.jsx(GripHorizontal, { "aria-hidden": "true", className: "size-3.5", strokeWidth: 2.5 }) })] }));
78399
+ }
77553
78400
 
77554
78401
  /** Full-page filter screen (e.g. an advanced search): one sticky header with every action, then the scrolling card grid. */
77555
78402
  function FilterSurface({ title, headerActions, primaryActions, chips, onClose, status = 'ready', errorMessage, onRetry, skeleton, children, }) {
@@ -77558,15 +78405,30 @@ function FilterSurface({ title, headerActions, primaryActions, chips, onClose, s
77558
78405
  const actions = ready ? primaryActions : undefined;
77559
78406
  return (jsxRuntime.jsxs("div", { role: "region", "aria-label": title, className: "flex h-full min-h-0 flex-col bg-background", children: [jsxRuntime.jsxs("header", { className: "sticky top-0 z-20 flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 bg-background px-3 py-2.5", children: [jsxRuntime.jsx(IconButton, { appearance: "ghost", size: "sm", "aria-label": BACK_LABEL, title: BACK_LABEL, onClick: onClose, children: jsxRuntime.jsx(ArrowLeft, {}) }), jsxRuntime.jsx("h1", { className: "sr-only", children: title }), jsxRuntime.jsx("div", { className: "min-w-0 flex-1", children: ready && chips }), jsxRuntime.jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [headerActions, actions] })] }), jsxRuntime.jsx("div", { className: "min-h-0 flex-1 overflow-y-auto px-3 pb-3", children: status === 'loading' ? (jsxRuntime.jsx(FilterSurfaceSkeleton, { children: skeleton })) : status === 'error' ? (jsxRuntime.jsx(FilterSurfaceError, { message: errorMessage ?? DEFAULT_ERROR_MESSAGE, onRetry: onRetry })) : (jsxRuntime.jsx("div", { className: "flex flex-col gap-3", children: children })) })] }));
77560
78407
  }
77561
- function FilterSurfaceGroup({ title, masonry = false, className, children, }) {
77562
- return (jsxRuntime.jsxs("section", { className: "flex flex-col gap-3", children: [title && (jsxRuntime.jsx("h2", { className: "text-xs font-semibold uppercase tracking-wide text-label", children: title })), jsxRuntime.jsx("div", { className: cn('grid grid-cols-1 items-start lg:grid-cols-6', masonry ? MASONRY_GRID_CLASS_NAME : 'gap-3', className), children: masonry
78408
+ /**
78409
+ * Lets the user drag the cards of its `FilterSurfaceGroup`s (those with a `sortableId`) into any order and
78410
+ * between groups, from a grip on each card. A collapsed `FilterSurfaceMore` opens when a card rests on it.
78411
+ */
78412
+ function FilterSurfaceSortable({ layout, onLayoutChange, renderCard, cardLabel, children, }) {
78413
+ const reorder = useFilterCardReorder({ layout, onLayoutChange });
78414
+ const context = useSortableContextValue(reorder, renderCard, cardLabel);
78415
+ return (jsxRuntime.jsxs(DndContext, { sensors: reorder.sensors, onDragStart: reorder.onDragStart, onDragEnd: reorder.onDragEnd, onDragCancel: reorder.onDragCancel, children: [jsxRuntime.jsx(FilterSurfaceSortableContext.Provider, { value: context, children: children }), jsxRuntime.jsx(DragOverlay, { className: "pointer-events-none", dropAnimation: CARD_DROP_ANIMATION, children: reorder.overlay && jsxRuntime.jsx(FilterCardOverlay, { node: reorder.overlay }) })] }));
78416
+ }
78417
+ function FilterSurfaceGroup({ title, masonry = false, sortableId, className, children, }) {
78418
+ const sortable = useFilterSurfaceSortable();
78419
+ const ids = sortable && sortableId ? (sortable.layout[sortableId] ?? []) : null;
78420
+ // Cards keep their own height (no stretching to the row's tallest), so short cards carry no empty space.
78421
+ const gridClassName = cn('grid grid-cols-1 items-start lg:grid-cols-6', masonry ? MASONRY_GRID_CLASS_NAME : 'gap-3', className);
78422
+ return (jsxRuntime.jsxs("section", { className: "flex flex-col gap-3", children: [title && (jsxRuntime.jsx("h2", { className: "text-xs font-semibold uppercase tracking-wide text-label", children: title })), sortable && sortableId && ids ? (ids.length === 0 ? (jsxRuntime.jsx(EmptyGroupDropZone, { groupId: sortableId })) : (
78423
+ // Top room for the first row's grab tabs, which sit on the cards' top edge.
78424
+ jsxRuntime.jsx("div", { className: cn(gridClassName, 'pt-2'), "data-filter-group-id": sortableId, children: ids.map((id) => (jsxRuntime.jsx(SortableFilterCard, { id: id, masonry: masonry, children: sortable.renderCard(id) }, id))) }))) : (jsxRuntime.jsx("div", { className: gridClassName, children: masonry
77563
78425
  ? React.Children.map(children, (child) => child ? jsxRuntime.jsx(MasonryItem, { children: child }) : null)
77564
- : children })] }));
78426
+ : children }))] }));
77565
78427
  }
77566
78428
  /** Collapsed "More filters" divider that reveals extra cards. */
77567
78429
  function FilterSurfaceMore({ label, defaultExpanded, children, }) {
77568
- const { expanded, toggle } = useFilterSurfaceMore(defaultExpanded);
77569
- return (jsxRuntime.jsxs("section", { className: "flex flex-col gap-3", children: [jsxRuntime.jsxs("div", { className: "flex items-center gap-3", children: [jsxRuntime.jsx("span", { className: "h-px flex-1 bg-border" }), jsxRuntime.jsxs("button", { type: "button", "aria-expanded": expanded, onClick: toggle, className: "inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-border bg-card px-3.5 py-1.5 text-xs font-medium text-primary outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-primary/30", children: [label, jsxRuntime.jsx(ChevronDown, { "aria-hidden": "true", className: cn('size-3.5 transition-transform', expanded && 'rotate-180') })] }), jsxRuntime.jsx("span", { className: "h-px flex-1 bg-border" })] }), expanded && children] }));
78430
+ const { expanded, toggle, toggleRef } = useFilterSurfaceMore(defaultExpanded);
78431
+ return (jsxRuntime.jsxs("section", { className: "flex flex-col gap-3", children: [jsxRuntime.jsxs("div", { className: "flex items-center gap-3", children: [jsxRuntime.jsx("span", { className: "h-px flex-1 bg-border" }), jsxRuntime.jsxs("button", { ref: toggleRef, type: "button", "aria-expanded": expanded, onClick: toggle, "data-filter-more-toggle": "", className: "inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-border bg-card px-3.5 py-1.5 text-xs font-medium text-primary outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-primary/30", children: [label, jsxRuntime.jsx(ChevronDown, { "aria-hidden": "true", className: cn('size-3.5 transition-transform', expanded && 'rotate-180') })] }), jsxRuntime.jsx("span", { className: "h-px flex-1 bg-border" })] }), expanded && children] }));
77570
78432
  }
77571
78433
 
77572
78434
  /** Falls back to counting the current page when the caller has no total. */
@@ -80231,6 +81093,7 @@ exports.FilterSelect = FilterSelect;
80231
81093
  exports.FilterSurface = FilterSurface;
80232
81094
  exports.FilterSurfaceGroup = FilterSurfaceGroup;
80233
81095
  exports.FilterSurfaceMore = FilterSurfaceMore;
81096
+ exports.FilterSurfaceSortable = FilterSurfaceSortable;
80234
81097
  exports.FormSectionCard = FormSectionCard;
80235
81098
  exports.FormSectionCardSkeleton = FormSectionCardSkeleton;
80236
81099
  exports.GEM_AUDIT_CONFIG = GEM_AUDIT_CONFIG;