@askrjs/themes 0.0.7 → 0.0.9

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.
Files changed (51) hide show
  1. package/THEMING.md +5 -1
  2. package/dist/components/_internal/box-layout.js +1 -1
  3. package/dist/components/_internal/classes.js.map +1 -1
  4. package/dist/components/_internal/jsx.js.map +1 -1
  5. package/dist/components/_internal/layout.js +2 -7
  6. package/dist/components/_internal/layout.js.map +1 -1
  7. package/dist/components/_internal/style.js +25 -1
  8. package/dist/components/_internal/style.js.map +1 -1
  9. package/dist/components/aspect-ratio/aspect-ratio.js +1 -1
  10. package/dist/components/block/block.js.map +1 -1
  11. package/dist/components/container/container.js.map +1 -1
  12. package/dist/components/flex/flex.js.map +1 -1
  13. package/dist/components/nav/nav.js.map +1 -1
  14. package/dist/components/navbar/navbar-panel.js +10 -16
  15. package/dist/components/navbar/navbar-panel.js.map +1 -1
  16. package/dist/components/navbar/navbar.js +23 -12
  17. package/dist/components/navbar/navbar.js.map +1 -1
  18. package/dist/components/section/section.js.map +1 -1
  19. package/dist/components/shell/shell-responsive.js +51 -20
  20. package/dist/components/shell/shell-responsive.js.map +1 -1
  21. package/dist/components/sidebar/sidebar-panel.js +14 -24
  22. package/dist/components/sidebar/sidebar-panel.js.map +1 -1
  23. package/dist/components/sidebar/sidebar.js +38 -24
  24. package/dist/components/sidebar/sidebar.js.map +1 -1
  25. package/dist/components/spacer/spacer.js +1 -1
  26. package/dist/components/spacer/spacer.js.map +1 -1
  27. package/dist/components/theme/theme.js +55 -13
  28. package/dist/components/theme/theme.js.map +1 -1
  29. package/dist/themes/default/index.css +35 -3
  30. package/package.json +11 -10
  31. package/src/components/_internal/jsx.ts +18 -6
  32. package/src/components/_internal/layout.ts +3 -12
  33. package/src/components/_internal/style.ts +42 -4
  34. package/src/components/navbar/navbar-panel.tsx +11 -16
  35. package/src/components/navbar/navbar.tsx +32 -17
  36. package/src/components/shell/shell-responsive.tsx +79 -29
  37. package/src/components/sidebar/sidebar-panel.tsx +15 -24
  38. package/src/components/sidebar/sidebar.tsx +52 -34
  39. package/src/components/theme/theme.tsx +88 -16
  40. package/src/themes/default/styles/actions/button.css +16 -0
  41. package/src/themes/default/styles/display/avatar.css +7 -1
  42. package/src/themes/default/styles/display/badge.css +2 -1
  43. package/src/themes/default/styles/display/table.css +2 -0
  44. package/src/themes/default/styles/forms/select.css +6 -0
  45. package/src/themes/default/styles/shell/navbar.css +2 -1
  46. package/templates/theme/styles/actions/button.css +16 -0
  47. package/templates/theme/styles/display/avatar.css +7 -1
  48. package/templates/theme/styles/display/badge.css +2 -1
  49. package/templates/theme/styles/display/table.css +2 -0
  50. package/templates/theme/styles/forms/select.css +6 -0
  51. package/templates/theme/styles/shell/navbar.css +2 -1
@@ -1 +1 @@
1
- {"version":3,"file":"theme.js","names":[],"sources":["../../../src/components/theme/theme.tsx"],"sourcesContent":["import { defineContext, readContext, state } from \"@askrjs/askr\";\nimport type { JSXElement } from \"@askrjs/askr/foundations/structures\";\nimport { resource } from \"@askrjs/askr/resources\";\nimport { Button } from \"@askrjs/ui\";\nimport type { ButtonNativeProps, PressEvent } from \"@askrjs/ui\";\n\nexport const CAT_THEME_NAMES = [\"tabby\", \"ginger\", \"tuxedo\", \"calico\", \"torty\"] as const;\n\nexport type CatThemeName = (typeof CAT_THEME_NAMES)[number];\nexport type ThemeName = \"light\" | \"dark\" | \"system\" | CatThemeName | (string & {});\n\nexport type ThemeOption = {\n value: ThemeName;\n label: string;\n};\n\nexport type ThemeContextValue = {\n theme: () => ThemeName;\n setTheme: (theme: ThemeName) => void;\n themes: readonly ThemeOption[];\n storageKey: string;\n};\n\nexport type ThemeProviderProps = {\n children?: unknown;\n defaultTheme?: ThemeName;\n themes?: readonly ThemeOption[];\n storageKey?: string;\n};\n\nexport type ThemePickerProps = Omit<\n JSX.IntrinsicElements[\"select\"],\n \"children\" | \"value\" | \"defaultValue\" | \"onChange\"\n> & {\n themes?: readonly ThemeOption[];\n label?: string;\n};\n\nexport type ThemeToggleRenderContext = {\n theme: ThemeName;\n nextTheme: ThemeName;\n};\n\nexport type ThemeToggleProps = Omit<ButtonNativeProps, \"children\" | \"onPress\"> & {\n children?: unknown | ((context: ThemeToggleRenderContext) => unknown);\n lightIcon?: unknown;\n darkIcon?: unknown;\n systemIcon?: unknown;\n themes?: readonly ThemeName[];\n onPress?: (event: PressEvent) => void;\n};\n\nexport const DEFAULT_THEME_OPTIONS: readonly ThemeOption[] = [\n { value: \"system\", label: \"System\" },\n { value: \"light\", label: \"Light\" },\n { value: \"dark\", label: \"Dark\" },\n];\n\nexport const CAT_THEME_OPTIONS: readonly ThemeOption[] = [\n { value: \"tabby\", label: \"Tabby\" },\n { value: \"ginger\", label: \"Ginger\" },\n { value: \"tuxedo\", label: \"Tuxedo\" },\n { value: \"calico\", label: \"Calico\" },\n { value: \"torty\", label: \"Torty\" },\n];\n\nconst DEFAULT_STORAGE_KEY = \"askr-theme\";\n\nconst ThemeContext = defineContext<ThemeContextValue>({\n theme: () => \"system\",\n setTheme: () => undefined,\n themes: DEFAULT_THEME_OPTIONS,\n storageKey: DEFAULT_STORAGE_KEY,\n});\n\nexport function useTheme(): ThemeContextValue {\n return readContext(ThemeContext);\n}\n\nexport function ThemeProvider(props: ThemeProviderProps): JSX.Element {\n const {\n children,\n defaultTheme = \"system\",\n themes = DEFAULT_THEME_OPTIONS,\n storageKey = DEFAULT_STORAGE_KEY,\n } = props;\n\n const themeState = state<ThemeName>(readStoredTheme(storageKey) ?? defaultTheme);\n\n const setTheme = (nextTheme: ThemeName) => {\n themeState.set(nextTheme);\n writeStoredTheme(storageKey, nextTheme);\n syncThemeRoot(nextTheme);\n };\n\n const value: ThemeContextValue = {\n theme: themeState,\n setTheme,\n themes,\n storageKey,\n };\n\n const currentTheme = themeState();\n\n return (\n <ThemeContext.Scope value={value}>\n <ThemeRootSync theme={currentTheme} />\n <div data-slot=\"theme-provider\">{children}</div>\n </ThemeContext.Scope>\n );\n}\n\nfunction ThemeRootSync(props: { theme: ThemeName }): JSX.Element | null {\n const { theme } = props;\n\n resource(\n ({ signal }: { signal: AbortSignal }) => {\n if (typeof window === \"undefined\") {\n syncThemeRoot(theme);\n return null;\n }\n\n const timeoutId = window.setTimeout(() => {\n if (!signal.aborted) {\n syncThemeRoot(theme);\n }\n }, 0);\n\n signal.addEventListener(\n \"abort\",\n () => {\n window.clearTimeout(timeoutId);\n },\n { once: true },\n );\n\n return null;\n },\n [theme],\n );\n\n return null;\n}\n\nexport function ThemePicker(props: ThemePickerProps): JSX.Element {\n const theme = useTheme();\n const { themes = theme.themes, label = \"Theme\", ...rest } = props;\n\n return (\n <select\n {...rest}\n aria-label={rest[\"aria-label\"] ?? label}\n data-slot=\"theme-picker\"\n value={theme.theme()}\n onChange={(event: Event) => {\n const target = event.currentTarget as HTMLSelectElement;\n theme.setTheme(target.value as ThemeName);\n }}\n >\n {themes.map((option) => (\n <option key={option.value} value={option.value}>\n {option.label}\n </option>\n ))}\n </select>\n );\n}\n\nexport function ThemeToggle(props: ThemeToggleProps): JSX.Element {\n const theme = useTheme();\n const {\n children,\n lightIcon,\n darkIcon,\n systemIcon,\n themes = [\"light\", \"dark\"],\n onPress,\n ...rest\n } = props;\n\n const currentTheme = theme.theme();\n const nextTheme = getNextTheme(currentTheme, themes);\n const renderContext = { theme: currentTheme, nextTheme };\n const ariaLabel = (rest as Record<string, unknown>)[\"aria-label\"];\n const themedIcon = resolveThemeToggleIcon(currentTheme, nextTheme, {\n lightIcon,\n darkIcon,\n systemIcon,\n });\n const renderedIcon = cloneThemeToggleIcon(themedIcon);\n const content =\n typeof children === \"function\" ? children(renderContext) : (children ?? renderedIcon);\n\n return (\n <Button\n {...(rest as ButtonNativeProps)}\n aria-label={typeof ariaLabel === \"string\" ? ariaLabel : `Switch to ${nextTheme} theme`}\n data-theme-control=\"toggle\"\n data-theme-choice={currentTheme}\n data-next-theme={nextTheme}\n onPress={(event) => {\n onPress?.(event);\n if (!event.defaultPrevented) theme.setTheme(nextTheme);\n }}\n >\n {content}\n </Button>\n );\n}\n\nfunction getNextTheme(currentTheme: ThemeName, themes: readonly ThemeName[]): ThemeName {\n if (themes.length === 0) return currentTheme;\n const index = themes.indexOf(currentTheme);\n return themes[index >= 0 && index < themes.length - 1 ? index + 1 : 0]!;\n}\n\nfunction getThemeIcon(\n theme: ThemeName,\n icons: Pick<ThemeToggleProps, \"lightIcon\" | \"darkIcon\" | \"systemIcon\">,\n): unknown {\n if (theme === \"light\") return icons.lightIcon;\n if (theme === \"dark\") return icons.darkIcon;\n if (theme === \"system\") return icons.systemIcon;\n return undefined;\n}\n\nexport function resolveThemeToggleIcon(\n theme: ThemeName,\n nextTheme: ThemeName,\n icons: Pick<ThemeToggleProps, \"lightIcon\" | \"darkIcon\" | \"systemIcon\">,\n): unknown {\n return getThemeIcon(theme, icons) ?? getThemeIcon(nextTheme, icons);\n}\n\nfunction isJSXElement(value: unknown): value is JSXElement {\n return (\n typeof value === \"object\" &&\n value !== null &&\n \"$$typeof\" in value &&\n \"type\" in value &&\n \"props\" in value\n );\n}\n\nfunction cloneThemeToggleIcon(icon: unknown): unknown {\n if (!isJSXElement(icon)) return icon;\n\n return {\n ...icon,\n props: { ...(icon.props as Record<string, unknown> | undefined) },\n };\n}\n\nfunction syncThemeRoot(themeChoice: ThemeName | null | undefined): void {\n if (typeof document === \"undefined\") {\n return;\n }\n\n const html = document.documentElement;\n\n if (themeChoice == null) {\n html.removeAttribute(\"data-theme\");\n html.removeAttribute(\"data-theme-choice\");\n return;\n }\n\n html.setAttribute(\"data-theme-choice\", themeChoice);\n\n if (themeChoice === \"system\") {\n html.removeAttribute(\"data-theme\");\n } else {\n html.setAttribute(\"data-theme\", themeChoice);\n }\n}\n\nfunction readStoredTheme(storageKey: string): ThemeName | undefined {\n if (typeof window === \"undefined\") return undefined;\n try {\n return (window.localStorage.getItem(storageKey) as ThemeName | null) ?? undefined;\n } catch {\n return undefined;\n }\n}\n\nfunction writeStoredTheme(storageKey: string, theme: ThemeName): void {\n if (typeof window === \"undefined\") return;\n try {\n window.localStorage.setItem(storageKey, theme);\n } catch {\n // Storage can be unavailable in private or locked-down browser contexts.\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"theme.js","names":[],"sources":["../../../src/components/theme/theme.tsx"],"sourcesContent":["import { defineContext, getSignal, readContext, state } from \"@askrjs/askr\";\nimport type { JSXElement } from \"@askrjs/askr/foundations/structures\";\nimport { resource } from \"@askrjs/askr/resources\";\nimport { Button } from \"@askrjs/ui\";\nimport type { ButtonNativeProps, PressEvent } from \"@askrjs/ui\";\n\nexport const CAT_THEME_NAMES = [\"tabby\", \"ginger\", \"tuxedo\", \"calico\", \"torty\"] as const;\n\nexport type CatThemeName = (typeof CAT_THEME_NAMES)[number];\nexport type ThemeName = \"light\" | \"dark\" | \"system\" | CatThemeName | (string & {});\n\nexport type ThemeOption = {\n value: ThemeName;\n label: string;\n};\n\nexport type ThemeContextValue = {\n theme: () => ThemeName;\n setTheme: (theme: ThemeName) => void;\n themes: readonly ThemeOption[];\n storageKey: string;\n};\n\nexport type ThemeProviderProps = {\n children?: unknown;\n defaultTheme?: ThemeName;\n themes?: readonly ThemeOption[];\n storageKey?: string;\n};\n\nexport type ThemePickerProps = Omit<\n JSX.IntrinsicElements[\"select\"],\n \"children\" | \"value\" | \"defaultValue\" | \"onChange\"\n> & {\n themes?: readonly ThemeOption[];\n label?: string;\n};\n\nexport type ThemeToggleRenderContext = {\n theme: ThemeName;\n nextTheme: ThemeName;\n};\n\nexport type ThemeToggleProps = Omit<ButtonNativeProps, \"children\" | \"onPress\"> & {\n children?: unknown | ((context: ThemeToggleRenderContext) => unknown);\n lightIcon?: unknown;\n darkIcon?: unknown;\n systemIcon?: unknown;\n themes?: readonly ThemeName[];\n onPress?: (event: PressEvent) => void;\n};\n\nexport const DEFAULT_THEME_OPTIONS: readonly ThemeOption[] = [\n { value: \"system\", label: \"System\" },\n { value: \"light\", label: \"Light\" },\n { value: \"dark\", label: \"Dark\" },\n];\n\nexport const CAT_THEME_OPTIONS: readonly ThemeOption[] = [\n { value: \"tabby\", label: \"Tabby\" },\n { value: \"ginger\", label: \"Ginger\" },\n { value: \"tuxedo\", label: \"Tuxedo\" },\n { value: \"calico\", label: \"Calico\" },\n { value: \"torty\", label: \"Torty\" },\n];\n\nconst DEFAULT_STORAGE_KEY = \"askr-theme\";\n// Passive mount sync should not overwrite a newer user/provider-initiated change.\nlet rootThemeRevision = 0;\nlet explicitRootThemeOwner: symbol | undefined;\nconst registeredProviderSignals = new WeakSet<AbortSignal>();\n\nconst ThemeContext = defineContext<ThemeContextValue>({\n theme: () => \"system\",\n setTheme: () => undefined,\n themes: DEFAULT_THEME_OPTIONS,\n storageKey: DEFAULT_STORAGE_KEY,\n});\n\nexport function useTheme(): ThemeContextValue {\n return readContext(ThemeContext);\n}\n\nexport function ThemeProvider(props: ThemeProviderProps): JSX.Element {\n const {\n children,\n defaultTheme = \"system\",\n themes = DEFAULT_THEME_OPTIONS,\n storageKey = DEFAULT_STORAGE_KEY,\n } = props;\n\n const providerId = state<symbol>(Symbol(\"ThemeProvider\"))();\n const providerSignal = getSignal();\n const themeState = state<ThemeName>(readStoredTheme(storageKey) ?? defaultTheme);\n const currentTheme = themeState();\n\n if (!registeredProviderSignals.has(providerSignal)) {\n registeredProviderSignals.add(providerSignal);\n providerSignal.addEventListener(\n \"abort\",\n () => {\n if (explicitRootThemeOwner === providerId) {\n explicitRootThemeOwner = undefined;\n }\n },\n { once: true },\n );\n }\n\n const setTheme = (nextTheme: ThemeName) => {\n themeState.set(nextTheme);\n writeStoredTheme(storageKey, nextTheme);\n explicitRootThemeOwner = providerId;\n rootThemeRevision += 1;\n syncThemeRoot(nextTheme);\n };\n\n const value: ThemeContextValue = {\n theme: themeState,\n setTheme,\n themes,\n storageKey,\n };\n\n return (\n <ThemeContext.Scope value={value}>\n <ThemeRootSync providerId={providerId} theme={currentTheme} />\n <div data-slot=\"theme-provider\">{children}</div>\n </ThemeContext.Scope>\n );\n}\n\nfunction ThemeRootSync(props: { providerId: symbol; theme: ThemeName }): JSX.Element | null {\n const { providerId, theme } = props;\n const expectedRootThemeRevision = rootThemeRevision;\n\n resource(\n ({ signal }: { signal: AbortSignal }) => {\n if (typeof window === \"undefined\") {\n syncThemeRoot(theme);\n return null;\n }\n\n const timeoutId = window.setTimeout(() => {\n if (!signal.aborted) {\n syncThemeRoot(theme, {\n expectedRevision: expectedRootThemeRevision,\n passive: true,\n providerId,\n });\n }\n }, 0);\n\n signal.addEventListener(\n \"abort\",\n () => {\n window.clearTimeout(timeoutId);\n },\n { once: true },\n );\n\n return null;\n },\n [theme],\n );\n\n return null;\n}\n\nexport function ThemePicker(props: ThemePickerProps): JSX.Element {\n const theme = useTheme();\n const { themes = theme.themes, label = \"Theme\", ...rest } = props;\n const currentTheme = theme.theme();\n\n return (\n <select\n {...rest}\n aria-label={rest[\"aria-label\"] ?? label}\n data-slot=\"theme-picker\"\n value={currentTheme}\n onChange={(event: Event) => {\n const target = getThemePickerTarget(event);\n if (target) {\n theme.setTheme(target.value as ThemeName);\n }\n }}\n >\n {themes.map((option) => (\n <option key={option.value} value={option.value} selected={option.value === currentTheme}>\n {option.label}\n </option>\n ))}\n </select>\n );\n}\n\nfunction getThemePickerTarget(event: Event): HTMLSelectElement | null {\n if (typeof HTMLSelectElement === \"undefined\") {\n return null;\n }\n\n const path = typeof event.composedPath === \"function\" ? event.composedPath() : [];\n const candidates = [event.target, event.currentTarget, ...path];\n\n for (const candidate of candidates) {\n if (candidate instanceof HTMLSelectElement) {\n return candidate;\n }\n }\n\n return null;\n}\n\nexport function ThemeToggle(props: ThemeToggleProps): JSX.Element {\n const theme = useTheme();\n const {\n children,\n lightIcon,\n darkIcon,\n systemIcon,\n themes = [\"light\", \"dark\"],\n onPress,\n ...rest\n } = props;\n\n const currentTheme = theme.theme();\n const nextTheme = getNextTheme(currentTheme, themes);\n const renderContext = { theme: currentTheme, nextTheme };\n const ariaLabel = (rest as Record<string, unknown>)[\"aria-label\"];\n const themedIcon = resolveThemeToggleIcon(currentTheme, nextTheme, {\n lightIcon,\n darkIcon,\n systemIcon,\n });\n const renderedIcon = cloneThemeToggleIcon(themedIcon);\n const content =\n typeof children === \"function\" ? children(renderContext) : (children ?? renderedIcon);\n\n return (\n <Button\n {...(rest as ButtonNativeProps)}\n aria-label={typeof ariaLabel === \"string\" ? ariaLabel : `Switch to ${nextTheme} theme`}\n data-theme-control=\"toggle\"\n data-theme-choice={currentTheme}\n data-next-theme={nextTheme}\n onPress={(event) => {\n onPress?.(event);\n if (!event.defaultPrevented && !Object.is(nextTheme, currentTheme)) {\n theme.setTheme(nextTheme);\n }\n }}\n >\n <span data-slot=\"theme-toggle-content\">{content}</span>\n </Button>\n );\n}\n\nfunction getNextTheme(currentTheme: ThemeName, themes: readonly ThemeName[]): ThemeName {\n if (themes.length === 0) return currentTheme;\n const index = themes.indexOf(currentTheme);\n return themes[index >= 0 && index < themes.length - 1 ? index + 1 : 0]!;\n}\n\nfunction getThemeIcon(\n theme: ThemeName,\n icons: Pick<ThemeToggleProps, \"lightIcon\" | \"darkIcon\" | \"systemIcon\">,\n): unknown {\n if (theme === \"light\") return icons.lightIcon;\n if (theme === \"dark\") return icons.darkIcon;\n if (theme === \"system\") return icons.systemIcon;\n return undefined;\n}\n\nexport function resolveThemeToggleIcon(\n theme: ThemeName,\n nextTheme: ThemeName,\n icons: Pick<ThemeToggleProps, \"lightIcon\" | \"darkIcon\" | \"systemIcon\">,\n): unknown {\n return getThemeIcon(theme, icons) ?? getThemeIcon(nextTheme, icons);\n}\n\nfunction isJSXElement(value: unknown): value is JSXElement {\n return (\n typeof value === \"object\" &&\n value !== null &&\n \"$$typeof\" in value &&\n \"type\" in value &&\n \"props\" in value\n );\n}\n\nfunction cloneThemeToggleIcon(icon: unknown): unknown {\n if (!isJSXElement(icon)) return icon;\n\n const props = icon.props as Record<string, unknown> | undefined;\n if (!props || Object.keys(props).length === 0) {\n return icon;\n }\n\n return {\n ...icon,\n props: { ...props },\n };\n}\n\nfunction syncThemeRoot(\n themeChoice: ThemeName | null | undefined,\n options?: { expectedRevision?: number; passive?: boolean; providerId?: symbol },\n): void {\n if (typeof document === \"undefined\") {\n return;\n }\n\n const html = document.documentElement;\n\n if (\n options?.passive &&\n options.expectedRevision !== undefined &&\n options.expectedRevision !== rootThemeRevision\n ) {\n return;\n }\n\n if (\n options?.passive &&\n explicitRootThemeOwner !== undefined &&\n explicitRootThemeOwner !== options.providerId\n ) {\n return;\n }\n\n if (themeChoice == null) {\n html.removeAttribute(\"data-theme\");\n html.removeAttribute(\"data-theme-choice\");\n return;\n }\n\n html.setAttribute(\"data-theme-choice\", themeChoice);\n\n if (themeChoice === \"system\") {\n html.removeAttribute(\"data-theme\");\n } else {\n html.setAttribute(\"data-theme\", themeChoice);\n }\n}\n\nfunction readStoredTheme(storageKey: string): ThemeName | undefined {\n if (typeof window === \"undefined\") return undefined;\n try {\n const storedTheme = window.localStorage.getItem(storageKey);\n return storedTheme ? (storedTheme as ThemeName) : undefined;\n } catch {\n return undefined;\n }\n}\n\nfunction writeStoredTheme(storageKey: string, theme: ThemeName): void {\n if (typeof window === \"undefined\") return;\n try {\n window.localStorage.setItem(storageKey, theme);\n } catch {\n // Storage can be unavailable in private or locked-down browser contexts.\n }\n}\n"],"mappings":"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"}
@@ -1648,6 +1648,7 @@
1648
1648
  display: flex;
1649
1649
  position: relative;
1650
1650
  overflow: hidden;
1651
+ container-type: inline-size;
1651
1652
  }
1652
1653
 
1653
1654
  :where(.navbar-shell, [data-slot="navbar-shell"]) {
@@ -1779,7 +1780,7 @@
1779
1780
  margin-inline-start: 0;
1780
1781
  }
1781
1782
 
1782
- @media (width >= 48rem) {
1783
+ @container (width >= 48rem) {
1783
1784
  :where(.navbar-shell, [data-slot="navbar-shell"]):has( > :where(.navbar-group[data-align="center"], [data-slot="navbar-group"][data-align="center"])) {
1784
1785
  align-items: center;
1785
1786
  column-gap: var(--ak-space-md);
@@ -2628,6 +2629,22 @@
2628
2629
  display: inline-flex;
2629
2630
  }
2630
2631
 
2632
+ :where([data-slot="theme-toggle-content"]) {
2633
+ --_ak-theme-toggle-icon-size: var(--ak-theme-toggle-icon-size, var(--ak-icon-size, 1em));
2634
+ flex: none;
2635
+ justify-content: center;
2636
+ align-items: center;
2637
+ min-inline-size: 0;
2638
+ display: inline-flex;
2639
+ }
2640
+
2641
+ :where([data-slot="theme-toggle-content"]) > :where([data-slot="icon"], svg) {
2642
+ inline-size: var(--_ak-theme-toggle-icon-size);
2643
+ block-size: var(--_ak-theme-toggle-icon-size);
2644
+ color: currentColor;
2645
+ flex: none;
2646
+ }
2647
+
2631
2648
  :where(.btn) > :where([data-slot="icon"], svg), :where([data-slot="button"]) > :where([data-slot="icon"], svg) {
2632
2649
  color: currentColor;
2633
2650
  flex: none;
@@ -3663,16 +3680,22 @@
3663
3680
  }
3664
3681
 
3665
3682
  :where(.select-item, [data-slot="select-item"]) {
3683
+ appearance: none;
3684
+ inline-size: 100%;
3666
3685
  min-height: var(--ak-density-control-height-md);
3667
3686
  padding: 0 var(--ak-density-control-padding-x-md);
3668
3687
  border-radius: var(--ak-radius-md);
3669
3688
  color: var(--ak-color-text);
3670
3689
  cursor: pointer;
3690
+ font-family: inherit;
3671
3691
  font-size: var(--ak-font-size-sm);
3672
3692
  font-weight: var(--ak-font-weight-medium);
3673
3693
  overflow-wrap: anywhere;
3694
+ text-align: start;
3674
3695
  transition: background var(--ak-duration-fast) var(--ak-ease-standard),
3675
3696
  color var(--ak-duration-fast) var(--ak-ease-standard);
3697
+ background: none;
3698
+ border: 0;
3676
3699
  align-items: center;
3677
3700
  display: flex;
3678
3701
  }
@@ -4049,7 +4072,8 @@
4049
4072
  font-size: var(--ak-font-size-xs);
4050
4073
  font-weight: var(--ak-font-weight-semibold);
4051
4074
  letter-spacing: 0;
4052
- overflow-wrap: anywhere;
4075
+ overflow-wrap: normal;
4076
+ word-break: normal;
4053
4077
  align-items: center;
4054
4078
  gap: .35rem;
4055
4079
  padding: 0 .55rem;
@@ -4148,14 +4172,20 @@
4148
4172
  }
4149
4173
 
4150
4174
  :where(.avatar-fallback, [data-slot="avatar-fallback"]) {
4175
+ box-sizing: border-box;
4151
4176
  block-size: 100%;
4152
4177
  inline-size: 100%;
4178
+ min-inline-size: 0;
4179
+ max-inline-size: 100%;
4153
4180
  font-size: var(--ak-font-size-sm);
4154
4181
  font-weight: var(--ak-font-weight-semibold);
4155
- overflow-wrap: anywhere;
4182
+ overflow-wrap: normal;
4183
+ text-overflow: ellipsis;
4184
+ white-space: nowrap;
4156
4185
  place-items: center;
4157
4186
  line-height: 1;
4158
4187
  display: inline-grid;
4188
+ overflow: hidden;
4159
4189
  }
4160
4190
 
4161
4191
  :where([data-slot="separator"]) {
@@ -4232,7 +4262,9 @@
4232
4262
  color: var(--ak-color-text-muted);
4233
4263
  font-weight: var(--ak-font-weight-semibold);
4234
4264
  letter-spacing: .04em;
4265
+ overflow-wrap: normal;
4235
4266
  text-transform: uppercase;
4267
+ word-break: normal;
4236
4268
  white-space: normal;
4237
4269
  }
4238
4270
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askrjs/themes",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "description": "Default theme tokens, styles, and themed layout wrappers for Askr apps.",
5
5
  "keywords": [
6
6
  "askr",
@@ -124,11 +124,11 @@
124
124
  "scripts": {
125
125
  "new:theme": "node ./scripts/new-theme.js",
126
126
  "clean": "npx --yes rimraf dist node_modules",
127
- "bench": "npm run bench:tier2",
127
+ "bench": "npm run bench:tier1 && npm run bench:tier2 && npm run bench:tier3 && npm run bench:tier4",
128
128
  "bench:tier1": "cross-env NODE_ENV=production vp test bench --run --reporter=default --config vitest.bench.tier1.config.ts",
129
129
  "bench:tier2": "cross-env NODE_ENV=production vp test bench --run --reporter=default --config vitest.bench.tier2.config.ts",
130
130
  "bench:tier3": "cross-env NODE_ENV=production vp test bench --run --reporter=default --config vitest.bench.tier3.config.ts",
131
- "bench:tier4": "cross-env NODE_ENV=production vp test bench --run --reporter=default --config vitest.bench.tier4.config.ts",
131
+ "bench:tier4": "cross-env NODE_ENV=production vp test bench --run --reporter=default --config vitest.bench.tier4.config.ts --mode production",
132
132
  "test": "npm run test:unit && npm run test:checks && npm run test:jsdom && npm run test:browser",
133
133
  "test:unit": "vp test run -c vitest.test.unit.config.ts",
134
134
  "test:jsdom": "cross-env NODE_ENV=production vp test run -c vitest.test.jsdom.config.ts",
@@ -142,21 +142,22 @@
142
142
  "devDependencies": {
143
143
  "@askrjs/askr": ">=0.0.1 <0.1.0",
144
144
  "@askrjs/ui": ">=0.0.1 <0.1.0",
145
- "@askrjs/vite": ">=0.0.2 <0.1.0",
146
- "@tsdown/css": "0.22.2",
147
- "@types/node": "^25.9.2",
145
+ "@askrjs/vite": ">=0.0.1 <0.1.0",
146
+ "@tsdown/css": "0.22.3",
147
+ "@types/node": "^26.0.0",
148
+ "@vitest/browser-playwright": "^4.1.9",
148
149
  "cross-env": "^10.1.0",
149
150
  "jsdom": "^29.1.1",
150
- "playwright": "^1.60.0",
151
+ "playwright": "^1.61.0",
151
152
  "typescript": "^6.0.3",
152
- "vite-plus": "^0.1.24"
153
+ "vite-plus": "^0.2.1"
153
154
  },
154
155
  "peerDependencies": {
155
156
  "@askrjs/askr": ">=0.0.1 <0.1.0",
156
- "@askrjs/ui": ">=0.0.1 <0.1.0"
157
+ "@askrjs/ui": ">=0.0.10 <0.1.0"
157
158
  },
158
159
  "engines": {
159
160
  "node": ">=18"
160
161
  },
161
- "packageManager": "npm@11.16.0"
162
+ "packageManager": "npm@11.17.0"
162
163
  }
@@ -53,9 +53,11 @@ export function collectJsxElements(
53
53
  ): JSXElement[] {
54
54
  const result: JSXElement[] = [];
55
55
 
56
- const visit = (value: unknown) => {
56
+ function visit(value: unknown): void {
57
57
  if (Array.isArray(value)) {
58
- value.forEach(visit);
58
+ for (let index = 0; index < value.length; index += 1) {
59
+ visit(value[index]);
60
+ }
59
61
  return;
60
62
  }
61
63
 
@@ -68,13 +70,23 @@ export function collectJsxElements(
68
70
  }
69
71
 
70
72
  visit(value.props?.children);
71
- };
73
+ }
72
74
 
73
75
  visit(children);
74
76
 
75
77
  return result;
76
78
  }
77
79
 
80
+ function isEventHandlerLikeKey(key: string): boolean {
81
+ return key.length >= 2 && key.charCodeAt(0) === 111 && key.charCodeAt(1) === 110;
82
+ }
83
+
84
+ function compareKeys(left: string, right: string): number {
85
+ if (left < right) return -1;
86
+ if (left > right) return 1;
87
+ return 0;
88
+ }
89
+
78
90
  function serializeArray(value: readonly unknown[]): string {
79
91
  if (value.length === 0) {
80
92
  return "";
@@ -101,7 +113,7 @@ function serializeSerializableProps(props: Record<string, unknown>): string {
101
113
  const keys: string[] = [];
102
114
 
103
115
  for (const key of Object.keys(props)) {
104
- if (key === "children" || key === "ref" || key.startsWith("on")) {
116
+ if (key === "children" || key === "ref" || isEventHandlerLikeKey(key)) {
105
117
  continue;
106
118
  }
107
119
 
@@ -127,14 +139,14 @@ function serializeSerializableProps(props: Record<string, unknown>): string {
127
139
  }
128
140
 
129
141
  if (keys.length === 2) {
130
- if (keys[1].localeCompare(keys[0]) < 0) {
142
+ if (keys[1] < keys[0]) {
131
143
  [keys[0], keys[1]] = [keys[1], keys[0]];
132
144
  }
133
145
 
134
146
  return `${keys[0]}:${String(props[keys[0]])},${keys[1]}:${String(props[keys[1]])}`;
135
147
  }
136
148
 
137
- keys.sort((left, right) => left.localeCompare(right));
149
+ keys.sort(compareKeys);
138
150
 
139
151
  let result = `${keys[0]}:${String(props[keys[0]])}`;
140
152
 
@@ -1,3 +1,5 @@
1
+ import { serializeCssDeclarations } from "./style";
2
+
1
3
  /**
2
4
  * Layout helpers shared by headless layout primitives.
3
5
  */
@@ -190,7 +192,7 @@ export function mergeLayoutStyles(
190
192
  Object.assign(merged, user as Record<string, unknown>);
191
193
  }
192
194
 
193
- const mergedString = objectToCssString(merged);
195
+ const mergedString = serializeCssDeclarations(merged);
194
196
 
195
197
  if (typeof user === "string" && user.trim()) {
196
198
  return mergedString ? `${mergedString};${user.trim()}` : user.trim();
@@ -198,14 +200,3 @@ export function mergeLayoutStyles(
198
200
 
199
201
  return mergedString || undefined;
200
202
  }
201
-
202
- function camelToKebab(s: string): string {
203
- return s.replace(/([A-Z])/g, (c) => `-${c.toLowerCase()}`);
204
- }
205
-
206
- function objectToCssString(styles: Record<string, unknown>): string {
207
- return Object.entries(styles)
208
- .filter(([, value]) => value !== undefined && value !== null)
209
- .map(([key, value]) => `${camelToKebab(key)}:${String(value)}`)
210
- .join(";");
211
- }
@@ -1,3 +1,44 @@
1
+ const cssPropertyNameCache = new Map<string, string>();
2
+
3
+ function cssPropertyName(name: string): string {
4
+ let cached = cssPropertyNameCache.get(name);
5
+ if (cached !== undefined) {
6
+ return cached;
7
+ }
8
+
9
+ let result = "";
10
+
11
+ for (let index = 0; index < name.length; index += 1) {
12
+ const code = name.charCodeAt(index);
13
+
14
+ if (code >= 65 && code <= 90) {
15
+ result += `-${String.fromCharCode(code + 32)}`;
16
+ } else {
17
+ result += name[index];
18
+ }
19
+ }
20
+
21
+ cssPropertyNameCache.set(name, result);
22
+ return result;
23
+ }
24
+
25
+ export function serializeCssDeclarations(styles: Record<string, unknown>): string {
26
+ const keys = Object.keys(styles);
27
+ let result = "";
28
+
29
+ for (const key of keys) {
30
+ const value = styles[key];
31
+ if (value === undefined || value === null) {
32
+ continue;
33
+ }
34
+
35
+ const declaration = `${cssPropertyName(key)}:${String(value)}`;
36
+ result = result ? `${result};${declaration}` : declaration;
37
+ }
38
+
39
+ return result;
40
+ }
41
+
1
42
  export function mergeCssVar(style: unknown, name: string, value: string): string {
2
43
  const decl = `${name}:${value}`;
3
44
 
@@ -7,10 +48,7 @@ export function mergeCssVar(style: unknown, name: string, value: string): string
7
48
  }
8
49
 
9
50
  if (style && typeof style === "object") {
10
- const entries = Object.entries(style as Record<string, unknown>)
11
- .filter(([, v]) => v !== undefined && v !== null)
12
- .map(([k, v]) => `${k.replace(/([A-Z])/g, (c) => `-${c.toLowerCase()}`)}:${String(v)}`)
13
- .join(";");
51
+ const entries = serializeCssDeclarations(style as Record<string, unknown>);
14
52
  return entries ? `${entries};${decl}` : decl;
15
53
  }
16
54
 
@@ -3,6 +3,15 @@ import { renderShellPanel } from "../shell/shell-panel";
3
3
  import { NavbarResponsiveContext } from "./navbar.context";
4
4
  import type { NavbarPanelProps } from "./navbar.types";
5
5
 
6
+ const DEFAULT_NAVBAR_RESPONSIVE_CONTEXT = {
7
+ active: () => false,
8
+ collapseLabel: () => "Menu",
9
+ closePanel: () => undefined,
10
+ panelId: () => undefined,
11
+ panelOpen: () => false,
12
+ togglePanel: () => undefined,
13
+ };
14
+
6
15
  export function NavbarPanel(props: NavbarPanelProps): JSX.Element | null {
7
16
  const {
8
17
  active: activeProp,
@@ -27,24 +36,10 @@ export function NavbarPanel(props: NavbarPanelProps): JSX.Element | null {
27
36
  try {
28
37
  return readContext(NavbarResponsiveContext);
29
38
  } catch {
30
- return {
31
- active: () => false,
32
- collapseLabel: () => "Menu",
33
- closePanel: () => undefined,
34
- panelId: () => undefined,
35
- panelOpen: () => false,
36
- togglePanel: () => undefined,
37
- };
39
+ return DEFAULT_NAVBAR_RESPONSIVE_CONTEXT;
38
40
  }
39
41
  })()
40
- : {
41
- active: () => false,
42
- collapseLabel: () => "Menu",
43
- closePanel: () => undefined,
44
- panelId: () => undefined,
45
- panelOpen: () => false,
46
- togglePanel: () => undefined,
47
- };
42
+ : DEFAULT_NAVBAR_RESPONSIVE_CONTEXT;
48
43
  const active = activeProp ?? responsive.active();
49
44
  const open = openProp ?? responsive.panelOpen();
50
45
  const panelId = panelIdProp ?? responsive.panelId();
@@ -13,18 +13,36 @@ import { NavbarResponsiveContext } from "./navbar.context";
13
13
  import { isNavbarCollapsed, resolveNavGroupAlign } from "./navbar.shared";
14
14
  import type { NavBrandProps, NavGroupProps, NavToggleProps, NavbarProps } from "./navbar.types";
15
15
 
16
- /**
17
- * Extracts the first NavBrand child to display in the mobile panel.
18
- * NavBrand should be the first child of Navbar for it to appear in the mobile menu.
19
- */
20
- function findNavbarBrand(children: unknown): unknown {
21
- return toChildArray(children).find((child) => isJsxElement(child) && child.type === NavBrand);
22
- }
16
+ type NavbarChildren = {
17
+ brand?: unknown;
18
+ panelItems: unknown[];
19
+ };
23
20
 
24
- function withoutNavbarBrand(children: unknown): unknown[] {
25
- return toChildArray(children).filter(
26
- (child) => !(isJsxElement(child) && child.type === NavBrand),
27
- );
21
+ function splitNavbarChildren(children: unknown[]): NavbarChildren {
22
+ let brand: unknown;
23
+ let hasBrand = false;
24
+ let panelItems: unknown[] | undefined;
25
+
26
+ for (let index = 0; index < children.length; index += 1) {
27
+ const child = children[index];
28
+
29
+ if (isJsxElement(child) && child.type === NavBrand) {
30
+ if (!hasBrand) {
31
+ brand = child;
32
+ hasBrand = true;
33
+ }
34
+
35
+ panelItems ??= children.slice(0, index);
36
+ continue;
37
+ }
38
+
39
+ panelItems?.push(child);
40
+ }
41
+
42
+ return {
43
+ brand: hasBrand ? brand : undefined,
44
+ panelItems: panelItems ?? children,
45
+ };
28
46
  }
29
47
 
30
48
  /**
@@ -59,11 +77,9 @@ export function Navbar(props: NavbarProps): JSX.Element {
59
77
  } = props;
60
78
  const effectiveCollapseLabel = collapseLabel ?? "Menu";
61
79
  const responsiveChildren = toChildArray(children);
80
+ const navbarChildren = splitNavbarChildren(responsiveChildren);
62
81
  const desktopChildren = renderKeyedShellChildren(responsiveChildren, "navbar-desktop");
63
- const panelChildren = renderKeyedShellChildren(
64
- withoutNavbarBrand(responsiveChildren),
65
- "navbar-panel",
66
- );
82
+ const panelChildren = renderKeyedShellChildren(navbarChildren.panelItems, "navbar-panel");
67
83
  // Extract brand to display in mobile panel header
68
84
  const responsiveCollapsedState =
69
85
  breakpoint !== undefined ? state(isNavbarCollapsed(breakpoint)) : undefined;
@@ -95,7 +111,6 @@ export function Navbar(props: NavbarProps): JSX.Element {
95
111
  panelOpen: () => Boolean(responsiveCollapsedState?.() && mobileMenuOpen),
96
112
  togglePanel,
97
113
  };
98
- const panelBrand = findNavbarBrand(responsiveChildren);
99
114
  const finalProps = mergeProps(rest, {
100
115
  "aria-label": ariaLabel,
101
116
  ref,
@@ -131,7 +146,7 @@ export function Navbar(props: NavbarProps): JSX.Element {
131
146
  {breakpoint !== undefined ? (
132
147
  <NavbarPanel
133
148
  active={breakpoint !== undefined}
134
- brand={panelBrand}
149
+ brand={navbarChildren.brand}
135
150
  collapseLabel={effectiveCollapseLabel}
136
151
  onClose={closePanel}
137
152
  onClick={closePanelOnNavActivation}
@@ -46,60 +46,110 @@ export function isShellPanelChild(child: unknown, panelComponent: unknown): chil
46
46
  return isJsxElement(child) && child.type === panelComponent;
47
47
  }
48
48
 
49
- export function renderKeyedShellChildren(children: unknown, keyPrefix: string): JSX.Element {
50
- const keyedChildren = toChildArray(children).map((child, index) => {
51
- if (!isJsxElement(child) || child.key != null) {
52
- return child;
49
+ function renderShellChildKey(child: unknown, index: number, keyPrefix: string): string {
50
+ return isJsxElement(child) && child.key != null ? String(child.key) : `${keyPrefix}-${index}`;
51
+ }
52
+
53
+ function keyMissingShellChildren(children: unknown[], keyPrefix: string): unknown[] {
54
+ let keyedChildren: unknown[] | undefined;
55
+
56
+ for (let index = 0; index < children.length; index += 1) {
57
+ const child = children[index];
58
+
59
+ if (isJsxElement(child) && child.key == null) {
60
+ keyedChildren ??= children.slice(0, index);
61
+ keyedChildren.push({
62
+ ...child,
63
+ key: `${keyPrefix}-${index}`,
64
+ });
65
+ continue;
53
66
  }
54
67
 
55
- return {
56
- ...child,
57
- key: `${keyPrefix}-${index}`,
58
- };
59
- });
68
+ keyedChildren?.push(child);
69
+ }
70
+
71
+ return keyedChildren ?? children;
72
+ }
60
73
 
74
+ function hasPanelProps(
75
+ props: Record<string, unknown> | undefined,
76
+ panelProps: ShellResponsivePanelProps,
77
+ ): boolean {
61
78
  return (
62
- <For
63
- each={() => keyedChildren}
64
- by={(child, index) =>
65
- isJsxElement(child) && child.key != null ? String(child.key) : `${keyPrefix}-${index}`
66
- }
67
- >
68
- {(child) => child as never}
69
- </For>
79
+ props?.active === panelProps.active &&
80
+ props?.collapseLabel === panelProps.collapseLabel &&
81
+ props?.onClose === panelProps.onClose &&
82
+ props?.open === panelProps.open &&
83
+ props?.panelId === panelProps.panelId
70
84
  );
71
85
  }
72
86
 
73
- export function renderKeyedShellPanelChildren(
74
- children: unknown,
87
+ function applyShellPanelProps(
88
+ children: unknown[],
75
89
  keyPrefix: string,
76
90
  panelProps: ShellResponsivePanelProps,
77
- ): JSX.Element {
78
- const keyedChildren = toChildArray(children).map((child, index) => {
91
+ ): unknown[] {
92
+ let panelChildren: unknown[] | undefined;
93
+
94
+ for (let index = 0; index < children.length; index += 1) {
95
+ const child = children[index];
96
+
79
97
  if (!isJsxElement(child)) {
80
- return child;
98
+ panelChildren?.push(child);
99
+ continue;
81
100
  }
82
101
 
83
- return {
102
+ const props = child.props as Record<string, unknown> | undefined;
103
+
104
+ if (hasPanelProps(props, panelProps)) {
105
+ panelChildren?.push(child);
106
+ continue;
107
+ }
108
+
109
+ panelChildren ??= children.slice(0, index);
110
+ panelChildren.push({
84
111
  ...child,
85
112
  key: child.key != null ? child.key : `${keyPrefix}-${index}`,
86
113
  props: {
87
- ...child.props,
114
+ ...props,
88
115
  active: panelProps.active,
89
116
  collapseLabel: panelProps.collapseLabel,
90
117
  onClose: panelProps.onClose,
91
118
  open: panelProps.open,
92
119
  panelId: panelProps.panelId,
93
120
  },
94
- };
95
- });
121
+ });
122
+ }
123
+
124
+ return panelChildren ?? children;
125
+ }
126
+
127
+ export function renderKeyedShellChildren(children: unknown, keyPrefix: string): JSX.Element {
128
+ const childList = toChildArray(children);
129
+ const keyedChildren = keyMissingShellChildren(childList, keyPrefix);
96
130
 
97
131
  return (
98
132
  <For
99
133
  each={() => keyedChildren}
100
- by={(child, index) =>
101
- isJsxElement(child) && child.key != null ? String(child.key) : `${keyPrefix}-${index}`
102
- }
134
+ by={(child, index) => renderShellChildKey(child, index, keyPrefix)}
135
+ >
136
+ {(child) => child as never}
137
+ </For>
138
+ );
139
+ }
140
+
141
+ export function renderKeyedShellPanelChildren(
142
+ children: unknown,
143
+ keyPrefix: string,
144
+ panelProps: ShellResponsivePanelProps,
145
+ ): JSX.Element {
146
+ const childList = toChildArray(children);
147
+ const keyedChildren = applyShellPanelProps(childList, keyPrefix, panelProps);
148
+
149
+ return (
150
+ <For
151
+ each={() => keyedChildren}
152
+ by={(child, index) => renderShellChildKey(child, index, keyPrefix)}
103
153
  >
104
154
  {(child) => child as never}
105
155
  </For>