@refraction-ui/astro 0.17.0 → 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.
Files changed (41) hide show
  1. package/dist/accordion/accordion.ts +155 -0
  2. package/dist/accordion/index.ts +9 -1
  3. package/dist/analytics/analytics-manager.ts +7 -2
  4. package/dist/analytics/http-sink.ts +3 -1
  5. package/dist/analytics/types.ts +6 -0
  6. package/dist/astro-location-selector/LocationSelector.astro +56 -0
  7. package/dist/astro-location-selector/LocationSelector.stories.ts +15 -0
  8. package/dist/astro-location-selector/index.ts +1 -0
  9. package/dist/calendar/calendar.ts +11 -6
  10. package/dist/call-controls/call-controls.ts +2 -4
  11. package/dist/carousel/carousel.ts +45 -0
  12. package/dist/carousel/index.ts +9 -1
  13. package/dist/command-input/command-input.ts +5 -5
  14. package/dist/conversation/conversation.ts +9 -7
  15. package/dist/conversation/types.ts +2 -0
  16. package/dist/date-picker/date-picker.ts +6 -3
  17. package/dist/device-frame/device-frame.ts +2 -4
  18. package/dist/emoji-picker/emoji-picker.ts +2 -3
  19. package/dist/footer/footer.ts +4 -1
  20. package/dist/i18n/countries.ts +122 -0
  21. package/dist/i18n/detect-region.ts +243 -0
  22. package/dist/i18n/index.ts +47 -0
  23. package/dist/i18n/languages.ts +181 -0
  24. package/dist/i18n/locale-utils.ts +123 -0
  25. package/dist/i18n/voice-registry.ts +92 -0
  26. package/dist/index.ts +1 -0
  27. package/dist/location-selector/index.ts +1 -0
  28. package/dist/location-selector/location-selector.ts +60 -0
  29. package/dist/logger/telemetry-manager.ts +7 -4
  30. package/dist/logger/types.ts +4 -0
  31. package/dist/pagination/index.ts +14 -1
  32. package/dist/pagination/pagination.styles.ts +33 -0
  33. package/dist/pagination/pagination.ts +160 -0
  34. package/dist/presence-indicator/presence-indicator.ts +2 -4
  35. package/dist/reaction-bar/reaction-bar.ts +2 -3
  36. package/dist/slider/slider.ts +134 -2
  37. package/dist/status-indicator/status-indicator.ts +2 -4
  38. package/dist/thread-view/thread-view.ts +6 -7
  39. package/dist/toast/toast.ts +16 -5
  40. package/dist/voice-pill/voice-pill.ts +2 -4
  41. package/package.json +2 -1
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Locale utilities for display names, storage, and localized value resolution.
3
+ */
4
+
5
+ /** Display names for common locale codes (in their native language) */
6
+ export const LOCALE_DISPLAY_NAMES: Record<string, string> = {
7
+ 'en': 'English',
8
+ 'en-US': 'English (US)',
9
+ 'en-GB': 'English (UK)',
10
+ 'en-IN': 'English (India)',
11
+ 'hi': 'हिन्दी',
12
+ 'te': 'తెలుగు',
13
+ 'ta': 'தமிழ்',
14
+ 'ur': 'اردو',
15
+ 'pa': 'ਪੰਜਾਬੀ',
16
+ 'bn': 'বাংলা',
17
+ 'gu': 'ગુજરાતી',
18
+ 'mr': 'मराठी',
19
+ 'kn': 'ಕನ್ನಡ',
20
+ 'ml': 'മലയാളം',
21
+ 'fr': 'Français',
22
+ 'es': 'Español',
23
+ 'de': 'Deutsch',
24
+ 'it': 'Italiano',
25
+ 'pt': 'Português',
26
+ 'pt-BR': 'Português (Brasil)',
27
+ 'nl': 'Nederlands',
28
+ 'sv': 'Svenska',
29
+ 'da': 'Dansk',
30
+ 'no': 'Norsk',
31
+ 'fi': 'Suomi',
32
+ 'pl': 'Polski',
33
+ 'cs': 'Čeština',
34
+ 'ro': 'Română',
35
+ 'hu': 'Magyar',
36
+ 'bg': 'Български',
37
+ 'hr': 'Hrvatski',
38
+ 'sk': 'Slovenčina',
39
+ 'sl': 'Slovenščina',
40
+ 'sr': 'Srpski',
41
+ 'uk': 'Українська',
42
+ 'el': 'Ελληνικά',
43
+ 'ru': 'Русский',
44
+ 'ja': '日本語',
45
+ 'ko': '한국어',
46
+ 'zh': '中文',
47
+ 'zh-TW': '中文(繁體)',
48
+ 'ar': 'العربية',
49
+ 'he': 'עברית',
50
+ 'tr': 'Türkçe',
51
+ 'th': 'ไทย',
52
+ 'vi': 'Tiếng Việt',
53
+ 'id': 'Bahasa Indonesia',
54
+ 'ms': 'Bahasa Melayu',
55
+ }
56
+
57
+ const DEFAULT_STORAGE_KEY = 'refraction-ui-locale'
58
+
59
+ /**
60
+ * Get the display name for a locale code.
61
+ * Returns the code itself if not found in the display names map.
62
+ */
63
+ export function getLocaleDisplayName(code: string): string {
64
+ return LOCALE_DISPLAY_NAMES[code] ?? code
65
+ }
66
+
67
+ /**
68
+ * Get the stored locale from localStorage (or compatible storage).
69
+ * Returns null if not stored or localStorage is not available.
70
+ */
71
+ export function getStoredLocale(storageKey?: string): string | null {
72
+ try {
73
+ if (typeof localStorage !== 'undefined') {
74
+ return localStorage.getItem(storageKey ?? DEFAULT_STORAGE_KEY)
75
+ }
76
+ } catch {
77
+ // localStorage may not be available
78
+ }
79
+ return null
80
+ }
81
+
82
+ /**
83
+ * Store a locale code in localStorage (or compatible storage).
84
+ * Does nothing if localStorage is not available.
85
+ */
86
+ export function setStoredLocale(code: string, storageKey?: string): void {
87
+ try {
88
+ if (typeof localStorage !== 'undefined') {
89
+ localStorage.setItem(storageKey ?? DEFAULT_STORAGE_KEY, code)
90
+ }
91
+ } catch {
92
+ // localStorage may not be available
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Resolve a localized value from a record of locale -> value.
98
+ * Tries exact locale, then base language code (for regional variants like 'en-US' -> 'en'),
99
+ * then the fallback locale.
100
+ */
101
+ export function getLocalizedValue<T>(
102
+ localized: Record<string, T>,
103
+ locale: string,
104
+ fallbackLocale?: string,
105
+ ): T | undefined {
106
+ // 1. Exact match
107
+ if (locale in localized) {
108
+ return localized[locale]
109
+ }
110
+
111
+ // 2. Try base language code (e.g., 'en-US' -> 'en')
112
+ const baseLang = locale.split('-')[0]
113
+ if (baseLang !== locale && baseLang in localized) {
114
+ return localized[baseLang]
115
+ }
116
+
117
+ // 3. Fallback locale
118
+ if (fallbackLocale && fallbackLocale in localized) {
119
+ return localized[fallbackLocale]
120
+ }
121
+
122
+ return undefined
123
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Voice registry for TTS providers.
3
+ */
4
+
5
+ export interface VoiceOption {
6
+ id: string
7
+ name: string
8
+ language: string
9
+ provider: 'browser' | 'google' | 'elevenlabs' | 'openai'
10
+ gender?: 'male' | 'female'
11
+ }
12
+
13
+ export const VOICES: VoiceOption[] = [
14
+ // Browser voices — English
15
+ { id: 'browser-en-us-female', name: 'English (US) Female', language: 'en-US', provider: 'browser', gender: 'female' },
16
+ { id: 'browser-en-us-male', name: 'English (US) Male', language: 'en-US', provider: 'browser', gender: 'male' },
17
+ { id: 'browser-en-gb-female', name: 'English (UK) Female', language: 'en-GB', provider: 'browser', gender: 'female' },
18
+ { id: 'browser-en-gb-male', name: 'English (UK) Male', language: 'en-GB', provider: 'browser', gender: 'male' },
19
+ { id: 'browser-en-in-female', name: 'English (India) Female', language: 'en-IN', provider: 'browser', gender: 'female' },
20
+
21
+ // Browser voices — European
22
+ { id: 'browser-fr-female', name: 'French Female', language: 'fr', provider: 'browser', gender: 'female' },
23
+ { id: 'browser-de-female', name: 'German Female', language: 'de', provider: 'browser', gender: 'female' },
24
+ { id: 'browser-es-female', name: 'Spanish Female', language: 'es', provider: 'browser', gender: 'female' },
25
+ { id: 'browser-it-female', name: 'Italian Female', language: 'it', provider: 'browser', gender: 'female' },
26
+ { id: 'browser-pt-female', name: 'Portuguese Female', language: 'pt', provider: 'browser', gender: 'female' },
27
+
28
+ // Browser voices — Asian
29
+ { id: 'browser-ja-female', name: 'Japanese Female', language: 'ja', provider: 'browser', gender: 'female' },
30
+ { id: 'browser-ko-female', name: 'Korean Female', language: 'ko', provider: 'browser', gender: 'female' },
31
+ { id: 'browser-zh-female', name: 'Chinese Female', language: 'zh', provider: 'browser', gender: 'female' },
32
+ { id: 'browser-hi-female', name: 'Hindi Female', language: 'hi', provider: 'browser', gender: 'female' },
33
+ { id: 'browser-ar-male', name: 'Arabic Male', language: 'ar', provider: 'browser', gender: 'male' },
34
+
35
+ // Google TTS voices
36
+ { id: 'google-en-us-wavenet-d', name: 'Google WaveNet English (US)', language: 'en-US', provider: 'google', gender: 'male' },
37
+ { id: 'google-en-us-wavenet-f', name: 'Google WaveNet English (US) Female', language: 'en-US', provider: 'google', gender: 'female' },
38
+ { id: 'google-en-gb-wavenet-a', name: 'Google WaveNet English (UK)', language: 'en-GB', provider: 'google', gender: 'female' },
39
+ { id: 'google-fr-wavenet-a', name: 'Google WaveNet French', language: 'fr', provider: 'google', gender: 'female' },
40
+ { id: 'google-de-wavenet-a', name: 'Google WaveNet German', language: 'de', provider: 'google', gender: 'female' },
41
+ { id: 'google-es-wavenet-a', name: 'Google WaveNet Spanish', language: 'es', provider: 'google', gender: 'female' },
42
+ { id: 'google-ja-wavenet-a', name: 'Google WaveNet Japanese', language: 'ja', provider: 'google', gender: 'female' },
43
+ { id: 'google-hi-wavenet-a', name: 'Google WaveNet Hindi', language: 'hi', provider: 'google', gender: 'female' },
44
+ { id: 'google-ar-wavenet-a', name: 'Google WaveNet Arabic', language: 'ar', provider: 'google', gender: 'female' },
45
+
46
+ // ElevenLabs voices
47
+ { id: 'elevenlabs-rachel', name: 'Rachel', language: 'en', provider: 'elevenlabs', gender: 'female' },
48
+ { id: 'elevenlabs-adam', name: 'Adam', language: 'en', provider: 'elevenlabs', gender: 'male' },
49
+ { id: 'elevenlabs-antoni', name: 'Antoni', language: 'en', provider: 'elevenlabs', gender: 'male' },
50
+ { id: 'elevenlabs-bella', name: 'Bella', language: 'es', provider: 'elevenlabs', gender: 'female' },
51
+ { id: 'elevenlabs-elli', name: 'Elli', language: 'de', provider: 'elevenlabs', gender: 'female' },
52
+
53
+ // OpenAI TTS voices
54
+ { id: 'openai-alloy', name: 'Alloy', language: 'en', provider: 'openai' },
55
+ { id: 'openai-echo', name: 'Echo', language: 'en', provider: 'openai', gender: 'male' },
56
+ { id: 'openai-fable', name: 'Fable', language: 'en', provider: 'openai', gender: 'male' },
57
+ { id: 'openai-onyx', name: 'Onyx', language: 'en', provider: 'openai', gender: 'male' },
58
+ { id: 'openai-nova', name: 'Nova', language: 'en', provider: 'openai', gender: 'female' },
59
+ { id: 'openai-shimmer', name: 'Shimmer', language: 'en', provider: 'openai', gender: 'female' },
60
+ ]
61
+
62
+ /**
63
+ * Get all voices that match a given language code.
64
+ * Matches both exact codes and prefix codes (e.g., 'en' matches 'en', 'en-US', 'en-GB').
65
+ */
66
+ export function getVoicesForLanguage(langCode: string): VoiceOption[] {
67
+ return VOICES.filter((v) => v.language === langCode || v.language.startsWith(langCode + '-') || langCode.startsWith(v.language + '-') || v.language.split('-')[0] === langCode)
68
+ }
69
+
70
+ /**
71
+ * Get the best voice for a language, optionally preferring a specific provider.
72
+ * Returns undefined if no voice matches.
73
+ */
74
+ export function getBestVoice(langCode: string, preferredProvider?: VoiceOption['provider']): VoiceOption | undefined {
75
+ const voices = getVoicesForLanguage(langCode)
76
+ if (voices.length === 0) return undefined
77
+
78
+ if (preferredProvider) {
79
+ const preferred = voices.find((v) => v.provider === preferredProvider)
80
+ if (preferred) return preferred
81
+ }
82
+
83
+ // Return first available voice
84
+ return voices[0]
85
+ }
86
+
87
+ /**
88
+ * Get all voices from a specific provider.
89
+ */
90
+ export function getVoicesByProvider(provider: VoiceOption['provider']): VoiceOption[] {
91
+ return VOICES.filter((v) => v.provider === provider)
92
+ }
package/dist/index.ts CHANGED
@@ -64,6 +64,7 @@ export * from './astro-link-card/index.ts';
64
64
  export * from './astro-live-captions/index.ts';
65
65
  export * from './astro-live-cursors/index.ts';
66
66
  export * from './astro-live-transcript/index.ts';
67
+ export * from './astro-location-selector/index.ts';
67
68
  export * from './astro-logger/index.ts';
68
69
  export * from './astro-markdown-renderer/index.ts';
69
70
  export * from './astro-marquee-strip/index.ts';
@@ -0,0 +1 @@
1
+ export * from './location-selector.ts'
@@ -0,0 +1,60 @@
1
+ import { COUNTRIES, LANGUAGES, detectCountry, detectLanguage } from '../i18n/index.ts'
2
+ import { generateId } from '../shared/index.ts'
3
+
4
+ export interface LocationSelectorState {
5
+ country: string
6
+ language: string
7
+ }
8
+
9
+ export interface LocationSelectorOptions {
10
+ defaultCountry?: string
11
+ defaultLanguage?: string
12
+ onCountryChange?: (country: string) => void
13
+ onLanguageChange?: (language: string) => void
14
+ }
15
+
16
+ export function createLocationSelector(options: LocationSelectorOptions = {}) {
17
+ const {
18
+ defaultCountry = 'US',
19
+ defaultLanguage = 'en',
20
+ onCountryChange,
21
+ onLanguageChange,
22
+ } = options
23
+
24
+ let state: LocationSelectorState = {
25
+ country: defaultCountry,
26
+ language: defaultLanguage,
27
+ }
28
+
29
+ const countryId = generateId('location-sel-country')
30
+ const languageId = generateId('location-sel-language')
31
+
32
+ function setCountry(country: string) {
33
+ state.country = country
34
+ onCountryChange?.(country)
35
+ }
36
+
37
+ function setLanguage(language: string) {
38
+ state.language = language
39
+ onLanguageChange?.(language)
40
+ }
41
+
42
+ const countries = Array.isArray(COUNTRIES) ? COUNTRIES : Object.values(COUNTRIES)
43
+ const languages = Array.isArray(LANGUAGES) ? LANGUAGES : Object.values(LANGUAGES)
44
+
45
+ return {
46
+ state,
47
+ setCountry,
48
+ setLanguage,
49
+ countries,
50
+ languages,
51
+ countryProps: {
52
+ id: countryId,
53
+ name: 'country',
54
+ },
55
+ languageProps: {
56
+ id: languageId,
57
+ name: 'language',
58
+ },
59
+ }
60
+ }
@@ -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 Math.random() < sampleRate
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: Date.now(),
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 = Date.now()
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 = Date.now()
176
+ const endTime = now()
174
177
  const merged = redact(
175
178
  { ...boundContext, ...attributes, ...opts?.attributes },
176
179
  redactKeys,
@@ -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. */
@@ -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: Partial<AccessibilityProps> & Record<string, unknown>
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: Partial<AccessibilityProps> & Record<string, unknown> = {
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: Partial<AccessibilityProps> & Record<string, unknown>
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: Partial<AccessibilityProps> & Record<string, unknown> = {
55
+ const ariaProps: Record<string, string | number | boolean> = {
57
56
  role: 'group',
58
57
  'aria-label': 'Reactions',
59
58
  id: containerId,