sid-ui 0.0.0-stage → 0.1.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 (116) hide show
  1. package/README.md +54 -2
  2. package/package.json +51 -4
  3. package/src/brand/BrandFooter.tsx +45 -0
  4. package/src/brand/BrandHeading.tsx +24 -0
  5. package/src/brand/BrandLockup.tsx +47 -0
  6. package/src/brand/JoinedLogo.tsx +67 -0
  7. package/src/brand/JoinedMark.tsx +76 -0
  8. package/src/brand/geometry.ts +26 -0
  9. package/src/brand/index.ts +11 -0
  10. package/src/brand/name.ts +5 -0
  11. package/src/brand/paint.tsx +56 -0
  12. package/src/components/Action.tsx +44 -0
  13. package/src/components/AddressSelector.tsx +99 -0
  14. package/src/components/BarChart.tsx +132 -0
  15. package/src/components/Calendar.tsx +284 -0
  16. package/src/components/CalendarAgenda.tsx +87 -0
  17. package/src/components/CalendarMonth.tsx +223 -0
  18. package/src/components/CalendarSchedule.tsx +210 -0
  19. package/src/components/Card.tsx +80 -0
  20. package/src/components/Charts.tsx +64 -0
  21. package/src/components/Chat.tsx +35 -0
  22. package/src/components/CitySelector.tsx +81 -0
  23. package/src/components/Clock.tsx +196 -0
  24. package/src/components/CodeInput.tsx +183 -0
  25. package/src/components/Container.tsx +20 -0
  26. package/src/components/Content.tsx +50 -0
  27. package/src/components/Data.tsx +43 -0
  28. package/src/components/DataInput.tsx +107 -0
  29. package/src/components/DateField.tsx +162 -0
  30. package/src/components/DonutChart.tsx +123 -0
  31. package/src/components/Drawer.tsx +110 -0
  32. package/src/components/Feedback.tsx +37 -0
  33. package/src/components/FileUploader.tsx +377 -0
  34. package/src/components/FunnelChart.tsx +83 -0
  35. package/src/components/Glyph.tsx +330 -0
  36. package/src/components/GoogleSignIn.tsx +67 -0
  37. package/src/components/GridSystem.tsx +131 -0
  38. package/src/components/HeatmapCalendar.tsx +167 -0
  39. package/src/components/KanbanBoard.tsx +421 -0
  40. package/src/components/Kpi.tsx +61 -0
  41. package/src/components/LayoutPrimitives.tsx +86 -0
  42. package/src/components/Lists.tsx +19 -0
  43. package/src/components/LocationSelector.tsx +49 -0
  44. package/src/components/MessageList.tsx +80 -0
  45. package/src/components/Nav.tsx +69 -0
  46. package/src/components/Navigation.tsx +52 -0
  47. package/src/components/Notification.tsx +115 -0
  48. package/src/components/NotificationTrigger.tsx +248 -0
  49. package/src/components/Overlay.tsx +50 -0
  50. package/src/components/Page.tsx +143 -0
  51. package/src/components/PageTabs.tsx +28 -0
  52. package/src/components/PickerShell.tsx +138 -0
  53. package/src/components/PillNav.tsx +194 -0
  54. package/src/components/Primitives.tsx +30 -0
  55. package/src/components/Rating.tsx +78 -0
  56. package/src/components/Responsive.tsx +128 -0
  57. package/src/components/ResponsiveFrame.tsx +122 -0
  58. package/src/components/Segment.tsx +190 -0
  59. package/src/components/Sparkline.tsx +52 -0
  60. package/src/components/StateSelector.tsx +39 -0
  61. package/src/components/Sticky.tsx +95 -0
  62. package/src/components/Table.tsx +344 -0
  63. package/src/components/ThemeToggle.tsx +48 -0
  64. package/src/components/Tile.tsx +23 -0
  65. package/src/components/TimeColumns.tsx +155 -0
  66. package/src/components/TimeDial.tsx +260 -0
  67. package/src/components/TimeField.tsx +196 -0
  68. package/src/components/TimeInput.tsx +213 -0
  69. package/src/components/TimeSlots.tsx +83 -0
  70. package/src/components/Timeline.tsx +111 -0
  71. package/src/components/TokenDemo.tsx +378 -0
  72. package/src/components/Tree.tsx +283 -0
  73. package/src/components/TrendChart.tsx +211 -0
  74. package/src/components/breakpoints.ts +42 -0
  75. package/src/components/calendarTypes.ts +26 -0
  76. package/src/components/chartMath.ts +125 -0
  77. package/src/components/chartParts.tsx +116 -0
  78. package/src/components/codeInputRules.ts +58 -0
  79. package/src/components/date.ts +87 -0
  80. package/src/components/hooks.ts +53 -0
  81. package/src/components/index.ts +195 -0
  82. package/src/components/kanban.ts +78 -0
  83. package/src/components/motion.ts +78 -0
  84. package/src/components/places-search.tsx +73 -0
  85. package/src/components/places.ts +209 -0
  86. package/src/components/size.ts +3 -0
  87. package/src/components/time.ts +83 -0
  88. package/src/components/useFileDrop.ts +104 -0
  89. package/src/places/geoapify.ts +129 -0
  90. package/src/styles/astryx.css +5 -0
  91. package/src/styles/components/brand.css +98 -0
  92. package/src/styles/components/calendar.css +680 -0
  93. package/src/styles/components/charts.css +659 -0
  94. package/src/styles/components/code-input.css +233 -0
  95. package/src/styles/components/google.css +11 -0
  96. package/src/styles/components/message-list.css +153 -0
  97. package/src/styles/components/notification.css +350 -0
  98. package/src/styles/components/picker.css +114 -0
  99. package/src/styles/components/pill-nav.css +179 -0
  100. package/src/styles/components/responsive.css +419 -0
  101. package/src/styles/components/sticky.css +23 -0
  102. package/src/styles/components/table.css +267 -0
  103. package/src/styles/components/time.css +526 -0
  104. package/src/styles/components/timeline.css +426 -0
  105. package/src/styles/components/tree.css +269 -0
  106. package/src/styles/components.css +423 -0
  107. package/src/styles/joined.css +4 -0
  108. package/src/styles/theme.css +79 -0
  109. package/src/styles/tokens.css +338 -0
  110. package/src/theme/JoinedProvider.tsx +50 -0
  111. package/src/theme/index.ts +3 -0
  112. package/src/theme/joined.d.ts +11 -0
  113. package/src/theme/joined.js +569 -0
  114. package/src/theme/joined.theme.ts +74 -0
  115. package/src/theme/joined.variants.d.ts +16 -0
  116. package/src/theme/theme.css +786 -0
@@ -0,0 +1,143 @@
1
+ import { Card } from "@astryxdesign/core/Card";
2
+ import { Divider } from "@astryxdesign/core/Divider";
3
+ import { Grid } from "@astryxdesign/core/Grid";
4
+ import { HStack, Stack } from "@astryxdesign/core/Stack";
5
+ import { Heading, Text } from "@astryxdesign/core/Text";
6
+
7
+ import type { ReactNode } from "react";
8
+
9
+ /**
10
+ * Page-level composites every Joined app shares: the centered content
11
+ * column, the page title row, a titled section card, and a row of stats.
12
+ * Built only from Astryx parts, so they follow the theme like everything else.
13
+ */
14
+
15
+ export const PAGE_WIDTHS = { narrow: 720, default: 1200, wide: 1360 } as const;
16
+ export type PageWidth = keyof typeof PAGE_WIDTHS;
17
+
18
+ /** Centers a page's content at a shared reading width. */
19
+ export function PageContainer({
20
+ children,
21
+ width = "default",
22
+ }: {
23
+ children: ReactNode;
24
+ width?: PageWidth;
25
+ }) {
26
+ return (
27
+ <Stack hAlign="center">
28
+ <Stack gap={6} maxWidth={PAGE_WIDTHS[width]} width="100%">
29
+ {children}
30
+ </Stack>
31
+ </Stack>
32
+ );
33
+ }
34
+
35
+ /** The title row: heading, a quiet description, and the page's main action. */
36
+ export function PageHeader({
37
+ title,
38
+ description,
39
+ action,
40
+ }: {
41
+ title: string;
42
+ description?: string;
43
+ action?: ReactNode;
44
+ }) {
45
+ return (
46
+ <HStack hAlign="between" vAlign="end" wrap="wrap" gap={3}>
47
+ <Stack gap={1}>
48
+ <Heading level={1}>{title}</Heading>
49
+ {description ? (
50
+ <Text color="secondary" display="block">
51
+ {description}
52
+ </Text>
53
+ ) : null}
54
+ </Stack>
55
+ {action}
56
+ </HStack>
57
+ );
58
+ }
59
+
60
+ /** A titled card: heading, quiet description, optional action, then content. */
61
+ export function SectionCard({
62
+ title,
63
+ description,
64
+ action,
65
+ footer,
66
+ children,
67
+ }: {
68
+ title: string;
69
+ description?: string;
70
+ action?: ReactNode;
71
+ footer?: ReactNode;
72
+ children?: ReactNode;
73
+ }) {
74
+ return (
75
+ <Card padding={6}>
76
+ <Stack gap={5}>
77
+ <HStack hAlign="between" vAlign="start" gap={3} wrap="wrap">
78
+ <Stack gap={1}>
79
+ <Heading level={2}>{title}</Heading>
80
+ {description ? (
81
+ <Text type="supporting" color="secondary" display="block">
82
+ {description}
83
+ </Text>
84
+ ) : null}
85
+ </Stack>
86
+ {action}
87
+ </HStack>
88
+ {children}
89
+ {footer ? (
90
+ <Stack gap={4}>
91
+ <Divider />
92
+ {footer}
93
+ </Stack>
94
+ ) : null}
95
+ </Stack>
96
+ </Card>
97
+ );
98
+ }
99
+
100
+ export type Stat = {
101
+ label: string;
102
+ value: string;
103
+ hint?: string;
104
+ accessory?: ReactNode;
105
+ };
106
+
107
+ /** One headline number with its label and a quiet hint. */
108
+ export function StatCard({ label, value, hint, accessory }: Stat) {
109
+ return (
110
+ <Card padding={5}>
111
+ <Stack gap={2}>
112
+ <Text type="supporting" color="secondary" display="block">
113
+ {label}
114
+ </Text>
115
+ <HStack gap={2} vAlign="center" wrap="wrap">
116
+ <Heading level={2} type="display-3">
117
+ {value}
118
+ </Heading>
119
+ {accessory}
120
+ </HStack>
121
+ {hint ? (
122
+ <Text type="supporting" color="secondary" display="block">
123
+ {hint}
124
+ </Text>
125
+ ) : null}
126
+ </Stack>
127
+ </Card>
128
+ );
129
+ }
130
+
131
+ const STAT_MIN_WIDTH = 150;
132
+ const STAT_MAX_COLUMNS = 4;
133
+
134
+ /** Stats that wrap from four across to one as the page narrows. */
135
+ export function StatGrid({ stats }: { stats: Stat[] }) {
136
+ return (
137
+ <Grid columns={{ minWidth: STAT_MIN_WIDTH, max: STAT_MAX_COLUMNS }} gap={4}>
138
+ {stats.map((stat) => (
139
+ <StatCard key={stat.label} {...stat} />
140
+ ))}
141
+ </Grid>
142
+ );
143
+ }
@@ -0,0 +1,28 @@
1
+ "use client";
2
+
3
+ import { Tab, TabList } from "./Navigation";
4
+
5
+ export type PageTab = { value: string; label: string; href: string };
6
+
7
+ /**
8
+ * Tabs that switch a page's view by navigating (each tab is a link), so the view is
9
+ * server-rendered and shareable. Pass the current tab's `value`.
10
+ */
11
+ export function PageTabs({
12
+ tabs,
13
+ value,
14
+ label,
15
+ }: {
16
+ tabs: PageTab[];
17
+ value: string;
18
+ /** Names the tab group for screen readers. */
19
+ label?: string;
20
+ }) {
21
+ return (
22
+ <TabList value={value} onChange={() => {}} hasDivider overflow="scroll" aria-label={label}>
23
+ {tabs.map((tab) => (
24
+ <Tab key={tab.value} value={tab.value} label={tab.label} href={tab.href} />
25
+ ))}
26
+ </TabList>
27
+ );
28
+ }
@@ -0,0 +1,138 @@
1
+ "use client";
2
+
3
+ import {
4
+ useCallback,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ type CSSProperties,
9
+ type ReactNode,
10
+ } from "react";
11
+
12
+ import { IconButton } from "./Action";
13
+ import { icons, type GlyphName } from "./Glyph";
14
+ import { useDismiss } from "./hooks";
15
+ import { Icon } from "./Primitives";
16
+
17
+ import type { ControlSize } from "./size";
18
+
19
+ export interface PickerShellProps {
20
+ /** Typed segments rendered inside the control. */
21
+ segments: ReactNode;
22
+ /** The dropdown panel. Omit for a type-only field. */
23
+ panel?: ReactNode;
24
+ open: boolean;
25
+ onOpenChange: (open: boolean) => void;
26
+ icon: GlyphName;
27
+ /** Accessible name for the segments group and the panel. */
28
+ label: string;
29
+ labelledBy?: string;
30
+ size?: ControlSize;
31
+ error?: boolean;
32
+ disabled?: boolean;
33
+ }
34
+
35
+ /** Space between the field and its panel, in px. */
36
+ const PANEL_GAP = 8;
37
+
38
+ /**
39
+ * The shared chrome for typed date and time fields: an Input-shaped control
40
+ * that you can type into, plus a trigger that drops a picker panel below it.
41
+ */
42
+ export function PickerShell({
43
+ segments,
44
+ panel,
45
+ open,
46
+ onOpenChange,
47
+ icon,
48
+ label,
49
+ labelledBy,
50
+ size = "md",
51
+ error,
52
+ disabled,
53
+ }: PickerShellProps) {
54
+ const rootRef = useRef<HTMLDivElement>(null);
55
+ const controlRef = useRef<HTMLDivElement>(null);
56
+ const panelRef = useRef<HTMLDivElement>(null);
57
+ const [position, setPosition] = useState<CSSProperties | null>(null);
58
+ const close = useCallback(() => onOpenChange(false), [onOpenChange]);
59
+ useDismiss(rootRef, open, close);
60
+
61
+ // Fixed positioning escapes cards that clip overflow; flip above when below is short.
62
+ useLayoutEffect(() => {
63
+ if (!open) {
64
+ setPosition(null);
65
+ return;
66
+ }
67
+ function place() {
68
+ const field = controlRef.current?.getBoundingClientRect();
69
+ const height = panelRef.current?.offsetHeight ?? 0;
70
+ if (!field) return;
71
+ const below = window.innerHeight - field.bottom - PANEL_GAP;
72
+ const flip = height > below && field.top > below;
73
+ setPosition({
74
+ left: field.left,
75
+ top: flip ? field.top - PANEL_GAP - height : field.bottom + PANEL_GAP,
76
+ });
77
+ }
78
+ place();
79
+ window.addEventListener("scroll", place, true);
80
+ window.addEventListener("resize", place);
81
+ return () => {
82
+ window.removeEventListener("scroll", place, true);
83
+ window.removeEventListener("resize", place);
84
+ };
85
+ }, [open]);
86
+
87
+ const shell = [
88
+ "os-control",
89
+ `os-control-${size}`,
90
+ "os-picker-control",
91
+ error && "os-control-error",
92
+ disabled && "os-control-disabled",
93
+ open && "os-picker-control-open",
94
+ ]
95
+ .filter(Boolean)
96
+ .join(" ");
97
+
98
+ return (
99
+ <div ref={rootRef} className="os-picker">
100
+ <div ref={controlRef} className={shell}>
101
+ <div
102
+ className="os-segments"
103
+ role="group"
104
+ aria-label={labelledBy ? undefined : label}
105
+ aria-labelledby={labelledBy}
106
+ >
107
+ {segments}
108
+ </div>
109
+ {panel && (
110
+ <IconButton
111
+ className="os-picker-trigger"
112
+ label={
113
+ open ? `Close ${label.toLowerCase()} picker` : `Open ${label.toLowerCase()} picker`
114
+ }
115
+ aria-expanded={open}
116
+ aria-haspopup="dialog"
117
+ variant="ghost"
118
+ size="sm"
119
+ isDisabled={disabled}
120
+ icon={<Icon icon={icons[icon]} />}
121
+ onClick={() => onOpenChange(!open)}
122
+ />
123
+ )}
124
+ </div>
125
+ {panel && open && (
126
+ <div
127
+ ref={panelRef}
128
+ className="os-picker-panel"
129
+ role="dialog"
130
+ aria-label={label}
131
+ style={position ?? { visibility: "hidden" }}
132
+ >
133
+ {panel}
134
+ </div>
135
+ )}
136
+ </div>
137
+ );
138
+ }
@@ -0,0 +1,194 @@
1
+ "use client";
2
+
3
+ import { useLinkComponent } from "@astryxdesign/core/Link";
4
+ import { Tooltip } from "@astryxdesign/core/Tooltip";
5
+ import { useLayoutEffect, useRef, useState, type MouseEvent, type RefObject } from "react";
6
+
7
+ import { Glyph, type GlyphName } from "./Glyph";
8
+ import { lerpBox, parseDuration, parseEasing, type Box } from "./motion";
9
+
10
+ /** Larger counts read as "99+" so the bubble stays small. */
11
+ const MAX_COUNT = 99;
12
+ /** The theme tokens the sliding highlight moves with, so it lands together with the CSS label. */
13
+ const DURATION_TOKEN = "--duration-medium";
14
+ const EASING_TOKEN = "--ease-standard";
15
+ const ACTIVE_SELECTOR = '.os-pill[data-active="true"]';
16
+
17
+ export type PillNavItem = {
18
+ href: string;
19
+ label: string;
20
+ icon: GlyphName;
21
+ /** Unread or pending count; hidden at zero. */
22
+ count?: number;
23
+ };
24
+
25
+ export type PillNavProps = {
26
+ items: PillNavItem[];
27
+ /** The href of the current page; that pill opens to show its label. */
28
+ activeHref?: string;
29
+ /** Names the navigation landmark. */
30
+ label: string;
31
+ /** `top` sits in a header; `bottom` is a fixed bar for small screens. */
32
+ placement?: "top" | "bottom";
33
+ };
34
+
35
+ function boxOf(element: HTMLElement): Box {
36
+ return { x: element.offsetLeft, width: element.offsetWidth };
37
+ }
38
+
39
+ /** A click the router will handle in this tab: primary button, no modifier keys. */
40
+ function isPlainClick(event: MouseEvent) {
41
+ return (
42
+ !event.defaultPrevented &&
43
+ event.button === 0 &&
44
+ !event.metaKey &&
45
+ !event.ctrlKey &&
46
+ !event.shiftKey &&
47
+ !event.altKey
48
+ );
49
+ }
50
+
51
+ /**
52
+ * Keeps one highlight under the active pill. When the active pill changes it glides there,
53
+ * easing toward the pill's live box each frame, so it tracks the label as it opens and
54
+ * finishes exactly on the final size. Between changes a ResizeObserver keeps it aligned.
55
+ */
56
+ function useSlidingIndicator(
57
+ trackRef: RefObject<HTMLDivElement | null>,
58
+ indicatorRef: RefObject<HTMLSpanElement | null>,
59
+ activeKey: string | undefined,
60
+ ) {
61
+ const lastBox = useRef<Box | null>(null);
62
+ const isAnimating = useRef(false);
63
+
64
+ useLayoutEffect(() => {
65
+ const track = trackRef.current;
66
+ const indicator = indicatorRef.current;
67
+ if (!track || !indicator) return;
68
+ const target = () => track.querySelector<HTMLElement>(ACTIVE_SELECTOR);
69
+ const place = (box: Box | null) => {
70
+ lastBox.current = box;
71
+ indicator.dataset.visible = box ? "true" : "false";
72
+ if (!box) return;
73
+ indicator.style.transform = `translateX(${box.x}px)`;
74
+ indicator.style.width = `${box.width}px`;
75
+ };
76
+
77
+ const to = target();
78
+ const from = lastBox.current;
79
+ const styles = getComputedStyle(track);
80
+ const duration = parseDuration(styles.getPropertyValue(DURATION_TOKEN));
81
+ const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
82
+ if (!to || !from || reduceMotion || duration === 0) {
83
+ place(to ? boxOf(to) : null);
84
+ return;
85
+ }
86
+
87
+ const ease = parseEasing(styles.getPropertyValue(EASING_TOKEN));
88
+ const start = performance.now();
89
+ let frame = 0;
90
+ isAnimating.current = true;
91
+ const step = (now: number) => {
92
+ const live = target();
93
+ if (!live) return;
94
+ const progress = Math.min(1, (now - start) / duration);
95
+ place(lerpBox(from, boxOf(live), ease(progress)));
96
+ if (progress < 1) frame = requestAnimationFrame(step);
97
+ else isAnimating.current = false;
98
+ };
99
+ frame = requestAnimationFrame(step);
100
+ return () => {
101
+ cancelAnimationFrame(frame);
102
+ isAnimating.current = false;
103
+ };
104
+ }, [activeKey, trackRef, indicatorRef]);
105
+
106
+ useLayoutEffect(() => {
107
+ const track = trackRef.current;
108
+ const indicator = indicatorRef.current;
109
+ if (!track || !indicator) return;
110
+ const observer = new ResizeObserver(() => {
111
+ if (isAnimating.current) return;
112
+ const active = track.querySelector<HTMLElement>(ACTIVE_SELECTOR);
113
+ if (!active) return;
114
+ const box = boxOf(active);
115
+ lastBox.current = box;
116
+ indicator.style.transform = `translateX(${box.x}px)`;
117
+ indicator.style.width = `${box.width}px`;
118
+ });
119
+ observer.observe(track);
120
+ return () => observer.disconnect();
121
+ }, [trackRef, indicatorRef]);
122
+ }
123
+
124
+ /**
125
+ * App navigation as a row of pills: each page is an icon, and the page you are on opens to
126
+ * icon + label in the accent colour. A click opens the pill at once, before the next page
127
+ * arrives, while one highlight slides across and the label opens to its natural width.
128
+ */
129
+ export function PillNav({ items, activeHref, label, placement = "top" }: PillNavProps) {
130
+ const LinkComponent = useLinkComponent();
131
+ const trackRef = useRef<HTMLDivElement>(null);
132
+ const indicatorRef = useRef<HTMLSpanElement>(null);
133
+
134
+ // The pill you clicked opens straight away; the route catches up and clears it.
135
+ const [pendingHref, setPendingHref] = useState<string>();
136
+ const [routedHref, setRoutedHref] = useState(activeHref);
137
+ if (routedHref !== activeHref) {
138
+ setRoutedHref(activeHref);
139
+ setPendingHref(undefined);
140
+ }
141
+ const currentHref = pendingHref ?? activeHref;
142
+ useSlidingIndicator(trackRef, indicatorRef, currentHref);
143
+
144
+ const nav = (
145
+ <nav aria-label={label} className="os-pill-nav" data-placement={placement}>
146
+ <div ref={trackRef} className="os-pill-track">
147
+ <span ref={indicatorRef} className="os-pill-indicator" aria-hidden />
148
+ <ul className="os-pill-list">
149
+ {items.map((item) => {
150
+ const isActive = item.href === currentHref;
151
+ const count = item.count && item.count > 0 ? item.count : 0;
152
+ return (
153
+ <li key={item.href}>
154
+ <Tooltip content={item.label} isEnabled={!isActive}>
155
+ <LinkComponent
156
+ href={item.href}
157
+ className="os-pill"
158
+ data-active={isActive ? "true" : "false"}
159
+ aria-current={isActive ? "page" : undefined}
160
+ aria-label={count ? `${item.label}, ${count}` : item.label}
161
+ onClick={(event: MouseEvent) => {
162
+ if (isPlainClick(event)) setPendingHref(item.href);
163
+ }}
164
+ >
165
+ <span className="os-pill-icon">
166
+ <Glyph name={item.icon} />
167
+ {count ? (
168
+ <span className="os-pill-count" aria-hidden>
169
+ {count > MAX_COUNT ? `${MAX_COUNT}+` : count}
170
+ </span>
171
+ ) : null}
172
+ </span>
173
+ <span className="os-pill-label" aria-hidden>
174
+ <span>{item.label}</span>
175
+ </span>
176
+ </LinkComponent>
177
+ </Tooltip>
178
+ </li>
179
+ );
180
+ })}
181
+ </ul>
182
+ </div>
183
+ </nav>
184
+ );
185
+ // A fixed bottom bar would cover the end of the page; the spacer keeps that content reachable.
186
+ return placement === "bottom" ? (
187
+ <>
188
+ <div className="os-pill-nav-spacer" aria-hidden />
189
+ {nav}
190
+ </>
191
+ ) : (
192
+ nav
193
+ );
194
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Layout, text, and icon primitives from Astryx. Everything Joined renders
3
+ * sits on these, so apps never import Astryx directly.
4
+ */
5
+ export { Stack, HStack, VStack, StackItem } from "@astryxdesign/core/Stack";
6
+ export type {
7
+ StackProps,
8
+ HStackProps,
9
+ VStackProps,
10
+ StackAlignment,
11
+ } from "@astryxdesign/core/Stack";
12
+
13
+ export { Text, Heading } from "@astryxdesign/core/Text";
14
+ export type {
15
+ TextProps,
16
+ TextType,
17
+ TextColor,
18
+ TextWeight,
19
+ HeadingProps,
20
+ HeadingLevel,
21
+ } from "@astryxdesign/core/Text";
22
+
23
+ export { Card } from "@astryxdesign/core/Card";
24
+ export type { CardProps, CardVariant } from "@astryxdesign/core/Card";
25
+
26
+ export { Icon } from "@astryxdesign/core/Icon";
27
+ export type { IconProps, IconName, IconSize, IconColor, IconType } from "@astryxdesign/core/Icon";
28
+
29
+ export { Tooltip } from "@astryxdesign/core/Tooltip";
30
+ export type { TooltipProps } from "@astryxdesign/core/Tooltip";
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+
5
+ export const RATING_MAX = 5;
6
+
7
+ export interface RatingProps {
8
+ value: number;
9
+ onChange?: (value: number) => void;
10
+ max?: number;
11
+ readOnly?: boolean;
12
+ size?: "sm" | "md" | "lg";
13
+ label?: string;
14
+ /** Hides the numeric score. The marks still expose it to assistive tech. */
15
+ showValue?: boolean;
16
+ caption?: string;
17
+ }
18
+
19
+ /** A star score. The number is always written next to the marks. */
20
+ export function Rating({
21
+ value,
22
+ onChange,
23
+ max = RATING_MAX,
24
+ readOnly = false,
25
+ size = "md",
26
+ label = "Rating",
27
+ showValue = true,
28
+ caption,
29
+ }: RatingProps) {
30
+ const [hover, setHover] = useState<number | null>(null);
31
+ const shown = hover ?? value;
32
+ const interactive = Boolean(onChange) && !readOnly;
33
+
34
+ return (
35
+ <div className={`os-rating os-rating-${size}`}>
36
+ <div
37
+ className="os-rating-stars"
38
+ role={interactive ? "radiogroup" : "img"}
39
+ aria-label={interactive ? label : `${label}, ${value} of ${max}`}
40
+ onMouseLeave={() => setHover(null)}
41
+ >
42
+ {Array.from({ length: max }, (_, index) => {
43
+ const star = index + 1;
44
+ const filled = shown >= star;
45
+ if (!interactive) {
46
+ return (
47
+ <span key={star} className={filled ? "os-star os-star-on" : "os-star"} aria-hidden>
48
+ ★
49
+ </span>
50
+ );
51
+ }
52
+ return (
53
+ <button
54
+ key={star}
55
+ type="button"
56
+ role="radio"
57
+ aria-checked={value === star}
58
+ aria-label={`${star} of ${max}`}
59
+ className={filled ? "os-star os-star-on" : "os-star"}
60
+ onMouseEnter={() => setHover(star)}
61
+ onFocus={() => setHover(star)}
62
+ onBlur={() => setHover(null)}
63
+ onClick={() => onChange?.(star)}
64
+ >
65
+ ★
66
+ </button>
67
+ );
68
+ })}
69
+ </div>
70
+ {showValue && (
71
+ <span className="os-rating-value">
72
+ {value} / {max}
73
+ </span>
74
+ )}
75
+ {caption && <span className="os-rating-caption">{caption}</span>}
76
+ </div>
77
+ );
78
+ }