diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f145e1..fe88e3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,12 +144,26 @@ jobs: with: xcode-version: ${{ env.XCODE_VERSION }} + - name: Cache cocoapods + id: cocoapods-cache + if: env.turbo_cache_hit != 1 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: | + example/ios/Pods + ~/.cocoapods + key: ${{ runner.os }}-pods-${{ hashFiles('example/ios/Podfile.lock', 'example/Gemfile.lock') }} + restore-keys: | + ${{ runner.os }}-pods- + - name: Install cocoapods - if: env.turbo_cache_hit != 1 && steps.cocoapods-cache.outputs.cache-hit != 'true' + if: env.turbo_cache_hit != 1 run: | cd example bundle install - bundle exec pod repo update --verbose + if [[ "${{ steps.cocoapods-cache.outputs.cache-hit }}" != "true" ]]; then + bundle exec pod repo update --verbose + fi bundle exec pod install --project-directory=ios - name: Build example for iOS diff --git a/CHANGELOG.md b/CHANGELOG.md index b2a95fd..38c0468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Accessibility: set `accessibilityState.busy` automatically while `loading={true}` +- Reduce Motion: render a static `baseColor` skeleton when the system setting is enabled (no shimmer animation) +- DEV warning when `loading` is omitted but `children` are present (defaults to `true`) +- Example app: Reduce Motion banner, wrapped Lines demo, accessibility labels + +### Changed + +- `GleamView.Line` nested inside intermediate wrappers (e.g. ``) is detected on the first render — stable ref, no NativeGleamView→View flip + +### Fixed + +- CI: add missing CocoaPods cache step referenced by the iOS build job +- CI: always run `pod install` after cache restore (codegen needs fresh `build/generated`) +- CI: stop caching `vendor/bundle` (stale gems broke Ruby 3.4 pod install) +- Example: add `nkf` and `base64` gems for Ruby 3.4 / CocoaPods compatibility + ## [1.0.6] - 2026-05-15 ### Fixed diff --git a/README.md b/README.md index d0b2076..233fecf 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ When `loading={true}`, each `GleamView.Line` renders its own shimmer bar, sized Lines inherit `loading`, `speed`, `direction`, `baseColor`, `highlightColor`, `intensity`, `transitionDuration`, and `transitionType` from the parent. `delay` and `onTransitionEnd` are per-line. -For best performance, place `GleamView.Line` as direct children of `GleamView` (or inside fragments). Lines nested inside intermediate wrappers (e.g., ``) still work, but require an extra render cycle to detect. +For best performance, place `GleamView.Line` as direct children of `GleamView` (or inside fragments). Lines nested inside intermediate wrappers (e.g., ``) are detected automatically on the first render. Every `GleamView` provides context to its subtree. A `GleamView.Line` always binds to its nearest `GleamView` ancestor — nested `GleamView` components each control their own Lines independently. @@ -196,6 +196,27 @@ The shimmer respects uniform `borderRadius` and standard view styles. - The shimmer overlay supports uniform `borderRadius` only — per-corner radii are not applied to the shimmer. +## Accessibility + +When `loading={true}`, `GleamView` and `GleamView.Line` automatically set `accessibilityState={{ busy: true }}` (merged with any existing `accessibilityState` you pass). + +When the system **Reduce Motion** setting is enabled, shimmer animation is replaced by a static `baseColor` placeholder — no native animation runs until content loads. + +## Example app + +The [`example/`](example/) app includes interactive demos for all props plus: + +- **Reduce Motion** — live status banner (toggle in system accessibility settings) +- **Wrapped Lines** — `GleamView.Line` nested inside a `` wrapper +- **Accessibility** — `accessibilityLabel` / `accessibilityRole` on lines; `accessibilityState.busy` while loading + +Run from the repo root: + +```sh +yarn example start +yarn example ios # or: yarn example android +``` + ## License MIT diff --git a/example/Gemfile b/example/Gemfile index 6a4c5f1..c61ddef 100644 --- a/example/Gemfile +++ b/example/Gemfile @@ -14,3 +14,5 @@ gem 'bigdecimal' gem 'logger' gem 'benchmark' gem 'mutex_m' +gem 'base64' +gem 'nkf' diff --git a/example/Gemfile.lock b/example/Gemfile.lock index 88ebf16..5fdf0fd 100644 --- a/example/Gemfile.lock +++ b/example/Gemfile.lock @@ -82,6 +82,7 @@ GEM minitest (5.27.0) molinillo (0.8.0) mutex_m (0.3.0) + nkf (0.3.0) nanaimo (0.3.0) nap (1.1.0) netrc (0.11.0) @@ -106,12 +107,14 @@ PLATFORMS DEPENDENCIES activesupport (>= 6.1.7.5, != 7.1.0) + base64 benchmark bigdecimal cocoapods (>= 1.13, != 1.15.1, != 1.15.0) concurrent-ruby (< 1.3.4) logger mutex_m + nkf xcodeproj (< 1.26.0) RUBY VERSION diff --git a/example/src/App.tsx b/example/src/App.tsx index ed8783e..4745cc5 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,5 +1,6 @@ -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { + AccessibilityInfo, FlatList, StyleSheet, Text, @@ -88,10 +89,56 @@ export default function App() { const [colorIndex, setColorIndex] = useState(0); const [transitionDuration, setTransitionDuration] = useState(300); const [transitionType, setTransitionType] = useState(GleamTransition.Fade); + const [reduceMotion, setReduceMotion] = useState(false); + + useEffect(() => { + let mounted = true; + + AccessibilityInfo.isReduceMotionEnabled() + .then((enabled) => { + if (mounted) { + setReduceMotion(enabled); + } + }) + .catch(() => {}); + + const subscription = AccessibilityInfo.addEventListener( + 'reduceMotionChanged', + setReduceMotion + ); + + return () => { + mounted = false; + subscription.remove(); + }; + }, []); const direction = DIRECTIONS[dirIndex]!.value; const colors = COLOR_PRESETS[colorIndex]!; + const sharedGleamProps = useMemo( + () => ({ + loading, + speed, + intensity, + direction, + baseColor: colors.base, + highlightColor: colors.highlight, + transitionDuration, + transitionType, + }), + [ + loading, + speed, + intensity, + direction, + colors.base, + colors.highlight, + transitionDuration, + transitionType, + ] + ); + const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v)); @@ -99,6 +146,20 @@ export default function App() { react-native-gleam + + Reduce Motion + + {reduceMotion + ? 'Enabled — GleamView shows a static baseColor skeleton (no shimmer).' + : 'Disabled — toggle in iOS Settings → Accessibility → Motion, or Android Remove animations.'} + + + {/* Profile card skeleton */} @@ -112,6 +173,8 @@ export default function App() { transitionDuration={transitionDuration} transitionType={transitionType} style={styles.avatar} + accessibilityLabel="User avatar" + accessibilityRole="image" > JD @@ -304,6 +367,43 @@ export default function App() { + {/* Lines inside a View wrapper + accessibility labels */} + Wrapped Lines + accessibility + + GleamView.Line inside a View wrapper (first-render detection). Each line + sets accessibilityLabel; busy is applied automatically while loading. + + + + + + Premium Plan + + + $12 / month + + + Includes all features. + + + + + {/* Staggered demo */} Staggered @@ -400,6 +500,31 @@ const styles = StyleSheet.create({ marginBottom: 20, textAlign: 'center', }, + callout: { + borderRadius: 12, + padding: 12, + marginBottom: 20, + borderWidth: 1, + }, + calloutActive: { + backgroundColor: '#FFF8E6', + borderColor: '#F0D48A', + }, + calloutMuted: { + backgroundColor: '#FFFFFF', + borderColor: '#E5E5E5', + }, + calloutTitle: { + fontSize: 13, + fontWeight: '700', + color: '#444', + marginBottom: 4, + }, + calloutText: { + fontSize: 12, + color: '#666', + lineHeight: 18, + }, profileCard: { backgroundColor: '#FFFFFF', borderRadius: 16, @@ -606,6 +731,15 @@ const styles = StyleSheet.create({ color: '#555', marginBottom: 8, }, + sectionHint: { + fontSize: 12, + color: '#888', + lineHeight: 18, + marginBottom: 10, + }, + wrappedLines: { + gap: 8, + }, staggered: { gap: 6, marginBottom: 24, diff --git a/lib/module/GleamContext.js.map b/lib/module/GleamContext.js.map index 4f8a6eb..4d68eb7 100644 --- a/lib/module/GleamContext.js.map +++ b/lib/module/GleamContext.js.map @@ -1 +1 @@ -{"version":3,"names":["createContext","GleamContext"],"sourceRoot":"../../src","sources":["GleamContext.ts"],"mappings":";;AAAA,SAASA,aAAa,QAAQ,OAAO;AAerC,OAAO,MAAMC,YAAY,gBAAGD,aAAa,CAA2B,IAAI,CAAC","ignoreList":[]} +{"version":3,"names":["createContext","GleamContext"],"sourceRoot":"../../src","sources":["GleamContext.ts"],"mappings":";;AAAA,SAASA,aAAa,QAAQ,OAAO;AAgBrC,OAAO,MAAMC,YAAY,gBAAGD,aAAa,CAA2B,IAAI,CAAC","ignoreList":[]} diff --git a/lib/module/GleamLine.js b/lib/module/GleamLine.js index 596e75e..319dcc7 100644 --- a/lib/module/GleamLine.js +++ b/lib/module/GleamLine.js @@ -4,6 +4,7 @@ import { useContext, useLayoutEffect } from 'react'; import { View } from 'react-native'; import NativeGleamView from './GleamViewNativeComponent'; import { GleamContext } from "./GleamContext.js"; +import { gleamAccessibilityState, gleamStyles, resolveBaseColor } from "./gleamAccessibility.js"; import { jsx as _jsx } from "react/jsx-runtime"; export function GleamLine({ children, @@ -15,6 +16,12 @@ export function GleamLine({ }) { const ctx = useContext(GleamContext); const register = ctx?.registerLine; + const isLoading = ctx?.loading ?? true; + const useStaticSkeleton = ctx?.reduceMotion && isLoading; + const { + accessibilityState, + ...restAccessibilityProps + } = accessibilityProps; useLayoutEffect(() => { if (!register) return; return register(); @@ -30,6 +37,21 @@ export function GleamLine({ children: children }); } + if (useStaticSkeleton) { + return /*#__PURE__*/_jsx(View, { + style: [style, { + backgroundColor: resolveBaseColor(ctx.baseColor) + }], + testID: testID, + ...restAccessibilityProps, + accessibilityState: gleamAccessibilityState(isLoading, accessibilityState), + children: /*#__PURE__*/_jsx(View, { + style: gleamStyles.hiddenContent, + pointerEvents: "none", + children: children + }) + }); + } return /*#__PURE__*/_jsx(NativeGleamView, { loading: ctx.loading, speed: ctx.speed, @@ -43,7 +65,8 @@ export function GleamLine({ onTransitionEnd: onTransitionEnd, style: style, testID: testID, - ...accessibilityProps, + ...restAccessibilityProps, + accessibilityState: gleamAccessibilityState(isLoading, accessibilityState), children: children }); } diff --git a/lib/module/GleamLine.js.map b/lib/module/GleamLine.js.map index 00830a9..15be646 100644 --- a/lib/module/GleamLine.js.map +++ b/lib/module/GleamLine.js.map @@ -1 +1 @@ -{"version":3,"names":["useContext","useLayoutEffect","View","NativeGleamView","GleamContext","jsx","_jsx","GleamLine","children","style","testID","delay","onTransitionEnd","accessibilityProps","ctx","register","registerLine","__DEV__","console","warn","loading","speed","direction","transitionDuration","transitionType","intensity","baseColor","highlightColor"],"sourceRoot":"../../src","sources":["GleamLine.tsx"],"mappings":";;AAAA,SAASA,UAAU,EAAEC,eAAe,QAAwB,OAAO;AACnE,SACEC,IAAI,QAIC,cAAc;AACrB,OAAOC,eAAe,MAA4B,4BAA4B;AAC9E,SAASC,YAAY,QAAQ,mBAAgB;AAAC,SAAAC,GAAA,IAAAC,IAAA;AAa9C,OAAO,SAASC,SAASA,CAAC;EACxBC,QAAQ;EACRC,KAAK;EACLC,MAAM;EACNC,KAAK;EACLC,eAAe;EACf,GAAGC;AACW,CAAC,EAAE;EACjB,MAAMC,GAAG,GAAGd,UAAU,CAACI,YAAY,CAAC;EACpC,MAAMW,QAAQ,GAAGD,GAAG,EAAEE,YAAY;EAElCf,eAAe,CAAC,MAAM;IACpB,IAAI,CAACc,QAAQ,EAAE;IACf,OAAOA,QAAQ,CAAC,CAAC;EACnB,CAAC,EAAE,CAACA,QAAQ,CAAC,CAAC;EAEd,IAAI,CAACD,GAAG,EAAE;IACR,IAAIG,OAAO,EAAE;MACXC,OAAO,CAACC,IAAI,CAAC,gDAAgD,CAAC;IAChE;IACA,oBACEb,IAAA,CAACJ,IAAI;MAACO,KAAK,EAAEA,KAAM;MAACC,MAAM,EAAEA,MAAO;MAAA,GAAKG,kBAAkB;MAAAL,QAAA,EACvDA;IAAQ,CACL,CAAC;EAEX;EAEA,oBACEF,IAAA,CAACH,eAAe;IACdiB,OAAO,EAAEN,GAAG,CAACM,OAAQ;IACrBC,KAAK,EAAEP,GAAG,CAACO,KAAM;IACjBC,SAAS,EAAER,GAAG,CAACQ,SAAU;IACzBX,KAAK,EAAEA,KAAM;IACbY,kBAAkB,EAAET,GAAG,CAACS,kBAAmB;IAC3CC,cAAc,EAAEV,GAAG,CAACU,cAAe;IACnCC,SAAS,EAAEX,GAAG,CAACW,SAAU;IACzBC,SAAS,EAAEZ,GAAG,CAACY,SAAU;IACzBC,cAAc,EAAEb,GAAG,CAACa,cAAe;IACnCf,eAAe,EAAEA,eAAgB;IACjCH,KAAK,EAAEA,KAAM;IACbC,MAAM,EAAEA,MAAO;IAAA,GACXG,kBAAkB;IAAAL,QAAA,EAErBA;EAAQ,CACM,CAAC;AAEtB","ignoreList":[]} +{"version":3,"names":["useContext","useLayoutEffect","View","NativeGleamView","GleamContext","gleamAccessibilityState","gleamStyles","resolveBaseColor","jsx","_jsx","GleamLine","children","style","testID","delay","onTransitionEnd","accessibilityProps","ctx","register","registerLine","isLoading","loading","useStaticSkeleton","reduceMotion","accessibilityState","restAccessibilityProps","__DEV__","console","warn","backgroundColor","baseColor","hiddenContent","pointerEvents","speed","direction","transitionDuration","transitionType","intensity","highlightColor"],"sourceRoot":"../../src","sources":["GleamLine.tsx"],"mappings":";;AAAA,SAASA,UAAU,EAAEC,eAAe,QAAwB,OAAO;AACnE,SACEC,IAAI,QAIC,cAAc;AACrB,OAAOC,eAAe,MAA4B,4BAA4B;AAC9E,SAASC,YAAY,QAAQ,mBAAgB;AAC7C,SACEC,uBAAuB,EACvBC,WAAW,EACXC,gBAAgB,QACX,yBAAsB;AAAC,SAAAC,GAAA,IAAAC,IAAA;AAa9B,OAAO,SAASC,SAASA,CAAC;EACxBC,QAAQ;EACRC,KAAK;EACLC,MAAM;EACNC,KAAK;EACLC,eAAe;EACf,GAAGC;AACW,CAAC,EAAE;EACjB,MAAMC,GAAG,GAAGjB,UAAU,CAACI,YAAY,CAAC;EACpC,MAAMc,QAAQ,GAAGD,GAAG,EAAEE,YAAY;EAClC,MAAMC,SAAS,GAAGH,GAAG,EAAEI,OAAO,IAAI,IAAI;EACtC,MAAMC,iBAAiB,GAAGL,GAAG,EAAEM,YAAY,IAAIH,SAAS;EACxD,MAAM;IAAEI,kBAAkB;IAAE,GAAGC;EAAuB,CAAC,GAAGT,kBAAkB;EAE5Ef,eAAe,CAAC,MAAM;IACpB,IAAI,CAACiB,QAAQ,EAAE;IACf,OAAOA,QAAQ,CAAC,CAAC;EACnB,CAAC,EAAE,CAACA,QAAQ,CAAC,CAAC;EAEd,IAAI,CAACD,GAAG,EAAE;IACR,IAAIS,OAAO,EAAE;MACXC,OAAO,CAACC,IAAI,CAAC,gDAAgD,CAAC;IAChE;IACA,oBACEnB,IAAA,CAACP,IAAI;MAACU,KAAK,EAAEA,KAAM;MAACC,MAAM,EAAEA,MAAO;MAAA,GAAKG,kBAAkB;MAAAL,QAAA,EACvDA;IAAQ,CACL,CAAC;EAEX;EAEA,IAAIW,iBAAiB,EAAE;IACrB,oBACEb,IAAA,CAACP,IAAI;MACHU,KAAK,EAAE,CAACA,KAAK,EAAE;QAAEiB,eAAe,EAAEtB,gBAAgB,CAACU,GAAG,CAACa,SAAS;MAAE,CAAC,CAAE;MACrEjB,MAAM,EAAEA,MAAO;MAAA,GACXY,sBAAsB;MAC1BD,kBAAkB,EAAEnB,uBAAuB,CACzCe,SAAS,EACTI,kBACF,CAAE;MAAAb,QAAA,eAEFF,IAAA,CAACP,IAAI;QAACU,KAAK,EAAEN,WAAW,CAACyB,aAAc;QAACC,aAAa,EAAC,MAAM;QAAArB,QAAA,EACzDA;MAAQ,CACL;IAAC,CACH,CAAC;EAEX;EAEA,oBACEF,IAAA,CAACN,eAAe;IACdkB,OAAO,EAAEJ,GAAG,CAACI,OAAQ;IACrBY,KAAK,EAAEhB,GAAG,CAACgB,KAAM;IACjBC,SAAS,EAAEjB,GAAG,CAACiB,SAAU;IACzBpB,KAAK,EAAEA,KAAM;IACbqB,kBAAkB,EAAElB,GAAG,CAACkB,kBAAmB;IAC3CC,cAAc,EAAEnB,GAAG,CAACmB,cAAe;IACnCC,SAAS,EAAEpB,GAAG,CAACoB,SAAU;IACzBP,SAAS,EAAEb,GAAG,CAACa,SAAU;IACzBQ,cAAc,EAAErB,GAAG,CAACqB,cAAe;IACnCvB,eAAe,EAAEA,eAAgB;IACjCH,KAAK,EAAEA,KAAM;IACbC,MAAM,EAAEA,MAAO;IAAA,GACXY,sBAAsB;IAC1BD,kBAAkB,EAAEnB,uBAAuB,CACzCe,SAAS,EACTI,kBACF,CAAE;IAAAb,QAAA,EAEDA;EAAQ,CACM,CAAC;AAEtB","ignoreList":[]} diff --git a/lib/module/gleamAccessibility.js b/lib/module/gleamAccessibility.js new file mode 100644 index 0000000..815d375 --- /dev/null +++ b/lib/module/gleamAccessibility.js @@ -0,0 +1,25 @@ +"use strict"; + +import { StyleSheet } from 'react-native'; +const DEFAULT_BASE_COLOR = '#E0E0E0'; +export const gleamStyles = StyleSheet.create({ + hiddenContent: { + opacity: 0 + } +}); +export function resolveBaseColor(baseColor) { + if (typeof baseColor === 'string') { + return baseColor; + } + return DEFAULT_BASE_COLOR; +} +export function gleamAccessibilityState(loading, existing) { + if (!loading) { + return existing ?? undefined; + } + return { + ...existing, + busy: true + }; +} +//# sourceMappingURL=gleamAccessibility.js.map \ No newline at end of file diff --git a/lib/module/gleamAccessibility.js.map b/lib/module/gleamAccessibility.js.map new file mode 100644 index 0000000..d318027 --- /dev/null +++ b/lib/module/gleamAccessibility.js.map @@ -0,0 +1 @@ +{"version":3,"names":["StyleSheet","DEFAULT_BASE_COLOR","gleamStyles","create","hiddenContent","opacity","resolveBaseColor","baseColor","gleamAccessibilityState","loading","existing","undefined","busy"],"sourceRoot":"../../src","sources":["gleamAccessibility.ts"],"mappings":";;AACA,SAASA,UAAU,QAAQ,cAAc;AAEzC,MAAMC,kBAAkB,GAAG,SAAS;AAEpC,OAAO,MAAMC,WAAW,GAAGF,UAAU,CAACG,MAAM,CAAC;EAC3CC,aAAa,EAAE;IACbC,OAAO,EAAE;EACX;AACF,CAAC,CAAC;AAEF,OAAO,SAASC,gBAAgBA,CAACC,SAAsB,EAAU;EAC/D,IAAI,OAAOA,SAAS,KAAK,QAAQ,EAAE;IACjC,OAAOA,SAAS;EAClB;EACA,OAAON,kBAAkB;AAC3B;AAEA,OAAO,SAASO,uBAAuBA,CACrCC,OAA4B,EAC5BC,QAAoC,EACJ;EAChC,IAAI,CAACD,OAAO,EAAE;IACZ,OAAOC,QAAQ,IAAIC,SAAS;EAC9B;EAEA,OAAO;IACL,GAAGD,QAAQ;IACXE,IAAI,EAAE;EACR,CAAC;AACH","ignoreList":[]} diff --git a/lib/module/index.js b/lib/module/index.js index 9b051d5..518debf 100644 --- a/lib/module/index.js +++ b/lib/module/index.js @@ -1,10 +1,12 @@ "use strict"; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { View } from 'react-native'; import NativeGleamView from './GleamViewNativeComponent'; import { GleamContext } from "./GleamContext.js"; import { GleamLine } from "./GleamLine.js"; +import { gleamAccessibilityState, gleamStyles, resolveBaseColor } from "./gleamAccessibility.js"; +import { useReduceMotion } from "./useReduceMotion.js"; /** * Props accepted by GleamView, including ref (React 19 ref-as-prop). @@ -31,19 +33,51 @@ export let GleamTransition = /*#__PURE__*/function (GleamTransition) { // Direction 2 (DEV runtime): check inside GleamViewComponent catches // new NativeProps keys that weren't added to this list or destructured. const SHIMMER_KEYS = new Set(['loading', 'speed', 'direction', 'delay', 'transitionDuration', 'transitionType', 'intensity', 'baseColor', 'highlightColor', 'onTransitionEnd']); -function hasLineChildren(children) { +function scanLineChildren(children, gleamViewType) { let found = false; React.Children.forEach(children, child => { if (found) return; if (! /*#__PURE__*/React.isValidElement(child)) return; if (child.type === GleamLine) { found = true; - } else if (child.type === React.Fragment) { - found = hasLineChildren(child.props.children); + return; + } + if (child.type === gleamViewType) { + return; + } + const nested = child.props.children; + if (nested != null) { + found = scanLineChildren(nested, gleamViewType); } }); return found; } +function StaticSkeleton({ + ref, + loading, + baseColor, + viewProps, + children +}) { + const { + accessibilityState, + style, + ...restViewProps + } = viewProps; + return /*#__PURE__*/_jsx(View, { + ref: ref, + accessibilityState: gleamAccessibilityState(loading, accessibilityState), + style: [style, { + backgroundColor: resolveBaseColor(baseColor) + }], + ...restViewProps, + children: /*#__PURE__*/_jsx(View, { + style: gleamStyles.hiddenContent, + pointerEvents: "none", + children: children + }) + }); +} // React 19: ref is a regular prop, no forwardRef needed. // Internal ref type is loosened to avoid monorepo type conflicts between @@ -68,6 +102,9 @@ function GleamViewComponent({ children, ...viewProps } = props; + const reduceMotion = useReduceMotion(); + const isLoading = loading ?? true; + const useStaticSkeleton = reduceMotion && isLoading; if (__DEV__) { for (const key of Object.keys(viewProps)) { if (SHIMMER_KEYS.has(key)) { @@ -77,16 +114,25 @@ function GleamViewComponent({ } const lineCountRef = useRef(0); const warnedTransitionRef = useRef(false); - const [hasLines, setHasLines] = useState(() => hasLineChildren(children)); + const warnedDefaultLoadingRef = useRef(false); + const hasLinesInTree = useMemo(() => scanLineChildren(children, GleamViewComponent), [children]); + const [hasRegisteredLines, setHasRegisteredLines] = useState(false); + const hasLines = hasLinesInTree || hasRegisteredLines; + useEffect(() => { + if (__DEV__ && !warnedDefaultLoadingRef.current && loading === undefined && children != null && !hasLines) { + warnedDefaultLoadingRef.current = true; + console.warn('GleamView: loading defaults to true — children stay hidden until loading={false}.'); + } + }, [children, hasLines, loading]); const registerLine = useCallback(() => { lineCountRef.current++; - setHasLines(true); + setHasRegisteredLines(true); return () => { lineCountRef.current--; if (lineCountRef.current === 0) { queueMicrotask(() => { if (lineCountRef.current === 0) { - setHasLines(false); + setHasRegisteredLines(false); warnedTransitionRef.current = false; } }); @@ -102,8 +148,9 @@ function GleamViewComponent({ intensity, baseColor, highlightColor, - registerLine - }), [loading, speed, direction, transitionDuration, transitionType, intensity, baseColor, highlightColor, registerLine]); + registerLine, + reduceMotion + }), [loading, speed, direction, transitionDuration, transitionType, intensity, baseColor, highlightColor, registerLine, reduceMotion]); // Cast needed: View and NativeGleamView accept Ref // but that type isn't publicly exported from react-native. Safe at runtime. @@ -117,16 +164,30 @@ function GleamViewComponent({ value: contextValue, children: /*#__PURE__*/_jsx(View, { ref: nativeRef, + accessibilityState: gleamAccessibilityState(isLoading, viewProps.accessibilityState), ...viewProps, children: children }) }); } + if (useStaticSkeleton) { + return /*#__PURE__*/_jsx(GleamContext.Provider, { + value: contextValue, + children: /*#__PURE__*/_jsx(StaticSkeleton, { + ref: nativeRef, + loading: isLoading, + baseColor: baseColor, + viewProps: viewProps, + children: children + }) + }); + } return /*#__PURE__*/_jsx(GleamContext.Provider, { value: contextValue, children: /*#__PURE__*/_jsx(NativeGleamView, { ref: nativeRef, ...props, + accessibilityState: gleamAccessibilityState(isLoading, props.accessibilityState), children: children }) }); diff --git a/lib/module/index.js.map b/lib/module/index.js.map index 4a98d3e..07a7faa 100644 --- a/lib/module/index.js.map +++ b/lib/module/index.js.map @@ -1 +1 @@ -{"version":3,"names":["React","useCallback","useMemo","useRef","useState","View","NativeGleamView","GleamContext","GleamLine","jsx","_jsx","GleamDirection","GleamTransition","SHIMMER_KEYS","Set","hasLineChildren","children","found","Children","forEach","child","isValidElement","type","Fragment","props","GleamViewComponent","ref","loading","speed","direction","delay","transitionDuration","transitionType","intensity","baseColor","highlightColor","onTransitionEnd","viewProps","__DEV__","key","Object","keys","has","console","error","lineCountRef","warnedTransitionRef","hasLines","setHasLines","registerLine","current","queueMicrotask","contextValue","nativeRef","warn","Provider","value","displayName","GleamView","assign","Line"],"sourceRoot":"../../src","sources":["index.tsx"],"mappings":";;AAAA,OAAOA,KAAK,IAAIC,WAAW,EAAEC,OAAO,EAAEC,MAAM,EAAEC,QAAQ,QAAQ,OAAO;AACrE,SAASC,IAAI,QAAQ,cAAc;AACnC,OAAOC,eAAe,MAA4B,4BAA4B;AAC9E,SAASC,YAAY,QAAgC,mBAAgB;AACrE,SAASC,SAAS,QAAQ,gBAAa;;AAKvC;AACA;AACA;AACA;AACA;AAJA,SAAAC,GAAA,IAAAC,IAAA;AASA,WAAYC,cAAc,0BAAdA,cAAc;EAAdA,cAAc;EAAdA,cAAc;EAAdA,cAAc;EAAA,OAAdA,cAAc;AAAA;AAM1B,WAAYC,eAAe,0BAAfA,eAAe;EAAfA,eAAe;EAAfA,eAAe;EAAfA,eAAe;EAAA,OAAfA,eAAe;AAAA;;AAM3B;AACA;AACA;AACA;AACA;AACA,MAAMC,YAAiC,GAAG,IAAIC,GAAG,CAAC,CAChD,SAAS,EACT,OAAO,EACP,WAAW,EACX,OAAO,EACP,oBAAoB,EACpB,gBAAgB,EAChB,WAAW,EACX,WAAW,EACX,gBAAgB,EAChB,iBAAiB,CACkC,CAAC;AAEtD,SAASC,eAAeA,CAACC,QAAyB,EAAW;EAC3D,IAAIC,KAAK,GAAG,KAAK;EACjBjB,KAAK,CAACkB,QAAQ,CAACC,OAAO,CAACH,QAAQ,EAAGI,KAAK,IAAK;IAC1C,IAAIH,KAAK,EAAE;IACX,IAAI,eAACjB,KAAK,CAACqB,cAAc,CAACD,KAAK,CAAC,EAAE;IAClC,IAAIA,KAAK,CAACE,IAAI,KAAKd,SAAS,EAAE;MAC5BS,KAAK,GAAG,IAAI;IACd,CAAC,MAAM,IAAIG,KAAK,CAACE,IAAI,KAAKtB,KAAK,CAACuB,QAAQ,EAAE;MACxCN,KAAK,GAAGF,eAAe,CACpBK,KAAK,CAACI,KAAK,CAAoCR,QAClD,CAAC;IACH;EACF,CAAC,CAAC;EACF,OAAOC,KAAK;AACd;;AAEA;AACA;AACA;AACA;AACA,SAASQ,kBAAkBA,CAAC;EAC1BC,GAAG;EACH,GAAGF;AACuC,CAAC,EAAE;EAC7C,MAAM;IACJG,OAAO;IACPC,KAAK;IACLC,SAAS;IACT;IACAC,KAAK;IACLC,kBAAkB;IAClBC,cAAc;IACdC,SAAS;IACTC,SAAS;IACTC,cAAc;IACdC,eAAe;IACfpB,QAAQ;IACR,GAAGqB;EACL,CAAC,GAAGb,KAAK;EAET,IAAIc,OAAO,EAAE;IACX,KAAK,MAAMC,GAAG,IAAIC,MAAM,CAACC,IAAI,CAACJ,SAAS,CAAC,EAAE;MACxC,IAAIxB,YAAY,CAAC6B,GAAG,CAACH,GAAG,CAAC,EAAE;QACzBI,OAAO,CAACC,KAAK,CACX,4BAA4BL,GAAG,2BAA2B,GACxD,wEACJ,CAAC;MACH;IACF;EACF;EAEA,MAAMM,YAAY,GAAG1C,MAAM,CAAC,CAAC,CAAC;EAC9B,MAAM2C,mBAAmB,GAAG3C,MAAM,CAAC,KAAK,CAAC;EACzC,MAAM,CAAC4C,QAAQ,EAAEC,WAAW,CAAC,GAAG5C,QAAQ,CAAC,MAAMW,eAAe,CAACC,QAAQ,CAAC,CAAC;EAEzE,MAAMiC,YAAY,GAAGhD,WAAW,CAAC,MAAM;IACrC4C,YAAY,CAACK,OAAO,EAAE;IACtBF,WAAW,CAAC,IAAI,CAAC;IACjB,OAAO,MAAM;MACXH,YAAY,CAACK,OAAO,EAAE;MACtB,IAAIL,YAAY,CAACK,OAAO,KAAK,CAAC,EAAE;QAC9BC,cAAc,CAAC,MAAM;UACnB,IAAIN,YAAY,CAACK,OAAO,KAAK,CAAC,EAAE;YAC9BF,WAAW,CAAC,KAAK,CAAC;YAClBF,mBAAmB,CAACI,OAAO,GAAG,KAAK;UACrC;QACF,CAAC,CAAC;MACJ;IACF,CAAC;EACH,CAAC,EAAE,EAAE,CAAC;EAEN,MAAME,YAAY,GAAGlD,OAAO,CAC1B,OAAO;IACLyB,OAAO;IACPC,KAAK;IACLC,SAAS;IACTE,kBAAkB;IAClBC,cAAc;IACdC,SAAS;IACTC,SAAS;IACTC,cAAc;IACdc;EACF,CAAC,CAAC,EACF,CACEtB,OAAO,EACPC,KAAK,EACLC,SAAS,EACTE,kBAAkB,EAClBC,cAAc,EACdC,SAAS,EACTC,SAAS,EACTC,cAAc,EACdc,YAAY,CAEhB,CAAC;;EAED;EACA;EACA,MAAMI,SAAS,GAAG3B,GAAU;EAE5B,IAAIqB,QAAQ,EAAE;IACZ,IAAIT,OAAO,IAAIF,eAAe,IAAI,CAACU,mBAAmB,CAACI,OAAO,EAAE;MAC9DJ,mBAAmB,CAACI,OAAO,GAAG,IAAI;MAClCP,OAAO,CAACW,IAAI,CACV,kFAAkF,GAChF,sEACJ,CAAC;IACH;IACA,oBACE5C,IAAA,CAACH,YAAY,CAACgD,QAAQ;MAACC,KAAK,EAAEJ,YAAa;MAAApC,QAAA,eACzCN,IAAA,CAACL,IAAI;QAACqB,GAAG,EAAE2B,SAAU;QAAA,GAAKhB,SAAS;QAAArB,QAAA,EAChCA;MAAQ,CACL;IAAC,CACc,CAAC;EAE5B;EAEA,oBACEN,IAAA,CAACH,YAAY,CAACgD,QAAQ;IAACC,KAAK,EAAEJ,YAAa;IAAApC,QAAA,eACzCN,IAAA,CAACJ,eAAe;MAACoB,GAAG,EAAE2B,SAAU;MAAA,GAAK7B,KAAK;MAAAR,QAAA,EACvCA;IAAQ,CACM;EAAC,CACG,CAAC;AAE5B;AAEAS,kBAAkB,CAACgC,WAAW,GAAG,WAAW;AAE5C,OAAO,MAAMC,SAAS,GAAGlB,MAAM,CAACmB,MAAM,CAAClC,kBAAkB,EAAE;EACzDmC,IAAI,EAAEpD;AACR,CAAC,CAAC","ignoreList":[]} +{"version":3,"names":["React","useCallback","useEffect","useMemo","useRef","useState","View","NativeGleamView","GleamContext","GleamLine","gleamAccessibilityState","gleamStyles","resolveBaseColor","useReduceMotion","jsx","_jsx","GleamDirection","GleamTransition","SHIMMER_KEYS","Set","scanLineChildren","children","gleamViewType","found","Children","forEach","child","isValidElement","type","nested","props","StaticSkeleton","ref","loading","baseColor","viewProps","accessibilityState","style","restViewProps","backgroundColor","hiddenContent","pointerEvents","GleamViewComponent","speed","direction","delay","transitionDuration","transitionType","intensity","highlightColor","onTransitionEnd","reduceMotion","isLoading","useStaticSkeleton","__DEV__","key","Object","keys","has","console","error","lineCountRef","warnedTransitionRef","warnedDefaultLoadingRef","hasLinesInTree","hasRegisteredLines","setHasRegisteredLines","hasLines","current","undefined","warn","registerLine","queueMicrotask","contextValue","nativeRef","Provider","value","displayName","GleamView","assign","Line"],"sourceRoot":"../../src","sources":["index.tsx"],"mappings":"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","ignoreList":[]} diff --git a/lib/module/useReduceMotion.js b/lib/module/useReduceMotion.js new file mode 100644 index 0000000..ae39110 --- /dev/null +++ b/lib/module/useReduceMotion.js @@ -0,0 +1,26 @@ +"use strict"; + +import { useEffect, useState } from 'react'; +import { AccessibilityInfo } from 'react-native'; + +/** Subscribes to the platform "reduce motion" accessibility setting. */ +export function useReduceMotion() { + const [reduceMotion, setReduceMotion] = useState(false); + useEffect(() => { + let mounted = true; + AccessibilityInfo.isReduceMotionEnabled().then(enabled => { + if (mounted) { + setReduceMotion(enabled); + } + }).catch(() => { + // Ignore — treat as motion allowed when the API is unavailable. + }); + const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion); + return () => { + mounted = false; + subscription.remove(); + }; + }, []); + return reduceMotion; +} +//# sourceMappingURL=useReduceMotion.js.map \ No newline at end of file diff --git a/lib/module/useReduceMotion.js.map b/lib/module/useReduceMotion.js.map new file mode 100644 index 0000000..319acb6 --- /dev/null +++ b/lib/module/useReduceMotion.js.map @@ -0,0 +1 @@ +{"version":3,"names":["useEffect","useState","AccessibilityInfo","useReduceMotion","reduceMotion","setReduceMotion","mounted","isReduceMotionEnabled","then","enabled","catch","subscription","addEventListener","remove"],"sourceRoot":"../../src","sources":["useReduceMotion.ts"],"mappings":";;AAAA,SAASA,SAAS,EAAEC,QAAQ,QAAQ,OAAO;AAC3C,SAASC,iBAAiB,QAAQ,cAAc;;AAEhD;AACA,OAAO,SAASC,eAAeA,CAAA,EAAY;EACzC,MAAM,CAACC,YAAY,EAAEC,eAAe,CAAC,GAAGJ,QAAQ,CAAC,KAAK,CAAC;EAEvDD,SAAS,CAAC,MAAM;IACd,IAAIM,OAAO,GAAG,IAAI;IAElBJ,iBAAiB,CAACK,qBAAqB,CAAC,CAAC,CACtCC,IAAI,CAAEC,OAAO,IAAK;MACjB,IAAIH,OAAO,EAAE;QACXD,eAAe,CAACI,OAAO,CAAC;MAC1B;IACF,CAAC,CAAC,CACDC,KAAK,CAAC,MAAM;MACX;IAAA,CACD,CAAC;IAEJ,MAAMC,YAAY,GAAGT,iBAAiB,CAACU,gBAAgB,CACrD,qBAAqB,EACrBP,eACF,CAAC;IAED,OAAO,MAAM;MACXC,OAAO,GAAG,KAAK;MACfK,YAAY,CAACE,MAAM,CAAC,CAAC;IACvB,CAAC;EACH,CAAC,EAAE,EAAE,CAAC;EAEN,OAAOT,YAAY;AACrB","ignoreList":[]} diff --git a/lib/typescript/src/GleamContext.d.ts b/lib/typescript/src/GleamContext.d.ts index 34c5570..af68a05 100644 --- a/lib/typescript/src/GleamContext.d.ts +++ b/lib/typescript/src/GleamContext.d.ts @@ -8,6 +8,7 @@ export interface GleamContextValue { intensity: NativeProps['intensity']; baseColor: NativeProps['baseColor']; highlightColor: NativeProps['highlightColor']; + reduceMotion: boolean; registerLine: () => () => void; } export declare const GleamContext: import("react").Context; diff --git a/lib/typescript/src/GleamContext.d.ts.map b/lib/typescript/src/GleamContext.d.ts.map index 2117181..2e69608 100644 --- a/lib/typescript/src/GleamContext.d.ts.map +++ b/lib/typescript/src/GleamContext.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"GleamContext.d.ts","sourceRoot":"","sources":["../../../src/GleamContext.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAE9D,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,WAAW,CAAC,SAAS,CAAC,CAAC;IAChC,KAAK,EAAE,WAAW,CAAC,OAAO,CAAC,CAAC;IAC5B,SAAS,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IACpC,kBAAkB,EAAE,WAAW,CAAC,oBAAoB,CAAC,CAAC;IACtD,cAAc,EAAE,WAAW,CAAC,gBAAgB,CAAC,CAAC;IAC9C,SAAS,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IACpC,SAAS,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IACpC,cAAc,EAAE,WAAW,CAAC,gBAAgB,CAAC,CAAC;IAC9C,YAAY,EAAE,MAAM,MAAM,IAAI,CAAC;CAChC;AAED,eAAO,MAAM,YAAY,mDAAgD,CAAC"} \ No newline at end of file +{"version":3,"file":"GleamContext.d.ts","sourceRoot":"","sources":["../../../src/GleamContext.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAE9D,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,WAAW,CAAC,SAAS,CAAC,CAAC;IAChC,KAAK,EAAE,WAAW,CAAC,OAAO,CAAC,CAAC;IAC5B,SAAS,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IACpC,kBAAkB,EAAE,WAAW,CAAC,oBAAoB,CAAC,CAAC;IACtD,cAAc,EAAE,WAAW,CAAC,gBAAgB,CAAC,CAAC;IAC9C,SAAS,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IACpC,SAAS,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IACpC,cAAc,EAAE,WAAW,CAAC,gBAAgB,CAAC,CAAC;IAC9C,YAAY,EAAE,OAAO,CAAC;IACtB,YAAY,EAAE,MAAM,MAAM,IAAI,CAAC;CAChC;AAED,eAAO,MAAM,YAAY,mDAAgD,CAAC"} \ No newline at end of file diff --git a/lib/typescript/src/GleamLine.d.ts.map b/lib/typescript/src/GleamLine.d.ts.map index ed90f2e..ad8a614 100644 --- a/lib/typescript/src/GleamLine.d.ts.map +++ b/lib/typescript/src/GleamLine.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"GleamLine.d.ts","sourceRoot":"","sources":["../../../src/GleamLine.tsx"],"names":[],"mappings":"AAAA,OAAO,EAA+B,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AACpE,OAAO,EAEL,KAAK,kBAAkB,EACvB,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AACtB,OAAwB,EAAE,KAAK,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAG/E,MAAM,WAAW,cAAe,SAAQ,kBAAkB;IACxD,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,qEAAqE;IACrE,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iFAAiF;IACjF,KAAK,CAAC,EAAE,WAAW,CAAC,OAAO,CAAC,CAAC;IAC7B,sEAAsE;IACtE,eAAe,CAAC,EAAE,WAAW,CAAC,iBAAiB,CAAC,CAAC;CAClD;AAED,wBAAgB,SAAS,CAAC,EACxB,QAAQ,EACR,KAAK,EACL,MAAM,EACN,KAAK,EACL,eAAe,EACf,GAAG,kBAAkB,EACtB,EAAE,cAAc,2CAuChB"} \ No newline at end of file +{"version":3,"file":"GleamLine.d.ts","sourceRoot":"","sources":["../../../src/GleamLine.tsx"],"names":[],"mappings":"AAAA,OAAO,EAA+B,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AACpE,OAAO,EAEL,KAAK,kBAAkB,EACvB,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AACtB,OAAwB,EAAE,KAAK,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAQ/E,MAAM,WAAW,cAAe,SAAQ,kBAAkB;IACxD,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,qEAAqE;IACrE,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iFAAiF;IACjF,KAAK,CAAC,EAAE,WAAW,CAAC,OAAO,CAAC,CAAC;IAC7B,sEAAsE;IACtE,eAAe,CAAC,EAAE,WAAW,CAAC,iBAAiB,CAAC,CAAC;CAClD;AAED,wBAAgB,SAAS,CAAC,EACxB,QAAQ,EACR,KAAK,EACL,MAAM,EACN,KAAK,EACL,eAAe,EACf,GAAG,kBAAkB,EACtB,EAAE,cAAc,2CAgEhB"} \ No newline at end of file diff --git a/lib/typescript/src/gleamAccessibility.d.ts b/lib/typescript/src/gleamAccessibility.d.ts new file mode 100644 index 0000000..d150c89 --- /dev/null +++ b/lib/typescript/src/gleamAccessibility.d.ts @@ -0,0 +1,9 @@ +import type { AccessibilityState, ColorValue } from 'react-native'; +export declare const gleamStyles: Readonly<{ + hiddenContent: { + opacity: number; + }; +}>; +export declare function resolveBaseColor(baseColor?: ColorValue): string; +export declare function gleamAccessibilityState(loading: boolean | undefined, existing?: AccessibilityState | null): AccessibilityState | undefined; +//# sourceMappingURL=gleamAccessibility.d.ts.map \ No newline at end of file diff --git a/lib/typescript/src/gleamAccessibility.d.ts.map b/lib/typescript/src/gleamAccessibility.d.ts.map new file mode 100644 index 0000000..cf194e5 --- /dev/null +++ b/lib/typescript/src/gleamAccessibility.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"gleamAccessibility.d.ts","sourceRoot":"","sources":["../../../src/gleamAccessibility.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAKnE,eAAO,MAAM,WAAW;;;;EAItB,CAAC;AAEH,wBAAgB,gBAAgB,CAAC,SAAS,CAAC,EAAE,UAAU,GAAG,MAAM,CAK/D;AAED,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,OAAO,GAAG,SAAS,EAC5B,QAAQ,CAAC,EAAE,kBAAkB,GAAG,IAAI,GACnC,kBAAkB,GAAG,SAAS,CAShC"} \ No newline at end of file diff --git a/lib/typescript/src/index.d.ts.map b/lib/typescript/src/index.d.ts.map index f05a76d..cc41d92 100644 --- a/lib/typescript/src/index.d.ts.map +++ b/lib/typescript/src/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/index.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAiD,MAAM,OAAO,CAAC;AACtE,OAAO,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AACpC,OAAwB,EAAE,KAAK,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAE/E,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,YAAY,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAC9D,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,WAAW,GAAG;IACzC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;CACvB,CAAC;AAEF,oBAAY,cAAc;IACxB,WAAW,QAAQ;IACnB,WAAW,QAAQ;IACnB,WAAW,QAAQ;CACpB;AAED,oBAAY,eAAe;IACzB,IAAI,SAAS;IACb,MAAM,WAAW;IACjB,QAAQ,aAAa;CACtB;AAwCD,iBAAS,kBAAkB,CAAC,EAC1B,GAAG,EACH,GAAG,KAAK,EACT,EAAE,WAAW,GAAG;IAAE,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;CAAE,2CAqG5C;kBAxGQ,kBAAkB;;;AA4G3B,eAAO,MAAM,SAAS;;CAEpB,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/index.tsx"],"names":[],"mappings":"AAAA,OAAO,KAMN,MAAM,OAAO,CAAC;AACf,OAAO,EAAE,IAAI,EAAkB,MAAM,cAAc,CAAC;AACpD,OAAwB,EAAE,KAAK,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAE/E,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAQxC,YAAY,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAC9D,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,WAAW,GAAG;IACzC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;CACvB,CAAC;AAEF,oBAAY,cAAc;IACxB,WAAW,QAAQ;IACnB,WAAW,QAAQ;IACnB,WAAW,QAAQ;CACpB;AAED,oBAAY,eAAe;IACzB,IAAI,SAAS;IACb,MAAM,WAAW;IACjB,QAAQ,aAAa;CACtB;AA4ED,iBAAS,kBAAkB,CAAC,EAC1B,GAAG,EACH,GAAG,KAAK,EACT,EAAE,WAAW,GAAG;IAAE,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;CAAE,2CA6J5C;kBAhKQ,kBAAkB;;;AAoK3B,eAAO,MAAM,SAAS;;CAEpB,CAAC"} \ No newline at end of file diff --git a/lib/typescript/src/useReduceMotion.d.ts b/lib/typescript/src/useReduceMotion.d.ts new file mode 100644 index 0000000..d25b558 --- /dev/null +++ b/lib/typescript/src/useReduceMotion.d.ts @@ -0,0 +1,3 @@ +/** Subscribes to the platform "reduce motion" accessibility setting. */ +export declare function useReduceMotion(): boolean; +//# sourceMappingURL=useReduceMotion.d.ts.map \ No newline at end of file diff --git a/lib/typescript/src/useReduceMotion.d.ts.map b/lib/typescript/src/useReduceMotion.d.ts.map new file mode 100644 index 0000000..b20b044 --- /dev/null +++ b/lib/typescript/src/useReduceMotion.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"useReduceMotion.d.ts","sourceRoot":"","sources":["../../../src/useReduceMotion.ts"],"names":[],"mappings":"AAGA,wEAAwE;AACxE,wBAAgB,eAAe,IAAI,OAAO,CA4BzC"} \ No newline at end of file diff --git a/package.json b/package.json index f32c8c7..63d7ea3 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", "jest": "^30.3.0", - "prettier": "^2.8.8", + "prettier": "^3.4.2", "react": "19.2.0", "react-native": "0.83.0", "react-native-builder-bob": "^0.40.18", diff --git a/src/GleamContext.ts b/src/GleamContext.ts index 3fe0fc7..77b5a05 100644 --- a/src/GleamContext.ts +++ b/src/GleamContext.ts @@ -10,6 +10,7 @@ export interface GleamContextValue { intensity: NativeProps['intensity']; baseColor: NativeProps['baseColor']; highlightColor: NativeProps['highlightColor']; + reduceMotion: boolean; registerLine: () => () => void; } diff --git a/src/GleamLine.tsx b/src/GleamLine.tsx index 792db4a..89bd1fa 100644 --- a/src/GleamLine.tsx +++ b/src/GleamLine.tsx @@ -7,6 +7,11 @@ import { } from 'react-native'; import NativeGleamView, { type NativeProps } from './GleamViewNativeComponent'; import { GleamContext } from './GleamContext'; +import { + gleamAccessibilityState, + gleamStyles, + resolveBaseColor, +} from './gleamAccessibility'; export interface GleamLineProps extends AccessibilityProps { children?: ReactNode; @@ -29,6 +34,9 @@ export function GleamLine({ }: GleamLineProps) { const ctx = useContext(GleamContext); const register = ctx?.registerLine; + const isLoading = ctx?.loading ?? true; + const useStaticSkeleton = ctx?.reduceMotion && isLoading; + const { accessibilityState, ...restAccessibilityProps } = accessibilityProps; useLayoutEffect(() => { if (!register) return; @@ -46,6 +54,24 @@ export function GleamLine({ ); } + if (useStaticSkeleton) { + return ( + + + {children} + + + ); + } + return ( {children} diff --git a/src/__tests__/GleamView.test.tsx b/src/__tests__/GleamView.test.tsx index 59cad25..6d5420d 100644 --- a/src/__tests__/GleamView.test.tsx +++ b/src/__tests__/GleamView.test.tsx @@ -3,6 +3,24 @@ import { Text, View } from 'react-native'; import { act, render, screen } from '@testing-library/react-native'; import { GleamView, GleamDirection, GleamTransition } from '../index'; +jest.mock('../useReduceMotion', () => ({ + useReduceMotion: jest.fn(() => false), +})); + +import { useReduceMotion } from '../useReduceMotion'; + +const mockUseReduceMotion = useReduceMotion as jest.MockedFunction< + typeof useReduceMotion +>; + +beforeEach(() => { + mockUseReduceMotion.mockReturnValue(false); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + /** * The native component is mocked by react-native's jest preset as a host View. * We inspect the props passed to it to verify JS-level prop resolution. @@ -248,6 +266,13 @@ describe('onTransitionEnd', () => { expect(getNativeProps().onTransitionEnd).toBeDefined(); }); + it('invokes onTransitionEnd when the native event fires', () => { + const handler = jest.fn(); + render(); + getNativeProps().onTransitionEnd({ nativeEvent: { finished: true } }); + expect(handler).toHaveBeenCalledWith({ nativeEvent: { finished: true } }); + }); + it('does not pass callback when not provided', () => { render(); expect(getNativeProps().onTransitionEnd).toBeUndefined(); @@ -1005,9 +1030,9 @@ describe('GleamView.Line ref stability', () => { expect(mountCalls).toHaveLength(1); }); - it('ref re-fires when Lines are inside an intermediate wrapper', () => { + it('ref is stable on first render with Lines inside an intermediate wrapper', () => { function Wrapper({ children }: { children: React.ReactNode }) { - return <>{children}; + return {children}; } const refCalls: unknown[] = []; @@ -1025,12 +1050,8 @@ describe('GleamView.Line ref stability', () => { ); - // Wrapper is not a Fragment, so hasLineChildren can't detect Lines - // synchronously. First render is NativeGleamView, then useLayoutEffect - // registers the Line and flips to View → ref receives two instances. - // This is a known limitation documented here. const mountCalls = refCalls.filter((n) => n !== null); - expect(mountCalls).toHaveLength(2); + expect(mountCalls).toHaveLength(1); }); }); @@ -1074,6 +1095,117 @@ describe('GleamView.Line outside parent', () => { }); }); +// --------------------------------------------------------------------------- +// Accessibility +// --------------------------------------------------------------------------- +describe('accessibility', () => { + it('sets accessibilityState.busy while loading', () => { + render(); + expect(getNativeProps().accessibilityState).toEqual({ busy: true }); + }); + + it('preserves existing accessibilityState while loading', () => { + render( + + ); + expect(getNativeProps().accessibilityState).toEqual({ + disabled: true, + busy: true, + }); + }); + + it('does not force busy when loading=false', () => { + render(); + expect(getNativeProps().accessibilityState).toBeUndefined(); + }); + + it('sets busy on GleamView.Line while loading', () => { + render( + + + Content + + + ); + expect(screen.getByTestId('line').props.accessibilityState).toEqual({ + busy: true, + }); + }); +}); + +// --------------------------------------------------------------------------- +// Reduce motion +// --------------------------------------------------------------------------- +describe('reduce motion', () => { + it('renders a static skeleton instead of the native shimmer', () => { + mockUseReduceMotion.mockReturnValue(true); + + render( + + Content + + ); + + const view = screen.getByTestId('gleam'); + expect(view.props.loading).toBeUndefined(); + expect(view.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ backgroundColor: '#ABCDEF' }), + ]) + ); + }); + + it('uses native shimmer again when reduce motion is disabled', () => { + mockUseReduceMotion.mockReturnValue(false); + + render(); + expect(getNativeProps().loading).toBe(true); + }); + + it('renders static GleamView.Line bars when reduce motion is enabled', () => { + mockUseReduceMotion.mockReturnValue(true); + + render( + + + Content + + + ); + + const line = screen.getByTestId('line'); + expect(line.props.loading).toBeUndefined(); + expect(line.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ backgroundColor: '#ABCDEF' }), + expect.objectContaining({ height: 20 }), + ]) + ); + }); +}); + +// --------------------------------------------------------------------------- +// DEV warnings +// --------------------------------------------------------------------------- +describe('DEV warnings', () => { + it('warns when loading is omitted but children are present', () => { + const spy = jest.spyOn(console, 'warn').mockImplementation(); + render( + + Content + + ); + expect(spy).toHaveBeenCalledWith( + expect.stringContaining('loading defaults to true') + ); + spy.mockRestore(); + }); +}); + // --------------------------------------------------------------------------- // Exports // --------------------------------------------------------------------------- diff --git a/src/gleamAccessibility.ts b/src/gleamAccessibility.ts new file mode 100644 index 0000000..4e66a71 --- /dev/null +++ b/src/gleamAccessibility.ts @@ -0,0 +1,31 @@ +import type { AccessibilityState, ColorValue } from 'react-native'; +import { StyleSheet } from 'react-native'; + +const DEFAULT_BASE_COLOR = '#E0E0E0'; + +export const gleamStyles = StyleSheet.create({ + hiddenContent: { + opacity: 0, + }, +}); + +export function resolveBaseColor(baseColor?: ColorValue): string { + if (typeof baseColor === 'string') { + return baseColor; + } + return DEFAULT_BASE_COLOR; +} + +export function gleamAccessibilityState( + loading: boolean | undefined, + existing?: AccessibilityState | null +): AccessibilityState | undefined { + if (!loading) { + return existing ?? undefined; + } + + return { + ...existing, + busy: true, + }; +} diff --git a/src/index.tsx b/src/index.tsx index 6da117a..811ecaa 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,8 +1,20 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { View } from 'react-native'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { View, type ViewProps } from 'react-native'; import NativeGleamView, { type NativeProps } from './GleamViewNativeComponent'; import { GleamContext, type GleamContextValue } from './GleamContext'; import { GleamLine } from './GleamLine'; +import { + gleamAccessibilityState, + gleamStyles, + resolveBaseColor, +} from './gleamAccessibility'; +import { useReduceMotion } from './useReduceMotion'; export type { NativeProps } from './GleamViewNativeComponent'; export type { GleamLineProps } from './GleamLine'; @@ -46,22 +58,58 @@ const SHIMMER_KEYS: ReadonlySet = new Set([ 'onTransitionEnd', ] as const satisfies ReadonlyArray); -function hasLineChildren(children: React.ReactNode): boolean { +function scanLineChildren( + children: React.ReactNode, + gleamViewType: unknown +): boolean { let found = false; React.Children.forEach(children, (child) => { if (found) return; if (!React.isValidElement(child)) return; if (child.type === GleamLine) { found = true; - } else if (child.type === React.Fragment) { - found = hasLineChildren( - (child.props as { children?: React.ReactNode }).children - ); + return; + } + if (child.type === gleamViewType) { + return; + } + const nested = (child.props as { children?: React.ReactNode }).children; + if (nested != null) { + found = scanLineChildren(nested, gleamViewType); } }); return found; } +function StaticSkeleton({ + ref, + loading, + baseColor, + viewProps, + children, +}: { + ref: React.Ref; + loading: boolean; + baseColor?: NativeProps['baseColor']; + viewProps: Omit; + children: React.ReactNode; +}) { + const { accessibilityState, style, ...restViewProps } = viewProps; + + return ( + + + {children} + + + ); +} + // React 19: ref is a regular prop, no forwardRef needed. // Internal ref type is loosened to avoid monorepo type conflicts between // root and example workspace @types/react copies. The exported GleamViewProps @@ -86,6 +134,10 @@ function GleamViewComponent({ ...viewProps } = props; + const reduceMotion = useReduceMotion(); + const isLoading = loading ?? true; + const useStaticSkeleton = reduceMotion && isLoading; + if (__DEV__) { for (const key of Object.keys(viewProps)) { if (SHIMMER_KEYS.has(key)) { @@ -99,17 +151,38 @@ function GleamViewComponent({ const lineCountRef = useRef(0); const warnedTransitionRef = useRef(false); - const [hasLines, setHasLines] = useState(() => hasLineChildren(children)); + const warnedDefaultLoadingRef = useRef(false); + const hasLinesInTree = useMemo( + () => scanLineChildren(children, GleamViewComponent), + [children] + ); + const [hasRegisteredLines, setHasRegisteredLines] = useState(false); + const hasLines = hasLinesInTree || hasRegisteredLines; + + useEffect(() => { + if ( + __DEV__ && + !warnedDefaultLoadingRef.current && + loading === undefined && + children != null && + !hasLines + ) { + warnedDefaultLoadingRef.current = true; + console.warn( + 'GleamView: loading defaults to true — children stay hidden until loading={false}.' + ); + } + }, [children, hasLines, loading]); const registerLine = useCallback(() => { lineCountRef.current++; - setHasLines(true); + setHasRegisteredLines(true); return () => { lineCountRef.current--; if (lineCountRef.current === 0) { queueMicrotask(() => { if (lineCountRef.current === 0) { - setHasLines(false); + setHasRegisteredLines(false); warnedTransitionRef.current = false; } }); @@ -128,6 +201,7 @@ function GleamViewComponent({ baseColor, highlightColor, registerLine, + reduceMotion, }), [ loading, @@ -139,6 +213,7 @@ function GleamViewComponent({ baseColor, highlightColor, registerLine, + reduceMotion, ] ); @@ -156,16 +231,45 @@ function GleamViewComponent({ } return ( - + {children} ); } + if (useStaticSkeleton) { + return ( + + + {children} + + + ); + } + return ( - + {children} diff --git a/src/useReduceMotion.ts b/src/useReduceMotion.ts new file mode 100644 index 0000000..066eb21 --- /dev/null +++ b/src/useReduceMotion.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react'; +import { AccessibilityInfo } from 'react-native'; + +/** Subscribes to the platform "reduce motion" accessibility setting. */ +export function useReduceMotion(): boolean { + const [reduceMotion, setReduceMotion] = useState(false); + + useEffect(() => { + let mounted = true; + + AccessibilityInfo.isReduceMotionEnabled() + .then((enabled) => { + if (mounted) { + setReduceMotion(enabled); + } + }) + .catch(() => { + // Ignore — treat as motion allowed when the API is unavailable. + }); + + const subscription = AccessibilityInfo.addEventListener( + 'reduceMotionChanged', + setReduceMotion + ); + + return () => { + mounted = false; + subscription.remove(); + }; + }, []); + + return reduceMotion; +} diff --git a/yarn.lock b/yarn.lock index ae2991b..f973bf6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8466,12 +8466,12 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^2.8.8": - version: 2.8.8 - resolution: "prettier@npm:2.8.8" +"prettier@npm:^3.4.2": + version: 3.9.3 + resolution: "prettier@npm:3.9.3" bin: - prettier: bin-prettier.js - checksum: 10c0/463ea8f9a0946cd5b828d8cf27bd8b567345cf02f56562d5ecde198b91f47a76b7ac9eae0facd247ace70e927143af6135e8cf411986b8cb8478784a4d6d724a + prettier: bin/prettier.cjs + checksum: 10c0/56623a03f0a1a791413b76458dbcab1dff8e5a7d02744be98ccc589a3a8cf8371dda8fba68f2d2336ded8475ed40f6eaf66f0f4d39df33235656018f64d2fdac languageName: node linkType: hard @@ -8705,7 +8705,7 @@ __metadata: eslint-config-prettier: "npm:^10.1.8" eslint-plugin-prettier: "npm:^5.5.4" jest: "npm:^30.3.0" - prettier: "npm:^2.8.8" + prettier: "npm:^3.4.2" react: "npm:19.2.0" react-native: "npm:0.83.0" react-native-builder-bob: "npm:^0.40.18"