@exxatdesignux/ui 0.0.5

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 (59) hide show
  1. package/package.json +72 -0
  2. package/src/components/ui/avatar.tsx +384 -0
  3. package/src/components/ui/badge.tsx +49 -0
  4. package/src/components/ui/banner.tsx +364 -0
  5. package/src/components/ui/breadcrumb.tsx +120 -0
  6. package/src/components/ui/button.tsx +66 -0
  7. package/src/components/ui/calendar.tsx +220 -0
  8. package/src/components/ui/card.tsx +136 -0
  9. package/src/components/ui/chart.tsx +378 -0
  10. package/src/components/ui/checkbox.tsx +160 -0
  11. package/src/components/ui/coach-mark.tsx +361 -0
  12. package/src/components/ui/collapsible.tsx +33 -0
  13. package/src/components/ui/command.tsx +232 -0
  14. package/src/components/ui/date-picker-field.tsx +186 -0
  15. package/src/components/ui/dialog.tsx +171 -0
  16. package/src/components/ui/drag-handle-grip.tsx +10 -0
  17. package/src/components/ui/drawer.tsx +134 -0
  18. package/src/components/ui/dropdown-menu.tsx +422 -0
  19. package/src/components/ui/field.tsx +238 -0
  20. package/src/components/ui/form.tsx +137 -0
  21. package/src/components/ui/input-group.tsx +156 -0
  22. package/src/components/ui/input-mask.tsx +135 -0
  23. package/src/components/ui/input.tsx +22 -0
  24. package/src/components/ui/kbd.tsx +55 -0
  25. package/src/components/ui/label.tsx +25 -0
  26. package/src/components/ui/payment-card-fields.tsx +65 -0
  27. package/src/components/ui/popover.tsx +46 -0
  28. package/src/components/ui/radio-group.tsx +217 -0
  29. package/src/components/ui/select.tsx +191 -0
  30. package/src/components/ui/selection-tile-grid.tsx +246 -0
  31. package/src/components/ui/separator.tsx +28 -0
  32. package/src/components/ui/sheet.tsx +147 -0
  33. package/src/components/ui/sidebar.tsx +716 -0
  34. package/src/components/ui/skeleton.tsx +13 -0
  35. package/src/components/ui/sonner.tsx +39 -0
  36. package/src/components/ui/status-badge.tsx +109 -0
  37. package/src/components/ui/table.tsx +117 -0
  38. package/src/components/ui/tabs.tsx +90 -0
  39. package/src/components/ui/textarea.tsx +18 -0
  40. package/src/components/ui/tip.tsx +21 -0
  41. package/src/components/ui/toggle-group.tsx +89 -0
  42. package/src/components/ui/toggle-switch.tsx +31 -0
  43. package/src/components/ui/toggle.tsx +48 -0
  44. package/src/components/ui/tooltip.tsx +59 -0
  45. package/src/components/ui/view-segmented-control.tsx +160 -0
  46. package/src/globals.css +1795 -0
  47. package/src/hooks/.gitkeep +0 -0
  48. package/src/hooks/use-app-theme.ts +172 -0
  49. package/src/hooks/use-coach-mark.ts +342 -0
  50. package/src/hooks/use-mobile.ts +31 -0
  51. package/src/hooks/use-mod-key-label.ts +29 -0
  52. package/src/index.ts +55 -0
  53. package/src/lib/compose-refs.ts +15 -0
  54. package/src/lib/date-filter.ts +67 -0
  55. package/src/lib/utils.ts +6 -0
  56. package/src/theme/apply-windows-contrast-theme.ts +29 -0
  57. package/src/theme/windows-contrast-theme.json +147 -0
  58. package/src/theme.css +1130 -0
  59. package/src/types/react-payment-inputs.d.ts +20 -0
File without changes
@@ -0,0 +1,172 @@
1
+ "use client"
2
+
3
+ /**
4
+ * useAppTheme — Manages theme dimensions beyond light/dark:
5
+ * • Brand : "one" (Lavender) | "prism" (Rose)
6
+ * • Contrast : "system" | "normal" | "high" | "windows" (JSON-driven palette)
7
+ * • Text size : "compact" | "default" | "large" — root rem scale (industry pattern:
8
+ * iOS “Larger Text”, Android font scale, Gmail/Slack density). Default keeps
9
+ * 16px root; compact/large adjust modestly with xs clamped to 11px in CSS.
10
+ *
11
+ * Persists to localStorage; applies class / data-* on <html>.
12
+ * Use alongside `useTheme()` from next-themes for light / dark / system.
13
+ */
14
+
15
+ import { useCallback, useEffect, useState } from "react"
16
+
17
+ import {
18
+ applyWindowsContrastTheme,
19
+ clearWindowsContrastTheme,
20
+ } from "../theme/apply-windows-contrast-theme"
21
+
22
+ export type Brand = "one" | "prism"
23
+
24
+ const CONTRAST_PREFS = ["system", "normal", "high", "windows"] as const
25
+
26
+ /** What the user explicitly picks (or "system" to follow OS). */
27
+ export type ContrastPreference = (typeof CONTRAST_PREFS)[number]
28
+
29
+ /** The resolved mode actually applied to the DOM. */
30
+ export type ContrastMode = "normal" | "high" | "windows"
31
+
32
+ /** UI text scale at the document root (rem-based UI tracks together). */
33
+ export type TextSizePreference = "compact" | "default" | "large"
34
+
35
+ const BRAND_KEY = "exxat-brand"
36
+ const CONTRAST_KEY = "exxat-contrast"
37
+ const TEXT_SIZE_KEY = "exxat-text-size"
38
+
39
+ const MQ = "(prefers-contrast: more)"
40
+
41
+ function getOsContrast(): ContrastMode {
42
+ if (typeof window === "undefined") return "normal"
43
+ return window.matchMedia(MQ).matches ? "high" : "normal"
44
+ }
45
+
46
+ function resolveContrast(pref: ContrastPreference): ContrastMode {
47
+ if (pref === "system") return getOsContrast()
48
+ return pref
49
+ }
50
+
51
+ function normalizeContrastPref(raw: string | null): ContrastPreference {
52
+ if (raw && (CONTRAST_PREFS as readonly string[]).includes(raw)) {
53
+ return raw as ContrastPreference
54
+ }
55
+ return "system"
56
+ }
57
+
58
+ function applyBrand(brand: Brand) {
59
+ const html = document.documentElement
60
+ html.classList.remove("theme-one", "theme-prism")
61
+ html.classList.add(`theme-${brand}`)
62
+ }
63
+
64
+ function applyContrast(mode: ContrastMode) {
65
+ if (mode === "windows") {
66
+ document.documentElement.setAttribute("data-contrast", "windows")
67
+ applyWindowsContrastTheme()
68
+ return
69
+ }
70
+ clearWindowsContrastTheme()
71
+ document.documentElement.setAttribute(
72
+ "data-contrast",
73
+ mode === "high" ? "high" : "off",
74
+ )
75
+ }
76
+
77
+ function applyTextSize(pref: TextSizePreference) {
78
+ const html = document.documentElement
79
+ if (pref === "default") {
80
+ html.removeAttribute("data-text-size")
81
+ } else {
82
+ html.setAttribute("data-text-size", pref)
83
+ }
84
+ }
85
+
86
+ export function useAppTheme() {
87
+ const [brand, setBrandState] = useState<Brand>("one")
88
+ const [contrastPref, setContrastPrefState] = useState<ContrastPreference>("system")
89
+ const [resolvedContrast, setResolvedContrast] = useState<ContrastMode>("normal")
90
+ const [textSizePref, setTextSizePrefState] = useState<TextSizePreference>("default")
91
+ const [mounted, setMounted] = useState(false)
92
+
93
+ /* Hydrate from localStorage on first client render */
94
+ useEffect(() => {
95
+ setMounted(true)
96
+ const storedBrand = (localStorage.getItem(BRAND_KEY) as Brand) ?? "one"
97
+ const storedPref = normalizeContrastPref(localStorage.getItem(CONTRAST_KEY))
98
+ const storedText =
99
+ (localStorage.getItem(TEXT_SIZE_KEY) as TextSizePreference | null) ?? "default"
100
+
101
+ setBrandState(storedBrand)
102
+ setContrastPrefState(storedPref)
103
+ setTextSizePrefState(storedText)
104
+ applyBrand(storedBrand)
105
+
106
+ const resolved = resolveContrast(storedPref)
107
+ setResolvedContrast(resolved)
108
+ applyContrast(resolved)
109
+ applyTextSize(storedText)
110
+ }, [])
111
+
112
+ /* Listen for OS contrast changes when preference is "system" */
113
+ useEffect(() => {
114
+ if (contrastPref !== "system") return
115
+
116
+ const mql = window.matchMedia(MQ)
117
+ function onChange() {
118
+ const resolved = getOsContrast()
119
+ setResolvedContrast(resolved)
120
+ applyContrast(resolved)
121
+ }
122
+ mql.addEventListener("change", onChange)
123
+ return () => mql.removeEventListener("change", onChange)
124
+ }, [contrastPref])
125
+
126
+ /* Re-apply Windows JSON palette when light/dark class toggles (next-themes). */
127
+ useEffect(() => {
128
+ if (resolvedContrast !== "windows") return
129
+ applyWindowsContrastTheme()
130
+ const html = document.documentElement
131
+ const obs = new MutationObserver(() => {
132
+ applyWindowsContrastTheme()
133
+ })
134
+ obs.observe(html, { attributes: true, attributeFilter: ["class"] })
135
+ return () => obs.disconnect()
136
+ }, [resolvedContrast])
137
+
138
+ const setBrand = useCallback((b: Brand) => {
139
+ setBrandState(b)
140
+ localStorage.setItem(BRAND_KEY, b)
141
+ applyBrand(b)
142
+ }, [])
143
+
144
+ const setContrast = useCallback((pref: ContrastPreference) => {
145
+ setContrastPrefState(pref)
146
+ localStorage.setItem(CONTRAST_KEY, pref)
147
+ const resolved = resolveContrast(pref)
148
+ setResolvedContrast(resolved)
149
+ applyContrast(resolved)
150
+ }, [])
151
+
152
+ const setTextSize = useCallback((pref: TextSizePreference) => {
153
+ setTextSizePrefState(pref)
154
+ localStorage.setItem(TEXT_SIZE_KEY, pref)
155
+ applyTextSize(pref)
156
+ }, [])
157
+
158
+ return {
159
+ brand,
160
+ setBrand,
161
+ /** The user's preference: "system" | "normal" | "high" | "windows" */
162
+ contrastPref,
163
+ /** The resolved contrast mode actually applied to the DOM. */
164
+ contrast: resolvedContrast,
165
+ /** Set the contrast preference. */
166
+ setContrast,
167
+ /** Text scale: "compact" | "default" | "large" */
168
+ textSizePref,
169
+ setTextSize,
170
+ mounted,
171
+ }
172
+ }
@@ -0,0 +1,342 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ /* ═══════════════════════════════════════════════════════════════════════════
6
+ useCoachMark — Flow & single-step coach-mark state manager
7
+ ═══════════════════════════════════════════════════════════════════════════
8
+ Handles:
9
+ • Multi-step flows (next / prev / skip / complete)
10
+ • Single coach marks (show / dismiss)
11
+ • Per-step CSS selector targeting — scrolls element into view
12
+ • localStorage persistence so dismissed marks don't reappear
13
+ • Delay before first show (avoids layout flash)
14
+ ═══════════════════════════════════════════════════════════════════════════ */
15
+
16
+ const STORAGE_PREFIX = "exxat-coach-mark:"
17
+
18
+ export interface CoachMarkStep {
19
+ /** Unique key — also used for localStorage persistence */
20
+ id: string
21
+ /** CSS selector for the target element this step attaches to */
22
+ target: string
23
+ /** Popover placement side for this step */
24
+ side?: "top" | "bottom" | "left" | "right"
25
+ /** Popover alignment for this step */
26
+ align?: "start" | "center" | "end"
27
+ /** Title shown in the coach mark */
28
+ title: string
29
+ /** Body text / description */
30
+ description: string
31
+ /** Optional image URL shown above the content */
32
+ image?: string
33
+ /** Image alt text (required when image is provided) */
34
+ imageAlt?: string
35
+ }
36
+
37
+ /** Fired on `window` when any coach flow completes (skip or last step). `detail.flowId` is the completed flow. */
38
+ export const COACH_MARK_FLOW_COMPLETED_EVENT = "exxat-coach-mark-flow-completed" as const
39
+
40
+ export interface UseCoachMarkOptions {
41
+ /** Unique ID for the entire flow (used as localStorage key) */
42
+ flowId: string
43
+ /** Steps in order — single-item array for a standalone coach mark */
44
+ steps: CoachMarkStep[]
45
+ /** Delay in ms before the coach mark appears (default 500) */
46
+ delay?: number
47
+ /** Called when the entire flow is completed or skipped */
48
+ onComplete?: () => void
49
+ /** If true, always show even if previously dismissed (dev mode) */
50
+ force?: boolean
51
+ /**
52
+ * When false, the auto-open timer does not run (e.g. until the user switches to a view where the target exists).
53
+ * Default true.
54
+ */
55
+ enabled?: boolean
56
+ /**
57
+ * If set, auto-open only runs after this flow id is dismissed (localStorage) or completes (same-tab via
58
+ * `COACH_MARK_FLOW_COMPLETED_EVENT`). Used to run a follow-up tour after another flow finishes.
59
+ */
60
+ dependsOnDismissedFlowId?: string
61
+ }
62
+
63
+ export interface CoachMarkState {
64
+ /** Whether the coach mark is currently visible */
65
+ isOpen: boolean
66
+ /** Current step index (0-based) */
67
+ currentStep: number
68
+ /** Total number of steps */
69
+ totalSteps: number
70
+ /** The current step data */
71
+ step: CoachMarkStep | null
72
+ /** The resolved target element for the current step */
73
+ targetEl: HTMLElement | null
74
+ /** Virtual anchor rect for Radix positioning */
75
+ anchorRect: { x: number; y: number; width: number; height: number } | null
76
+ /** Whether this is a multi-step flow */
77
+ isFlow: boolean
78
+ /** Whether we're on the first step */
79
+ isFirst: boolean
80
+ /** Whether we're on the last step */
81
+ isLast: boolean
82
+ /** Advance to the next step (or complete if last) */
83
+ next: () => void
84
+ /** Go back to the previous step */
85
+ prev: () => void
86
+ /** Skip/dismiss the entire flow */
87
+ skip: () => void
88
+ /** Programmatically open the coach mark */
89
+ open: () => void
90
+ /** Reset the flow (clears persistence and starts over) */
91
+ reset: () => void
92
+ }
93
+
94
+ function isDismissed(flowId: string): boolean {
95
+ if (typeof window === "undefined") return false
96
+ try {
97
+ return localStorage.getItem(`${STORAGE_PREFIX}${flowId}`) === "dismissed"
98
+ } catch {
99
+ return false
100
+ }
101
+ }
102
+
103
+ function setDismissed(flowId: string) {
104
+ if (typeof window === "undefined") return
105
+ try {
106
+ localStorage.setItem(`${STORAGE_PREFIX}${flowId}`, "dismissed")
107
+ } catch {
108
+ /* storage full or blocked — silently ignore */
109
+ }
110
+ }
111
+
112
+ function clearDismissed(flowId: string) {
113
+ if (typeof window === "undefined") return
114
+ try {
115
+ localStorage.removeItem(`${STORAGE_PREFIX}${flowId}`)
116
+ } catch {
117
+ /* ignore */
118
+ }
119
+ }
120
+
121
+ /** Exported for the Settings page — list all coach mark keys in localStorage */
122
+ export function getAllCoachMarkKeys(): string[] {
123
+ if (typeof window === "undefined") return []
124
+ const keys: string[] = []
125
+ try {
126
+ for (let i = 0; i < localStorage.length; i++) {
127
+ const key = localStorage.key(i)
128
+ if (key?.startsWith(STORAGE_PREFIX)) {
129
+ keys.push(key.replace(STORAGE_PREFIX, ""))
130
+ }
131
+ }
132
+ } catch { /* ignore */ }
133
+ return keys
134
+ }
135
+
136
+ /** Exported for the Settings page — reset a specific flow */
137
+ export function resetCoachMarkFlow(flowId: string) {
138
+ clearDismissed(flowId)
139
+ }
140
+
141
+ /** Exported for the Settings page — reset ALL coach marks */
142
+ export function resetAllCoachMarks() {
143
+ if (typeof window === "undefined") return
144
+ try {
145
+ const toRemove: string[] = []
146
+ for (let i = 0; i < localStorage.length; i++) {
147
+ const key = localStorage.key(i)
148
+ if (key?.startsWith(STORAGE_PREFIX)) toRemove.push(key)
149
+ }
150
+ toRemove.forEach((k) => localStorage.removeItem(k))
151
+ } catch { /* ignore */ }
152
+ }
153
+
154
+ export function useCoachMark({
155
+ flowId,
156
+ steps,
157
+ delay = 500,
158
+ onComplete,
159
+ force = false,
160
+ enabled = true,
161
+ dependsOnDismissedFlowId,
162
+ }: UseCoachMarkOptions): CoachMarkState {
163
+ const [isOpen, setIsOpen] = React.useState(false)
164
+ const [currentStep, setCurrentStep] = React.useState(0)
165
+ const [targetEl, setTargetEl] = React.useState<HTMLElement | null>(null)
166
+ const [anchorRect, setAnchorRect] = React.useState<{
167
+ x: number; y: number; width: number; height: number
168
+ } | null>(null)
169
+
170
+ const [prereqMet, setPrereqMet] = React.useState(() => {
171
+ if (!dependsOnDismissedFlowId) return true
172
+ return isDismissed(dependsOnDismissedFlowId)
173
+ })
174
+
175
+ React.useEffect(() => {
176
+ if (!dependsOnDismissedFlowId) {
177
+ setPrereqMet(true)
178
+ return
179
+ }
180
+ setPrereqMet(isDismissed(dependsOnDismissedFlowId))
181
+ }, [dependsOnDismissedFlowId])
182
+
183
+ React.useEffect(() => {
184
+ if (!dependsOnDismissedFlowId) return
185
+ const handler = (e: Event) => {
186
+ const d = (e as CustomEvent<{ flowId?: string }>).detail
187
+ if (d?.flowId === dependsOnDismissedFlowId) setPrereqMet(true)
188
+ }
189
+ window.addEventListener(COACH_MARK_FLOW_COMPLETED_EVENT, handler as EventListener)
190
+ return () => window.removeEventListener(COACH_MARK_FLOW_COMPLETED_EVENT, handler as EventListener)
191
+ }, [dependsOnDismissedFlowId])
192
+
193
+ const totalSteps = steps.length
194
+ const isFlow = totalSteps > 1
195
+ const step = steps[currentStep] ?? null
196
+ const isFirst = currentStep === 0
197
+ const isLast = currentStep === totalSteps - 1
198
+
199
+ /* Auto-show after delay (unless previously dismissed) */
200
+ React.useEffect(() => {
201
+ if (enabled === false) return
202
+ if (dependsOnDismissedFlowId && !prereqMet) return
203
+ if (!force && isDismissed(flowId)) return
204
+ const timer = setTimeout(() => setIsOpen(true), delay)
205
+ return () => clearTimeout(timer)
206
+ }, [flowId, delay, force, enabled, dependsOnDismissedFlowId, prereqMet])
207
+
208
+ /* Resolve target element + scroll into view when step changes.
209
+ Retries: toolbar controls (e.g. last tour step on “Properties”) often mount after layout;
210
+ a single query was easy to miss → no anchorRect → coach UI vanished on that step. */
211
+ React.useEffect(() => {
212
+ if (!isOpen || !step?.target) {
213
+ return
214
+ }
215
+
216
+ let cancelled = false
217
+ const timeouts: ReturnType<typeof setTimeout>[] = []
218
+ const schedule = (fn: () => void, ms: number) => {
219
+ const id = setTimeout(fn, ms)
220
+ timeouts.push(id)
221
+ return id
222
+ }
223
+
224
+ setAnchorRect(null)
225
+ setTargetEl(null)
226
+
227
+ let attempts = 0
228
+ const maxAttempts = 30
229
+ const intervalMs = 100
230
+
231
+ const tryResolve = () => {
232
+ if (cancelled) return
233
+ const el = document.querySelector<HTMLElement>(step.target)
234
+ if (!el) {
235
+ attempts += 1
236
+ if (attempts < maxAttempts) schedule(tryResolve, intervalMs)
237
+ return
238
+ }
239
+
240
+ el.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" })
241
+
242
+ schedule(() => {
243
+ if (cancelled) return
244
+ const rect = el.getBoundingClientRect()
245
+ setAnchorRect({
246
+ x: rect.left,
247
+ y: rect.top,
248
+ width: rect.width,
249
+ height: rect.height,
250
+ })
251
+ setTargetEl(el)
252
+ }, 350)
253
+ }
254
+
255
+ schedule(tryResolve, 100)
256
+
257
+ return () => {
258
+ cancelled = true
259
+ timeouts.forEach(clearTimeout)
260
+ }
261
+ }, [isOpen, step?.target, currentStep])
262
+
263
+ /* Re-measure on scroll/resize while open */
264
+ React.useEffect(() => {
265
+ if (!isOpen || !targetEl) return
266
+
267
+ const measure = () => {
268
+ if (!targetEl.isConnected) return
269
+ const rect = targetEl.getBoundingClientRect()
270
+ setAnchorRect({
271
+ x: rect.left,
272
+ y: rect.top,
273
+ width: rect.width,
274
+ height: rect.height,
275
+ })
276
+ }
277
+
278
+ window.addEventListener("scroll", measure, { passive: true, capture: true })
279
+ window.addEventListener("resize", measure, { passive: true })
280
+ return () => {
281
+ window.removeEventListener("scroll", measure, true)
282
+ window.removeEventListener("resize", measure)
283
+ }
284
+ }, [isOpen, targetEl])
285
+
286
+ const complete = React.useCallback(() => {
287
+ setIsOpen(false)
288
+ setTargetEl(null)
289
+ setAnchorRect(null)
290
+ setDismissed(flowId)
291
+ if (typeof window !== "undefined") {
292
+ window.dispatchEvent(
293
+ new CustomEvent(COACH_MARK_FLOW_COMPLETED_EVENT, { detail: { flowId } }),
294
+ )
295
+ }
296
+ onComplete?.()
297
+ }, [flowId, onComplete])
298
+
299
+ const next = React.useCallback(() => {
300
+ if (isLast) {
301
+ complete()
302
+ } else {
303
+ setCurrentStep((s) => s + 1)
304
+ }
305
+ }, [isLast, complete])
306
+
307
+ const prev = React.useCallback(() => {
308
+ setCurrentStep((s) => Math.max(0, s - 1))
309
+ }, [])
310
+
311
+ const skip = React.useCallback(() => {
312
+ complete()
313
+ }, [complete])
314
+
315
+ const open = React.useCallback(() => {
316
+ setCurrentStep(0)
317
+ setIsOpen(true)
318
+ }, [])
319
+
320
+ const reset = React.useCallback(() => {
321
+ clearDismissed(flowId)
322
+ setCurrentStep(0)
323
+ setIsOpen(true)
324
+ }, [flowId])
325
+
326
+ return {
327
+ isOpen,
328
+ currentStep,
329
+ totalSteps,
330
+ step,
331
+ targetEl,
332
+ anchorRect,
333
+ isFlow,
334
+ isFirst,
335
+ isLast,
336
+ next,
337
+ prev,
338
+ skip,
339
+ open,
340
+ reset,
341
+ }
342
+ }
@@ -0,0 +1,31 @@
1
+ import * as React from "react"
2
+
3
+ const MOBILE_BREAKPOINT = 768
4
+ /** Matches Tailwind `max-md` / desktop branch `md:` (min-width: 768px). */
5
+ const MOBILE_MQ = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`
6
+
7
+ export function useIsMobile() {
8
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
9
+
10
+ React.useLayoutEffect(() => {
11
+ const read = () => {
12
+ setIsMobile(window.matchMedia(MOBILE_MQ).matches)
13
+ }
14
+
15
+ const mql = window.matchMedia(MOBILE_MQ)
16
+ mql.addEventListener("change", read)
17
+ window.addEventListener("resize", read)
18
+ const vv = window.visualViewport
19
+ vv?.addEventListener("resize", read)
20
+
21
+ read()
22
+
23
+ return () => {
24
+ mql.removeEventListener("change", read)
25
+ window.removeEventListener("resize", read)
26
+ vv?.removeEventListener("resize", read)
27
+ }
28
+ }, [])
29
+
30
+ return !!isMobile
31
+ }
@@ -0,0 +1,29 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ /** "⌘" on Apple platforms, "Ctrl" elsewhere — for `Kbd` tooltips. */
6
+ export function useModKeyLabel() {
7
+ const [mod, setMod] = React.useState("⌘")
8
+ React.useEffect(() => {
9
+ setMod(
10
+ typeof navigator !== "undefined" && /Mac|iPhone|iPod|iPad/i.test(navigator.platform)
11
+ ? "⌘"
12
+ : "Ctrl",
13
+ )
14
+ }, [])
15
+ return mod
16
+ }
17
+
18
+ /** "⌥" on Apple platforms, "Alt" elsewhere — pair with `useModKeyLabel` for ⌘⌥ / Ctrl+Alt chords. */
19
+ export function useAltKeyLabel() {
20
+ const [alt, setAlt] = React.useState("⌥")
21
+ React.useEffect(() => {
22
+ setAlt(
23
+ typeof navigator !== "undefined" && /Mac|iPhone|iPod|iPad/i.test(navigator.platform)
24
+ ? "⌥"
25
+ : "Alt",
26
+ )
27
+ }, [])
28
+ return alt
29
+ }
package/src/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ // Components
2
+ export * from "./components/ui/avatar"
3
+ export * from "./components/ui/badge"
4
+ export * from "./components/ui/banner"
5
+ export * from "./components/ui/breadcrumb"
6
+ export * from "./components/ui/button"
7
+ export * from "./components/ui/calendar"
8
+ export * from "./components/ui/card"
9
+ export * from "./components/ui/chart"
10
+ export * from "./components/ui/checkbox"
11
+ export * from "./components/ui/coach-mark"
12
+ export * from "./components/ui/collapsible"
13
+ export * from "./components/ui/command"
14
+ export * from "./components/ui/date-picker-field"
15
+ export * from "./components/ui/dialog"
16
+ export * from "./components/ui/drag-handle-grip"
17
+ export * from "./components/ui/drawer"
18
+ export * from "./components/ui/dropdown-menu"
19
+ export * from "./components/ui/field"
20
+ export * from "./components/ui/form"
21
+ export * from "./components/ui/input-group"
22
+ export * from "./components/ui/input"
23
+ export * from "./components/ui/input-mask"
24
+ export * from "./components/ui/payment-card-fields"
25
+ export * from "./components/ui/kbd"
26
+ export * from "./components/ui/label"
27
+ export * from "./components/ui/popover"
28
+ export * from "./components/ui/radio-group"
29
+ export * from "./components/ui/select"
30
+ export * from "./components/ui/selection-tile-grid"
31
+ export * from "./components/ui/separator"
32
+ export * from "./components/ui/sheet"
33
+ export * from "./components/ui/sidebar"
34
+ export * from "./components/ui/skeleton"
35
+ export * from "./components/ui/sonner"
36
+ export * from "./components/ui/status-badge"
37
+ export * from "./components/ui/table"
38
+ export * from "./components/ui/tabs"
39
+ export * from "./components/ui/textarea"
40
+ export * from "./components/ui/tip"
41
+ export * from "./components/ui/toggle-group"
42
+ export * from "./components/ui/toggle-switch"
43
+ export * from "./components/ui/toggle"
44
+ export * from "./components/ui/tooltip"
45
+ export * from "./components/ui/view-segmented-control"
46
+
47
+ // Hooks
48
+ export * from "./hooks/use-app-theme"
49
+ export * from "./hooks/use-coach-mark"
50
+ export * from "./hooks/use-mobile"
51
+ export * from "./hooks/use-mod-key-label"
52
+
53
+ // Utilities
54
+ export * from "./lib/utils"
55
+ export * from "./lib/date-filter"
@@ -0,0 +1,15 @@
1
+ import * as React from "react"
2
+
3
+ /** Merges multiple refs (callback or object) for the same DOM node — e.g. RHF `field.ref` + mask ref. */
4
+ export function composeRefs<T>(...refs: (React.Ref<T> | undefined)[]): React.RefCallback<T> {
5
+ return node => {
6
+ for (const ref of refs) {
7
+ if (ref == null) continue
8
+ if (typeof ref === "function") {
9
+ ref(node)
10
+ } else {
11
+ ;(ref as React.MutableRefObject<T | null>).current = node
12
+ }
13
+ }
14
+ }
15
+ }