@hackcove/ui-react 0.10.7 → 0.10.9
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/AppShell.d.ts +14 -2
- package/dist/AppShell.js +18 -10
- package/dist/AppShellMenu.d.ts +8 -1
- package/dist/AppShellMenu.js +16 -5
- package/dist/SortableList.d.ts +81 -0
- package/dist/SortableList.js +186 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
package/dist/AppShell.d.ts
CHANGED
|
@@ -78,21 +78,33 @@ export declare function AppShellHideBelow({ breakpoint, children, ...rest }: Pas
|
|
|
78
78
|
* reachable and nothing scrolls sideways. The menu is a native `<details>`, so it works with the
|
|
79
79
|
* keyboard, with a screen reader and on a page that never hydrates. From `md` the header is the
|
|
80
80
|
* row, and the `subnav` row, as they are.
|
|
81
|
+
*
|
|
82
|
+
* `controls` stay in the row at every width, so keep them few (a theme toggle, the viewer's avatar);
|
|
83
|
+
* on a phone a crowded `controls` clips, and the Menu button is never the part cut off. Pickers and
|
|
84
|
+
* actions that do not fit a phone row go in `menuControls`: rendered once, they sit in the row after
|
|
85
|
+
* `controls` from `md`, and below `md` at the top of the Menu, above `nav` and `subnav`.
|
|
81
86
|
*/
|
|
82
|
-
export declare function AppShell({ brand, nav, controls, header, subnav, menuLabel, footer, bleed, hideChromeInPrint, children, ...rest }: {
|
|
87
|
+
export declare function AppShell({ brand, nav, controls, menuControls, header, subnav, menuLabel, footer, bleed, hideChromeInPrint, children, ...rest }: {
|
|
83
88
|
/** The brand: a logo and a name, in the header's brand type, with the gap between them. `AppShellBrand` makes both one link. */
|
|
84
89
|
brand?: ReactNode;
|
|
85
90
|
/** The main navigation, e.g. a `Tabs variant="pill"` of links (it is already a `<nav>`). Below `md` it is in the menu. */
|
|
86
91
|
nav?: ReactNode;
|
|
87
92
|
/** The end of the row: a picker, a theme toggle. Gap-2, vertically centred; no child shrinks, and a full-width control (a `Select` at its default) sizes to its content. */
|
|
88
93
|
controls?: ReactNode;
|
|
94
|
+
/**
|
|
95
|
+
* Pickers and actions that do not fit a phone row, such as a project `Select` and a Sign out
|
|
96
|
+
* `Button`. From `md` they sit in the row after `controls`; below `md` they are at the top of the
|
|
97
|
+
* Menu, above `nav` and `subnav`. Rendered once, so a field keeps its state and ids stay unique.
|
|
98
|
+
* `controls` stay in the row at every width, so keep those few.
|
|
99
|
+
*/
|
|
100
|
+
menuControls?: ReactNode;
|
|
89
101
|
/** The escape hatch: a free node in the header row, after `brand` and `nav`. */
|
|
90
102
|
header?: ReactNode;
|
|
91
103
|
/** A second fixed-height row under the header from `md`, e.g. a `Tabs variant="pill"` of links. Below `md` it is in the menu with `nav`. */
|
|
92
104
|
subnav?: ReactNode;
|
|
93
105
|
/**
|
|
94
106
|
* The phone menu button's name, for an app in another language. Only drawn below `md`, and only
|
|
95
|
-
* when `nav` or `
|
|
107
|
+
* when `nav`, `subnav` or `menuControls` is given.
|
|
96
108
|
* @default Menu
|
|
97
109
|
*/
|
|
98
110
|
menuLabel?: string;
|
package/dist/AppShell.js
CHANGED
|
@@ -62,17 +62,20 @@ function ScrollRow({ className, children }) {
|
|
|
62
62
|
return (_jsx(ContainerFrame, { elementRef: setRow, className: cx(ROW, FADE, className), children: children }));
|
|
63
63
|
}
|
|
64
64
|
/**
|
|
65
|
-
* The header row's slots in order: brand, nav (from `md`), the free `children`, then controls and
|
|
66
|
-
*
|
|
65
|
+
* The header row's slots in order: brand, nav (from `md`), the free `children`, then `controls` and
|
|
66
|
+
* the menu at the end. `controls` have a wrapper of their own that shrinks and clips, and the menu
|
|
67
|
+
* sits beside it, so a crowded `controls` never pushes the Menu off a phone screen. From `md` the
|
|
68
|
+
* menu is the inline row of `menuControls` after `controls`.
|
|
67
69
|
*/
|
|
68
|
-
function HeaderSlots({ brand, nav, controls, menu, children, }) {
|
|
69
|
-
return (_jsxs(_Fragment, { children: [brand && (_jsx("div", { className: "flex items-center gap-2 text-base font-bold tracking-tight text-slate-900", children: brand })), nav && _jsx("div", { className: "hidden items-center gap-4 *:shrink-0 md:flex", children: nav }), children, (controls || menu) && (_jsxs("div", { className:
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
function HeaderSlots({ brand, nav, controls, menu, menuInline, children, }) {
|
|
71
|
+
return (_jsxs(_Fragment, { children: [brand && (_jsx("div", { className: "flex items-center gap-2 text-base font-bold tracking-tight text-slate-900", children: brand })), nav && _jsx("div", { className: "hidden items-center gap-4 *:shrink-0 md:flex", children: nav }), children, (controls || menu) && (_jsxs("div", { className: cx(
|
|
72
|
+
// Below `md` this wrapper may shrink (the row's `*:shrink-0` is overridden), so a crowded
|
|
73
|
+
// `controls` clips inside its own box; from `md` it keeps its width and the row scrolls.
|
|
74
|
+
"ml-auto flex min-w-0 items-center gap-2 max-md:shrink!", !controls && !menuInline && "md:hidden"), children: [controls && (_jsx("div", { className: "flex min-w-0 items-center gap-2 *:shrink-0 max-md:-mx-1 max-md:overflow-x-clip max-md:px-1 [&>.w-full]:w-auto", children: controls })), menu] }))] }));
|
|
72
75
|
}
|
|
73
76
|
/** The sticky header: the one-row slots, and from `md` the `subnav` row under them. */
|
|
74
|
-
function AppShellHeader({ brand, nav, controls, subnav, menuLabel, hideChromeInPrint, children, }) {
|
|
75
|
-
return (_jsxs("header", { className: cx("sticky top-0 z-10 border-b border-slate-200 bg-white", hideChromeInPrint && "print:hidden"), children: [_jsx(ScrollRow, { className: "flex h-14", children: _jsx(HeaderSlots, { brand: brand, nav: nav, controls: controls, menu: (nav || subnav) && (_jsxs(AppShellMenu, { label: menuLabel, children: [nav, subnav] })), children: children }) }), subnav && _jsx(ScrollRow, { className: "hidden h-12 md:flex", children: subnav })] }));
|
|
77
|
+
function AppShellHeader({ brand, nav, controls, menuControls, subnav, menuLabel, hideChromeInPrint, children, }) {
|
|
78
|
+
return (_jsxs("header", { className: cx("sticky top-0 z-10 border-b border-slate-200 bg-white", hideChromeInPrint && "print:hidden"), children: [_jsx(ScrollRow, { className: "flex h-14", children: _jsx(HeaderSlots, { brand: brand, nav: nav, controls: controls, menuInline: !!menuControls, menu: (nav || subnav || menuControls) && (_jsxs(AppShellMenu, { label: menuLabel, controls: menuControls, children: [nav, subnav] })), children: children }) }), subnav && _jsx(ScrollRow, { className: "hidden h-12 md:flex", children: subnav })] }));
|
|
76
79
|
}
|
|
77
80
|
/**
|
|
78
81
|
* The page frame: it paints the page canvas and sets the type face, ink and font smoothing
|
|
@@ -93,10 +96,15 @@ function AppShellHeader({ brand, nav, controls, subnav, menuLabel, hideChromeInP
|
|
|
93
96
|
* reachable and nothing scrolls sideways. The menu is a native `<details>`, so it works with the
|
|
94
97
|
* keyboard, with a screen reader and on a page that never hydrates. From `md` the header is the
|
|
95
98
|
* row, and the `subnav` row, as they are.
|
|
99
|
+
*
|
|
100
|
+
* `controls` stay in the row at every width, so keep them few (a theme toggle, the viewer's avatar);
|
|
101
|
+
* on a phone a crowded `controls` clips, and the Menu button is never the part cut off. Pickers and
|
|
102
|
+
* actions that do not fit a phone row go in `menuControls`: rendered once, they sit in the row after
|
|
103
|
+
* `controls` from `md`, and below `md` at the top of the Menu, above `nav` and `subnav`.
|
|
96
104
|
*/
|
|
97
|
-
export function AppShell({ brand, nav, controls, header, subnav, menuLabel = "Menu", footer, bleed = false, hideChromeInPrint = false, children, ...rest }) {
|
|
105
|
+
export function AppShell({ brand, nav, controls, menuControls, header, subnav, menuLabel = "Menu", footer, bleed = false, hideChromeInPrint = false, children, ...rest }) {
|
|
98
106
|
return (_jsxs("div", { ...rest, className: cx("flex min-h-dvh flex-col bg-gray-50 font-sans text-slate-900 antialiased",
|
|
99
107
|
// The sticky header's height (its rows and the bottom border), for a bar that sticks under it.
|
|
100
108
|
// Below `md` it is one row whatever the slots are: the subnav is in the menu.
|
|
101
|
-
"[--app-header:calc(3.5rem+1px)]", subnav ? "md:[--app-header:calc(6.5rem+1px)]" : undefined), children: [_jsx(AppShellHeader, { brand: brand, nav: nav, controls: controls, subnav: subnav, menuLabel: menuLabel, hideChromeInPrint: hideChromeInPrint, children: header }), _jsx("main", { className: "flex flex-1 flex-col", children: bleed ? (children) : (_jsx(ContainerFrame, { className: cx("py-8", hideChromeInPrint && "print:p-0"), children: children })) }), footer && (_jsx("footer", { className: cx("border-t border-slate-200", hideChromeInPrint && "print:hidden"), children: _jsx(ContainerFrame, { className: "py-6 text-sm text-slate-500", children: footer }) }))] }));
|
|
109
|
+
"[--app-header:calc(3.5rem+1px)]", subnav ? "md:[--app-header:calc(6.5rem+1px)]" : undefined), children: [_jsx(AppShellHeader, { brand: brand, nav: nav, controls: controls, menuControls: menuControls, subnav: subnav, menuLabel: menuLabel, hideChromeInPrint: hideChromeInPrint, children: header }), _jsx("main", { className: "flex flex-1 flex-col", children: bleed ? (children) : (_jsx(ContainerFrame, { className: cx("py-8", hideChromeInPrint && "print:p-0"), children: children })) }), footer && (_jsx("footer", { className: cx("border-t border-slate-200", hideChromeInPrint && "print:hidden"), children: _jsx(ContainerFrame, { className: "py-6 text-sm text-slate-500", children: footer }) }))] }));
|
|
102
110
|
}
|
package/dist/AppShellMenu.d.ts
CHANGED
|
@@ -6,8 +6,15 @@ import { type ReactElement, type ReactNode } from "react";
|
|
|
6
6
|
* is out of flow (it hangs under the header, `top-full`), so the header keeps its one height open
|
|
7
7
|
* or closed. With script it also closes on Escape (focus returns to the button) and when a link in
|
|
8
8
|
* it is followed, for a router that changes the page without a reload.
|
|
9
|
+
*
|
|
10
|
+
* `controls` (AppShell's `menuControls`) sit at the top of the panel, above the links. From `md`
|
|
11
|
+
* the `<details>` drops its own box and shows only them, inline at the row's end, whether it is
|
|
12
|
+
* open or not: the summary and the link copies are hidden (the links render in their own rows from
|
|
13
|
+
* `md`) and the panel is a plain row. They are rendered once, so a field keeps its state.
|
|
9
14
|
*/
|
|
10
|
-
export declare function AppShellMenu({ label, children, }: {
|
|
15
|
+
export declare function AppShellMenu({ label, controls, children, }: {
|
|
11
16
|
label: string;
|
|
17
|
+
/** Rendered once: at the top of the panel below `md`, inline in the row from `md`. */
|
|
18
|
+
controls?: ReactNode;
|
|
12
19
|
children: ReactNode;
|
|
13
20
|
}): ReactElement;
|
package/dist/AppShellMenu.js
CHANGED
|
@@ -21,8 +21,13 @@ const ICON = {
|
|
|
21
21
|
* is out of flow (it hangs under the header, `top-full`), so the header keeps its one height open
|
|
22
22
|
* or closed. With script it also closes on Escape (focus returns to the button) and when a link in
|
|
23
23
|
* it is followed, for a router that changes the page without a reload.
|
|
24
|
+
*
|
|
25
|
+
* `controls` (AppShell's `menuControls`) sit at the top of the panel, above the links. From `md`
|
|
26
|
+
* the `<details>` drops its own box and shows only them, inline at the row's end, whether it is
|
|
27
|
+
* open or not: the summary and the link copies are hidden (the links render in their own rows from
|
|
28
|
+
* `md`) and the panel is a plain row. They are rendered once, so a field keeps its state.
|
|
24
29
|
*/
|
|
25
|
-
export function AppShellMenu({ label, children, }) {
|
|
30
|
+
export function AppShellMenu({ label, controls, children, }) {
|
|
26
31
|
const details = useRef(null);
|
|
27
32
|
const close = (focusButton) => {
|
|
28
33
|
const el = details.current;
|
|
@@ -32,11 +37,17 @@ export function AppShellMenu({ label, children, }) {
|
|
|
32
37
|
if (focusButton)
|
|
33
38
|
el.querySelector("summary")?.focus();
|
|
34
39
|
};
|
|
35
|
-
return (_jsxs("details", { ref: details, className: "group
|
|
36
|
-
|
|
40
|
+
return (_jsxs("details", { ref: details, className: cx("group shrink-0", controls
|
|
41
|
+
? "md:contents md:[&::details-content]:contents md:[&::details-content]:[content-visibility:visible]"
|
|
42
|
+
: "md:hidden"), onKeyDown: (event) => {
|
|
43
|
+
// A dialog opened from a control in the menu takes its own Escape; the menu stays open.
|
|
44
|
+
if (event.key === "Escape" &&
|
|
45
|
+
!(event.target instanceof Element && event.target.closest("dialog")))
|
|
37
46
|
close(true);
|
|
38
|
-
}, children: [_jsxs("summary", { className: cx("flex cursor-pointer list-none items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 select-none hover:bg-slate-50 [&::-webkit-details-marker]:hidden", focusRing), children: [_jsx("svg", { ...ICON, className: cx(ICON.className, "group-open:hidden"), children: _jsx("path", { d: "M3 5.5h14M3 10h14M3 14.5h14" }) }), _jsx("svg", { ...ICON, className: cx(ICON.className, "hidden group-open:block"), children: _jsx("path", { d: "M5 5l10 10M15 5L5 15" }) }), label] }), _jsx("div", {
|
|
47
|
+
}, children: [_jsxs("summary", { className: cx("flex cursor-pointer list-none md:hidden items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 select-none hover:bg-slate-50 [&::-webkit-details-marker]:hidden", focusRing), children: [_jsx("svg", { ...ICON, className: cx(ICON.className, "group-open:hidden"), children: _jsx("path", { d: "M3 5.5h14M3 10h14M3 14.5h14" }) }), _jsx("svg", { ...ICON, className: cx(ICON.className, "hidden group-open:block"), children: _jsx("path", { d: "M5 5l10 10M15 5L5 15" }) }), label] }), _jsx("div", {
|
|
48
|
+
// From `md` the panel draws no box (`contents`), so its dropdown look applies below `md` only.
|
|
49
|
+
className: "absolute inset-x-0 top-full max-h-[calc(100dvh-var(--app-header))] overflow-y-auto overscroll-contain border-y border-slate-200 bg-white shadow-lg md:contents", onClick: (event) => {
|
|
39
50
|
if (event.target instanceof Element && event.target.closest("a[href]"))
|
|
40
51
|
close(false);
|
|
41
|
-
}, children:
|
|
52
|
+
}, children: _jsxs(ContainerFrame, { className: "flex flex-col gap-3 py-3 md:contents", children: [controls && (_jsx("div", { className: "flex flex-wrap items-center gap-2 md:flex-nowrap md:*:shrink-0 md:[&>.w-full]:w-auto", children: controls })), _jsx("div", { className: "flex flex-col gap-3 md:hidden", children: children })] }) })] }));
|
|
42
53
|
}
|
|
@@ -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.
|
|
3
|
+
"version": "0.10.9",
|
|
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",
|