@mlola-ui/registry 1.0.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/LICENSE +21 -0
- package/README.md +38 -0
- package/components/accordion.json +54 -0
- package/components/activity-heatmap.json +54 -0
- package/components/alert.json +56 -0
- package/components/app-shell.json +53 -0
- package/components/avatar.json +54 -0
- package/components/badge.json +62 -0
- package/components/breadcrumb.json +49 -0
- package/components/button.json +62 -0
- package/components/calendar.json +55 -0
- package/components/card.json +53 -0
- package/components/carousel.json +52 -0
- package/components/checkbox.json +50 -0
- package/components/color-picker.json +60 -0
- package/components/combobox.json +64 -0
- package/components/command-menu.json +64 -0
- package/components/context-menu.json +61 -0
- package/components/date-picker.json +59 -0
- package/components/dropdown-menu.json +51 -0
- package/components/dropzone.json +45 -0
- package/components/empty-state.json +45 -0
- package/components/hover-card.json +59 -0
- package/components/input.json +56 -0
- package/components/kbd.json +45 -0
- package/components/modal.json +63 -0
- package/components/number-input.json +58 -0
- package/components/otp-input.json +49 -0
- package/components/pagination.json +50 -0
- package/components/popover.json +58 -0
- package/components/priority-icon.json +53 -0
- package/components/progress.json +57 -0
- package/components/radio-group.json +48 -0
- package/components/rating.json +48 -0
- package/components/resizable.json +46 -0
- package/components/segmented-control.json +51 -0
- package/components/select.json +67 -0
- package/components/sheet.json +66 -0
- package/components/skeleton.json +48 -0
- package/components/slider.json +53 -0
- package/components/status-icon.json +53 -0
- package/components/stepper.json +50 -0
- package/components/table.json +53 -0
- package/components/tabs.json +54 -0
- package/components/tag-input.json +53 -0
- package/components/textarea.json +48 -0
- package/components/time-picker.json +58 -0
- package/components/timeline.json +54 -0
- package/components/toast.json +56 -0
- package/components/toggle.json +52 -0
- package/components/tooltip.json +54 -0
- package/components/tour.json +62 -0
- package/components/virtual-list.json +54 -0
- package/generated/catalog.ts +2037 -0
- package/index.json +413 -0
- package/package.json +47 -0
- package/schema/mlola.config.schema.json +51 -0
- package/schema/registry-index.schema.json +116 -0
- package/schema/registry-item.schema.json +80 -0
- package/snapshot/index.json +2938 -0
- package/snapshot/items/components/accordion.json +54 -0
- package/snapshot/items/components/activity-heatmap.json +54 -0
- package/snapshot/items/components/alert.json +56 -0
- package/snapshot/items/components/app-shell.json +53 -0
- package/snapshot/items/components/avatar.json +54 -0
- package/snapshot/items/components/badge.json +62 -0
- package/snapshot/items/components/breadcrumb.json +49 -0
- package/snapshot/items/components/button.json +62 -0
- package/snapshot/items/components/calendar.json +55 -0
- package/snapshot/items/components/card.json +53 -0
- package/snapshot/items/components/carousel.json +52 -0
- package/snapshot/items/components/checkbox.json +50 -0
- package/snapshot/items/components/color-picker.json +60 -0
- package/snapshot/items/components/combobox.json +64 -0
- package/snapshot/items/components/command-menu.json +64 -0
- package/snapshot/items/components/context-menu.json +61 -0
- package/snapshot/items/components/date-picker.json +59 -0
- package/snapshot/items/components/dropdown-menu.json +51 -0
- package/snapshot/items/components/dropzone.json +45 -0
- package/snapshot/items/components/empty-state.json +45 -0
- package/snapshot/items/components/hover-card.json +59 -0
- package/snapshot/items/components/input.json +56 -0
- package/snapshot/items/components/kbd.json +45 -0
- package/snapshot/items/components/modal.json +63 -0
- package/snapshot/items/components/number-input.json +58 -0
- package/snapshot/items/components/otp-input.json +49 -0
- package/snapshot/items/components/pagination.json +50 -0
- package/snapshot/items/components/popover.json +58 -0
- package/snapshot/items/components/priority-icon.json +53 -0
- package/snapshot/items/components/progress.json +57 -0
- package/snapshot/items/components/radio-group.json +48 -0
- package/snapshot/items/components/rating.json +48 -0
- package/snapshot/items/components/resizable.json +46 -0
- package/snapshot/items/components/segmented-control.json +51 -0
- package/snapshot/items/components/select.json +67 -0
- package/snapshot/items/components/sheet.json +66 -0
- package/snapshot/items/components/skeleton.json +48 -0
- package/snapshot/items/components/slider.json +53 -0
- package/snapshot/items/components/status-icon.json +53 -0
- package/snapshot/items/components/stepper.json +50 -0
- package/snapshot/items/components/table.json +53 -0
- package/snapshot/items/components/tabs.json +54 -0
- package/snapshot/items/components/tag-input.json +53 -0
- package/snapshot/items/components/textarea.json +48 -0
- package/snapshot/items/components/time-picker.json +58 -0
- package/snapshot/items/components/timeline.json +54 -0
- package/snapshot/items/components/toast.json +56 -0
- package/snapshot/items/components/toggle.json +52 -0
- package/snapshot/items/components/tooltip.json +54 -0
- package/snapshot/items/components/tour.json +62 -0
- package/snapshot/items/components/virtual-list.json +54 -0
- package/snapshot/source/packages/components/_internal/anchor.ts +54 -0
- package/snapshot/source/packages/components/_internal/dialog.ts +142 -0
- package/snapshot/source/packages/components/_internal/floating.ts +73 -0
- package/snapshot/source/packages/components/_internal/match.ts +39 -0
- package/snapshot/source/packages/components/_internal/react.ts +40 -0
- package/snapshot/source/packages/components/_internal/scroll.ts +15 -0
- package/snapshot/source/packages/components/accordion/accordion.tsx +164 -0
- package/snapshot/source/packages/components/activity-heatmap/activity-heatmap.tsx +204 -0
- package/snapshot/source/packages/components/activity-heatmap/calendar.ts +92 -0
- package/snapshot/source/packages/components/alert/alert.tsx +83 -0
- package/snapshot/source/packages/components/app-shell/app-shell.tsx +186 -0
- package/snapshot/source/packages/components/avatar/avatar.tsx +56 -0
- package/snapshot/source/packages/components/badge/badge.tsx +39 -0
- package/snapshot/source/packages/components/breadcrumb/breadcrumb.tsx +61 -0
- package/snapshot/source/packages/components/button/button.tsx +42 -0
- package/snapshot/source/packages/components/calendar/calendar.tsx +309 -0
- package/snapshot/source/packages/components/calendar/dates.ts +163 -0
- package/snapshot/source/packages/components/card/card.tsx +39 -0
- package/snapshot/source/packages/components/carousel/carousel.tsx +248 -0
- package/snapshot/source/packages/components/checkbox/checkbox.tsx +66 -0
- package/snapshot/source/packages/components/color-picker/color-picker.tsx +261 -0
- package/snapshot/source/packages/components/color-picker/color.ts +124 -0
- package/snapshot/source/packages/components/combobox/combobox.tsx +320 -0
- package/snapshot/source/packages/components/command-menu/command-menu.tsx +199 -0
- package/snapshot/source/packages/components/context-menu/context-menu.tsx +192 -0
- package/snapshot/source/packages/components/date-picker/date-picker.tsx +185 -0
- package/snapshot/source/packages/components/dropdown-menu/dropdown-menu.tsx +157 -0
- package/snapshot/source/packages/components/dropzone/dropzone.tsx +185 -0
- package/snapshot/source/packages/components/empty-state/empty-state.tsx +32 -0
- package/snapshot/source/packages/components/hover-card/hover-card.tsx +103 -0
- package/snapshot/source/packages/components/input/input.tsx +101 -0
- package/snapshot/source/packages/components/kbd/kbd.tsx +54 -0
- package/snapshot/source/packages/components/modal/modal.tsx +100 -0
- package/snapshot/source/packages/components/number-input/number-input.tsx +184 -0
- package/snapshot/source/packages/components/number-input/number.ts +50 -0
- package/snapshot/source/packages/components/otp-input/otp-input.tsx +116 -0
- package/snapshot/source/packages/components/pagination/pagination.tsx +65 -0
- package/snapshot/source/packages/components/popover/popover.tsx +107 -0
- package/snapshot/source/packages/components/priority-icon/labels.ts +13 -0
- package/snapshot/source/packages/components/priority-icon/priority-icon.tsx +60 -0
- package/snapshot/source/packages/components/progress/progress.tsx +110 -0
- package/snapshot/source/packages/components/radio-group/radio-group.tsx +106 -0
- package/snapshot/source/packages/components/rating/rating.tsx +94 -0
- package/snapshot/source/packages/components/resizable/resizable.tsx +171 -0
- package/snapshot/source/packages/components/segmented-control/segmented-control.tsx +78 -0
- package/snapshot/source/packages/components/select/select.tsx +332 -0
- package/snapshot/source/packages/components/sheet/sheet.tsx +109 -0
- package/snapshot/source/packages/components/skeleton/skeleton.tsx +84 -0
- package/snapshot/source/packages/components/slider/slider.tsx +99 -0
- package/snapshot/source/packages/components/status-icon/labels.ts +14 -0
- package/snapshot/source/packages/components/status-icon/status-icon.tsx +76 -0
- package/snapshot/source/packages/components/stepper/stepper.tsx +87 -0
- package/snapshot/source/packages/components/table/table.tsx +66 -0
- package/snapshot/source/packages/components/tabs/tabs.tsx +163 -0
- package/snapshot/source/packages/components/tag-input/tag-input.tsx +273 -0
- package/snapshot/source/packages/components/textarea/textarea.tsx +70 -0
- package/snapshot/source/packages/components/time-picker/time-picker.tsx +286 -0
- package/snapshot/source/packages/components/time-picker/times.ts +76 -0
- package/snapshot/source/packages/components/timeline/timeline.tsx +115 -0
- package/snapshot/source/packages/components/toast/toast.tsx +317 -0
- package/snapshot/source/packages/components/toggle/toggle.tsx +80 -0
- package/snapshot/source/packages/components/tooltip/tooltip.tsx +70 -0
- package/snapshot/source/packages/components/tour/tour.tsx +193 -0
- package/snapshot/source/packages/components/virtual-list/virtual-list.tsx +121 -0
- package/snapshot/source/packages/components/virtual-list/virtual.ts +33 -0
- package/snapshot/source-manifest.json +67 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cx } from "../_internal/react";
|
|
5
|
+
|
|
6
|
+
/** The meaning of the fill, from the theme's colour roles. */
|
|
7
|
+
export type ProgressTone = "primary" | "info" | "success" | "warning" | "danger";
|
|
8
|
+
type ProgressSize = "sm" | "md" | "lg";
|
|
9
|
+
export interface ProgressProps {
|
|
10
|
+
value?: number;
|
|
11
|
+
max?: number;
|
|
12
|
+
tone?: ProgressTone;
|
|
13
|
+
size?: ProgressSize;
|
|
14
|
+
showLabel?: boolean;
|
|
15
|
+
/** Text shown beside the value when showLabel is on. */
|
|
16
|
+
label?: string;
|
|
17
|
+
indeterminate?: boolean;
|
|
18
|
+
/** Work is happening right now: a light sweeps along the bar. Leave it off for static amounts such as quotas. */
|
|
19
|
+
active?: boolean;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
const metrics = (value: number, max: number) => {
|
|
23
|
+
const safeMax = Number.isFinite(max) && max > 0 ? max : 100;
|
|
24
|
+
const safeValue = Number.isFinite(value) ? Math.min(safeMax, Math.max(0, value)) : 0;
|
|
25
|
+
return { safeMax, safeValue, percent: Math.round((safeValue / safeMax) * 100) };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The shown percentage eases towards the real one, so the number moves with the bar. */
|
|
29
|
+
function useCountUp(target: number) {
|
|
30
|
+
const [shown, setShown] = React.useState(0);
|
|
31
|
+
const current = React.useRef(0);
|
|
32
|
+
React.useEffect(() => {
|
|
33
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
34
|
+
current.current = target;
|
|
35
|
+
setShown(target);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const from = current.current;
|
|
39
|
+
const started = performance.now();
|
|
40
|
+
let frame = 0;
|
|
41
|
+
const step = (now: number) => {
|
|
42
|
+
const t = Math.min(1, (now - started) / 600);
|
|
43
|
+
const eased = 1 - Math.pow(1 - t, 3);
|
|
44
|
+
current.current = from + (target - from) * eased;
|
|
45
|
+
setShown(Math.round(current.current));
|
|
46
|
+
if (t < 1) frame = requestAnimationFrame(step);
|
|
47
|
+
};
|
|
48
|
+
frame = requestAnimationFrame(step);
|
|
49
|
+
return () => cancelAnimationFrame(frame);
|
|
50
|
+
}, [target]);
|
|
51
|
+
return shown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A bar that fills as work completes. It grows in when it appears, eases
|
|
56
|
+
* between values with the percentage counting alongside, sweeps a light
|
|
57
|
+
* along the fill while `active`, and pulses once when it reaches the end.
|
|
58
|
+
*/
|
|
59
|
+
export function Progress({ value = 0, max = 100, tone = "primary", size = "md", showLabel = false, label = "Progress", indeterminate = false, active = false, className }: ProgressProps) {
|
|
60
|
+
const { safeMax, safeValue, percent } = metrics(value, max);
|
|
61
|
+
const shown = useCountUp(percent);
|
|
62
|
+
const complete = !indeterminate && percent >= 100;
|
|
63
|
+
return (
|
|
64
|
+
<div className={cx("ml-progress-root", className)} data-tone={tone} data-size={size} data-state={indeterminate ? "indeterminate" : complete ? "complete" : "determinate"} data-active={(active && !complete && !indeterminate) || undefined}>
|
|
65
|
+
{showLabel ? (
|
|
66
|
+
<div className="ml-progress-label">
|
|
67
|
+
<span>{label}</span>
|
|
68
|
+
<span aria-hidden="true" className="ml-progress-value">
|
|
69
|
+
{indeterminate ? "Loading…" : `${shown}%`}
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
) : null}
|
|
73
|
+
<div role="progressbar" aria-label={label} aria-valuemin={0} aria-valuemax={safeMax} aria-valuenow={indeterminate ? undefined : safeValue} aria-valuetext={indeterminate ? "Loading" : `${percent}%`} className="ml-progress-track">
|
|
74
|
+
<div aria-hidden="true" className="ml-progress-fill" style={indeterminate ? undefined : { width: `${percent}%` }} />
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface CircularProgressProps extends ProgressProps {}
|
|
81
|
+
const CIRCUMFERENCE = 2 * Math.PI * 44;
|
|
82
|
+
export function CircularProgress({ value = 0, max = 100, tone = "primary", size = "md", showLabel = false, label = "Progress", indeterminate = false, className }: CircularProgressProps) {
|
|
83
|
+
const { safeMax, safeValue, percent } = metrics(value, max);
|
|
84
|
+
const shown = useCountUp(percent);
|
|
85
|
+
const filled = indeterminate ? CIRCUMFERENCE * 0.28 : (CIRCUMFERENCE * percent) / 100;
|
|
86
|
+
return (
|
|
87
|
+
<div
|
|
88
|
+
role="progressbar"
|
|
89
|
+
aria-label={label}
|
|
90
|
+
aria-valuemin={0}
|
|
91
|
+
aria-valuemax={safeMax}
|
|
92
|
+
aria-valuenow={indeterminate ? undefined : safeValue}
|
|
93
|
+
aria-valuetext={indeterminate ? "Loading" : `${percent}%`}
|
|
94
|
+
data-tone={tone}
|
|
95
|
+
data-size={size}
|
|
96
|
+
data-state={indeterminate ? "indeterminate" : percent >= 100 ? "complete" : "determinate"}
|
|
97
|
+
className={cx("ml-circular-progress", className)}
|
|
98
|
+
>
|
|
99
|
+
<svg viewBox="0 0 100 100" aria-hidden="true" className="ml-circular-progress-svg">
|
|
100
|
+
<circle cx="50" cy="50" r="44" fill="none" strokeWidth="9" className="ml-circular-progress-track" />
|
|
101
|
+
<circle cx="50" cy="50" r="44" fill="none" strokeWidth="9" strokeLinecap="round" style={{ strokeDasharray: `${filled} ${CIRCUMFERENCE}` }} transform="rotate(-90 50 50)" className="ml-circular-progress-fill" />
|
|
102
|
+
</svg>
|
|
103
|
+
{showLabel && !indeterminate ? (
|
|
104
|
+
<span aria-hidden="true" className="ml-circular-progress-label">
|
|
105
|
+
{shown}%
|
|
106
|
+
</span>
|
|
107
|
+
) : null}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cx, useControllableState } from "../_internal/react";
|
|
5
|
+
|
|
6
|
+
interface RadioContextValue {
|
|
7
|
+
selected: string;
|
|
8
|
+
name: string;
|
|
9
|
+
disabled: boolean;
|
|
10
|
+
select: (value: string) => void;
|
|
11
|
+
}
|
|
12
|
+
const RadioContext = React.createContext<RadioContextValue | null>(null);
|
|
13
|
+
const useRadio = () => {
|
|
14
|
+
const context = React.useContext(RadioContext);
|
|
15
|
+
if (!context) throw new Error("RadioGroupItem must be used inside RadioGroup");
|
|
16
|
+
return context;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
interface RadioGroupProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
20
|
+
value?: string;
|
|
21
|
+
defaultValue?: string;
|
|
22
|
+
onValueChange?: (value: string) => void;
|
|
23
|
+
name?: string;
|
|
24
|
+
orientation?: "horizontal" | "vertical";
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
label?: React.ReactNode;
|
|
27
|
+
description?: string;
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
|
|
31
|
+
({ value, defaultValue = "", onValueChange, name, orientation = "vertical", disabled = false, label, description, error, id, className, children, "aria-describedby": describedBy, ...props }, ref) => {
|
|
32
|
+
const generated = React.useId();
|
|
33
|
+
const groupName = name ?? generated;
|
|
34
|
+
const [selected, select] = useControllableState({ value, defaultValue, onChange: onValueChange });
|
|
35
|
+
const labelId = `${groupName}-label`;
|
|
36
|
+
const descriptionId = `${groupName}-description`;
|
|
37
|
+
const errorId = `${groupName}-error`;
|
|
38
|
+
return (
|
|
39
|
+
<RadioContext.Provider value={{ selected, name: groupName, disabled, select }}>
|
|
40
|
+
<div
|
|
41
|
+
ref={ref}
|
|
42
|
+
id={id}
|
|
43
|
+
role="radiogroup"
|
|
44
|
+
aria-labelledby={label ? labelId : undefined}
|
|
45
|
+
aria-label={label ? undefined : "Options"}
|
|
46
|
+
aria-orientation={orientation}
|
|
47
|
+
aria-describedby={[describedBy, description && descriptionId, error && errorId].filter(Boolean).join(" ") || undefined}
|
|
48
|
+
aria-invalid={error ? true : undefined}
|
|
49
|
+
data-orientation={orientation}
|
|
50
|
+
data-disabled={disabled ? "" : undefined}
|
|
51
|
+
data-invalid={error ? "" : undefined}
|
|
52
|
+
className={cx("ml-radio-group", className)}
|
|
53
|
+
{...props}
|
|
54
|
+
>
|
|
55
|
+
{label ? <div id={labelId} className="ml-radio-group-label">{label}</div> : null}
|
|
56
|
+
{description ? <p id={descriptionId} className="ml-radio-group-description">{description}</p> : null}
|
|
57
|
+
<div className="ml-radio-group-items">{children}</div>
|
|
58
|
+
{error ? <p id={errorId} role="alert" className="ml-radio-group-error">{error}</p> : null}
|
|
59
|
+
</div>
|
|
60
|
+
</RadioContext.Provider>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
RadioGroup.displayName = "RadioGroup";
|
|
65
|
+
|
|
66
|
+
interface RadioGroupItemProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "name" | "onChange"> {
|
|
67
|
+
value: string;
|
|
68
|
+
label: React.ReactNode;
|
|
69
|
+
description?: string;
|
|
70
|
+
}
|
|
71
|
+
const RadioGroupItem = React.forwardRef<HTMLInputElement, RadioGroupItemProps>(
|
|
72
|
+
({ value, label, description, disabled = false, id, className, "aria-describedby": describedBy, ...props }, ref) => {
|
|
73
|
+
const group = useRadio();
|
|
74
|
+
const generated = React.useId();
|
|
75
|
+
const inputId = id ?? `${group.name}-${generated}`;
|
|
76
|
+
const descriptionId = `${inputId}-description`;
|
|
77
|
+
const checked = group.selected === value;
|
|
78
|
+
const isDisabled = disabled || group.disabled;
|
|
79
|
+
return (
|
|
80
|
+
<label htmlFor={inputId} className={cx("ml-radio-item", className)} data-state={checked ? "checked" : "unchecked"} data-disabled={isDisabled ? "" : undefined}>
|
|
81
|
+
<span className="ml-radio-control">
|
|
82
|
+
<input
|
|
83
|
+
ref={ref}
|
|
84
|
+
id={inputId}
|
|
85
|
+
type="radio"
|
|
86
|
+
name={group.name}
|
|
87
|
+
value={value}
|
|
88
|
+
checked={checked}
|
|
89
|
+
disabled={isDisabled}
|
|
90
|
+
aria-describedby={[describedBy, description && descriptionId].filter(Boolean).join(" ") || undefined}
|
|
91
|
+
className="ml-radio-input"
|
|
92
|
+
onChange={() => group.select(value)}
|
|
93
|
+
{...props}
|
|
94
|
+
/>
|
|
95
|
+
<span aria-hidden="true" className="ml-radio-indicator" />
|
|
96
|
+
</span>
|
|
97
|
+
<span className="ml-radio-copy">
|
|
98
|
+
<span className="ml-radio-label">{label}</span>
|
|
99
|
+
{description ? <span id={descriptionId} className="ml-radio-description">{description}</span> : null}
|
|
100
|
+
</span>
|
|
101
|
+
</label>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
);
|
|
105
|
+
RadioGroupItem.displayName = "RadioGroupItem";
|
|
106
|
+
export { RadioGroup, RadioGroupItem };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cx } from "../_internal/react";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A star rating that tells the truth about fractions.
|
|
8
|
+
*
|
|
9
|
+
* Rounding 4.4 up to five stars is a small lie that shows up everywhere a
|
|
10
|
+
* product is compared. So the filled stars are a clipped overlay whose width
|
|
11
|
+
* is the exact percentage, which renders 4.5 as half a star without needing a
|
|
12
|
+
* second glyph, an image, or a font that happens to have one.
|
|
13
|
+
*
|
|
14
|
+
* The visible stars are decorative; the accessible name carries the number.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface RatingProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
|
|
18
|
+
value: number;
|
|
19
|
+
max?: number;
|
|
20
|
+
/** Review count shown beside the stars. */
|
|
21
|
+
count?: number;
|
|
22
|
+
/** Show the numeric value, which is clearer than stars alone at a glance. */
|
|
23
|
+
showValue?: boolean;
|
|
24
|
+
label?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Rating({
|
|
28
|
+
value,
|
|
29
|
+
max = 5,
|
|
30
|
+
count,
|
|
31
|
+
showValue = false,
|
|
32
|
+
label,
|
|
33
|
+
className,
|
|
34
|
+
...props
|
|
35
|
+
}: RatingProps) {
|
|
36
|
+
const total = Number.isFinite(max) && max > 0 ? Math.round(max) : 5;
|
|
37
|
+
const safe = Number.isFinite(value) ? Math.min(total, Math.max(0, value)) : 0;
|
|
38
|
+
const percent = (safe / total) * 100;
|
|
39
|
+
const rounded = Math.round(safe * 10) / 10;
|
|
40
|
+
const stars = "★".repeat(total);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<span
|
|
44
|
+
{...props}
|
|
45
|
+
role="img"
|
|
46
|
+
aria-label={label ?? `Rated ${rounded} out of ${total}${count === undefined ? "" : ` from ${count} reviews`}`}
|
|
47
|
+
className={cx("ml-rating", className)}
|
|
48
|
+
>
|
|
49
|
+
<span aria-hidden="true" className="ml-rating-track">
|
|
50
|
+
<span className="ml-rating-fill" style={{ ["--ml-rating" as string]: `${percent}%` }}>
|
|
51
|
+
{stars}
|
|
52
|
+
</span>
|
|
53
|
+
{stars}
|
|
54
|
+
</span>
|
|
55
|
+
{showValue ? <span className="ml-rating-value">{rounded.toFixed(1)}</span> : null}
|
|
56
|
+
{count === undefined ? null : <span className="ml-rating-count">({count})</span>}
|
|
57
|
+
</span>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default function Showcase() {
|
|
62
|
+
return (
|
|
63
|
+
<div className="ml-rating-showcase">
|
|
64
|
+
<section className="ml-showcase-group">
|
|
65
|
+
<h3 className="ml-showcase-group-label">Fractions</h3>
|
|
66
|
+
<p className="ml-showcase-note">
|
|
67
|
+
The fill is clipped to the exact percentage, so a half star is really half.
|
|
68
|
+
</p>
|
|
69
|
+
<div className="ml-showcase-stack">
|
|
70
|
+
{[5, 4.5, 4, 3.7, 3, 2.5, 1, 0].map((value) => (
|
|
71
|
+
<Rating key={value} value={value} showValue />
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
</section>
|
|
75
|
+
|
|
76
|
+
<section className="ml-showcase-group">
|
|
77
|
+
<h3 className="ml-showcase-group-label">With review count</h3>
|
|
78
|
+
<div className="ml-showcase-stack">
|
|
79
|
+
<Rating value={4.8} count={214} />
|
|
80
|
+
<Rating value={4.2} count={89} />
|
|
81
|
+
<Rating value={3.5} count={12} />
|
|
82
|
+
</div>
|
|
83
|
+
</section>
|
|
84
|
+
|
|
85
|
+
<section className="ml-showcase-group">
|
|
86
|
+
<h3 className="ml-showcase-group-label">Other scales</h3>
|
|
87
|
+
<div className="ml-showcase-stack">
|
|
88
|
+
<Rating value={7.5} max={10} showValue />
|
|
89
|
+
<Rating value={2.5} max={3} showValue />
|
|
90
|
+
</div>
|
|
91
|
+
</section>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cx, useControllableState } from "../_internal/react";
|
|
5
|
+
|
|
6
|
+
export interface ResizableProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
|
|
7
|
+
first: React.ReactNode;
|
|
8
|
+
second: React.ReactNode;
|
|
9
|
+
/** "horizontal" puts the panes side by side. */
|
|
10
|
+
direction?: "horizontal" | "vertical";
|
|
11
|
+
/** Which pane holds the size; the other takes the rest. A right-hand inspector anchors "second". */
|
|
12
|
+
anchor?: "first" | "second";
|
|
13
|
+
/** "percent" of the whole, or "pixels" for panes that should keep their width as the window grows. */
|
|
14
|
+
units?: "percent" | "pixels";
|
|
15
|
+
/** Size of the anchored pane. */
|
|
16
|
+
size?: number;
|
|
17
|
+
defaultSize?: number;
|
|
18
|
+
onSizeChange?: (size: number) => void;
|
|
19
|
+
min?: number;
|
|
20
|
+
max?: number;
|
|
21
|
+
/** Let the anchored pane collapse: drag it past its minimum, press Enter on the handle, or set `collapsed`. */
|
|
22
|
+
collapsible?: boolean;
|
|
23
|
+
collapsed?: boolean;
|
|
24
|
+
defaultCollapsed?: boolean;
|
|
25
|
+
onCollapsedChange?: (collapsed: boolean) => void;
|
|
26
|
+
/** Remember the size and collapsed state in this browser under this key. */
|
|
27
|
+
storageKey?: string;
|
|
28
|
+
/** The separator's accessible name. */
|
|
29
|
+
label?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
|
|
33
|
+
|
|
34
|
+
function readStored(key: string | undefined): { size?: number; collapsed?: boolean } {
|
|
35
|
+
if (!key) return {};
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(window.localStorage.getItem(key) ?? "{}") as { size?: number; collapsed?: boolean };
|
|
38
|
+
} catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Two panes and a handle between them. Drag it, or focus it and use the
|
|
45
|
+
* arrow keys (Home and End jump to the limits); double-click restores the
|
|
46
|
+
* default split. Sizes can be percentages or pixels, anchored to either pane,
|
|
47
|
+
* and a collapsible pane folds away past its minimum or on Enter.
|
|
48
|
+
*/
|
|
49
|
+
export function Resizable({
|
|
50
|
+
first,
|
|
51
|
+
second,
|
|
52
|
+
direction = "horizontal",
|
|
53
|
+
anchor = "first",
|
|
54
|
+
units = "percent",
|
|
55
|
+
size,
|
|
56
|
+
defaultSize = units === "pixels" ? 280 : 50,
|
|
57
|
+
onSizeChange,
|
|
58
|
+
min = units === "pixels" ? 160 : 15,
|
|
59
|
+
max = units === "pixels" ? 720 : 85,
|
|
60
|
+
collapsible = false,
|
|
61
|
+
collapsed,
|
|
62
|
+
defaultCollapsed = false,
|
|
63
|
+
onCollapsedChange,
|
|
64
|
+
storageKey,
|
|
65
|
+
label = "Resize panes",
|
|
66
|
+
className,
|
|
67
|
+
style,
|
|
68
|
+
...props
|
|
69
|
+
}: ResizableProps) {
|
|
70
|
+
const [current, setCurrent] = useControllableState({ value: size, defaultValue: defaultSize, onChange: onSizeChange });
|
|
71
|
+
const [folded, setFolded] = useControllableState({ value: collapsed, defaultValue: defaultCollapsed, onChange: onCollapsedChange });
|
|
72
|
+
const root = React.useRef<HTMLDivElement>(null);
|
|
73
|
+
const dragging = React.useRef(false);
|
|
74
|
+
const horizontal = direction === "horizontal";
|
|
75
|
+
const set = (value: number) => setCurrent(Math.round(clamp(value, min, max) * 10) / 10);
|
|
76
|
+
const isFolded = collapsible && folded;
|
|
77
|
+
|
|
78
|
+
// Restore a remembered layout after mount, so the server and the first client render agree.
|
|
79
|
+
React.useEffect(() => {
|
|
80
|
+
const stored = readStored(storageKey);
|
|
81
|
+
if (typeof stored.size === "number") setCurrent(clamp(stored.size, min, max));
|
|
82
|
+
if (collapsible && typeof stored.collapsed === "boolean") setFolded(stored.collapsed);
|
|
83
|
+
// Only on mount and when the key changes.
|
|
84
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
85
|
+
}, [storageKey]);
|
|
86
|
+
|
|
87
|
+
React.useEffect(() => {
|
|
88
|
+
if (!storageKey) return;
|
|
89
|
+
try {
|
|
90
|
+
window.localStorage.setItem(storageKey, JSON.stringify({ size: current, collapsed: isFolded }));
|
|
91
|
+
} catch {
|
|
92
|
+
// The layout still works when storage is unavailable.
|
|
93
|
+
}
|
|
94
|
+
}, [storageKey, current, isFolded]);
|
|
95
|
+
|
|
96
|
+
const fromPointer = (event: React.PointerEvent) => {
|
|
97
|
+
const rect = root.current?.getBoundingClientRect();
|
|
98
|
+
if (!rect) return;
|
|
99
|
+
const total = horizontal ? rect.width : rect.height;
|
|
100
|
+
const offset = horizontal ? event.clientX - rect.left : event.clientY - rect.top;
|
|
101
|
+
const along = anchor === "first" ? offset : total - offset;
|
|
102
|
+
const value = units === "pixels" ? along : (along / total) * 100;
|
|
103
|
+
// Dragging well past the minimum folds a collapsible pane; dragging back unfolds it.
|
|
104
|
+
if (collapsible && value < min * 0.6) {
|
|
105
|
+
setFolded(true);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (isFolded) setFolded(false);
|
|
109
|
+
set(value);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const shown = isFolded ? 0 : current;
|
|
113
|
+
const track = units === "pixels" ? `${shown}px` : `${shown}%`;
|
|
114
|
+
const grow = anchor === "first" ? 1 : -1;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div
|
|
118
|
+
ref={root}
|
|
119
|
+
className={cx("ml-resizable", className)}
|
|
120
|
+
data-direction={direction}
|
|
121
|
+
data-anchor={anchor}
|
|
122
|
+
data-collapsed={isFolded || undefined}
|
|
123
|
+
style={{ ...style, ["--ml-resizable-size" as string]: track }}
|
|
124
|
+
{...props}
|
|
125
|
+
>
|
|
126
|
+
<div className="ml-resizable-pane" data-folded={(isFolded && anchor === "first") || undefined}>
|
|
127
|
+
{first}
|
|
128
|
+
</div>
|
|
129
|
+
<div
|
|
130
|
+
role="separator"
|
|
131
|
+
tabIndex={0}
|
|
132
|
+
aria-label={label}
|
|
133
|
+
aria-orientation={horizontal ? "vertical" : "horizontal"}
|
|
134
|
+
aria-valuemin={collapsible ? 0 : min}
|
|
135
|
+
aria-valuemax={max}
|
|
136
|
+
aria-valuenow={Math.round(shown)}
|
|
137
|
+
aria-expanded={collapsible ? !isFolded : undefined}
|
|
138
|
+
className="ml-resizable-handle"
|
|
139
|
+
onPointerDown={(event) => {
|
|
140
|
+
dragging.current = true;
|
|
141
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
142
|
+
}}
|
|
143
|
+
onPointerMove={(event) => {
|
|
144
|
+
if (dragging.current) fromPointer(event);
|
|
145
|
+
}}
|
|
146
|
+
onPointerUp={() => {
|
|
147
|
+
dragging.current = false;
|
|
148
|
+
}}
|
|
149
|
+
onDoubleClick={() => {
|
|
150
|
+
setFolded(false);
|
|
151
|
+
set(defaultSize);
|
|
152
|
+
}}
|
|
153
|
+
onKeyDown={(event) => {
|
|
154
|
+
const step = (event.shiftKey ? 5 : 1) * (units === "pixels" ? 16 : 2);
|
|
155
|
+
const back = horizontal ? "ArrowLeft" : "ArrowUp";
|
|
156
|
+
const forward = horizontal ? "ArrowRight" : "ArrowDown";
|
|
157
|
+
if (event.key === back) set(current - step * grow);
|
|
158
|
+
else if (event.key === forward) set(current + step * grow);
|
|
159
|
+
else if (event.key === "Home") set(anchor === "first" ? min : max);
|
|
160
|
+
else if (event.key === "End") set(anchor === "first" ? max : min);
|
|
161
|
+
else if (event.key === "Enter" && collapsible) setFolded(!isFolded);
|
|
162
|
+
else return;
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
}}
|
|
165
|
+
/>
|
|
166
|
+
<div className="ml-resizable-pane" data-folded={(isFolded && anchor === "second") || undefined}>
|
|
167
|
+
{second}
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { rovingIndex } from "@mlola-ui/behavior/logic";
|
|
5
|
+
import { cx, useControllableState } from "../_internal/react";
|
|
6
|
+
|
|
7
|
+
export interface SegmentedOption {
|
|
8
|
+
value: string;
|
|
9
|
+
label: React.ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SegmentedControlProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
13
|
+
options: SegmentedOption[];
|
|
14
|
+
label: string;
|
|
15
|
+
value?: string;
|
|
16
|
+
defaultValue?: string;
|
|
17
|
+
onValueChange?: (value: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function SegmentedControl({
|
|
21
|
+
options,
|
|
22
|
+
label,
|
|
23
|
+
value,
|
|
24
|
+
defaultValue,
|
|
25
|
+
onValueChange,
|
|
26
|
+
className,
|
|
27
|
+
...props
|
|
28
|
+
}: SegmentedControlProps) {
|
|
29
|
+
const [current, setCurrent] = useControllableState({
|
|
30
|
+
value,
|
|
31
|
+
defaultValue: defaultValue ?? options[0]?.value ?? "",
|
|
32
|
+
onChange: onValueChange,
|
|
33
|
+
});
|
|
34
|
+
const index = Math.max(
|
|
35
|
+
0,
|
|
36
|
+
options.findIndex((option) => option.value === current),
|
|
37
|
+
);
|
|
38
|
+
const items = React.useRef<Array<HTMLButtonElement | null>>([]);
|
|
39
|
+
|
|
40
|
+
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
41
|
+
const target = rovingIndex(options.length, index, event.key, { horizontal: true, wrap: true });
|
|
42
|
+
if (target < 0) return;
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
setCurrent(options[target].value);
|
|
45
|
+
items.current[target]?.focus();
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
{...props}
|
|
51
|
+
className={cx("ml-segmented-control", className)}
|
|
52
|
+
role="radiogroup"
|
|
53
|
+
aria-label={label}
|
|
54
|
+
onKeyDown={onKeyDown}
|
|
55
|
+
>
|
|
56
|
+
{options.map((option, position) => {
|
|
57
|
+
const active = option.value === current;
|
|
58
|
+
return (
|
|
59
|
+
<button
|
|
60
|
+
key={option.value}
|
|
61
|
+
ref={(node) => {
|
|
62
|
+
items.current[position] = node;
|
|
63
|
+
}}
|
|
64
|
+
type="button"
|
|
65
|
+
role="radio"
|
|
66
|
+
aria-checked={active}
|
|
67
|
+
className="ml-segmented-control-item"
|
|
68
|
+
data-state={active ? "active" : "inactive"}
|
|
69
|
+
tabIndex={active ? 0 : -1}
|
|
70
|
+
onClick={() => setCurrent(option.value)}
|
|
71
|
+
>
|
|
72
|
+
{option.label}
|
|
73
|
+
</button>
|
|
74
|
+
);
|
|
75
|
+
})}
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|