@hackcove/ui-react 0.9.1 → 0.10.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.
- package/MIGRATING.md +21 -0
- package/README.md +31 -11
- package/dist/Avatar.d.ts +12 -5
- package/dist/Avatar.js +17 -12
- package/dist/AvatarStack.js +2 -1
- package/dist/Button.d.ts +15 -1
- package/dist/Button.js +17 -6
- package/dist/Card.d.ts +8 -1
- package/dist/Card.js +15 -6
- package/dist/CardParts.d.ts +2 -1
- package/dist/CardParts.js +6 -2
- package/dist/Checkbox.d.ts +1 -1
- package/dist/Checkbox.js +1 -13
- package/dist/ColourSchemeToggle.d.ts +4 -2
- package/dist/ColourSchemeToggle.js +23 -10
- package/dist/Dialog.d.ts +2 -1
- package/dist/Dialog.js +44 -3
- package/dist/Disclosure.d.ts +10 -1
- package/dist/Disclosure.js +13 -3
- package/dist/Field.d.ts +9 -1
- package/dist/Field.js +5 -3
- package/dist/IconButton.d.ts +2 -0
- package/dist/IconButton.js +2 -2
- package/dist/Image.d.ts +14 -4
- package/dist/Image.js +22 -6
- package/dist/InlineEdit.d.ts +22 -0
- package/dist/InlineEdit.js +60 -0
- package/dist/KeyValueList.d.ts +9 -2
- package/dist/KeyValueList.js +8 -3
- package/dist/Lightbox.d.ts +2 -0
- package/dist/Lightbox.js +2 -1
- package/dist/ListRow.d.ts +11 -0
- package/dist/ListRow.js +28 -6
- package/dist/MediaTile.d.ts +11 -3
- package/dist/MediaTile.js +10 -7
- package/dist/PageLayouts.d.ts +22 -21
- package/dist/PageLayouts.js +17 -21
- package/dist/SideColumn.d.ts +26 -0
- package/dist/SideColumn.js +22 -0
- package/dist/SidebarLayout.d.ts +24 -0
- package/dist/SidebarLayout.js +22 -0
- package/dist/Stack.d.ts +10 -2
- package/dist/Stack.js +5 -3
- package/dist/StatusPages.d.ts +13 -3
- package/dist/StatusPages.js +8 -5
- package/dist/Switch.d.ts +42 -0
- package/dist/Switch.js +56 -0
- package/dist/TableEditCell.d.ts +9 -7
- package/dist/TableEditCell.js +12 -53
- package/dist/foldedColumn.d.ts +18 -0
- package/dist/foldedColumn.js +45 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/labelWords.d.ts +12 -0
- package/dist/labelWords.js +14 -0
- package/dist/scrollStop.d.ts +27 -0
- package/dist/scrollStop.js +68 -0
- package/dist/stickyColumn.d.ts +17 -0
- package/dist/stickyColumn.js +17 -0
- package/package.json +2 -2
package/dist/TableEditCell.d.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import type { ComponentProps, ReactElement, ReactNode } from "react";
|
|
2
2
|
import { TableCell } from "./Table.js";
|
|
3
3
|
/**
|
|
4
|
-
* A `TableCell` whose value opens an editor in place
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* closes it: both put the focus back on the value. A press outside it or
|
|
8
|
-
* focus where it went, so the editor saves its value on
|
|
9
|
-
* Put the control in the cell itself, not in a popover
|
|
10
|
-
* `TableCell`'s `align`, `tone`, `numeric`, `mono`,
|
|
4
|
+
* A `TableCell` whose value opens an editor in place: a cell that holds one `InlineEdit` and
|
|
5
|
+
* nothing else. It shows `children` as a button with a dotted underline; a click, Enter or Space
|
|
6
|
+
* swaps it for `renderEditor(close)` and moves the focus into the editor. The editor calls `close`
|
|
7
|
+
* when it has saved, and Esc closes it: both put the focus back on the value. A press outside it or
|
|
8
|
+
* Tab out of it closes it too and leaves the focus where it went, so the editor saves its value on
|
|
9
|
+
* its own change or submit, never on a close. Put the control in the cell itself, not in a popover
|
|
10
|
+
* of its own, which counts as outside. Takes `TableCell`'s `align`, `tone`, `numeric`, `mono`,
|
|
11
|
+
* `indent` and `secondary`. For an edit trigger in a cell that holds other content too, put an
|
|
12
|
+
* `InlineEdit` in a `TableCell`.
|
|
11
13
|
*/
|
|
12
14
|
export declare function TableEditCell({ label, renderEditor, children, ...cell }: {
|
|
13
15
|
/** What the button does, for a screen reader, heard after the value: "Edit category". It also names the editor. */
|
package/dist/TableEditCell.js
CHANGED
|
@@ -1,58 +1,17 @@
|
|
|
1
|
-
import { jsx as _jsx
|
|
2
|
-
import {
|
|
3
|
-
import { cx, focusRing } from "./cx.js";
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { InlineEdit } from "./InlineEdit.js";
|
|
4
3
|
import { TableCell } from "./Table.js";
|
|
5
|
-
/** What takes the focus when the editor opens, unless the editor already took it (`autoFocus`). */
|
|
6
|
-
const FOCUSABLE = "input, select, textarea, button, a[href]";
|
|
7
|
-
/** Whether `target` is a node outside `box`. */
|
|
8
|
-
function isOutside(box, target) {
|
|
9
|
-
return target instanceof Node && box !== null && !box.contains(target);
|
|
10
|
-
}
|
|
11
|
-
/** Closes the editor when a press lands outside `box`, for as long as `open`. */
|
|
12
|
-
function useDismissOnOutsidePress(box, open, dismiss) {
|
|
13
|
-
useEffect(() => {
|
|
14
|
-
if (!open)
|
|
15
|
-
return undefined;
|
|
16
|
-
const onPress = (event) => {
|
|
17
|
-
if (isOutside(box.current, event.target))
|
|
18
|
-
dismiss();
|
|
19
|
-
};
|
|
20
|
-
document.addEventListener("pointerdown", onPress);
|
|
21
|
-
return () => document.removeEventListener("pointerdown", onPress);
|
|
22
|
-
}, [box, open, dismiss]);
|
|
23
|
-
}
|
|
24
4
|
/**
|
|
25
|
-
* A `TableCell` whose value opens an editor in place
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* closes it: both put the focus back on the value. A press outside it or
|
|
29
|
-
* focus where it went, so the editor saves its value on
|
|
30
|
-
* Put the control in the cell itself, not in a popover
|
|
31
|
-
* `TableCell`'s `align`, `tone`, `numeric`, `mono`,
|
|
5
|
+
* A `TableCell` whose value opens an editor in place: a cell that holds one `InlineEdit` and
|
|
6
|
+
* nothing else. It shows `children` as a button with a dotted underline; a click, Enter or Space
|
|
7
|
+
* swaps it for `renderEditor(close)` and moves the focus into the editor. The editor calls `close`
|
|
8
|
+
* when it has saved, and Esc closes it: both put the focus back on the value. A press outside it or
|
|
9
|
+
* Tab out of it closes it too and leaves the focus where it went, so the editor saves its value on
|
|
10
|
+
* its own change or submit, never on a close. Put the control in the cell itself, not in a popover
|
|
11
|
+
* of its own, which counts as outside. Takes `TableCell`'s `align`, `tone`, `numeric`, `mono`,
|
|
12
|
+
* `indent` and `secondary`. For an edit trigger in a cell that holds other content too, put an
|
|
13
|
+
* `InlineEdit` in a `TableCell`.
|
|
32
14
|
*/
|
|
33
15
|
export function TableEditCell({ label, renderEditor, children, ...cell }) {
|
|
34
|
-
|
|
35
|
-
const open = phase === "open";
|
|
36
|
-
const box = useRef(null);
|
|
37
|
-
const trigger = useRef(null);
|
|
38
|
-
const dismiss = useCallback(() => setPhase("closed"), []);
|
|
39
|
-
const close = () => setPhase("returning");
|
|
40
|
-
useDismissOnOutsidePress(box, open, dismiss);
|
|
41
|
-
useEffect(() => {
|
|
42
|
-
if (phase === "returning")
|
|
43
|
-
trigger.current?.focus();
|
|
44
|
-
if (phase === "open" && !box.current?.contains(document.activeElement))
|
|
45
|
-
box.current?.querySelector(FOCUSABLE)?.focus();
|
|
46
|
-
}, [phase]);
|
|
47
|
-
const onKeyDown = (event) => {
|
|
48
|
-
if (event.key !== "Escape")
|
|
49
|
-
return;
|
|
50
|
-
event.preventDefault();
|
|
51
|
-
event.stopPropagation();
|
|
52
|
-
close();
|
|
53
|
-
};
|
|
54
|
-
return (_jsx(TableCell, { ...cell, children: open ? (_jsx("div", { ref: box, role: "group", "aria-label": label, className: "-mx-2 -my-1", onKeyDown: onKeyDown, onBlur: (event) => {
|
|
55
|
-
if (isOutside(event.currentTarget, event.relatedTarget))
|
|
56
|
-
dismiss();
|
|
57
|
-
}, children: renderEditor(close) })) : (_jsxs("button", { ref: trigger, type: "button", onClick: () => setPhase("open"), className: cx("-mx-2 -my-1 block w-[calc(100%+1rem)] cursor-pointer rounded-md px-2 py-1 [text-align:inherit] underline decoration-dotted underline-offset-4 transition hover:bg-slate-100", focusRing), children: [children, _jsxs("span", { className: "sr-only", children: [" ", label] })] })) }));
|
|
16
|
+
return (_jsx(TableCell, { ...cell, children: _jsx(InlineEdit, { label: label, renderEditor: renderEditor, children: children }) }));
|
|
58
17
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode } from "react";
|
|
2
|
+
/** The breakpoint a folded column opens up at: below it the column sits behind a disclosure. */
|
|
3
|
+
export type FoldFrom = "md" | "lg";
|
|
4
|
+
/**
|
|
5
|
+
* A side column that folds behind a native `<details>` disclosure named `label`, closed, below
|
|
6
|
+
* `from`, and is the same column, always open, from `from` up. The column stays mounted while it is
|
|
7
|
+
* folded, so state inside it is kept. Shared by `SidebarLayout` (`md`) and `SideColumn` (`lg`).
|
|
8
|
+
*
|
|
9
|
+
* From `from` up the CSS shows the content of the closed `<details>` (the first frame, and a page
|
|
10
|
+
* that never hydrates). But a closed `<details>` is hidden to the accessibility tree and to axe, so
|
|
11
|
+
* once mounted an effect also opens it for real from `from` up and closes it again below. There is
|
|
12
|
+
* no `open` prop, so React never fights the browser's toggle and the server markup stays closed.
|
|
13
|
+
*/
|
|
14
|
+
export declare function FoldedColumn({ from, label, children, }: {
|
|
15
|
+
from: FoldFrom;
|
|
16
|
+
label: string;
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
}): ReactElement;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { cx, focusRing } from "./cx.js";
|
|
4
|
+
// Each variant is written out whole, so Tailwind sees every class.
|
|
5
|
+
const FOLD = {
|
|
6
|
+
md: {
|
|
7
|
+
query: "(width >= 48rem)",
|
|
8
|
+
content: "md:[&::details-content]:[content-visibility:visible]",
|
|
9
|
+
summary: "md:hidden",
|
|
10
|
+
body: "md:mt-0",
|
|
11
|
+
},
|
|
12
|
+
lg: {
|
|
13
|
+
query: "(width >= 64rem)",
|
|
14
|
+
content: "lg:[&::details-content]:[content-visibility:visible]",
|
|
15
|
+
summary: "lg:hidden",
|
|
16
|
+
body: "lg:mt-0",
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* A side column that folds behind a native `<details>` disclosure named `label`, closed, below
|
|
21
|
+
* `from`, and is the same column, always open, from `from` up. The column stays mounted while it is
|
|
22
|
+
* folded, so state inside it is kept. Shared by `SidebarLayout` (`md`) and `SideColumn` (`lg`).
|
|
23
|
+
*
|
|
24
|
+
* From `from` up the CSS shows the content of the closed `<details>` (the first frame, and a page
|
|
25
|
+
* that never hydrates). But a closed `<details>` is hidden to the accessibility tree and to axe, so
|
|
26
|
+
* once mounted an effect also opens it for real from `from` up and closes it again below. There is
|
|
27
|
+
* no `open` prop, so React never fights the browser's toggle and the server markup stays closed.
|
|
28
|
+
*/
|
|
29
|
+
export function FoldedColumn({ from, label, children, }) {
|
|
30
|
+
const fold = FOLD[from];
|
|
31
|
+
const details = useRef(null);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const el = details.current;
|
|
34
|
+
if (!el || typeof window.matchMedia !== "function")
|
|
35
|
+
return undefined;
|
|
36
|
+
const wide = window.matchMedia(fold.query);
|
|
37
|
+
const sync = () => {
|
|
38
|
+
el.open = wide.matches;
|
|
39
|
+
};
|
|
40
|
+
sync();
|
|
41
|
+
wide.addEventListener("change", sync);
|
|
42
|
+
return () => wide.removeEventListener("change", sync);
|
|
43
|
+
}, [fold.query]);
|
|
44
|
+
return (_jsxs("details", { ref: details, className: cx("group", fold.content), children: [_jsxs("summary", { className: cx("flex cursor-pointer list-none items-center justify-between 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", fold.summary, focusRing), children: [label, _jsx("svg", { viewBox: "0 0 20 20", width: "16", height: "16", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", className: "shrink-0 text-slate-500 group-open:rotate-180", children: _jsx("path", { d: "M5 8l5 5 5-5" }) })] }), _jsx("div", { className: cx("mt-2", fold.body), children: children })] }));
|
|
45
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,7 +10,9 @@ export { Markdown } from "./Markdown.js";
|
|
|
10
10
|
export { AppShell, AppShellBrand, type AppShellBreakpoint, AppShellHideBelow, appShellRootClassFor, } from "./AppShell.js";
|
|
11
11
|
export { Container } from "./Container.js";
|
|
12
12
|
export { PageHeader, type PageHeaderProps } from "./PageHeader.js";
|
|
13
|
-
export { CardGrid, CenteredLayout, DetailLayout, FormLayout, FullBleedLayout, ListLayout, ProseLayout,
|
|
13
|
+
export { CardGrid, CenteredLayout, DetailLayout, FormLayout, FullBleedLayout, ListLayout, ProseLayout, } from "./PageLayouts.js";
|
|
14
|
+
export { SidebarLayout } from "./SidebarLayout.js";
|
|
15
|
+
export { SideColumn, type SideColumnWidth } from "./SideColumn.js";
|
|
14
16
|
export { StickyBar } from "./StickyBar.js";
|
|
15
17
|
export { ErrorPage, Forbidden, Maintenance, NotFound, Offline } from "./StatusPages.js";
|
|
16
18
|
export { Cluster, ClusterGrow, type ClusterAlign, type ClusterGap, type ClusterJustify, } from "./Cluster.js";
|
|
@@ -26,6 +28,7 @@ export { Callout, type CalloutTone } from "./Callout.js";
|
|
|
26
28
|
export { ListRow, type ListRowProps } from "./ListRow.js";
|
|
27
29
|
export { OutlineRegion, RegionFrame, type RegionRect } from "./OutlineRegion.js";
|
|
28
30
|
export { type CellTone, type RowTone, Table, TableBody, TableCell, TableFoot, TableGroupRow, TableHead, TableHeaderCell, TableRow, } from "./Table.js";
|
|
31
|
+
export { InlineEdit } from "./InlineEdit.js";
|
|
29
32
|
export { TableEditCell } from "./TableEditCell.js";
|
|
30
33
|
export { EmptyState } from "./EmptyState.js";
|
|
31
34
|
export { Step, Steps } from "./Steps.js";
|
|
@@ -49,6 +52,7 @@ export { Tabs, type Tab, type TabsProps } from "./Tabs.js";
|
|
|
49
52
|
export { SegmentedControl, type Segment } from "./SegmentedControl.js";
|
|
50
53
|
export { FilterGroup } from "./FilterGroup.js";
|
|
51
54
|
export { Checkbox } from "./Checkbox.js";
|
|
55
|
+
export { Switch } from "./Switch.js";
|
|
52
56
|
export { Radio, RadioGroup } from "./RadioGroup.js";
|
|
53
57
|
export { FileDrop } from "./FileDrop.js";
|
|
54
58
|
export { CopyButton } from "./CopyButton.js";
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,9 @@ export { Markdown } from "./Markdown.js";
|
|
|
10
10
|
export { AppShell, AppShellBrand, AppShellHideBelow, appShellRootClassFor, } from "./AppShell.js";
|
|
11
11
|
export { Container } from "./Container.js";
|
|
12
12
|
export { PageHeader } from "./PageHeader.js";
|
|
13
|
-
export { CardGrid, CenteredLayout, DetailLayout, FormLayout, FullBleedLayout, ListLayout, ProseLayout,
|
|
13
|
+
export { CardGrid, CenteredLayout, DetailLayout, FormLayout, FullBleedLayout, ListLayout, ProseLayout, } from "./PageLayouts.js";
|
|
14
|
+
export { SidebarLayout } from "./SidebarLayout.js";
|
|
15
|
+
export { SideColumn } from "./SideColumn.js";
|
|
14
16
|
export { StickyBar } from "./StickyBar.js";
|
|
15
17
|
export { ErrorPage, Forbidden, Maintenance, NotFound, Offline } from "./StatusPages.js";
|
|
16
18
|
export { Cluster, ClusterGrow, } from "./Cluster.js";
|
|
@@ -26,6 +28,7 @@ export { Callout } from "./Callout.js";
|
|
|
26
28
|
export { ListRow } from "./ListRow.js";
|
|
27
29
|
export { OutlineRegion, RegionFrame } from "./OutlineRegion.js";
|
|
28
30
|
export { Table, TableBody, TableCell, TableFoot, TableGroupRow, TableHead, TableHeaderCell, TableRow, } from "./Table.js";
|
|
31
|
+
export { InlineEdit } from "./InlineEdit.js";
|
|
29
32
|
export { TableEditCell } from "./TableEditCell.js";
|
|
30
33
|
export { EmptyState } from "./EmptyState.js";
|
|
31
34
|
export { Step, Steps } from "./Steps.js";
|
|
@@ -47,6 +50,7 @@ export { Tabs } from "./Tabs.js";
|
|
|
47
50
|
export { SegmentedControl } from "./SegmentedControl.js";
|
|
48
51
|
export { FilterGroup } from "./FilterGroup.js";
|
|
49
52
|
export { Checkbox } from "./Checkbox.js";
|
|
53
|
+
export { Switch } from "./Switch.js";
|
|
50
54
|
export { Radio, RadioGroup } from "./RadioGroup.js";
|
|
51
55
|
export { FileDrop } from "./FileDrop.js";
|
|
52
56
|
export { CopyButton } from "./CopyButton.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
/** The label, with its muted description under it when there is one. */
|
|
3
|
+
export declare function Words({ label, description, id, }: {
|
|
4
|
+
label: ReactNode;
|
|
5
|
+
description: ReactNode;
|
|
6
|
+
id: string | undefined;
|
|
7
|
+
}): ReactNode;
|
|
8
|
+
/** The description's id (none without a description) and the control's whole `aria-describedby`. */
|
|
9
|
+
export declare function useDescription(description: ReactNode, describedBy: string | undefined): {
|
|
10
|
+
descriptionId: string | undefined;
|
|
11
|
+
describedIds: string;
|
|
12
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
/** The label, with its muted description under it when there is one. */
|
|
4
|
+
export function Words({ label, description, id, }) {
|
|
5
|
+
if (id === undefined)
|
|
6
|
+
return label;
|
|
7
|
+
return (_jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { children: label }), _jsx("span", { id: id, className: "text-xs text-slate-500", children: description })] }));
|
|
8
|
+
}
|
|
9
|
+
/** The description's id (none without a description) and the control's whole `aria-describedby`. */
|
|
10
|
+
export function useDescription(description, describedBy) {
|
|
11
|
+
const generatedId = useId();
|
|
12
|
+
const descriptionId = description != null && description !== false ? generatedId : undefined;
|
|
13
|
+
return { descriptionId, describedIds: [describedBy, descriptionId].filter(Boolean).join(" ") };
|
|
14
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type HTMLAttributes } from "react";
|
|
2
|
+
/** Anything the keyboard can reach on its own. */
|
|
3
|
+
export declare const TABBABLE = ":is(a[href], button, input:not([type=\"hidden\"]), select, textarea, summary, iframe, audio[controls], video[controls], [contenteditable]:not([contenteditable=\"false\"]), [tabindex]):not([disabled], [tabindex=\"-1\"])";
|
|
4
|
+
/**
|
|
5
|
+
* The keyboard side of a box that scrolls (a `Dialog`'s body, a sticky column): a box that
|
|
6
|
+
* overflows and holds nothing the keyboard can reach has no way to be scrolled from the keyboard,
|
|
7
|
+
* so it takes a tab stop (axe's `scrollable-region-focusable`). A box that holds a link, a field or
|
|
8
|
+
* a button needs none: the keyboard scrolls it by moving focus inside. `attach` is the box's `ref`;
|
|
9
|
+
* `element` is the box once it is mounted; `stop` is whether it takes the tab stop. Attach it to
|
|
10
|
+
* nothing and `stop` stays false.
|
|
11
|
+
*/
|
|
12
|
+
export declare function useScrollStop(): {
|
|
13
|
+
element: HTMLElement | null;
|
|
14
|
+
attach: (el: HTMLDivElement | null) => void;
|
|
15
|
+
stop: boolean;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The props a scroller takes while it holds the tab stop: focusable, ringed and, when the column
|
|
19
|
+
* has a name, a group of that name so a screen reader says what the stop is. Nothing otherwise.
|
|
20
|
+
*/
|
|
21
|
+
export declare function scrollStopProps(stop: boolean, name?: {
|
|
22
|
+
label?: string | undefined;
|
|
23
|
+
labelledBy?: string | undefined;
|
|
24
|
+
}): {
|
|
25
|
+
props: HTMLAttributes<HTMLDivElement>;
|
|
26
|
+
ring: string;
|
|
27
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { focusRing } from "./cx.js";
|
|
3
|
+
/** Anything the keyboard can reach on its own. */
|
|
4
|
+
export const TABBABLE = ':is(a[href], button, input:not([type="hidden"]), select, textarea, summary, iframe, audio[controls], video[controls], [contenteditable]:not([contenteditable="false"]), [tabindex]):not([disabled], [tabindex="-1"])';
|
|
5
|
+
/** Whether something in `scroller` is on screen and reachable by the keyboard. */
|
|
6
|
+
function holdsTabbable(scroller) {
|
|
7
|
+
for (const el of scroller.querySelectorAll(TABBABLE)) {
|
|
8
|
+
// A `display: none` summary (a folded column's, from its breakpoint up) is not a tab stop.
|
|
9
|
+
// jsdom (a consumer's own tests) has no `checkVisibility`; there everything counts.
|
|
10
|
+
if (typeof el.checkVisibility !== "function" || el.checkVisibility())
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The keyboard side of a box that scrolls (a `Dialog`'s body, a sticky column): a box that
|
|
17
|
+
* overflows and holds nothing the keyboard can reach has no way to be scrolled from the keyboard,
|
|
18
|
+
* so it takes a tab stop (axe's `scrollable-region-focusable`). A box that holds a link, a field or
|
|
19
|
+
* a button needs none: the keyboard scrolls it by moving focus inside. `attach` is the box's `ref`;
|
|
20
|
+
* `element` is the box once it is mounted; `stop` is whether it takes the tab stop. Attach it to
|
|
21
|
+
* nothing and `stop` stays false.
|
|
22
|
+
*/
|
|
23
|
+
export function useScrollStop() {
|
|
24
|
+
const [element, setElement] = useState(null);
|
|
25
|
+
const [stop, setStop] = useState(false);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
// jsdom (a consumer's own tests) has no ResizeObserver.
|
|
28
|
+
if (!element || typeof ResizeObserver === "undefined")
|
|
29
|
+
return undefined;
|
|
30
|
+
const measure = () => setStop(element.scrollHeight > element.clientHeight && !holdsTabbable(element));
|
|
31
|
+
// Each observer reports once when it starts, and again when the box is resized, its content
|
|
32
|
+
// changes or something in it becomes (un)reachable by the keyboard.
|
|
33
|
+
const resize = new ResizeObserver(measure);
|
|
34
|
+
resize.observe(element);
|
|
35
|
+
for (const child of element.children)
|
|
36
|
+
resize.observe(child);
|
|
37
|
+
const change = new MutationObserver(measure);
|
|
38
|
+
change.observe(element, {
|
|
39
|
+
childList: true,
|
|
40
|
+
subtree: true,
|
|
41
|
+
characterData: true,
|
|
42
|
+
attributeFilter: ["disabled", "tabindex", "href", "type", "contenteditable", "open"],
|
|
43
|
+
});
|
|
44
|
+
return () => {
|
|
45
|
+
resize.disconnect();
|
|
46
|
+
change.disconnect();
|
|
47
|
+
};
|
|
48
|
+
}, [element]);
|
|
49
|
+
return { element, attach: setElement, stop: stop && element !== null };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The props a scroller takes while it holds the tab stop: focusable, ringed and, when the column
|
|
53
|
+
* has a name, a group of that name so a screen reader says what the stop is. Nothing otherwise.
|
|
54
|
+
*/
|
|
55
|
+
export function scrollStopProps(stop, name = {}) {
|
|
56
|
+
if (!stop)
|
|
57
|
+
return { props: {}, ring: "" };
|
|
58
|
+
const named = name.label !== undefined || name.labelledBy !== undefined;
|
|
59
|
+
return {
|
|
60
|
+
props: {
|
|
61
|
+
tabIndex: 0,
|
|
62
|
+
...(named ? { role: "group" } : {}),
|
|
63
|
+
...(name.label !== undefined ? { "aria-label": name.label } : {}),
|
|
64
|
+
...(name.labelledBy !== undefined ? { "aria-labelledby": name.labelledBy } : {}),
|
|
65
|
+
},
|
|
66
|
+
ring: focusRing,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A column that stays in view as the page scrolls (`SidebarLayout`'s rail from `md`, `DetailLayout`'s
|
|
3
|
+
* aside from `lg` when asked). The `<aside>` sticks directly under AppShell's header (`--app-header`,
|
|
4
|
+
* as `StickyBar` does; 0 outside a shell) and is as tall as its content. A sticky `top` also pushes a
|
|
5
|
+
* column that starts above it down, so it is exactly the header and no gap: a gap would move a rail
|
|
6
|
+
* rendered at the top of its box. Inside it the scroller is capped at the viewport less the header
|
|
7
|
+
* and 2rem (the room the Container's `py-8` leaves at the page's end, so the column is never pushed
|
|
8
|
+
* up under the header there), so a column taller than that scrolls itself and its end stays
|
|
9
|
+
* reachable. `overflow` clips, which would cut a keyboard focus ring (an outline 4px out) at the
|
|
10
|
+
* scroller's edges, so it is padded 4px (`p-1`) and pulled back out by the same (`-m-1`): the ring
|
|
11
|
+
* has room and the column stays on the page's edges. Each variant is written out whole, so Tailwind
|
|
12
|
+
* sees every class.
|
|
13
|
+
*/
|
|
14
|
+
export declare const STICKY_ASIDE_MD = "md:sticky md:top-[var(--app-header,0px)] md:self-start";
|
|
15
|
+
export declare const STICKY_SCROLL_MD = "md:-m-1 md:max-h-[calc(100dvh-var(--app-header,0px)-2rem)] md:overflow-y-auto md:p-1";
|
|
16
|
+
export declare const STICKY_ASIDE_LG = "lg:sticky lg:top-[var(--app-header,0px)] lg:self-start";
|
|
17
|
+
export declare const STICKY_SCROLL_LG = "lg:-m-1 lg:max-h-[calc(100dvh-var(--app-header,0px)-2rem)] lg:overflow-y-auto lg:p-1";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A column that stays in view as the page scrolls (`SidebarLayout`'s rail from `md`, `DetailLayout`'s
|
|
3
|
+
* aside from `lg` when asked). The `<aside>` sticks directly under AppShell's header (`--app-header`,
|
|
4
|
+
* as `StickyBar` does; 0 outside a shell) and is as tall as its content. A sticky `top` also pushes a
|
|
5
|
+
* column that starts above it down, so it is exactly the header and no gap: a gap would move a rail
|
|
6
|
+
* rendered at the top of its box. Inside it the scroller is capped at the viewport less the header
|
|
7
|
+
* and 2rem (the room the Container's `py-8` leaves at the page's end, so the column is never pushed
|
|
8
|
+
* up under the header there), so a column taller than that scrolls itself and its end stays
|
|
9
|
+
* reachable. `overflow` clips, which would cut a keyboard focus ring (an outline 4px out) at the
|
|
10
|
+
* scroller's edges, so it is padded 4px (`p-1`) and pulled back out by the same (`-m-1`): the ring
|
|
11
|
+
* has room and the column stays on the page's edges. Each variant is written out whole, so Tailwind
|
|
12
|
+
* sees every class.
|
|
13
|
+
*/
|
|
14
|
+
export const STICKY_ASIDE_MD = "md:sticky md:top-[var(--app-header,0px)] md:self-start";
|
|
15
|
+
export const STICKY_SCROLL_MD = "md:-m-1 md:max-h-[calc(100dvh-var(--app-header,0px)-2rem)] md:overflow-y-auto md:p-1";
|
|
16
|
+
export const STICKY_ASIDE_LG = "lg:sticky lg:top-[var(--app-header,0px)] lg:self-start";
|
|
17
|
+
export const STICKY_SCROLL_LG = "lg:-m-1 lg:max-h-[calc(100dvh-var(--app-header,0px)-2rem)] lg:overflow-y-auto lg:p-1";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hackcove/ui-react",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Hack Cove React components: primitives, typography roles and a safe Markdown renderer, written against Tailwind v4 utilities. Pair with a @hackcove/
|
|
3
|
+
"version": "0.10.0",
|
|
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",
|
|
7
7
|
"design-system",
|