@refraction-ui/astro 0.17.1 → 0.17.2
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/dist/accordion/accordion.ts +155 -0
- package/dist/accordion/index.ts +9 -1
- package/dist/analytics/analytics-manager.ts +7 -2
- package/dist/analytics/http-sink.ts +3 -1
- package/dist/analytics/types.ts +6 -0
- package/dist/calendar/calendar.ts +11 -6
- package/dist/call-controls/call-controls.ts +2 -4
- package/dist/carousel/carousel.ts +45 -0
- package/dist/carousel/index.ts +9 -1
- package/dist/conversation/conversation.ts +9 -7
- package/dist/conversation/types.ts +2 -0
- package/dist/date-picker/date-picker.ts +6 -3
- package/dist/device-frame/device-frame.ts +2 -4
- package/dist/emoji-picker/emoji-picker.ts +2 -3
- package/dist/footer/footer.ts +4 -1
- package/dist/logger/telemetry-manager.ts +7 -4
- package/dist/logger/types.ts +4 -0
- package/dist/pagination/index.ts +14 -1
- package/dist/pagination/pagination.styles.ts +33 -0
- package/dist/pagination/pagination.ts +160 -0
- package/dist/presence-indicator/presence-indicator.ts +2 -4
- package/dist/reaction-bar/reaction-bar.ts +2 -3
- package/dist/slider/slider.ts +134 -2
- package/dist/status-indicator/status-indicator.ts +2 -4
- package/dist/thread-view/thread-view.ts +6 -7
- package/dist/toast/toast.ts +16 -5
- package/dist/voice-pill/voice-pill.ts +2 -4
- package/package.json +1 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @refraction-ui/accordion — headless accordion (disclosure) state machine.
|
|
3
|
+
*
|
|
4
|
+
* Owns the *behavior* of an accordion: which item(s) are open, the single vs
|
|
5
|
+
* multiple expansion modes, and the collapsible toggle rule. It has NO UI
|
|
6
|
+
* opinion — React/Astro adapters subscribe to the store and render the parts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Expansion mode: one item open at a time, or any number. */
|
|
10
|
+
export type AccordionType = 'single' | 'multiple'
|
|
11
|
+
|
|
12
|
+
/** Open item id (`single`) or ids (`multiple`). */
|
|
13
|
+
export type AccordionValue = string | string[]
|
|
14
|
+
|
|
15
|
+
/** Options for {@link createAccordion}. */
|
|
16
|
+
export interface AccordionConfig {
|
|
17
|
+
/** Expansion mode (default `'single'`). */
|
|
18
|
+
type?: AccordionType
|
|
19
|
+
/** When `type="single"`, allows the open item to be toggled fully closed. */
|
|
20
|
+
collapsible?: boolean
|
|
21
|
+
/** Controlled open item(s). Pair with `onValueChange`. */
|
|
22
|
+
value?: AccordionValue
|
|
23
|
+
/** Uncontrolled initial open item(s). */
|
|
24
|
+
defaultValue?: AccordionValue
|
|
25
|
+
/** Called whenever the open item(s) change. */
|
|
26
|
+
onValueChange?: (value: AccordionValue) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Snapshot of the store. Returned by `getState()`; treat as immutable. */
|
|
30
|
+
export interface AccordionState {
|
|
31
|
+
type: AccordionType
|
|
32
|
+
collapsible: boolean
|
|
33
|
+
/** Current open item id (`single`) or ids (`multiple`). */
|
|
34
|
+
value: AccordionValue
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The framework-agnostic store. React/Astro adapters wrap this. */
|
|
38
|
+
export interface AccordionAPI {
|
|
39
|
+
/** Current immutable snapshot */
|
|
40
|
+
getState(): AccordionState
|
|
41
|
+
/** Subscribe to changes; returns an unsubscribe fn (suits useSyncExternalStore) */
|
|
42
|
+
subscribe(listener: () => void): () => void
|
|
43
|
+
/** Toggle an item open/closed per the type/collapsible rules */
|
|
44
|
+
toggleItem(itemValue: string): void
|
|
45
|
+
/** Whether an item is currently open */
|
|
46
|
+
isOpen(itemValue: string): boolean
|
|
47
|
+
/** Sync a controlled value into the store (adapters call when the prop changes) */
|
|
48
|
+
setValue(value: AccordionValue): void
|
|
49
|
+
/** Update options after creation (adapters keep props in sync) */
|
|
50
|
+
setOptions(options: {
|
|
51
|
+
type?: AccordionType
|
|
52
|
+
collapsible?: boolean
|
|
53
|
+
onValueChange?: (value: AccordionValue) => void
|
|
54
|
+
}): void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Pure open-state check shared by every adapter. */
|
|
58
|
+
export function isItemOpen(state: AccordionState, itemValue: string): boolean {
|
|
59
|
+
return state.type === 'single'
|
|
60
|
+
? state.value === itemValue
|
|
61
|
+
: Array.isArray(state.value) && state.value.includes(itemValue)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Shallow value equality (arrays compared item-by-item, order-sensitive). */
|
|
65
|
+
function valueEquals(a: AccordionValue, b: AccordionValue): boolean {
|
|
66
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
67
|
+
return a.length === b.length && a.every((v, i) => v === b[i])
|
|
68
|
+
}
|
|
69
|
+
return a === b
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* createAccordion — headless accordion store.
|
|
74
|
+
*
|
|
75
|
+
* Supports controlled (`value`) and uncontrolled (`defaultValue`) usage.
|
|
76
|
+
* `single` mode holds one open item id (`''` when all closed); `multiple`
|
|
77
|
+
* holds an array of open ids. In `single` mode, re-toggling the open item
|
|
78
|
+
* closes it only when `collapsible` is set.
|
|
79
|
+
*/
|
|
80
|
+
export function createAccordion(config: AccordionConfig = {}): AccordionAPI {
|
|
81
|
+
let type: AccordionType = config.type ?? 'single'
|
|
82
|
+
let collapsible: boolean = config.collapsible ?? false
|
|
83
|
+
let onValueChange = config.onValueChange
|
|
84
|
+
|
|
85
|
+
let uncontrolledValue: AccordionValue =
|
|
86
|
+
config.defaultValue ?? (type === 'multiple' ? [] : '')
|
|
87
|
+
let controlledValue: AccordionValue | undefined = config.value
|
|
88
|
+
|
|
89
|
+
const listeners = new Set<() => void>()
|
|
90
|
+
let snapshot: AccordionState = buildSnapshot()
|
|
91
|
+
|
|
92
|
+
function currentValue(): AccordionValue {
|
|
93
|
+
return controlledValue !== undefined ? controlledValue : uncontrolledValue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildSnapshot(): AccordionState {
|
|
97
|
+
return { type, collapsible, value: currentValue() }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function emit(): void {
|
|
101
|
+
snapshot = buildSnapshot()
|
|
102
|
+
for (const l of listeners) l()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const api: AccordionAPI = {
|
|
106
|
+
getState() {
|
|
107
|
+
return snapshot
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
subscribe(listener) {
|
|
111
|
+
listeners.add(listener)
|
|
112
|
+
return () => listeners.delete(listener)
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
toggleItem(itemValue) {
|
|
116
|
+
const value = currentValue()
|
|
117
|
+
let next: AccordionValue
|
|
118
|
+
if (type === 'single') {
|
|
119
|
+
next = value === itemValue && collapsible ? '' : itemValue
|
|
120
|
+
} else {
|
|
121
|
+
const arrValue = Array.isArray(value) ? value : []
|
|
122
|
+
next = arrValue.includes(itemValue)
|
|
123
|
+
? arrValue.filter((v) => v !== itemValue)
|
|
124
|
+
: [...arrValue, itemValue]
|
|
125
|
+
}
|
|
126
|
+
if (controlledValue === undefined) {
|
|
127
|
+
if (valueEquals(uncontrolledValue, next)) return
|
|
128
|
+
uncontrolledValue = next
|
|
129
|
+
}
|
|
130
|
+
onValueChange?.(next)
|
|
131
|
+
emit()
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
isOpen(itemValue) {
|
|
135
|
+
return isItemOpen(snapshot, itemValue)
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
setValue(value) {
|
|
139
|
+
if (controlledValue !== undefined && valueEquals(controlledValue, value)) return
|
|
140
|
+
controlledValue = value
|
|
141
|
+
emit()
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
setOptions(options) {
|
|
145
|
+
const prevType = type
|
|
146
|
+
const prevCollapsible = collapsible
|
|
147
|
+
if (options.type !== undefined) type = options.type
|
|
148
|
+
if (options.collapsible !== undefined) collapsible = options.collapsible
|
|
149
|
+
if (options.onValueChange !== undefined) onValueChange = options.onValueChange
|
|
150
|
+
if (type !== prevType || collapsible !== prevCollapsible) emit()
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return api
|
|
155
|
+
}
|
package/dist/accordion/index.ts
CHANGED
|
@@ -64,6 +64,9 @@ export function createAnalytics(config: AnalyticsConfig): Analytics {
|
|
|
64
64
|
const sampleRate = config.sampleRate ?? 1
|
|
65
65
|
const batchSize = config.batchSize ?? 20
|
|
66
66
|
const flushIntervalMs = config.flushIntervalMs ?? 10_000
|
|
67
|
+
// Injection boundary: ambient time/randomness default here; inject `now`/`random` for determinism.
|
|
68
|
+
const now = config.now ?? (() => new Date())
|
|
69
|
+
const random = config.random ?? (() => Math.random())
|
|
67
70
|
|
|
68
71
|
const session = createSession(config.session)
|
|
69
72
|
const identity = createIdentity(config.identity)
|
|
@@ -86,6 +89,8 @@ export function createAnalytics(config: AnalyticsConfig): Analytics {
|
|
|
86
89
|
createHttpSink({
|
|
87
90
|
endpoint: config.endpoint,
|
|
88
91
|
writeKey: config.writeKey ?? '',
|
|
92
|
+
// One clock for the whole manager: the auto sink stamps sentAt with it too.
|
|
93
|
+
now,
|
|
89
94
|
}),
|
|
90
95
|
)
|
|
91
96
|
}
|
|
@@ -203,7 +208,7 @@ export function createAnalytics(config: AnalyticsConfig): Analytics {
|
|
|
203
208
|
function sampled(): boolean {
|
|
204
209
|
if (sampleRate >= 1) return true
|
|
205
210
|
if (sampleRate <= 0) return false
|
|
206
|
-
return
|
|
211
|
+
return random() < sampleRate
|
|
207
212
|
}
|
|
208
213
|
|
|
209
214
|
function enqueue(ev: AnalyticsEvent): void {
|
|
@@ -238,7 +243,7 @@ export function createAnalytics(config: AnalyticsConfig): Analytics {
|
|
|
238
243
|
userId: identity.userId(),
|
|
239
244
|
sessionId,
|
|
240
245
|
context: buildContext(opts?.context, childCtx),
|
|
241
|
-
timestamp: opts?.timestamp ??
|
|
246
|
+
timestamp: opts?.timestamp ?? now().toISOString(),
|
|
242
247
|
schemaVersion: SCHEMA_VERSION,
|
|
243
248
|
...fields,
|
|
244
249
|
}
|
|
@@ -113,6 +113,8 @@ export function createHttpSink(options: HttpSinkOptions): AnalyticsSink {
|
|
|
113
113
|
const base = endpoint.replace(/\/+$/, '')
|
|
114
114
|
const url = `${base}/v${SCHEMA_VERSION}/batch`
|
|
115
115
|
const authHeader = `Basic ${base64(`${writeKey}:`)}`
|
|
116
|
+
// Injection boundary: `sentAt` defaults to ambient time; inject `now` for determinism.
|
|
117
|
+
const now = options.now ?? (() => new Date())
|
|
116
118
|
|
|
117
119
|
const resolveFetch = (): typeof fetch => {
|
|
118
120
|
if (options.fetchImpl) return options.fetchImpl
|
|
@@ -137,7 +139,7 @@ export function createHttpSink(options: HttpSinkOptions): AnalyticsSink {
|
|
|
137
139
|
function envelope(batch: AnalyticsEvent[]) {
|
|
138
140
|
return {
|
|
139
141
|
batch,
|
|
140
|
-
sentAt:
|
|
142
|
+
sentAt: now().toISOString(),
|
|
141
143
|
batchId: uuidv4(),
|
|
142
144
|
}
|
|
143
145
|
}
|
package/dist/analytics/types.ts
CHANGED
|
@@ -187,6 +187,8 @@ export interface HttpSinkOptions {
|
|
|
187
187
|
maxBatchBytes?: number
|
|
188
188
|
/** Soft per-event byte cap (Segment ≈ 32KB). Default 32_000. */
|
|
189
189
|
maxEventBytes?: number
|
|
190
|
+
/** Clock for the batch `sentAt` stamp. Default: ambient `new Date()` — inject for deterministic tests. */
|
|
191
|
+
now?: () => Date
|
|
190
192
|
}
|
|
191
193
|
|
|
192
194
|
/** `createAnalytics` configuration. */
|
|
@@ -222,6 +224,10 @@ export interface AnalyticsConfig {
|
|
|
222
224
|
batchSize?: number
|
|
223
225
|
/** Auto-flush interval in ms (prod). Default 10_000. */
|
|
224
226
|
flushIntervalMs?: number
|
|
227
|
+
/** Clock for default event timestamps. Default: ambient `new Date()` — inject for deterministic tests/SSR. */
|
|
228
|
+
now?: () => Date
|
|
229
|
+
/** RNG for sample-rate decisions. Default: ambient `Math.random` — inject for deterministic tests. */
|
|
230
|
+
random?: () => number
|
|
225
231
|
}
|
|
226
232
|
|
|
227
233
|
/** Options accepted by every top-level call. */
|
|
@@ -18,6 +18,8 @@ export interface CalendarProps {
|
|
|
18
18
|
maxDate?: Date
|
|
19
19
|
/** Specific dates to disable */
|
|
20
20
|
disabledDates?: Date[]
|
|
21
|
+
/** Reference "today" for isToday/aria-current. Default: ambient `new Date()` — inject to avoid SSR/CSR hydration mismatch. */
|
|
22
|
+
today?: Date
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export interface CalendarDay {
|
|
@@ -52,9 +54,9 @@ export interface CalendarAPI {
|
|
|
52
54
|
/** Select a date */
|
|
53
55
|
select(date: Date): void
|
|
54
56
|
/** ARIA attributes for the calendar grid */
|
|
55
|
-
ariaProps: Partial<AccessibilityProps> & Record<string,
|
|
57
|
+
ariaProps: Partial<AccessibilityProps> & Record<string, string | number | boolean>
|
|
56
58
|
/** Get ARIA attributes for a specific day cell */
|
|
57
|
-
getDayAriaProps(day: CalendarDay): Partial<AccessibilityProps> & Record<string,
|
|
59
|
+
getDayAriaProps(day: CalendarDay): Partial<AccessibilityProps> & Record<string, string | number | boolean>
|
|
58
60
|
/** Generated IDs */
|
|
59
61
|
ids: {
|
|
60
62
|
grid: string
|
|
@@ -89,9 +91,11 @@ export function createCalendar(props: CalendarProps = {}): CalendarAPI {
|
|
|
89
91
|
minDate,
|
|
90
92
|
maxDate,
|
|
91
93
|
disabledDates = [],
|
|
94
|
+
today: injectedToday,
|
|
92
95
|
} = props
|
|
93
96
|
|
|
94
|
-
|
|
97
|
+
// Injection boundary: ambient "today" is the default; inject `today` so SSR and client agree.
|
|
98
|
+
const today = injectedToday ?? new Date()
|
|
95
99
|
|
|
96
100
|
// Determine selected date
|
|
97
101
|
const selectedDate = controlledValue ?? defaultValue
|
|
@@ -167,18 +171,19 @@ export function createCalendar(props: CalendarProps = {}): CalendarAPI {
|
|
|
167
171
|
}
|
|
168
172
|
}
|
|
169
173
|
|
|
170
|
-
const ariaProps: Partial<AccessibilityProps> & Record<string,
|
|
174
|
+
const ariaProps: Partial<AccessibilityProps> & Record<string, string | number | boolean> = {
|
|
171
175
|
role: 'grid',
|
|
172
176
|
'aria-labelledby': labelId,
|
|
173
177
|
id: gridId,
|
|
174
178
|
}
|
|
175
179
|
|
|
176
|
-
function getDayAriaProps(day: CalendarDay): Partial<AccessibilityProps> & Record<string,
|
|
180
|
+
function getDayAriaProps(day: CalendarDay): Partial<AccessibilityProps> & Record<string, string | number | boolean> {
|
|
177
181
|
return {
|
|
178
182
|
role: 'gridcell',
|
|
179
183
|
'aria-selected': day.isSelected,
|
|
180
184
|
'aria-disabled': day.isDisabled,
|
|
181
|
-
|
|
185
|
+
// Key absent (not undefined) when not today — the ARIA record type excludes undefined.
|
|
186
|
+
...(day.isToday ? { 'aria-current': 'date' as const } : {}),
|
|
182
187
|
'aria-label': `${DAY_NAMES[day.date.getDay()]}, ${day.date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`,
|
|
183
188
|
}
|
|
184
189
|
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { AccessibilityProps } from '../shared/index.ts'
|
|
2
|
-
|
|
3
1
|
/** Visual/semantic tone of a call control button. */
|
|
4
2
|
export type CallControlTone = 'default' | 'active' | 'destructive'
|
|
5
3
|
|
|
@@ -33,7 +31,7 @@ export interface CallControlsProps {
|
|
|
33
31
|
|
|
34
32
|
export interface CallControlsAPI {
|
|
35
33
|
/** ARIA attributes to spread on the toolbar element (`role="toolbar"`). */
|
|
36
|
-
ariaProps:
|
|
34
|
+
ariaProps: Record<string, string | number | boolean>
|
|
37
35
|
/** Data attributes for styling hooks. */
|
|
38
36
|
dataAttributes: Record<string, string>
|
|
39
37
|
}
|
|
@@ -66,7 +64,7 @@ export function controlTone(
|
|
|
66
64
|
* attributes; adapters spread these onto their container element.
|
|
67
65
|
*/
|
|
68
66
|
export function createCallControls(_props: CallControlsProps = {}): CallControlsAPI {
|
|
69
|
-
const ariaProps:
|
|
67
|
+
const ariaProps: Record<string, string | number | boolean> = {
|
|
70
68
|
role: 'toolbar',
|
|
71
69
|
'aria-label': 'Call controls',
|
|
72
70
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @refraction-ui/carousel — headless "carousel" store.
|
|
3
|
+
*
|
|
4
|
+
* Honest note: despite the name, this component is an expand/collapse panel
|
|
5
|
+
* set (an accordion in disguise) — that is the behavior the React adapter and
|
|
6
|
+
* the docs site demo (single/multiple open panels, collapsible toggles). The
|
|
7
|
+
* state machine is therefore shared with `@refraction-ui/accordion` rather
|
|
8
|
+
* than duplicated. A real slide-navigation carousel (active index, next/prev)
|
|
9
|
+
* would be a different core; this package keeps the shipped behavior.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
createAccordion,
|
|
13
|
+
isItemOpen,
|
|
14
|
+
type AccordionAPI,
|
|
15
|
+
type AccordionConfig,
|
|
16
|
+
type AccordionState,
|
|
17
|
+
type AccordionType,
|
|
18
|
+
type AccordionValue,
|
|
19
|
+
} from '../accordion/index.ts'
|
|
20
|
+
|
|
21
|
+
/** Expansion mode: one item open at a time, or any number. */
|
|
22
|
+
export type CarouselType = AccordionType
|
|
23
|
+
|
|
24
|
+
/** Open item id (`single`) or ids (`multiple`). */
|
|
25
|
+
export type CarouselValue = AccordionValue
|
|
26
|
+
|
|
27
|
+
/** Options for {@link createCarousel}. Mirrors {@link AccordionConfig}. */
|
|
28
|
+
export interface CarouselConfig extends AccordionConfig {}
|
|
29
|
+
|
|
30
|
+
/** Snapshot of the store. Returned by `getState()`; treat as immutable. */
|
|
31
|
+
export interface CarouselState extends AccordionState {}
|
|
32
|
+
|
|
33
|
+
/** The framework-agnostic store. React/Astro adapters wrap this. */
|
|
34
|
+
export type CarouselAPI = AccordionAPI
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* createCarousel — headless expand/collapse panel store. This is the
|
|
38
|
+
* accordion state machine under the carousel name; see the package note.
|
|
39
|
+
*/
|
|
40
|
+
export function createCarousel(config: CarouselConfig = {}): CarouselAPI {
|
|
41
|
+
return createAccordion(config)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Pure open-state check shared by every adapter. */
|
|
45
|
+
export { isItemOpen }
|
package/dist/carousel/index.ts
CHANGED
|
@@ -33,6 +33,8 @@ export function createConversation(config: ConversationConfig = {}): Conversatio
|
|
|
33
33
|
const assistant = config.assistant ?? DEFAULT_ASSISTANT
|
|
34
34
|
const currentUser = config.currentUser ?? DEFAULT_USER
|
|
35
35
|
const generateTitle = config.generateTitle ?? defaultGenerateTitle
|
|
36
|
+
// Injection boundary: ambient time is the default; inject `now` for deterministic timestamps.
|
|
37
|
+
const now = config.now ?? (() => new Date())
|
|
36
38
|
|
|
37
39
|
let transport = config.transport
|
|
38
40
|
let threadingMode: ThreadingMode = config.threadingMode ?? 'inline'
|
|
@@ -87,7 +89,7 @@ export function createConversation(config: ConversationConfig = {}): Conversatio
|
|
|
87
89
|
|
|
88
90
|
function touch(conversationId: string): void {
|
|
89
91
|
const c = conversations.get(conversationId)
|
|
90
|
-
if (c) conversations.set(conversationId, { ...c, updatedAt:
|
|
92
|
+
if (c) conversations.set(conversationId, { ...c, updatedAt: now() })
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
function ensureActiveConversation(opts?: SendOptions): string {
|
|
@@ -103,12 +105,12 @@ export function createConversation(config: ConversationConfig = {}): Conversatio
|
|
|
103
105
|
opts: { title?: string; metadata?: Record<string, unknown> },
|
|
104
106
|
id?: string,
|
|
105
107
|
): Conversation {
|
|
106
|
-
const
|
|
108
|
+
const timestamp = now()
|
|
107
109
|
const conversation: Conversation = {
|
|
108
110
|
id: id ?? generateId('rfr-conv'),
|
|
109
111
|
title: opts.title ?? DEFAULT_TITLE,
|
|
110
|
-
createdAt:
|
|
111
|
-
updatedAt:
|
|
112
|
+
createdAt: timestamp,
|
|
113
|
+
updatedAt: timestamp,
|
|
112
114
|
metadata: opts.metadata,
|
|
113
115
|
}
|
|
114
116
|
conversations.set(conversation.id, conversation)
|
|
@@ -294,7 +296,7 @@ export function createConversation(config: ConversationConfig = {}): Conversatio
|
|
|
294
296
|
role: 'user',
|
|
295
297
|
author: currentUser,
|
|
296
298
|
content: trimmed,
|
|
297
|
-
timestamp:
|
|
299
|
+
timestamp: now(),
|
|
298
300
|
status: 'sent',
|
|
299
301
|
parentId,
|
|
300
302
|
replyToId,
|
|
@@ -323,7 +325,7 @@ export function createConversation(config: ConversationConfig = {}): Conversatio
|
|
|
323
325
|
role: 'assistant',
|
|
324
326
|
author: assistant,
|
|
325
327
|
content: '',
|
|
326
|
-
timestamp:
|
|
328
|
+
timestamp: now(),
|
|
327
329
|
status: 'streaming',
|
|
328
330
|
parentId,
|
|
329
331
|
}
|
|
@@ -350,7 +352,7 @@ export function createConversation(config: ConversationConfig = {}): Conversatio
|
|
|
350
352
|
role: 'assistant',
|
|
351
353
|
author: assistant,
|
|
352
354
|
content: '',
|
|
353
|
-
timestamp:
|
|
355
|
+
timestamp: now(),
|
|
354
356
|
status: 'streaming',
|
|
355
357
|
parentId,
|
|
356
358
|
}
|
|
@@ -170,6 +170,8 @@ export interface ConversationConfig {
|
|
|
170
170
|
generateTitle?: (firstMessage: string) => string
|
|
171
171
|
/** Threading mode (default 'inline') */
|
|
172
172
|
threadingMode?: ThreadingMode
|
|
173
|
+
/** Clock for conversation/message timestamps. Default: ambient `new Date()` — inject for deterministic SSR/tests. */
|
|
174
|
+
now?: () => Date
|
|
173
175
|
}
|
|
174
176
|
|
|
175
177
|
/** Options for a single `sendMessage`. */
|
|
@@ -26,6 +26,8 @@ export interface DatePickerProps {
|
|
|
26
26
|
onOpenChange?: (open: boolean) => void
|
|
27
27
|
/** The currently displayed month (controlled) */
|
|
28
28
|
currentMonth?: Date
|
|
29
|
+
/** Reference "today" for isToday/aria-current and time defaults. Default: ambient `new Date()` — inject to avoid SSR/CSR hydration mismatch. */
|
|
30
|
+
today?: Date
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export interface DatePickerState {
|
|
@@ -149,7 +151,8 @@ export function createDatePicker(props: DatePickerProps = {}): DatePickerAPI {
|
|
|
149
151
|
onOpenChange,
|
|
150
152
|
} = props
|
|
151
153
|
|
|
152
|
-
|
|
154
|
+
// Injection boundary: ambient "today" is the default; inject `today` so SSR and client agree.
|
|
155
|
+
const today = props.today ?? new Date()
|
|
153
156
|
let internalOpen = controlledOpen ?? defaultOpen
|
|
154
157
|
let currentMonth = props.currentMonth ?? (value ? startOfMonth(value) : startOfMonth(today))
|
|
155
158
|
let view: DatePickerView = 'calendar'
|
|
@@ -230,14 +233,14 @@ export function createDatePicker(props: DatePickerProps = {}): DatePickerAPI {
|
|
|
230
233
|
|
|
231
234
|
function setHours(h: number): void {
|
|
232
235
|
if (h < 0 || h > 23) return
|
|
233
|
-
const newDate = value ? new Date(value) : new Date()
|
|
236
|
+
const newDate = value ? new Date(value) : new Date(today.getTime())
|
|
234
237
|
newDate.setHours(h)
|
|
235
238
|
onChange?.(newDate)
|
|
236
239
|
}
|
|
237
240
|
|
|
238
241
|
function setMinutes(m: number): void {
|
|
239
242
|
if (m < 0 || m > 59) return
|
|
240
|
-
const newDate = value ? new Date(value) : new Date()
|
|
243
|
+
const newDate = value ? new Date(value) : new Date(today.getTime())
|
|
241
244
|
newDate.setMinutes(m)
|
|
242
245
|
onChange?.(newDate)
|
|
243
246
|
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { AccessibilityProps } from '../shared/index.ts'
|
|
2
|
-
|
|
3
1
|
export type DeviceType = 'iphone' | 'ipad' | 'android-phone' | 'android-tablet'
|
|
4
2
|
export type DeviceOrientation = 'portrait' | 'landscape'
|
|
5
3
|
|
|
@@ -27,7 +25,7 @@ export interface DeviceFrameAPI {
|
|
|
27
25
|
/** Computed dimensions for the device */
|
|
28
26
|
dimensions: DeviceDimensions
|
|
29
27
|
/** ARIA attributes for the device frame */
|
|
30
|
-
ariaProps:
|
|
28
|
+
ariaProps: Record<string, string | number | boolean>
|
|
31
29
|
/** Data attributes for styling hooks */
|
|
32
30
|
dataAttributes: Record<string, string>
|
|
33
31
|
}
|
|
@@ -92,7 +90,7 @@ export function createDeviceFrame(props: DeviceFrameProps): DeviceFrameAPI {
|
|
|
92
90
|
homeIndicator: spec.homeIndicator,
|
|
93
91
|
}
|
|
94
92
|
|
|
95
|
-
const ariaProps:
|
|
93
|
+
const ariaProps: Record<string, string | number | boolean> = {
|
|
96
94
|
role: 'img',
|
|
97
95
|
'aria-label': `${DEVICE_LABELS[device]} device frame in ${orientation} orientation`,
|
|
98
96
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { AccessibilityProps } from '../shared/index.ts'
|
|
2
1
|
import { generateId } from '../shared/index.ts'
|
|
3
2
|
import {
|
|
4
3
|
EMOJI_DATA,
|
|
@@ -39,7 +38,7 @@ export interface EmojiPickerAPI {
|
|
|
39
38
|
/** Category tabs with labels */
|
|
40
39
|
categoryTabs: { category: EmojiCategory; label: string; emoji: string }[]
|
|
41
40
|
/** ARIA props for the picker container */
|
|
42
|
-
ariaProps:
|
|
41
|
+
ariaProps: Record<string, string | number | boolean>
|
|
43
42
|
/** ARIA props for the search input */
|
|
44
43
|
searchInputProps: Record<string, unknown>
|
|
45
44
|
/** Get ARIA props for an emoji button */
|
|
@@ -118,7 +117,7 @@ export function createEmojiPicker(props: EmojiPickerProps = {}): EmojiPickerAPI
|
|
|
118
117
|
emoji: CATEGORY_ICONS[cat],
|
|
119
118
|
}))
|
|
120
119
|
|
|
121
|
-
const ariaProps:
|
|
120
|
+
const ariaProps: Record<string, string | number | boolean> = {
|
|
122
121
|
role: 'dialog',
|
|
123
122
|
'aria-labelledby': labelId,
|
|
124
123
|
id: containerId,
|
package/dist/footer/footer.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface FooterProps {
|
|
|
13
13
|
copyright?: string
|
|
14
14
|
socialLinks?: SocialLink[]
|
|
15
15
|
columns?: FooterColumn[]
|
|
16
|
+
/** Year for the default copyrightText. Default: ambient current year — inject to avoid year-boundary SSR/CSR mismatch. */
|
|
17
|
+
year?: number
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export interface FooterAPI {
|
|
@@ -22,7 +24,8 @@ export interface FooterAPI {
|
|
|
22
24
|
|
|
23
25
|
export function createFooter(props: FooterProps = {}): FooterAPI {
|
|
24
26
|
const { copyright } = props
|
|
25
|
-
|
|
27
|
+
// Injection boundary: ambient year is the default; inject `year` so SSR and client agree.
|
|
28
|
+
const year = props.year ?? new Date().getFullYear()
|
|
26
29
|
const copyrightText = copyright ?? `© ${year} All rights reserved.`
|
|
27
30
|
|
|
28
31
|
return {
|
|
@@ -34,6 +34,9 @@ export function createTelemetry(config: TelemetryConfig): Telemetry {
|
|
|
34
34
|
const preset = resolvePreset(config.env)
|
|
35
35
|
const sampleRate = config.sampleRate ?? preset.sampleRate
|
|
36
36
|
const redactKeys = config.redactKeys ?? []
|
|
37
|
+
// Injection boundary: ambient time/randomness default here; inject `now`/`random` for determinism.
|
|
38
|
+
const now = config.now ?? (() => Date.now())
|
|
39
|
+
const random = config.random ?? (() => Math.random())
|
|
37
40
|
|
|
38
41
|
const sinks = new Map<string, TelemetrySink>()
|
|
39
42
|
/** Insertion-ordered sink names. */
|
|
@@ -71,7 +74,7 @@ export function createTelemetry(config: TelemetryConfig): Telemetry {
|
|
|
71
74
|
function shouldSample(): boolean {
|
|
72
75
|
if (sampleRate >= 1) return true
|
|
73
76
|
if (sampleRate <= 0) return false
|
|
74
|
-
return
|
|
77
|
+
return random() < sampleRate
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
function dispatch(
|
|
@@ -134,7 +137,7 @@ export function createTelemetry(config: TelemetryConfig): Telemetry {
|
|
|
134
137
|
const record: LogRecord = {
|
|
135
138
|
level,
|
|
136
139
|
message,
|
|
137
|
-
timestamp:
|
|
140
|
+
timestamp: now(),
|
|
138
141
|
app: config.app,
|
|
139
142
|
env: config.env,
|
|
140
143
|
context: merged,
|
|
@@ -164,13 +167,13 @@ export function createTelemetry(config: TelemetryConfig): Telemetry {
|
|
|
164
167
|
},
|
|
165
168
|
|
|
166
169
|
startSpan(name: string, attributes?: LogContext): Span {
|
|
167
|
-
const startTime =
|
|
170
|
+
const startTime = now()
|
|
168
171
|
let ended = false
|
|
169
172
|
return {
|
|
170
173
|
end(opts?: { error?: unknown; attributes?: LogContext }): void {
|
|
171
174
|
if (ended) return
|
|
172
175
|
ended = true
|
|
173
|
-
const endTime =
|
|
176
|
+
const endTime = now()
|
|
174
177
|
const merged = redact(
|
|
175
178
|
{ ...boundContext, ...attributes, ...opts?.attributes },
|
|
176
179
|
redactKeys,
|
package/dist/logger/types.ts
CHANGED
|
@@ -84,6 +84,10 @@ export interface TelemetryConfig {
|
|
|
84
84
|
* PII / secrets, e.g. `['password', 'token', 'authorization']`.
|
|
85
85
|
*/
|
|
86
86
|
redactKeys?: string[]
|
|
87
|
+
/** Clock for record timestamps and span timing (ms epoch). Default: ambient `Date.now()` — inject for deterministic tests. */
|
|
88
|
+
now?: () => number
|
|
89
|
+
/** RNG for sample-rate decisions. Default: ambient `Math.random` — inject for deterministic tests. */
|
|
90
|
+
random?: () => number
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
/** A logger bound to a context. Child loggers inherit + extend that context. */
|
package/dist/pagination/index.ts
CHANGED
|
@@ -1 +1,14 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
createPagination,
|
|
3
|
+
clampPage,
|
|
4
|
+
getPaginationItems,
|
|
5
|
+
type PaginationItem,
|
|
6
|
+
type PaginationConfig,
|
|
7
|
+
type PaginationAPI,
|
|
8
|
+
} from './pagination.js'
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
paginationVariants,
|
|
12
|
+
paginationItemVariants,
|
|
13
|
+
paginationEllipsisClass,
|
|
14
|
+
} from './pagination.styles.js'
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { cva } from '../shared/index.ts'
|
|
2
|
+
|
|
3
|
+
/** Row of pagination controls. */
|
|
4
|
+
export const paginationVariants = cva({
|
|
5
|
+
base: 'flex items-center gap-1',
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A pagination button (prev / next / numbered page). The `state` variant
|
|
10
|
+
* carries the current-page treatment so adapters never inline these classes.
|
|
11
|
+
*/
|
|
12
|
+
export const paginationItemVariants = cva({
|
|
13
|
+
base: [
|
|
14
|
+
'inline-flex h-9 min-w-9 items-center justify-center rounded-md border px-3',
|
|
15
|
+
'text-sm font-medium transition-colors',
|
|
16
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
17
|
+
'focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
|
18
|
+
'disabled:pointer-events-none disabled:opacity-50',
|
|
19
|
+
].join(' '),
|
|
20
|
+
variants: {
|
|
21
|
+
state: {
|
|
22
|
+
current: 'border-primary bg-primary text-primary-foreground',
|
|
23
|
+
default: 'border-border bg-background text-foreground hover:bg-muted',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
defaultVariants: {
|
|
27
|
+
state: 'default',
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/** Ellipsis gap between page numbers (not interactive). */
|
|
32
|
+
export const paginationEllipsisClass =
|
|
33
|
+
'inline-flex h-9 min-w-9 items-center justify-center text-sm text-muted-foreground'
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @refraction-ui/pagination — headless pagination.
|
|
3
|
+
*
|
|
4
|
+
* Owns the *behavior* of page navigation: the windowed page range with
|
|
5
|
+
* ellipses, current-page clamping, and prev/next edge semantics. It has NO UI
|
|
6
|
+
* opinion — adapters render the controls and spread the ARIA objects.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** One entry of the computed page range: a page number or an ellipsis gap. */
|
|
10
|
+
export type PaginationItem =
|
|
11
|
+
| { type: 'page'; page: number }
|
|
12
|
+
| { type: 'ellipsis'; id: 'start' | 'end' }
|
|
13
|
+
|
|
14
|
+
/** Options for {@link createPagination}. */
|
|
15
|
+
export interface PaginationConfig {
|
|
16
|
+
/** Current page (1-based). Clamped into `[1, totalPages]`. */
|
|
17
|
+
page?: number
|
|
18
|
+
/** Total number of pages. Values < 1 behave as 1. */
|
|
19
|
+
totalPages?: number
|
|
20
|
+
/** Pages shown on each side of the current page (default 1). */
|
|
21
|
+
siblingCount?: number
|
|
22
|
+
/** Accessible label for the nav landmark (default `'Pagination'`). */
|
|
23
|
+
ariaLabel?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The computed view of a pagination control. */
|
|
27
|
+
export interface PaginationAPI {
|
|
28
|
+
/** ARIA attributes for the root element (`role="navigation"`). */
|
|
29
|
+
ariaProps: Record<string, string | number | boolean>
|
|
30
|
+
/** Data attributes for styling hooks. */
|
|
31
|
+
dataAttributes: Record<string, string>
|
|
32
|
+
/** Current page after clamping (1-based). */
|
|
33
|
+
page: number
|
|
34
|
+
/** Total number of pages (>= 1). */
|
|
35
|
+
totalPages: number
|
|
36
|
+
/** Windowed range of pages and ellipses to render. */
|
|
37
|
+
items: PaginationItem[]
|
|
38
|
+
/** Whether "previous" navigation is possible. */
|
|
39
|
+
canGoPrevious: boolean
|
|
40
|
+
/** Whether "next" navigation is possible. */
|
|
41
|
+
canGoNext: boolean
|
|
42
|
+
/** Target page for the previous control (clamped). */
|
|
43
|
+
previousPage: number
|
|
44
|
+
/** Target page for the next control (clamped). */
|
|
45
|
+
nextPage: number
|
|
46
|
+
/** ARIA attributes for a numbered page button (`aria-current` on the current page). */
|
|
47
|
+
getPageAria(page: number): Record<string, string | number | boolean>
|
|
48
|
+
/** ARIA attributes for the previous control. */
|
|
49
|
+
getPreviousAria(): Record<string, string | number | boolean>
|
|
50
|
+
/** ARIA attributes for the next control. */
|
|
51
|
+
getNextAria(): Record<string, string | number | boolean>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Clamp a 1-based page number into `[1, max(totalPages, 1)]`. */
|
|
55
|
+
export function clampPage(page: number, totalPages: number): number {
|
|
56
|
+
const total = Math.max(1, Math.floor(totalPages))
|
|
57
|
+
if (Number.isNaN(page)) return 1
|
|
58
|
+
return Math.min(Math.max(1, Math.floor(page)), total)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pageItem(page: number): PaginationItem {
|
|
62
|
+
return { type: 'page', page }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function pageRange(start: number, end: number): PaginationItem[] {
|
|
66
|
+
const items: PaginationItem[] = []
|
|
67
|
+
for (let p = start; p <= end; p++) items.push(pageItem(p))
|
|
68
|
+
return items
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compute the windowed page range with ellipses.
|
|
73
|
+
*
|
|
74
|
+
* Always shows the first and last page plus `siblingCount` pages on each side
|
|
75
|
+
* of the current page; gaps of more than one page collapse into an ellipsis.
|
|
76
|
+
* Small totals render every page (no ellipses).
|
|
77
|
+
*/
|
|
78
|
+
export function getPaginationItems(
|
|
79
|
+
current: number,
|
|
80
|
+
totalPages: number,
|
|
81
|
+
siblingCount = 1,
|
|
82
|
+
): PaginationItem[] {
|
|
83
|
+
const total = Math.max(0, Math.floor(totalPages))
|
|
84
|
+
if (total === 0) return []
|
|
85
|
+
|
|
86
|
+
// first + last + current + 2 siblings + 2 ellipses
|
|
87
|
+
const maxVisibleWithoutEllipsis = siblingCount * 2 + 5
|
|
88
|
+
if (total <= maxVisibleWithoutEllipsis) return pageRange(1, total)
|
|
89
|
+
|
|
90
|
+
const currentPage = clampPage(current, total)
|
|
91
|
+
const leftSibling = Math.max(currentPage - siblingCount, 1)
|
|
92
|
+
const rightSibling = Math.min(currentPage + siblingCount, total)
|
|
93
|
+
|
|
94
|
+
const showLeftEllipsis = leftSibling > 2
|
|
95
|
+
const showRightEllipsis = rightSibling < total - 1
|
|
96
|
+
|
|
97
|
+
if (!showLeftEllipsis && showRightEllipsis) {
|
|
98
|
+
const leftCount = 3 + 2 * siblingCount
|
|
99
|
+
return [...pageRange(1, leftCount), { type: 'ellipsis', id: 'end' }, pageItem(total)]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (showLeftEllipsis && !showRightEllipsis) {
|
|
103
|
+
const rightCount = 3 + 2 * siblingCount
|
|
104
|
+
return [
|
|
105
|
+
pageItem(1),
|
|
106
|
+
{ type: 'ellipsis', id: 'start' },
|
|
107
|
+
...pageRange(total - rightCount + 1, total),
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return [
|
|
112
|
+
pageItem(1),
|
|
113
|
+
{ type: 'ellipsis', id: 'start' },
|
|
114
|
+
...pageRange(leftSibling, rightSibling),
|
|
115
|
+
{ type: 'ellipsis', id: 'end' },
|
|
116
|
+
pageItem(total),
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* createPagination — the framework-agnostic view of a pagination control:
|
|
122
|
+
* ARIA objects for the nav landmark and controls, plus the clamped
|
|
123
|
+
* current/total and prev/next edge semantics. Adapters own the page state
|
|
124
|
+
* (controlled/uncontrolled) and call this per render.
|
|
125
|
+
*/
|
|
126
|
+
export function createPagination(config: PaginationConfig = {}): PaginationAPI {
|
|
127
|
+
const totalPages = Math.max(1, Math.floor(config.totalPages ?? 1))
|
|
128
|
+
const page = clampPage(config.page ?? 1, totalPages)
|
|
129
|
+
const siblingCount = Math.max(0, Math.floor(config.siblingCount ?? 1))
|
|
130
|
+
|
|
131
|
+
const canGoPrevious = page > 1
|
|
132
|
+
const canGoNext = page < totalPages
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
ariaProps: {
|
|
136
|
+
role: 'navigation',
|
|
137
|
+
'aria-label': config.ariaLabel ?? 'Pagination',
|
|
138
|
+
},
|
|
139
|
+
dataAttributes: {
|
|
140
|
+
'data-page': String(page),
|
|
141
|
+
'data-total-pages': String(totalPages),
|
|
142
|
+
},
|
|
143
|
+
page,
|
|
144
|
+
totalPages,
|
|
145
|
+
items: getPaginationItems(page, totalPages, siblingCount),
|
|
146
|
+
canGoPrevious,
|
|
147
|
+
canGoNext,
|
|
148
|
+
previousPage: canGoPrevious ? page - 1 : page,
|
|
149
|
+
nextPage: canGoNext ? page + 1 : page,
|
|
150
|
+
getPageAria(p): Record<string, string | number | boolean> {
|
|
151
|
+
return p === page ? { 'aria-current': 'page' } : {}
|
|
152
|
+
},
|
|
153
|
+
getPreviousAria(): Record<string, string | number | boolean> {
|
|
154
|
+
return { 'aria-label': 'Previous page' }
|
|
155
|
+
},
|
|
156
|
+
getNextAria(): Record<string, string | number | boolean> {
|
|
157
|
+
return { 'aria-label': 'Next page' }
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { AccessibilityProps } from '../shared/index.ts'
|
|
2
|
-
|
|
3
1
|
export type PresenceStatus = 'online' | 'offline' | 'away' | 'busy' | 'dnd'
|
|
4
2
|
|
|
5
3
|
export interface PresenceProps {
|
|
@@ -13,7 +11,7 @@ export interface PresenceProps {
|
|
|
13
11
|
|
|
14
12
|
export interface PresenceAPI {
|
|
15
13
|
/** ARIA attributes for the indicator */
|
|
16
|
-
ariaProps:
|
|
14
|
+
ariaProps: Record<string, string | number | boolean>
|
|
17
15
|
/** CSS color for the status dot */
|
|
18
16
|
color: string
|
|
19
17
|
/** Human-readable label for the status */
|
|
@@ -48,7 +46,7 @@ export function createPresence(props: PresenceProps): PresenceAPI {
|
|
|
48
46
|
const color = STATUS_COLORS[status]
|
|
49
47
|
const label = labelOverride ?? STATUS_LABELS[status]
|
|
50
48
|
|
|
51
|
-
const ariaProps:
|
|
49
|
+
const ariaProps: Record<string, string | number | boolean> = {
|
|
52
50
|
role: 'status',
|
|
53
51
|
'aria-label': label,
|
|
54
52
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { AccessibilityProps } from '../shared/index.ts'
|
|
2
1
|
import { generateId } from '../shared/index.ts'
|
|
3
2
|
|
|
4
3
|
export interface Reaction {
|
|
@@ -27,7 +26,7 @@ export interface ReactionBarAPI {
|
|
|
27
26
|
/** Add a new reaction */
|
|
28
27
|
add(emoji: string): void
|
|
29
28
|
/** ARIA props for the reaction bar container */
|
|
30
|
-
ariaProps:
|
|
29
|
+
ariaProps: Record<string, string | number | boolean>
|
|
31
30
|
/** Get ARIA props for an individual reaction pill */
|
|
32
31
|
getReactionAriaProps(reaction: Reaction): Record<string, unknown>
|
|
33
32
|
/** ARIA props for the "add reaction" button */
|
|
@@ -53,7 +52,7 @@ export function createReactionBar(props: ReactionBarProps): ReactionBarAPI {
|
|
|
53
52
|
onAdd?.(emoji)
|
|
54
53
|
}
|
|
55
54
|
|
|
56
|
-
const ariaProps:
|
|
55
|
+
const ariaProps: Record<string, string | number | boolean> = {
|
|
57
56
|
role: 'group',
|
|
58
57
|
'aria-label': 'Reactions',
|
|
59
58
|
id: containerId,
|
package/dist/slider/slider.ts
CHANGED
|
@@ -1,2 +1,134 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @refraction-ui/slider — headless slider.
|
|
3
|
+
*
|
|
4
|
+
* Owns the *behavior* of a single-value range slider: min/max clamping,
|
|
5
|
+
* step rounding, and keyboard increment/decrement semantics. It has NO UI
|
|
6
|
+
* opinion — adapters render the control (a native `<input type="range">` in
|
|
7
|
+
* React, which already applies these same keyboard rules natively) and spread
|
|
8
|
+
* the ARIA object.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Options for {@link createSlider}. */
|
|
12
|
+
export interface SliderProps {
|
|
13
|
+
/** Current value. Clamped into `[min, max]` and rounded to `step`. */
|
|
14
|
+
value?: number
|
|
15
|
+
/** Minimum selectable value (default 0). */
|
|
16
|
+
min?: number
|
|
17
|
+
/** Maximum selectable value (default 100). */
|
|
18
|
+
max?: number
|
|
19
|
+
/** Granularity between selectable values (default 1). */
|
|
20
|
+
step?: number
|
|
21
|
+
/** Disables the control. */
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
/** Accessible label attached to the slider, when provided. */
|
|
24
|
+
'aria-label'?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The computed view of a slider control. */
|
|
28
|
+
export interface SliderAPI {
|
|
29
|
+
/** Normalized value (clamped + step-rounded). */
|
|
30
|
+
value: number
|
|
31
|
+
min: number
|
|
32
|
+
max: number
|
|
33
|
+
step: number
|
|
34
|
+
/** Value position within the range, 0–100. */
|
|
35
|
+
percentage: number
|
|
36
|
+
/** ARIA attributes for the slider element (`role="slider"` + value attrs). */
|
|
37
|
+
ariaProps: Record<string, string | number | boolean>
|
|
38
|
+
/** Data attributes for styling hooks. */
|
|
39
|
+
dataAttributes: Record<string, string>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Clamp a value into `[min, max]`. */
|
|
43
|
+
export function clampSliderValue(value: number, min = 0, max = 100): number {
|
|
44
|
+
if (Number.isNaN(value)) return min
|
|
45
|
+
return Math.min(Math.max(value, min), max)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Round a value to the nearest multiple of `step`, offset from `min`.
|
|
50
|
+
* Non-positive steps leave the value unchanged. The result is re-rounded to
|
|
51
|
+
* the step's decimal precision to avoid float noise (e.g. 0.30000000000000004).
|
|
52
|
+
*/
|
|
53
|
+
export function roundToStep(value: number, min = 0, step = 1): number {
|
|
54
|
+
if (!step || step <= 0 || Number.isNaN(value)) return value
|
|
55
|
+
const snapped = min + Math.round((value - min) / step) * step
|
|
56
|
+
const decimals = (String(step).split('.')[1] ?? '').length
|
|
57
|
+
return Number(snapped.toFixed(decimals))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Pure keyboard rule for a slider (WAI-ARIA slider pattern), shared by every
|
|
62
|
+
* adapter that does NOT render a native range input (native inputs already
|
|
63
|
+
* implement these keys):
|
|
64
|
+
*
|
|
65
|
+
* - ArrowRight / ArrowUp → +step
|
|
66
|
+
* - ArrowLeft / ArrowDown → -step
|
|
67
|
+
* - PageUp / PageDown → ±10×step
|
|
68
|
+
* - Home / End → min / max
|
|
69
|
+
*
|
|
70
|
+
* The result is clamped; unhandled keys return `current` unchanged.
|
|
71
|
+
*/
|
|
72
|
+
export function getSliderValueFromKey(
|
|
73
|
+
current: number,
|
|
74
|
+
key: string,
|
|
75
|
+
options: { min?: number; max?: number; step?: number } = {},
|
|
76
|
+
): number {
|
|
77
|
+
const { min = 0, max = 100, step = 1 } = options
|
|
78
|
+
switch (key) {
|
|
79
|
+
case 'ArrowRight':
|
|
80
|
+
case 'ArrowUp':
|
|
81
|
+
return clampSliderValue(current + step, min, max)
|
|
82
|
+
case 'ArrowLeft':
|
|
83
|
+
case 'ArrowDown':
|
|
84
|
+
return clampSliderValue(current - step, min, max)
|
|
85
|
+
case 'PageUp':
|
|
86
|
+
return clampSliderValue(current + step * 10, min, max)
|
|
87
|
+
case 'PageDown':
|
|
88
|
+
return clampSliderValue(current - step * 10, min, max)
|
|
89
|
+
case 'Home':
|
|
90
|
+
return min
|
|
91
|
+
case 'End':
|
|
92
|
+
return max
|
|
93
|
+
default:
|
|
94
|
+
return current
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* createSlider — the framework-agnostic view of a slider: normalized value,
|
|
100
|
+
* percentage position, and the ARIA object (`role="slider"`,
|
|
101
|
+
* `aria-valuemin/max/now`). Adapters own the value state
|
|
102
|
+
* (controlled/uncontrolled) and call this per render.
|
|
103
|
+
*/
|
|
104
|
+
export function createSlider(props: SliderProps = {}): SliderAPI {
|
|
105
|
+
const min = props.min ?? 0
|
|
106
|
+
const max = props.max ?? 100
|
|
107
|
+
const step = props.step ?? 1
|
|
108
|
+
const value = clampSliderValue(roundToStep(props.value ?? min, min, step), min, max)
|
|
109
|
+
const percentage = max === min ? 0 : ((value - min) / (max - min)) * 100
|
|
110
|
+
|
|
111
|
+
const ariaProps: Record<string, string | number | boolean> = {
|
|
112
|
+
role: 'slider',
|
|
113
|
+
'aria-valuemin': min,
|
|
114
|
+
'aria-valuemax': max,
|
|
115
|
+
'aria-valuenow': value,
|
|
116
|
+
'aria-orientation': 'horizontal',
|
|
117
|
+
}
|
|
118
|
+
if (props.disabled) ariaProps['aria-disabled'] = true
|
|
119
|
+
if (props['aria-label']) ariaProps['aria-label'] = props['aria-label']
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
value,
|
|
123
|
+
min,
|
|
124
|
+
max,
|
|
125
|
+
step,
|
|
126
|
+
percentage,
|
|
127
|
+
ariaProps,
|
|
128
|
+
dataAttributes: {
|
|
129
|
+
'data-value': String(value),
|
|
130
|
+
'data-min': String(min),
|
|
131
|
+
'data-max': String(max),
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { AccessibilityProps } from '../shared/index.ts'
|
|
2
|
-
|
|
3
1
|
export type StatusType = 'success' | 'error' | 'warning' | 'info' | 'pending' | 'neutral'
|
|
4
2
|
|
|
5
3
|
export interface StatusProps {
|
|
@@ -13,7 +11,7 @@ export interface StatusProps {
|
|
|
13
11
|
|
|
14
12
|
export interface StatusAPI {
|
|
15
13
|
/** ARIA attributes for the status indicator */
|
|
16
|
-
ariaProps:
|
|
14
|
+
ariaProps: Record<string, string | number | boolean>
|
|
17
15
|
/** The status type */
|
|
18
16
|
type: StatusType
|
|
19
17
|
/** The display label */
|
|
@@ -54,7 +52,7 @@ export function createStatusIndicator(props: StatusProps): StatusAPI {
|
|
|
54
52
|
const label = labelOverride ?? STATUS_INDICATOR_LABELS[type]
|
|
55
53
|
const pulse = pulseOverride ?? (type === 'pending')
|
|
56
54
|
|
|
57
|
-
const ariaProps:
|
|
55
|
+
const ariaProps: Record<string, string | number | boolean> = {
|
|
58
56
|
role: 'status',
|
|
59
57
|
'aria-label': label,
|
|
60
58
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { AccessibilityProps } from '../shared/index.ts'
|
|
2
1
|
import { generateId } from '../shared/index.ts'
|
|
3
2
|
|
|
4
3
|
export interface MessageReaction {
|
|
@@ -70,11 +69,11 @@ export interface ThreadViewAPI {
|
|
|
70
69
|
/** Format relative time (e.g., "2 minutes ago") */
|
|
71
70
|
formatRelativeTime(date: Date): string
|
|
72
71
|
/** ARIA props for the thread container */
|
|
73
|
-
ariaProps:
|
|
72
|
+
ariaProps: Record<string, string | number | boolean>
|
|
74
73
|
/** Get ARIA props for a message */
|
|
75
|
-
getMessageAriaProps(message: MessageData): Record<string,
|
|
74
|
+
getMessageAriaProps(message: MessageData): Record<string, string | number | boolean>
|
|
76
75
|
/** Get ARIA props for a reply button */
|
|
77
|
-
getReplyButtonAriaProps(messageId: string): Record<string,
|
|
76
|
+
getReplyButtonAriaProps(messageId: string): Record<string, string | number | boolean>
|
|
78
77
|
/** Generated IDs */
|
|
79
78
|
ids: {
|
|
80
79
|
thread: string
|
|
@@ -132,14 +131,14 @@ export function createThreadView(props: ThreadViewProps): ThreadViewAPI {
|
|
|
132
131
|
onReact?.(messageId, emoji)
|
|
133
132
|
}
|
|
134
133
|
|
|
135
|
-
const ariaProps:
|
|
134
|
+
const ariaProps: Record<string, string | number | boolean> = {
|
|
136
135
|
role: 'log',
|
|
137
136
|
'aria-label': 'Message thread',
|
|
138
137
|
'aria-live': 'polite',
|
|
139
138
|
id: threadId,
|
|
140
139
|
}
|
|
141
140
|
|
|
142
|
-
function getMessageAriaProps(message: MessageData): Record<string,
|
|
141
|
+
function getMessageAriaProps(message: MessageData): Record<string, string | number | boolean> {
|
|
143
142
|
const isOwn = currentUserId && message.author.id === currentUserId
|
|
144
143
|
return {
|
|
145
144
|
role: 'article',
|
|
@@ -147,7 +146,7 @@ export function createThreadView(props: ThreadViewProps): ThreadViewAPI {
|
|
|
147
146
|
}
|
|
148
147
|
}
|
|
149
148
|
|
|
150
|
-
function getReplyButtonAriaProps(_messageId: string): Record<string,
|
|
149
|
+
function getReplyButtonAriaProps(_messageId: string): Record<string, string | number | boolean> {
|
|
151
150
|
return {
|
|
152
151
|
role: 'button',
|
|
153
152
|
'aria-label': `Reply to message`,
|
package/dist/toast/toast.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface ToastProps {
|
|
|
25
25
|
open?: boolean
|
|
26
26
|
/** Callback when open state changes */
|
|
27
27
|
onOpenChange?: (open: boolean) => void
|
|
28
|
+
/** Elapsed-time clock for timer pause/resume math (ms). Default: ambient `Date.now()` — inject for deterministic tests. */
|
|
29
|
+
now?: () => number
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export interface ToastState {
|
|
@@ -55,6 +57,8 @@ export function createToast(props: ToastProps = {}): ToastAPI {
|
|
|
55
57
|
onOpenChange,
|
|
56
58
|
} = props
|
|
57
59
|
|
|
60
|
+
// Injection boundary: ambient time is the default; inject `now` for deterministic timer math.
|
|
61
|
+
const now = props.now ?? (() => Date.now())
|
|
58
62
|
let isOpen = controlledOpen ?? true
|
|
59
63
|
let timerId: ReturnType<typeof setTimeout> | null = null
|
|
60
64
|
let remaining = duration
|
|
@@ -77,21 +81,21 @@ export function createToast(props: ToastProps = {}): ToastAPI {
|
|
|
77
81
|
if (duration <= 0) return
|
|
78
82
|
clearTimer()
|
|
79
83
|
remaining = duration
|
|
80
|
-
startedAt =
|
|
84
|
+
startedAt = now()
|
|
81
85
|
timerId = setTimeout(dismiss, remaining)
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
function pauseTimer(): void {
|
|
85
89
|
if (timerId === null) return
|
|
86
90
|
clearTimer()
|
|
87
|
-
remaining = remaining - (
|
|
91
|
+
remaining = remaining - (now() - startedAt)
|
|
88
92
|
if (remaining < 0) remaining = 0
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
function resumeTimer(): void {
|
|
92
96
|
if (duration <= 0 || remaining <= 0) return
|
|
93
97
|
clearTimer()
|
|
94
|
-
startedAt =
|
|
98
|
+
startedAt = now()
|
|
95
99
|
timerId = setTimeout(dismiss, remaining)
|
|
96
100
|
}
|
|
97
101
|
|
|
@@ -136,7 +140,14 @@ export interface ToastManagerAPI {
|
|
|
136
140
|
subscribe(fn: (toasts: ReadonlyArray<ToastEntry>) => void): () => void
|
|
137
141
|
}
|
|
138
142
|
|
|
139
|
-
export
|
|
143
|
+
export interface ToastManagerConfig {
|
|
144
|
+
/** Clock for entry `createdAt` stamps (ms). Default: ambient `Date.now()` — inject for deterministic tests. */
|
|
145
|
+
now?: () => number
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function createToastManager(config: ToastManagerConfig = {}): ToastManagerAPI {
|
|
149
|
+
// Injection boundary: ambient time is the default; inject `now` for deterministic createdAt.
|
|
150
|
+
const now = config.now ?? (() => Date.now())
|
|
140
151
|
let toasts: ToastEntry[] = []
|
|
141
152
|
const listeners = new Set<(toasts: ReadonlyArray<ToastEntry>) => void>()
|
|
142
153
|
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
@@ -171,7 +182,7 @@ export function createToastManager(): ToastManagerAPI {
|
|
|
171
182
|
message,
|
|
172
183
|
variant,
|
|
173
184
|
duration,
|
|
174
|
-
createdAt:
|
|
185
|
+
createdAt: now(),
|
|
175
186
|
}
|
|
176
187
|
|
|
177
188
|
toasts = [...toasts, entry]
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { AccessibilityProps } from '../shared/index.ts'
|
|
2
|
-
|
|
3
1
|
export type VoicePillSpeaker = 'ai' | 'user' | (string & {})
|
|
4
2
|
|
|
5
3
|
export type VoicePillPosition =
|
|
@@ -60,7 +58,7 @@ export interface VoicePillAPI {
|
|
|
60
58
|
/** Invoke the mute callback when provided */
|
|
61
59
|
toggleMute(): void
|
|
62
60
|
/** ARIA props for the live status pill */
|
|
63
|
-
ariaProps:
|
|
61
|
+
ariaProps: Record<string, string | number | boolean>
|
|
64
62
|
/** ARIA props for the mute toggle */
|
|
65
63
|
toggleMuteAriaProps: Record<string, unknown>
|
|
66
64
|
/** Data attributes reflecting speaker/state */
|
|
@@ -196,7 +194,7 @@ export function createVoicePill(props: VoicePillProps): VoicePillAPI {
|
|
|
196
194
|
onToggleMute?.()
|
|
197
195
|
}
|
|
198
196
|
|
|
199
|
-
const ariaProps:
|
|
197
|
+
const ariaProps: Record<string, string | number | boolean> = {
|
|
200
198
|
role: 'status',
|
|
201
199
|
'aria-live': 'polite',
|
|
202
200
|
'aria-atomic': true,
|