@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.
- package/package.json +72 -0
- package/src/components/ui/avatar.tsx +384 -0
- package/src/components/ui/badge.tsx +49 -0
- package/src/components/ui/banner.tsx +364 -0
- package/src/components/ui/breadcrumb.tsx +120 -0
- package/src/components/ui/button.tsx +66 -0
- package/src/components/ui/calendar.tsx +220 -0
- package/src/components/ui/card.tsx +136 -0
- package/src/components/ui/chart.tsx +378 -0
- package/src/components/ui/checkbox.tsx +160 -0
- package/src/components/ui/coach-mark.tsx +361 -0
- package/src/components/ui/collapsible.tsx +33 -0
- package/src/components/ui/command.tsx +232 -0
- package/src/components/ui/date-picker-field.tsx +186 -0
- package/src/components/ui/dialog.tsx +171 -0
- package/src/components/ui/drag-handle-grip.tsx +10 -0
- package/src/components/ui/drawer.tsx +134 -0
- package/src/components/ui/dropdown-menu.tsx +422 -0
- package/src/components/ui/field.tsx +238 -0
- package/src/components/ui/form.tsx +137 -0
- package/src/components/ui/input-group.tsx +156 -0
- package/src/components/ui/input-mask.tsx +135 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/kbd.tsx +55 -0
- package/src/components/ui/label.tsx +25 -0
- package/src/components/ui/payment-card-fields.tsx +65 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/radio-group.tsx +217 -0
- package/src/components/ui/select.tsx +191 -0
- package/src/components/ui/selection-tile-grid.tsx +246 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +147 -0
- package/src/components/ui/sidebar.tsx +716 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/sonner.tsx +39 -0
- package/src/components/ui/status-badge.tsx +109 -0
- package/src/components/ui/table.tsx +117 -0
- package/src/components/ui/tabs.tsx +90 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tip.tsx +21 -0
- package/src/components/ui/toggle-group.tsx +89 -0
- package/src/components/ui/toggle-switch.tsx +31 -0
- package/src/components/ui/toggle.tsx +48 -0
- package/src/components/ui/tooltip.tsx +59 -0
- package/src/components/ui/view-segmented-control.tsx +160 -0
- package/src/globals.css +1795 -0
- package/src/hooks/.gitkeep +0 -0
- package/src/hooks/use-app-theme.ts +172 -0
- package/src/hooks/use-coach-mark.ts +342 -0
- package/src/hooks/use-mobile.ts +31 -0
- package/src/hooks/use-mod-key-label.ts +29 -0
- package/src/index.ts +55 -0
- package/src/lib/compose-refs.ts +15 -0
- package/src/lib/date-filter.ts +67 -0
- package/src/lib/utils.ts +6 -0
- package/src/theme/apply-windows-contrast-theme.ts +29 -0
- package/src/theme/windows-contrast-theme.json +147 -0
- package/src/theme.css +1130 -0
- 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
|
+
}
|