@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.
Files changed (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -0
  3. package/components/accordion.json +54 -0
  4. package/components/activity-heatmap.json +54 -0
  5. package/components/alert.json +56 -0
  6. package/components/app-shell.json +53 -0
  7. package/components/avatar.json +54 -0
  8. package/components/badge.json +62 -0
  9. package/components/breadcrumb.json +49 -0
  10. package/components/button.json +62 -0
  11. package/components/calendar.json +55 -0
  12. package/components/card.json +53 -0
  13. package/components/carousel.json +52 -0
  14. package/components/checkbox.json +50 -0
  15. package/components/color-picker.json +60 -0
  16. package/components/combobox.json +64 -0
  17. package/components/command-menu.json +64 -0
  18. package/components/context-menu.json +61 -0
  19. package/components/date-picker.json +59 -0
  20. package/components/dropdown-menu.json +51 -0
  21. package/components/dropzone.json +45 -0
  22. package/components/empty-state.json +45 -0
  23. package/components/hover-card.json +59 -0
  24. package/components/input.json +56 -0
  25. package/components/kbd.json +45 -0
  26. package/components/modal.json +63 -0
  27. package/components/number-input.json +58 -0
  28. package/components/otp-input.json +49 -0
  29. package/components/pagination.json +50 -0
  30. package/components/popover.json +58 -0
  31. package/components/priority-icon.json +53 -0
  32. package/components/progress.json +57 -0
  33. package/components/radio-group.json +48 -0
  34. package/components/rating.json +48 -0
  35. package/components/resizable.json +46 -0
  36. package/components/segmented-control.json +51 -0
  37. package/components/select.json +67 -0
  38. package/components/sheet.json +66 -0
  39. package/components/skeleton.json +48 -0
  40. package/components/slider.json +53 -0
  41. package/components/status-icon.json +53 -0
  42. package/components/stepper.json +50 -0
  43. package/components/table.json +53 -0
  44. package/components/tabs.json +54 -0
  45. package/components/tag-input.json +53 -0
  46. package/components/textarea.json +48 -0
  47. package/components/time-picker.json +58 -0
  48. package/components/timeline.json +54 -0
  49. package/components/toast.json +56 -0
  50. package/components/toggle.json +52 -0
  51. package/components/tooltip.json +54 -0
  52. package/components/tour.json +62 -0
  53. package/components/virtual-list.json +54 -0
  54. package/generated/catalog.ts +2037 -0
  55. package/index.json +413 -0
  56. package/package.json +47 -0
  57. package/schema/mlola.config.schema.json +51 -0
  58. package/schema/registry-index.schema.json +116 -0
  59. package/schema/registry-item.schema.json +80 -0
  60. package/snapshot/index.json +2938 -0
  61. package/snapshot/items/components/accordion.json +54 -0
  62. package/snapshot/items/components/activity-heatmap.json +54 -0
  63. package/snapshot/items/components/alert.json +56 -0
  64. package/snapshot/items/components/app-shell.json +53 -0
  65. package/snapshot/items/components/avatar.json +54 -0
  66. package/snapshot/items/components/badge.json +62 -0
  67. package/snapshot/items/components/breadcrumb.json +49 -0
  68. package/snapshot/items/components/button.json +62 -0
  69. package/snapshot/items/components/calendar.json +55 -0
  70. package/snapshot/items/components/card.json +53 -0
  71. package/snapshot/items/components/carousel.json +52 -0
  72. package/snapshot/items/components/checkbox.json +50 -0
  73. package/snapshot/items/components/color-picker.json +60 -0
  74. package/snapshot/items/components/combobox.json +64 -0
  75. package/snapshot/items/components/command-menu.json +64 -0
  76. package/snapshot/items/components/context-menu.json +61 -0
  77. package/snapshot/items/components/date-picker.json +59 -0
  78. package/snapshot/items/components/dropdown-menu.json +51 -0
  79. package/snapshot/items/components/dropzone.json +45 -0
  80. package/snapshot/items/components/empty-state.json +45 -0
  81. package/snapshot/items/components/hover-card.json +59 -0
  82. package/snapshot/items/components/input.json +56 -0
  83. package/snapshot/items/components/kbd.json +45 -0
  84. package/snapshot/items/components/modal.json +63 -0
  85. package/snapshot/items/components/number-input.json +58 -0
  86. package/snapshot/items/components/otp-input.json +49 -0
  87. package/snapshot/items/components/pagination.json +50 -0
  88. package/snapshot/items/components/popover.json +58 -0
  89. package/snapshot/items/components/priority-icon.json +53 -0
  90. package/snapshot/items/components/progress.json +57 -0
  91. package/snapshot/items/components/radio-group.json +48 -0
  92. package/snapshot/items/components/rating.json +48 -0
  93. package/snapshot/items/components/resizable.json +46 -0
  94. package/snapshot/items/components/segmented-control.json +51 -0
  95. package/snapshot/items/components/select.json +67 -0
  96. package/snapshot/items/components/sheet.json +66 -0
  97. package/snapshot/items/components/skeleton.json +48 -0
  98. package/snapshot/items/components/slider.json +53 -0
  99. package/snapshot/items/components/status-icon.json +53 -0
  100. package/snapshot/items/components/stepper.json +50 -0
  101. package/snapshot/items/components/table.json +53 -0
  102. package/snapshot/items/components/tabs.json +54 -0
  103. package/snapshot/items/components/tag-input.json +53 -0
  104. package/snapshot/items/components/textarea.json +48 -0
  105. package/snapshot/items/components/time-picker.json +58 -0
  106. package/snapshot/items/components/timeline.json +54 -0
  107. package/snapshot/items/components/toast.json +56 -0
  108. package/snapshot/items/components/toggle.json +52 -0
  109. package/snapshot/items/components/tooltip.json +54 -0
  110. package/snapshot/items/components/tour.json +62 -0
  111. package/snapshot/items/components/virtual-list.json +54 -0
  112. package/snapshot/source/packages/components/_internal/anchor.ts +54 -0
  113. package/snapshot/source/packages/components/_internal/dialog.ts +142 -0
  114. package/snapshot/source/packages/components/_internal/floating.ts +73 -0
  115. package/snapshot/source/packages/components/_internal/match.ts +39 -0
  116. package/snapshot/source/packages/components/_internal/react.ts +40 -0
  117. package/snapshot/source/packages/components/_internal/scroll.ts +15 -0
  118. package/snapshot/source/packages/components/accordion/accordion.tsx +164 -0
  119. package/snapshot/source/packages/components/activity-heatmap/activity-heatmap.tsx +204 -0
  120. package/snapshot/source/packages/components/activity-heatmap/calendar.ts +92 -0
  121. package/snapshot/source/packages/components/alert/alert.tsx +83 -0
  122. package/snapshot/source/packages/components/app-shell/app-shell.tsx +186 -0
  123. package/snapshot/source/packages/components/avatar/avatar.tsx +56 -0
  124. package/snapshot/source/packages/components/badge/badge.tsx +39 -0
  125. package/snapshot/source/packages/components/breadcrumb/breadcrumb.tsx +61 -0
  126. package/snapshot/source/packages/components/button/button.tsx +42 -0
  127. package/snapshot/source/packages/components/calendar/calendar.tsx +309 -0
  128. package/snapshot/source/packages/components/calendar/dates.ts +163 -0
  129. package/snapshot/source/packages/components/card/card.tsx +39 -0
  130. package/snapshot/source/packages/components/carousel/carousel.tsx +248 -0
  131. package/snapshot/source/packages/components/checkbox/checkbox.tsx +66 -0
  132. package/snapshot/source/packages/components/color-picker/color-picker.tsx +261 -0
  133. package/snapshot/source/packages/components/color-picker/color.ts +124 -0
  134. package/snapshot/source/packages/components/combobox/combobox.tsx +320 -0
  135. package/snapshot/source/packages/components/command-menu/command-menu.tsx +199 -0
  136. package/snapshot/source/packages/components/context-menu/context-menu.tsx +192 -0
  137. package/snapshot/source/packages/components/date-picker/date-picker.tsx +185 -0
  138. package/snapshot/source/packages/components/dropdown-menu/dropdown-menu.tsx +157 -0
  139. package/snapshot/source/packages/components/dropzone/dropzone.tsx +185 -0
  140. package/snapshot/source/packages/components/empty-state/empty-state.tsx +32 -0
  141. package/snapshot/source/packages/components/hover-card/hover-card.tsx +103 -0
  142. package/snapshot/source/packages/components/input/input.tsx +101 -0
  143. package/snapshot/source/packages/components/kbd/kbd.tsx +54 -0
  144. package/snapshot/source/packages/components/modal/modal.tsx +100 -0
  145. package/snapshot/source/packages/components/number-input/number-input.tsx +184 -0
  146. package/snapshot/source/packages/components/number-input/number.ts +50 -0
  147. package/snapshot/source/packages/components/otp-input/otp-input.tsx +116 -0
  148. package/snapshot/source/packages/components/pagination/pagination.tsx +65 -0
  149. package/snapshot/source/packages/components/popover/popover.tsx +107 -0
  150. package/snapshot/source/packages/components/priority-icon/labels.ts +13 -0
  151. package/snapshot/source/packages/components/priority-icon/priority-icon.tsx +60 -0
  152. package/snapshot/source/packages/components/progress/progress.tsx +110 -0
  153. package/snapshot/source/packages/components/radio-group/radio-group.tsx +106 -0
  154. package/snapshot/source/packages/components/rating/rating.tsx +94 -0
  155. package/snapshot/source/packages/components/resizable/resizable.tsx +171 -0
  156. package/snapshot/source/packages/components/segmented-control/segmented-control.tsx +78 -0
  157. package/snapshot/source/packages/components/select/select.tsx +332 -0
  158. package/snapshot/source/packages/components/sheet/sheet.tsx +109 -0
  159. package/snapshot/source/packages/components/skeleton/skeleton.tsx +84 -0
  160. package/snapshot/source/packages/components/slider/slider.tsx +99 -0
  161. package/snapshot/source/packages/components/status-icon/labels.ts +14 -0
  162. package/snapshot/source/packages/components/status-icon/status-icon.tsx +76 -0
  163. package/snapshot/source/packages/components/stepper/stepper.tsx +87 -0
  164. package/snapshot/source/packages/components/table/table.tsx +66 -0
  165. package/snapshot/source/packages/components/tabs/tabs.tsx +163 -0
  166. package/snapshot/source/packages/components/tag-input/tag-input.tsx +273 -0
  167. package/snapshot/source/packages/components/textarea/textarea.tsx +70 -0
  168. package/snapshot/source/packages/components/time-picker/time-picker.tsx +286 -0
  169. package/snapshot/source/packages/components/time-picker/times.ts +76 -0
  170. package/snapshot/source/packages/components/timeline/timeline.tsx +115 -0
  171. package/snapshot/source/packages/components/toast/toast.tsx +317 -0
  172. package/snapshot/source/packages/components/toggle/toggle.tsx +80 -0
  173. package/snapshot/source/packages/components/tooltip/tooltip.tsx +70 -0
  174. package/snapshot/source/packages/components/tour/tour.tsx +193 -0
  175. package/snapshot/source/packages/components/virtual-list/virtual-list.tsx +121 -0
  176. package/snapshot/source/packages/components/virtual-list/virtual.ts +33 -0
  177. 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
+ }