@shadow-js/ui 1.0.1 → 1.0.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.
@@ -82,7 +82,7 @@ function ThemeProvider(props) {
82
82
  setSystemDark(event.matches);
83
83
  };
84
84
  const onStorage = (event) => {
85
- if (event.key !== storageKey()) return;
85
+ if (event.key !== null && event.key !== storageKey()) return;
86
86
  setThemeState(isTheme(event.newValue) ? event.newValue : defaultTheme());
87
87
  };
88
88
  query.addEventListener("change", onSystemChange);
@@ -1 +1 @@
1
- {"version":3,"file":"theme-provider.js","names":[],"sources":["../../../src/theme/theme-provider.ts"],"sourcesContent":["import {\n type Accessor,\n batch,\n createContext,\n onCleanup,\n onMount,\n untrack,\n useContext,\n useEffect,\n useMemo,\n useSignal,\n} from \"@shadow-js/core\";\nimport type { JsxChild } from \"@shadow-js/web/jsx-runtime\";\nimport {\n applyTheme,\n DEFAULT_STORAGE_KEY,\n isTheme,\n type ResolvedTheme,\n readStoredTheme,\n resolveTheme,\n type Theme,\n type ThemeOptions,\n writeStoredTheme,\n} from \"./theme-logic\";\n\n/**\n * What {@link useTheme} returns. Every value is an accessor: call it to read.\n * @since 1.0.0\n */\nexport interface ThemeContextValue {\n /** The user's choice: `\"light\"`, `\"dark\"` or `\"system\"`. Call it to read the value. */\n readonly theme: Accessor<Theme>;\n /**\n * The theme that is shown: `\"light\"` or `\"dark\"`. `undefined` on the server and until the provider\n * has mounted, so markup that depends on it hydrates without a mismatch.\n */\n readonly resolvedTheme: Accessor<ResolvedTheme | undefined>;\n /**\n * The operating system preference: `\"light\"` or `\"dark\"`. `undefined` on the server and until the\n * provider has mounted.\n */\n readonly systemTheme: Accessor<ResolvedTheme | undefined>;\n /**\n * Chooses a theme, or derives one from the previous choice, and stores it under the provider's\n * `storageKey`. Throws a `TypeError` for a value that is not `\"light\"`, `\"dark\"` or `\"system\"`.\n */\n readonly setTheme: (theme: Theme | ((previous: Theme) => Theme)) => void;\n}\n\n/**\n * Props of {@link ThemeProvider}: the {@link ThemeOptions} plus a transition switch and the children.\n * @since 1.0.0\n */\nexport interface ThemeProviderProps extends ThemeOptions {\n /**\n * Stops CSS transitions for one frame while the theme changes, so colors flip at once instead\n * of fading piecemeal.\n * @default false\n */\n readonly disableTransitionOnChange?: boolean | undefined;\n /** The app to render inside the theme context. */\n readonly children?: JsxChild | undefined;\n}\n\nconst MISSING: ThemeContextValue = {\n theme: () => \"system\",\n resolvedTheme: () => undefined,\n systemTheme: () => undefined,\n setTheme: () => undefined,\n};\n\nconst ThemeContext = createContext<ThemeContextValue>(MISSING, { name: \"ShadowUITheme\" });\n\nconst DARK_QUERY = \"(prefers-color-scheme: dark)\";\n\n/** Disables transitions until the next frame; returns nothing, cleans itself up. */\nfunction suppressTransitions(): void {\n const style = document.createElement(\"style\");\n style.textContent =\n \"*,*::before,*::after{transition:none!important;animation-duration:0s!important}\";\n document.head.append(style);\n // Reading a computed style forces the browser to apply the change with transitions off.\n window.getComputedStyle(document.body).getPropertyValue(\"opacity\");\n window.setTimeout(() => style.remove(), 1);\n}\n\n/**\n * The page's `localStorage`, or `undefined` when the browser blocks it: reading the property\n * itself throws a `DOMException` in some sandboxed and locked-down contexts.\n */\nfunction localStorageOrUndefined(): Storage | undefined {\n try {\n return window.localStorage;\n } catch (error) {\n if (error instanceof DOMException) return undefined;\n throw error;\n }\n}\n\n/**\n * Provides the color theme to the app: `\"light\"`, `\"dark\"` or `\"system\"`. The choice is persisted in\n * `localStorage`, applied to `<html>` as a `light` or `dark` class (or a `data-*` attribute), and kept\n * in sync across tabs and with the operating system. The provider renders no element of its own, and\n * nothing touches the DOM until it has mounted, so it is safe to render on the server. Pair it with\n * {@link themeScript} in the document head so the first paint already has the right theme.\n *\n * @param props The {@link ThemeProviderProps}: theme options (`storageKey`, `defaultTheme`,\n * `attribute`, `enableSystem`), `disableTransitionOnChange` and `children`.\n * @returns The children inside the theme context.\n * @example\n * import { ThemeProvider } from \"@shadow-js/ui\";\n *\n * function App() {\n * return <p>Hello</p>;\n * }\n *\n * export const root = (\n * <ThemeProvider defaultTheme=\"system\">\n * <App />\n * </ThemeProvider>\n * );\n * @see {@link useTheme}\n * @since 1.0.0\n */\nexport function ThemeProvider(props: ThemeProviderProps): JsxChild {\n const storageKey = (): string => props.storageKey ?? DEFAULT_STORAGE_KEY;\n const defaultTheme = (): Theme => props.defaultTheme ?? \"system\";\n const attribute = (): \"class\" | `data-${string}` => props.attribute ?? \"class\";\n const enableSystem = (): boolean => props.enableSystem ?? true;\n\n const [theme, setThemeState] = useSignal<Theme>(untrack(defaultTheme));\n const [systemDark, setSystemDark] = useSignal<boolean | undefined>(undefined);\n const resolved = useMemo((): ResolvedTheme | undefined => {\n const dark = systemDark();\n if (dark === undefined) return undefined;\n return resolveTheme(theme(), dark, enableSystem());\n });\n const system = useMemo((): ResolvedTheme | undefined => {\n const dark = systemDark();\n return dark === undefined ? undefined : dark ? \"dark\" : \"light\";\n });\n\n onMount(() => {\n const query = window.matchMedia(DARK_QUERY);\n batch(() => {\n const storage = localStorageOrUndefined();\n const stored = storage === undefined ? undefined : readStoredTheme(storage, storageKey());\n if (stored !== undefined) setThemeState(stored);\n setSystemDark(query.matches);\n });\n const onSystemChange = (event: MediaQueryListEvent): void => {\n setSystemDark(event.matches);\n };\n const onStorage = (event: StorageEvent): void => {\n if (event.key !== storageKey()) return;\n setThemeState(isTheme(event.newValue) ? event.newValue : defaultTheme());\n };\n query.addEventListener(\"change\", onSystemChange);\n window.addEventListener(\"storage\", onStorage);\n onCleanup(() => {\n query.removeEventListener(\"change\", onSystemChange);\n window.removeEventListener(\"storage\", onStorage);\n });\n });\n\n useEffect(() => {\n const value = resolved();\n if (value === undefined) return;\n const attributeName = attribute();\n const current = document.documentElement;\n if (props.disableTransitionOnChange === true) suppressTransitions();\n applyTheme(current, value, attributeName);\n });\n\n const value: ThemeContextValue = {\n theme,\n resolvedTheme: resolved,\n systemTheme: system,\n setTheme(next) {\n const chosen = typeof next === \"function\" ? next(untrack(theme)) : next;\n if (!isTheme(chosen)) {\n throw new TypeError(\n `setTheme: expected \"light\", \"dark\" or \"system\", received ${String(chosen)}`,\n );\n }\n setThemeState(chosen);\n const storage = localStorageOrUndefined();\n if (storage !== undefined) writeStoredTheme(storage, untrack(storageKey), chosen);\n },\n };\n\n return ThemeContext.Provider({\n value,\n get children() {\n return props.children;\n },\n }) as JsxChild;\n}\n\n/**\n * Reads the theme and its setter. Call it inside a component rendered under {@link ThemeProvider}.\n *\n * @returns The {@link ThemeContextValue}: accessors for the chosen, the resolved and the system\n * theme, and `setTheme`.\n * @throws Error when no `ThemeProvider` is above the caller.\n * @example\n * import { useTheme } from \"@shadow-js/ui\";\n *\n * export function ThemeToggle() {\n * const { resolvedTheme, setTheme } = useTheme();\n * return (\n * <button onClick={() => setTheme(resolvedTheme() === \"dark\" ? \"light\" : \"dark\")}>\n * Toggle theme\n * </button>\n * );\n * }\n * @see {@link ThemeProvider}\n * @since 1.0.0\n */\nexport function useTheme(): ThemeContextValue {\n const value = useContext(ThemeContext);\n if (value === MISSING) throw new Error(\"useTheme() must be used within <ThemeProvider>\");\n return value;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"theme-provider.js","names":[],"sources":["../../../src/theme/theme-provider.ts"],"sourcesContent":["import {\n type Accessor,\n batch,\n createContext,\n onCleanup,\n onMount,\n untrack,\n useContext,\n useEffect,\n useMemo,\n useSignal,\n} from \"@shadow-js/core\";\nimport type { JsxChild } from \"@shadow-js/web/jsx-runtime\";\nimport {\n applyTheme,\n DEFAULT_STORAGE_KEY,\n isTheme,\n type ResolvedTheme,\n readStoredTheme,\n resolveTheme,\n type Theme,\n type ThemeOptions,\n writeStoredTheme,\n} from \"./theme-logic\";\n\n/**\n * What {@link useTheme} returns. Every value is an accessor: call it to read.\n * @since 1.0.0\n */\nexport interface ThemeContextValue {\n /** The user's choice: `\"light\"`, `\"dark\"` or `\"system\"`. Call it to read the value. */\n readonly theme: Accessor<Theme>;\n /**\n * The theme that is shown: `\"light\"` or `\"dark\"`. `undefined` on the server and until the provider\n * has mounted, so markup that depends on it hydrates without a mismatch.\n */\n readonly resolvedTheme: Accessor<ResolvedTheme | undefined>;\n /**\n * The operating system preference: `\"light\"` or `\"dark\"`. `undefined` on the server and until the\n * provider has mounted.\n */\n readonly systemTheme: Accessor<ResolvedTheme | undefined>;\n /**\n * Chooses a theme, or derives one from the previous choice, and stores it under the provider's\n * `storageKey`. Throws a `TypeError` for a value that is not `\"light\"`, `\"dark\"` or `\"system\"`.\n */\n readonly setTheme: (theme: Theme | ((previous: Theme) => Theme)) => void;\n}\n\n/**\n * Props of {@link ThemeProvider}: the {@link ThemeOptions} plus a transition switch and the children.\n * @since 1.0.0\n */\nexport interface ThemeProviderProps extends ThemeOptions {\n /**\n * Stops CSS transitions for one frame while the theme changes, so colors flip at once instead\n * of fading piecemeal.\n * @default false\n */\n readonly disableTransitionOnChange?: boolean | undefined;\n /** The app to render inside the theme context. */\n readonly children?: JsxChild | undefined;\n}\n\nconst MISSING: ThemeContextValue = {\n theme: () => \"system\",\n resolvedTheme: () => undefined,\n systemTheme: () => undefined,\n setTheme: () => undefined,\n};\n\nconst ThemeContext = createContext<ThemeContextValue>(MISSING, { name: \"ShadowUITheme\" });\n\nconst DARK_QUERY = \"(prefers-color-scheme: dark)\";\n\n/** Disables transitions until the next frame; returns nothing, cleans itself up. */\nfunction suppressTransitions(): void {\n const style = document.createElement(\"style\");\n style.textContent =\n \"*,*::before,*::after{transition:none!important;animation-duration:0s!important}\";\n document.head.append(style);\n // Reading a computed style forces the browser to apply the change with transitions off.\n window.getComputedStyle(document.body).getPropertyValue(\"opacity\");\n window.setTimeout(() => style.remove(), 1);\n}\n\n/**\n * The page's `localStorage`, or `undefined` when the browser blocks it: reading the property\n * itself throws a `DOMException` in some sandboxed and locked-down contexts.\n */\nfunction localStorageOrUndefined(): Storage | undefined {\n try {\n return window.localStorage;\n } catch (error) {\n if (error instanceof DOMException) return undefined;\n throw error;\n }\n}\n\n/**\n * Provides the color theme to the app: `\"light\"`, `\"dark\"` or `\"system\"`. The choice is persisted in\n * `localStorage`, applied to `<html>` as a `light` or `dark` class (or a `data-*` attribute), and kept\n * in sync across tabs and with the operating system. The provider renders no element of its own, and\n * nothing touches the DOM until it has mounted, so it is safe to render on the server. Pair it with\n * {@link themeScript} in the document head so the first paint already has the right theme.\n *\n * @param props The {@link ThemeProviderProps}: theme options (`storageKey`, `defaultTheme`,\n * `attribute`, `enableSystem`), `disableTransitionOnChange` and `children`.\n * @returns The children inside the theme context.\n * @example\n * import { ThemeProvider } from \"@shadow-js/ui\";\n *\n * function App() {\n * return <p>Hello</p>;\n * }\n *\n * export const root = (\n * <ThemeProvider defaultTheme=\"system\">\n * <App />\n * </ThemeProvider>\n * );\n * @see {@link useTheme}\n * @since 1.0.0\n */\nexport function ThemeProvider(props: ThemeProviderProps): JsxChild {\n const storageKey = (): string => props.storageKey ?? DEFAULT_STORAGE_KEY;\n const defaultTheme = (): Theme => props.defaultTheme ?? \"system\";\n const attribute = (): \"class\" | `data-${string}` => props.attribute ?? \"class\";\n const enableSystem = (): boolean => props.enableSystem ?? true;\n\n const [theme, setThemeState] = useSignal<Theme>(untrack(defaultTheme));\n const [systemDark, setSystemDark] = useSignal<boolean | undefined>(undefined);\n const resolved = useMemo((): ResolvedTheme | undefined => {\n const dark = systemDark();\n if (dark === undefined) return undefined;\n return resolveTheme(theme(), dark, enableSystem());\n });\n const system = useMemo((): ResolvedTheme | undefined => {\n const dark = systemDark();\n return dark === undefined ? undefined : dark ? \"dark\" : \"light\";\n });\n\n onMount(() => {\n const query = window.matchMedia(DARK_QUERY);\n batch(() => {\n const storage = localStorageOrUndefined();\n const stored = storage === undefined ? undefined : readStoredTheme(storage, storageKey());\n if (stored !== undefined) setThemeState(stored);\n setSystemDark(query.matches);\n });\n const onSystemChange = (event: MediaQueryListEvent): void => {\n setSystemDark(event.matches);\n };\n const onStorage = (event: StorageEvent): void => {\n // A null key is `storage.clear()`: the stored theme is gone, like a removed key.\n if (event.key !== null && event.key !== storageKey()) return;\n setThemeState(isTheme(event.newValue) ? event.newValue : defaultTheme());\n };\n query.addEventListener(\"change\", onSystemChange);\n window.addEventListener(\"storage\", onStorage);\n onCleanup(() => {\n query.removeEventListener(\"change\", onSystemChange);\n window.removeEventListener(\"storage\", onStorage);\n });\n });\n\n useEffect(() => {\n const value = resolved();\n if (value === undefined) return;\n const attributeName = attribute();\n const current = document.documentElement;\n if (props.disableTransitionOnChange === true) suppressTransitions();\n applyTheme(current, value, attributeName);\n });\n\n const value: ThemeContextValue = {\n theme,\n resolvedTheme: resolved,\n systemTheme: system,\n setTheme(next) {\n const chosen = typeof next === \"function\" ? next(untrack(theme)) : next;\n if (!isTheme(chosen)) {\n throw new TypeError(\n `setTheme: expected \"light\", \"dark\" or \"system\", received ${String(chosen)}`,\n );\n }\n setThemeState(chosen);\n const storage = localStorageOrUndefined();\n if (storage !== undefined) writeStoredTheme(storage, untrack(storageKey), chosen);\n },\n };\n\n return ThemeContext.Provider({\n value,\n get children() {\n return props.children;\n },\n }) as JsxChild;\n}\n\n/**\n * Reads the theme and its setter. Call it inside a component rendered under {@link ThemeProvider}.\n *\n * @returns The {@link ThemeContextValue}: accessors for the chosen, the resolved and the system\n * theme, and `setTheme`.\n * @throws Error when no `ThemeProvider` is above the caller.\n * @example\n * import { useTheme } from \"@shadow-js/ui\";\n *\n * export function ThemeToggle() {\n * const { resolvedTheme, setTheme } = useTheme();\n * return (\n * <button onClick={() => setTheme(resolvedTheme() === \"dark\" ? \"light\" : \"dark\")}>\n * Toggle theme\n * </button>\n * );\n * }\n * @see {@link ThemeProvider}\n * @since 1.0.0\n */\nexport function useTheme(): ThemeContextValue {\n const value = useContext(ThemeContext);\n if (value === MISSING) throw new Error(\"useTheme() must be used within <ThemeProvider>\");\n return value;\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- import{applyTheme as e,isTheme as t,readStoredTheme as n,resolveTheme as r,writeStoredTheme as i}from"./theme-logic.js";import{batch as a,createContext as o,onCleanup as s,onMount as c,untrack as l,useContext as u,useEffect as d,useMemo as f,useSignal as p}from"@shadow-js/core";const m={theme:()=>`system`,resolvedTheme:()=>void 0,systemTheme:()=>void 0,setTheme:()=>void 0},h=o(m,{name:`ShadowUITheme`});function g(){let e=document.createElement(`style`);e.textContent=`*,*::before,*::after{transition:none!important;animation-duration:0s!important}`,document.head.append(e),window.getComputedStyle(document.body).getPropertyValue(`opacity`),window.setTimeout(()=>e.remove(),1)}function _(){try{return window.localStorage}catch(e){if(e instanceof DOMException)return;throw e}}function v(o){let u=()=>o.storageKey??`shadow-ui-theme`,m=()=>o.defaultTheme??`system`,v=()=>o.attribute??`class`,y=()=>o.enableSystem??!0,[b,x]=p(l(m)),[S,C]=p(void 0),w=f(()=>{let e=S();if(e!==void 0)return r(b(),e,y())}),T=f(()=>{let e=S();return e===void 0?void 0:e?`dark`:`light`});c(()=>{let e=window.matchMedia(`(prefers-color-scheme: dark)`);a(()=>{let t=_(),r=t===void 0?void 0:n(t,u());r!==void 0&&x(r),C(e.matches)});let r=e=>{C(e.matches)},i=e=>{e.key===u()&&x(t(e.newValue)?e.newValue:m())};e.addEventListener(`change`,r),window.addEventListener(`storage`,i),s(()=>{e.removeEventListener(`change`,r),window.removeEventListener(`storage`,i)})}),d(()=>{let t=w();if(t===void 0)return;let n=v(),r=document.documentElement;o.disableTransitionOnChange===!0&&g(),e(r,t,n)});let E={theme:b,resolvedTheme:w,systemTheme:T,setTheme(e){let n=typeof e==`function`?e(l(b)):e;if(!t(n))throw TypeError(`setTheme: expected "light", "dark" or "system", received ${String(n)}`);x(n);let r=_();r!==void 0&&i(r,l(u),n)}};return h.Provider({value:E,get children(){return o.children}})}function y(){let e=u(h);if(e===m)throw Error(`useTheme() must be used within <ThemeProvider>`);return e}export{v as ThemeProvider,y as useTheme};
1
+ import{applyTheme as e,isTheme as t,readStoredTheme as n,resolveTheme as r,writeStoredTheme as i}from"./theme-logic.js";import{batch as a,createContext as o,onCleanup as s,onMount as c,untrack as l,useContext as u,useEffect as d,useMemo as f,useSignal as p}from"@shadow-js/core";const m={theme:()=>`system`,resolvedTheme:()=>void 0,systemTheme:()=>void 0,setTheme:()=>void 0},h=o(m,{name:`ShadowUITheme`});function g(){let e=document.createElement(`style`);e.textContent=`*,*::before,*::after{transition:none!important;animation-duration:0s!important}`,document.head.append(e),window.getComputedStyle(document.body).getPropertyValue(`opacity`),window.setTimeout(()=>e.remove(),1)}function _(){try{return window.localStorage}catch(e){if(e instanceof DOMException)return;throw e}}function v(o){let u=()=>o.storageKey??`shadow-ui-theme`,m=()=>o.defaultTheme??`system`,v=()=>o.attribute??`class`,y=()=>o.enableSystem??!0,[b,x]=p(l(m)),[S,C]=p(void 0),w=f(()=>{let e=S();if(e!==void 0)return r(b(),e,y())}),T=f(()=>{let e=S();return e===void 0?void 0:e?`dark`:`light`});c(()=>{let e=window.matchMedia(`(prefers-color-scheme: dark)`);a(()=>{let t=_(),r=t===void 0?void 0:n(t,u());r!==void 0&&x(r),C(e.matches)});let r=e=>{C(e.matches)},i=e=>{(e.key===null||e.key===u())&&x(t(e.newValue)?e.newValue:m())};e.addEventListener(`change`,r),window.addEventListener(`storage`,i),s(()=>{e.removeEventListener(`change`,r),window.removeEventListener(`storage`,i)})}),d(()=>{let t=w();if(t===void 0)return;let n=v(),r=document.documentElement;o.disableTransitionOnChange===!0&&g(),e(r,t,n)});let E={theme:b,resolvedTheme:w,systemTheme:T,setTheme(e){let n=typeof e==`function`?e(l(b)):e;if(!t(n))throw TypeError(`setTheme: expected "light", "dark" or "system", received ${String(n)}`);x(n);let r=_();r!==void 0&&i(r,l(u),n)}};return h.Provider({value:E,get children(){return o.children}})}function y(){let e=u(h);if(e===m)throw Error(`useTheme() must be used within <ThemeProvider>`);return e}export{v as ThemeProvider,y as useTheme};
2
2
  //# sourceMappingURL=theme-provider.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"theme-provider.js","names":["MISSING","ThemeContext","createContext","suppressTransitions","style","localStorageOrUndefined","error","ThemeProvider","storageKey","props","defaultTheme","attribute","enableSystem","theme","setThemeState","useSignal","untrack","systemDark","setSystemDark","resolved","useMemo","dark","resolveTheme","system","onMount","query","batch","storage","stored","readStoredTheme","onSystemChange","event","onStorage","isTheme","onCleanup","useEffect","value","attributeName","current","applyTheme","chosen","next","writeStoredTheme","useTheme","useContext"],"sources":["../../../src/theme/theme-provider.ts"],"sourcesContent":["import {\n type Accessor,\n batch,\n createContext,\n onCleanup,\n onMount,\n untrack,\n useContext,\n useEffect,\n useMemo,\n useSignal,\n} from \"@shadow-js/core\";\nimport type { JsxChild } from \"@shadow-js/web/jsx-runtime\";\nimport {\n applyTheme,\n DEFAULT_STORAGE_KEY,\n isTheme,\n type ResolvedTheme,\n readStoredTheme,\n resolveTheme,\n type Theme,\n type ThemeOptions,\n writeStoredTheme,\n} from \"./theme-logic\";\n\n/**\n * What {@link useTheme} returns. Every value is an accessor: call it to read.\n * @since 1.0.0\n */\nexport interface ThemeContextValue {\n /** The user's choice: `\"light\"`, `\"dark\"` or `\"system\"`. Call it to read the value. */\n readonly theme: Accessor<Theme>;\n /**\n * The theme that is shown: `\"light\"` or `\"dark\"`. `undefined` on the server and until the provider\n * has mounted, so markup that depends on it hydrates without a mismatch.\n */\n readonly resolvedTheme: Accessor<ResolvedTheme | undefined>;\n /**\n * The operating system preference: `\"light\"` or `\"dark\"`. `undefined` on the server and until the\n * provider has mounted.\n */\n readonly systemTheme: Accessor<ResolvedTheme | undefined>;\n /**\n * Chooses a theme, or derives one from the previous choice, and stores it under the provider's\n * `storageKey`. Throws a `TypeError` for a value that is not `\"light\"`, `\"dark\"` or `\"system\"`.\n */\n readonly setTheme: (theme: Theme | ((previous: Theme) => Theme)) => void;\n}\n\n/**\n * Props of {@link ThemeProvider}: the {@link ThemeOptions} plus a transition switch and the children.\n * @since 1.0.0\n */\nexport interface ThemeProviderProps extends ThemeOptions {\n /**\n * Stops CSS transitions for one frame while the theme changes, so colors flip at once instead\n * of fading piecemeal.\n * @default false\n */\n readonly disableTransitionOnChange?: boolean | undefined;\n /** The app to render inside the theme context. */\n readonly children?: JsxChild | undefined;\n}\n\nconst MISSING: ThemeContextValue = {\n theme: () => \"system\",\n resolvedTheme: () => undefined,\n systemTheme: () => undefined,\n setTheme: () => undefined,\n};\n\nconst ThemeContext = createContext<ThemeContextValue>(MISSING, { name: \"ShadowUITheme\" });\n\nconst DARK_QUERY = \"(prefers-color-scheme: dark)\";\n\n/** Disables transitions until the next frame; returns nothing, cleans itself up. */\nfunction suppressTransitions(): void {\n const style = document.createElement(\"style\");\n style.textContent =\n \"*,*::before,*::after{transition:none!important;animation-duration:0s!important}\";\n document.head.append(style);\n // Reading a computed style forces the browser to apply the change with transitions off.\n window.getComputedStyle(document.body).getPropertyValue(\"opacity\");\n window.setTimeout(() => style.remove(), 1);\n}\n\n/**\n * The page's `localStorage`, or `undefined` when the browser blocks it: reading the property\n * itself throws a `DOMException` in some sandboxed and locked-down contexts.\n */\nfunction localStorageOrUndefined(): Storage | undefined {\n try {\n return window.localStorage;\n } catch (error) {\n if (error instanceof DOMException) return undefined;\n throw error;\n }\n}\n\n/**\n * Provides the color theme to the app: `\"light\"`, `\"dark\"` or `\"system\"`. The choice is persisted in\n * `localStorage`, applied to `<html>` as a `light` or `dark` class (or a `data-*` attribute), and kept\n * in sync across tabs and with the operating system. The provider renders no element of its own, and\n * nothing touches the DOM until it has mounted, so it is safe to render on the server. Pair it with\n * {@link themeScript} in the document head so the first paint already has the right theme.\n *\n * @param props The {@link ThemeProviderProps}: theme options (`storageKey`, `defaultTheme`,\n * `attribute`, `enableSystem`), `disableTransitionOnChange` and `children`.\n * @returns The children inside the theme context.\n * @example\n * import { ThemeProvider } from \"@shadow-js/ui\";\n *\n * function App() {\n * return <p>Hello</p>;\n * }\n *\n * export const root = (\n * <ThemeProvider defaultTheme=\"system\">\n * <App />\n * </ThemeProvider>\n * );\n * @see {@link useTheme}\n * @since 1.0.0\n */\nexport function ThemeProvider(props: ThemeProviderProps): JsxChild {\n const storageKey = (): string => props.storageKey ?? DEFAULT_STORAGE_KEY;\n const defaultTheme = (): Theme => props.defaultTheme ?? \"system\";\n const attribute = (): \"class\" | `data-${string}` => props.attribute ?? \"class\";\n const enableSystem = (): boolean => props.enableSystem ?? true;\n\n const [theme, setThemeState] = useSignal<Theme>(untrack(defaultTheme));\n const [systemDark, setSystemDark] = useSignal<boolean | undefined>(undefined);\n const resolved = useMemo((): ResolvedTheme | undefined => {\n const dark = systemDark();\n if (dark === undefined) return undefined;\n return resolveTheme(theme(), dark, enableSystem());\n });\n const system = useMemo((): ResolvedTheme | undefined => {\n const dark = systemDark();\n return dark === undefined ? undefined : dark ? \"dark\" : \"light\";\n });\n\n onMount(() => {\n const query = window.matchMedia(DARK_QUERY);\n batch(() => {\n const storage = localStorageOrUndefined();\n const stored = storage === undefined ? undefined : readStoredTheme(storage, storageKey());\n if (stored !== undefined) setThemeState(stored);\n setSystemDark(query.matches);\n });\n const onSystemChange = (event: MediaQueryListEvent): void => {\n setSystemDark(event.matches);\n };\n const onStorage = (event: StorageEvent): void => {\n if (event.key !== storageKey()) return;\n setThemeState(isTheme(event.newValue) ? event.newValue : defaultTheme());\n };\n query.addEventListener(\"change\", onSystemChange);\n window.addEventListener(\"storage\", onStorage);\n onCleanup(() => {\n query.removeEventListener(\"change\", onSystemChange);\n window.removeEventListener(\"storage\", onStorage);\n });\n });\n\n useEffect(() => {\n const value = resolved();\n if (value === undefined) return;\n const attributeName = attribute();\n const current = document.documentElement;\n if (props.disableTransitionOnChange === true) suppressTransitions();\n applyTheme(current, value, attributeName);\n });\n\n const value: ThemeContextValue = {\n theme,\n resolvedTheme: resolved,\n systemTheme: system,\n setTheme(next) {\n const chosen = typeof next === \"function\" ? next(untrack(theme)) : next;\n if (!isTheme(chosen)) {\n throw new TypeError(\n `setTheme: expected \"light\", \"dark\" or \"system\", received ${String(chosen)}`,\n );\n }\n setThemeState(chosen);\n const storage = localStorageOrUndefined();\n if (storage !== undefined) writeStoredTheme(storage, untrack(storageKey), chosen);\n },\n };\n\n return ThemeContext.Provider({\n value,\n get children() {\n return props.children;\n },\n }) as JsxChild;\n}\n\n/**\n * Reads the theme and its setter. Call it inside a component rendered under {@link ThemeProvider}.\n *\n * @returns The {@link ThemeContextValue}: accessors for the chosen, the resolved and the system\n * theme, and `setTheme`.\n * @throws Error when no `ThemeProvider` is above the caller.\n * @example\n * import { useTheme } from \"@shadow-js/ui\";\n *\n * export function ThemeToggle() {\n * const { resolvedTheme, setTheme } = useTheme();\n * return (\n * <button onClick={() => setTheme(resolvedTheme() === \"dark\" ? \"light\" : \"dark\")}>\n * Toggle theme\n * </button>\n * );\n * }\n * @see {@link ThemeProvider}\n * @since 1.0.0\n */\nexport function useTheme(): ThemeContextValue {\n const value = useContext(ThemeContext);\n if (value === MISSING) throw new Error(\"useTheme() must be used within <ThemeProvider>\");\n return value;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"theme-provider.js","names":["MISSING","ThemeContext","createContext","suppressTransitions","style","localStorageOrUndefined","error","ThemeProvider","storageKey","props","defaultTheme","attribute","enableSystem","theme","setThemeState","useSignal","untrack","systemDark","setSystemDark","resolved","useMemo","dark","resolveTheme","system","onMount","query","batch","storage","stored","readStoredTheme","onSystemChange","event","onStorage","isTheme","onCleanup","useEffect","value","attributeName","current","applyTheme","chosen","next","writeStoredTheme","useTheme","useContext"],"sources":["../../../src/theme/theme-provider.ts"],"sourcesContent":["import {\n type Accessor,\n batch,\n createContext,\n onCleanup,\n onMount,\n untrack,\n useContext,\n useEffect,\n useMemo,\n useSignal,\n} from \"@shadow-js/core\";\nimport type { JsxChild } from \"@shadow-js/web/jsx-runtime\";\nimport {\n applyTheme,\n DEFAULT_STORAGE_KEY,\n isTheme,\n type ResolvedTheme,\n readStoredTheme,\n resolveTheme,\n type Theme,\n type ThemeOptions,\n writeStoredTheme,\n} from \"./theme-logic\";\n\n/**\n * What {@link useTheme} returns. Every value is an accessor: call it to read.\n * @since 1.0.0\n */\nexport interface ThemeContextValue {\n /** The user's choice: `\"light\"`, `\"dark\"` or `\"system\"`. Call it to read the value. */\n readonly theme: Accessor<Theme>;\n /**\n * The theme that is shown: `\"light\"` or `\"dark\"`. `undefined` on the server and until the provider\n * has mounted, so markup that depends on it hydrates without a mismatch.\n */\n readonly resolvedTheme: Accessor<ResolvedTheme | undefined>;\n /**\n * The operating system preference: `\"light\"` or `\"dark\"`. `undefined` on the server and until the\n * provider has mounted.\n */\n readonly systemTheme: Accessor<ResolvedTheme | undefined>;\n /**\n * Chooses a theme, or derives one from the previous choice, and stores it under the provider's\n * `storageKey`. Throws a `TypeError` for a value that is not `\"light\"`, `\"dark\"` or `\"system\"`.\n */\n readonly setTheme: (theme: Theme | ((previous: Theme) => Theme)) => void;\n}\n\n/**\n * Props of {@link ThemeProvider}: the {@link ThemeOptions} plus a transition switch and the children.\n * @since 1.0.0\n */\nexport interface ThemeProviderProps extends ThemeOptions {\n /**\n * Stops CSS transitions for one frame while the theme changes, so colors flip at once instead\n * of fading piecemeal.\n * @default false\n */\n readonly disableTransitionOnChange?: boolean | undefined;\n /** The app to render inside the theme context. */\n readonly children?: JsxChild | undefined;\n}\n\nconst MISSING: ThemeContextValue = {\n theme: () => \"system\",\n resolvedTheme: () => undefined,\n systemTheme: () => undefined,\n setTheme: () => undefined,\n};\n\nconst ThemeContext = createContext<ThemeContextValue>(MISSING, { name: \"ShadowUITheme\" });\n\nconst DARK_QUERY = \"(prefers-color-scheme: dark)\";\n\n/** Disables transitions until the next frame; returns nothing, cleans itself up. */\nfunction suppressTransitions(): void {\n const style = document.createElement(\"style\");\n style.textContent =\n \"*,*::before,*::after{transition:none!important;animation-duration:0s!important}\";\n document.head.append(style);\n // Reading a computed style forces the browser to apply the change with transitions off.\n window.getComputedStyle(document.body).getPropertyValue(\"opacity\");\n window.setTimeout(() => style.remove(), 1);\n}\n\n/**\n * The page's `localStorage`, or `undefined` when the browser blocks it: reading the property\n * itself throws a `DOMException` in some sandboxed and locked-down contexts.\n */\nfunction localStorageOrUndefined(): Storage | undefined {\n try {\n return window.localStorage;\n } catch (error) {\n if (error instanceof DOMException) return undefined;\n throw error;\n }\n}\n\n/**\n * Provides the color theme to the app: `\"light\"`, `\"dark\"` or `\"system\"`. The choice is persisted in\n * `localStorage`, applied to `<html>` as a `light` or `dark` class (or a `data-*` attribute), and kept\n * in sync across tabs and with the operating system. The provider renders no element of its own, and\n * nothing touches the DOM until it has mounted, so it is safe to render on the server. Pair it with\n * {@link themeScript} in the document head so the first paint already has the right theme.\n *\n * @param props The {@link ThemeProviderProps}: theme options (`storageKey`, `defaultTheme`,\n * `attribute`, `enableSystem`), `disableTransitionOnChange` and `children`.\n * @returns The children inside the theme context.\n * @example\n * import { ThemeProvider } from \"@shadow-js/ui\";\n *\n * function App() {\n * return <p>Hello</p>;\n * }\n *\n * export const root = (\n * <ThemeProvider defaultTheme=\"system\">\n * <App />\n * </ThemeProvider>\n * );\n * @see {@link useTheme}\n * @since 1.0.0\n */\nexport function ThemeProvider(props: ThemeProviderProps): JsxChild {\n const storageKey = (): string => props.storageKey ?? DEFAULT_STORAGE_KEY;\n const defaultTheme = (): Theme => props.defaultTheme ?? \"system\";\n const attribute = (): \"class\" | `data-${string}` => props.attribute ?? \"class\";\n const enableSystem = (): boolean => props.enableSystem ?? true;\n\n const [theme, setThemeState] = useSignal<Theme>(untrack(defaultTheme));\n const [systemDark, setSystemDark] = useSignal<boolean | undefined>(undefined);\n const resolved = useMemo((): ResolvedTheme | undefined => {\n const dark = systemDark();\n if (dark === undefined) return undefined;\n return resolveTheme(theme(), dark, enableSystem());\n });\n const system = useMemo((): ResolvedTheme | undefined => {\n const dark = systemDark();\n return dark === undefined ? undefined : dark ? \"dark\" : \"light\";\n });\n\n onMount(() => {\n const query = window.matchMedia(DARK_QUERY);\n batch(() => {\n const storage = localStorageOrUndefined();\n const stored = storage === undefined ? undefined : readStoredTheme(storage, storageKey());\n if (stored !== undefined) setThemeState(stored);\n setSystemDark(query.matches);\n });\n const onSystemChange = (event: MediaQueryListEvent): void => {\n setSystemDark(event.matches);\n };\n const onStorage = (event: StorageEvent): void => {\n // A null key is `storage.clear()`: the stored theme is gone, like a removed key.\n if (event.key !== null && event.key !== storageKey()) return;\n setThemeState(isTheme(event.newValue) ? event.newValue : defaultTheme());\n };\n query.addEventListener(\"change\", onSystemChange);\n window.addEventListener(\"storage\", onStorage);\n onCleanup(() => {\n query.removeEventListener(\"change\", onSystemChange);\n window.removeEventListener(\"storage\", onStorage);\n });\n });\n\n useEffect(() => {\n const value = resolved();\n if (value === undefined) return;\n const attributeName = attribute();\n const current = document.documentElement;\n if (props.disableTransitionOnChange === true) suppressTransitions();\n applyTheme(current, value, attributeName);\n });\n\n const value: ThemeContextValue = {\n theme,\n resolvedTheme: resolved,\n systemTheme: system,\n setTheme(next) {\n const chosen = typeof next === \"function\" ? next(untrack(theme)) : next;\n if (!isTheme(chosen)) {\n throw new TypeError(\n `setTheme: expected \"light\", \"dark\" or \"system\", received ${String(chosen)}`,\n );\n }\n setThemeState(chosen);\n const storage = localStorageOrUndefined();\n if (storage !== undefined) writeStoredTheme(storage, untrack(storageKey), chosen);\n },\n };\n\n return ThemeContext.Provider({\n value,\n get children() {\n return props.children;\n },\n }) as JsxChild;\n}\n\n/**\n * Reads the theme and its setter. Call it inside a component rendered under {@link ThemeProvider}.\n *\n * @returns The {@link ThemeContextValue}: accessors for the chosen, the resolved and the system\n * theme, and `setTheme`.\n * @throws Error when no `ThemeProvider` is above the caller.\n * @example\n * import { useTheme } from \"@shadow-js/ui\";\n *\n * export function ThemeToggle() {\n * const { resolvedTheme, setTheme } = useTheme();\n * return (\n * <button onClick={() => setTheme(resolvedTheme() === \"dark\" ? \"light\" : \"dark\")}>\n * Toggle theme\n * </button>\n * );\n * }\n * @see {@link ThemeProvider}\n * @since 1.0.0\n */\nexport function useTheme(): ThemeContextValue {\n const value = useContext(ThemeContext);\n if (value === MISSING) throw new Error(\"useTheme() must be used within <ThemeProvider>\");\n return value;\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shadow-js/ui",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "shadcn-style components you add to your project with a CLI, styled with Tailwind v4",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -60,18 +60,18 @@
60
60
  "check": "npm run lint && npm run typecheck && npm run build && npm test && npm run test:perf && npm run test:gc"
61
61
  },
62
62
  "peerDependencies": {
63
- "@shadow-js/core": "^1.0.1",
64
- "@shadow-js/primitives": "^1.0.1",
65
- "@shadow-js/web": "^1.0.1"
63
+ "@shadow-js/core": "^1.0.3",
64
+ "@shadow-js/primitives": "^1.0.3",
65
+ "@shadow-js/web": "^1.0.3"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@biomejs/biome": "2.5.15",
69
- "@shadow-js/compiler": "^1.0.1",
70
- "@shadow-js/core": "^1.0.1",
71
- "@shadow-js/forms": "^1.0.1",
72
- "@shadow-js/primitives": "^1.0.1",
73
- "@shadow-js/testing": "^1.0.1",
74
- "@shadow-js/web": "^1.0.1",
69
+ "@shadow-js/compiler": "^1.0.3",
70
+ "@shadow-js/core": "^1.0.3",
71
+ "@shadow-js/forms": "^1.0.3",
72
+ "@shadow-js/primitives": "^1.0.3",
73
+ "@shadow-js/testing": "^1.0.3",
74
+ "@shadow-js/web": "^1.0.3",
75
75
  "@tailwindcss/node": "4.3.3",
76
76
  "@types/node": "^24.0.0",
77
77
  "fast-check": "^4.10.2",
@@ -84,7 +84,7 @@
84
84
  "vitest": "5.0.3"
85
85
  },
86
86
  "dependencies": {
87
- "@shadow-js/table": "1.0.1",
87
+ "@shadow-js/table": "1.0.3",
88
88
  "class-variance-authority": "0.7.1",
89
89
  "clsx": "2.1.1",
90
90
  "tailwind-merge": "3.7.0"
@@ -1,7 +1,9 @@
1
1
  import { onCleanup, onMount, splitProps, useEffect, useSignal } from "@shadow-js/core";
2
2
  import {
3
+ callHandler,
3
4
  createPartContext,
4
5
  type Direction,
6
+ mergeElementProps,
5
7
  useDirectionAround,
6
8
  useRefSignal,
7
9
  } from "@shadow-js/primitives";
@@ -99,6 +101,7 @@ interface CarouselContextValue {
99
101
  readonly setViewport: (element: HTMLElement | undefined) => void;
100
102
  readonly claimSlide: () => number;
101
103
  readonly registerCount: () => void;
104
+ readonly releaseSlide: () => void;
102
105
  readonly pause: (paused: boolean) => void;
103
106
  readonly labels: () => ResolvedLabels;
104
107
  /** True once the carousel is mounted in the browser; the slide names wait for it (see CarouselItem). */
@@ -306,6 +309,12 @@ function Carousel(props: CarouselProps): JSX.Element {
306
309
  const [ready, setReady] = useSignal(false);
307
310
  let viewport: HTMLElement | undefined;
308
311
  let claimed = 0;
312
+ // The slide count the "reInit" listeners were last told, and whether the carousel is gone.
313
+ let reported = 0;
314
+ let disposed = false;
315
+ onCleanup(() => {
316
+ disposed = true;
317
+ });
309
318
  // The slide a programmatic move is heading for, until the scroll arrives there. While it is set,
310
319
  // the intermediate positions of a smooth scroll do not move the index back.
311
320
  let pending: number | undefined;
@@ -376,6 +385,27 @@ function Carousel(props: CarouselProps): JSX.Element {
376
385
  if (typeof viewport.scrollTo !== "function") viewport.scrollLeft = position * sign;
377
386
  }
378
387
  };
388
+ /** Tells the "reInit" listeners when the slide count differs from what they last heard. */
389
+ const announceCount = (): void => {
390
+ if (!ready()) return;
391
+ const total = count();
392
+ if (total === reported) return;
393
+ reported = total;
394
+ // Slides were removed: the current one may be gone.
395
+ if (total > 0 && index() > total - 1) setIndex(total - 1);
396
+ emit("reInit");
397
+ };
398
+ // A list that is replaced removes every slide and adds new ones in the same turn, and a carousel
399
+ // that unmounts removes them all: wait for the final count before telling anyone, once.
400
+ let announcing = false;
401
+ const scheduleAnnounce = (): void => {
402
+ if (!ready() || announcing) return;
403
+ announcing = true;
404
+ queueMicrotask(() => {
405
+ announcing = false;
406
+ if (!disposed) announceCount();
407
+ });
408
+ };
379
409
  const canPrevious = (): boolean => loops() || index() > 0;
380
410
  const canNext = (): boolean => loops() || index() < count() - 1;
381
411
 
@@ -402,9 +432,13 @@ function Carousel(props: CarouselProps): JSX.Element {
402
432
  return slot;
403
433
  },
404
434
  registerCount() {
405
- const changed = claimed !== count();
406
435
  setCount(claimed);
407
- if (changed && ready()) emit("reInit");
436
+ scheduleAnnounce();
437
+ },
438
+ releaseSlide() {
439
+ claimed = Math.max(0, claimed - 1);
440
+ setCount(claimed);
441
+ scheduleAnnounce();
408
442
  },
409
443
  pause: (value) => setPaused(value),
410
444
  labels,
@@ -436,6 +470,7 @@ function Carousel(props: CarouselProps): JSX.Element {
436
470
  onMount(() => {
437
471
  setRegionElement(regionNode);
438
472
  setReady(true);
473
+ reported = count();
439
474
  const start = opts().startIndex;
440
475
  if (start !== undefined && start > 0) scrollToIndex(start, true);
441
476
  (local.setApi as ((api: CarouselApi) => void) | undefined)?.(api);
@@ -445,7 +480,8 @@ function Carousel(props: CarouselProps): JSX.Element {
445
480
  // `autoplay` prop: changing it retimes the interval, and removing it stops the interval.
446
481
  useEffect(() => {
447
482
  const interval = local.autoplay as number | undefined;
448
- if (interval === undefined || interval <= 0) return undefined;
483
+ // NaN and Infinity must not reach setInterval: a NaN delay fires every tick and an Infinity one wraps to 0.
484
+ if (interval === undefined || !Number.isFinite(interval) || interval <= 0) return undefined;
449
485
  if (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches)
450
486
  return undefined;
451
487
  const timer = setInterval(() => {
@@ -489,12 +525,24 @@ function Carousel(props: CarouselProps): JSX.Element {
489
525
  class={cn("relative", local.class)}
490
526
  onKeyDown={(event: KeyboardEvent) => {
491
527
  onKeyDown(event);
492
- (local.onKeyDown as ((event: KeyboardEvent) => void) | undefined)?.(event);
528
+ callHandler(local.onKeyDown, event);
529
+ }}
530
+ onPointerEnter={(event: PointerEvent) => {
531
+ callHandler(local.onPointerEnter, event);
532
+ setPaused(true);
533
+ }}
534
+ onPointerLeave={(event: PointerEvent) => {
535
+ callHandler(local.onPointerLeave, event);
536
+ setPaused(false);
537
+ }}
538
+ onFocusIn={(event: FocusEvent) => {
539
+ callHandler(local.onFocusIn, event);
540
+ setPaused(true);
541
+ }}
542
+ onFocusOut={(event: FocusEvent) => {
543
+ callHandler(local.onFocusOut, event);
544
+ setPaused(false);
493
545
  }}
494
- onPointerEnter={() => setPaused(true)}
495
- onPointerLeave={() => setPaused(false)}
496
- onFocusIn={() => setPaused(true)}
497
- onFocusOut={() => setPaused(false)}
498
546
  {...rest}
499
547
  >
500
548
  {local.children as JSX.Element}
@@ -551,15 +599,15 @@ function CarouselContent(props: ComponentProps<"div">): JSX.Element {
551
599
  local.class,
552
600
  )}
553
601
  onScroll={(event: Event) => {
554
- (local.onScroll as ((event: Event) => void) | undefined)?.(event);
602
+ callHandler(local.onScroll, event);
555
603
  carousel.scrolled();
556
604
  }}
557
605
  onWheel={(event: WheelEvent) => {
558
- (local.onWheel as ((event: WheelEvent) => void) | undefined)?.(event);
606
+ callHandler(local.onWheel, event);
559
607
  carousel.userMoved();
560
608
  }}
561
609
  onPointerDown={(event: PointerEvent) => {
562
- (local.onPointerDown as ((event: PointerEvent) => void) | undefined)?.(event);
610
+ callHandler(local.onPointerDown, event);
563
611
  carousel.userMoved();
564
612
  if (event.button !== 0 || event.pointerType === "touch") return;
565
613
  const element = event.currentTarget as HTMLElement;
@@ -630,12 +678,28 @@ function CarouselItem(props: ComponentProps<"div">): JSX.Element {
630
678
  // before the next one is counted; so a slide is named only once the carousel is mounted, and
631
679
  // the name then uses the final count (UI-CRS-18).
632
680
  carousel.registerCount();
681
+ // A slide that goes away (a list that shrinks or is replaced) no longer counts.
682
+ onCleanup(() => carousel.releaseSlide());
683
+ let node: HTMLElement | undefined;
684
+ // The position among the slides that exist now, from the DOM order: the slot a slide claimed when
685
+ // it rendered is stale once another slide has been removed or inserted before it.
686
+ const position = (): number => {
687
+ carousel.count();
688
+ const parent = node?.parentElement;
689
+ if (node === undefined || parent === null || parent === undefined) return slot + 1;
690
+ const slides = Array.from(parent.children).filter(
691
+ (child) => child.getAttribute("data-slot") === "carousel-item",
692
+ );
693
+ const at = slides.indexOf(node);
694
+ return at === -1 ? slot + 1 : at + 1;
695
+ };
696
+ const merged = mergeElementProps({ ref: (element: HTMLElement) => (node = element) }, rest);
633
697
  return (
634
698
  <div
635
699
  role="group"
636
700
  aria-roledescription="slide"
637
701
  aria-label={
638
- carousel.ready() ? carousel.labels().slide(slot + 1, carousel.count()) : undefined
702
+ carousel.ready() ? carousel.labels().slide(position(), carousel.count()) : undefined
639
703
  }
640
704
  data-slot="carousel-item"
641
705
  class={cn(
@@ -643,7 +707,7 @@ function CarouselItem(props: ComponentProps<"div">): JSX.Element {
643
707
  carousel.orientation() === "vertical" ? "pt-4" : "ps-4",
644
708
  local.class,
645
709
  )}
646
- {...rest}
710
+ {...merged}
647
711
  />
648
712
  );
649
713
  }
@@ -699,7 +763,7 @@ function CarouselPrevious(props: ComponentProps<"button">): JSX.Element {
699
763
  )}
700
764
  disabled={!carousel.canPrevious()}
701
765
  onClick={(event: MouseEvent) => {
702
- (local.onClick as ((event: MouseEvent) => void) | undefined)?.(event);
766
+ callHandler(local.onClick, event);
703
767
  carousel.previous();
704
768
  }}
705
769
  {...rest}
@@ -764,7 +828,7 @@ function CarouselNext(props: ComponentProps<"button">): JSX.Element {
764
828
  )}
765
829
  disabled={!carousel.canNext()}
766
830
  onClick={(event: MouseEvent) => {
767
- (local.onClick as ((event: MouseEvent) => void) | undefined)?.(event);
831
+ callHandler(local.onClick, event);
768
832
  carousel.next();
769
833
  }}
770
834
  {...rest}
@@ -220,7 +220,11 @@ function ChartContainer(props: ChartContainerProps): JSX.Element {
220
220
  "children",
221
221
  ]);
222
222
  const uiLocale = useUiLocale();
223
- const [width, setWidth] = useSignal<number>((local.initialWidth as number | undefined) ?? 600);
223
+ // A NaN or Infinity width would put "NaN" into every path; fall back to the default instead.
224
+ const initial = local.initialWidth as number | undefined;
225
+ const [width, setWidth] = useSignal<number>(
226
+ initial !== undefined && Number.isFinite(initial) ? initial : 600,
227
+ );
224
228
  const [element, setElement] = useRefSignal<HTMLElement>();
225
229
  let node: HTMLElement | undefined;
226
230
  const dir = useDirectionAround(() => local.dir as Direction | undefined, element);
@@ -1,4 +1,5 @@
1
1
  import { splitProps } from "@shadow-js/core";
2
+ import { callHandler } from "@shadow-js/primitives";
2
3
  import type { ComponentProps, JSX } from "@shadow-js/web";
3
4
  import { cva, type VariantProps } from "class-variance-authority";
4
5
  import { cn } from "@/lib/utils";
@@ -117,7 +118,7 @@ function InputGroupAddon(props: InputGroupAddonProps): JSX.Element {
117
118
  const [local, rest] = splitProps(props, ["class", "align", "onClick"]);
118
119
  // The application's own click handler runs first; the focus move is the default.
119
120
  const onClick = (event: MouseEvent): void => {
120
- (local.onClick as ((event: MouseEvent) => void) | undefined)?.(event);
121
+ callHandler(local.onClick, event);
121
122
  const target = event.target;
122
123
  if (target instanceof Element && target.closest("button") !== null) return;
123
124
  const group = (event.currentTarget as HTMLElement).closest('[data-slot="input-group"]');