@lepid-labs/ui-react 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ButtonGroup.d.ts +13 -0
- package/dist/ButtonGroup.js +14 -0
- package/dist/HoldButton.d.ts +23 -0
- package/dist/HoldButton.js +112 -0
- package/dist/StatusCard.d.ts +42 -0
- package/dist/StatusCard.js +35 -0
- package/dist/Stepper.d.ts +19 -0
- package/dist/Stepper.js +12 -0
- package/dist/Tabs.js +4 -1
- package/dist/button-group.d.ts +7 -0
- package/dist/button-group.js +11 -0
- package/dist/feedback.d.ts +5 -1
- package/dist/feedback.js +2 -2
- package/dist/hold.d.ts +31 -0
- package/dist/hold.js +82 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/slide-dir.d.ts +5 -0
- package/dist/slide-dir.js +12 -0
- package/dist/status-card.d.ts +15 -0
- package/dist/status-card.js +27 -0
- package/dist/stepper-state.d.ts +12 -0
- package/dist/stepper-state.js +24 -0
- package/dist/useSlideDir.d.ts +7 -0
- package/dist/useSlideDir.js +26 -0
- package/package.json +3 -2
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { HTMLAttributes } from "react";
|
|
2
|
+
export interface ButtonGroupProps extends HTMLAttributes<HTMLDivElement> {
|
|
3
|
+
/** Stretch to the full row with equal-width segments. Maps to .ld-btn-group--block. */
|
|
4
|
+
block?: boolean;
|
|
5
|
+
}
|
|
6
|
+
/** One long track with `Button`s lined up inside it. The theme strips the
|
|
7
|
+
* segments' own chrome, so children are plain `Button`s; name the group with
|
|
8
|
+
* `aria-label`. For a toggle / segmented control set `aria-pressed` on each
|
|
9
|
+
* `Button` — the theme lights the pressed segment from that attribute, and
|
|
10
|
+
* which one is pressed stays the caller's state. With a single pressed segment
|
|
11
|
+
* the theme slides one pill between segments; the group stamps `data-ld-dir`
|
|
12
|
+
* so the pill's leading edge reaches the new segment first. */
|
|
13
|
+
export declare function ButtonGroup({ block, className, ...rest }: ButtonGroupProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { buttonGroupAttrs } from "./button-group.js";
|
|
3
|
+
import { useSlideDir } from "./useSlideDir.js";
|
|
4
|
+
/** One long track with `Button`s lined up inside it. The theme strips the
|
|
5
|
+
* segments' own chrome, so children are plain `Button`s; name the group with
|
|
6
|
+
* `aria-label`. For a toggle / segmented control set `aria-pressed` on each
|
|
7
|
+
* `Button` — the theme lights the pressed segment from that attribute, and
|
|
8
|
+
* which one is pressed stays the caller's state. With a single pressed segment
|
|
9
|
+
* the theme slides one pill between segments; the group stamps `data-ld-dir`
|
|
10
|
+
* so the pill's leading edge reaches the new segment first. */
|
|
11
|
+
export function ButtonGroup({ block, className, ...rest }) {
|
|
12
|
+
const ref = useSlideDir('.ld-btn--active, [aria-pressed="true"]');
|
|
13
|
+
return _jsx("div", { ...buttonGroupAttrs(block, className), ...rest, ref: ref });
|
|
14
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { ButtonProps } from "./Button.js";
|
|
3
|
+
export interface HoldButtonProps extends Omit<ButtonProps, "onClick"> {
|
|
4
|
+
/** Commitment window in ms. Defaults to the theme's --ld-hold-duration (300 ms). */
|
|
5
|
+
duration?: number;
|
|
6
|
+
/** Fires once when the hold completes. */
|
|
7
|
+
onConfirm: () => void;
|
|
8
|
+
/** Optional secondary text under the label (announced via aria-describedby). */
|
|
9
|
+
hint?: ReactNode;
|
|
10
|
+
/** Show the live percentage. Default true. */
|
|
11
|
+
showMeter?: boolean;
|
|
12
|
+
/** Content of the circular well the ring wraps — usually an icon. */
|
|
13
|
+
icon?: ReactNode;
|
|
14
|
+
/** Keyboard escape hatch: a plain Space/Enter tap fires immediately instead
|
|
15
|
+
* of requiring a held key. Pair with a confirm dialog in onConfirm. */
|
|
16
|
+
confirmOnKeyboardTap?: boolean;
|
|
17
|
+
/** Announced to assistive tech once the hold fires. Default "Confirmed". */
|
|
18
|
+
confirmedLabel?: string;
|
|
19
|
+
}
|
|
20
|
+
/** Hold-to-confirm button: the ring around the icon fills over the commitment
|
|
21
|
+
* window and the action fires only when it completes. Releasing early cancels.
|
|
22
|
+
* Renders .ld-btn.ld-btn--hold with the ring/body/meter subparts. */
|
|
23
|
+
export declare function HoldButton({ duration, onConfirm, hint, showMeter, icon, confirmOnKeyboardTap, confirmedLabel, variant, size, className, style, children, disabled, onPointerDown, onPointerUp, onPointerCancel, onLostPointerCapture, onKeyDown, onKeyUp, onBlur, ...rest }: HoldButtonProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
3
|
+
import { cx } from "./cx.js";
|
|
4
|
+
import { createHoldController } from "./hold.js";
|
|
5
|
+
const SR_ONLY = {
|
|
6
|
+
position: "absolute",
|
|
7
|
+
width: 1,
|
|
8
|
+
height: 1,
|
|
9
|
+
margin: -1,
|
|
10
|
+
padding: 0,
|
|
11
|
+
overflow: "hidden",
|
|
12
|
+
clip: "rect(0 0 0 0)",
|
|
13
|
+
whiteSpace: "nowrap",
|
|
14
|
+
border: 0,
|
|
15
|
+
};
|
|
16
|
+
const HOLD_KEYS = new Set([" ", "Enter"]);
|
|
17
|
+
/** Parse a CSS <time> ("300ms" | "0.3s") into ms; undefined if unparseable. */
|
|
18
|
+
function parseCssTime(raw) {
|
|
19
|
+
const m = raw.trim().match(/^([\d.]+)\s*(ms|s)$/);
|
|
20
|
+
if (!m)
|
|
21
|
+
return undefined;
|
|
22
|
+
const n = Number(m[1]);
|
|
23
|
+
return m[2] === "s" ? n * 1000 : n;
|
|
24
|
+
}
|
|
25
|
+
/** Hold-to-confirm button: the ring around the icon fills over the commitment
|
|
26
|
+
* window and the action fires only when it completes. Releasing early cancels.
|
|
27
|
+
* Renders .ld-btn.ld-btn--hold with the ring/body/meter subparts. */
|
|
28
|
+
export function HoldButton({ duration, onConfirm, hint, showMeter = true, icon, confirmOnKeyboardTap = false, confirmedLabel = "Confirmed", variant = "default", size = "md", className, style, children, disabled, onPointerDown, onPointerUp, onPointerCancel, onLostPointerCapture, onKeyDown, onKeyUp, onBlur, ...rest }) {
|
|
29
|
+
const ref = useRef(null);
|
|
30
|
+
const ctl = useRef(null);
|
|
31
|
+
const latest = useRef({ duration, onConfirm });
|
|
32
|
+
latest.current = { duration, onConfirm };
|
|
33
|
+
const hintId = useId();
|
|
34
|
+
const [progress, setProgress] = useState(0);
|
|
35
|
+
const [state, setState] = useState("idle");
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const c = createHoldController({
|
|
38
|
+
duration: () => {
|
|
39
|
+
const explicit = latest.current.duration;
|
|
40
|
+
if (explicit !== undefined)
|
|
41
|
+
return explicit;
|
|
42
|
+
const el = ref.current;
|
|
43
|
+
const fromTheme = el && parseCssTime(getComputedStyle(el).getPropertyValue("--ld-hold-duration"));
|
|
44
|
+
return fromTheme ?? 300;
|
|
45
|
+
},
|
|
46
|
+
onProgress: setProgress,
|
|
47
|
+
onConfirm: () => latest.current.onConfirm(),
|
|
48
|
+
onStateChange: setState,
|
|
49
|
+
});
|
|
50
|
+
ctl.current = c;
|
|
51
|
+
return () => c.dispose();
|
|
52
|
+
}, []);
|
|
53
|
+
const start = useCallback(() => {
|
|
54
|
+
if (!disabled)
|
|
55
|
+
ctl.current?.start();
|
|
56
|
+
}, [disabled]);
|
|
57
|
+
const cancel = useCallback(() => ctl.current?.cancel(), []);
|
|
58
|
+
const handlePointerDown = (e) => {
|
|
59
|
+
onPointerDown?.(e);
|
|
60
|
+
if (e.defaultPrevented || e.button !== 0 || !e.isPrimary)
|
|
61
|
+
return;
|
|
62
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
63
|
+
start();
|
|
64
|
+
};
|
|
65
|
+
const handlePointerUp = (e) => {
|
|
66
|
+
onPointerUp?.(e);
|
|
67
|
+
cancel();
|
|
68
|
+
};
|
|
69
|
+
const handlePointerCancel = (e) => {
|
|
70
|
+
onPointerCancel?.(e);
|
|
71
|
+
cancel();
|
|
72
|
+
};
|
|
73
|
+
const handleLostCapture = (e) => {
|
|
74
|
+
onLostPointerCapture?.(e);
|
|
75
|
+
cancel();
|
|
76
|
+
};
|
|
77
|
+
const handleKeyDown = (e) => {
|
|
78
|
+
onKeyDown?.(e);
|
|
79
|
+
if (e.defaultPrevented)
|
|
80
|
+
return;
|
|
81
|
+
if (e.key === "Escape") {
|
|
82
|
+
cancel();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (!HOLD_KEYS.has(e.key))
|
|
86
|
+
return;
|
|
87
|
+
e.preventDefault(); // suppress the native click that Space/Enter would synthesize
|
|
88
|
+
if (e.repeat)
|
|
89
|
+
return;
|
|
90
|
+
if (confirmOnKeyboardTap) {
|
|
91
|
+
if (!disabled)
|
|
92
|
+
ctl.current?.fire();
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
start();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const handleKeyUp = (e) => {
|
|
99
|
+
onKeyUp?.(e);
|
|
100
|
+
if (HOLD_KEYS.has(e.key)) {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
cancel();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const handleBlur = (e) => {
|
|
106
|
+
onBlur?.(e);
|
|
107
|
+
cancel();
|
|
108
|
+
};
|
|
109
|
+
const percent = Math.round(progress * 100);
|
|
110
|
+
const classes = cx("ld-btn", "ld-btn--hold", variant !== "default" && `ld-btn--${variant}`, size === "sm" && "ld-btn--sm", className);
|
|
111
|
+
return (_jsxs("button", { ref: ref, type: "button", className: classes, style: { ...style, "--ld-hold": progress }, "data-ld-hold": state === "idle" ? undefined : state, "aria-describedby": hint !== undefined ? hintId : undefined, disabled: disabled, onPointerDown: handlePointerDown, onPointerUp: handlePointerUp, onPointerCancel: handlePointerCancel, onLostPointerCapture: handleLostCapture, onKeyDown: handleKeyDown, onKeyUp: handleKeyUp, onBlur: handleBlur, ...rest, children: [_jsx("span", { className: "ld-btn__ring", "aria-hidden": "true", children: icon }), _jsxs("span", { className: "ld-btn__body", children: [_jsx("span", { className: "ld-btn__label", children: children }), hint !== undefined && (_jsx("span", { className: "ld-btn__hint", id: hintId, children: hint }))] }), showMeter && (_jsxs("span", { className: "ld-btn__meter", "aria-hidden": "true", children: [percent, "%"] })), _jsx("span", { style: SR_ONLY, "aria-live": "polite", children: state === "fired" ? confirmedLabel : "" })] }));
|
|
112
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
import { type StatusTone } from "./status-card.js";
|
|
3
|
+
export interface StatusCardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title" | "prefix"> {
|
|
4
|
+
title: ReactNode;
|
|
5
|
+
/** Renders the title as a link. */
|
|
6
|
+
href?: string;
|
|
7
|
+
/** Open `href` in a new tab (adds the noopener/noreferrer rel). */
|
|
8
|
+
external?: boolean;
|
|
9
|
+
/** Faint lead-in before the title — an owner, group, or namespace. */
|
|
10
|
+
prefix?: ReactNode;
|
|
11
|
+
/** Colors the left status edge. */
|
|
12
|
+
tone?: StatusTone;
|
|
13
|
+
/** Outline + glow ring calling for action; `true` means warning. */
|
|
14
|
+
attention?: boolean | StatusTone;
|
|
15
|
+
/** Bump (e.g. `Date.now()`) whenever the card's data changes to replay the flash. */
|
|
16
|
+
changedAt?: number;
|
|
17
|
+
/** Faint icon in the top-left corner (a lock, a pin). */
|
|
18
|
+
watermark?: ReactNode;
|
|
19
|
+
/** Right side of the head: badges, icon links. */
|
|
20
|
+
meta?: ReactNode;
|
|
21
|
+
/** Faint trailing text after `meta` — typically a relative time. */
|
|
22
|
+
note?: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
export declare function StatusCard({ title, href, external, prefix, tone, attention, changedAt, watermark, meta, note, className, children, ...rest }: StatusCardProps): import("react").JSX.Element;
|
|
25
|
+
/** `<ul>` of rows — the card's list body. */
|
|
26
|
+
export declare function StatusCardRows({ className, ...rest }: HTMLAttributes<HTMLUListElement>): import("react").JSX.Element;
|
|
27
|
+
export interface StatusCardRowProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "prefix"> {
|
|
28
|
+
/** Narrow right-aligned lead column — an id, a number. */
|
|
29
|
+
leading?: ReactNode;
|
|
30
|
+
/** Compact trailing cluster — badges, counts, icons. */
|
|
31
|
+
trailing?: ReactNode;
|
|
32
|
+
external?: boolean;
|
|
33
|
+
}
|
|
34
|
+
/** One row: `<li>` wrapping an `<a>` when `href` is set, else a `<div>`. */
|
|
35
|
+
export declare function StatusCardRow({ leading, trailing, external, href, className, children, ...rest }: StatusCardRowProps): import("react").JSX.Element;
|
|
36
|
+
export interface StatusCardFooterProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
37
|
+
external?: boolean;
|
|
38
|
+
}
|
|
39
|
+
/** Faint footer line — an overflow link ("4 more…") or a plain summary. */
|
|
40
|
+
export declare function StatusCardFooter({ external, href, className, ...rest }: StatusCardFooterProps): import("react").JSX.Element;
|
|
41
|
+
/** Empty-state line under the head ("No open issues"). */
|
|
42
|
+
export declare function StatusCardEmpty({ className, ...rest }: HTMLAttributes<HTMLParagraphElement>): import("react").JSX.Element;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { cx } from "./cx.js";
|
|
4
|
+
import { replayFlash, statusCardAttrs } from "./status-card.js";
|
|
5
|
+
export function StatusCard({ title, href, external, prefix, tone, attention, changedAt, watermark, meta, note, className, children, ...rest }) {
|
|
6
|
+
const root = useRef(null);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
if (changedAt && root.current)
|
|
9
|
+
replayFlash(root.current);
|
|
10
|
+
}, [changedAt]);
|
|
11
|
+
const heading = (_jsxs(_Fragment, { children: [prefix !== undefined && _jsx("span", { className: "ld-status-card__prefix", children: prefix }), title] }));
|
|
12
|
+
return (_jsxs("div", { ref: root, ...statusCardAttrs(tone, attention, className), ...rest, children: [watermark !== undefined && (_jsx("span", { className: "ld-status-card__watermark", "aria-hidden": "true", children: watermark })), _jsxs("div", { className: "ld-status-card__head", children: [href ? (_jsx("a", { className: "ld-status-card__title", href: href, ...linkTarget(external), children: heading })) : (_jsx("span", { className: "ld-status-card__title", children: heading })), (meta !== undefined || note !== undefined) && (_jsxs("div", { className: "ld-status-card__meta", children: [meta, note !== undefined && _jsx("span", { className: "ld-status-card__note", children: note })] }))] }), children] }));
|
|
13
|
+
}
|
|
14
|
+
/** `<ul>` of rows — the card's list body. */
|
|
15
|
+
export function StatusCardRows({ className, ...rest }) {
|
|
16
|
+
return _jsx("ul", { className: cx("ld-status-card__rows", className), ...rest });
|
|
17
|
+
}
|
|
18
|
+
/** One row: `<li>` wrapping an `<a>` when `href` is set, else a `<div>`. */
|
|
19
|
+
export function StatusCardRow({ leading, trailing, external, href, className, children, ...rest }) {
|
|
20
|
+
const body = (_jsxs(_Fragment, { children: [leading !== undefined && _jsx("span", { className: "ld-status-card__row-lead", children: leading }), _jsx("span", { className: "ld-status-card__row-main", children: children }), trailing !== undefined && _jsx("span", { className: "ld-status-card__row-trail", children: trailing })] }));
|
|
21
|
+
const cls = cx("ld-status-card__row", className);
|
|
22
|
+
return (_jsx("li", { children: href ? (_jsx("a", { className: cls, href: href, ...linkTarget(external), ...rest, children: body })) : (_jsx("div", { className: cls, ...rest, children: body })) }));
|
|
23
|
+
}
|
|
24
|
+
/** Faint footer line — an overflow link ("4 more…") or a plain summary. */
|
|
25
|
+
export function StatusCardFooter({ external, href, className, ...rest }) {
|
|
26
|
+
const cls = cx("ld-status-card__footer", className);
|
|
27
|
+
return href ? (_jsx("a", { className: cls, href: href, ...linkTarget(external), ...rest })) : (_jsx("div", { className: cls, ...rest }));
|
|
28
|
+
}
|
|
29
|
+
/** Empty-state line under the head ("No open issues"). */
|
|
30
|
+
export function StatusCardEmpty({ className, ...rest }) {
|
|
31
|
+
return _jsx("p", { className: cx("ld-status-card__empty", className), ...rest });
|
|
32
|
+
}
|
|
33
|
+
function linkTarget(external) {
|
|
34
|
+
return external ? { target: "_blank", rel: "noopener noreferrer" } : {};
|
|
35
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
export interface StepItem {
|
|
3
|
+
id: string;
|
|
4
|
+
label: ReactNode;
|
|
5
|
+
/** Icon inside the node. Defaults to a check mark once complete, else the step number. */
|
|
6
|
+
icon?: ReactNode;
|
|
7
|
+
}
|
|
8
|
+
export interface StepperProps extends HTMLAttributes<HTMLOListElement> {
|
|
9
|
+
steps: StepItem[];
|
|
10
|
+
/** Index of the step in progress. Earlier steps are complete, later ones upcoming;
|
|
11
|
+
* pass `steps.length` when everything is done. */
|
|
12
|
+
current: number;
|
|
13
|
+
/** Extra class for every step `<li>`. */
|
|
14
|
+
stepClassName?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Horizontal milestone rail: labelled step nodes on a progress track. The
|
|
17
|
+
* theme CSS fills the rail up to the current node from the state modifiers,
|
|
18
|
+
* so `current` is the only piece of state. */
|
|
19
|
+
export declare function Stepper({ steps, current, stepClassName, className, ...rest }: StepperProps): import("react").JSX.Element;
|
package/dist/Stepper.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
import { stepAttrs, stepState } from "./stepper-state.js";
|
|
4
|
+
/** Horizontal milestone rail: labelled step nodes on a progress track. The
|
|
5
|
+
* theme CSS fills the rail up to the current node from the state modifiers,
|
|
6
|
+
* so `current` is the only piece of state. */
|
|
7
|
+
export function Stepper({ steps, current, stepClassName, className, ...rest }) {
|
|
8
|
+
return (_jsx("ol", { className: cx("ld-stepper", className), ...rest, children: steps.map((step, index) => (_jsxs("li", { ...stepAttrs(index, current, stepClassName), children: [_jsx("span", { className: "ld-stepper__node", "aria-hidden": "true", children: step.icon ?? (stepState(index, current) === "complete" ? _jsx(CheckIcon, {}) : index + 1) }), _jsx("span", { className: "ld-stepper__label", children: step.label })] }, step.id))) }));
|
|
9
|
+
}
|
|
10
|
+
function CheckIcon() {
|
|
11
|
+
return (_jsx("svg", { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M3 8.5l3 3 7-7" }) }));
|
|
12
|
+
}
|
package/dist/Tabs.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { cx } from "./cx.js";
|
|
4
|
+
import { useSlideDir } from "./useSlideDir.js";
|
|
4
5
|
export function Tabs({ items, active, defaultActive, onChange }) {
|
|
5
6
|
const [internal, setInternal] = useState(defaultActive ?? items[0]?.id);
|
|
6
7
|
const current = active ?? internal;
|
|
@@ -9,5 +10,7 @@ export function Tabs({ items, active, defaultActive, onChange }) {
|
|
|
9
10
|
onChange?.(id);
|
|
10
11
|
};
|
|
11
12
|
const activeItem = items.find((t) => t.id === current);
|
|
12
|
-
|
|
13
|
+
// data-ld-dir for the theme's sliding underline: which edge leads
|
|
14
|
+
const tablist = useSlideDir('[aria-selected="true"]');
|
|
15
|
+
return (_jsxs("div", { children: [_jsx("div", { ref: tablist, role: "tablist", className: "ld-tabs", children: items.map((t) => (_jsx("button", { role: "tab", type: "button", "aria-selected": t.id === current, className: cx("ld-tab", t.id === current && "ld-tab--active"), onClick: () => select(t.id), children: t.label }, t.id))) }), _jsx("div", { role: "tabpanel", className: "ld-tabpanel", children: activeItem?.content })] }));
|
|
13
16
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface ButtonGroupAttrs {
|
|
2
|
+
className: string;
|
|
3
|
+
role: "group";
|
|
4
|
+
}
|
|
5
|
+
/** Root class list and role for the group track. `block` stretches it to the
|
|
6
|
+
* row with equal-width segments. */
|
|
7
|
+
export declare function buttonGroupAttrs(block?: boolean, className?: string): ButtonGroupAttrs;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Pure attribute logic behind ButtonGroup, kept DOM-free so it can be tested
|
|
2
|
+
// under node:test without a renderer (so it imports nothing — Node runs the
|
|
3
|
+
// .ts source and cannot resolve the .js specifiers the build uses).
|
|
4
|
+
/** Root class list and role for the group track. `block` stretches it to the
|
|
5
|
+
* row with equal-width segments. */
|
|
6
|
+
export function buttonGroupAttrs(block, className) {
|
|
7
|
+
return {
|
|
8
|
+
className: ["ld-btn-group", block && "ld-btn-group--block", className].filter(Boolean).join(" "),
|
|
9
|
+
role: "group",
|
|
10
|
+
};
|
|
11
|
+
}
|
package/dist/feedback.d.ts
CHANGED
|
@@ -2,8 +2,12 @@ import type { HTMLAttributes, ReactNode } from "react";
|
|
|
2
2
|
export type SemanticVariant = "info" | "success" | "warning" | "danger";
|
|
3
3
|
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
|
4
4
|
variant?: SemanticVariant | "primary";
|
|
5
|
+
/** `sm` is the 9px dashboard pill used in StatusCard meta rows. */
|
|
6
|
+
size?: "sm";
|
|
7
|
+
/** Solid warning fill with a breathing glow — the one call to action. */
|
|
8
|
+
pulse?: boolean;
|
|
5
9
|
}
|
|
6
|
-
export declare function Badge({ variant, className, ...rest }: BadgeProps): import("react").JSX.Element;
|
|
10
|
+
export declare function Badge({ variant, size, pulse, className, ...rest }: BadgeProps): import("react").JSX.Element;
|
|
7
11
|
export interface AlertProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
|
8
12
|
variant?: SemanticVariant;
|
|
9
13
|
title?: ReactNode;
|
package/dist/feedback.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cx } from "./cx.js";
|
|
3
|
-
export function Badge({ variant, className, ...rest }) {
|
|
4
|
-
return _jsx("span", { className: cx("ld-badge", variant && `ld-badge--${variant}`, className), ...rest });
|
|
3
|
+
export function Badge({ variant, size, pulse, className, ...rest }) {
|
|
4
|
+
return (_jsx("span", { className: cx("ld-badge", variant && `ld-badge--${variant}`, size && `ld-badge--${size}`, pulse && "ld-badge--pulse", className), ...rest }));
|
|
5
5
|
}
|
|
6
6
|
export function Alert({ variant = "info", title, className, children, ...rest }) {
|
|
7
7
|
return (_jsxs("div", { role: "alert", className: cx("ld-alert", `ld-alert--${variant}`, className), ...rest, children: [title !== undefined && _jsx("span", { className: "ld-alert__title", children: title }), children] }));
|
package/dist/hold.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Hold-to-confirm state machine, framework-free so it can be unit-tested
|
|
2
|
+
* without a DOM. HoldButton drives it from pointer/keyboard events. */
|
|
3
|
+
export type HoldState = "idle" | "holding" | "fired";
|
|
4
|
+
export interface HoldOptions {
|
|
5
|
+
/** Commitment window in ms, resolved at the moment a hold starts. */
|
|
6
|
+
duration: () => number;
|
|
7
|
+
/** Called with progress in [0, 1] on every frame, and with 0 on reset. */
|
|
8
|
+
onProgress: (progress: number) => void;
|
|
9
|
+
/** Fires exactly once per completed hold. */
|
|
10
|
+
onConfirm: () => void;
|
|
11
|
+
onStateChange?: (state: HoldState) => void;
|
|
12
|
+
/** How long the "fired" state lingers before returning to idle (ms). */
|
|
13
|
+
firedFor?: number;
|
|
14
|
+
now?: () => number;
|
|
15
|
+
requestFrame?: (cb: () => void) => number;
|
|
16
|
+
cancelFrame?: (id: number) => void;
|
|
17
|
+
setTimer?: (cb: () => void, ms: number) => number;
|
|
18
|
+
clearTimer?: (id: number) => void;
|
|
19
|
+
}
|
|
20
|
+
export interface HoldController {
|
|
21
|
+
readonly state: HoldState;
|
|
22
|
+
/** Begin a hold. Ignored unless idle. */
|
|
23
|
+
start(): void;
|
|
24
|
+
/** Abort an in-progress hold without firing. Ignored unless holding. */
|
|
25
|
+
cancel(): void;
|
|
26
|
+
/** Skip the hold and fire immediately (keyboard-tap escape hatch). */
|
|
27
|
+
fire(): void;
|
|
28
|
+
/** Cancel everything and drop pending timers. */
|
|
29
|
+
dispose(): void;
|
|
30
|
+
}
|
|
31
|
+
export declare function createHoldController(opts: HoldOptions): HoldController;
|
package/dist/hold.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Hold-to-confirm state machine, framework-free so it can be unit-tested
|
|
2
|
+
* without a DOM. HoldButton drives it from pointer/keyboard events. */
|
|
3
|
+
export function createHoldController(opts) {
|
|
4
|
+
const now = opts.now ?? (() => performance.now());
|
|
5
|
+
const requestFrame = opts.requestFrame ?? ((cb) => requestAnimationFrame(cb));
|
|
6
|
+
const cancelFrame = opts.cancelFrame ?? ((id) => cancelAnimationFrame(id));
|
|
7
|
+
const setTimer = opts.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
|
|
8
|
+
const clearTimer = opts.clearTimer ?? ((id) => clearTimeout(id));
|
|
9
|
+
const firedFor = opts.firedFor ?? 600;
|
|
10
|
+
let state = "idle";
|
|
11
|
+
let startedAt = 0;
|
|
12
|
+
let duration = 0;
|
|
13
|
+
let frame;
|
|
14
|
+
let timer;
|
|
15
|
+
const setState = (next) => {
|
|
16
|
+
if (state === next)
|
|
17
|
+
return;
|
|
18
|
+
state = next;
|
|
19
|
+
opts.onStateChange?.(next);
|
|
20
|
+
};
|
|
21
|
+
const stopFrame = () => {
|
|
22
|
+
if (frame !== undefined)
|
|
23
|
+
cancelFrame(frame);
|
|
24
|
+
frame = undefined;
|
|
25
|
+
};
|
|
26
|
+
const fire = () => {
|
|
27
|
+
stopFrame();
|
|
28
|
+
setState("fired");
|
|
29
|
+
opts.onProgress(1);
|
|
30
|
+
opts.onConfirm();
|
|
31
|
+
timer = setTimer(() => {
|
|
32
|
+
timer = undefined;
|
|
33
|
+
setState("idle");
|
|
34
|
+
opts.onProgress(0);
|
|
35
|
+
}, firedFor);
|
|
36
|
+
};
|
|
37
|
+
const tick = () => {
|
|
38
|
+
frame = undefined;
|
|
39
|
+
if (state !== "holding")
|
|
40
|
+
return;
|
|
41
|
+
const elapsed = now() - startedAt;
|
|
42
|
+
if (elapsed >= duration) {
|
|
43
|
+
fire();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
opts.onProgress(Math.max(0, elapsed / duration));
|
|
47
|
+
frame = requestFrame(tick);
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
get state() {
|
|
51
|
+
return state;
|
|
52
|
+
},
|
|
53
|
+
start() {
|
|
54
|
+
if (state !== "idle")
|
|
55
|
+
return;
|
|
56
|
+
duration = Math.max(0, opts.duration());
|
|
57
|
+
startedAt = now();
|
|
58
|
+
setState("holding");
|
|
59
|
+
opts.onProgress(0);
|
|
60
|
+
frame = requestFrame(tick);
|
|
61
|
+
},
|
|
62
|
+
cancel() {
|
|
63
|
+
if (state !== "holding")
|
|
64
|
+
return;
|
|
65
|
+
stopFrame();
|
|
66
|
+
setState("idle");
|
|
67
|
+
opts.onProgress(0);
|
|
68
|
+
},
|
|
69
|
+
fire() {
|
|
70
|
+
if (state === "fired")
|
|
71
|
+
return;
|
|
72
|
+
fire();
|
|
73
|
+
},
|
|
74
|
+
dispose() {
|
|
75
|
+
stopFrame();
|
|
76
|
+
if (timer !== undefined)
|
|
77
|
+
clearTimer(timer);
|
|
78
|
+
timer = undefined;
|
|
79
|
+
state = "idle";
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
export { Button, type ButtonProps } from "./Button.js";
|
|
2
|
+
export { ButtonGroup, type ButtonGroupProps } from "./ButtonGroup.js";
|
|
3
|
+
export { buttonGroupAttrs } from "./button-group.js";
|
|
4
|
+
export { HoldButton, type HoldButtonProps } from "./HoldButton.js";
|
|
5
|
+
export { createHoldController, type HoldController, type HoldOptions, type HoldState } from "./hold.js";
|
|
2
6
|
export { Card, type CardProps } from "./Card.js";
|
|
7
|
+
export { StatusCard, StatusCardRows, StatusCardRow, StatusCardFooter, StatusCardEmpty, type StatusCardProps, type StatusCardRowProps, type StatusCardFooterProps, } from "./StatusCard.js";
|
|
8
|
+
export { attentionTone, statusCardAttrs, type StatusTone } from "./status-card.js";
|
|
9
|
+
export { Stepper, type StepperProps, type StepItem } from "./Stepper.js";
|
|
10
|
+
export { stepAttrs, stepState, type StepState } from "./stepper-state.js";
|
|
3
11
|
export { NavLink, type NavLinkProps } from "./NavLink.js";
|
|
4
12
|
export { Input, Textarea, Select, Label, Field, Checkbox, Radio, Switch, type InputProps, type TextareaProps, type SelectProps, type LabelProps, } from "./form.js";
|
|
5
13
|
export { Badge, Alert, Progress, Spinner, type BadgeProps, type AlertProps, type ProgressProps, type SemanticVariant, } from "./feedback.js";
|
|
6
14
|
export { Dialog, type DialogProps } from "./Dialog.js";
|
|
7
15
|
export { Tabs, type TabsProps, type TabItem } from "./Tabs.js";
|
|
16
|
+
export { slideDir, type SlideDir } from "./slide-dir.js";
|
|
17
|
+
export { useSlideDir } from "./useSlideDir.js";
|
|
8
18
|
export { cx } from "./cx.js";
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
export { Button } from "./Button.js";
|
|
2
|
+
export { ButtonGroup } from "./ButtonGroup.js";
|
|
3
|
+
export { buttonGroupAttrs } from "./button-group.js";
|
|
4
|
+
export { HoldButton } from "./HoldButton.js";
|
|
5
|
+
export { createHoldController } from "./hold.js";
|
|
2
6
|
export { Card } from "./Card.js";
|
|
7
|
+
export { StatusCard, StatusCardRows, StatusCardRow, StatusCardFooter, StatusCardEmpty, } from "./StatusCard.js";
|
|
8
|
+
export { attentionTone, statusCardAttrs } from "./status-card.js";
|
|
9
|
+
export { Stepper } from "./Stepper.js";
|
|
10
|
+
export { stepAttrs, stepState } from "./stepper-state.js";
|
|
3
11
|
export { NavLink } from "./NavLink.js";
|
|
4
12
|
export { Input, Textarea, Select, Label, Field, Checkbox, Radio, Switch, } from "./form.js";
|
|
5
13
|
export { Badge, Alert, Progress, Spinner, } from "./feedback.js";
|
|
6
14
|
export { Dialog } from "./Dialog.js";
|
|
7
15
|
export { Tabs } from "./Tabs.js";
|
|
16
|
+
export { slideDir } from "./slide-dir.js";
|
|
17
|
+
export { useSlideDir } from "./useSlideDir.js";
|
|
8
18
|
export { cx } from "./cx.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type SlideDir = "forward" | "back";
|
|
2
|
+
/** Which way the active item moved between two child indexes. `undefined` when
|
|
3
|
+
* there is no travel to describe — nothing was active before or after (-1),
|
|
4
|
+
* or the index did not change — so the caller leaves the last direction alone. */
|
|
5
|
+
export declare function slideDir(prev: number, next: number): SlideDir | undefined;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Pure direction logic behind the sliding indicator (Tabs underline,
|
|
2
|
+
// ButtonGroup pill), kept DOM-free so it can be tested under node:test without
|
|
3
|
+
// a renderer (so it imports nothing — Node runs the .ts source and cannot
|
|
4
|
+
// resolve the .js specifiers the build uses).
|
|
5
|
+
/** Which way the active item moved between two child indexes. `undefined` when
|
|
6
|
+
* there is no travel to describe — nothing was active before or after (-1),
|
|
7
|
+
* or the index did not change — so the caller leaves the last direction alone. */
|
|
8
|
+
export function slideDir(prev, next) {
|
|
9
|
+
if (prev < 0 || next < 0 || prev === next)
|
|
10
|
+
return undefined;
|
|
11
|
+
return next > prev ? "forward" : "back";
|
|
12
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type StatusTone = "info" | "success" | "warning" | "danger";
|
|
2
|
+
/** `true` means "warning" — the default call-to-action ring. */
|
|
3
|
+
export declare function attentionTone(attention: boolean | StatusTone | undefined): StatusTone | undefined;
|
|
4
|
+
export interface StatusCardAttrs {
|
|
5
|
+
className: string;
|
|
6
|
+
"data-ld-attention"?: StatusTone;
|
|
7
|
+
}
|
|
8
|
+
/** Root class list and data attributes for a given tone/attention state. */
|
|
9
|
+
export declare function statusCardAttrs(tone: StatusTone | undefined, attention: boolean | StatusTone | undefined, className?: string): StatusCardAttrs;
|
|
10
|
+
/** Restart the change flash on `el`: drop the attribute, reflow, set it again. */
|
|
11
|
+
export declare function replayFlash(el: {
|
|
12
|
+
removeAttribute(name: string): void;
|
|
13
|
+
setAttribute(name: string, value: string): void;
|
|
14
|
+
readonly offsetWidth: number;
|
|
15
|
+
}): void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Pure attribute logic behind StatusCard, kept DOM-free so it can be tested
|
|
2
|
+
// under node:test without a renderer (so it imports nothing — Node runs the
|
|
3
|
+
// .ts source and cannot resolve the .js specifiers the build uses).
|
|
4
|
+
/** `true` means "warning" — the default call-to-action ring. */
|
|
5
|
+
export function attentionTone(attention) {
|
|
6
|
+
if (attention === true)
|
|
7
|
+
return "warning";
|
|
8
|
+
if (!attention)
|
|
9
|
+
return undefined;
|
|
10
|
+
return attention;
|
|
11
|
+
}
|
|
12
|
+
/** Root class list and data attributes for a given tone/attention state. */
|
|
13
|
+
export function statusCardAttrs(tone, attention, className) {
|
|
14
|
+
const attrs = {
|
|
15
|
+
className: ["ld-status-card", tone && `ld-status-card--${tone}`, className].filter(Boolean).join(" "),
|
|
16
|
+
};
|
|
17
|
+
const ring = attentionTone(attention);
|
|
18
|
+
if (ring)
|
|
19
|
+
attrs["data-ld-attention"] = ring;
|
|
20
|
+
return attrs;
|
|
21
|
+
}
|
|
22
|
+
/** Restart the change flash on `el`: drop the attribute, reflow, set it again. */
|
|
23
|
+
export function replayFlash(el) {
|
|
24
|
+
el.removeAttribute("data-ld-changed");
|
|
25
|
+
void el.offsetWidth;
|
|
26
|
+
el.setAttribute("data-ld-changed", "");
|
|
27
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type StepState = "complete" | "current" | "upcoming";
|
|
2
|
+
/** Where step `index` sits relative to `current`: before it is complete, at it
|
|
3
|
+
* is current, after it is upcoming. A `current` past the last step therefore
|
|
4
|
+
* reads as "all complete"; a negative one as "all upcoming". */
|
|
5
|
+
export declare function stepState(index: number, current: number): StepState;
|
|
6
|
+
export interface StepAttrs {
|
|
7
|
+
className: string;
|
|
8
|
+
"aria-current"?: "step";
|
|
9
|
+
}
|
|
10
|
+
/** Class list and aria attributes for step `index`. The theme CSS derives
|
|
11
|
+
* the rail fill from the state modifiers, so nothing else is written. */
|
|
12
|
+
export declare function stepAttrs(index: number, current: number, className?: string): StepAttrs;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Pure state logic behind Stepper, kept DOM-free so it can be tested under
|
|
2
|
+
// node:test without a renderer (so it imports nothing — Node runs the .ts
|
|
3
|
+
// source and cannot resolve the .js specifiers the build uses).
|
|
4
|
+
/** Where step `index` sits relative to `current`: before it is complete, at it
|
|
5
|
+
* is current, after it is upcoming. A `current` past the last step therefore
|
|
6
|
+
* reads as "all complete"; a negative one as "all upcoming". */
|
|
7
|
+
export function stepState(index, current) {
|
|
8
|
+
if (index < current)
|
|
9
|
+
return "complete";
|
|
10
|
+
if (index === current)
|
|
11
|
+
return "current";
|
|
12
|
+
return "upcoming";
|
|
13
|
+
}
|
|
14
|
+
/** Class list and aria attributes for step `index`. The theme CSS derives
|
|
15
|
+
* the rail fill from the state modifiers, so nothing else is written. */
|
|
16
|
+
export function stepAttrs(index, current, className) {
|
|
17
|
+
const state = stepState(index, current);
|
|
18
|
+
const attrs = {
|
|
19
|
+
className: ["ld-stepper__step", `ld-stepper__step--${state}`, className].filter(Boolean).join(" "),
|
|
20
|
+
};
|
|
21
|
+
if (state === "current")
|
|
22
|
+
attrs["aria-current"] = "step";
|
|
23
|
+
return attrs;
|
|
24
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RefObject } from "react";
|
|
2
|
+
/** Ref for an indicator track (`.ld-tabs`, `.ld-btn-group`). After every render
|
|
3
|
+
* it finds the child matching `activeSelector` and, when that moved, stamps
|
|
4
|
+
* `data-ld-dir="forward" | "back"` on the track — the theme reads it to pick
|
|
5
|
+
* which edge of the sliding indicator leads. Read from the DOM, so it works
|
|
6
|
+
* when the active state lives in the children (`aria-pressed` on `Button`s). */
|
|
7
|
+
export declare function useSlideDir<T extends HTMLElement>(activeSelector: string): RefObject<T | null>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef } from "react";
|
|
2
|
+
import { slideDir } from "./slide-dir.js";
|
|
3
|
+
// Layout effect on the client so data-ld-dir lands in the same frame as the
|
|
4
|
+
// selection change (a transition reads its timing when it starts); plain
|
|
5
|
+
// effect on the server, where React 18 warns about layout effects.
|
|
6
|
+
const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
7
|
+
/** Ref for an indicator track (`.ld-tabs`, `.ld-btn-group`). After every render
|
|
8
|
+
* it finds the child matching `activeSelector` and, when that moved, stamps
|
|
9
|
+
* `data-ld-dir="forward" | "back"` on the track — the theme reads it to pick
|
|
10
|
+
* which edge of the sliding indicator leads. Read from the DOM, so it works
|
|
11
|
+
* when the active state lives in the children (`aria-pressed` on `Button`s). */
|
|
12
|
+
export function useSlideDir(activeSelector) {
|
|
13
|
+
const ref = useRef(null);
|
|
14
|
+
const prev = useRef(-1);
|
|
15
|
+
useIsomorphicLayoutEffect(() => {
|
|
16
|
+
const el = ref.current;
|
|
17
|
+
if (!el)
|
|
18
|
+
return;
|
|
19
|
+
const next = Array.from(el.children).findIndex((c) => c.matches(activeSelector));
|
|
20
|
+
const dir = slideDir(prev.current, next);
|
|
21
|
+
if (dir)
|
|
22
|
+
el.dataset.ldDir = dir;
|
|
23
|
+
prev.current = next;
|
|
24
|
+
});
|
|
25
|
+
return ref;
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lepid-labs/ui-react",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Shared React components styled by @lepid-labs/styles themes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
25
|
"build": "tsc -p tsconfig.json",
|
|
26
|
-
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
26
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
27
|
+
"test": "node --test"
|
|
27
28
|
},
|
|
28
29
|
"peerDependencies": {
|
|
29
30
|
"react": ">=18"
|