cortena-ui 1.2.0 → 1.3.1

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.
@@ -4,7 +4,9 @@ import {
4
4
  DndContext,
5
5
  DragOverlay,
6
6
  KeyboardSensor,
7
+ MouseSensor,
7
8
  PointerSensor,
9
+ TouchSensor,
8
10
  closestCenter,
9
11
  useSensor,
10
12
  useSensors,
@@ -40,8 +42,29 @@ import { cn } from "@/lib/cn";
40
42
  * By default the whole row is the drag activator. Pass `handle` to restrict
41
43
  * dragging to a `<SortableHandle>` (or any element given `handleProps`) so
42
44
  * buttons and inputs inside a row stay clickable.
45
+ *
46
+ * ## Touch
47
+ *
48
+ * A pointer drag and a touch scroll start the same way, so something has to
49
+ * say which one a finger means. In handle mode only the small handle is
50
+ * `touch-none`, so the rest of the row scrolls: nothing to decide. In
51
+ * whole-row mode the whole row is `touch-none`, which is why a touch list
52
+ * whose rows are the activators cannot be scrolled by dragging over them.
53
+ *
54
+ * `touchScroll` is the way out, and it defaults to whichever of those is
55
+ * already true — `true` in handle mode, `false` in whole-row mode, so existing
56
+ * lists behave exactly as before. Setting it `true` on a whole-row list drops
57
+ * `touch-none` and swaps the PointerSensor for a MouseSensor plus a
58
+ * TouchSensor with delay activation: a quick swipe scrolls, a press held for
59
+ * {@link TOUCH_DELAY_MS} picks the row up. `false` in handle mode is the
60
+ * opposite trade — the row never scrolls under a finger.
43
61
  */
44
62
 
63
+ /** How long a finger must rest on a row before it becomes a drag. */
64
+ const TOUCH_DELAY_MS = 250;
65
+ /** How far it may slip in that time and still count as a press, not a scroll. */
66
+ const TOUCH_TOLERANCE_PX = 8;
67
+
45
68
  /** Spread onto the element that should start a drag. */
46
69
  export interface SortableHandleProps extends Partial<DraggableAttributes> {
47
70
  ref: (element: HTMLElement | null) => void;
@@ -63,17 +86,40 @@ export interface SortableRenderState {
63
86
  isDragging: boolean;
64
87
  }
65
88
 
89
+ /** What moved where, passed to `onReorder` alongside the reordered array. */
90
+ export interface SortableReorderDetails {
91
+ /** Index the item came from, in the array as it was passed in. */
92
+ from: number;
93
+ /** Index it landed on. */
94
+ to: number;
95
+ /** Id of the item that moved (`getId` of the dragged item). */
96
+ activeId: UniqueIdentifier;
97
+ /** Id of the item it was dropped onto. */
98
+ overId: UniqueIdentifier;
99
+ }
100
+
66
101
  export interface SortableListProps<T> extends Omit<React.ComponentProps<"ul">, "children"> {
67
102
  items: readonly T[];
68
103
  /** Stable id per item; must be unique within the list. */
69
104
  getId: (item: T) => UniqueIdentifier;
70
- /** Called with the reordered array after a successful drop. */
71
- onReorder: (next: T[]) => void;
105
+ /**
106
+ * Called with the reordered array after a successful drop, and with the move
107
+ * itself — `{ from, to, activeId, overId }` — for a server that persists a
108
+ * position rather than the whole list.
109
+ */
110
+ onReorder: (next: T[], details: SortableReorderDetails) => void;
72
111
  renderItem: (item: T, state: SortableRenderState) => React.ReactNode;
73
112
  /** Layout axis; also selects the sorting strategy. */
74
113
  orientation?: "vertical" | "horizontal";
75
114
  /** Only a `<SortableHandle>` (or an element given `handleProps`) starts a drag. */
76
115
  handle?: boolean;
116
+ /**
117
+ * Let a finger scroll the page over the rows. Defaults to `handle`: `true`
118
+ * in handle mode, `false` in whole-row mode, which is what each already did.
119
+ * `true` on a whole-row list drops `touch-none` and uses a TouchSensor with
120
+ * delay activation, so a swipe scrolls and a held press drags.
121
+ */
122
+ touchScroll?: boolean;
77
123
  /** Disables every sensor and hides the handles. */
78
124
  disabled?: boolean;
79
125
  /** Class name for each item wrapper. */
@@ -106,11 +152,12 @@ interface SortableItemProps<T> {
106
152
  item: T;
107
153
  handle: boolean;
108
154
  disabled: boolean;
155
+ touchScroll: boolean;
109
156
  renderItem: SortableListProps<T>["renderItem"];
110
157
  className: string | undefined;
111
158
  }
112
159
 
113
- function SortableItem<T>({ id, item, handle, disabled, renderItem, className }: SortableItemProps<T>) {
160
+ function SortableItem<T>({ id, item, handle, disabled, touchScroll, renderItem, className }: SortableItemProps<T>) {
114
161
  const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } =
115
162
  useSortable({ id, disabled });
116
163
 
@@ -134,7 +181,9 @@ function SortableItem<T>({ id, item, handle, disabled, renderItem, className }:
134
181
  style={{ transform: CSS.Translate.toString(transform), transition }}
135
182
  className={cn(
136
183
  itemClassName,
137
- !handle && !disabled && "cursor-grab touch-none data-[dragging]:cursor-grabbing",
184
+ !handle && !disabled && "cursor-grab data-[dragging]:cursor-grabbing",
185
+ // touch-action is what decides whether a finger scrolls or drags.
186
+ !touchScroll && !disabled && "touch-none",
138
187
  className,
139
188
  )}
140
189
  {...rowActivator}
@@ -184,15 +233,27 @@ function SortableList<T>({
184
233
  orientation = "vertical",
185
234
  handle = false,
186
235
  disabled = false,
236
+ touchScroll = handle,
187
237
  itemClassName: itemClass,
188
238
  className,
189
239
  ...props
190
240
  }: SortableListProps<T>) {
191
241
  const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null);
192
- const sensors = useSensors(
193
- useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
194
- useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
195
- );
242
+ // Only a whole-row list needs the split sensors: in handle mode the row is
243
+ // already scrollable because only the handle is touch-none, and swapping
244
+ // sensors there would put a 250 ms delay on the handle for no gain.
245
+ const delayTouch = touchScroll && !handle;
246
+ // Every sensor is created on every render (they are hooks); which ones are
247
+ // handed to DndContext is what changes.
248
+ const pointerSensor = useSensor(PointerSensor, { activationConstraint: { distance: 4 } });
249
+ const mouseSensor = useSensor(MouseSensor, { activationConstraint: { distance: 4 } });
250
+ const touchSensor = useSensor(TouchSensor, {
251
+ activationConstraint: { delay: TOUCH_DELAY_MS, tolerance: TOUCH_TOLERANCE_PX },
252
+ });
253
+ const keyboardSensor = useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates });
254
+ const pointerSensors = useSensors(pointerSensor, keyboardSensor);
255
+ const splitSensors = useSensors(mouseSensor, touchSensor, keyboardSensor);
256
+ const sensors = delayTouch ? splitSensors : pointerSensors;
196
257
  const ids = React.useMemo(() => items.map(getId), [items, getId]);
197
258
  const activeItem = activeId === null ? undefined : items[ids.indexOf(activeId)];
198
259
 
@@ -207,7 +268,12 @@ function SortableList<T>({
207
268
  const from = ids.indexOf(active.id);
208
269
  const to = ids.indexOf(over.id);
209
270
  if (from < 0 || to < 0) return;
210
- onReorder(arrayMove([...items], from, to));
271
+ onReorder(arrayMove([...items], from, to), {
272
+ from,
273
+ to,
274
+ activeId: active.id,
275
+ overId: over.id,
276
+ });
211
277
  },
212
278
  [ids, items, onReorder],
213
279
  );
@@ -230,6 +296,7 @@ function SortableList<T>({
230
296
  data-slot="sortable-list"
231
297
  data-orientation={orientation}
232
298
  data-disabled={disabled || undefined}
299
+ data-touch-scroll={touchScroll ? "" : undefined}
233
300
  className={cn(
234
301
  "flex list-none gap-2 p-0",
235
302
  orientation === "horizontal" ? "flex-row flex-wrap" : "flex-col",
@@ -244,6 +311,7 @@ function SortableList<T>({
244
311
  item={item}
245
312
  handle={handle}
246
313
  disabled={disabled}
314
+ touchScroll={touchScroll}
247
315
  renderItem={renderItem}
248
316
  className={itemClass}
249
317
  />
@@ -6,21 +6,38 @@ import { cn } from "@/lib/cn";
6
6
  export interface SpinnerProps extends React.ComponentProps<"span"> {
7
7
  /** Diameter in px. Defaults to 16. */
8
8
  size?: number;
9
+ /**
10
+ * `inverse` for a spinner on a filled surface — inside a primary or
11
+ * destructive Button, on a toast, on any `-foreground` pairing. The default
12
+ * ring is `--ds-border` with a `--ds-primary` leading edge, which all but
13
+ * disappears on a primary fill; `inverse` draws both from
14
+ * `--ds-primary-foreground` instead.
15
+ */
16
+ tone?: "default" | "inverse";
9
17
  }
10
18
 
11
19
  /**
12
- * Spinner — a ring with a primary-coloured leading edge. Announces itself as
20
+ * Spinner — a ring with a coloured leading edge. Announces itself as
13
21
  * `role="status"` with a "Loading" label; pass `aria-label` to override.
22
+ *
23
+ * ```tsx
24
+ * <Button disabled>
25
+ * <Spinner tone="inverse" /> Saving…
26
+ * </Button>
27
+ * ```
14
28
  */
15
- function Spinner({ className, size = 16, style, ...props }: SpinnerProps) {
29
+ function Spinner({ className, size = 16, tone = "default", style, ...props }: SpinnerProps) {
16
30
  return (
17
31
  <span
18
32
  data-slot="spinner"
33
+ data-tone={tone}
19
34
  role="status"
20
35
  aria-label="Loading"
21
36
  className={cn(
22
- "inline-block shrink-0 animate-spin rounded-[var(--ds-radius-full)]",
23
- "border-2 border-[var(--ds-border)] border-t-[var(--ds-primary)]",
37
+ "inline-block shrink-0 animate-spin rounded-[var(--ds-radius-full)] border-2",
38
+ tone === "inverse"
39
+ ? "border-[var(--ds-primary-foreground)]/35 border-t-[var(--ds-primary-foreground)]"
40
+ : "border-[var(--ds-border)] border-t-[var(--ds-primary)]",
24
41
  className,
25
42
  )}
26
43
  style={{ width: size, height: size, ...style }}
@@ -3,12 +3,25 @@
3
3
  import type * as React from "react";
4
4
  import { cn } from "@/lib/cn";
5
5
 
6
- type StatusDotTone = "accent" | "success" | "warning" | "danger" | "info" | "neutral";
6
+ /**
7
+ * The error tone is `destructive`, the same spelling Button, Alert and Badge
8
+ * use. `danger` is a deprecated alias of it and paints the same.
9
+ */
10
+ type StatusDotTone =
11
+ | "accent"
12
+ | "success"
13
+ | "warning"
14
+ | "destructive"
15
+ /** @deprecated Use `destructive`; this alias paints the same. */
16
+ | "danger"
17
+ | "info"
18
+ | "neutral";
7
19
 
8
20
  const TONE_CLASS: Record<StatusDotTone, string> = {
9
21
  accent: "bg-[var(--ds-primary)]",
10
22
  success: "bg-[var(--ds-success)]",
11
23
  warning: "bg-[var(--ds-warning)]",
24
+ destructive: "bg-[var(--ds-destructive)]",
12
25
  danger: "bg-[var(--ds-destructive)]",
13
26
  info: "bg-[var(--ds-info)]",
14
27
  neutral: "bg-[var(--ds-text-tertiary)]",
package/src/index.ts CHANGED
@@ -11,6 +11,8 @@ export type { AlertProps } from "@/components/alert";
11
11
  export { Avatar, AvatarFallback, AvatarImage } from "@/components/avatar";
12
12
  export { Badge, badgeVariants } from "@/components/badge";
13
13
  export type { BadgeProps } from "@/components/badge";
14
+ export { Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/breadcrumb";
15
+ export type { BreadcrumbLinkProps } from "@/components/breadcrumb";
14
16
  export { ButtonLink } from "@/components/button-link";
15
17
  export type { ButtonLinkProps } from "@/components/button-link";
16
18
  export { Button, buttonVariants } from "@/components/button";
@@ -69,14 +71,14 @@ export type { SectionCardProps } from "@/components/section-card";
69
71
  export { Segmented } from "@/components/segmented";
70
72
  export type { SegmentedOption, SegmentedProps } from "@/components/segmented";
71
73
  export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@/components/select";
72
- export type { SelectContentProps, SelectTriggerProps } from "@/components/select";
74
+ export type { SelectContentProps, SelectProps, SelectTriggerProps } from "@/components/select";
73
75
  export { Separator } from "@/components/separator";
74
76
  export type { SeparatorProps } from "@/components/separator";
75
77
  export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, sheetVariants } from "@/components/sheet";
76
- export type { SheetContentProps } from "@/components/sheet";
78
+ export type { SheetContentProps, SheetProps } from "@/components/sheet";
77
79
  export { Skeleton } from "@/components/skeleton";
78
80
  export { SortableHandle, SortableList, arrayMove } from "@/components/sortable-list";
79
- export type { SortableHandleProps, SortableListProps, SortableRenderState } from "@/components/sortable-list";
81
+ export type { SortableHandleProps, SortableListProps, SortableRenderState, SortableReorderDetails } from "@/components/sortable-list";
80
82
  export { Spinner } from "@/components/spinner";
81
83
  export type { SpinnerProps } from "@/components/spinner";
82
84
  export { StatusDot } from "@/components/status-dot";
@@ -0,0 +1,39 @@
1
+ import type * as React from "react";
2
+
3
+ /**
4
+ * The `render` prop for the components that are plain markup rather than Base
5
+ * UI primitives (Card, SectionCard, ButtonLink, BreadcrumbLink).
6
+ *
7
+ * Base UI parts take their own `render` and need nothing from here. The rest
8
+ * are a `<div>` or an `<a>` this package writes itself, and a consumer
9
+ * sometimes needs a different element under the same styling: a Card that is
10
+ * the page's `<aside>` landmark, or a link that is the router's `<Link>`.
11
+ * Passing an element rather than a component keeps the call site readable and
12
+ * lets the caller set props on it (`render={<Link to="/x" />}`).
13
+ *
14
+ * `props` wins over the passed element's own props, so the `className` and
15
+ * handlers a component computed are not silently dropped, while the element's
16
+ * own props (`href`, `to`, `aria-labelledby`) come through untouched. What a
17
+ * component lets a *caller* override is decided by the order it builds `props`
18
+ * in, not here. Children are `children`, falling back to the element's.
19
+ */
20
+ export type RenderProp = React.ReactElement<Record<string, unknown>>;
21
+
22
+ export function renderWith(
23
+ render: RenderProp | undefined,
24
+ props: Record<string, unknown>,
25
+ children: React.ReactNode,
26
+ Fallback: React.ElementType,
27
+ ): React.ReactElement {
28
+ if (!render) {
29
+ const Element = Fallback;
30
+ return <Element {...props}>{children}</Element>;
31
+ }
32
+ const Element = render.type as React.ElementType;
33
+ const given = render.props as { children?: React.ReactNode };
34
+ return (
35
+ <Element {...(render.props as Record<string, unknown>)} {...props}>
36
+ {children ?? given.children}
37
+ </Element>
38
+ );
39
+ }