@uniweb/kit 0.1.10 → 0.2.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.1.10",
3
+ "version": "0.2.0",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "tailwind-merge": "^2.6.0",
40
- "@uniweb/core": "0.1.15"
40
+ "@uniweb/core": "0.2.0"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "react": "^18.0.0 || ^19.0.0",
@@ -170,8 +170,10 @@ export function Link({
170
170
  // Normalize href
171
171
  let linkHref = href || to || ''
172
172
 
173
- // Handle topic: protocol (internal reference)
174
- if (linkHref.startsWith('topic:')) {
173
+ // Handle internal reference protocols
174
+ // - topic: legacy internal reference
175
+ // - page: stable page reference (page:pageId#sectionId)
176
+ if (linkHref.startsWith('topic:') || linkHref.startsWith('page:')) {
175
177
  linkHref = makeHref(linkHref)
176
178
  }
177
179
 
@@ -1,8 +1,19 @@
1
1
  export { useWebsite, default } from './useWebsite.js'
2
2
  export { useRouting } from './useRouting.js'
3
3
  export { useActiveRoute } from './useActiveRoute.js'
4
+ export { useVersion } from './useVersion.js'
4
5
  export { useScrolled } from './useScrolled.js'
5
6
  export { useMobileMenu } from './useMobileMenu.js'
6
7
  export { useAccordion } from './useAccordion.js'
7
8
  export { useGridLayout, getGridClasses } from './useGridLayout.js'
8
9
  export { useTheme, getThemeClasses, THEMES, THEME_NAMES } from './useTheme.js'
10
+ export { useInView, useIsInView } from './useInView.js'
11
+
12
+ // Theme data hooks (runtime theme access)
13
+ export {
14
+ useThemeData,
15
+ useColorContext,
16
+ useAppearance,
17
+ useThemeColor,
18
+ useThemeColorVar
19
+ } from './useThemeData.js'
@@ -0,0 +1,200 @@
1
+ /**
2
+ * useInView Hook
3
+ *
4
+ * Detects when an element enters or leaves the viewport using IntersectionObserver.
5
+ * Useful for lazy loading, animations on scroll, and infinite scroll.
6
+ *
7
+ * @example
8
+ * // Basic usage - trigger animation when element enters viewport
9
+ * function AnimatedSection() {
10
+ * const { ref, inView } = useInView()
11
+ * return (
12
+ * <div ref={ref} className={inView ? 'animate-fade-in' : 'opacity-0'}>
13
+ * Content appears when scrolled into view
14
+ * </div>
15
+ * )
16
+ * }
17
+ *
18
+ * @example
19
+ * // Lazy load image when near viewport
20
+ * function LazyImage({ src, alt }) {
21
+ * const { ref, inView } = useInView({
22
+ * triggerOnce: true,
23
+ * rootMargin: '200px', // Load 200px before entering viewport
24
+ * })
25
+ * return (
26
+ * <div ref={ref}>
27
+ * {inView ? <img src={src} alt={alt} /> : <div className="placeholder" />}
28
+ * </div>
29
+ * )
30
+ * }
31
+ *
32
+ * @example
33
+ * // Threshold for partial visibility
34
+ * const { ref, inView } = useInView({
35
+ * threshold: 0.5, // Trigger when 50% visible
36
+ * })
37
+ */
38
+
39
+ import { useState, useEffect, useRef, useCallback } from 'react'
40
+
41
+ /**
42
+ * Default options for IntersectionObserver
43
+ */
44
+ const DEFAULT_OPTIONS = {
45
+ threshold: 0,
46
+ rootMargin: '0px',
47
+ triggerOnce: false,
48
+ root: null,
49
+ }
50
+
51
+ /**
52
+ * Hook to detect when an element enters/leaves the viewport.
53
+ *
54
+ * @param {Object} options - Configuration options
55
+ * @param {number|number[]} options.threshold - Visibility ratio(s) to trigger (0-1). Default: 0
56
+ * @param {string} options.rootMargin - Margin around root. Default: '0px'
57
+ * @param {boolean} options.triggerOnce - Only trigger once, then stop observing. Default: false
58
+ * @param {Element|null} options.root - Scroll container (null = viewport). Default: null
59
+ * @param {boolean} options.initialInView - Initial inView state for SSR. Default: false
60
+ *
61
+ * @returns {Object} { ref, inView, entry }
62
+ * @returns {Function} ref - Callback ref to attach to the target element
63
+ * @returns {boolean} inView - Whether element is currently in viewport
64
+ * @returns {IntersectionObserverEntry|null} entry - Latest IntersectionObserver entry
65
+ */
66
+ export function useInView(options = {}) {
67
+ const {
68
+ threshold = DEFAULT_OPTIONS.threshold,
69
+ rootMargin = DEFAULT_OPTIONS.rootMargin,
70
+ triggerOnce = DEFAULT_OPTIONS.triggerOnce,
71
+ root = DEFAULT_OPTIONS.root,
72
+ initialInView = false,
73
+ } = options
74
+
75
+ const [inView, setInView] = useState(initialInView)
76
+ const [entry, setEntry] = useState(null)
77
+
78
+ // Track if we've already triggered (for triggerOnce)
79
+ const hasTriggered = useRef(false)
80
+
81
+ // Store the observer instance
82
+ const observerRef = useRef(null)
83
+
84
+ // Store the current element
85
+ const elementRef = useRef(null)
86
+
87
+ /**
88
+ * Callback ref that handles element changes
89
+ */
90
+ const ref = useCallback(
91
+ (node) => {
92
+ // Clean up previous observer
93
+ if (observerRef.current) {
94
+ observerRef.current.disconnect()
95
+ observerRef.current = null
96
+ }
97
+
98
+ // Store the new element
99
+ elementRef.current = node
100
+
101
+ // Don't observe if:
102
+ // - No element
103
+ // - SSR (no IntersectionObserver)
104
+ // - Already triggered and triggerOnce is true
105
+ if (!node) return
106
+ if (typeof IntersectionObserver === 'undefined') return
107
+ if (triggerOnce && hasTriggered.current) return
108
+
109
+ // Create new observer
110
+ const observer = new IntersectionObserver(
111
+ ([observerEntry]) => {
112
+ const isIntersecting = observerEntry.isIntersecting
113
+
114
+ setInView(isIntersecting)
115
+ setEntry(observerEntry)
116
+
117
+ // Handle triggerOnce
118
+ if (isIntersecting && triggerOnce) {
119
+ hasTriggered.current = true
120
+ observer.disconnect()
121
+ observerRef.current = null
122
+ }
123
+ },
124
+ {
125
+ threshold,
126
+ rootMargin,
127
+ root,
128
+ }
129
+ )
130
+
131
+ observer.observe(node)
132
+ observerRef.current = observer
133
+ },
134
+ [threshold, rootMargin, root, triggerOnce]
135
+ )
136
+
137
+ // Clean up on unmount
138
+ useEffect(() => {
139
+ return () => {
140
+ if (observerRef.current) {
141
+ observerRef.current.disconnect()
142
+ }
143
+ }
144
+ }, [])
145
+
146
+ return { ref, inView, entry }
147
+ }
148
+
149
+ /**
150
+ * Simpler hook that just returns inView state for a ref'd element.
151
+ * Use when you don't need the entry details.
152
+ *
153
+ * @example
154
+ * const ref = useRef()
155
+ * const inView = useIsInView(ref, { triggerOnce: true })
156
+ *
157
+ * @param {React.RefObject} targetRef - Ref to the target element
158
+ * @param {Object} options - Same options as useInView
159
+ * @returns {boolean} Whether element is in viewport
160
+ */
161
+ export function useIsInView(targetRef, options = {}) {
162
+ const {
163
+ threshold = DEFAULT_OPTIONS.threshold,
164
+ rootMargin = DEFAULT_OPTIONS.rootMargin,
165
+ triggerOnce = DEFAULT_OPTIONS.triggerOnce,
166
+ root = DEFAULT_OPTIONS.root,
167
+ initialInView = false,
168
+ } = options
169
+
170
+ const [inView, setInView] = useState(initialInView)
171
+ const hasTriggered = useRef(false)
172
+
173
+ useEffect(() => {
174
+ const element = targetRef.current
175
+ if (!element) return
176
+ if (typeof IntersectionObserver === 'undefined') return
177
+ if (triggerOnce && hasTriggered.current) return
178
+
179
+ const observer = new IntersectionObserver(
180
+ ([entry]) => {
181
+ const isIntersecting = entry.isIntersecting
182
+ setInView(isIntersecting)
183
+
184
+ if (isIntersecting && triggerOnce) {
185
+ hasTriggered.current = true
186
+ observer.disconnect()
187
+ }
188
+ },
189
+ { threshold, rootMargin, root }
190
+ )
191
+
192
+ observer.observe(element)
193
+
194
+ return () => observer.disconnect()
195
+ }, [targetRef, threshold, rootMargin, root, triggerOnce])
196
+
197
+ return inView
198
+ }
199
+
200
+ export default useInView
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Theme Data Hooks
3
+ *
4
+ * Provides access to runtime theme configuration and appearance settings.
5
+ * These hooks are for accessing theme data from the Uniweb runtime.
6
+ *
7
+ * Note: For component styling classes (light/dark/gray themes), use useTheme() instead.
8
+ *
9
+ * @example
10
+ * function ColorPicker() {
11
+ * const theme = useThemeData()
12
+ * const colors = theme?.getPaletteNames() || []
13
+ * return <div>{colors.join(', ')}</div>
14
+ * }
15
+ *
16
+ * @example
17
+ * function DarkModeToggle() {
18
+ * const { scheme, toggle, canToggle } = useAppearance()
19
+ * if (!canToggle) return null
20
+ * return <button onClick={toggle}>{scheme === 'dark' ? '☀️' : '🌙'}</button>
21
+ * }
22
+ */
23
+
24
+ import { useState, useEffect, useCallback } from 'react'
25
+ import { getUniweb, Theme } from '@uniweb/core'
26
+
27
+ // Storage key for appearance preference
28
+ const APPEARANCE_STORAGE_KEY = 'uniweb-appearance'
29
+
30
+ // CSS class for dark scheme
31
+ const DARK_SCHEME_CLASS = 'scheme-dark'
32
+
33
+ /**
34
+ * Get the initial scheme from storage or system preference
35
+ *
36
+ * @param {Object} appearance - Theme appearance configuration
37
+ * @returns {string} 'light' or 'dark'
38
+ */
39
+ function getInitialScheme(appearance) {
40
+ // Check localStorage first
41
+ if (typeof localStorage !== 'undefined') {
42
+ const stored = localStorage.getItem(APPEARANCE_STORAGE_KEY)
43
+ if (stored === 'light' || stored === 'dark') {
44
+ return stored
45
+ }
46
+ }
47
+
48
+ // Check system preference if respectSystemPreference is enabled
49
+ if (appearance?.respectSystemPreference && typeof window !== 'undefined') {
50
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
51
+ if (prefersDark && appearance.schemes?.includes('dark')) {
52
+ return 'dark'
53
+ }
54
+ }
55
+
56
+ // Fall back to default
57
+ const defaultScheme = appearance?.default || 'light'
58
+ return defaultScheme === 'system' ? 'light' : defaultScheme
59
+ }
60
+
61
+ /**
62
+ * Access the full Theme object from the active website.
63
+ *
64
+ * Returns the Theme instance which provides methods like:
65
+ * - getColor(name, shade) - Get a color value
66
+ * - getPalette(name) - Get all shades for a color
67
+ * - getContextToken(context, token) - Get semantic token
68
+ * - getAppearance() - Get appearance configuration
69
+ *
70
+ * @returns {Theme|null} Theme instance or null if not available
71
+ *
72
+ * @example
73
+ * const theme = useThemeData()
74
+ * const primaryColor = theme?.getColor('primary', 500)
75
+ */
76
+ export function useThemeData() {
77
+ const uniweb = getUniweb()
78
+ const website = uniweb?.activeWebsite
79
+ const themeData = website?.themeData
80
+
81
+ if (!themeData) {
82
+ return null
83
+ }
84
+
85
+ // Return a Theme instance if we have theme data
86
+ // The website may already have a Theme instance, or we create one
87
+ if (website.theme instanceof Theme) {
88
+ return website.theme
89
+ }
90
+
91
+ // Create a new Theme instance from themeData
92
+ return new Theme(themeData)
93
+ }
94
+
95
+ /**
96
+ * Get the current section's color context.
97
+ *
98
+ * Note: This requires the component to receive the block prop.
99
+ * For components that don't have block access, use a default or pass context explicitly.
100
+ *
101
+ * @param {Object} block - Block instance (optional)
102
+ * @returns {string} Context name ('light', 'medium', or 'dark')
103
+ *
104
+ * @example
105
+ * function MyComponent({ block }) {
106
+ * const context = useColorContext(block)
107
+ * return <div className={`context-${context}`}>...</div>
108
+ * }
109
+ */
110
+ export function useColorContext(block) {
111
+ // Get context from block's theme property
112
+ const context = block?.themeName || block?.theme || 'light'
113
+
114
+ // Validate context name
115
+ const validContexts = ['light', 'medium', 'dark']
116
+ if (validContexts.includes(context)) {
117
+ return context
118
+ }
119
+
120
+ return 'light'
121
+ }
122
+
123
+ /**
124
+ * Check and toggle site-wide appearance scheme (light/dark mode).
125
+ *
126
+ * This hook manages the site's color scheme preference and provides
127
+ * utilities for toggling between light and dark modes.
128
+ *
129
+ * @returns {Object} Appearance utilities
130
+ * @property {string} scheme - Current scheme ('light' or 'dark')
131
+ * @property {Function} setScheme - Set scheme explicitly
132
+ * @property {Function} toggle - Toggle between light and dark
133
+ * @property {boolean} canToggle - Whether toggling is enabled
134
+ * @property {string[]} schemes - Available schemes
135
+ *
136
+ * @example
137
+ * function DarkModeToggle() {
138
+ * const { scheme, toggle, canToggle } = useAppearance()
139
+ *
140
+ * if (!canToggle) return null
141
+ *
142
+ * return (
143
+ * <button onClick={toggle}>
144
+ * {scheme === 'dark' ? 'Switch to Light' : 'Switch to Dark'}
145
+ * </button>
146
+ * )
147
+ * }
148
+ */
149
+ export function useAppearance() {
150
+ const theme = useThemeData()
151
+ const appearance = theme?.getAppearance() || { default: 'light', allowToggle: false }
152
+
153
+ const [scheme, setSchemeState] = useState(() => getInitialScheme(appearance))
154
+
155
+ // Apply scheme to document
156
+ const applyScheme = useCallback((newScheme) => {
157
+ if (typeof document !== 'undefined') {
158
+ const root = document.documentElement
159
+ if (newScheme === 'dark') {
160
+ root.classList.add(DARK_SCHEME_CLASS)
161
+ } else {
162
+ root.classList.remove(DARK_SCHEME_CLASS)
163
+ }
164
+ }
165
+ }, [])
166
+
167
+ // Set scheme with persistence and DOM update
168
+ const setScheme = useCallback((newScheme) => {
169
+ if (newScheme !== 'light' && newScheme !== 'dark') {
170
+ console.warn(`[useAppearance] Invalid scheme: ${newScheme}. Use 'light' or 'dark'.`)
171
+ return
172
+ }
173
+
174
+ setSchemeState(newScheme)
175
+ applyScheme(newScheme)
176
+
177
+ // Persist to localStorage
178
+ if (typeof localStorage !== 'undefined') {
179
+ localStorage.setItem(APPEARANCE_STORAGE_KEY, newScheme)
180
+ }
181
+ }, [applyScheme])
182
+
183
+ // Toggle between light and dark
184
+ const toggle = useCallback(() => {
185
+ const newScheme = scheme === 'light' ? 'dark' : 'light'
186
+ setScheme(newScheme)
187
+ }, [scheme, setScheme])
188
+
189
+ // Apply initial scheme on mount
190
+ useEffect(() => {
191
+ applyScheme(scheme)
192
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
193
+
194
+ // Listen for system preference changes
195
+ useEffect(() => {
196
+ if (!appearance.respectSystemPreference || typeof window === 'undefined') {
197
+ return
198
+ }
199
+
200
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
201
+
202
+ const handleChange = (e) => {
203
+ // Only auto-switch if user hasn't manually set preference
204
+ const stored = localStorage.getItem(APPEARANCE_STORAGE_KEY)
205
+ if (!stored) {
206
+ const newScheme = e.matches ? 'dark' : 'light'
207
+ if (appearance.schemes?.includes(newScheme)) {
208
+ setScheme(newScheme)
209
+ }
210
+ }
211
+ }
212
+
213
+ mediaQuery.addEventListener('change', handleChange)
214
+ return () => mediaQuery.removeEventListener('change', handleChange)
215
+ }, [appearance.respectSystemPreference, appearance.schemes, setScheme])
216
+
217
+ return {
218
+ scheme,
219
+ setScheme,
220
+ toggle,
221
+ canToggle: appearance.allowToggle === true,
222
+ schemes: appearance.schemes || ['light'],
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Get a specific color from the theme.
228
+ * Convenience hook that combines useThemeData with getColor.
229
+ *
230
+ * @param {string} name - Color name (e.g., 'primary', 'neutral')
231
+ * @param {number} shade - Shade level (50-950), defaults to 500
232
+ * @returns {string|null} Color value or null
233
+ *
234
+ * @example
235
+ * const primaryColor = useThemeColor('primary', 600)
236
+ */
237
+ export function useThemeColor(name, shade = 500) {
238
+ const theme = useThemeData()
239
+ return theme?.getColor(name, shade) || null
240
+ }
241
+
242
+ /**
243
+ * Get CSS variable reference for a color.
244
+ * Useful for inline styles that reference theme colors.
245
+ *
246
+ * @param {string} name - Color name
247
+ * @param {number} shade - Shade level
248
+ * @returns {string} CSS var() reference
249
+ *
250
+ * @example
251
+ * const style = { color: useThemeColorVar('primary', 600) }
252
+ * // Returns: 'var(--primary-600)'
253
+ */
254
+ export function useThemeColorVar(name, shade = 500) {
255
+ return `var(--${name}-${shade})`
256
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * useVersion Hook
3
+ *
4
+ * Hook for version switching in documentation sites.
5
+ * Provides access to version information and utilities for building
6
+ * version switcher components.
7
+ *
8
+ * @example
9
+ * function VersionSwitcher() {
10
+ * const { isVersioned, currentVersion, versions, getVersionUrl } = useVersion()
11
+ *
12
+ * if (!isVersioned) return null
13
+ *
14
+ * return (
15
+ * <select
16
+ * value={currentVersion?.id}
17
+ * onChange={(e) => navigate(getVersionUrl(e.target.value))}
18
+ * >
19
+ * {versions.map(v => (
20
+ * <option key={v.id} value={v.id}>
21
+ * {v.label} {v.latest ? '(latest)' : ''} {v.deprecated ? '(deprecated)' : ''}
22
+ * </option>
23
+ * ))}
24
+ * </select>
25
+ * )
26
+ * }
27
+ */
28
+
29
+ import { useRouting } from './useRouting.js'
30
+ import { useWebsite } from './useWebsite.js'
31
+
32
+ /**
33
+ * Hook for version information and switching.
34
+ * Works both at page level (when page has version context) and
35
+ * at site level (checking versioned scopes).
36
+ *
37
+ * @param {Object} options - Optional configuration
38
+ * @param {Page} options.page - Specific page to check (default: active page)
39
+ * @returns {Object} Version utilities
40
+ */
41
+ export function useVersion(options = {}) {
42
+ const { useLocation } = useRouting()
43
+ const { website } = useWebsite()
44
+ const location = useLocation()
45
+
46
+ const currentRoute = location?.pathname || '/'
47
+ const page = options.page || website.activePage
48
+
49
+ // Check if the current page/route is within a versioned section
50
+ const isVersioned = page?.isVersioned() || website.isVersionedRoute(currentRoute)
51
+
52
+ // Get version information from page (preferred) or compute from route
53
+ const currentVersion = page?.getVersion() || null
54
+ const versionMeta = page?.versionMeta || website.getVersionMeta(website.getVersionScope(currentRoute))
55
+ const versions = versionMeta?.versions || []
56
+ const latestVersionId = versionMeta?.latestId || null
57
+
58
+ // Find the version scope for the current route
59
+ const versionScope = page?.versionScope || website.getVersionScope(currentRoute)
60
+
61
+ return {
62
+ /**
63
+ * Whether the current page is within a versioned section
64
+ * @type {boolean}
65
+ */
66
+ isVersioned,
67
+
68
+ /**
69
+ * Current version info { id, label, latest, deprecated }
70
+ * @type {Object|null}
71
+ */
72
+ currentVersion,
73
+
74
+ /**
75
+ * All available versions for this scope
76
+ * @type {Array<{id: string, label: string, latest: boolean, deprecated: boolean}>}
77
+ */
78
+ versions,
79
+
80
+ /**
81
+ * The ID of the latest version
82
+ * @type {string|null}
83
+ */
84
+ latestVersionId,
85
+
86
+ /**
87
+ * The route where versioning starts (e.g., '/docs')
88
+ * @type {string|null}
89
+ */
90
+ versionScope,
91
+
92
+ /**
93
+ * Check if current version is the latest
94
+ * @type {boolean}
95
+ */
96
+ isLatestVersion: currentVersion?.latest === true,
97
+
98
+ /**
99
+ * Check if current version is deprecated
100
+ * @type {boolean}
101
+ */
102
+ isDeprecatedVersion: currentVersion?.deprecated === true,
103
+
104
+ /**
105
+ * Get URL for switching to a different version
106
+ * @param {string} targetVersion - Target version ID (e.g., 'v1')
107
+ * @returns {string|null} Target URL or null if not versioned
108
+ */
109
+ getVersionUrl: (targetVersion) => {
110
+ if (!isVersioned) return null
111
+ return website.getVersionUrl(targetVersion, currentRoute)
112
+ },
113
+
114
+ /**
115
+ * Check if site has any versioned content
116
+ * @type {boolean}
117
+ */
118
+ hasVersionedContent: website.hasVersionedContent(),
119
+
120
+ /**
121
+ * Get all versioned scopes in the site
122
+ * @type {Object} Map of scope → { versions, latestId }
123
+ */
124
+ versionedScopes: website.getVersionedScopes(),
125
+ }
126
+ }
127
+
128
+ export default useVersion
package/src/index.js CHANGED
@@ -62,6 +62,7 @@ export {
62
62
  useWebsite,
63
63
  useRouting,
64
64
  useActiveRoute,
65
+ useVersion,
65
66
  useScrolled,
66
67
  useMobileMenu,
67
68
  useAccordion,
@@ -70,7 +71,16 @@ export {
70
71
  useTheme,
71
72
  getThemeClasses,
72
73
  THEMES,
73
- THEME_NAMES
74
+ THEME_NAMES,
75
+ // Viewport detection
76
+ useInView,
77
+ useIsInView,
78
+ // Theme data hooks (runtime theme access)
79
+ useThemeData,
80
+ useColorContext,
81
+ useAppearance,
82
+ useThemeColor,
83
+ useThemeColorVar
74
84
  } from './hooks/index.js'
75
85
 
76
86
  // ============================================================================
@@ -84,5 +94,8 @@ export {
84
94
  stripTags,
85
95
  isExternalUrl,
86
96
  isFileUrl,
87
- detectMediaType
97
+ detectMediaType,
98
+ // Locale utilities
99
+ LOCALE_DISPLAY_NAMES,
100
+ getLocaleLabel
88
101
  } from './utils/index.js'