@hackcove/ui-react 0.10.6 → 0.10.8

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/Checkbox.js CHANGED
@@ -25,5 +25,5 @@ export function Checkbox({ label, description, size = "md", align = "center", "a
25
25
  return (_jsxs("label", { className: cx(
26
26
  // Faded and default-cursor unless the box is enabled. Written as `has-enabled` so the
27
27
  // label carries no `disabled` class text: the disabled state belongs on the input.
28
- "group/checkbox inline-flex cursor-default text-slate-700 opacity-50 has-enabled:cursor-pointer has-enabled:opacity-100", start ? "items-start" : "items-center", SIZE[size].label), children: [_jsx("span", { "aria-hidden": "true", className: cx(SIZE[size].box, start && SIZE[size].start, "pointer-events-none invisible absolute z-10 block rounded border border-violet-600 bg-violet-600 text-violet-50 group-has-checked/checkbox:visible"), children: _jsx("svg", { viewBox: "0 0 16 16", fill: "none", className: "h-full w-full", children: _jsx("path", { d: "M4 8.5l2.5 2.5L12 5.5", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) }) }), _jsx("input", { type: "checkbox", className: cx(SIZE[size].box, "shrink-0", start && SIZE[size].start, "appearance-none rounded border border-slate-300 bg-white hover:border-violet-400", focusRing), ...(describedIds ? { "aria-describedby": describedIds } : {}), ...rest }), _jsx(Words, { label: label, description: description, id: descriptionId })] }));
28
+ "group/checkbox inline-flex cursor-default text-slate-700 opacity-50 has-enabled:cursor-pointer has-enabled:opacity-100", start ? "items-start" : "items-center", SIZE[size].label), children: [_jsx("span", { "aria-hidden": "true", className: cx(SIZE[size].box, start && SIZE[size].start, "pointer-events-none invisible absolute in-[.isolate]:z-20 block rounded border border-violet-600 bg-violet-600 text-violet-50 group-has-checked/checkbox:visible"), children: _jsx("svg", { viewBox: "0 0 16 16", fill: "none", className: "h-full w-full", children: _jsx("path", { d: "M4 8.5l2.5 2.5L12 5.5", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) }) }), _jsx("input", { type: "checkbox", className: cx(SIZE[size].box, "shrink-0", start && SIZE[size].start, "appearance-none rounded border border-slate-300 bg-white hover:border-violet-400", focusRing), ...(describedIds ? { "aria-describedby": describedIds } : {}), ...rest }), _jsx(Words, { label: label, description: description, id: descriptionId })] }));
29
29
  }
@@ -0,0 +1,81 @@
1
+ import { type AriaAttributes, type ReactElement, type ReactNode } from "react";
2
+ import { type DropTarget, type Key } from "react-aria-components";
3
+ import type { DataAttributes } from "./link.js";
4
+ /** A move the user made: which item, the section it lands in, and the item it lands next to. */
5
+ export type SortableMove = {
6
+ /** The `id` of the item that moved. */
7
+ id: string;
8
+ /** The `id` of the `SortableSection` the item lands in. Set whenever the list has sections. */
9
+ section?: string;
10
+ /**
11
+ * The item it was dropped before or after. Absent only for a drop into an empty section, where
12
+ * there is no item to land next to.
13
+ */
14
+ target?: {
15
+ id: string;
16
+ position: "before" | "after";
17
+ };
18
+ };
19
+ /** What a part takes through to its element: `data-*` (an e2e hook) and a description. */
20
+ type Through = DataAttributes & Pick<AriaAttributes, "aria-describedby" | "aria-details">;
21
+ /** A row's section and title; a tail row also lists its section's items, in order. */
22
+ type Place = {
23
+ section?: string;
24
+ title: string;
25
+ tail?: string[];
26
+ };
27
+ /** Only a drop between two rows is a move; a drop on a row's centre, or on the list itself, is not. */
28
+ export declare function dropOperation(target: DropTarget): "move" | "cancel";
29
+ /**
30
+ * The move react-aria reported, as the app's `SortableMove`, or nothing when it is not one (a drop
31
+ * on a row's centre, or next to the item itself). A drop at a section's tail lands after the
32
+ * section's last other item, or with no target when it has none.
33
+ */
34
+ export declare function moveOf(places: Map<string, Place>, keys: Iterable<Key>, target: DropTarget): SortableMove | null;
35
+ /**
36
+ * A list the user puts in order: items under section headings, moved by dragging a row or from the
37
+ * keyboard (each row's grip handle, Enter, the arrow keys, Enter to drop, Escape to cancel). A drop
38
+ * reports `onReorder` with the item, the section it lands in and the item it lands next to; the app
39
+ * applies it to its own data. The list never reorders itself: it shows what its children say.
40
+ *
41
+ * Its children are `SortableSection`s, or `SortableItem`s directly for a list with no sections, each
42
+ * a direct child (map your data straight to them). Moving an item into another section is how the
43
+ * app changes its group, such as a ticket's priority. Two lists on one page never take each other's
44
+ * items.
45
+ */
46
+ export declare function SortableList({ label, onReorder, children, ...rest }: {
47
+ /** The list's accessible name, read by a screen reader. */
48
+ label: string;
49
+ /** Called once per drop with the move; apply it to your data. */
50
+ onReorder: (move: SortableMove) => void;
51
+ /** `SortableSection`s, or `SortableItem`s for a list with no sections. */
52
+ children: ReactNode;
53
+ } & Through): ReactElement;
54
+ type SortableSectionProps = {
55
+ /** The section's id, reported as `section` in a move that lands in it. */
56
+ id: string;
57
+ /** The heading over its items. */
58
+ title: string;
59
+ /** Its `SortableItem`s, each a direct child. With none it shows a quiet "Drop here" row. */
60
+ children?: ReactNode;
61
+ };
62
+ /**
63
+ * One group of a `SortableList` under a heading that shows its title and how many items it holds.
64
+ * An empty section still takes a drop: it shows a quiet "Drop here" row in place of items.
65
+ */
66
+ export declare function SortableSection({ id, title, children, ...rest }: SortableSectionProps & Through): ReactElement;
67
+ type SortableItemProps = {
68
+ /** The item's id, reported in a move. */
69
+ id: string;
70
+ /** Its one line; also what a screen reader announces while it is dragged. */
71
+ title: string;
72
+ /** A meta line under the title: `Pill`s, an `Avatar`. */
73
+ children?: ReactNode;
74
+ };
75
+ /**
76
+ * One row of a `SortableList`: a grip handle, the title, and an optional meta line under it. A
77
+ * pointer drags the row itself; the handle ("Reorder <title>") is how a keyboard or screen reader
78
+ * starts a move.
79
+ */
80
+ export declare function SortableItem({ id, title, children, ...rest }: SortableItemProps & Through): ReactElement;
81
+ export {};
@@ -0,0 +1,186 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Children, isValidElement, useId, useMemo, } from "react";
3
+ import { Button, DropIndicator, GridList, GridListHeader, GridListItem, GridListSection, useDragAndDrop, } from "react-aria-components";
4
+ import { cx, focusRing } from "./cx.js";
5
+ /** The list's box: the Card's border, fill and shadow, clipped to its radius. */
6
+ const FRAME = "w-full overflow-hidden rounded-xl border border-slate-200/70 bg-white shadow-sm outline-none";
7
+ /**
8
+ * A hairline above every row that follows another row (an item, a heading, a drop line), so the
9
+ * first row of the list sits flush under the frame's own border. The line's colour is set apart.
10
+ */
11
+ const AFTER_ROW = "border-slate-200/70 [[role=row]+&]:border-t";
12
+ /** A section after another section gets the same hairline above its heading. */
13
+ const AFTER_SECTION = "border-slate-200/70 [[role=rowgroup]+&]:border-t";
14
+ const HEADING = "flex items-center justify-between gap-3 bg-slate-50 px-4 py-2 text-xs font-medium tracking-wide text-slate-600 uppercase";
15
+ const ITEM = "flex w-full cursor-grab items-center gap-3 px-4 py-3 text-left text-sm text-slate-900 outline-none hover:bg-slate-50";
16
+ /** The keyboard cursor: the focus ring, drawn inside the row. */
17
+ const ITEM_FOCUSED = "outline-2 -outline-offset-2 outline-violet-600";
18
+ /** An empty section's "Drop here" row. */
19
+ const PLACEHOLDER = "px-4 py-3 text-sm text-slate-500 outline-none";
20
+ /** The tail row of a section with items: takes no room and shows nothing but the drop line. */
21
+ const TAIL = "h-0 overflow-hidden outline-none";
22
+ /** A marker on every row a pointer can drop next to: the items and each section's tail row. */
23
+ const ROW_MARK = "data-sortable-row";
24
+ /**
25
+ * react-aria drops an `aria-describedby` or `aria-details` it is handed on a row or a section, and
26
+ * sets its own describedby on a row. This draws the element with both: its own ids and the app's.
27
+ */
28
+ function describedBy({ "aria-describedby": described, "aria-details": details, ...data }) {
29
+ return {
30
+ ...data,
31
+ render: (props) => (_jsx("div", { ...props, "aria-describedby": [props["aria-describedby"], described].filter(Boolean).join(" ") || undefined, "aria-details": details ?? props["aria-details"] })),
32
+ };
33
+ }
34
+ /**
35
+ * The key of a section's tail row. Every section ends with one: in an empty section it is the "Drop
36
+ * here" row, in one with items it is an empty row of no height. A drop before it is a drop at the
37
+ * end of the section. react-aria's keyboard drop moves cannot step from "after the last item" of a
38
+ * section into the next one (it wraps to the top), but they do step from "before" a row to "before"
39
+ * the next row across sections, so with a tail row the end of each section is a "before" target.
40
+ */
41
+ function tailKey(section) {
42
+ return `hackcove-sortable-end:${section}`;
43
+ }
44
+ /** The `SortableItem` children of a section, the ones its count counts. */
45
+ function itemsOf(children) {
46
+ return Children.toArray(children).filter((child) => isValidElement(child) && child.type === SortableItem);
47
+ }
48
+ /** Every row's key mapped to its section (and, for a tail row, the section's items), read from the children. */
49
+ function placesOf(children) {
50
+ const places = new Map();
51
+ for (const child of Children.toArray(children)) {
52
+ if (isValidElement(child) && child.type === SortableItem) {
53
+ const { id, title } = child.props;
54
+ places.set(id, { title });
55
+ }
56
+ else if (isValidElement(child) && child.type === SortableSection) {
57
+ const { id: section, children: items } = child.props;
58
+ const own = itemsOf(items);
59
+ for (const item of own)
60
+ places.set(item.props.id, { section, title: item.props.title });
61
+ places.set(tailKey(section), { section, title: "", tail: own.map((item) => item.props.id) });
62
+ }
63
+ }
64
+ return places;
65
+ }
66
+ /**
67
+ * Where a pointer drops: before or after the row it is over, by which half it is in. Over a section
68
+ * heading, or in a gap, it is before the next row below (the first item of that section, or its
69
+ * "Drop here" row), and below every row it is after the last. Never "on" a row.
70
+ */
71
+ function dropTargetAt(list, y) {
72
+ if (!list)
73
+ return null;
74
+ const top = y + list.getBoundingClientRect().top;
75
+ const rows = [...list.querySelectorAll(`[${ROW_MARK}]`)];
76
+ for (const row of rows) {
77
+ const key = row.dataset.key;
78
+ if (key == null)
79
+ continue;
80
+ const box = row.getBoundingClientRect();
81
+ if (top < box.top)
82
+ return { type: "item", key, dropPosition: "before" };
83
+ if (top <= box.bottom) {
84
+ return {
85
+ type: "item",
86
+ key,
87
+ dropPosition: top < box.top + box.height / 2 ? "before" : "after",
88
+ };
89
+ }
90
+ }
91
+ const last = rows.at(-1)?.dataset.key;
92
+ return last == null ? null : { type: "item", key: last, dropPosition: "after" };
93
+ }
94
+ /** react-aria's pointer drop delegate, aware of sections (the stock one sees only top-level items). */
95
+ function sectionDelegate(listId) {
96
+ return {
97
+ getDropTargetFromPoint: (_x, y) => dropTargetAt(document.getElementById(listId), y),
98
+ };
99
+ }
100
+ /** Only a drop between two rows is a move; a drop on a row's centre, or on the list itself, is not. */
101
+ export function dropOperation(target) {
102
+ return target.type === "item" && target.dropPosition !== "on" ? "move" : "cancel";
103
+ }
104
+ /**
105
+ * The move react-aria reported, as the app's `SortableMove`, or nothing when it is not one (a drop
106
+ * on a row's centre, or next to the item itself). A drop at a section's tail lands after the
107
+ * section's last other item, or with no target when it has none.
108
+ */
109
+ export function moveOf(places, keys, target) {
110
+ const [key] = keys;
111
+ if (key == null || target.type !== "item" || target.dropPosition === "on")
112
+ return null;
113
+ const place = places.get(String(target.key));
114
+ if (!place)
115
+ return null;
116
+ const id = String(key);
117
+ if (String(target.key) === id)
118
+ return null;
119
+ const section = place.section === undefined ? {} : { section: place.section };
120
+ if (place.tail) {
121
+ const last = place.tail.filter((item) => item !== id).at(-1);
122
+ return last === undefined
123
+ ? { id, ...section }
124
+ : { id, ...section, target: { id: last, position: "after" } };
125
+ }
126
+ return {
127
+ id,
128
+ ...section,
129
+ target: { id: String(target.key), position: target.dropPosition },
130
+ };
131
+ }
132
+ /**
133
+ * A list the user puts in order: items under section headings, moved by dragging a row or from the
134
+ * keyboard (each row's grip handle, Enter, the arrow keys, Enter to drop, Escape to cancel). A drop
135
+ * reports `onReorder` with the item, the section it lands in and the item it lands next to; the app
136
+ * applies it to its own data. The list never reorders itself: it shows what its children say.
137
+ *
138
+ * Its children are `SortableSection`s, or `SortableItem`s directly for a list with no sections, each
139
+ * a direct child (map your data straight to them). Moving an item into another section is how the
140
+ * app changes its group, such as a ticket's priority. Two lists on one page never take each other's
141
+ * items.
142
+ */
143
+ export function SortableList({ label, onReorder, children, ...rest }) {
144
+ const listId = useId();
145
+ const dragType = `application/x-hackcove-sortable-${useId()
146
+ .replace(/[^a-z0-9]/gi, "")
147
+ .toLowerCase()}`;
148
+ const places = placesOf(children);
149
+ const delegate = useMemo(() => sectionDelegate(listId), [listId]);
150
+ const { dragAndDropHooks } = useDragAndDrop({
151
+ getItems: (keys) => [...keys].map((key) => ({
152
+ "text/plain": places.get(String(key))?.title ?? String(key),
153
+ [dragType]: String(key),
154
+ })),
155
+ acceptedDragTypes: [dragType],
156
+ getDropOperation: dropOperation,
157
+ dropTargetDelegate: delegate,
158
+ onMove: (e) => {
159
+ const move = moveOf(places, e.keys, e.target);
160
+ if (move)
161
+ onReorder(move);
162
+ },
163
+ renderDropIndicator: (target) => (_jsx(DropIndicator, { target: target, className: "relative h-0 outline-none", children: ({ isDropTarget }) => (_jsx("span", { "aria-hidden": "true", className: cx("absolute inset-x-0 -top-px z-10 block h-0.5", isDropTarget && "bg-violet-600") })) })),
164
+ });
165
+ return (_jsx(GridList, { ...describedBy(rest), id: listId, "aria-label": label, dragAndDropHooks: dragAndDropHooks, disabledBehavior: "all", className: FRAME, children: children }));
166
+ }
167
+ /**
168
+ * One group of a `SortableList` under a heading that shows its title and how many items it holds.
169
+ * An empty section still takes a drop: it shows a quiet "Drop here" row in place of items.
170
+ */
171
+ export function SortableSection({ id, title, children, ...rest }) {
172
+ const count = itemsOf(children).length;
173
+ return (_jsxs(GridListSection, { ...describedBy(rest),
174
+ // Sections and items share react-aria's key space; this keeps a section id from colliding with an item's.
175
+ id: `hackcove-sortable-section:${id}`, "aria-label": title, className: AFTER_SECTION, children: [_jsxs(GridListHeader, { className: HEADING, children: [_jsx("span", { children: title }), _jsx("span", { className: "tabular-nums text-slate-500", children: count })] }), children, _jsxs(GridListItem, { id: tailKey(id), textValue: count > 0 ? `End of ${title}` : "Drop here", isDisabled: true, [ROW_MARK]: "", className: count > 0 ? TAIL : cx(PLACEHOLDER, AFTER_ROW), children: [_jsx(Button, { slot: "drag", className: "hidden" }), count === 0 && "Drop here"] })] }));
176
+ }
177
+ /** The six-dot grip on each row's handle. */
178
+ const GRIP = (_jsxs("svg", { viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": "true", className: "h-4 w-4", children: [_jsx("circle", { cx: "6", cy: "4", r: "1.25" }), _jsx("circle", { cx: "10", cy: "4", r: "1.25" }), _jsx("circle", { cx: "6", cy: "8", r: "1.25" }), _jsx("circle", { cx: "10", cy: "8", r: "1.25" }), _jsx("circle", { cx: "6", cy: "12", r: "1.25" }), _jsx("circle", { cx: "10", cy: "12", r: "1.25" })] }));
179
+ /**
180
+ * One row of a `SortableList`: a grip handle, the title, and an optional meta line under it. A
181
+ * pointer drags the row itself; the handle ("Reorder <title>") is how a keyboard or screen reader
182
+ * starts a move.
183
+ */
184
+ export function SortableItem({ id, title, children, ...rest }) {
185
+ return (_jsxs(GridListItem, { ...describedBy(rest), id: id, textValue: title, [ROW_MARK]: "", className: ({ isFocusVisible, isDragging }) => cx(ITEM, AFTER_ROW, isFocusVisible && ITEM_FOCUSED, isDragging && "opacity-50"), children: [_jsx(Button, { slot: "drag", "aria-label": `Reorder ${title}`, className: cx("shrink-0 rounded p-0.5 text-slate-400", focusRing), children: GRIP }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "truncate", children: title }), children != null && (_jsx("div", { className: "mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500", children: children }))] })] }));
186
+ }
package/dist/index.d.ts CHANGED
@@ -28,6 +28,7 @@ export { Breadcrumbs, type Crumb } from "./Breadcrumbs.js";
28
28
  export { BulletList } from "./BulletList.js";
29
29
  export { Callout, type CalloutTone } from "./Callout.js";
30
30
  export { ListRow, type ListRowProps } from "./ListRow.js";
31
+ export { SortableItem, SortableList, type SortableMove, SortableSection } from "./SortableList.js";
31
32
  export { OutlineRegion, RegionFrame, type RegionRect } from "./OutlineRegion.js";
32
33
  export { type CellTone, type RowTone, Table, TableBody, TableCell, TableFoot, TableGroupRow, TableHead, TableHeaderCell, TableRow, } from "./Table.js";
33
34
  export { InlineEdit } from "./InlineEdit.js";
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ export { Breadcrumbs } from "./Breadcrumbs.js";
28
28
  export { BulletList } from "./BulletList.js";
29
29
  export { Callout } from "./Callout.js";
30
30
  export { ListRow } from "./ListRow.js";
31
+ export { SortableItem, SortableList, SortableSection } from "./SortableList.js";
31
32
  export { OutlineRegion, RegionFrame } from "./OutlineRegion.js";
32
33
  export { Table, TableBody, TableCell, TableFoot, TableGroupRow, TableHead, TableHeaderCell, TableRow, } from "./Table.js";
33
34
  export { InlineEdit } from "./InlineEdit.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackcove/ui-react",
3
- "version": "0.10.6",
3
+ "version": "0.10.8",
4
4
  "description": "Hack Cove React components: primitives, typography roles and a safe Markdown renderer, written against Tailwind v4 utilities. Pair with a theme from @hackcove/themes.",
5
5
  "keywords": [
6
6
  "components",