atlas-react-kit 0.13.0 → 1.1.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.
Files changed (103) hide show
  1. package/COMPONENTS.md +144 -120
  2. package/DESIGN_RULES.md +53 -32
  3. package/README.md +12 -53
  4. package/dist/accordion.d.ts +1 -0
  5. package/dist/avatar.d.ts +1 -0
  6. package/dist/badge.d.ts +2 -3
  7. package/dist/breadcrumb.d.ts +2 -3
  8. package/dist/button.d.ts +2 -3
  9. package/dist/calendar.d.ts +2 -0
  10. package/dist/card.d.ts +3 -0
  11. package/dist/chart.d.ts +152 -0
  12. package/dist/chart.js +177 -0
  13. package/dist/color-legend.d.ts +8 -0
  14. package/dist/combobox.d.ts +13 -4
  15. package/dist/combobox.js +1 -8
  16. package/dist/date-picker.d.ts +26 -3
  17. package/dist/date-picker.js +37 -31
  18. package/dist/date-time-picker.d.ts +5 -2
  19. package/dist/date-time-picker.js +25 -38
  20. package/dist/dialog.d.ts +1 -0
  21. package/dist/dialog.js +4 -2
  22. package/dist/dropdown-menu.d.ts +9 -8
  23. package/dist/empty-panel.d.ts +8 -2
  24. package/dist/empty-panel.js +2 -2
  25. package/dist/error-boundary.d.ts +6 -1
  26. package/dist/field.d.ts +3 -0
  27. package/dist/field.js +1 -1
  28. package/dist/form/checkbox-field.d.ts +45 -0
  29. package/dist/form/checkbox-field.js +39 -0
  30. package/dist/form/combobox-field.d.ts +13 -0
  31. package/dist/form/combobox-field.js +14 -0
  32. package/dist/form/control-field.d.ts +6 -7
  33. package/dist/form/date-picker-field.d.ts +12 -21
  34. package/dist/form/date-picker-field.js +5 -4
  35. package/dist/form/date-time-picker-field.d.ts +7 -22
  36. package/dist/form/date-time-picker-field.js +4 -3
  37. package/dist/form/feedback-field.d.ts +2 -0
  38. package/dist/form/field-error.d.ts +35 -3
  39. package/dist/form/field-shell.d.ts +35 -6
  40. package/dist/form/field-shell.js +1 -1
  41. package/dist/form/focus-first-invalid.d.ts +28 -0
  42. package/dist/form/focus-first-invalid.js +29 -0
  43. package/dist/form/markdown-field.d.ts +4 -16
  44. package/dist/form/select-field.d.ts +13 -7
  45. package/dist/form/select-field.js +3 -2
  46. package/dist/form/text-field.d.ts +29 -10
  47. package/dist/form/text-field.js +4 -0
  48. package/dist/form/toggle-group-field.d.ts +30 -0
  49. package/dist/form/toggle-group-field.js +19 -0
  50. package/dist/info-tip.d.ts +10 -1
  51. package/dist/internal/as-child.d.ts +8 -0
  52. package/dist/internal/as-child.js +1 -0
  53. package/dist/internal/form-control.d.ts +3 -2
  54. package/dist/internal/form-control.js +3 -2
  55. package/dist/internal/kanban-layout.d.ts +55 -0
  56. package/dist/internal/kanban-layout.js +80 -0
  57. package/dist/internal/kanban-plugins.d.ts +17 -0
  58. package/dist/internal/kanban-plugins.js +17 -0
  59. package/dist/internal/listbox-navigation.d.ts +0 -11
  60. package/dist/internal/listbox-navigation.js +2 -2
  61. package/dist/internal/listbox-option.d.ts +0 -15
  62. package/dist/internal/listbox-option.js +1 -1
  63. package/dist/internal/use-drag-scroll.d.ts +17 -0
  64. package/dist/internal/use-drag-scroll.js +98 -0
  65. package/dist/internal/use-media-query.d.ts +4 -2
  66. package/dist/internal/use-media-query.js +11 -15
  67. package/dist/kanban.d.ts +84 -0
  68. package/dist/kanban.js +197 -0
  69. package/dist/markdown.d.ts +2 -0
  70. package/dist/meter.d.ts +7 -0
  71. package/dist/nav-link.d.ts +15 -6
  72. package/dist/nav-link.js +14 -4
  73. package/dist/next/nav-link.d.ts +6 -3
  74. package/dist/next/nav-link.js +8 -6
  75. package/dist/next/period-stepper.d.ts +11 -0
  76. package/dist/next/period-stepper.js +16 -0
  77. package/dist/next/tabs-nav-link.d.ts +6 -0
  78. package/dist/next/tabs-nav-link.js +8 -3
  79. package/dist/page-header.d.ts +19 -0
  80. package/dist/page-header.js +11 -0
  81. package/dist/pagination.d.ts +2 -0
  82. package/dist/period-stepper.d.ts +32 -0
  83. package/dist/period-stepper.js +27 -0
  84. package/dist/picker-shell.d.ts +33 -18
  85. package/dist/picker-shell.js +12 -10
  86. package/dist/progress.d.ts +1 -0
  87. package/dist/search-picker.d.ts +12 -0
  88. package/dist/select.d.ts +1 -0
  89. package/dist/skip-link.d.ts +7 -0
  90. package/dist/skip-link.js +10 -0
  91. package/dist/slider.d.ts +11 -0
  92. package/dist/sortable-table-head.d.ts +2 -0
  93. package/dist/stat-tile.d.ts +5 -0
  94. package/dist/table-body-skeleton.d.ts +2 -0
  95. package/dist/table.d.ts +1 -0
  96. package/dist/tabs.d.ts +27 -12
  97. package/dist/tabs.js +23 -10
  98. package/dist/toast.d.ts +7 -1
  99. package/dist/toggle-group.d.ts +9 -2
  100. package/dist/toggle-group.js +2 -1
  101. package/package.json +72 -6
  102. package/skills/atlas-react-kit/SKILL.md +46 -40
  103. package/theme.css +13 -0
@@ -0,0 +1,19 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useId } from "react";
4
+ import { ToggleGroup, ToggleGroupItem } from "../toggle-group.js";
5
+ import { describedBy, descriptionIdFor, FormFieldShell, invalidWhen, labelIdFor, metaErrorText, } from "./field-shell.js";
6
+ /**
7
+ * One choice from a few as a joined outline toggle group (a segmented
8
+ * control), with a visible label and the `form/*` error wiring.
9
+ */
10
+ export function FormToggleGroupField({ label, description, value, onValueChange, options, meta, submitted, showError, required, disabled, size = "sm", className, fieldClassName, }) {
11
+ const id = useId();
12
+ const errorText = metaErrorText(meta, { submitted, showError });
13
+ function handleValueChange(next) {
14
+ const option = options.find((o) => o.value === next);
15
+ if (option)
16
+ onValueChange(option.value);
17
+ }
18
+ return (_jsx(FormFieldShell, { id: id, label: label, labelFor: false, description: description, errorText: errorText, required: required, className: fieldClassName, children: _jsx(ToggleGroup, { id: id, type: "single", value: value, onValueChange: handleValueChange, variant: "outline", size: size, spacing: 0, disabled: disabled, "aria-labelledby": labelIdFor(id), "aria-invalid": invalidWhen(errorText), "aria-describedby": describedBy(id, errorText, description ? descriptionIdFor(id) : undefined), className: className, children: options.map((option) => (_jsx(ToggleGroupItem, { value: option.value, children: option.label }, option.value))) }) }));
19
+ }
@@ -4,15 +4,24 @@ export type InfoTipLayout = "box" | "inline";
4
4
  export type InfoTipProps = {
5
5
  /** Required name for the icon button (the glyph has no visible text). */
6
6
  label: string;
7
+ /** Panel heading; also names the panel. Without it the panel uses `label`. */
7
8
  title?: string;
9
+ /** Muted text under the title. */
8
10
  description?: React.ReactNode;
9
11
  /** Extra body after the title and description. */
10
12
  children?: React.ReactNode;
11
- /** `inline` keeps the surrounding line height; the 44px hit area overflows. */
13
+ /**
14
+ * `inline` keeps the surrounding line height; the 44px hit area overflows.
15
+ * Default `box`.
16
+ */
12
17
  layout?: InfoTipLayout;
18
+ /** Panel side. Default `top`; Radix still flips on collision. */
13
19
  side?: React.ComponentProps<typeof PopoverContent>["side"];
20
+ /** Panel alignment against the trigger. Default `center`. */
14
21
  align?: React.ComponentProps<typeof PopoverContent>["align"];
22
+ /** Shown beside the title, e.g. a `Badge`. */
15
23
  badge?: React.ReactNode;
24
+ /** Replaces the default question-mark glyph. */
16
25
  icon?: React.ReactNode;
17
26
  /** Classes for the trigger button. */
18
27
  className?: string;
@@ -0,0 +1,8 @@
1
+ /** Not a public entry. The Slot prop that Button, Badge, and the links share. */
2
+ export type AsChildProps = {
3
+ /**
4
+ * Render the single child (e.g. a router `Link`) with these styles instead
5
+ * of the default element. Default `false`.
6
+ */
7
+ asChild?: boolean;
8
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -9,9 +9,10 @@
9
9
  export declare const formControlTriggerClassName = "h-10 w-full border-input bg-background px-3 font-normal text-base tracking-normal shadow-xs normal-case md:text-sm hover:border-input hover:bg-background hover:text-foreground focus-visible:text-foreground aria-expanded:border-input aria-expanded:bg-background aria-expanded:text-foreground has-data-[icon=inline-start]:pl-3 has-data-[icon=inline-end]:pr-3 dark:hover:bg-background dark:aria-expanded:bg-background";
10
10
  /**
11
11
  * Date and date-time picker trigger: the form-control chrome, start-aligned,
12
- * with muted text while empty (`data-empty`).
12
+ * with muted text while empty (`data-empty`). Intrinsic padding makes one
13
+ * line 40px tall while longer values wrap and consumer height classes work.
13
14
  */
14
- export declare const pickerTriggerClassName = "h-10 w-full border-input bg-background px-3 font-normal text-base tracking-normal shadow-xs normal-case md:text-sm hover:border-input hover:bg-background hover:text-foreground focus-visible:text-foreground aria-expanded:border-input aria-expanded:bg-background aria-expanded:text-foreground has-data-[icon=inline-start]:pl-3 has-data-[icon=inline-end]:pr-3 dark:hover:bg-background dark:aria-expanded:bg-background justify-start data-[empty=true]:text-muted-foreground";
15
+ export declare const pickerTriggerClassName = "h-10 w-full border-input bg-background px-3 font-normal text-base tracking-normal shadow-xs normal-case md:text-sm hover:border-input hover:bg-background hover:text-foreground focus-visible:text-foreground aria-expanded:border-input aria-expanded:bg-background aria-expanded:text-foreground has-data-[icon=inline-start]:pl-3 has-data-[icon=inline-end]:pr-3 dark:hover:bg-background dark:aria-expanded:bg-background h-auto justify-start py-[calc((--spacing(10)_-_1lh_-_2px)/2)] data-[empty=true]:text-muted-foreground";
15
16
  /** Calendar popover body: fits the viewport height and scrolls inside it. */
16
17
  export declare const pickerContentClassName = "max-h-(--radix-popover-content-available-height) overflow-y-auto rounded-none p-0";
17
18
  /** Gutter used when a calendar popover collides with the viewport. */
@@ -9,9 +9,10 @@
9
9
  export const formControlTriggerClassName = "h-10 w-full border-input bg-background px-3 font-normal text-base tracking-normal shadow-xs normal-case md:text-sm hover:border-input hover:bg-background hover:text-foreground focus-visible:text-foreground aria-expanded:border-input aria-expanded:bg-background aria-expanded:text-foreground has-data-[icon=inline-start]:pl-3 has-data-[icon=inline-end]:pr-3 dark:hover:bg-background dark:aria-expanded:bg-background";
10
10
  /**
11
11
  * Date and date-time picker trigger: the form-control chrome, start-aligned,
12
- * with muted text while empty (`data-empty`).
12
+ * with muted text while empty (`data-empty`). Intrinsic padding makes one
13
+ * line 40px tall while longer values wrap and consumer height classes work.
13
14
  */
14
- export const pickerTriggerClassName = `${formControlTriggerClassName} justify-start data-[empty=true]:text-muted-foreground`;
15
+ export const pickerTriggerClassName = `${formControlTriggerClassName} h-auto justify-start py-[calc((--spacing(10)_-_1lh_-_2px)/2)] data-[empty=true]:text-muted-foreground`;
15
16
  /** Calendar popover body: fits the viewport height and scrolls inside it. */
16
17
  export const pickerContentClassName = "max-h-(--radix-popover-content-available-height) overflow-y-auto rounded-none p-0";
17
18
  /** Gutter used when a calendar popover collides with the viewport. */
@@ -0,0 +1,55 @@
1
+ /** Column id → ordered card ids, derived from the consumer's `items`. */
2
+ export type KanbanGroups = Record<string, string[]>;
3
+ /** Drop zone ids for the drag-time action rail. */
4
+ export declare const KANBAN_COMPLETE_ID = "__kanban_complete__";
5
+ export declare const KANBAN_DELETE_ID = "__kanban_delete__";
6
+ export declare function buildKanbanGroups<T extends {
7
+ id: string;
8
+ }>(columns: {
9
+ id: string;
10
+ }[], items: T[], getColumnId: (item: T) => string): KanbanGroups;
11
+ /** What dnd-kit reports at drag end, reduced to plain ids. */
12
+ export type KanbanDropInput = {
13
+ itemId: string;
14
+ /** Droppable under the pointer: a column, a card, or a rail zone. */
15
+ targetId: string | null;
16
+ /** The target card's column, when the target is a card. */
17
+ targetGroup: string | null;
18
+ /** Column the card started in. */
19
+ initialGroup: string | null;
20
+ /** Column the card is over now. */
21
+ currentGroup: string | null;
22
+ /** Sortable index in the target column. */
23
+ index: number | undefined;
24
+ };
25
+ export type KanbanDrop = {
26
+ kind: "complete";
27
+ itemId: string;
28
+ } | {
29
+ kind: "delete";
30
+ itemId: string;
31
+ } | {
32
+ kind: "move";
33
+ itemId: string;
34
+ toColumnId: string;
35
+ beforeId: string | null;
36
+ afterId: string | null;
37
+ };
38
+ /**
39
+ * Turn a drop into a complete, delete, or move with the neighbours at the
40
+ * insertion slot. `null` when the drop changes nothing.
41
+ */
42
+ export declare function resolveKanbanDrop(input: KanbanDropInput, groups: KanbanGroups): KanbanDrop | null;
43
+ export type KanbanOverflowEdges = {
44
+ start: boolean;
45
+ end: boolean;
46
+ };
47
+ /** Whether a horizontal scroller has more content past either edge. */
48
+ export declare function kanbanOverflowEdges(scrollLeft: number, clientWidth: number, scrollWidth: number): KanbanOverflowEdges;
49
+ /** One "page" of columns: most of the viewport, at least one narrow column. */
50
+ export declare function kanbanScrollStepPx(clientWidth: number): number;
51
+ /** Smooth-scroll toward the start (`-1`) or end (`1`); no-op without a scroller. */
52
+ export declare function kanbanScrollBy(scroller: {
53
+ clientWidth: number;
54
+ scrollBy: (options: ScrollToOptions) => void;
55
+ } | null, direction: -1 | 1): void;
@@ -0,0 +1,80 @@
1
+ /** Drop zone ids for the drag-time action rail. */
2
+ export const KANBAN_COMPLETE_ID = "__kanban_complete__";
3
+ export const KANBAN_DELETE_ID = "__kanban_delete__";
4
+ export function buildKanbanGroups(columns, items, getColumnId) {
5
+ const groups = {};
6
+ for (const column of columns)
7
+ groups[column.id] = [];
8
+ for (const item of items) {
9
+ const columnId = getColumnId(item);
10
+ const group = groups[columnId];
11
+ if (!group) {
12
+ console.warn(`[KanbanBoard] item "${item.id}" maps to unknown column "${columnId}" and is not rendered`);
13
+ continue;
14
+ }
15
+ group.push(item.id);
16
+ }
17
+ return groups;
18
+ }
19
+ function targetColumn(input, groups) {
20
+ const { targetId, targetGroup, currentGroup } = input;
21
+ if (targetId != null) {
22
+ if (targetId in groups)
23
+ return targetId;
24
+ if (targetGroup != null)
25
+ return targetGroup;
26
+ for (const [columnId, ids] of Object.entries(groups)) {
27
+ if (ids.includes(targetId))
28
+ return columnId;
29
+ }
30
+ }
31
+ return currentGroup;
32
+ }
33
+ /**
34
+ * Turn a drop into a complete, delete, or move with the neighbours at the
35
+ * insertion slot. `null` when the drop changes nothing.
36
+ */
37
+ export function resolveKanbanDrop(input, groups) {
38
+ const { itemId, targetId, initialGroup, index: rawIndex } = input;
39
+ if (targetId === KANBAN_COMPLETE_ID)
40
+ return { kind: "complete", itemId };
41
+ if (targetId === KANBAN_DELETE_ID)
42
+ return { kind: "delete", itemId };
43
+ const toColumnId = targetColumn(input, groups);
44
+ if (!initialGroup || !toColumnId)
45
+ return null;
46
+ const columnItems = groups[toColumnId] ?? [];
47
+ const remaining = columnItems.filter((id) => id !== itemId);
48
+ const index = Math.max(0, Math.min(rawIndex ?? remaining.length, remaining.length));
49
+ if (initialGroup === toColumnId && index === columnItems.indexOf(itemId)) {
50
+ return null;
51
+ }
52
+ return {
53
+ kind: "move",
54
+ itemId,
55
+ toColumnId,
56
+ beforeId: index > 0 ? (remaining[index - 1] ?? null) : null,
57
+ afterId: remaining[index] ?? null,
58
+ };
59
+ }
60
+ /** Pixel slop so sub-pixel scrollWidth vs clientWidth does not flicker cues. */
61
+ const OVERFLOW_EDGE_PX = 8;
62
+ /** Whether a horizontal scroller has more content past either edge. */
63
+ export function kanbanOverflowEdges(scrollLeft, clientWidth, scrollWidth) {
64
+ const maxScroll = Math.max(0, scrollWidth - clientWidth);
65
+ return {
66
+ start: scrollLeft > OVERFLOW_EDGE_PX,
67
+ end: scrollLeft < maxScroll - OVERFLOW_EDGE_PX,
68
+ };
69
+ }
70
+ /** One "page" of columns: most of the viewport, at least one narrow column. */
71
+ export function kanbanScrollStepPx(clientWidth) {
72
+ return Math.max(140, Math.round(clientWidth * 0.75));
73
+ }
74
+ /** Smooth-scroll toward the start (`-1`) or end (`1`); no-op without a scroller. */
75
+ export function kanbanScrollBy(scroller, direction) {
76
+ scroller?.scrollBy({
77
+ left: direction * kanbanScrollStepPx(scroller.clientWidth),
78
+ behavior: "smooth",
79
+ });
80
+ }
@@ -0,0 +1,17 @@
1
+ import type { ComponentProps } from "react";
2
+ import type { DragDropProvider } from "@dnd-kit/react";
3
+ type PluginsProp = NonNullable<ComponentProps<typeof DragDropProvider>["plugins"]>;
4
+ /**
5
+ * Board plugins. AutoScroller stays (edge scroll while dragging). Feedback
6
+ * stays because it positions and sizes `DragOverlay`; `feedback: "none"`
7
+ * would leave the overlay behind. The drop animation is off so it does not
8
+ * snap twice against an optimistic update.
9
+ */
10
+ export declare const configureKanbanPlugins: Extract<PluginsProp, (...args: never[]) => unknown>;
11
+ /**
12
+ * Cards leave out OptimisticSortingPlugin: it moves DOM nodes under React and
13
+ * throws `removeChild` on re-render (dnd-kit #1747). Column layout comes from
14
+ * the consumer's `items`.
15
+ */
16
+ export declare function configureCardSortablePlugins<T>(defaults: readonly T[]): T[];
17
+ export {};
@@ -0,0 +1,17 @@
1
+ import { Feedback } from "@dnd-kit/dom";
2
+ import { OptimisticSortingPlugin } from "@dnd-kit/dom/sortable";
3
+ /**
4
+ * Board plugins. AutoScroller stays (edge scroll while dragging). Feedback
5
+ * stays because it positions and sizes `DragOverlay`; `feedback: "none"`
6
+ * would leave the overlay behind. The drop animation is off so it does not
7
+ * snap twice against an optimistic update.
8
+ */
9
+ export const configureKanbanPlugins = (defaults) => defaults.map((plugin) => plugin === Feedback ? Feedback.configure({ dropAnimation: null }) : plugin);
10
+ /**
11
+ * Cards leave out OptimisticSortingPlugin: it moves DOM nodes under React and
12
+ * throws `removeChild` on re-render (dnd-kit #1747). Column layout comes from
13
+ * the consumer's `items`.
14
+ */
15
+ export function configureCardSortablePlugins(defaults) {
16
+ return defaults.filter((plugin) => plugin !== OptimisticSortingPlugin);
17
+ }
@@ -1,5 +1,3 @@
1
- /** Return the next highlighted index for a listbox navigation key. */
2
- export declare function moveListboxHighlight(key: string, index: number, length: number): number | undefined;
3
1
  /**
4
2
  * Option id for `aria-activedescendant`. Index-based because option values
5
3
  * may contain spaces, which are not valid in an id reference.
@@ -12,15 +10,6 @@ export declare function listboxOptionId(listId: string, index: number): string;
12
10
  * so the ref is stable without `useCallback`.
13
11
  */
14
12
  export declare function scrollOptionIntoView(node: HTMLElement | null): void;
15
- /**
16
- * True while an IME is composing (Japanese, Chinese, Korean input). Enter
17
- * then commits the composition and must not select an option. `keyCode`
18
- * 229 covers Safari, which ends composition before the keydown.
19
- */
20
- export declare function isComposingKey(event: {
21
- nativeEvent: KeyboardEvent;
22
- keyCode: number;
23
- }): boolean;
24
13
  /**
25
14
  * Shared keydown for a search input that drives a listbox: navigation keys
26
15
  * move the highlight, Enter picks the highlighted index. Returns `true` when
@@ -1,7 +1,7 @@
1
1
  /** Page keys move ten rows, a little more than one max-h-60 list viewport. */
2
2
  const PAGE_SIZE = 10;
3
3
  /** Return the next highlighted index for a listbox navigation key. */
4
- export function moveListboxHighlight(key, index, length) {
4
+ function moveListboxHighlight(key, index, length) {
5
5
  switch (key) {
6
6
  case "ArrowDown":
7
7
  return length === 0 ? 0 : Math.min(index + 1, length - 1);
@@ -40,7 +40,7 @@ export function scrollOptionIntoView(node) {
40
40
  * then commits the composition and must not select an option. `keyCode`
41
41
  * 229 covers Safari, which ends composition before the keydown.
42
42
  */
43
- export function isComposingKey(event) {
43
+ function isComposingKey(event) {
44
44
  return event.nativeEvent.isComposing || event.keyCode === 229;
45
45
  }
46
46
  /**
@@ -1,19 +1,4 @@
1
1
  import * as React from "react";
2
- type ListboxOptionProps = Omit<React.ComponentProps<"button">, "id" | "role" | "type" | "tabIndex" | "aria-selected"> & {
3
- /** The listbox id; the option id derives from it for aria-activedescendant. */
4
- listId: string;
5
- index: number;
6
- /** Keyboard or pointer highlight (tracked in state, not DOM focus). */
7
- active: boolean;
8
- selected: boolean;
9
- };
10
- /**
11
- * One option of a search-driven listbox (Combobox, SearchPicker): a
12
- * non-focusable button in a presentational `li`, highlighted by state and
13
- * scrolled into view while active. Pass `data-slot`, handlers, and the
14
- * component's right padding and indicator.
15
- */
16
- export declare function ListboxOption({ listId, index, active, selected, className, ...props }: ListboxOptionProps): React.JSX.Element;
17
2
  type PickerOptionProps<T> = {
18
3
  item: T;
19
4
  getLabel: (item: T) => string;
@@ -11,7 +11,7 @@ import { listboxEmpty, optionState } from "./styles.js";
11
11
  * scrolled into view while active. Pass `data-slot`, handlers, and the
12
12
  * component's right padding and indicator.
13
13
  */
14
- export function ListboxOption({ listId, index, active, selected, className, ...props }) {
14
+ function ListboxOption({ listId, index, active, selected, className, ...props }) {
15
15
  return (_jsx("li", { role: "none", children: _jsx("button", { ref: active ? scrollOptionIntoView : undefined, id: listboxOptionId(listId, index), type: "button", role: "option", tabIndex: -1, "aria-selected": selected, className: cn(optionRow, selected && optionState.selected, active && optionState.active, className), ...props }) }));
16
16
  }
17
17
  /** Only the old and new active rows rerender during keyboard navigation. */
@@ -0,0 +1,17 @@
1
+ import * as React from "react";
2
+ /** Pointer travel, in px, before a press on the scroller counts as a drag. */
3
+ export declare const DRAG_THRESHOLD = 4;
4
+ /**
5
+ * Lets a mouse drag a horizontal scroller sideways, the way touch already
6
+ * does natively. Mouse only (`pointerType === "mouse"`, left button); touch
7
+ * and pen keep the browser's own scrolling. A press becomes a drag after
8
+ * `DRAG_THRESHOLD` px of travel, so a plain click on a tab or link still
9
+ * activates it; once a drag has moved, the click after pointerup is
10
+ * swallowed so the drag never also follows a link. (Radix `TabsTrigger`
11
+ * selects on mousedown, so a drag that starts on an inactive tab still
12
+ * selects that tab on press.)
13
+ *
14
+ * Listens only while `enabled` (the scroller overflows): a row that fits
15
+ * behaves exactly as before. Keyboard scrolling is untouched.
16
+ */
17
+ export declare function useDragScroll(ref: React.RefObject<HTMLElement | null>, enabled: boolean): void;
@@ -0,0 +1,98 @@
1
+ "use client";
2
+ import * as React from "react";
3
+ /** Pointer travel, in px, before a press on the scroller counts as a drag. */
4
+ export const DRAG_THRESHOLD = 4;
5
+ /**
6
+ * Lets a mouse drag a horizontal scroller sideways, the way touch already
7
+ * does natively. Mouse only (`pointerType === "mouse"`, left button); touch
8
+ * and pen keep the browser's own scrolling. A press becomes a drag after
9
+ * `DRAG_THRESHOLD` px of travel, so a plain click on a tab or link still
10
+ * activates it; once a drag has moved, the click after pointerup is
11
+ * swallowed so the drag never also follows a link. (Radix `TabsTrigger`
12
+ * selects on mousedown, so a drag that starts on an inactive tab still
13
+ * selects that tab on press.)
14
+ *
15
+ * Listens only while `enabled` (the scroller overflows): a row that fits
16
+ * behaves exactly as before. Keyboard scrolling is untouched.
17
+ */
18
+ export function useDragScroll(ref, enabled) {
19
+ React.useEffect(() => {
20
+ const element = ref.current;
21
+ if (!element || !enabled)
22
+ return;
23
+ let pointerId = null;
24
+ let startX = 0;
25
+ let startScrollLeft = 0;
26
+ let dragged = false;
27
+ const onPointerDown = (event) => {
28
+ if (event.pointerType !== "mouse" || event.button !== 0)
29
+ return;
30
+ pointerId = event.pointerId;
31
+ startX = event.clientX;
32
+ startScrollLeft = element.scrollLeft;
33
+ dragged = false;
34
+ };
35
+ const onPointerMove = (event) => {
36
+ if (pointerId === null || event.pointerId !== pointerId)
37
+ return;
38
+ if ((event.buttons & 1) === 0) {
39
+ // The button came up outside the scroller before capture.
40
+ pointerId = null;
41
+ return;
42
+ }
43
+ const delta = event.clientX - startX;
44
+ if (!dragged) {
45
+ if (Math.abs(delta) < DRAG_THRESHOLD)
46
+ return;
47
+ dragged = true;
48
+ // Keep the moves and the up when the pointer leaves the box. The
49
+ // browser releases capture on pointerup and pointercancel.
50
+ element.setPointerCapture(event.pointerId);
51
+ }
52
+ element.scrollLeft = startScrollLeft - delta;
53
+ };
54
+ const onPointerUp = (event) => {
55
+ if (event.pointerId !== pointerId)
56
+ return;
57
+ pointerId = null;
58
+ // `dragged` stays set so the click that follows is swallowed.
59
+ };
60
+ const onPointerCancel = (event) => {
61
+ if (event.pointerId !== pointerId)
62
+ return;
63
+ pointerId = null;
64
+ // No click follows a cancel, so the next real click must go through.
65
+ dragged = false;
66
+ };
67
+ // Capture phase, so it runs before the link's or trigger's own handler.
68
+ const onClick = (event) => {
69
+ if (!dragged)
70
+ return;
71
+ dragged = false;
72
+ event.preventDefault();
73
+ event.stopPropagation();
74
+ };
75
+ // Links are draggable by default: a native drag would cancel the pointer.
76
+ // A text selection started in the row would smear across the labels.
77
+ const onDragOrSelectStart = (event) => {
78
+ if (pointerId !== null)
79
+ event.preventDefault();
80
+ };
81
+ element.addEventListener("pointerdown", onPointerDown);
82
+ element.addEventListener("pointermove", onPointerMove);
83
+ element.addEventListener("pointerup", onPointerUp);
84
+ element.addEventListener("pointercancel", onPointerCancel);
85
+ element.addEventListener("click", onClick, true);
86
+ element.addEventListener("dragstart", onDragOrSelectStart);
87
+ element.addEventListener("selectstart", onDragOrSelectStart);
88
+ return () => {
89
+ element.removeEventListener("pointerdown", onPointerDown);
90
+ element.removeEventListener("pointermove", onPointerMove);
91
+ element.removeEventListener("pointerup", onPointerUp);
92
+ element.removeEventListener("pointercancel", onPointerCancel);
93
+ element.removeEventListener("click", onClick, true);
94
+ element.removeEventListener("dragstart", onDragOrSelectStart);
95
+ element.removeEventListener("selectstart", onDragOrSelectStart);
96
+ };
97
+ }, [ref, enabled]);
98
+ }
@@ -1,7 +1,9 @@
1
1
  /** Tailwind `md` breakpoint. Used for range calendars that stack on small screens. */
2
2
  export declare const MD_MEDIA_QUERY = "(min-width: 768px)";
3
3
  /**
4
- * Subscribe to a CSS media query. `defaultMatches` covers SSR and environments
5
- * without `matchMedia` (jsdom unless the test stubs it).
4
+ * Subscribe to a CSS media query with `useSyncExternalStore`. The server
5
+ * render and hydration use `defaultMatches`, so the markup always agrees;
6
+ * React then re-renders with the real value. `defaultMatches` also covers
7
+ * environments without `matchMedia` (jsdom unless the test stubs it).
6
8
  */
7
9
  export declare function useMediaQuery(query: string, defaultMatches?: boolean): boolean;
@@ -2,26 +2,22 @@
2
2
  import * as React from "react";
3
3
  /** Tailwind `md` breakpoint. Used for range calendars that stack on small screens. */
4
4
  export const MD_MEDIA_QUERY = "(min-width: 768px)";
5
+ function hasMatchMedia() {
6
+ return (typeof window !== "undefined" && typeof window.matchMedia === "function");
7
+ }
5
8
  /**
6
- * Subscribe to a CSS media query. `defaultMatches` covers SSR and environments
7
- * without `matchMedia` (jsdom unless the test stubs it).
9
+ * Subscribe to a CSS media query with `useSyncExternalStore`. The server
10
+ * render and hydration use `defaultMatches`, so the markup always agrees;
11
+ * React then re-renders with the real value. `defaultMatches` also covers
12
+ * environments without `matchMedia` (jsdom unless the test stubs it).
8
13
  */
9
14
  export function useMediaQuery(query, defaultMatches = false) {
10
- const [matches, setMatches] = React.useState(() => {
11
- if (typeof window === "undefined" ||
12
- typeof window.matchMedia !== "function") {
13
- return defaultMatches;
14
- }
15
- return window.matchMedia(query).matches;
16
- });
17
- React.useEffect(() => {
18
- if (typeof window.matchMedia !== "function")
19
- return;
15
+ const subscribe = React.useCallback((onChange) => {
16
+ if (!hasMatchMedia())
17
+ return () => { };
20
18
  const media = window.matchMedia(query);
21
- const onChange = () => setMatches(media.matches);
22
- onChange();
23
19
  media.addEventListener("change", onChange);
24
20
  return () => media.removeEventListener("change", onChange);
25
21
  }, [query]);
26
- return matches;
22
+ return React.useSyncExternalStore(subscribe, () => (hasMatchMedia() ? window.matchMedia(query).matches : defaultMatches), () => defaultMatches);
27
23
  }
@@ -0,0 +1,84 @@
1
+ import * as React from "react";
2
+ /**
3
+ * One board column. Columns render in the order given. `color` (any CSS
4
+ * color) draws a status dot; `badge` is trailing header meta such as a total.
5
+ */
6
+ export type KanbanColumn = {
7
+ /** Matches what `getColumnId` returns for the column's cards. */
8
+ id: string;
9
+ /** Header text. */
10
+ label: string;
11
+ /** Any CSS color; draws a status dot before the label. No dot when omitted. */
12
+ color?: string;
13
+ /** Trailing header meta under the card count, such as a total. */
14
+ badge?: React.ReactNode;
15
+ /** Replaces the board's `emptyLabel` for this column. */
16
+ emptyLabel?: string;
17
+ };
18
+ /**
19
+ * Positions of the cards on either side of the drop slot (`undefined` at a
20
+ * column edge). Feed them to a fractional-index helper to store the order.
21
+ */
22
+ export type KanbanDropTarget = {
23
+ /** `position` of the card above the drop slot; `undefined` at the top. */
24
+ beforePosition: number | undefined;
25
+ /** `position` of the card below the drop slot; `undefined` at the bottom. */
26
+ afterPosition: number | undefined;
27
+ };
28
+ export type KanbanBoardProps<T extends {
29
+ id: string;
30
+ position: number;
31
+ }> = {
32
+ /** The board's columns, in display order. */
33
+ columns: KanbanColumn[];
34
+ /** Every card, in display order; sort by `position` first. A card whose column id is unknown is not shown. */
35
+ items: T[];
36
+ /** The id of the column a card belongs in. */
37
+ getColumnId: (item: T) => string;
38
+ /** Every drop that changes a column or the order, including same-column reorders. */
39
+ onMove: (item: T, toColumnId: string, target: KanbanDropTarget) => void;
40
+ /** Shows a Finish zone while dragging. */
41
+ onComplete?: (itemId: string) => void;
42
+ /** Shows a Remove zone while dragging. */
43
+ onDelete?: (itemId: string) => void;
44
+ /**
45
+ * Card body; the board adds the grip, chrome, and sorting. Cards re-render
46
+ * only when their item changes while this and `onCardClick` keep their
47
+ * identity, so define them outside the render or wrap them in `useCallback`.
48
+ */
49
+ renderCard: (item: T) => React.ReactNode;
50
+ /** Makes the card body a button. */
51
+ onCardClick?: (item: T) => void;
52
+ /** Skeleton cards on a first load with no items; dims the board on a refetch. */
53
+ isLoading?: boolean;
54
+ /** Text in a column with no cards. Default `"Drop here"`. */
55
+ emptyLabel?: string;
56
+ /** Text on the Finish zone. Default `"Finish"`. */
57
+ completeLabel?: string;
58
+ /** Text on the Remove zone. Default `"Remove"`. */
59
+ deleteLabel?: string;
60
+ /** Classes for the horizontal scroller that holds the columns. */
61
+ scrollerClassName?: string;
62
+ /** Classes for every column, e.g. its width. */
63
+ columnClassName?: string;
64
+ /** Classes for every card, including the one under the pointer while dragging. */
65
+ cardClassName?: string;
66
+ } & Omit<React.ComponentProps<"div">, "children">;
67
+ /**
68
+ * Drag-and-drop board built on dnd-kit. The consumer owns `items` and maps
69
+ * each to a column; the board derives its layout from them, so an optimistic
70
+ * cache update is the only state. Cards drag by their grip, the body stays
71
+ * clickable, and off-screen columns get edge fades and scroll buttons.
72
+ */
73
+ declare function KanbanBoard<T extends {
74
+ id: string;
75
+ position: number;
76
+ }>({ columns, items, getColumnId, onMove, onComplete, onDelete, renderCard, onCardClick, isLoading, emptyLabel, completeLabel, deleteLabel, className, scrollerClassName, columnClassName, cardClassName, ...props }: KanbanBoardProps<T>): React.JSX.Element;
77
+ /** Board-shaped placeholder for a route `loading` file. */
78
+ declare function KanbanSkeleton({ columnCount, className, columnClassName, ...props }: {
79
+ /** How many placeholder columns to draw. Default 4. */
80
+ columnCount?: number;
81
+ /** Classes for every placeholder column, e.g. to match the board's width. */
82
+ columnClassName?: string;
83
+ } & Omit<React.ComponentProps<"div">, "children">): React.JSX.Element;
84
+ export { KanbanBoard, KanbanSkeleton };