@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.
- 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/astro-location-selector/LocationSelector.astro +56 -0
- package/dist/astro-location-selector/LocationSelector.stories.ts +15 -0
- package/dist/astro-location-selector/index.ts +1 -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/command-input/command-input.ts +5 -5
- 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/i18n/countries.ts +122 -0
- package/dist/i18n/detect-region.ts +243 -0
- package/dist/i18n/index.ts +47 -0
- package/dist/i18n/languages.ts +181 -0
- package/dist/i18n/locale-utils.ts +123 -0
- package/dist/i18n/voice-registry.ts +92 -0
- package/dist/index.ts +1 -0
- package/dist/location-selector/index.ts +1 -0
- package/dist/location-selector/location-selector.ts +60 -0
- 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 +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
|
|
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,
|