sid-ui 0.0.0-stage → 0.1.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/README.md +54 -2
- package/package.json +51 -4
- package/src/brand/BrandFooter.tsx +45 -0
- package/src/brand/BrandHeading.tsx +24 -0
- package/src/brand/BrandLockup.tsx +47 -0
- package/src/brand/JoinedLogo.tsx +67 -0
- package/src/brand/JoinedMark.tsx +76 -0
- package/src/brand/geometry.ts +26 -0
- package/src/brand/index.ts +11 -0
- package/src/brand/name.ts +5 -0
- package/src/brand/paint.tsx +56 -0
- package/src/components/Action.tsx +44 -0
- package/src/components/AddressSelector.tsx +99 -0
- package/src/components/BarChart.tsx +132 -0
- package/src/components/Calendar.tsx +284 -0
- package/src/components/CalendarAgenda.tsx +87 -0
- package/src/components/CalendarMonth.tsx +223 -0
- package/src/components/CalendarSchedule.tsx +210 -0
- package/src/components/Card.tsx +80 -0
- package/src/components/Charts.tsx +64 -0
- package/src/components/Chat.tsx +35 -0
- package/src/components/CitySelector.tsx +81 -0
- package/src/components/Clock.tsx +196 -0
- package/src/components/CodeInput.tsx +183 -0
- package/src/components/Container.tsx +20 -0
- package/src/components/Content.tsx +50 -0
- package/src/components/Data.tsx +43 -0
- package/src/components/DataInput.tsx +107 -0
- package/src/components/DateField.tsx +162 -0
- package/src/components/DonutChart.tsx +123 -0
- package/src/components/Drawer.tsx +110 -0
- package/src/components/Feedback.tsx +37 -0
- package/src/components/FileUploader.tsx +377 -0
- package/src/components/FunnelChart.tsx +83 -0
- package/src/components/Glyph.tsx +330 -0
- package/src/components/GoogleSignIn.tsx +67 -0
- package/src/components/GridSystem.tsx +131 -0
- package/src/components/HeatmapCalendar.tsx +167 -0
- package/src/components/KanbanBoard.tsx +421 -0
- package/src/components/Kpi.tsx +61 -0
- package/src/components/LayoutPrimitives.tsx +86 -0
- package/src/components/Lists.tsx +19 -0
- package/src/components/LocationSelector.tsx +49 -0
- package/src/components/MessageList.tsx +80 -0
- package/src/components/Nav.tsx +69 -0
- package/src/components/Navigation.tsx +52 -0
- package/src/components/Notification.tsx +115 -0
- package/src/components/NotificationTrigger.tsx +248 -0
- package/src/components/Overlay.tsx +50 -0
- package/src/components/Page.tsx +143 -0
- package/src/components/PageTabs.tsx +28 -0
- package/src/components/PickerShell.tsx +138 -0
- package/src/components/PillNav.tsx +194 -0
- package/src/components/Primitives.tsx +30 -0
- package/src/components/Rating.tsx +78 -0
- package/src/components/Responsive.tsx +128 -0
- package/src/components/ResponsiveFrame.tsx +122 -0
- package/src/components/Segment.tsx +190 -0
- package/src/components/Sparkline.tsx +52 -0
- package/src/components/StateSelector.tsx +39 -0
- package/src/components/Sticky.tsx +95 -0
- package/src/components/Table.tsx +344 -0
- package/src/components/ThemeToggle.tsx +48 -0
- package/src/components/Tile.tsx +23 -0
- package/src/components/TimeColumns.tsx +155 -0
- package/src/components/TimeDial.tsx +260 -0
- package/src/components/TimeField.tsx +196 -0
- package/src/components/TimeInput.tsx +213 -0
- package/src/components/TimeSlots.tsx +83 -0
- package/src/components/Timeline.tsx +111 -0
- package/src/components/TokenDemo.tsx +378 -0
- package/src/components/Tree.tsx +283 -0
- package/src/components/TrendChart.tsx +211 -0
- package/src/components/breakpoints.ts +42 -0
- package/src/components/calendarTypes.ts +26 -0
- package/src/components/chartMath.ts +125 -0
- package/src/components/chartParts.tsx +116 -0
- package/src/components/codeInputRules.ts +58 -0
- package/src/components/date.ts +87 -0
- package/src/components/hooks.ts +53 -0
- package/src/components/index.ts +195 -0
- package/src/components/kanban.ts +78 -0
- package/src/components/motion.ts +78 -0
- package/src/components/places-search.tsx +73 -0
- package/src/components/places.ts +209 -0
- package/src/components/size.ts +3 -0
- package/src/components/time.ts +83 -0
- package/src/components/useFileDrop.ts +104 -0
- package/src/places/geoapify.ts +129 -0
- package/src/styles/astryx.css +5 -0
- package/src/styles/components/brand.css +98 -0
- package/src/styles/components/calendar.css +680 -0
- package/src/styles/components/charts.css +659 -0
- package/src/styles/components/code-input.css +233 -0
- package/src/styles/components/google.css +11 -0
- package/src/styles/components/message-list.css +153 -0
- package/src/styles/components/notification.css +350 -0
- package/src/styles/components/picker.css +114 -0
- package/src/styles/components/pill-nav.css +179 -0
- package/src/styles/components/responsive.css +419 -0
- package/src/styles/components/sticky.css +23 -0
- package/src/styles/components/table.css +267 -0
- package/src/styles/components/time.css +526 -0
- package/src/styles/components/timeline.css +426 -0
- package/src/styles/components/tree.css +269 -0
- package/src/styles/components.css +423 -0
- package/src/styles/joined.css +4 -0
- package/src/styles/theme.css +79 -0
- package/src/styles/tokens.css +338 -0
- package/src/theme/JoinedProvider.tsx +50 -0
- package/src/theme/index.ts +3 -0
- package/src/theme/joined.d.ts +11 -0
- package/src/theme/joined.js +569 -0
- package/src/theme/joined.theme.ts +74 -0
- package/src/theme/joined.variants.d.ts +16 -0
- package/src/theme/theme.css +786 -0
- package/styles/astryx.css +1 -0
- package/styles/components.css +1 -0
- package/styles/joined.css +1 -0
- package/styles/theme.css +1 -0
- package/styles/tokens.css +1 -0
- package/styles.css +1 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
spacing,
|
|
7
|
+
tierFor,
|
|
8
|
+
type ResponsiveTo,
|
|
9
|
+
type SpacingStep,
|
|
10
|
+
type Tier,
|
|
11
|
+
type TierOrBase,
|
|
12
|
+
} from "./breakpoints";
|
|
13
|
+
|
|
14
|
+
/** Live content-box width of an element; 0 until measured. */
|
|
15
|
+
export function useElementWidth(ref: RefObject<HTMLElement | null>) {
|
|
16
|
+
const [width, setWidth] = useState(0);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const el = ref.current;
|
|
19
|
+
if (!el) return;
|
|
20
|
+
const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width)));
|
|
21
|
+
observer.observe(el);
|
|
22
|
+
return () => observer.disconnect();
|
|
23
|
+
}, [ref]);
|
|
24
|
+
return width;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Container tier of an element — the same tiers GridSystem uses in container mode. */
|
|
28
|
+
export function useContainerBreakpoint(ref: RefObject<HTMLElement | null>): TierOrBase {
|
|
29
|
+
return tierFor(useElementWidth(ref), "container");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Viewport tier of the window — Astryx's own width breakpoints. */
|
|
33
|
+
export function useViewportBreakpoint(): TierOrBase {
|
|
34
|
+
const [width, setWidth] = useState(0);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const update = () => setWidth(window.innerWidth);
|
|
37
|
+
update();
|
|
38
|
+
window.addEventListener("resize", update);
|
|
39
|
+
return () => window.removeEventListener("resize", update);
|
|
40
|
+
}, []);
|
|
41
|
+
return tierFor(width, "viewport");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ResponsiveContainerProps {
|
|
45
|
+
children: ReactNode;
|
|
46
|
+
className?: string;
|
|
47
|
+
style?: CSSProperties;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Marks a region that Show, Hide, and ResponsiveStack measure in container mode. */
|
|
51
|
+
export function ResponsiveContainer({ children, className, style }: ResponsiveContainerProps) {
|
|
52
|
+
return (
|
|
53
|
+
<div className={["os-responsive-container", className].filter(Boolean).join(" ")} style={style}>
|
|
54
|
+
{children}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ResponsiveStackProps {
|
|
60
|
+
children: ReactNode;
|
|
61
|
+
/** Tier where the stack turns from a column into a row. */
|
|
62
|
+
from?: Tier;
|
|
63
|
+
gap?: SpacingStep;
|
|
64
|
+
responsiveTo?: ResponsiveTo;
|
|
65
|
+
/** Give every child an equal share of the row. */
|
|
66
|
+
isEqual?: boolean;
|
|
67
|
+
/** Cross-axis alignment once horizontal. */
|
|
68
|
+
align?: "start" | "center" | "end" | "stretch";
|
|
69
|
+
/** Put the last child first while stacked — e.g. an image above its copy. */
|
|
70
|
+
isReversedWhenStacked?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Stacked on narrow widths, side by side from a tier up. */
|
|
74
|
+
export function ResponsiveStack({
|
|
75
|
+
children,
|
|
76
|
+
from = "md",
|
|
77
|
+
gap = 4,
|
|
78
|
+
responsiveTo = "container",
|
|
79
|
+
isEqual,
|
|
80
|
+
align = "stretch",
|
|
81
|
+
isReversedWhenStacked,
|
|
82
|
+
}: ResponsiveStackProps) {
|
|
83
|
+
return (
|
|
84
|
+
<div className={`os-rstack-root os-responsive-${responsiveTo}`}>
|
|
85
|
+
<div
|
|
86
|
+
className={[
|
|
87
|
+
"os-rstack",
|
|
88
|
+
`os-rstack-from-${from}`,
|
|
89
|
+
isEqual && "os-rstack-equal",
|
|
90
|
+
isReversedWhenStacked && "os-rstack-reverse",
|
|
91
|
+
]
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.join(" ")}
|
|
94
|
+
style={{ "--os-rstack-gap": spacing(gap), "--os-rstack-align": align } as CSSProperties}
|
|
95
|
+
>
|
|
96
|
+
{children}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ShowProps {
|
|
103
|
+
children: ReactNode;
|
|
104
|
+
/** Visible at this tier and wider. */
|
|
105
|
+
from?: Tier;
|
|
106
|
+
/** Visible below this tier. */
|
|
107
|
+
below?: Tier;
|
|
108
|
+
/** container reads the nearest ResponsiveContainer, GridSystem, or ResponsiveFrame. */
|
|
109
|
+
responsiveTo?: ResponsiveTo;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Renders children only inside a width range. Pure CSS — no layout flash. */
|
|
113
|
+
export function Show({ children, from, below, responsiveTo = "container" }: ShowProps) {
|
|
114
|
+
const classes = [
|
|
115
|
+
"os-show",
|
|
116
|
+
`os-show-${responsiveTo}`,
|
|
117
|
+
from && `os-show-from-${from}`,
|
|
118
|
+
below && `os-show-below-${below}`,
|
|
119
|
+
]
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.join(" ");
|
|
122
|
+
return <div className={classes}>{children}</div>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The inverse of Show: hidden inside the range. */
|
|
126
|
+
export function Hide({ from, below, ...props }: ShowProps) {
|
|
127
|
+
return <Show {...props} from={below} below={from} />;
|
|
128
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { SegmentedControl, SegmentedControlItem } from "./Action";
|
|
6
|
+
import { tierFor } from "./breakpoints";
|
|
7
|
+
import { Text } from "./Primitives";
|
|
8
|
+
import { useElementWidth } from "./Responsive";
|
|
9
|
+
|
|
10
|
+
export interface FramePreset {
|
|
11
|
+
label: string;
|
|
12
|
+
/** px, or "fill" for the full available width. */
|
|
13
|
+
width: number | "fill";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const FRAME_PRESETS: FramePreset[] = [
|
|
17
|
+
{ label: "Phone", width: 360 },
|
|
18
|
+
{ label: "Tablet", width: 640 },
|
|
19
|
+
{ label: "Laptop", width: 880 },
|
|
20
|
+
{ label: "Fill", width: "fill" },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const MIN_FRAME_WIDTH = 280;
|
|
24
|
+
const KEY_STEP = 16;
|
|
25
|
+
|
|
26
|
+
export interface ResponsiveFrameProps {
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
presets?: FramePreset[];
|
|
29
|
+
/** Preset label to start on. */
|
|
30
|
+
defaultPreset?: string;
|
|
31
|
+
/** Accessible name for the frame. */
|
|
32
|
+
label?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Shows content at a chosen width. Pick a device preset or drag the edge;
|
|
37
|
+
* everything inside measures this frame as its container, so GridSystem,
|
|
38
|
+
* ResponsiveStack, Show/Hide, and Astryx Grid all reflow live.
|
|
39
|
+
*/
|
|
40
|
+
export function ResponsiveFrame({
|
|
41
|
+
children,
|
|
42
|
+
presets = FRAME_PRESETS,
|
|
43
|
+
defaultPreset = "Fill",
|
|
44
|
+
label = "Responsive preview",
|
|
45
|
+
}: ResponsiveFrameProps) {
|
|
46
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
47
|
+
/** What children actually get — presets size the content box, not the chrome. */
|
|
48
|
+
const actual = useElementWidth(contentRef);
|
|
49
|
+
const initial = presets.find((p) => p.label === defaultPreset)?.width ?? "fill";
|
|
50
|
+
const [width, setWidth] = useState<number | "fill">(initial);
|
|
51
|
+
const drag = useRef<{ x: number; width: number } | null>(null);
|
|
52
|
+
const active = presets.find((p) => p.width === width)?.label ?? "";
|
|
53
|
+
|
|
54
|
+
function resizeTo(next: number) {
|
|
55
|
+
setWidth(Math.max(MIN_FRAME_WIDTH, Math.round(next)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function onPointerDown(event: PointerEvent<HTMLDivElement>) {
|
|
59
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
60
|
+
drag.current = { x: event.clientX, width: actual };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function onPointerMove(event: PointerEvent<HTMLDivElement>) {
|
|
64
|
+
if (!drag.current) return;
|
|
65
|
+
resizeTo(drag.current.width + (event.clientX - drag.current.x));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
|
69
|
+
const delta = event.key === "ArrowRight" ? KEY_STEP : event.key === "ArrowLeft" ? -KEY_STEP : 0;
|
|
70
|
+
if (!delta) return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
resizeTo(actual + delta);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="os-rframe">
|
|
77
|
+
<div className="os-rframe-bar">
|
|
78
|
+
<SegmentedControl
|
|
79
|
+
label="Preview width"
|
|
80
|
+
size="sm"
|
|
81
|
+
value={active}
|
|
82
|
+
onChange={(value) => setWidth(presets.find((p) => p.label === value)?.width ?? "fill")}
|
|
83
|
+
>
|
|
84
|
+
{presets.map((preset) => (
|
|
85
|
+
<SegmentedControlItem key={preset.label} value={preset.label} label={preset.label} />
|
|
86
|
+
))}
|
|
87
|
+
</SegmentedControl>
|
|
88
|
+
<Text type="supporting" color="secondary" hasTabularNumbers>
|
|
89
|
+
{actual ? `${actual}px · ${tierFor(actual, "container")}` : ""}
|
|
90
|
+
</Text>
|
|
91
|
+
</div>
|
|
92
|
+
<div className="os-rframe-stage">
|
|
93
|
+
<div
|
|
94
|
+
className={width === "fill" ? "os-rframe-viewport os-rframe-fill" : "os-rframe-viewport"}
|
|
95
|
+
style={
|
|
96
|
+
width === "fill" ? undefined : { width: `calc(${width}px + 2 * var(--os-rframe-pad))` }
|
|
97
|
+
}
|
|
98
|
+
role="group"
|
|
99
|
+
aria-label={label}
|
|
100
|
+
>
|
|
101
|
+
<div ref={contentRef} className="os-rframe-content os-responsive-container">
|
|
102
|
+
{children}
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
<div
|
|
106
|
+
className="os-rframe-handle"
|
|
107
|
+
role="separator"
|
|
108
|
+
aria-orientation="vertical"
|
|
109
|
+
aria-label="Resize preview"
|
|
110
|
+
aria-valuenow={actual}
|
|
111
|
+
aria-valuemin={MIN_FRAME_WIDTH}
|
|
112
|
+
tabIndex={0}
|
|
113
|
+
onPointerDown={onPointerDown}
|
|
114
|
+
onPointerMove={onPointerMove}
|
|
115
|
+
onPointerUp={() => (drag.current = null)}
|
|
116
|
+
onPointerCancel={() => (drag.current = null)}
|
|
117
|
+
onKeyDown={onKeyDown}
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
const SEGMENT_SELECTOR = "[data-os-segment]";
|
|
6
|
+
|
|
7
|
+
function siblings(from: HTMLElement) {
|
|
8
|
+
const root = from.closest(".os-segments");
|
|
9
|
+
return root ? Array.from(root.querySelectorAll<HTMLElement>(SEGMENT_SELECTOR)) : [from];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function focusSibling(from: HTMLElement, offset: 1 | -1) {
|
|
13
|
+
const list = siblings(from);
|
|
14
|
+
list[list.indexOf(from) + offset]?.focus();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface NumberSegmentProps {
|
|
18
|
+
value: number | null;
|
|
19
|
+
min: number;
|
|
20
|
+
max: number;
|
|
21
|
+
/** Digits shown, e.g. 2 for "09" or 4 for a year. */
|
|
22
|
+
digits: number;
|
|
23
|
+
placeholder: string;
|
|
24
|
+
label: string;
|
|
25
|
+
onChange: (value: number | null) => void;
|
|
26
|
+
/** Maps the stored value to what is shown, e.g. 13 → "01" on a 12-hour field. */
|
|
27
|
+
format?: (value: number) => string;
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One typed part of a date or time. Digits fill it and hop to the next part;
|
|
33
|
+
* arrows step and wrap; Backspace clears, then moves back.
|
|
34
|
+
*/
|
|
35
|
+
export function NumberSegment({
|
|
36
|
+
value,
|
|
37
|
+
min,
|
|
38
|
+
max,
|
|
39
|
+
digits,
|
|
40
|
+
placeholder,
|
|
41
|
+
label,
|
|
42
|
+
onChange,
|
|
43
|
+
format,
|
|
44
|
+
disabled,
|
|
45
|
+
}: NumberSegmentProps) {
|
|
46
|
+
// State drives what is shown; the ref is what handlers read, so fast typing
|
|
47
|
+
// and the blur that follows an auto-advance never see a stale buffer.
|
|
48
|
+
const [buffer, setBufferState] = useState("");
|
|
49
|
+
const bufferRef = useRef("");
|
|
50
|
+
function setBuffer(next: string) {
|
|
51
|
+
bufferRef.current = next;
|
|
52
|
+
setBufferState(next);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function step(delta: number) {
|
|
56
|
+
const span = max - min + 1;
|
|
57
|
+
const base = value ?? (delta > 0 ? min - 1 : max + 1);
|
|
58
|
+
onChange(((((base - min + delta + span) % span) + span) % span) + min);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function onKeyDown(event: KeyboardEvent<HTMLSpanElement>) {
|
|
62
|
+
const el = event.currentTarget;
|
|
63
|
+
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
setBuffer("");
|
|
66
|
+
step(event.key === "ArrowUp" ? 1 : -1);
|
|
67
|
+
} else if (event.key === "ArrowRight") {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
focusSibling(el, 1);
|
|
70
|
+
} else if (event.key === "ArrowLeft") {
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
focusSibling(el, -1);
|
|
73
|
+
} else if (event.key === "Backspace" || event.key === "Delete") {
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
if (bufferRef.current) setBuffer(bufferRef.current.slice(0, -1));
|
|
76
|
+
else if (value != null) onChange(null);
|
|
77
|
+
else focusSibling(el, -1);
|
|
78
|
+
} else if (/^\d$/.test(event.key)) {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
let next = bufferRef.current + event.key;
|
|
81
|
+
if (Number(next) > max) next = event.key;
|
|
82
|
+
const parsed = Number(next);
|
|
83
|
+
const complete = next.length >= digits || parsed * 10 > max;
|
|
84
|
+
if (complete) {
|
|
85
|
+
setBuffer("");
|
|
86
|
+
onChange(Math.min(Math.max(parsed, min), max));
|
|
87
|
+
focusSibling(el, 1);
|
|
88
|
+
} else {
|
|
89
|
+
setBuffer(next);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const shown = buffer
|
|
95
|
+
? buffer.padStart(digits, "0")
|
|
96
|
+
: value == null
|
|
97
|
+
? placeholder
|
|
98
|
+
: format
|
|
99
|
+
? format(value)
|
|
100
|
+
: String(value).padStart(digits, "0");
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<span
|
|
104
|
+
data-os-segment=""
|
|
105
|
+
role="spinbutton"
|
|
106
|
+
tabIndex={disabled ? -1 : 0}
|
|
107
|
+
aria-label={label}
|
|
108
|
+
aria-valuemin={min}
|
|
109
|
+
aria-valuemax={max}
|
|
110
|
+
aria-valuenow={value ?? undefined}
|
|
111
|
+
aria-valuetext={value == null ? "Empty" : shown}
|
|
112
|
+
aria-disabled={disabled || undefined}
|
|
113
|
+
className={value == null && !buffer ? "os-segment os-segment-empty" : "os-segment"}
|
|
114
|
+
onKeyDown={disabled ? undefined : onKeyDown}
|
|
115
|
+
onBlur={() => {
|
|
116
|
+
const pending = bufferRef.current;
|
|
117
|
+
if (pending && Number(pending) >= min) onChange(Number(pending));
|
|
118
|
+
setBuffer("");
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
{shown}
|
|
122
|
+
</span>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface ChoiceSegmentProps<T extends string> {
|
|
127
|
+
value: T | null;
|
|
128
|
+
options: readonly T[];
|
|
129
|
+
placeholder: string;
|
|
130
|
+
label: string;
|
|
131
|
+
onChange: (value: T) => void;
|
|
132
|
+
disabled?: boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A typed part with a closed set — AM/PM. Type its first letter or step with arrows. */
|
|
136
|
+
export function ChoiceSegment<T extends string>({
|
|
137
|
+
value,
|
|
138
|
+
options,
|
|
139
|
+
placeholder,
|
|
140
|
+
label,
|
|
141
|
+
onChange,
|
|
142
|
+
disabled,
|
|
143
|
+
}: ChoiceSegmentProps<T>) {
|
|
144
|
+
function onKeyDown(event: KeyboardEvent<HTMLSpanElement>) {
|
|
145
|
+
const el = event.currentTarget;
|
|
146
|
+
const index = value == null ? -1 : options.indexOf(value);
|
|
147
|
+
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
const delta = event.key === "ArrowUp" ? 1 : -1;
|
|
150
|
+
onChange(options[(index + delta + options.length) % options.length]);
|
|
151
|
+
} else if (event.key === "ArrowRight") {
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
focusSibling(el, 1);
|
|
154
|
+
} else if (event.key === "ArrowLeft" || event.key === "Backspace") {
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
focusSibling(el, -1);
|
|
157
|
+
} else {
|
|
158
|
+
const hit = options.find((option) =>
|
|
159
|
+
option.toLowerCase().startsWith(event.key.toLowerCase()),
|
|
160
|
+
);
|
|
161
|
+
if (hit) {
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
onChange(hit);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<span
|
|
170
|
+
data-os-segment=""
|
|
171
|
+
role="spinbutton"
|
|
172
|
+
tabIndex={disabled ? -1 : 0}
|
|
173
|
+
aria-label={label}
|
|
174
|
+
aria-valuetext={value ?? "Empty"}
|
|
175
|
+
aria-disabled={disabled || undefined}
|
|
176
|
+
className={value == null ? "os-segment os-segment-empty" : "os-segment"}
|
|
177
|
+
onKeyDown={disabled ? undefined : onKeyDown}
|
|
178
|
+
>
|
|
179
|
+
{value ?? placeholder}
|
|
180
|
+
</span>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function SegmentDivider({ children }: { children: ReactNode }) {
|
|
185
|
+
return (
|
|
186
|
+
<span className="os-segment-divider" aria-hidden>
|
|
187
|
+
{children}
|
|
188
|
+
</span>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef } from "react";
|
|
4
|
+
|
|
5
|
+
import { areaPath, linePath, linearScale } from "./chartMath";
|
|
6
|
+
import { useElementWidth } from "./Responsive";
|
|
7
|
+
|
|
8
|
+
import type { ChartTone } from "./chartParts";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_HEIGHT = 36;
|
|
11
|
+
/** Keeps the end dot and its ring inside the frame. */
|
|
12
|
+
const INSET = 5;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A small trend under a KpiWidget number: a 2px line with a light wash and the latest
|
|
16
|
+
* point marked. It has no axes; the figure above it carries the value.
|
|
17
|
+
*/
|
|
18
|
+
export function Sparkline({
|
|
19
|
+
values,
|
|
20
|
+
label,
|
|
21
|
+
tone = "blue",
|
|
22
|
+
height = DEFAULT_HEIGHT,
|
|
23
|
+
}: {
|
|
24
|
+
values: number[];
|
|
25
|
+
/** Read to screen readers, e.g. "Applications per week, last 12 weeks". */
|
|
26
|
+
label: string;
|
|
27
|
+
tone?: ChartTone;
|
|
28
|
+
height?: number;
|
|
29
|
+
}) {
|
|
30
|
+
const frame = useRef<HTMLDivElement>(null);
|
|
31
|
+
const width = useElementWidth(frame);
|
|
32
|
+
const max = Math.max(0, ...values);
|
|
33
|
+
const min = Math.min(max, ...values);
|
|
34
|
+
const x = linearScale([0, Math.max(1, values.length - 1)], [INSET, width - INSET]);
|
|
35
|
+
const y = linearScale([min, max === min ? min + 1 : max], [height - INSET, INSET]);
|
|
36
|
+
const points = values.map((value, index) => ({ x: x(index), y: y(value) }));
|
|
37
|
+
const last = points[points.length - 1];
|
|
38
|
+
const summary =
|
|
39
|
+
values.length > 0 ? `${label}: from ${values[0]} to ${values[values.length - 1]}` : label;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div ref={frame} className="os-sparkline" style={{ height }} role="img" aria-label={summary}>
|
|
43
|
+
{width > 0 && points.length > 0 ? (
|
|
44
|
+
<svg width={width} height={height} data-tone={tone} aria-hidden>
|
|
45
|
+
<path className="os-chart-area" d={areaPath(points, height)} />
|
|
46
|
+
<path className="os-chart-line" d={linePath(points)} />
|
|
47
|
+
<circle className="os-chart-dot" cx={last.x} cy={last.y} r={3.5} />
|
|
48
|
+
</svg>
|
|
49
|
+
) : null}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Selector } from "./DataInput";
|
|
4
|
+
import { US_STATES } from "./places";
|
|
5
|
+
|
|
6
|
+
const OPTIONS = US_STATES.map((state) => ({
|
|
7
|
+
value: state.abbreviation,
|
|
8
|
+
label: state.name,
|
|
9
|
+
description: state.abbreviation,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
/** A searchable list of US states. The value is the postal abbreviation. */
|
|
13
|
+
export function StateSelector({
|
|
14
|
+
label,
|
|
15
|
+
value,
|
|
16
|
+
onChange,
|
|
17
|
+
isLabelHidden,
|
|
18
|
+
isDisabled,
|
|
19
|
+
}: {
|
|
20
|
+
label: string;
|
|
21
|
+
value: string;
|
|
22
|
+
onChange: (state: string) => void;
|
|
23
|
+
isLabelHidden?: boolean;
|
|
24
|
+
isDisabled?: boolean;
|
|
25
|
+
}) {
|
|
26
|
+
return (
|
|
27
|
+
<Selector
|
|
28
|
+
label={label}
|
|
29
|
+
isLabelHidden={isLabelHidden}
|
|
30
|
+
options={OPTIONS}
|
|
31
|
+
value={value}
|
|
32
|
+
onChange={onChange}
|
|
33
|
+
hasSearch
|
|
34
|
+
searchPlaceholder="Search states"
|
|
35
|
+
placeholder="Select a state"
|
|
36
|
+
isDisabled={isDisabled}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type CSSProperties,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
type RefObject,
|
|
10
|
+
} from "react";
|
|
11
|
+
|
|
12
|
+
import { spacing, type SpacingStep } from "./breakpoints";
|
|
13
|
+
|
|
14
|
+
const SCROLLABLE = /(auto|scroll|overlay)/;
|
|
15
|
+
|
|
16
|
+
/** The nearest ancestor that scrolls vertically, or null for the window. */
|
|
17
|
+
function scrollParent(el: HTMLElement | null): HTMLElement | null {
|
|
18
|
+
let node = el?.parentElement ?? null;
|
|
19
|
+
while (node && node !== document.body) {
|
|
20
|
+
if (SCROLLABLE.test(getComputedStyle(node).overflowY)) return node;
|
|
21
|
+
node = node.parentElement;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Visible height of whatever scrolls the sticky element — the window or a
|
|
28
|
+
* scroll container. The scroller is looked up again whenever layout changes,
|
|
29
|
+
* because a container's overflow style can arrive after the first render.
|
|
30
|
+
*/
|
|
31
|
+
function useScrollportHeight(ref: RefObject<HTMLElement | null>, enabled: boolean) {
|
|
32
|
+
const [height, setHeight] = useState<number | null>(null);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const el = ref.current;
|
|
35
|
+
if (!enabled || !el) return;
|
|
36
|
+
let scroller: HTMLElement | null = null;
|
|
37
|
+
let frame = 0;
|
|
38
|
+
const observer = new ResizeObserver(() => measure());
|
|
39
|
+
function measure() {
|
|
40
|
+
const next = scrollParent(el);
|
|
41
|
+
if (next !== scroller) {
|
|
42
|
+
if (scroller) observer.unobserve(scroller);
|
|
43
|
+
if (next) observer.observe(next);
|
|
44
|
+
scroller = next;
|
|
45
|
+
}
|
|
46
|
+
setHeight(scroller ? scroller.clientHeight : window.innerHeight);
|
|
47
|
+
}
|
|
48
|
+
// A container can start scrolling without resizing (overflow flips from
|
|
49
|
+
// clip to auto), so re-check once per frame while anything scrolls.
|
|
50
|
+
const onScroll = () => {
|
|
51
|
+
cancelAnimationFrame(frame);
|
|
52
|
+
frame = requestAnimationFrame(measure);
|
|
53
|
+
};
|
|
54
|
+
if (el.parentElement) observer.observe(el.parentElement);
|
|
55
|
+
measure();
|
|
56
|
+
window.addEventListener("resize", measure);
|
|
57
|
+
document.addEventListener("scroll", onScroll, { capture: true, passive: true });
|
|
58
|
+
return () => {
|
|
59
|
+
cancelAnimationFrame(frame);
|
|
60
|
+
observer.disconnect();
|
|
61
|
+
window.removeEventListener("resize", measure);
|
|
62
|
+
document.removeEventListener("scroll", onScroll, { capture: true });
|
|
63
|
+
};
|
|
64
|
+
}, [ref, enabled]);
|
|
65
|
+
return height;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface StickyProps {
|
|
69
|
+
children: ReactNode;
|
|
70
|
+
/** Gap kept above the element while stuck — a spacing step. With `fill`, also kept below. */
|
|
71
|
+
offset?: SpacingStep;
|
|
72
|
+
/**
|
|
73
|
+
* Size the element to the visible scroll area, so a tall panel scrolls inside
|
|
74
|
+
* itself instead of running past the fold. Pair with Layout height="fill".
|
|
75
|
+
*/
|
|
76
|
+
fill?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Keeps a panel in view while its column scrolls — a detail pane beside a list,
|
|
81
|
+
* a summary beside a form. Works in the window or any scroll container.
|
|
82
|
+
*/
|
|
83
|
+
export function Sticky({ children, offset = 0, fill = false }: StickyProps) {
|
|
84
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
85
|
+
const scrollport = useScrollportHeight(ref, fill);
|
|
86
|
+
const style = {
|
|
87
|
+
"--os-sticky-offset": spacing(offset),
|
|
88
|
+
...(fill && scrollport ? { "--os-sticky-scrollport": `${scrollport}px` } : {}),
|
|
89
|
+
} as CSSProperties;
|
|
90
|
+
return (
|
|
91
|
+
<div ref={ref} className={fill ? "os-sticky os-sticky-fill" : "os-sticky"} style={style}>
|
|
92
|
+
{children}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|