@sigx/lynx-daisyui 0.4.1 → 0.4.3

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/README.md CHANGED
@@ -83,10 +83,88 @@ layout role. For multi-class compositions (color + modifier),
83
83
  `theme.set('daisy-light daisy-rounded')` works — the class string is
84
84
  applied verbatim to the host view.
85
85
 
86
+ ### Two layers: content vs. OS chrome
87
+
88
+ A theme drives two different things, and they scope differently:
89
+
90
+ 1. **In-app content** — the `--color-*` / radius variables and icon
91
+ tints. These live on a host view and inherit down a subtree, so they
92
+ are genuinely *scopable*.
93
+ 2. **OS chrome** — the status- and navigation-bar tint (pushed by
94
+ `<StatusBarSync>`). This is a global OS singleton; it can only reflect
95
+ one theme at a time.
96
+
97
+ The rule:
98
+
99
+ > `useTheme()` is the theme for the **content you render** — the nearest
100
+ > `<ThemeProvider>`, or the app-global theme at the root / in headless
101
+ > code. **System chrome always follows the global theme.** `StatusBarSync`
102
+ > binds to the global controller, so a nested provider can't hijack the
103
+ > bars.
104
+ >
105
+ > *Scopes recolor pixels you draw; only the global theme touches the OS.*
106
+
107
+ This mirrors Flutter, where `Theme` nests freely for content while system
108
+ chrome goes through a separate channel (`AnnotatedRegion`/`SystemChrome`).
109
+
110
+ ### Headless control (no provider required)
111
+
112
+ The active theme lives in a module-level singleton, so you can read and
113
+ set it from anywhere — a store, a service, app-boot logic, an effect —
114
+ without a mounted `<ThemeProvider>` ancestor. `useTheme()` resolves to
115
+ this same controller when no provider is in scope (it never throws).
116
+
117
+ ```tsx
118
+ import { themeController } from '@sigx/lynx-daisyui';
119
+
120
+ // From any non-component module:
121
+ themeController.set('daisy-dark');
122
+ themeController.toggle();
123
+ themeController.followSystem();
124
+ themeController.name; // current selection
125
+ ```
126
+
127
+ A mounted root `<ThemeProvider>` binds this singleton, so headless
128
+ mutations render and the OS bars follow.
129
+
130
+ ### Per-screen themes
131
+
132
+ Different screens can use different themes — and the status-bar icons
133
+ follow the active screen so they stay legible. Because this drives the
134
+ **global** theme, the bars update automatically:
135
+
136
+ ```tsx
137
+ import { useScreenTheme } from '@sigx/lynx-daisyui';
138
+
139
+ const Gallery = component(() => {
140
+ useScreenTheme('daisy-dark'); // dark (incl. status bar) while focused; restored on blur
141
+ return () => <view>…</view>;
142
+ });
143
+ ```
144
+
145
+ `useScreenTheme` is built on `@sigx/lynx-navigation`'s `useFocusEffect`
146
+ (an optional peer) and must be called from a routed screen.
147
+
148
+ ### Scoped sub-overrides
149
+
150
+ To recolor just a **region** without touching the OS bars, nest a
151
+ `<ThemeProvider>`. Its subtree (content + icons) re-themes; the status
152
+ bar stays on the global theme.
153
+
154
+ ```tsx
155
+ <ThemeProvider initial="daisy-light">
156
+ <App />
157
+ {/* this card renders synthwave; the status bar stays light */}
158
+ <ThemeProvider initial="daisy-synthwave">
159
+ <PreviewCard />
160
+ </ThemeProvider>
161
+ </ThemeProvider>
162
+ ```
163
+
86
164
  ## Navigation chrome
87
165
 
88
166
  Two daisy-themed components that pair with
89
- [`@sigx/lynx-navigation`](../lynx-navigation). Both read state via the
167
+ [`@sigx/lynx-navigation`](https://github.com/signalxjs/lynx/tree/main/packages/lynx-navigation). Both read state via the
90
168
  navigation package's hooks (no internal-module imports), so swapping
91
169
  in custom designs later is a one-component change.
92
170
 
@@ -143,6 +221,44 @@ For a fully-custom design, build directly on
143
221
  `useScreenChrome()` from `@sigx/lynx-navigation` — `NavHeader` is just
144
222
  one consumer of that hook.
145
223
 
224
+ ### `<SwiperIndicator>`
225
+
226
+ Themed wrapper around the headless `useSwiperDot*` hooks from
227
+ [`@sigx/lynx-gestures`](https://github.com/signalxjs/lynx/tree/main/packages/lynx-gestures#swiper-and-headless-dot-hooks).
228
+ Reads colours from the active daisy theme so the indicator follows light
229
+ / dark mode automatically.
230
+
231
+ ```tsx
232
+ import { Swiper } from '@sigx/lynx-gestures';
233
+ import { SwiperIndicator } from '@sigx/lynx-daisyui';
234
+
235
+ <Swiper offset={offset} index={pageIdx} width={pageWidth}>{pages}</Swiper>
236
+ <SwiperIndicator
237
+ variant="dots"
238
+ count={pages.length}
239
+ offset={offset}
240
+ pageWidth={pageWidth}
241
+ index={pageIdx}
242
+ color="primary"
243
+ onDotPress={(i) => { pageIdx.value = i }}
244
+ />
245
+ ```
246
+
247
+ | Variant | Animated channel | Notes |
248
+ | ------------- | ---------------------------- | ------------------------------------------------------------------ |
249
+ | `dots` | `opacity` crossfade | Default. Two-colour overlay per dot. |
250
+ | `bar` | `translateX` (single thumb) | One MT binding regardless of page count — cheapest for long lists. |
251
+ | `pill` | `scaleX` + `opacity` | Active dot stretches into a pill while overlay fades in. |
252
+ | `scale-pulse` | uniform `scale` | Monochrome pulse — no colour crossfade. |
253
+ | `numbered` | none (BG-thread text) | Renders `n / total`. Requires `index` signal. |
254
+
255
+ Props: `count`, `offset` (`SharedValue<number>`), `pageWidth`, `index`
256
+ (`PrimitiveSignal<number>`, required for `numbered`), `color`, `inactiveColor`
257
+ (daisy tokens), `size` (`'xs' | 'sm' | 'md' | 'lg'`), `onDotPress`.
258
+
259
+ For a non-standard visual, skip this component and call the headless
260
+ hooks directly — they're the same primitives this component composes.
261
+
146
262
  ## Layout primitives
147
263
 
148
264
  Daisy's flex primitives (`Center`, `Col`, `Row`) accept a `flex={n}`
package/dist/index.d.ts CHANGED
@@ -50,8 +50,16 @@ export { NavTabBar } from './navigation/NavTabBar.js';
50
50
  export type { NavTabBarProps, NavTabBarPosition, NavTabBarBackground, NavTabRenderContext, } from './navigation/NavTabBar.js';
51
51
  export { NavHeader } from './navigation/NavHeader.js';
52
52
  export type { NavHeaderProps, NavHeaderBackground, } from './navigation/NavHeader.js';
53
- export { ThemeProvider, useTheme } from './theme/ThemeProvider.js';
54
- export type { DaisyTheme, ThemeController, ThemeProviderProps, } from './theme/ThemeProvider.js';
53
+ export { NavDrawer } from './navigation/NavDrawer.js';
54
+ export type { NavDrawerProps, NavDrawerSide, } from './navigation/NavDrawer.js';
55
+ export { SwiperIndicator } from './navigation/SwiperIndicator.js';
56
+ export type { SwiperIndicatorProps, SwiperIndicatorVariant, SwiperIndicatorSize, } from './navigation/SwiperIndicator.js';
57
+ export { ThemeProvider, useTheme, listThemes, registerTheme, extendTheme, pickThemeFor, pairOf, variantOf, colorsOf, radiusOf, } from './theme/ThemeProvider.js';
58
+ export type { DaisyTheme, ThemeController, ThemeProviderProps, Theme, ThemePalette, ThemeRadius, ThemeVariant, } from './theme/ThemeProvider.js';
59
+ export { StatusBarSync } from './theme/StatusBarSync.js';
60
+ export type { StatusBarSyncProps } from './theme/StatusBarSync.js';
61
+ export { themeController } from './theme/theme-state.js';
62
+ export { useScreenTheme } from './theme/use-screen-theme.js';
55
63
  export { Avatar } from './data/Avatar.js';
56
64
  export type { AvatarProps, AvatarSize } from './data/Avatar.js';
57
65
  export { Text } from './typography/Text.js';
package/dist/index.js CHANGED
@@ -29,8 +29,18 @@ export { Steps } from './feedback/Steps.js';
29
29
  export { Tabs } from './navigation/Tabs.js';
30
30
  export { NavTabBar } from './navigation/NavTabBar.js';
31
31
  export { NavHeader } from './navigation/NavHeader.js';
32
+ export { NavDrawer } from './navigation/NavDrawer.js';
33
+ export { SwiperIndicator } from './navigation/SwiperIndicator.js';
32
34
  // Theme
33
- export { ThemeProvider, useTheme } from './theme/ThemeProvider.js';
35
+ export { ThemeProvider, useTheme, listThemes, registerTheme, extendTheme, pickThemeFor, pairOf, variantOf, colorsOf, radiusOf, } from './theme/ThemeProvider.js';
36
+ export { StatusBarSync } from './theme/StatusBarSync.js';
37
+ // Headless theme handle (issue #113): import and call from anywhere — stores,
38
+ // services, effects, app-boot — with no `<ThemeProvider>` ancestor required.
39
+ // `useTheme()` resolves to this when no provider is in scope.
40
+ export { themeController } from './theme/theme-state.js';
41
+ // Per-screen theming: pin the global theme while a navigation screen is focused
42
+ // (requires the optional `@sigx/lynx-navigation` peer).
43
+ export { useScreenTheme } from './theme/use-screen-theme.js';
34
44
  // Data
35
45
  export { Avatar } from './data/Avatar.js';
36
46
  // Typography
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `<NavDrawer>` — daisy-themed off-canvas drawer for `@sigx/lynx-navigation`.
3
+ *
4
+ * Composes the primitive `<Drawer>` purely as the state provider (so
5
+ * `useDrawer()` resolves for descendants) and drives its own
6
+ * `SharedValue`-backed slide + fade transition via `@sigx/lynx-motion`.
7
+ *
8
+ * Behavior:
9
+ * - Panel translates from off-screen on the configured `side` to `0`
10
+ * on open (and back on close). Default side is `'left'`.
11
+ * - Backdrop fades 0 → 0.3 in tandem.
12
+ * - Chrome mounts on open and unmounts after the exit animation completes,
13
+ * so the closed-state drawer doesn't intercept taps to underlying tabs.
14
+ * - Backdrop is a plain `<view bindtap>` — no Pressable scale/opacity
15
+ * feedback (which flickers an opaque scrim).
16
+ *
17
+ * Usage:
18
+ *
19
+ * ```tsx
20
+ * <NavigationRoot routes={routes}>
21
+ * <NavDrawer slots={{ sidebar: () => <MyMenu /> }}>
22
+ * <Stack />
23
+ * </NavDrawer>
24
+ * </NavigationRoot>
25
+ * ```
26
+ *
27
+ * Inside descendants, `useDrawer()` from `@sigx/lynx-navigation` returns
28
+ * `{ isOpen, open, close, toggle }`.
29
+ *
30
+ * The primitive's own `<Drawer />` is intentionally minimal (state +
31
+ * `display: none` overlay only); this component is the
32
+ * batteries-included variant for daisyui consumers.
33
+ */
34
+ import { type Define, type JSXElement } from '@sigx/lynx';
35
+ import { type BackgroundValue } from '../shared/styles.js';
36
+ export type NavDrawerSide = 'left' | 'right';
37
+ export type NavDrawerProps =
38
+ /** Which edge the panel slides in from. Default 'left'. */
39
+ Define.Prop<'side', NavDrawerSide, false>
40
+ /** Panel surface color. Accepts daisy tokens ('base-100', 'primary', …)
41
+ * — applied as a `bg-<token>` Tailwind class so the daisy preset's
42
+ * CSS-pipeline rule resolves the `var(--color-<token>)`. Also accepts
43
+ * raw CSS color strings ('#facc15', 'rgb(...)') — applied as inline
44
+ * `backgroundColor`. Default 'base-100'. */
45
+ & Define.Prop<'background', BackgroundValue, false>
46
+ /** Show a separator line on the panel's inner edge. Default true. */
47
+ & Define.Prop<'bordered', boolean, false>
48
+ /** Render a dismiss-on-tap scrim over the main content when open. Default true. */
49
+ & Define.Prop<'backdrop', boolean, false>
50
+ /** Panel width in pixels. Default 280. */
51
+ & Define.Prop<'width', number, false>
52
+ /** Open the drawer at mount. Default false. Passthrough to primitive `<Drawer>`. */
53
+ & Define.Prop<'initialOpen', boolean, false>
54
+ /** Drawer panel contents — your menu UI. */
55
+ & Define.Slot<'sidebar'>
56
+ /** Main content — usually a `<Stack>` or `<Tabs>`. */
57
+ & Define.Slot<'default'>;
58
+ export declare const NavDrawer: import("@sigx/runtime-core").ComponentFactory<NavDrawerProps, void, {
59
+ sidebar: () => JSXElement | JSXElement[] | null;
60
+ } & {
61
+ default: () => JSXElement | JSXElement[] | null;
62
+ }>;
@@ -0,0 +1,205 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@sigx/lynx/jsx-runtime";
2
+ /**
3
+ * `<NavDrawer>` — daisy-themed off-canvas drawer for `@sigx/lynx-navigation`.
4
+ *
5
+ * Composes the primitive `<Drawer>` purely as the state provider (so
6
+ * `useDrawer()` resolves for descendants) and drives its own
7
+ * `SharedValue`-backed slide + fade transition via `@sigx/lynx-motion`.
8
+ *
9
+ * Behavior:
10
+ * - Panel translates from off-screen on the configured `side` to `0`
11
+ * on open (and back on close). Default side is `'left'`.
12
+ * - Backdrop fades 0 → 0.3 in tandem.
13
+ * - Chrome mounts on open and unmounts after the exit animation completes,
14
+ * so the closed-state drawer doesn't intercept taps to underlying tabs.
15
+ * - Backdrop is a plain `<view bindtap>` — no Pressable scale/opacity
16
+ * feedback (which flickers an opaque scrim).
17
+ *
18
+ * Usage:
19
+ *
20
+ * ```tsx
21
+ * <NavigationRoot routes={routes}>
22
+ * <NavDrawer slots={{ sidebar: () => <MyMenu /> }}>
23
+ * <Stack />
24
+ * </NavDrawer>
25
+ * </NavigationRoot>
26
+ * ```
27
+ *
28
+ * Inside descendants, `useDrawer()` from `@sigx/lynx-navigation` returns
29
+ * `{ isOpen, open, close, toggle }`.
30
+ *
31
+ * The primitive's own `<Drawer />` is intentionally minimal (state +
32
+ * `display: none` overlay only); this component is the
33
+ * batteries-included variant for daisyui consumers.
34
+ */
35
+ import { component, effect, onUnmounted, runOnMainThread, signal, untrack, useAnimatedStyle, useMainThreadRef, useSharedValue, } from '@sigx/lynx';
36
+ import { withTiming } from '@sigx/lynx-motion';
37
+ import { Drawer, useDrawer } from '@sigx/lynx-navigation';
38
+ import { resolveDaisyColor } from '../shared/styles.js';
39
+ /**
40
+ * Slide-in / fade-in timing. Slightly longer than the slide-out so the
41
+ * drawer feels deliberate on open and snappy on dismiss — matches the
42
+ * convention used by Stack's push/pop transitions in `lynx-navigation`.
43
+ */
44
+ const ENTER_DURATION_SEC = 0.28;
45
+ const EXIT_DURATION_SEC = 0.22;
46
+ const EXIT_DURATION_MS = Math.round(EXIT_DURATION_SEC * 1000);
47
+ const BACKDROP_OPACITY = 0.3;
48
+ export const NavDrawer = component(({ props, slots }) => {
49
+ return () => (_jsx(Drawer, { initialOpen: props.initialOpen, children: _jsx(NavDrawerShell, { side: props.side ?? 'left', background: props.background ?? 'base-100', bordered: props.bordered ?? true, backdrop: props.backdrop ?? true, width: props.width ?? 280, renderSidebar: slots.sidebar, children: slots.default?.() }) }));
50
+ });
51
+ const NavDrawerShell = component(({ props, slots }) => {
52
+ const drawer = useDrawer();
53
+ // Seed progress from current open state so `initialOpen=true` mounts
54
+ // already-open without a slide-in flash.
55
+ const progress = useSharedValue(drawer.isOpen ? 1 : 0);
56
+ const shouldRender = signal(drawer.isOpen);
57
+ // Track whether the chrome is currently mounted (or animating out) so the
58
+ // initial effect tick on a closed drawer doesn't kick a no-op close
59
+ // animation + unmount timer.
60
+ let chromeMounted = drawer.isOpen;
61
+ let exitTimer = null;
62
+ // Pre-register the worklets at setup so the SWC main-thread transform
63
+ // captures `progress` once. Re-registering on every effect tick would
64
+ // re-ship the worklet body across the bridge unnecessarily.
65
+ const openAnim = runOnMainThread(() => {
66
+ 'main thread';
67
+ withTiming(progress, 1, { duration: ENTER_DURATION_SEC });
68
+ });
69
+ const closeAnim = runOnMainThread(() => {
70
+ 'main thread';
71
+ withTiming(progress, 0, { duration: EXIT_DURATION_SEC });
72
+ });
73
+ const animRunner = effect(() => {
74
+ const open = drawer.isOpen;
75
+ if (open) {
76
+ if (exitTimer != null) {
77
+ clearTimeout(exitTimer);
78
+ exitTimer = null;
79
+ }
80
+ chromeMounted = true;
81
+ untrack(() => {
82
+ shouldRender.value = true;
83
+ });
84
+ openAnim();
85
+ }
86
+ else if (chromeMounted) {
87
+ chromeMounted = false;
88
+ closeAnim();
89
+ // Wait for the exit animation to finish before unmounting the
90
+ // chrome — otherwise the panel pops out instead of sliding,
91
+ // and the backdrop's bindtap area disappears mid-fade.
92
+ exitTimer = setTimeout(() => {
93
+ untrack(() => {
94
+ shouldRender.value = false;
95
+ });
96
+ exitTimer = null;
97
+ }, EXIT_DURATION_MS);
98
+ }
99
+ // else: drawer is closed and the chrome was never mounted (the
100
+ // common initial-mount case) — nothing to animate or schedule.
101
+ });
102
+ onUnmounted(() => {
103
+ animRunner.stop();
104
+ if (exitTimer != null)
105
+ clearTimeout(exitTimer);
106
+ });
107
+ return () => {
108
+ return (_jsxs("view", { style: {
109
+ display: 'flex',
110
+ flexDirection: 'column',
111
+ position: 'relative',
112
+ width: '100%',
113
+ height: '100%',
114
+ }, children: [slots.default?.(), shouldRender.value
115
+ ? (_jsx(DrawerChrome
116
+ // Key by side+width — `useAnimatedStyle`
117
+ // snapshots `outputRange` at setup, so a
118
+ // runtime change to either (panel slide
119
+ // distance is signed by side, magnitude by
120
+ // width) needs a remount + rebind. Width
121
+ // changes mid-open are vanishingly rare;
122
+ // toggling `side` likewise. The explicit
123
+ // remount keeps the binding consistent if
124
+ // a consumer wires either to a reactive
125
+ // value.
126
+ , { side: props.side, progress: progress, width: props.width, background: props.background, bordered: props.bordered, backdrop: props.backdrop, renderSidebar: props.renderSidebar, onBackdropPress: () => drawer.close() }, `drawer-chrome-${props.side}-${props.width}`))
127
+ : null] }));
128
+ };
129
+ });
130
+ const DrawerChrome = component(({ props }) => {
131
+ const panelRef = useMainThreadRef(null);
132
+ const backdropRef = useMainThreadRef(null);
133
+ // Slide range mirrors `side`: left-side starts at `-width` (off-screen
134
+ // left) and lands at `0`; right-side starts at `+width` and lands at `0`.
135
+ // Capture once — NavDrawerShell remounts on side/width change to rebind.
136
+ const closedTx = props.side === 'right' ? props.width : -props.width;
137
+ // Bind once at setup. `useAnimatedStyle` snapshots its mapper/range
138
+ // params at registration time; NavDrawerShell keys DrawerChrome by
139
+ // side+width so a change to either forces a remount + rebind here.
140
+ useAnimatedStyle(panelRef, props.progress, 'translateX', {
141
+ inputRange: [0, 1],
142
+ outputRange: [closedTx, 0],
143
+ });
144
+ // Register unconditionally so a runtime `backdrop` toggle works
145
+ // both directions. `useAnimatedStyle` only binds once at setup; if
146
+ // this lived inside `if (props.backdrop)` a false→true toggle would
147
+ // mount a backdrop view with no opacity binding, leaving it stuck
148
+ // at the inline `opacity: 0` seed. When the backdrop view isn't
149
+ // rendered, `backdropRef.current` is null and the MT bridge's
150
+ // `setStyleProperties` apply silently skips — no harm.
151
+ useAnimatedStyle(backdropRef, props.progress, 'opacity', {
152
+ inputRange: [0, 1],
153
+ outputRange: [0, BACKDROP_OPACITY],
154
+ });
155
+ return () => {
156
+ const isRight = props.side === 'right';
157
+ // Lynx resolves `var(--color-*)` inside CSS-pipeline rules (Tailwind
158
+ // classes, stylesheet imports) but NOT inside inline `style.backgroundColor`
159
+ // — an inline `'var(--color-base-100)'` paints transparent. So for known
160
+ // daisy tokens we apply the surface via the Tailwind class `bg-<token>`
161
+ // (which the daisy preset compiles to a `var()` rule that DOES resolve);
162
+ // raw CSS strings ('#facc15', 'rgb(...)', 'var(--my-custom)') fall through
163
+ // to inline because there's no compiled class to use for them.
164
+ const resolved = resolveDaisyColor(props.background);
165
+ const isDaisyToken = resolved !== props.background;
166
+ const bgClass = isDaisyToken ? `bg-${props.background}` : '';
167
+ // Border lives on the panel's *inner* edge (the one facing the
168
+ // main content). Daisy class names are still the cleanest way to
169
+ // pick up `--color-base-300` for the separator hairline.
170
+ const borderClass = props.bordered
171
+ ? (isRight ? 'border-l border-base-300' : 'border-r border-base-300')
172
+ : '';
173
+ const panelClass = [bgClass, borderClass].filter(Boolean).join(' ');
174
+ const panelStyle = {
175
+ position: 'absolute',
176
+ top: 0,
177
+ bottom: 0,
178
+ width: props.width,
179
+ };
180
+ if (!isDaisyToken)
181
+ panelStyle.backgroundColor = props.background;
182
+ // Only the side-relevant inset is set; omitting the other lets
183
+ // the panel size to `width` rather than stretching edge-to-edge.
184
+ if (isRight)
185
+ panelStyle.right = 0;
186
+ else
187
+ panelStyle.left = 0;
188
+ return (_jsxs("view", { style: {
189
+ position: 'absolute',
190
+ top: 0,
191
+ left: 0,
192
+ right: 0,
193
+ bottom: 0,
194
+ }, children: [props.backdrop
195
+ ? (_jsx("view", { "main-thread:ref": backdropRef, bindtap: () => props.onBackdropPress(), class: "bg-base-content", style: {
196
+ position: 'absolute',
197
+ top: 0,
198
+ left: 0,
199
+ right: 0,
200
+ bottom: 0,
201
+ opacity: 0,
202
+ }, "accessibility-element": true, "accessibility-label": "Close drawer", "accessibility-trait": "button" }))
203
+ : null, _jsx("view", { "main-thread:ref": panelRef, class: panelClass, style: panelStyle, children: props.renderSidebar?.() })] }));
204
+ };
205
+ });
@@ -19,13 +19,24 @@
19
19
  * for daisyui consumers.
20
20
  */
21
21
  import { type Define, type JSXElement } from '@sigx/lynx';
22
+ import { type IconSpec } from '@sigx/lynx-icons';
22
23
  export type NavHeaderBackground = 'base-100' | 'base-200' | 'base-300' | 'transparent';
23
24
  export type NavHeaderProps =
24
25
  /** Surface color token. Default 'base-200'. */
25
26
  Define.Prop<'background', NavHeaderBackground, false>
26
27
  /** Show a separator line at the bottom. Default true. */
27
28
  & Define.Prop<'bordered', boolean, false>
28
- /** Replace the back button entirely. Receives `pop` so the custom node can wire its own tap handler. */
29
+ /**
30
+ * Render the back chevron from an `IconSpec` (e.g. `{ set: 'lucide',
31
+ * name: 'chevron-left' }`). The icon is rendered with
32
+ * `variant="primary"`, which `<ThemeProvider>`'s color resolver maps
33
+ * to the daisy primary hex and substitutes into the SVG `fill=`.
34
+ * Wrapped in a Pressable wired to the stack's pop. Falls back to the
35
+ * default "‹ Back" text when not provided. Ignored when `renderBack`
36
+ * or `<Screen.HeaderLeft>` is also supplied — those win.
37
+ */
38
+ & Define.Prop<'backIcon', IconSpec, false>
39
+ /** Full override: render any JSX for the back button. Takes priority over `backIcon`. */
29
40
  & Define.Prop<'renderBack', (ctx: {
30
41
  pop: () => void;
31
42
  }) => JSXElement, false>;
@@ -21,8 +21,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "@sigx/lynx/jsx-runtime";
21
21
  */
22
22
  import { component } from '@sigx/lynx';
23
23
  import { Pressable } from '@sigx/lynx-gestures';
24
+ import { Icon } from '@sigx/lynx-icons';
24
25
  import { useScreenChrome } from '@sigx/lynx-navigation';
25
26
  import { PRESSED_SCALE, PRESSED_OPACITY } from '../shared/press.js';
27
+ /** Pixel size used when rendering the back-button icon from an `IconSpec`. */
28
+ const NAV_HEADER_ICON_SIZE = 22;
26
29
  const backgroundClass = {
27
30
  'base-100': 'bg-base-100',
28
31
  'base-200': 'bg-base-200',
@@ -47,11 +50,17 @@ export const NavHeader = component(({ props }) => {
47
50
  bg,
48
51
  borderClass,
49
52
  ].filter(Boolean).join(' ');
53
+ // Resolution order: <Screen.HeaderLeft> slot fill → custom renderBack
54
+ // → backIcon spec → default text. The spec path renders `<Icon>`
55
+ // with `variant="primary"`, which the daisy resolver maps to the
56
+ // primary hex and substitutes into the SVG `fill=`.
50
57
  const left = chrome.headerLeft?.()
51
58
  ?? (chrome.canGoBack
52
59
  ? (props.renderBack
53
60
  ? props.renderBack({ pop: chrome.pop })
54
- : _jsx(DefaultBackButton, { onPress: chrome.pop }))
61
+ : (props.backIcon
62
+ ? _jsx(BackIconButton, { spec: props.backIcon, onPress: chrome.pop })
63
+ : _jsx(DefaultBackButton, { onPress: chrome.pop })))
55
64
  : null);
56
65
  const right = chrome.headerRight?.() ?? null;
57
66
  return (_jsxs("view", { class: containerClass, children: [_jsx("view", { class: "flex flex-row items-center", style: { minWidth: 56 }, children: left }), _jsx("view", { class: "flex-1 items-center justify-center", children: _jsx("text", { class: "text-base-content text-base font-semibold", children: chrome.title }) }), _jsx("view", { class: "flex flex-row items-center justify-end", style: { minWidth: 56 }, children: right })] }));
@@ -60,3 +69,6 @@ export const NavHeader = component(({ props }) => {
60
69
  const DefaultBackButton = component(({ props }) => {
61
70
  return () => (_jsx(Pressable, { class: "px-2 py-2", pressedScale: PRESSED_SCALE, pressedOpacity: PRESSED_OPACITY, longPressDuration: 0, "accessibility-element": true, "accessibility-label": "Back", "accessibility-trait": "button", onPress: () => props.onPress(), children: _jsx("text", { class: "text-primary text-base", children: "\u2039 Back" }) }));
62
71
  });
72
+ const BackIconButton = component(({ props }) => {
73
+ return () => (_jsx(Pressable, { class: "px-2 py-2", pressedScale: PRESSED_SCALE, pressedOpacity: PRESSED_OPACITY, longPressDuration: 0, "accessibility-element": true, "accessibility-label": "Back", "accessibility-trait": "button", onPress: () => props.onPress(), children: _jsx(Icon, { set: props.spec.set, name: props.spec.name, size: NAV_HEADER_ICON_SIZE, variant: "primary" }) }));
74
+ });
@@ -16,8 +16,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "@sigx/lynx/jsx-runtime";
16
16
  */
17
17
  import { component } from '@sigx/lynx';
18
18
  import { Pressable } from '@sigx/lynx-gestures';
19
+ import { Icon } from '@sigx/lynx-icons';
19
20
  import { useTabs } from '@sigx/lynx-navigation';
20
21
  import { PRESSED_SCALE, PRESSED_OPACITY } from '../shared/press.js';
22
+ /** Narrow `TabInfo.icon` to its `IconSpec` variant — the bar renders `<Icon>` for these. */
23
+ const isIconSpec = (v) => typeof v === 'object' && v !== null && 'set' in v && 'name' in v
24
+ && typeof v.set === 'string'
25
+ && typeof v.name === 'string';
21
26
  const backgroundClass = {
22
27
  'base-100': 'bg-base-100',
23
28
  'base-200': 'bg-base-200',
@@ -48,11 +53,38 @@ export const NavTabBar = component(({ props }) => {
48
53
  }) }));
49
54
  };
50
55
  });
56
+ /**
57
+ * Pixel size the bar uses when rendering `<Icon>` from an `IconSpec`.
58
+ * Matches the default tab-row height visually.
59
+ */
60
+ const TAB_ICON_SIZE = 22;
51
61
  const DefaultNavTab = component(({ props }) => {
52
62
  return () => {
53
63
  const label = props.info.label ?? props.info.name;
54
64
  const a11y = props.info.accessibilityLabel ?? label;
55
- const textColor = props.active ? 'text-primary font-semibold' : 'text-base-content opacity-60';
56
- return (_jsxs(Pressable, { class: "flex-1 items-center justify-center py-3", pressedScale: PRESSED_SCALE, pressedOpacity: PRESSED_OPACITY, longPressDuration: 0, "accessibility-element": true, "accessibility-label": a11y, "accessibility-trait": "button", "accessibility-status": props.active ? 'selected' : undefined, onPress: () => props.onPress(), children: [props.info.icon ?? null, _jsx("text", { class: `text-sm ${textColor}`, children: label })] }));
65
+ // Label uses native CSS color via daisy `text-*` classes — Lynx's
66
+ // `<text>` honors color inheritance normally. The icon path below
67
+ // can't rely on the same trick (see comment there for why).
68
+ const labelTone = props.active ? 'text-primary' : 'text-base-content opacity-60';
69
+ const weight = props.active ? 'font-semibold' : '';
70
+ const icon = props.info.icon;
71
+ // For an `IconSpec`, render `<Icon>` with the matching daisy
72
+ // variant. `<ThemeProvider>`'s color resolver maps `'primary'` /
73
+ // `'base-content'` (etc.) to the current theme's hex value, which
74
+ // `<Icon>` substitutes directly into the SVG `fill=` attribute —
75
+ // Lynx's `<svg content=…>` parses inline SVG in isolation and
76
+ // doesn't inherit host `color`, so class-based theming doesn't
77
+ // reach the SVG content. Inactive layers `opacity-60` as a class
78
+ // on the outer element (opacity does propagate to the raster).
79
+ //
80
+ // For a `JSXElement`, the consumer is in charge of styling — we
81
+ // leave it untouched. They can opt into the same theming by
82
+ // passing `variant="primary"` themselves.
83
+ const iconVariant = props.active ? 'primary' : 'base-content';
84
+ const iconClass = props.active ? undefined : 'opacity-60';
85
+ const renderedIcon = isIconSpec(icon)
86
+ ? _jsx(Icon, { set: icon.set, name: icon.name, size: TAB_ICON_SIZE, variant: iconVariant, class: iconClass })
87
+ : (icon ?? null);
88
+ return (_jsxs(Pressable, { class: "flex-1 items-center justify-center py-3", pressedScale: PRESSED_SCALE, pressedOpacity: PRESSED_OPACITY, longPressDuration: 0, "accessibility-element": true, "accessibility-label": a11y, "accessibility-trait": "button", "accessibility-status": props.active ? 'selected' : undefined, onPress: () => props.onPress(), children: [renderedIcon, _jsx("text", { class: `text-sm ${labelTone} ${weight}`, children: label })] }));
57
89
  };
58
90
  });
@@ -0,0 +1,59 @@
1
+ import { type Define, type PrimitiveSignal, type SharedValue } from '@sigx/lynx';
2
+ import { type DaisyColor } from '../shared/styles.js';
3
+ /**
4
+ * Visual style for the swiper page indicator.
5
+ *
6
+ * - `dots` — equally-spaced circles, the active one fades in via opacity.
7
+ * Today's default. Cheap (opacity-only MT mapper, no layout each frame).
8
+ * - `bar` — fixed track with a single sliding thumb. Single MT binding
9
+ * regardless of page count, so cheapest for very long carousels.
10
+ * - `pill` — the active dot stretches horizontally into a pill while
11
+ * neighbours stay circular. Uses `scaleX` so siblings don't reflow.
12
+ * - `numbered` — text counter like `2 / 5`. Pure BG-thread, no animation.
13
+ * - `scale-pulse` — circles where the active one scales up. No colour
14
+ * crossfade — pairs well with monochrome palettes.
15
+ */
16
+ export type SwiperIndicatorVariant = 'dots' | 'bar' | 'pill' | 'numbered' | 'scale-pulse';
17
+ export type SwiperIndicatorSize = 'xs' | 'sm' | 'md' | 'lg';
18
+ export type SwiperIndicatorProps = Define.Prop<'variant', SwiperIndicatorVariant, false>
19
+ /** Live MT pixel offset from the parent `<Swiper>`. Required for all animated variants. */
20
+ & Define.Prop<'offset', SharedValue<number>, false>
21
+ /** Page width in CSS px. Must match the Swiper's effective page width. */
22
+ & Define.Prop<'pageWidth', number, false>
23
+ /** Total page count. */
24
+ & Define.Prop<'count', number, true>
25
+ /**
26
+ * Current page (whole-units). Required for `numbered`, used by `bar`
27
+ * as fallback when `offset` isn't wired, and consumed by all variants
28
+ * for tap-to-jump.
29
+ */
30
+ & Define.Prop<'index', PrimitiveSignal<number>, false> & Define.Prop<'color', DaisyColor, false> & Define.Prop<'inactiveColor', DaisyColor, false> & Define.Prop<'size', SwiperIndicatorSize, false>
31
+ /**
32
+ * Tap-to-jump handler. The receiver should typically write
33
+ * `index.value = i` to glide the swiper to that page.
34
+ */
35
+ & Define.Prop<'onDotPress', (index: number) => void, false> & Define.Prop<'class', string, false> & Define.Prop<'style', Record<string, string | number>, false>;
36
+ /**
37
+ * Themed swiper page indicator with five preset variants. Each variant
38
+ * is a thin shell over a headless hook from `@sigx/lynx-gestures` (see
39
+ * `useSwiperDotProgress`, `useSwiperDotScale`, `useSwiperDotGrowX`,
40
+ * `useSwiperDotTranslate`). For a fully custom indicator, compose the
41
+ * hooks yourself rather than forking this file.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * const offset = useSharedValue(0);
46
+ * const idx = signal({ value: 0 });
47
+ * <Swiper offset={offset} index={idx} width={W}>…</Swiper>
48
+ * <SwiperIndicator
49
+ * variant="pill"
50
+ * offset={offset}
51
+ * pageWidth={W}
52
+ * count={photos.length}
53
+ * index={idx}
54
+ * color="primary"
55
+ * onDotPress={(i) => { idx.value = i; }}
56
+ * />
57
+ * ```
58
+ */
59
+ export declare const SwiperIndicator: import("@sigx/runtime-core").ComponentFactory<SwiperIndicatorProps, void, {}>;