@refraction-ui/astro 0.11.1 → 0.13.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 (148) hide show
  1. package/dist/astro-audio-room/AudioRoom.astro +81 -0
  2. package/dist/astro-audio-room/index.ts +1 -0
  3. package/dist/astro-call-controls/CallControls.astro +57 -0
  4. package/dist/astro-call-controls/index.ts +1 -0
  5. package/dist/astro-checklist/Checklist.astro +76 -0
  6. package/dist/astro-checklist/index.ts +1 -0
  7. package/dist/astro-editor-status-bar/EditorStatusBar.astro +92 -0
  8. package/dist/astro-editor-status-bar/index.ts +1 -0
  9. package/dist/astro-editor-tabs/EditorTabs.astro +74 -0
  10. package/dist/astro-editor-tabs/index.ts +1 -0
  11. package/dist/astro-empty-state/EmptyState.astro +57 -0
  12. package/dist/astro-empty-state/index.ts +1 -0
  13. package/dist/astro-floating-reactions/FloatingReactions.astro +56 -0
  14. package/dist/astro-floating-reactions/index.ts +1 -0
  15. package/dist/astro-flow-editor/FlowEditor.astro +99 -0
  16. package/dist/astro-flow-editor/index.ts +3 -0
  17. package/dist/astro-graph-view/GraphView.astro +109 -0
  18. package/dist/astro-graph-view/index.ts +1 -0
  19. package/dist/astro-infinite-canvas/InfiniteCanvas.astro +43 -0
  20. package/dist/astro-infinite-canvas/index.ts +1 -0
  21. package/dist/astro-input/Input.astro +5 -1
  22. package/dist/astro-live-captions/LiveCaptions.astro +54 -0
  23. package/dist/astro-live-captions/index.ts +1 -0
  24. package/dist/astro-live-cursors/LiveCursors.astro +55 -0
  25. package/dist/astro-live-cursors/index.ts +1 -0
  26. package/dist/astro-live-transcript/LiveTranscript.astro +55 -0
  27. package/dist/astro-live-transcript/index.ts +1 -0
  28. package/dist/astro-mini-map/MiniMap.astro +74 -0
  29. package/dist/astro-mini-map/index.ts +1 -0
  30. package/dist/astro-password-input/PasswordInput.astro +102 -0
  31. package/dist/astro-password-input/index.ts +1 -0
  32. package/dist/astro-pre-call-lobby/PreCallLobby.astro +202 -0
  33. package/dist/astro-pre-call-lobby/index.ts +1 -0
  34. package/dist/astro-radial-gauge/RadialGauge.astro +144 -0
  35. package/dist/astro-radial-gauge/index.ts +1 -0
  36. package/dist/astro-rating-scale/RatingScale.astro +73 -0
  37. package/dist/astro-rating-scale/index.ts +1 -0
  38. package/dist/astro-segmented-control/SegmentedControl.astro +106 -0
  39. package/dist/astro-segmented-control/SegmentedControlItem.astro +32 -0
  40. package/dist/astro-segmented-control/index.ts +1 -0
  41. package/dist/astro-separator/Separator.astro +49 -0
  42. package/dist/astro-separator/index.ts +1 -0
  43. package/dist/astro-social-auth-button/SocialAuthButton.astro +110 -0
  44. package/dist/astro-social-auth-button/SocialAuthRow.astro +13 -0
  45. package/dist/astro-social-auth-button/index.ts +1 -0
  46. package/dist/astro-sticky-note/StickyNote.astro +55 -0
  47. package/dist/astro-sticky-note/index.ts +1 -0
  48. package/dist/astro-terminal/Terminal.astro +67 -0
  49. package/dist/astro-terminal/index.ts +1 -0
  50. package/dist/astro-test-results/TestResults.astro +76 -0
  51. package/dist/astro-test-results/index.ts +1 -0
  52. package/dist/astro-timeline/Timeline.astro +59 -0
  53. package/dist/astro-timeline/index.ts +1 -0
  54. package/dist/astro-video-grid/VideoGrid.astro +182 -0
  55. package/dist/astro-video-grid/index.ts +1 -0
  56. package/dist/astro-video-tile/VideoTile.astro +104 -0
  57. package/dist/astro-video-tile/index.ts +1 -0
  58. package/dist/astro-wizard/Wizard.astro +137 -0
  59. package/dist/astro-wizard/index.ts +1 -0
  60. package/dist/audio-room/audio-room.styles.ts +88 -0
  61. package/dist/audio-room/audio-room.ts +70 -0
  62. package/dist/audio-room/index.ts +16 -0
  63. package/dist/call-controls/call-controls.styles.ts +63 -0
  64. package/dist/call-controls/call-controls.ts +79 -0
  65. package/dist/call-controls/index.ts +15 -0
  66. package/dist/checklist/checklist.styles.ts +76 -0
  67. package/dist/checklist/checklist.ts +62 -0
  68. package/dist/checklist/index.ts +18 -0
  69. package/dist/editor-status-bar/editor-status-bar.styles.ts +40 -0
  70. package/dist/editor-status-bar/editor-status-bar.ts +78 -0
  71. package/dist/editor-status-bar/index.ts +15 -0
  72. package/dist/editor-tabs/editor-tabs.styles.ts +52 -0
  73. package/dist/editor-tabs/editor-tabs.ts +82 -0
  74. package/dist/editor-tabs/index.ts +15 -0
  75. package/dist/empty-state/empty-state.styles.ts +42 -0
  76. package/dist/empty-state/empty-state.ts +27 -0
  77. package/dist/empty-state/index.ts +14 -0
  78. package/dist/floating-reactions/floating-reactions.styles.ts +54 -0
  79. package/dist/floating-reactions/floating-reactions.ts +85 -0
  80. package/dist/floating-reactions/index.ts +14 -0
  81. package/dist/flow-editor/flow-editor.styles.ts +56 -0
  82. package/dist/flow-editor/flow-editor.ts +126 -0
  83. package/dist/flow-editor/index.ts +18 -0
  84. package/dist/graph-view/graph-view.styles.ts +66 -0
  85. package/dist/graph-view/graph-view.ts +130 -0
  86. package/dist/graph-view/index.ts +21 -0
  87. package/dist/index.ts +45 -17
  88. package/dist/infinite-canvas/index.ts +17 -0
  89. package/dist/infinite-canvas/infinite-canvas.styles.ts +49 -0
  90. package/dist/infinite-canvas/infinite-canvas.ts +97 -0
  91. package/dist/input/index.ts +2 -0
  92. package/dist/input/input.styles.ts +4 -0
  93. package/dist/input/input.ts +39 -3
  94. package/dist/live-captions/index.ts +13 -0
  95. package/dist/live-captions/live-captions.styles.ts +45 -0
  96. package/dist/live-captions/live-captions.ts +57 -0
  97. package/dist/live-cursors/index.ts +14 -0
  98. package/dist/live-cursors/live-cursors.styles.ts +41 -0
  99. package/dist/live-cursors/live-cursors.ts +73 -0
  100. package/dist/live-transcript/index.ts +16 -0
  101. package/dist/live-transcript/live-transcript.styles.ts +48 -0
  102. package/dist/live-transcript/live-transcript.ts +82 -0
  103. package/dist/mini-map/index.ts +16 -0
  104. package/dist/mini-map/mini-map.styles.ts +32 -0
  105. package/dist/mini-map/mini-map.ts +122 -0
  106. package/dist/password-input/index.ts +9 -0
  107. package/dist/password-input/password-input.styles.ts +10 -0
  108. package/dist/password-input/password-input.ts +45 -0
  109. package/dist/pre-call-lobby/index.ts +22 -0
  110. package/dist/pre-call-lobby/pre-call-lobby.styles.ts +108 -0
  111. package/dist/pre-call-lobby/pre-call-lobby.ts +61 -0
  112. package/dist/radial-gauge/index.ts +26 -0
  113. package/dist/radial-gauge/radial-gauge.styles.ts +75 -0
  114. package/dist/radial-gauge/radial-gauge.ts +183 -0
  115. package/dist/rating-scale/index.ts +16 -0
  116. package/dist/rating-scale/rating-scale.styles.ts +48 -0
  117. package/dist/rating-scale/rating-scale.ts +101 -0
  118. package/dist/segmented-control/index.ts +13 -0
  119. package/dist/segmented-control/segmented-control.styles.ts +42 -0
  120. package/dist/segmented-control/segmented-control.ts +80 -0
  121. package/dist/separator/index.ts +12 -0
  122. package/dist/separator/separator.styles.ts +34 -0
  123. package/dist/separator/separator.ts +53 -0
  124. package/dist/social-auth-button/index.ts +14 -0
  125. package/dist/social-auth-button/social-auth-button.styles.ts +16 -0
  126. package/dist/social-auth-button/social-auth-button.ts +51 -0
  127. package/dist/sticky-note/index.ts +14 -0
  128. package/dist/sticky-note/sticky-note.styles.ts +37 -0
  129. package/dist/sticky-note/sticky-note.ts +47 -0
  130. package/dist/terminal/index.ts +13 -0
  131. package/dist/terminal/terminal.styles.ts +56 -0
  132. package/dist/terminal/terminal.ts +60 -0
  133. package/dist/test-results/index.ts +19 -0
  134. package/dist/test-results/test-results.styles.ts +93 -0
  135. package/dist/test-results/test-results.ts +83 -0
  136. package/dist/timeline/index.ts +22 -0
  137. package/dist/timeline/timeline.styles.ts +98 -0
  138. package/dist/timeline/timeline.ts +86 -0
  139. package/dist/video-grid/index.ts +16 -0
  140. package/dist/video-grid/video-grid.styles.ts +38 -0
  141. package/dist/video-grid/video-grid.ts +93 -0
  142. package/dist/video-tile/index.ts +17 -0
  143. package/dist/video-tile/video-tile.styles.ts +59 -0
  144. package/dist/video-tile/video-tile.ts +74 -0
  145. package/dist/wizard/index.ts +28 -0
  146. package/dist/wizard/wizard.styles.ts +147 -0
  147. package/dist/wizard/wizard.ts +128 -0
  148. package/package.json +47 -19
@@ -0,0 +1,108 @@
1
+ import { cva } from '../shared/index.ts'
2
+
3
+ /**
4
+ * Outer lobby card — a centered panel that holds all preflight controls.
5
+ */
6
+ export const preCallLobbyVariants = cva({
7
+ base: [
8
+ 'flex flex-col gap-6 rounded-2xl border border-border bg-card p-6 shadow-sm',
9
+ 'w-full max-w-lg mx-auto',
10
+ ].join(' '),
11
+ variants: {},
12
+ defaultVariants: {},
13
+ })
14
+
15
+ /**
16
+ * Camera preview area. The `camera` variant switches between the live preview
17
+ * slot and an avatar / placeholder shown when the camera is off.
18
+ */
19
+ export const preCallCameraVariants = cva({
20
+ base: [
21
+ 'relative flex items-center justify-center overflow-hidden rounded-xl',
22
+ 'aspect-video w-full bg-muted',
23
+ ].join(' '),
24
+ variants: {
25
+ camera: {
26
+ on: '',
27
+ off: 'bg-muted',
28
+ },
29
+ },
30
+ defaultVariants: {
31
+ camera: 'on',
32
+ },
33
+ })
34
+
35
+ /** Placeholder shown inside the camera area when the camera is off. */
36
+ export const preCallCameraPlaceholderClass = [
37
+ 'flex flex-col items-center justify-center gap-2',
38
+ 'text-muted-foreground select-none',
39
+ ].join(' ')
40
+
41
+ /** Row that holds the label and the <select> for a device picker. */
42
+ export const preCallDeviceRowClass = 'flex flex-col gap-1'
43
+
44
+ /** Label text above a device picker. */
45
+ export const preCallDeviceLabelClass = 'text-xs font-medium text-muted-foreground'
46
+
47
+ /** The <select> element used for device selection. */
48
+ export const preCallDeviceSelectClass = [
49
+ 'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1',
50
+ 'text-sm text-foreground shadow-sm',
51
+ 'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
52
+ 'disabled:cursor-not-allowed disabled:opacity-50',
53
+ ].join(' ')
54
+
55
+ /**
56
+ * Mic level meter container. Renders a row of bars.
57
+ */
58
+ export const preCallMicMeterClass = 'flex items-end gap-0.5 h-5'
59
+
60
+ /**
61
+ * An individual bar in the mic level meter.
62
+ * The `lit` variant distinguishes active (lit) from inactive bars.
63
+ */
64
+ export const preCallMicBarVariants = cva({
65
+ base: 'w-1.5 rounded-sm transition-colors',
66
+ variants: {
67
+ lit: {
68
+ true: 'bg-primary',
69
+ false: 'bg-muted-foreground/30',
70
+ },
71
+ },
72
+ defaultVariants: {
73
+ lit: 'false',
74
+ },
75
+ })
76
+
77
+ /** Row holding the camera-toggle and mic-toggle buttons. */
78
+ export const preCallControlsRowClass = 'flex items-center gap-3'
79
+
80
+ /**
81
+ * Toggle button for camera or microphone.
82
+ * The `active` variant styles the enabled vs disabled states.
83
+ */
84
+ export const preCallToggleVariants = cva({
85
+ base: [
86
+ 'inline-flex items-center justify-center rounded-full p-2.5',
87
+ 'transition-colors focus-visible:outline-none focus-visible:ring-2',
88
+ 'focus-visible:ring-ring focus-visible:ring-offset-2',
89
+ ].join(' '),
90
+ variants: {
91
+ active: {
92
+ true: 'bg-primary text-primary-foreground hover:bg-primary/90',
93
+ false: 'bg-destructive/10 text-destructive hover:bg-destructive/20',
94
+ },
95
+ },
96
+ defaultVariants: {
97
+ active: 'false',
98
+ },
99
+ })
100
+
101
+ /** Primary "Join" button. */
102
+ export const preCallJoinButtonClass = [
103
+ 'inline-flex w-full items-center justify-center rounded-lg px-4 py-2.5',
104
+ 'bg-primary text-primary-foreground text-sm font-semibold',
105
+ 'shadow transition-colors hover:bg-primary/90',
106
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
107
+ 'disabled:pointer-events-none disabled:opacity-50',
108
+ ].join(' ')
@@ -0,0 +1,61 @@
1
+ /** A single selectable media device (camera, microphone, or speaker). */
2
+ export interface MediaDeviceOption {
3
+ id: string
4
+ label: string
5
+ }
6
+
7
+ /** The kind of media device. */
8
+ export type DeviceKind = 'camera' | 'microphone' | 'speaker'
9
+
10
+ export interface PreCallLobbyProps {
11
+ /** Whether the camera is currently enabled. */
12
+ cameraOn: boolean
13
+ /** Whether the microphone is currently enabled. */
14
+ micOn: boolean
15
+ }
16
+
17
+ export interface PreCallLobbyAPI {
18
+ /** ARIA attributes to spread on the group element. */
19
+ ariaProps: { role: 'group'; 'aria-label': string }
20
+ /** Data attributes for styling hooks. */
21
+ dataAttributes: { 'data-camera': string; 'data-mic': string }
22
+ }
23
+
24
+ /**
25
+ * Build the framework-agnostic group props for a pre-call lobby / device-check panel.
26
+ *
27
+ * Returns `role="group"` plus data attributes reflecting camera and mic state.
28
+ * Adapters spread these onto their container and render the device pickers,
29
+ * preview, and join button themselves.
30
+ */
31
+ export function createPreCallLobby(props: PreCallLobbyProps): PreCallLobbyAPI {
32
+ const { cameraOn, micOn } = props
33
+
34
+ return {
35
+ ariaProps: {
36
+ role: 'group',
37
+ 'aria-label': 'Device setup',
38
+ },
39
+ dataAttributes: {
40
+ 'data-camera': cameraOn ? 'on' : 'off',
41
+ 'data-mic': micOn ? 'on' : 'off',
42
+ },
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Convert a microphone level (0..1) to a count of lit bars.
48
+ *
49
+ * Pure function — no side effects, no JSX. Adapters use this to render
50
+ * the mic level meter by iterating `0..barCount` and lighting bars up to
51
+ * the returned value.
52
+ *
53
+ * @param level Current mic level in the range [0, 1].
54
+ * @param barCount Total number of bars in the meter.
55
+ * @returns Number of bars that should appear "lit" (0..barCount).
56
+ */
57
+ export function micLevelToBars(level: number, barCount: number): number {
58
+ if (barCount <= 0) return 0
59
+ const clamped = Math.min(1, Math.max(0, level))
60
+ return Math.round(clamped * barCount)
61
+ }
@@ -0,0 +1,26 @@
1
+ export {
2
+ clampValue,
3
+ valueToFraction,
4
+ polarToCartesian,
5
+ describeArc,
6
+ gaugeStrokeDashoffset,
7
+ resolveZoneTone,
8
+ createRadialGauge,
9
+ GAUGE_SIZE_PX,
10
+ type GaugeSize,
11
+ type GaugeTone,
12
+ type GaugeZone,
13
+ type RadialGaugeProps,
14
+ type RadialGaugeAPI,
15
+ } from './radial-gauge.js'
16
+
17
+ export {
18
+ radialGaugeRootVariants,
19
+ gaugeTrackClass,
20
+ gaugeArcClass,
21
+ gaugeArcToneVariants,
22
+ gaugeLabelClass,
23
+ gaugeSublabelClass,
24
+ GAUGE_LABEL_FONT_SIZE,
25
+ GAUGE_SUBLABEL_FONT_SIZE,
26
+ } from './radial-gauge.styles.js'
@@ -0,0 +1,75 @@
1
+ import { cva } from '../shared/index.ts'
2
+
3
+ /**
4
+ * Root SVG element variants — controls the rendered size via an inline
5
+ * width/height (adapters use GAUGE_SIZE_PX) and sets up the color context.
6
+ */
7
+ export const radialGaugeRootVariants = cva({
8
+ base: 'inline-block shrink-0',
9
+ variants: {
10
+ size: {
11
+ sm: '',
12
+ md: '',
13
+ lg: '',
14
+ },
15
+ },
16
+ defaultVariants: {
17
+ size: 'md',
18
+ },
19
+ })
20
+
21
+ /**
22
+ * Track (background) circle stroke class.
23
+ * Uses a muted foreground so it sits behind the value arc without competing.
24
+ */
25
+ export const gaugeTrackClass = 'fill-none stroke-muted-foreground/20'
26
+
27
+ /**
28
+ * Value arc stroke class — resolved per tone via {@link gaugeArcToneVariants}.
29
+ * `currentColor` stroke picks up the Tailwind text-* class applied by the
30
+ * tone variant so we only need one class-generating helper.
31
+ */
32
+ export const gaugeArcClass = 'fill-none stroke-current transition-[stroke-dashoffset] duration-300 ease-out'
33
+
34
+ /**
35
+ * Tone variants for the value arc. Adapters combine this with `gaugeArcClass`.
36
+ */
37
+ export const gaugeArcToneVariants = cva({
38
+ base: '',
39
+ variants: {
40
+ tone: {
41
+ default: 'text-foreground',
42
+ success: 'text-emerald-500',
43
+ warning: 'text-amber-500',
44
+ danger: 'text-destructive',
45
+ },
46
+ },
47
+ defaultVariants: {
48
+ tone: 'default',
49
+ },
50
+ })
51
+
52
+ /** Center label (primary text) classes. */
53
+ export const gaugeLabelClass = 'fill-foreground text-center font-semibold'
54
+
55
+ /** Center sub-label (secondary text) classes. */
56
+ export const gaugeSublabelClass = 'fill-muted-foreground'
57
+
58
+ /**
59
+ * Font size for the primary label by size variant.
60
+ * Expressed as SVG `font-size` values (px) for use in the `<text>` element.
61
+ */
62
+ export const GAUGE_LABEL_FONT_SIZE: Record<'sm' | 'md' | 'lg', number> = {
63
+ sm: 14,
64
+ md: 20,
65
+ lg: 26,
66
+ }
67
+
68
+ /**
69
+ * Font size for the sub-label by size variant.
70
+ */
71
+ export const GAUGE_SUBLABEL_FONT_SIZE: Record<'sm' | 'md' | 'lg', number> = {
72
+ sm: 9,
73
+ md: 11,
74
+ lg: 13,
75
+ }
@@ -0,0 +1,183 @@
1
+
2
+ // ---------------------------------------------------------------------------
3
+ // Types
4
+ // ---------------------------------------------------------------------------
5
+
6
+ /** The visual size of the gauge. */
7
+ export type GaugeSize = 'sm' | 'md' | 'lg'
8
+
9
+ /** Tone mapped to a zone range. */
10
+ export type GaugeTone = 'default' | 'success' | 'warning' | 'danger'
11
+
12
+ /**
13
+ * A threshold zone that applies a semantic tone when the gauge value falls
14
+ * at or below `upTo`. Zones should be listed in ascending `upTo` order; the
15
+ * first zone whose `upTo >= value` wins.
16
+ */
17
+ export interface GaugeZone {
18
+ /** Value up to (and including) which this zone applies. */
19
+ upTo: number
20
+ /** Semantic tone for the arc while the value is in this zone. */
21
+ tone: GaugeTone
22
+ }
23
+
24
+ export interface RadialGaugeProps {
25
+ /** Current gauge value. */
26
+ value: number
27
+ /** Minimum possible value. */
28
+ min?: number
29
+ /** Maximum possible value. */
30
+ max?: number
31
+ /** Visual size. */
32
+ size?: GaugeSize
33
+ }
34
+
35
+ export interface RadialGaugeAPI {
36
+ /** ARIA attributes to spread on the SVG element (`role="meter"`). */
37
+ ariaProps: Record<string, string | number>
38
+ /** Data attributes for styling hooks. */
39
+ dataAttributes: Record<string, string>
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Pixel diameters for each size variant (exported for use in adapters)
44
+ // ---------------------------------------------------------------------------
45
+ export const GAUGE_SIZE_PX: Record<GaugeSize, number> = {
46
+ sm: 80,
47
+ md: 120,
48
+ lg: 160,
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Pure geometry helpers
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * Clamp `value` to the inclusive range `[min, max]`.
57
+ */
58
+ export function clampValue(value: number, min: number, max: number): number {
59
+ return Math.min(Math.max(value, min), max)
60
+ }
61
+
62
+ /**
63
+ * Map a `value` within `[min, max]` to a fraction in `[0, 1]`.
64
+ * Returns 0 when `min === max` to avoid division by zero.
65
+ */
66
+ export function valueToFraction(value: number, min: number, max: number): number {
67
+ if (max === min) return 0
68
+ return clampValue((value - min) / (max - min), 0, 1)
69
+ }
70
+
71
+ /**
72
+ * Convert polar coordinates to Cartesian.
73
+ * `angleDeg` is measured clockwise from the positive x-axis (SVG convention).
74
+ */
75
+ export function polarToCartesian(
76
+ cx: number,
77
+ cy: number,
78
+ r: number,
79
+ angleDeg: number,
80
+ ): { x: number; y: number } {
81
+ const rad = ((angleDeg - 90) * Math.PI) / 180
82
+ return {
83
+ x: cx + r * Math.cos(rad),
84
+ y: cy + r * Math.sin(rad),
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Build an SVG arc path `d` string from `startAngleDeg` to `endAngleDeg`
90
+ * (both measured clockwise from the top / 12-o'clock position).
91
+ *
92
+ * Returns an empty string when the arc span is zero.
93
+ */
94
+ export function describeArc(
95
+ cx: number,
96
+ cy: number,
97
+ r: number,
98
+ startAngleDeg: number,
99
+ endAngleDeg: number,
100
+ ): string {
101
+ const span = endAngleDeg - startAngleDeg
102
+ if (span === 0) return ''
103
+
104
+ // For a full circle we need two arcs to avoid SVG treating start === end as
105
+ // a no-op. Shift the end by 0.001° so the path stays valid.
106
+ const clampedEnd = span >= 360 ? startAngleDeg + 359.999 : endAngleDeg
107
+
108
+ const start = polarToCartesian(cx, cy, r, startAngleDeg)
109
+ const end = polarToCartesian(cx, cy, r, clampedEnd)
110
+ const largeArc = clampedEnd - startAngleDeg > 180 ? 1 : 0
111
+
112
+ return [
113
+ `M ${start.x} ${start.y}`,
114
+ `A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`,
115
+ ].join(' ')
116
+ }
117
+
118
+ /**
119
+ * Compute the `stroke-dashoffset` for a full-circle ring gauge.
120
+ *
121
+ * With `stroke-dasharray="circumference circumference"` and this offset, the
122
+ * visible portion of the stroke equals `fraction × circumference`.
123
+ */
124
+ export function gaugeStrokeDashoffset(fraction: number, circumference: number): number {
125
+ return circumference * (1 - fraction)
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Zone resolution
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Find the active zone tone for the current `value`.
134
+ * Zones are tested in ascending `upTo` order; the first zone whose
135
+ * `upTo >= value` wins. Falls back to `'default'` when no zone matches.
136
+ */
137
+ export function resolveZoneTone(
138
+ value: number,
139
+ zones: GaugeZone[],
140
+ _max: number,
141
+ ): GaugeTone {
142
+ if (!zones || zones.length === 0) return 'default'
143
+
144
+ // Sort a copy ascending by upTo so callers don't have to pre-sort.
145
+ const sorted = [...zones].sort((a, b) => a.upTo - b.upTo)
146
+
147
+ for (const zone of sorted) {
148
+ if (value <= zone.upTo) return zone.tone
149
+ }
150
+
151
+ // Value is above all zone thresholds — use the last zone's tone.
152
+ return sorted[sorted.length - 1].tone
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Factory
157
+ // ---------------------------------------------------------------------------
158
+
159
+ /**
160
+ * Build the framework-agnostic props for a radial gauge element.
161
+ *
162
+ * Returns `role="meter"` plus ARIA value attributes and data attributes;
163
+ * adapters spread these onto their `<svg>` element.
164
+ */
165
+ export function createRadialGauge(props: RadialGaugeProps): RadialGaugeAPI {
166
+ const { value, min = 0, max = 100, size = 'md' } = props
167
+
168
+ const clamped = clampValue(value, min, max)
169
+
170
+ const ariaProps: Record<string, string | number> = {
171
+ role: 'meter',
172
+ 'aria-valuenow': clamped,
173
+ 'aria-valuemin': min,
174
+ 'aria-valuemax': max,
175
+ }
176
+
177
+ const dataAttributes: Record<string, string> = {
178
+ 'data-size': size,
179
+ 'data-value': String(clamped),
180
+ }
181
+
182
+ return { ariaProps, dataAttributes }
183
+ }
@@ -0,0 +1,16 @@
1
+ export {
2
+ createRatingScale,
3
+ resolveRatingPoints,
4
+ getNextRatingIndex,
5
+ type RatingScaleSize,
6
+ type RatingScalePoint,
7
+ type RatingScaleProps,
8
+ type RatingScaleAPI,
9
+ } from './rating-scale.js'
10
+
11
+ export {
12
+ ratingScaleVariants,
13
+ ratingScaleTrackClass,
14
+ ratingScaleItemVariants,
15
+ ratingScaleLabelClass,
16
+ } from './rating-scale.styles.js'
@@ -0,0 +1,48 @@
1
+ import { cva } from '../shared/index.ts'
2
+
3
+ /** Container row that holds the points plus optional end labels. */
4
+ export const ratingScaleVariants = cva({
5
+ base: 'inline-flex items-center gap-3',
6
+ variants: {
7
+ size: {
8
+ sm: '',
9
+ md: '',
10
+ },
11
+ },
12
+ defaultVariants: {
13
+ size: 'md',
14
+ },
15
+ })
16
+
17
+ /** Row of point buttons (the scale itself, between the end labels). */
18
+ export const ratingScaleTrackClass = 'inline-flex items-center gap-1.5'
19
+
20
+ /**
21
+ * A single point button. The `state` variant carries the selected vs unselected
22
+ * treatment so adapters never inline these classes.
23
+ */
24
+ export const ratingScaleItemVariants = cva({
25
+ base: [
26
+ 'inline-flex items-center justify-center rounded-md border font-medium tabular-nums',
27
+ 'transition-colors focus-visible:outline-none focus-visible:ring-2',
28
+ 'focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
29
+ 'disabled:pointer-events-none disabled:opacity-50',
30
+ ].join(' '),
31
+ variants: {
32
+ size: {
33
+ sm: 'size-8 text-xs',
34
+ md: 'size-10 text-sm',
35
+ },
36
+ state: {
37
+ checked: 'border-primary bg-primary text-primary-foreground',
38
+ unchecked: 'border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground',
39
+ },
40
+ },
41
+ defaultVariants: {
42
+ size: 'md',
43
+ state: 'unchecked',
44
+ },
45
+ })
46
+
47
+ /** End-label text flanking the scale (e.g. "Never seen this" / "Could write it"). */
48
+ export const ratingScaleLabelClass = 'text-xs text-muted-foreground'
@@ -0,0 +1,101 @@
1
+ import type { AccessibilityProps } from '../shared/index.ts'
2
+
3
+ /** Visual size of a rating scale. */
4
+ export type RatingScaleSize = 'sm' | 'md'
5
+
6
+ /** A single selectable point on the scale (1-indexed value + accessible label). */
7
+ export interface RatingScalePoint {
8
+ /** The value this point represents. */
9
+ value: number
10
+ /** Accessible label for the point (falls back to the value). */
11
+ label?: string
12
+ }
13
+
14
+ export interface RatingScaleProps {
15
+ /** Currently selected value (controlled). */
16
+ value?: number
17
+ /** Number of points on the scale. Ignored when {@link points} is given. */
18
+ count?: number
19
+ /** Visual size. */
20
+ size?: RatingScaleSize
21
+ }
22
+
23
+ export interface RatingScaleAPI {
24
+ /** ARIA attributes to spread on the group element (`role="radiogroup"`). */
25
+ ariaProps: Partial<AccessibilityProps>
26
+ /** Data attributes for styling hooks. */
27
+ dataAttributes: Record<string, string>
28
+ }
29
+
30
+ /**
31
+ * Build the framework-agnostic group props for a rating / Likert scale.
32
+ *
33
+ * Returns `role="radiogroup"` plus data attributes; adapters spread these onto
34
+ * their container and render the points themselves. A rating scale is a
35
+ * single-select control with radio semantics, like a segmented control, but
36
+ * tuned for ordinal self-rating (e.g. a 1–5 Likert prompt with end labels).
37
+ */
38
+ export function createRatingScale(props: RatingScaleProps = {}): RatingScaleAPI {
39
+ const { value, size = 'md' } = props
40
+
41
+ const ariaProps: Partial<AccessibilityProps> = {
42
+ role: 'radiogroup',
43
+ }
44
+
45
+ const dataAttributes: Record<string, string> = {
46
+ 'data-size': size,
47
+ }
48
+ if (value !== undefined) {
49
+ dataAttributes['data-value'] = String(value)
50
+ }
51
+
52
+ return { ariaProps, dataAttributes }
53
+ }
54
+
55
+ /**
56
+ * Resolve the list of points for a scale from either an explicit list or a
57
+ * `count` (producing `1..count`). The single source of truth shared by every
58
+ * adapter so a `count={5}` scale renders identical points everywhere.
59
+ */
60
+ export function resolveRatingPoints(
61
+ points: RatingScalePoint[] | undefined,
62
+ count = 5,
63
+ ): RatingScalePoint[] {
64
+ if (points && points.length > 0) return points
65
+ return Array.from({ length: Math.max(0, count) }, (_, i) => ({ value: i + 1 }))
66
+ }
67
+
68
+ /**
69
+ * Pure keyboard-navigation rule shared by every adapter.
70
+ *
71
+ * Unlike a wrapping segmented control, an ordinal rating scale **clamps** at
72
+ * the ends (you can't wrap from "5 — strongly agree" back to "1"). Home/End
73
+ * jump to the extremes; unhandled keys return `current` unchanged.
74
+ *
75
+ * - ArrowRight / ArrowUp → next (clamped to last)
76
+ * - ArrowLeft / ArrowDown → previous (clamped to first)
77
+ * - Home → first
78
+ * - End → last
79
+ */
80
+ export function getNextRatingIndex(
81
+ current: number,
82
+ key: string,
83
+ count: number,
84
+ ): number {
85
+ if (count <= 0) return current
86
+
87
+ switch (key) {
88
+ case 'ArrowRight':
89
+ case 'ArrowUp':
90
+ return Math.min(current + 1, count - 1)
91
+ case 'ArrowLeft':
92
+ case 'ArrowDown':
93
+ return Math.max(current - 1, 0)
94
+ case 'Home':
95
+ return 0
96
+ case 'End':
97
+ return count - 1
98
+ default:
99
+ return current
100
+ }
101
+ }
@@ -0,0 +1,13 @@
1
+ export {
2
+ createSegmentedControl,
3
+ getNextSegmentIndex,
4
+ type SegmentedControlSize,
5
+ type SegmentedControlValue,
6
+ type SegmentedControlProps,
7
+ type SegmentedControlAPI,
8
+ } from './segmented-control.js'
9
+
10
+ export {
11
+ segmentedControlVariants,
12
+ segmentedControlItemVariants,
13
+ } from './segmented-control.styles.js'
@@ -0,0 +1,42 @@
1
+ import { cva } from '../shared/index.ts'
2
+
3
+ /** Pill-shaped container that holds the segments (`role="radiogroup"`). */
4
+ export const segmentedControlVariants = cva({
5
+ base: 'inline-flex items-center gap-1 rounded-lg bg-muted p-1',
6
+ variants: {
7
+ size: {
8
+ sm: '',
9
+ md: '',
10
+ },
11
+ },
12
+ defaultVariants: {
13
+ size: 'md',
14
+ },
15
+ })
16
+
17
+ /**
18
+ * A single segment (`role="radio"`). The `state` variant carries the
19
+ * active vs inactive visual treatment so adapters never inline these classes.
20
+ */
21
+ export const segmentedControlItemVariants = cva({
22
+ base: [
23
+ 'inline-flex items-center justify-center gap-1.5 rounded-md font-medium',
24
+ 'whitespace-nowrap transition-colors',
25
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
26
+ 'focus-visible:ring-offset-2 focus-visible:ring-offset-muted',
27
+ ].join(' '),
28
+ variants: {
29
+ size: {
30
+ sm: 'px-2.5 py-1 text-xs',
31
+ md: 'px-3 py-1.5 text-sm',
32
+ },
33
+ state: {
34
+ checked: 'bg-background text-foreground shadow-sm',
35
+ unchecked: 'text-muted-foreground hover:text-foreground',
36
+ },
37
+ },
38
+ defaultVariants: {
39
+ size: 'md',
40
+ state: 'unchecked',
41
+ },
42
+ })