@intlayer/design-system 9.3.3 → 9.4.0-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +1 -1
  2. package/dist/esm/api/hooks/analytics.mjs +6 -5
  3. package/dist/esm/api/hooks/analytics.mjs.map +1 -1
  4. package/dist/esm/components/Accordion/Accordion.mjs +2 -2
  5. package/dist/esm/components/Accordion/Accordion.mjs.map +1 -1
  6. package/dist/esm/components/ExpandCollapse/ExpandCollapse.mjs +1 -1
  7. package/dist/esm/components/ExpandCollapse/ExpandCollapse.mjs.map +1 -1
  8. package/dist/esm/components/Form/elements/OTPElement.mjs +4 -4
  9. package/dist/esm/components/Form/elements/OTPElement.mjs.map +1 -1
  10. package/dist/esm/components/Link/Link.mjs +10 -5
  11. package/dist/esm/components/Link/Link.mjs.map +1 -1
  12. package/dist/esm/components/LocaleSwitcherContentDropDown/LocaleSwitcherContent.mjs +1 -1
  13. package/dist/esm/components/Modal/Modal.mjs +2 -2
  14. package/dist/esm/components/Navbar/MobileNavbar.mjs +1 -1
  15. package/dist/esm/components/Pagination/Pagination.mjs +4 -4
  16. package/dist/esm/components/Pagination/Pagination.mjs.map +1 -1
  17. package/dist/esm/components/Pattern/GridDistortionPattern.mjs +3 -2
  18. package/dist/esm/components/Pattern/GridDistortionPattern.mjs.map +1 -1
  19. package/dist/esm/components/Popover/dynamic.mjs +68 -24
  20. package/dist/esm/components/Popover/dynamic.mjs.map +1 -1
  21. package/dist/esm/components/RightDrawer/RightDrawer.mjs +5 -4
  22. package/dist/esm/components/RightDrawer/RightDrawer.mjs.map +1 -1
  23. package/dist/esm/components/SwitchSelector/useSwitchSelector.mjs +2 -2
  24. package/dist/esm/components/SwitchSelector/useSwitchSelector.mjs.map +1 -1
  25. package/dist/esm/components/Tab/Tab.mjs +1 -1
  26. package/dist/esm/components/TabSelector/TabSelector.mjs +2 -2
  27. package/dist/esm/components/TabSelector/TabSelector.mjs.map +1 -1
  28. package/dist/esm/components/ThemeSwitcherDropDown/DesktopThemeSwitcher.mjs +10 -18
  29. package/dist/esm/components/ThemeSwitcherDropDown/DesktopThemeSwitcher.mjs.map +1 -1
  30. package/dist/esm/components/ThemeSwitcherDropDown/MobileThemeSwitcher.mjs +14 -13
  31. package/dist/esm/components/ThemeSwitcherDropDown/MobileThemeSwitcher.mjs.map +1 -1
  32. package/dist/esm/hooks/index.mjs +9 -9
  33. package/dist/esm/hooks/useItemSelector.mjs +12 -9
  34. package/dist/esm/hooks/useItemSelector.mjs.map +1 -1
  35. package/dist/esm/hooks/useScrollY.mjs +64 -45
  36. package/dist/esm/hooks/useScrollY.mjs.map +1 -1
  37. package/dist/esm/hooks/useStickToBottom.mjs +69 -0
  38. package/dist/esm/hooks/useStickToBottom.mjs.map +1 -0
  39. package/dist/esm/providers/ThemeProvider.mjs +143 -0
  40. package/dist/esm/providers/ThemeProvider.mjs.map +1 -0
  41. package/dist/esm/providers/index.mjs +2 -1
  42. package/dist/esm/routes.mjs +2 -1
  43. package/dist/esm/routes.mjs.map +1 -1
  44. package/dist/esm/utils/index.mjs +3 -1
  45. package/dist/esm/utils/observeScrollExtent.mjs +39 -0
  46. package/dist/esm/utils/observeScrollExtent.mjs.map +1 -0
  47. package/dist/esm/utils/scheduleFrameTask.mjs +44 -0
  48. package/dist/esm/utils/scheduleFrameTask.mjs.map +1 -0
  49. package/dist/types/api/hooks/analytics.d.ts +5 -3
  50. package/dist/types/api/hooks/analytics.d.ts.map +1 -1
  51. package/dist/types/components/Popover/dynamic.d.ts.map +1 -1
  52. package/dist/types/components/ThemeSwitcherDropDown/DesktopThemeSwitcher.d.ts +1 -1
  53. package/dist/types/components/ThemeSwitcherDropDown/DesktopThemeSwitcher.d.ts.map +1 -1
  54. package/dist/types/components/ThemeSwitcherDropDown/MobileThemeSwitcher.d.ts +1 -1
  55. package/dist/types/components/ThemeSwitcherDropDown/MobileThemeSwitcher.d.ts.map +1 -1
  56. package/dist/types/components/ThemeSwitcherDropDown/types.d.ts +3 -1
  57. package/dist/types/components/ThemeSwitcherDropDown/types.d.ts.map +1 -1
  58. package/dist/types/hooks/index.d.ts +2 -2
  59. package/dist/types/hooks/useItemSelector.d.ts +2 -0
  60. package/dist/types/hooks/useItemSelector.d.ts.map +1 -1
  61. package/dist/types/hooks/useScrollY.d.ts +10 -0
  62. package/dist/types/hooks/useScrollY.d.ts.map +1 -1
  63. package/dist/types/hooks/useStickToBottom.d.ts +41 -0
  64. package/dist/types/hooks/useStickToBottom.d.ts.map +1 -0
  65. package/dist/types/providers/ThemeProvider.d.ts +41 -0
  66. package/dist/types/providers/ThemeProvider.d.ts.map +1 -0
  67. package/dist/types/providers/index.d.ts +2 -1
  68. package/dist/types/routes.d.ts +2 -1
  69. package/dist/types/routes.d.ts.map +1 -1
  70. package/dist/types/utils/index.d.ts +3 -1
  71. package/dist/types/utils/observeScrollExtent.d.ts +22 -0
  72. package/dist/types/utils/observeScrollExtent.d.ts.map +1 -0
  73. package/dist/types/utils/scheduleFrameTask.d.ts +25 -0
  74. package/dist/types/utils/scheduleFrameTask.d.ts.map +1 -0
  75. package/package.json +18 -18
  76. package/dist/esm/hooks/useIsDarkMode.mjs +0 -17
  77. package/dist/esm/hooks/useIsDarkMode.mjs.map +0 -1
  78. package/dist/types/hooks/useIsDarkMode.d.ts +0 -5
  79. package/dist/types/hooks/useIsDarkMode.d.ts.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"DesktopThemeSwitcher.mjs","names":[],"sources":["../../../../src/components/ThemeSwitcherDropDown/DesktopThemeSwitcher.tsx"],"sourcesContent":["'use client';\n\nimport { CircleDashed, Moon, Sun } from 'lucide-react';\nimport { type FC, useState } from 'react';\nimport { Button, type ButtonProps } from '../Button';\nimport { Container } from '../Container';\nimport { DropDown } from '../DropDown';\nimport type { Modes } from './types';\n\nconst ButtonItem: FC<ButtonProps> = ({ Icon, children, ...props }) => (\n <div className=\"relative w-full p-0.5\">\n <Button\n className=\"w-full cursor-pointer rounded-lg p-1 text-left hover:bg-text/10 focus:bg-text-opposite/20 focus:outline-hidden disabled:text-white/25\"\n Icon={Icon}\n data-mode=\"system\"\n role=\"option\"\n variant=\"none\"\n {...props}\n >\n {children}\n </Button>\n </div>\n);\n\ntype DesktopThemeSwitcherProps = {\n theme: Modes;\n setTheme: (theme: Modes) => void;\n systemTheme: Modes;\n};\n\nexport const DesktopThemeSwitcher: FC<DesktopThemeSwitcherProps> = ({\n theme,\n setTheme,\n systemTheme,\n}) => {\n const isThemeSystemTheme = systemTheme === theme;\n const defaultMode = isThemeSystemTheme ? 'system' : theme;\n\n const [mode, setMode] = useState<Modes>(defaultMode);\n\n const switchMode = (mode: Modes) => {\n if (mode === 'system') {\n setTheme(systemTheme ?? 'light');\n } else {\n setTheme(mode);\n }\n setMode(mode);\n };\n\n const panelIdentifier = 'theme-switcher';\n\n return (\n <DropDown identifier={panelIdentifier}>\n <DropDown.Trigger\n className=\"p-2\"\n identifier={panelIdentifier}\n aria-label=\"Theme selector\"\n >\n {mode === 'system' && <CircleDashed data-mode=\"system\" />}\n {mode === 'light' && <Sun data-mode=\"light\" />}\n {mode === 'dark' && <Moon data-mode=\"dark\" />}\n </DropDown.Trigger>\n\n <DropDown.Panel identifier={panelIdentifier} isFocusable isOverable>\n <Container className=\"min-w-25 items-start p-1\" separator=\"y\">\n <ButtonItem\n Icon={CircleDashed}\n onClick={() => switchMode('system')}\n isActive={mode === 'system'}\n label=\"Restore to system mode\"\n >\n System\n </ButtonItem>\n <ButtonItem\n Icon={Sun}\n onClick={() => switchMode('light')}\n isActive={mode === 'light'}\n label=\"Switch to light mode\"\n >\n Light\n </ButtonItem>\n <ButtonItem\n Icon={Moon}\n onClick={() => switchMode('dark')}\n isActive={mode === 'dark'}\n label=\"Switch to dark mode\"\n >\n Dark\n </ButtonItem>\n </Container>\n </DropDown.Panel>\n </DropDown>\n );\n};\n"],"mappings":";;;;;;;;;;AASA,MAAM,cAA+B,EAAE,MAAM,UAAU,GAAG,YACxD,oBAAC,OAAD;CAAK,WAAU;WACb,oBAAC,QAAD;EACE,WAAU;EACJ;EACN,aAAU;EACV,MAAK;EACL,SAAQ;EACR,GAAI;EAEH;CACK;AACL;AASP,MAAa,wBAAuD,EAClE,OACA,UACA,kBACI;CAIJ,MAAM,CAAC,MAAM,WAAW,SAHG,gBAAgB,QACF,WAAW,KAED;CAEnD,MAAM,cAAc,SAAgB;EAClC,IAAI,SAAS,UACX,SAAS,eAAe,OAAO;OAE/B,SAAS,IAAI;EAEf,QAAQ,IAAI;CACd;CAEA,MAAM,kBAAkB;CAExB,OACE,qBAAC,UAAD;EAAU,YAAY;YAAtB,CACE,qBAAC,SAAS,SAAV;GACE,WAAU;GACV,YAAY;GACZ,cAAW;aAHb;IAKG,SAAS,YAAY,oBAAC,cAAD,EAAc,aAAU,SAAU;IACvD,SAAS,WAAW,oBAAC,KAAD,EAAK,aAAU,QAAS;IAC5C,SAAS,UAAU,oBAAC,MAAD,EAAM,aAAU,OAAQ;GAC5B;MAElB,oBAAC,SAAS,OAAV;GAAgB,YAAY;GAAiB;GAAY;aACvD,qBAAC,WAAD;IAAW,WAAU;IAA2B,WAAU;cAA1D;KACE,oBAAC,YAAD;MACE,MAAM;MACN,eAAe,WAAW,QAAQ;MAClC,UAAU,SAAS;MACnB,OAAM;gBACP;KAEW;KACZ,oBAAC,YAAD;MACE,MAAM;MACN,eAAe,WAAW,OAAO;MACjC,UAAU,SAAS;MACnB,OAAM;gBACP;KAEW;KACZ,oBAAC,YAAD;MACE,MAAM;MACN,eAAe,WAAW,MAAM;MAChC,UAAU,SAAS;MACnB,OAAM;gBACP;KAEW;IACH;;EACG,EACR;;AAEd"}
1
+ {"version":3,"file":"DesktopThemeSwitcher.mjs","names":[],"sources":["../../../../src/components/ThemeSwitcherDropDown/DesktopThemeSwitcher.tsx"],"sourcesContent":["'use client';\n\nimport { CircleDashed, Moon, Sun } from 'lucide-react';\nimport type { FC } from 'react';\nimport { Button, type ButtonProps } from '../Button';\nimport { Container } from '../Container';\nimport { DropDown } from '../DropDown';\nimport type { Modes } from './types';\n\nconst ButtonItem: FC<ButtonProps> = ({ Icon, children, ...props }) => (\n <div className=\"relative w-full p-0.5\">\n <Button\n className=\"w-full cursor-pointer rounded-lg p-1 text-left hover:bg-text/10 focus:bg-text-opposite/20 focus:outline-hidden disabled:text-white/25\"\n Icon={Icon}\n role=\"option\"\n variant=\"none\"\n {...props}\n >\n {children}\n </Button>\n </div>\n);\n\ntype DesktopThemeSwitcherProps = {\n /** Preference currently persisted — `system` while following the OS. */\n theme: Modes;\n setTheme: (theme: Modes) => void;\n};\n\nexport const DesktopThemeSwitcher: FC<DesktopThemeSwitcherProps> = ({\n theme,\n setTheme,\n}) => {\n const panelIdentifier = 'theme-switcher';\n\n return (\n <DropDown identifier={panelIdentifier}>\n <DropDown.Trigger\n className=\"p-2\"\n identifier={panelIdentifier}\n aria-label=\"Theme selector\"\n >\n {theme === 'system' && <CircleDashed data-mode=\"system\" />}\n {theme === 'light' && <Sun data-mode=\"light\" />}\n {theme === 'dark' && <Moon data-mode=\"dark\" />}\n </DropDown.Trigger>\n\n <DropDown.Panel identifier={panelIdentifier} isFocusable isOverable>\n <Container className=\"min-w-25 items-start p-1\" separator=\"y\">\n <ButtonItem\n Icon={CircleDashed}\n onClick={() => setTheme('system')}\n isActive={theme === 'system'}\n label=\"Restore to system mode\"\n >\n System\n </ButtonItem>\n <ButtonItem\n Icon={Sun}\n onClick={() => setTheme('light')}\n isActive={theme === 'light'}\n label=\"Switch to light mode\"\n >\n Light\n </ButtonItem>\n <ButtonItem\n Icon={Moon}\n onClick={() => setTheme('dark')}\n isActive={theme === 'dark'}\n label=\"Switch to dark mode\"\n >\n Dark\n </ButtonItem>\n </Container>\n </DropDown.Panel>\n </DropDown>\n );\n};\n"],"mappings":";;;;;;;;;AASA,MAAM,cAA+B,EAAE,MAAM,UAAU,GAAG,YACxD,oBAAC,OAAD;CAAK,WAAU;WACb,oBAAC,QAAD;EACE,WAAU;EACJ;EACN,MAAK;EACL,SAAQ;EACR,GAAI;EAEH;CACK;AACL;AASP,MAAa,wBAAuD,EAClE,OACA,eACI;CACJ,MAAM,kBAAkB;CAExB,OACE,qBAAC,UAAD;EAAU,YAAY;YAAtB,CACE,qBAAC,SAAS,SAAV;GACE,WAAU;GACV,YAAY;GACZ,cAAW;aAHb;IAKG,UAAU,YAAY,oBAAC,cAAD,EAAc,aAAU,SAAU;IACxD,UAAU,WAAW,oBAAC,KAAD,EAAK,aAAU,QAAS;IAC7C,UAAU,UAAU,oBAAC,MAAD,EAAM,aAAU,OAAQ;GAC7B;MAElB,oBAAC,SAAS,OAAV;GAAgB,YAAY;GAAiB;GAAY;aACvD,qBAAC,WAAD;IAAW,WAAU;IAA2B,WAAU;cAA1D;KACE,oBAAC,YAAD;MACE,MAAM;MACN,eAAe,SAAS,QAAQ;MAChC,UAAU,UAAU;MACpB,OAAM;gBACP;KAEW;KACZ,oBAAC,YAAD;MACE,MAAM;MACN,eAAe,SAAS,OAAO;MAC/B,UAAU,UAAU;MACpB,OAAM;gBACP;KAEW;KACZ,oBAAC,YAAD;MACE,MAAM;MACN,eAAe,SAAS,MAAM;MAC9B,UAAU,UAAU;MACpB,OAAM;gBACP;KAEW;IACH;;EACG,EACR;;AAEd"}
@@ -1,28 +1,29 @@
1
1
  'use client';
2
2
 
3
3
  import { cn } from "../../utils/cn.mjs";
4
- import { useState } from "react";
5
4
  import { jsx, jsxs } from "react/jsx-runtime";
6
5
  import { CircleDashed, Moon, Sun } from "lucide-react";
7
6
 
8
7
  //#region src/components/ThemeSwitcherDropDown/MobileThemeSwitcher.tsx
8
+ /** Order a tap cycles through. */
9
+ const modeCycle = [
10
+ "system",
11
+ "light",
12
+ "dark"
13
+ ];
14
+ const getNextMode = (mode) => modeCycle[(modeCycle.indexOf(mode) + 1) % modeCycle.length];
9
15
  const getIconStyle = ({ isCurrentMode, isNextMode }) => cn(`absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2`, `opacity-0 transition-opacity duration-300 ease-in-out`, isCurrentMode && `opacity-100 group-hover:opacity-0`, isNextMode && `group-hover:opacity-100`);
10
- const MobileThemeSwitcher = ({ theme, systemTheme, setTheme }) => {
11
- const isThemeSystemTheme = systemTheme === theme;
12
- const [mode, setMode] = useState(isThemeSystemTheme ? "system" : theme);
13
- const nextMode = mode === "system" ? theme === "dark" ? "light" : "dark" : isThemeSystemTheme ? "system" : mode === "light" ? "dark" : "light";
14
- const toggleMode = () => {
15
- if (nextMode === "system") setTheme(systemTheme ?? "light");
16
- else setTheme(nextMode);
17
- setMode(nextMode);
18
- };
16
+ const MobileThemeSwitcher = ({ theme, setTheme }) => {
17
+ const nextMode = getNextMode(theme);
18
+ const toggleMode = () => setTheme(nextMode);
19
19
  return /* @__PURE__ */ jsxs("button", {
20
+ type: "button",
20
21
  className: "group relative size-10",
21
22
  "aria-label": "Theme selector",
22
23
  children: [
23
24
  /* @__PURE__ */ jsx(CircleDashed, {
24
25
  className: getIconStyle({
25
- isCurrentMode: mode === "system",
26
+ isCurrentMode: theme === "system",
26
27
  isNextMode: nextMode === "system"
27
28
  }),
28
29
  onClick: toggleMode,
@@ -30,7 +31,7 @@ const MobileThemeSwitcher = ({ theme, systemTheme, setTheme }) => {
30
31
  }),
31
32
  /* @__PURE__ */ jsx(Moon, {
32
33
  className: getIconStyle({
33
- isCurrentMode: mode === "light",
34
+ isCurrentMode: theme === "light",
34
35
  isNextMode: nextMode === "light"
35
36
  }),
36
37
  onClick: toggleMode,
@@ -38,7 +39,7 @@ const MobileThemeSwitcher = ({ theme, systemTheme, setTheme }) => {
38
39
  }),
39
40
  /* @__PURE__ */ jsx(Sun, {
40
41
  className: getIconStyle({
41
- isCurrentMode: mode === "dark",
42
+ isCurrentMode: theme === "dark",
42
43
  isNextMode: nextMode === "dark"
43
44
  }),
44
45
  onClick: toggleMode,
@@ -1 +1 @@
1
- {"version":3,"file":"MobileThemeSwitcher.mjs","names":[],"sources":["../../../../src/components/ThemeSwitcherDropDown/MobileThemeSwitcher.tsx"],"sourcesContent":["'use client';\n\nimport { cn } from '@utils/cn';\nimport { CircleDashed, Moon, Sun } from 'lucide-react';\nimport { type FC, useState } from 'react';\nimport type { Modes } from './types';\n\ntype MobileThemeSwitcherProps = {\n theme: Modes;\n systemTheme: Modes;\n setTheme: (theme: Modes) => void;\n};\n\nconst getIconStyle = ({\n isCurrentMode,\n isNextMode,\n}: {\n isCurrentMode: boolean;\n isNextMode: boolean;\n}) =>\n cn(\n `absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2`,\n `opacity-0 transition-opacity duration-300 ease-in-out`,\n isCurrentMode && `opacity-100 group-hover:opacity-0`,\n isNextMode && `group-hover:opacity-100`\n );\n\nexport const MobileThemeSwitcher: FC<MobileThemeSwitcherProps> = ({\n theme,\n systemTheme,\n setTheme,\n}) => {\n const isThemeSystemTheme = systemTheme === theme;\n const defaultMode = isThemeSystemTheme ? 'system' : theme;\n\n const [mode, setMode] = useState<Modes>(defaultMode);\n\n const nextMode =\n // Start loop\n // If mode is system, toggle the theme inverse of the system theme\n mode === 'system'\n ? theme === 'dark'\n ? 'light'\n : 'dark'\n : // Close loop\n // If current theme same as system theme, reset by toggle the system theme\n isThemeSystemTheme\n ? 'system'\n : // Go to next step\n // Otherwise, toggle the remaining theme\n mode === 'light'\n ? 'dark'\n : 'light';\n\n const toggleMode = () => {\n if (nextMode === 'system') {\n setTheme(systemTheme ?? 'light');\n } else {\n setTheme(nextMode);\n }\n setMode(nextMode);\n };\n\n return (\n <button className=\"group relative size-10\" aria-label=\"Theme selector\">\n <CircleDashed\n className={getIconStyle({\n isCurrentMode: mode === 'system',\n isNextMode: nextMode === 'system',\n })}\n onClick={toggleMode}\n data-mode=\"system\"\n />\n\n <Moon\n className={getIconStyle({\n isCurrentMode: mode === 'light',\n isNextMode: nextMode === 'light',\n })}\n onClick={toggleMode}\n data-mode=\"light\"\n />\n\n <Sun\n className={getIconStyle({\n isCurrentMode: mode === 'dark',\n isNextMode: nextMode === 'dark',\n })}\n onClick={toggleMode}\n data-mode=\"dark\"\n />\n </button>\n );\n};\n"],"mappings":";;;;;;;;AAaA,MAAM,gBAAgB,EACpB,eACA,iBAKA,GACE,+DACA,yDACA,iBAAiB,qCACjB,cAAc,yBAChB;AAEF,MAAa,uBAAqD,EAChE,OACA,aACA,eACI;CACJ,MAAM,qBAAqB,gBAAgB;CAG3C,MAAM,CAAC,MAAM,WAAW,SAFJ,qBAAqB,WAAW,KAED;CAEnD,MAAM,WAGJ,SAAS,WACL,UAAU,SACR,UACA,SAGF,qBACE,WAGA,SAAS,UACP,SACA;CAEV,MAAM,mBAAmB;EACvB,IAAI,aAAa,UACf,SAAS,eAAe,OAAO;OAE/B,SAAS,QAAQ;EAEnB,QAAQ,QAAQ;CAClB;CAEA,OACE,qBAAC,UAAD;EAAQ,WAAU;EAAyB,cAAW;YAAtD;GACE,oBAAC,cAAD;IACE,WAAW,aAAa;KACtB,eAAe,SAAS;KACxB,YAAY,aAAa;IAC3B,CAAC;IACD,SAAS;IACT,aAAU;GACX;GAED,oBAAC,MAAD;IACE,WAAW,aAAa;KACtB,eAAe,SAAS;KACxB,YAAY,aAAa;IAC3B,CAAC;IACD,SAAS;IACT,aAAU;GACX;GAED,oBAAC,KAAD;IACE,WAAW,aAAa;KACtB,eAAe,SAAS;KACxB,YAAY,aAAa;IAC3B,CAAC;IACD,SAAS;IACT,aAAU;GACX;EACK;;AAEZ"}
1
+ {"version":3,"file":"MobileThemeSwitcher.mjs","names":[],"sources":["../../../../src/components/ThemeSwitcherDropDown/MobileThemeSwitcher.tsx"],"sourcesContent":["'use client';\n\nimport { cn } from '@utils/cn';\nimport { CircleDashed, Moon, Sun } from 'lucide-react';\nimport type { FC } from 'react';\nimport type { Modes } from './types';\n\ntype MobileThemeSwitcherProps = {\n /** Preference currently persisted — `system` while following the OS. */\n theme: Modes;\n setTheme: (theme: Modes) => void;\n};\n\n/** Order a tap cycles through. */\nconst modeCycle: Modes[] = ['system', 'light', 'dark'];\n\nconst getNextMode = (mode: Modes): Modes =>\n modeCycle[(modeCycle.indexOf(mode) + 1) % modeCycle.length];\n\nconst getIconStyle = ({\n isCurrentMode,\n isNextMode,\n}: {\n isCurrentMode: boolean;\n isNextMode: boolean;\n}) =>\n cn(\n `absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2`,\n `opacity-0 transition-opacity duration-300 ease-in-out`,\n isCurrentMode && `opacity-100 group-hover:opacity-0`,\n isNextMode && `group-hover:opacity-100`\n );\n\nexport const MobileThemeSwitcher: FC<MobileThemeSwitcherProps> = ({\n theme,\n setTheme,\n}) => {\n const nextMode = getNextMode(theme);\n const toggleMode = () => setTheme(nextMode);\n\n return (\n <button\n type=\"button\"\n className=\"group relative size-10\"\n aria-label=\"Theme selector\"\n >\n <CircleDashed\n className={getIconStyle({\n isCurrentMode: theme === 'system',\n isNextMode: nextMode === 'system',\n })}\n onClick={toggleMode}\n data-mode=\"system\"\n />\n\n <Moon\n className={getIconStyle({\n isCurrentMode: theme === 'light',\n isNextMode: nextMode === 'light',\n })}\n onClick={toggleMode}\n data-mode=\"light\"\n />\n\n <Sun\n className={getIconStyle({\n isCurrentMode: theme === 'dark',\n isNextMode: nextMode === 'dark',\n })}\n onClick={toggleMode}\n data-mode=\"dark\"\n />\n </button>\n );\n};\n"],"mappings":";;;;;;;;AAcA,MAAM,YAAqB;CAAC;CAAU;CAAS;AAAM;AAErD,MAAM,eAAe,SACnB,WAAW,UAAU,QAAQ,IAAI,IAAI,KAAK,UAAU;AAEtD,MAAM,gBAAgB,EACpB,eACA,iBAKA,GACE,+DACA,yDACA,iBAAiB,qCACjB,cAAc,yBAChB;AAEF,MAAa,uBAAqD,EAChE,OACA,eACI;CACJ,MAAM,WAAW,YAAY,KAAK;CAClC,MAAM,mBAAmB,SAAS,QAAQ;CAE1C,OACE,qBAAC,UAAD;EACE,MAAK;EACL,WAAU;EACV,cAAW;YAHb;GAKE,oBAAC,cAAD;IACE,WAAW,aAAa;KACtB,eAAe,UAAU;KACzB,YAAY,aAAa;IAC3B,CAAC;IACD,SAAS;IACT,aAAU;GACX;GAED,oBAAC,MAAD;IACE,WAAW,aAAa;KACtB,eAAe,UAAU;KACzB,YAAY,aAAa;IAC3B,CAAC;IACD,SAAS;IACT,aAAU;GACX;GAED,oBAAC,KAAD;IACE,WAAW,aAAa;KACtB,eAAe,UAAU;KACzB,YAAY,aAAa;IAC3B,CAAC;IACD,SAAS;IACT,aAAU;GACX;EACK;;AAEZ"}
@@ -1,16 +1,16 @@
1
- import { useKeyboardDetector } from "./useKeyboardDetector.mjs";
2
- import { useGetElementOrWindow } from "./useGetElementOrWindow.mjs";
3
- import { useScrollY } from "./useScrollY.mjs";
1
+ import { calculateIsMobile, checkIsIOS, checkIsIphoneOrSafariDevice, checkIsMac, checkIsMobileScreen, checkIsMobileUserAgent, getBreakpointFromSize, useDevice } from "./useDevice.mjs";
2
+ import { useItemSelector } from "./useItemSelector.mjs";
4
3
  import { usePersistedStore } from "./usePersistedStore.mjs";
4
+ import { useGetElementById } from "./useGetElementById.mjs";
5
+ import { useGetElementOrWindow } from "./useGetElementOrWindow.mjs";
5
6
  import { useHorizontalSwipe } from "./useHorizontalSwipe.mjs";
6
- import { useItemSelector } from "./useItemSelector.mjs";
7
- import { calculateIsMobile, checkIsIOS, checkIsIphoneOrSafariDevice, checkIsMac, checkIsMobileScreen, checkIsMobileUserAgent, getBreakpointFromSize, useDevice } from "./useDevice.mjs";
8
- import { useSearch } from "./useSearch.mjs";
9
7
  import { useIsMounted } from "./useIsMounted.mjs";
10
- import { useGetElementById } from "./useGetElementById.mjs";
11
- import { useIsDarkMode } from "./useIsDarkMode.mjs";
8
+ import { useKeyboardDetector } from "./useKeyboardDetector.mjs";
12
9
  import { useScreenWidth } from "./useScreenWidth.mjs";
13
10
  import { useScrollBlockage } from "./useScrollBlockage/index.mjs";
14
11
  import { useScrollDetection } from "./useScrollDetection.mjs";
12
+ import { useScrollY } from "./useScrollY.mjs";
13
+ import { useSearch } from "./useSearch.mjs";
14
+ import { useStickToBottom } from "./useStickToBottom.mjs";
15
15
 
16
- export { calculateIsMobile, checkIsIOS, checkIsIphoneOrSafariDevice, checkIsMac, checkIsMobileScreen, checkIsMobileUserAgent, getBreakpointFromSize, useDevice, useGetElementById, useGetElementOrWindow, useHorizontalSwipe, useIsDarkMode, useIsMounted, useItemSelector, useKeyboardDetector, usePersistedStore, useScreenWidth, useScrollBlockage, useScrollDetection, useScrollY, useSearch };
16
+ export { calculateIsMobile, checkIsIOS, checkIsIphoneOrSafariDevice, checkIsMac, checkIsMobileScreen, checkIsMobileUserAgent, getBreakpointFromSize, useDevice, useGetElementById, useGetElementOrWindow, useHorizontalSwipe, useIsMounted, useItemSelector, useKeyboardDetector, usePersistedStore, useScreenWidth, useScrollBlockage, useScrollDetection, useScrollY, useSearch, useStickToBottom };
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { scheduleFrameTask } from "../utils/scheduleFrameTask.mjs";
3
4
  import { useCallback, useEffect, useRef, useState } from "react";
4
5
 
5
6
  //#region src/hooks/useItemSelector.ts
@@ -21,7 +22,7 @@ const useItemSelector = (optionsRefs, { selector = selectorDefault, isHoverable
21
22
  const hoveredItemRef = useRef(null);
22
23
  const hideTimeoutRef = useRef(null);
23
24
  const lastPositionRef = useRef(null);
24
- const measurementFrameRef = useRef(null);
25
+ const cancelMeasurementRef = useRef(null);
25
26
  const selectorRef = useRef(selector);
26
27
  selectorRef.current = selector;
27
28
  const orientationRef = useRef(orientation);
@@ -81,12 +82,14 @@ const useItemSelector = (optionsRefs, { selector = selectorDefault, isHoverable
81
82
  *
82
83
  * Reading `offsetLeft` straight from a mutation, resize or scroll callback
83
84
  * forces a synchronous reflow, and a documentation page mounts one selector
84
- * per tab group and one per code block.
85
+ * per tab group and one per code block. The frame is shared with every other
86
+ * selector on the page, so React commits their positions in one pass instead
87
+ * of invalidating layout between two measurements.
85
88
  */
86
89
  const scheduleMeasurement = useCallback(() => {
87
- if (measurementFrameRef.current !== null) return;
88
- measurementFrameRef.current = requestAnimationFrame(() => {
89
- measurementFrameRef.current = null;
90
+ if (cancelMeasurementRef.current !== null) return;
91
+ cancelMeasurementRef.current = scheduleFrameTask(() => {
92
+ cancelMeasurementRef.current = null;
90
93
  calculatePosition();
91
94
  });
92
95
  }, [calculatePosition]);
@@ -124,10 +127,8 @@ const useItemSelector = (optionsRefs, { selector = selectorDefault, isHoverable
124
127
  }
125
128
  }
126
129
  return () => {
127
- if (measurementFrameRef.current !== null) {
128
- cancelAnimationFrame(measurementFrameRef.current);
129
- measurementFrameRef.current = null;
130
- }
130
+ cancelMeasurementRef.current?.();
131
+ cancelMeasurementRef.current = null;
131
132
  if (hideTimeoutRef.current) {
132
133
  clearTimeout(hideTimeoutRef.current);
133
134
  hideTimeoutRef.current = null;
@@ -150,7 +151,9 @@ const useItemSelector = (optionsRefs, { selector = selectorDefault, isHoverable
150
151
  ]);
151
152
  return {
152
153
  choiceIndicatorPosition,
154
+ /** Measures immediately. Prefer `scheduleMeasurement`, which does not force a reflow. */
153
155
  calculatePosition,
156
+ scheduleMeasurement,
154
157
  orientation
155
158
  };
156
159
  };
@@ -1 +1 @@
1
- {"version":3,"file":"useItemSelector.mjs","names":[],"sources":["../../../src/hooks/useItemSelector.ts"],"sourcesContent":["'use client';\n\nimport {\n type RefObject,\n useCallback,\n useEffect,\n useRef,\n useState,\n} from 'react';\n\nexport type ItemSelectorOrientation = 'horizontal' | 'vertical';\n\ntype HorizontalStyleState = {\n left: number;\n width: number;\n opacity: number;\n};\n\ntype VerticalStyleState = {\n top: number;\n height: number;\n opacity: number;\n};\n\ntype StyleState = HorizontalStyleState | VerticalStyleState;\n\nconst selectorDefault = (option: HTMLElement) =>\n option?.getAttribute('aria-selected') === 'true';\n\n/** Attributes whose change moves the indicator to another option. */\nconst WATCHED_ATTRIBUTES = ['aria-selected', 'data-active', 'data-indicator'];\n\n/** Delay before the indicator leaves the option the pointer just left. */\nconst HOVER_RELEASE_DELAY = 150;\n\ntype Options = {\n selector?: (option: HTMLElement, index: number) => boolean;\n isHoverable?: boolean;\n orientation?: ItemSelectorOrientation;\n};\n\nexport const useItemSelector = (\n optionsRefs: RefObject<HTMLElement[]>,\n {\n selector = selectorDefault,\n isHoverable = false,\n orientation = 'horizontal',\n }: Options = {\n selector: selectorDefault,\n isHoverable: false,\n orientation: 'horizontal',\n }\n) => {\n const [choiceIndicatorPosition, setChoiceIndicatorPosition] =\n useState<StyleState | null>(null);\n\n const hoveredItemRef = useRef<HTMLElement | null>(null);\n const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n const lastPositionRef = useRef<StyleState | null>(null);\n const measurementFrameRef = useRef<number | null>(null);\n\n /*\n * Callers build their options object inline, so `selector` and `orientation`\n * are new values on every render. Reading them from refs keeps the observers\n * below from being torn down and rebuilt each time — a switch selector used\n * to re-subscribe one MutationObserver per option per render.\n */\n const selectorRef = useRef(selector);\n selectorRef.current = selector;\n\n const orientationRef = useRef(orientation);\n orientationRef.current = orientation;\n\n const itemsLength = optionsRefs.current.length;\n\n const calculatePosition = useCallback(() => {\n const targetElement =\n hoveredItemRef.current ??\n optionsRefs.current.find(selectorRef.current) ??\n null;\n\n if (hoveredItemRef.current && hideTimeoutRef.current) {\n // Clear any pending hide timeout when hovering over an item\n clearTimeout(hideTimeoutRef.current);\n hideTimeoutRef.current = null;\n }\n\n const isVertical = orientationRef.current === 'vertical';\n\n if (!targetElement) {\n // Keep previous position but set opacity to 0\n if (lastPositionRef.current?.opacity === 0) return;\n\n const newPosition: StyleState = isVertical\n ? {\n top:\n (lastPositionRef.current as VerticalStyleState | null)?.top ?? 0,\n height:\n (lastPositionRef.current as VerticalStyleState | null)?.height ??\n 0,\n opacity: 0,\n }\n : {\n left:\n (lastPositionRef.current as HorizontalStyleState | null)?.left ??\n 0,\n width:\n (lastPositionRef.current as HorizontalStyleState | null)?.width ??\n 0,\n opacity: 0,\n };\n\n setChoiceIndicatorPosition(newPosition);\n lastPositionRef.current = newPosition;\n return;\n }\n\n if (isVertical) {\n const top = targetElement.offsetTop;\n const height = targetElement.offsetHeight;\n const previous = lastPositionRef.current as VerticalStyleState | null;\n\n if (\n previous?.top === top &&\n previous.height === height &&\n previous.opacity === 1\n ) {\n return;\n }\n\n const newPosition: VerticalStyleState = { top, height, opacity: 1 };\n\n setChoiceIndicatorPosition(newPosition);\n lastPositionRef.current = newPosition;\n return;\n }\n\n const left = targetElement.offsetLeft;\n const width = targetElement.offsetWidth;\n const previous = lastPositionRef.current as HorizontalStyleState | null;\n\n if (\n previous?.left === left &&\n previous.width === width &&\n previous.opacity === 1\n ) {\n return;\n }\n\n const newPosition: HorizontalStyleState = { left, width, opacity: 1 };\n\n setChoiceIndicatorPosition(newPosition);\n lastPositionRef.current = newPosition;\n }, [optionsRefs]);\n\n /**\n * Defers the measurement to the next frame, and to a single one however many\n * observers fire.\n *\n * Reading `offsetLeft` straight from a mutation, resize or scroll callback\n * forces a synchronous reflow, and a documentation page mounts one selector\n * per tab group and one per code block.\n */\n const scheduleMeasurement = useCallback(() => {\n if (measurementFrameRef.current !== null) return;\n\n measurementFrameRef.current = requestAnimationFrame(() => {\n measurementFrameRef.current = null;\n calculatePosition();\n });\n }, [calculatePosition]);\n\n useEffect(() => {\n scheduleMeasurement();\n\n window.addEventListener('resize', scheduleMeasurement, { passive: true });\n window.addEventListener('DOMContentLoaded', scheduleMeasurement);\n\n const options = optionsRefs.current.filter(Boolean);\n\n // One observer of each kind covers every option: both accept repeated\n // `observe` calls, so a group of ten options costs two observers, not\n // twenty.\n const mutationObserver = new MutationObserver(scheduleMeasurement);\n const resizeObserver = new ResizeObserver(scheduleMeasurement);\n\n const handleMouseEnter = (event: Event) => {\n if (hideTimeoutRef.current) {\n clearTimeout(hideTimeoutRef.current);\n hideTimeoutRef.current = null;\n }\n\n hoveredItemRef.current = event.currentTarget as HTMLElement;\n scheduleMeasurement();\n };\n\n const handleMouseLeave = () => {\n if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);\n\n // Keep the indicator where it is for a moment, so moving between two\n // neighbouring options does not make it blink out and back in.\n hideTimeoutRef.current = setTimeout(() => {\n hoveredItemRef.current = null;\n scheduleMeasurement();\n }, HOVER_RELEASE_DELAY);\n };\n\n for (const option of options) {\n mutationObserver.observe(option, {\n attributes: true,\n attributeFilter: WATCHED_ATTRIBUTES,\n });\n resizeObserver.observe(option);\n\n if (isHoverable) {\n option.addEventListener('mouseenter', handleMouseEnter, {\n passive: true,\n });\n option.addEventListener('mouseleave', handleMouseLeave, {\n passive: true,\n });\n }\n }\n\n return () => {\n if (measurementFrameRef.current !== null) {\n cancelAnimationFrame(measurementFrameRef.current);\n measurementFrameRef.current = null;\n }\n\n if (hideTimeoutRef.current) {\n clearTimeout(hideTimeoutRef.current);\n hideTimeoutRef.current = null;\n }\n\n window.removeEventListener('resize', scheduleMeasurement);\n window.removeEventListener('DOMContentLoaded', scheduleMeasurement);\n\n mutationObserver.disconnect();\n resizeObserver.disconnect();\n\n if (isHoverable) {\n for (const option of options) {\n option.removeEventListener('mouseenter', handleMouseEnter);\n option.removeEventListener('mouseleave', handleMouseLeave);\n }\n }\n };\n }, [optionsRefs, itemsLength, isHoverable, orientation, scheduleMeasurement]);\n\n return { choiceIndicatorPosition, calculatePosition, orientation };\n};\n"],"mappings":";;;;;AA0BA,MAAM,mBAAmB,WACvB,QAAQ,aAAa,eAAe,MAAM;;AAG5C,MAAM,qBAAqB;CAAC;CAAiB;CAAe;AAAgB;;AAG5E,MAAM,sBAAsB;AAQ5B,MAAa,mBACX,aACA,EACE,WAAW,iBACX,cAAc,OACd,cAAc,iBACH;CACX,UAAU;CACV,aAAa;CACb,aAAa;AACf,MACG;CACH,MAAM,CAAC,yBAAyB,8BAC9B,SAA4B,IAAI;CAElC,MAAM,iBAAiB,OAA2B,IAAI;CACtD,MAAM,iBAAiB,OAA8B,IAAI;CACzD,MAAM,kBAAkB,OAA0B,IAAI;CACtD,MAAM,sBAAsB,OAAsB,IAAI;CAQtD,MAAM,cAAc,OAAO,QAAQ;CACnC,YAAY,UAAU;CAEtB,MAAM,iBAAiB,OAAO,WAAW;CACzC,eAAe,UAAU;CAEzB,MAAM,cAAc,YAAY,QAAQ;CAExC,MAAM,oBAAoB,kBAAkB;EAC1C,MAAM,gBACJ,eAAe,WACf,YAAY,QAAQ,KAAK,YAAY,OAAO,KAC5C;EAEF,IAAI,eAAe,WAAW,eAAe,SAAS;GAEpD,aAAa,eAAe,OAAO;GACnC,eAAe,UAAU;EAC3B;EAEA,MAAM,aAAa,eAAe,YAAY;EAE9C,IAAI,CAAC,eAAe;GAElB,IAAI,gBAAgB,SAAS,YAAY,GAAG;GAE5C,MAAM,cAA0B,aAC5B;IACE,KACG,gBAAgB,SAAuC,OAAO;IACjE,QACG,gBAAgB,SAAuC,UACxD;IACF,SAAS;GACX,IACA;IACE,MACG,gBAAgB,SAAyC,QAC1D;IACF,OACG,gBAAgB,SAAyC,SAC1D;IACF,SAAS;GACX;GAEJ,2BAA2B,WAAW;GACtC,gBAAgB,UAAU;GAC1B;EACF;EAEA,IAAI,YAAY;GACd,MAAM,MAAM,cAAc;GAC1B,MAAM,SAAS,cAAc;GAC7B,MAAM,WAAW,gBAAgB;GAEjC,IACE,UAAU,QAAQ,OAClB,SAAS,WAAW,UACpB,SAAS,YAAY,GAErB;GAGF,MAAM,cAAkC;IAAE;IAAK;IAAQ,SAAS;GAAE;GAElE,2BAA2B,WAAW;GACtC,gBAAgB,UAAU;GAC1B;EACF;EAEA,MAAM,OAAO,cAAc;EAC3B,MAAM,QAAQ,cAAc;EAC5B,MAAM,WAAW,gBAAgB;EAEjC,IACE,UAAU,SAAS,QACnB,SAAS,UAAU,SACnB,SAAS,YAAY,GAErB;EAGF,MAAM,cAAoC;GAAE;GAAM;GAAO,SAAS;EAAE;EAEpE,2BAA2B,WAAW;EACtC,gBAAgB,UAAU;CAC5B,GAAG,CAAC,WAAW,CAAC;;;;;;;;;CAUhB,MAAM,sBAAsB,kBAAkB;EAC5C,IAAI,oBAAoB,YAAY,MAAM;EAE1C,oBAAoB,UAAU,4BAA4B;GACxD,oBAAoB,UAAU;GAC9B,kBAAkB;EACpB,CAAC;CACH,GAAG,CAAC,iBAAiB,CAAC;CAEtB,gBAAgB;EACd,oBAAoB;EAEpB,OAAO,iBAAiB,UAAU,qBAAqB,EAAE,SAAS,KAAK,CAAC;EACxE,OAAO,iBAAiB,oBAAoB,mBAAmB;EAE/D,MAAM,UAAU,YAAY,QAAQ,OAAO,OAAO;EAKlD,MAAM,mBAAmB,IAAI,iBAAiB,mBAAmB;EACjE,MAAM,iBAAiB,IAAI,eAAe,mBAAmB;EAE7D,MAAM,oBAAoB,UAAiB;GACzC,IAAI,eAAe,SAAS;IAC1B,aAAa,eAAe,OAAO;IACnC,eAAe,UAAU;GAC3B;GAEA,eAAe,UAAU,MAAM;GAC/B,oBAAoB;EACtB;EAEA,MAAM,yBAAyB;GAC7B,IAAI,eAAe,SAAS,aAAa,eAAe,OAAO;GAI/D,eAAe,UAAU,iBAAiB;IACxC,eAAe,UAAU;IACzB,oBAAoB;GACtB,GAAG,mBAAmB;EACxB;EAEA,KAAK,MAAM,UAAU,SAAS;GAC5B,iBAAiB,QAAQ,QAAQ;IAC/B,YAAY;IACZ,iBAAiB;GACnB,CAAC;GACD,eAAe,QAAQ,MAAM;GAE7B,IAAI,aAAa;IACf,OAAO,iBAAiB,cAAc,kBAAkB,EACtD,SAAS,KACX,CAAC;IACD,OAAO,iBAAiB,cAAc,kBAAkB,EACtD,SAAS,KACX,CAAC;GACH;EACF;EAEA,aAAa;GACX,IAAI,oBAAoB,YAAY,MAAM;IACxC,qBAAqB,oBAAoB,OAAO;IAChD,oBAAoB,UAAU;GAChC;GAEA,IAAI,eAAe,SAAS;IAC1B,aAAa,eAAe,OAAO;IACnC,eAAe,UAAU;GAC3B;GAEA,OAAO,oBAAoB,UAAU,mBAAmB;GACxD,OAAO,oBAAoB,oBAAoB,mBAAmB;GAElE,iBAAiB,WAAW;GAC5B,eAAe,WAAW;GAE1B,IAAI,aACF,KAAK,MAAM,UAAU,SAAS;IAC5B,OAAO,oBAAoB,cAAc,gBAAgB;IACzD,OAAO,oBAAoB,cAAc,gBAAgB;GAC3D;EAEJ;CACF,GAAG;EAAC;EAAa;EAAa;EAAa;EAAa;CAAmB,CAAC;CAE5E,OAAO;EAAE;EAAyB;EAAmB;CAAY;AACnE"}
1
+ {"version":3,"file":"useItemSelector.mjs","names":[],"sources":["../../../src/hooks/useItemSelector.ts"],"sourcesContent":["'use client';\n\nimport { scheduleFrameTask } from '@utils/scheduleFrameTask';\nimport {\n type RefObject,\n useCallback,\n useEffect,\n useRef,\n useState,\n} from 'react';\n\nexport type ItemSelectorOrientation = 'horizontal' | 'vertical';\n\ntype HorizontalStyleState = {\n left: number;\n width: number;\n opacity: number;\n};\n\ntype VerticalStyleState = {\n top: number;\n height: number;\n opacity: number;\n};\n\ntype StyleState = HorizontalStyleState | VerticalStyleState;\n\nconst selectorDefault = (option: HTMLElement) =>\n option?.getAttribute('aria-selected') === 'true';\n\n/** Attributes whose change moves the indicator to another option. */\nconst WATCHED_ATTRIBUTES = ['aria-selected', 'data-active', 'data-indicator'];\n\n/** Delay before the indicator leaves the option the pointer just left. */\nconst HOVER_RELEASE_DELAY = 150;\n\ntype Options = {\n selector?: (option: HTMLElement, index: number) => boolean;\n isHoverable?: boolean;\n orientation?: ItemSelectorOrientation;\n};\n\nexport const useItemSelector = (\n optionsRefs: RefObject<HTMLElement[]>,\n {\n selector = selectorDefault,\n isHoverable = false,\n orientation = 'horizontal',\n }: Options = {\n selector: selectorDefault,\n isHoverable: false,\n orientation: 'horizontal',\n }\n) => {\n const [choiceIndicatorPosition, setChoiceIndicatorPosition] =\n useState<StyleState | null>(null);\n\n const hoveredItemRef = useRef<HTMLElement | null>(null);\n const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n const lastPositionRef = useRef<StyleState | null>(null);\n const cancelMeasurementRef = useRef<(() => void) | null>(null);\n\n /*\n * Callers build their options object inline, so `selector` and `orientation`\n * are new values on every render. Reading them from refs keeps the observers\n * below from being torn down and rebuilt each time — a switch selector used\n * to re-subscribe one MutationObserver per option per render.\n */\n const selectorRef = useRef(selector);\n selectorRef.current = selector;\n\n const orientationRef = useRef(orientation);\n orientationRef.current = orientation;\n\n const itemsLength = optionsRefs.current.length;\n\n const calculatePosition = useCallback(() => {\n const targetElement =\n hoveredItemRef.current ??\n optionsRefs.current.find(selectorRef.current) ??\n null;\n\n if (hoveredItemRef.current && hideTimeoutRef.current) {\n // Clear any pending hide timeout when hovering over an item\n clearTimeout(hideTimeoutRef.current);\n hideTimeoutRef.current = null;\n }\n\n const isVertical = orientationRef.current === 'vertical';\n\n if (!targetElement) {\n // Keep previous position but set opacity to 0\n if (lastPositionRef.current?.opacity === 0) return;\n\n const newPosition: StyleState = isVertical\n ? {\n top:\n (lastPositionRef.current as VerticalStyleState | null)?.top ?? 0,\n height:\n (lastPositionRef.current as VerticalStyleState | null)?.height ??\n 0,\n opacity: 0,\n }\n : {\n left:\n (lastPositionRef.current as HorizontalStyleState | null)?.left ??\n 0,\n width:\n (lastPositionRef.current as HorizontalStyleState | null)?.width ??\n 0,\n opacity: 0,\n };\n\n setChoiceIndicatorPosition(newPosition);\n lastPositionRef.current = newPosition;\n return;\n }\n\n if (isVertical) {\n const top = targetElement.offsetTop;\n const height = targetElement.offsetHeight;\n const previous = lastPositionRef.current as VerticalStyleState | null;\n\n if (\n previous?.top === top &&\n previous.height === height &&\n previous.opacity === 1\n ) {\n return;\n }\n\n const newPosition: VerticalStyleState = { top, height, opacity: 1 };\n\n setChoiceIndicatorPosition(newPosition);\n lastPositionRef.current = newPosition;\n return;\n }\n\n const left = targetElement.offsetLeft;\n const width = targetElement.offsetWidth;\n const previous = lastPositionRef.current as HorizontalStyleState | null;\n\n if (\n previous?.left === left &&\n previous.width === width &&\n previous.opacity === 1\n ) {\n return;\n }\n\n const newPosition: HorizontalStyleState = { left, width, opacity: 1 };\n\n setChoiceIndicatorPosition(newPosition);\n lastPositionRef.current = newPosition;\n }, [optionsRefs]);\n\n /**\n * Defers the measurement to the next frame, and to a single one however many\n * observers fire.\n *\n * Reading `offsetLeft` straight from a mutation, resize or scroll callback\n * forces a synchronous reflow, and a documentation page mounts one selector\n * per tab group and one per code block. The frame is shared with every other\n * selector on the page, so React commits their positions in one pass instead\n * of invalidating layout between two measurements.\n */\n const scheduleMeasurement = useCallback(() => {\n if (cancelMeasurementRef.current !== null) return;\n\n cancelMeasurementRef.current = scheduleFrameTask(() => {\n cancelMeasurementRef.current = null;\n calculatePosition();\n });\n }, [calculatePosition]);\n\n useEffect(() => {\n scheduleMeasurement();\n\n window.addEventListener('resize', scheduleMeasurement, { passive: true });\n window.addEventListener('DOMContentLoaded', scheduleMeasurement);\n\n const options = optionsRefs.current.filter(Boolean);\n\n // One observer of each kind covers every option: both accept repeated\n // `observe` calls, so a group of ten options costs two observers, not\n // twenty.\n const mutationObserver = new MutationObserver(scheduleMeasurement);\n const resizeObserver = new ResizeObserver(scheduleMeasurement);\n\n const handleMouseEnter = (event: Event) => {\n if (hideTimeoutRef.current) {\n clearTimeout(hideTimeoutRef.current);\n hideTimeoutRef.current = null;\n }\n\n hoveredItemRef.current = event.currentTarget as HTMLElement;\n scheduleMeasurement();\n };\n\n const handleMouseLeave = () => {\n if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);\n\n // Keep the indicator where it is for a moment, so moving between two\n // neighbouring options does not make it blink out and back in.\n hideTimeoutRef.current = setTimeout(() => {\n hoveredItemRef.current = null;\n scheduleMeasurement();\n }, HOVER_RELEASE_DELAY);\n };\n\n for (const option of options) {\n mutationObserver.observe(option, {\n attributes: true,\n attributeFilter: WATCHED_ATTRIBUTES,\n });\n resizeObserver.observe(option);\n\n if (isHoverable) {\n option.addEventListener('mouseenter', handleMouseEnter, {\n passive: true,\n });\n option.addEventListener('mouseleave', handleMouseLeave, {\n passive: true,\n });\n }\n }\n\n return () => {\n cancelMeasurementRef.current?.();\n cancelMeasurementRef.current = null;\n\n if (hideTimeoutRef.current) {\n clearTimeout(hideTimeoutRef.current);\n hideTimeoutRef.current = null;\n }\n\n window.removeEventListener('resize', scheduleMeasurement);\n window.removeEventListener('DOMContentLoaded', scheduleMeasurement);\n\n mutationObserver.disconnect();\n resizeObserver.disconnect();\n\n if (isHoverable) {\n for (const option of options) {\n option.removeEventListener('mouseenter', handleMouseEnter);\n option.removeEventListener('mouseleave', handleMouseLeave);\n }\n }\n };\n }, [optionsRefs, itemsLength, isHoverable, orientation, scheduleMeasurement]);\n\n return {\n choiceIndicatorPosition,\n /** Measures immediately. Prefer `scheduleMeasurement`, which does not force a reflow. */\n calculatePosition,\n scheduleMeasurement,\n orientation,\n };\n};\n"],"mappings":"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"}
@@ -1,7 +1,9 @@
1
1
  'use client';
2
2
 
3
+ import { scheduleFrameTask } from "../utils/scheduleFrameTask.mjs";
3
4
  import { useGetElementOrWindow } from "./useGetElementOrWindow.mjs";
4
- import { useCallback, useSyncExternalStore } from "react";
5
+ import { observeScrollExtent } from "../utils/observeScrollExtent.mjs";
6
+ import { useEffect, useState } from "react";
5
7
 
6
8
  //#region src/hooks/useScrollY.ts
7
9
  const INITIAL_SCROLL_STATE = {
@@ -9,59 +11,76 @@ const INITIAL_SCROLL_STATE = {
9
11
  scrollPercentage: 0,
10
12
  scrollYMax: 0
11
13
  };
12
- let lastSnapshot = INITIAL_SCROLL_STATE;
14
+ /**
15
+ * Tracks the vertical scroll of an element — or of the page — as a position and
16
+ * a 0-to-1 progress.
17
+ *
18
+ * @param props.element - Scrollable element. Defaults to the document.
19
+ * @returns The current offset, its maximum, and the ratio between the two.
20
+ *
21
+ * @example
22
+ * const { scrollPercentage } = useScrollY({ element: contentElement });
23
+ */
13
24
  const useScrollY = (props) => {
14
25
  const { element } = props ?? {};
15
26
  const containerElement = useGetElementOrWindow(element);
16
- const subscribe = useCallback((onChange) => {
17
- if (typeof window === "undefined" || !containerElement) return () => {};
18
- let raf = 0;
19
- const handler = () => {
20
- if (raf) return;
21
- raf = window.requestAnimationFrame(() => {
22
- raf = 0;
23
- onChange();
24
- });
25
- };
26
- containerElement.addEventListener("scroll", handler, { passive: true });
27
- return () => {
28
- containerElement.removeEventListener("scroll", handler);
29
- if (raf) window.cancelAnimationFrame(raf);
27
+ const [scrollState, setScrollState] = useState(INITIAL_SCROLL_STATE);
28
+ useEffect(() => {
29
+ if (typeof window === "undefined" || !containerElement) return;
30
+ const isElementScroll = containerElement instanceof HTMLElement;
31
+ /**
32
+ * Travel available to the scroll, cached between layout changes.
33
+ *
34
+ * `scrollHeight` and `clientHeight` both flush pending layout, so reading
35
+ * them on every scroll frame would recompute the whole page while the user
36
+ * scrolls. Only `scrollTop` is read per frame; the rest is refreshed when
37
+ * the content or the viewport actually resizes.
38
+ */
39
+ let scrollYMax = 0;
40
+ const measureScrollRange = () => {
41
+ const scrollHeight = isElementScroll ? containerElement.scrollHeight : document.documentElement?.scrollHeight ?? document.body?.scrollHeight ?? 0;
42
+ const clientHeight = isElementScroll ? containerElement.clientHeight : document.documentElement?.clientHeight ?? window.innerHeight ?? 0;
43
+ scrollYMax = Math.max(0, scrollHeight - clientHeight);
30
44
  };
31
- }, [containerElement]);
32
- const getSnapshot = () => {
33
- if (typeof window === "undefined" || !containerElement) return INITIAL_SCROLL_STATE;
34
- if (containerElement instanceof HTMLElement) {
35
- const scrollY = containerElement.scrollTop;
36
- const scrollHeight = containerElement.scrollHeight;
37
- const clientHeight = containerElement.clientHeight;
38
- const scrollYMax = Math.max(0, scrollHeight - clientHeight);
45
+ const updateScrollState = () => {
46
+ const scrollY = isElementScroll ? containerElement.scrollTop : window.scrollY ?? document.documentElement?.scrollTop ?? 0;
39
47
  const scrollPercentage = scrollYMax > 0 ? scrollY / scrollYMax : 0;
40
- if (lastSnapshot.scrollY === scrollY && lastSnapshot.scrollPercentage === scrollPercentage && lastSnapshot.scrollYMax === scrollYMax) return lastSnapshot;
41
- lastSnapshot = {
48
+ setScrollState((previousState) => previousState.scrollY === scrollY && previousState.scrollPercentage === scrollPercentage && previousState.scrollYMax === scrollYMax ? previousState : {
42
49
  scrollY,
43
50
  scrollPercentage,
44
51
  scrollYMax
45
- };
46
- return lastSnapshot;
47
- }
48
- const doc = document.documentElement;
49
- const body = document.body;
50
- const scrollY = window.scrollY ?? window.pageYOffset ?? doc?.scrollTop ?? body?.scrollTop ?? 0;
51
- const scrollHeight = doc?.scrollHeight ?? body?.scrollHeight ?? 0;
52
- const clientHeight = doc?.clientHeight ?? window.innerHeight ?? 0;
53
- const scrollYMax = Math.max(0, scrollHeight - clientHeight);
54
- const scrollPercentage = scrollYMax > 0 ? scrollY / scrollYMax : 0;
55
- if (lastSnapshot.scrollY === scrollY && lastSnapshot.scrollPercentage === scrollPercentage && lastSnapshot.scrollYMax === scrollYMax) return lastSnapshot;
56
- lastSnapshot = {
57
- scrollY,
58
- scrollPercentage,
59
- scrollYMax
52
+ });
53
+ };
54
+ let cancelScheduledRead = null;
55
+ const scheduleScrollRead = () => {
56
+ if (cancelScheduledRead) return;
57
+ cancelScheduledRead = scheduleFrameTask(() => {
58
+ cancelScheduledRead = null;
59
+ updateScrollState();
60
+ });
61
+ };
62
+ let cancelScheduledRangeRead = null;
63
+ const scheduleRangeRead = () => {
64
+ if (cancelScheduledRangeRead) return;
65
+ cancelScheduledRangeRead = scheduleFrameTask(() => {
66
+ cancelScheduledRangeRead = null;
67
+ measureScrollRange();
68
+ updateScrollState();
69
+ });
60
70
  };
61
- return lastSnapshot;
62
- };
63
- const getServerSnapshot = () => INITIAL_SCROLL_STATE;
64
- return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
71
+ scheduleRangeRead();
72
+ const stopObservingScrollExtent = observeScrollExtent(isElementScroll ? containerElement : document.documentElement, scheduleRangeRead);
73
+ containerElement.addEventListener("scroll", scheduleScrollRead, { passive: true });
74
+ window.addEventListener("resize", scheduleRangeRead, { passive: true });
75
+ return () => {
76
+ cancelScheduledRead?.();
77
+ cancelScheduledRangeRead?.();
78
+ stopObservingScrollExtent();
79
+ containerElement.removeEventListener("scroll", scheduleScrollRead);
80
+ window.removeEventListener("resize", scheduleRangeRead);
81
+ };
82
+ }, [containerElement]);
83
+ return scrollState;
65
84
  };
66
85
 
67
86
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"useScrollY.mjs","names":[],"sources":["../../../src/hooks/useScrollY.ts"],"sourcesContent":["'use client';\n\nimport { useCallback, useSyncExternalStore } from 'react';\nimport { useGetElementOrWindow } from './useGetElementOrWindow';\n\ntype UseScrollYProps = {\n element?: HTMLElement;\n};\n\ntype UseScrollYResult = {\n scrollY: number;\n scrollPercentage: number;\n scrollYMax: number;\n};\n\nconst INITIAL_SCROLL_STATE: UseScrollYResult = {\n scrollY: 0,\n scrollPercentage: 0,\n scrollYMax: 0,\n};\n\nlet lastSnapshot: UseScrollYResult = INITIAL_SCROLL_STATE;\n\nexport const useScrollY = (props?: UseScrollYProps): UseScrollYResult => {\n const { element } = props ?? {};\n\n const containerElement = useGetElementOrWindow(element);\n\n const subscribe = useCallback(\n (onChange: () => void) => {\n if (typeof window === 'undefined' || !containerElement) return () => {};\n let raf = 0;\n\n const handler = () => {\n if (raf) return;\n raf = window.requestAnimationFrame(() => {\n raf = 0;\n onChange();\n });\n };\n\n containerElement.addEventListener('scroll', handler, { passive: true });\n\n return () => {\n containerElement.removeEventListener('scroll', handler);\n if (raf) window.cancelAnimationFrame(raf);\n };\n },\n [containerElement]\n );\n\n const getSnapshot = (): UseScrollYResult => {\n if (typeof window === 'undefined' || !containerElement) {\n return INITIAL_SCROLL_STATE; // SSR/hydration-safe\n }\n\n // Handle custom element\n if (containerElement instanceof HTMLElement) {\n const scrollY = containerElement.scrollTop;\n const scrollHeight = containerElement.scrollHeight;\n const clientHeight = containerElement.clientHeight;\n const scrollYMax = Math.max(0, scrollHeight - clientHeight);\n const scrollPercentage = scrollYMax > 0 ? scrollY / scrollYMax : 0;\n\n if (\n lastSnapshot.scrollY === scrollY &&\n lastSnapshot.scrollPercentage === scrollPercentage &&\n lastSnapshot.scrollYMax === scrollYMax\n ) {\n return lastSnapshot;\n }\n\n lastSnapshot = { scrollY, scrollPercentage, scrollYMax };\n return lastSnapshot;\n }\n\n // Handle window\n const doc = document.documentElement;\n const body = document.body;\n\n const scrollY =\n window.scrollY ??\n window.pageYOffset ??\n doc?.scrollTop ??\n body?.scrollTop ??\n 0;\n\n const scrollHeight = doc?.scrollHeight ?? body?.scrollHeight ?? 0;\n const clientHeight = doc?.clientHeight ?? window.innerHeight ?? 0;\n const scrollYMax = Math.max(0, scrollHeight - clientHeight);\n const scrollPercentage = scrollYMax > 0 ? scrollY / scrollYMax : 0;\n\n if (\n lastSnapshot.scrollY === scrollY &&\n lastSnapshot.scrollPercentage === scrollPercentage &&\n lastSnapshot.scrollYMax === scrollYMax\n ) {\n return lastSnapshot;\n }\n\n lastSnapshot = { scrollY, scrollPercentage, scrollYMax };\n return lastSnapshot;\n };\n\n const getServerSnapshot = (): UseScrollYResult => INITIAL_SCROLL_STATE;\n\n return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);\n};\n"],"mappings":";;;;;;AAeA,MAAM,uBAAyC;CAC7C,SAAS;CACT,kBAAkB;CAClB,YAAY;AACd;AAEA,IAAI,eAAiC;AAErC,MAAa,cAAc,UAA8C;CACvE,MAAM,EAAE,YAAY,SAAS,CAAC;CAE9B,MAAM,mBAAmB,sBAAsB,OAAO;CAEtD,MAAM,YAAY,aACf,aAAyB;EACxB,IAAI,OAAO,WAAW,eAAe,CAAC,kBAAkB,aAAa,CAAC;EACtE,IAAI,MAAM;EAEV,MAAM,gBAAgB;GACpB,IAAI,KAAK;GACT,MAAM,OAAO,4BAA4B;IACvC,MAAM;IACN,SAAS;GACX,CAAC;EACH;EAEA,iBAAiB,iBAAiB,UAAU,SAAS,EAAE,SAAS,KAAK,CAAC;EAEtE,aAAa;GACX,iBAAiB,oBAAoB,UAAU,OAAO;GACtD,IAAI,KAAK,OAAO,qBAAqB,GAAG;EAC1C;CACF,GACA,CAAC,gBAAgB,CACnB;CAEA,MAAM,oBAAsC;EAC1C,IAAI,OAAO,WAAW,eAAe,CAAC,kBACpC,OAAO;EAIT,IAAI,4BAA4B,aAAa;GAC3C,MAAM,UAAU,iBAAiB;GACjC,MAAM,eAAe,iBAAiB;GACtC,MAAM,eAAe,iBAAiB;GACtC,MAAM,aAAa,KAAK,IAAI,GAAG,eAAe,YAAY;GAC1D,MAAM,mBAAmB,aAAa,IAAI,UAAU,aAAa;GAEjE,IACE,aAAa,YAAY,WACzB,aAAa,qBAAqB,oBAClC,aAAa,eAAe,YAE5B,OAAO;GAGT,eAAe;IAAE;IAAS;IAAkB;GAAW;GACvD,OAAO;EACT;EAGA,MAAM,MAAM,SAAS;EACrB,MAAM,OAAO,SAAS;EAEtB,MAAM,UACJ,OAAO,WACP,OAAO,eACP,KAAK,aACL,MAAM,aACN;EAEF,MAAM,eAAe,KAAK,gBAAgB,MAAM,gBAAgB;EAChE,MAAM,eAAe,KAAK,gBAAgB,OAAO,eAAe;EAChE,MAAM,aAAa,KAAK,IAAI,GAAG,eAAe,YAAY;EAC1D,MAAM,mBAAmB,aAAa,IAAI,UAAU,aAAa;EAEjE,IACE,aAAa,YAAY,WACzB,aAAa,qBAAqB,oBAClC,aAAa,eAAe,YAE5B,OAAO;EAGT,eAAe;GAAE;GAAS;GAAkB;EAAW;EACvD,OAAO;CACT;CAEA,MAAM,0BAA4C;CAElD,OAAO,qBAAqB,WAAW,aAAa,iBAAiB;AACvE"}
1
+ {"version":3,"file":"useScrollY.mjs","names":[],"sources":["../../../src/hooks/useScrollY.ts"],"sourcesContent":["'use client';\n\nimport { observeScrollExtent } from '@utils/observeScrollExtent';\nimport { scheduleFrameTask } from '@utils/scheduleFrameTask';\nimport { useEffect, useState } from 'react';\nimport { useGetElementOrWindow } from './useGetElementOrWindow';\n\ntype UseScrollYProps = {\n element?: HTMLElement;\n};\n\ntype UseScrollYResult = {\n scrollY: number;\n scrollPercentage: number;\n scrollYMax: number;\n};\n\nconst INITIAL_SCROLL_STATE: UseScrollYResult = {\n scrollY: 0,\n scrollPercentage: 0,\n scrollYMax: 0,\n};\n\n/**\n * Tracks the vertical scroll of an element — or of the page — as a position and\n * a 0-to-1 progress.\n *\n * @param props.element - Scrollable element. Defaults to the document.\n * @returns The current offset, its maximum, and the ratio between the two.\n *\n * @example\n * const { scrollPercentage } = useScrollY({ element: contentElement });\n */\nexport const useScrollY = (props?: UseScrollYProps): UseScrollYResult => {\n const { element } = props ?? {};\n const containerElement = useGetElementOrWindow(element);\n const [scrollState, setScrollState] =\n useState<UseScrollYResult>(INITIAL_SCROLL_STATE);\n\n useEffect(() => {\n if (typeof window === 'undefined' || !containerElement) return;\n\n const isElementScroll = containerElement instanceof HTMLElement;\n\n /**\n * Travel available to the scroll, cached between layout changes.\n *\n * `scrollHeight` and `clientHeight` both flush pending layout, so reading\n * them on every scroll frame would recompute the whole page while the user\n * scrolls. Only `scrollTop` is read per frame; the rest is refreshed when\n * the content or the viewport actually resizes.\n */\n let scrollYMax = 0;\n\n const measureScrollRange = () => {\n const scrollHeight = isElementScroll\n ? containerElement.scrollHeight\n : (document.documentElement?.scrollHeight ??\n document.body?.scrollHeight ??\n 0);\n\n const clientHeight = isElementScroll\n ? containerElement.clientHeight\n : (document.documentElement?.clientHeight ?? window.innerHeight ?? 0);\n\n scrollYMax = Math.max(0, scrollHeight - clientHeight);\n };\n\n const updateScrollState = () => {\n const scrollY = isElementScroll\n ? containerElement.scrollTop\n : (window.scrollY ?? document.documentElement?.scrollTop ?? 0);\n\n const scrollPercentage = scrollYMax > 0 ? scrollY / scrollYMax : 0;\n\n setScrollState((previousState) =>\n previousState.scrollY === scrollY &&\n previousState.scrollPercentage === scrollPercentage &&\n previousState.scrollYMax === scrollYMax\n ? previousState\n : { scrollY, scrollPercentage, scrollYMax }\n );\n };\n\n let cancelScheduledRead: (() => void) | null = null;\n\n const scheduleScrollRead = () => {\n if (cancelScheduledRead) return;\n\n cancelScheduledRead = scheduleFrameTask(() => {\n cancelScheduledRead = null;\n updateScrollState();\n });\n };\n\n let cancelScheduledRangeRead: (() => void) | null = null;\n\n const scheduleRangeRead = () => {\n if (cancelScheduledRangeRead) return;\n\n cancelScheduledRangeRead = scheduleFrameTask(() => {\n cancelScheduledRangeRead = null;\n measureScrollRange();\n updateScrollState();\n });\n };\n\n // Initial measurement, deferred so that it shares the frame of every other\n // measurement the page schedules while mounting.\n scheduleRangeRead();\n\n const stopObservingScrollExtent = observeScrollExtent(\n isElementScroll ? containerElement : document.documentElement,\n scheduleRangeRead\n );\n\n containerElement.addEventListener('scroll', scheduleScrollRead, {\n passive: true,\n });\n window.addEventListener('resize', scheduleRangeRead, { passive: true });\n\n return () => {\n cancelScheduledRead?.();\n cancelScheduledRangeRead?.();\n stopObservingScrollExtent();\n containerElement.removeEventListener('scroll', scheduleScrollRead);\n window.removeEventListener('resize', scheduleRangeRead);\n };\n }, [containerElement]);\n\n return scrollState;\n};\n"],"mappings":";;;;;;;;AAiBA,MAAM,uBAAyC;CAC7C,SAAS;CACT,kBAAkB;CAClB,YAAY;AACd;;;;;;;;;;;AAYA,MAAa,cAAc,UAA8C;CACvE,MAAM,EAAE,YAAY,SAAS,CAAC;CAC9B,MAAM,mBAAmB,sBAAsB,OAAO;CACtD,MAAM,CAAC,aAAa,kBAClB,SAA2B,oBAAoB;CAEjD,gBAAgB;EACd,IAAI,OAAO,WAAW,eAAe,CAAC,kBAAkB;EAExD,MAAM,kBAAkB,4BAA4B;;;;;;;;;EAUpD,IAAI,aAAa;EAEjB,MAAM,2BAA2B;GAC/B,MAAM,eAAe,kBACjB,iBAAiB,eAChB,SAAS,iBAAiB,gBAC3B,SAAS,MAAM,gBACf;GAEJ,MAAM,eAAe,kBACjB,iBAAiB,eAChB,SAAS,iBAAiB,gBAAgB,OAAO,eAAe;GAErE,aAAa,KAAK,IAAI,GAAG,eAAe,YAAY;EACtD;EAEA,MAAM,0BAA0B;GAC9B,MAAM,UAAU,kBACZ,iBAAiB,YAChB,OAAO,WAAW,SAAS,iBAAiB,aAAa;GAE9D,MAAM,mBAAmB,aAAa,IAAI,UAAU,aAAa;GAEjE,gBAAgB,kBACd,cAAc,YAAY,WAC1B,cAAc,qBAAqB,oBACnC,cAAc,eAAe,aACzB,gBACA;IAAE;IAAS;IAAkB;GAAW,CAC9C;EACF;EAEA,IAAI,sBAA2C;EAE/C,MAAM,2BAA2B;GAC/B,IAAI,qBAAqB;GAEzB,sBAAsB,wBAAwB;IAC5C,sBAAsB;IACtB,kBAAkB;GACpB,CAAC;EACH;EAEA,IAAI,2BAAgD;EAEpD,MAAM,0BAA0B;GAC9B,IAAI,0BAA0B;GAE9B,2BAA2B,wBAAwB;IACjD,2BAA2B;IAC3B,mBAAmB;IACnB,kBAAkB;GACpB,CAAC;EACH;EAIA,kBAAkB;EAElB,MAAM,4BAA4B,oBAChC,kBAAkB,mBAAmB,SAAS,iBAC9C,iBACF;EAEA,iBAAiB,iBAAiB,UAAU,oBAAoB,EAC9D,SAAS,KACX,CAAC;EACD,OAAO,iBAAiB,UAAU,mBAAmB,EAAE,SAAS,KAAK,CAAC;EAEtE,aAAa;GACX,sBAAsB;GACtB,2BAA2B;GAC3B,0BAA0B;GAC1B,iBAAiB,oBAAoB,UAAU,kBAAkB;GACjE,OAAO,oBAAoB,UAAU,iBAAiB;EACxD;CACF,GAAG,CAAC,gBAAgB,CAAC;CAErB,OAAO;AACT"}
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef } from "react";
4
+
5
+ //#region src/hooks/useStickToBottom.ts
6
+ /**
7
+ * Distance from the bottom, in pixels, under which the list still counts as
8
+ * pinned. Sub-pixel scroll positions and zoom levels never land exactly on the
9
+ * bottom, so an exact comparison would unpin a list the user never moved.
10
+ */
11
+ const PIN_THRESHOLD = 32;
12
+ /**
13
+ * Keeps a scrolling container pinned to its bottom edge as its content grows,
14
+ * and releases the pin as soon as the user scrolls away from the bottom.
15
+ *
16
+ * Growth is detected with a `ResizeObserver` rather than from a render effect,
17
+ * so a message streaming in token by token follows without the component
18
+ * having to describe what changed. Both the read and the write happen inside
19
+ * the observer callback, which the browser runs once layout is already
20
+ * computed — neither forces a reflow, unlike measuring the same container from
21
+ * an effect that has just committed new nodes.
22
+ *
23
+ * @returns The refs to attach to the container and to its content wrapper.
24
+ *
25
+ * @example
26
+ * const { scrollRef, contentRef } = useStickToBottom<HTMLDivElement, HTMLUListElement>();
27
+ *
28
+ * return (
29
+ * <div ref={scrollRef} className="overflow-auto">
30
+ * <ul ref={contentRef}>{items}</ul>
31
+ * </div>
32
+ * );
33
+ */
34
+ const useStickToBottom = () => {
35
+ const scrollRef = useRef(null);
36
+ const contentRef = useRef(null);
37
+ const isPinnedRef = useRef(true);
38
+ useEffect(() => {
39
+ const scrollElement = scrollRef.current;
40
+ const contentElement = contentRef.current;
41
+ if (!scrollElement || !contentElement) return;
42
+ const getDistanceFromBottom = () => scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight;
43
+ const handleScroll = () => {
44
+ isPinnedRef.current = getDistanceFromBottom() <= PIN_THRESHOLD;
45
+ };
46
+ const resizeObserver = new ResizeObserver(() => {
47
+ if (!isPinnedRef.current) return;
48
+ if (getDistanceFromBottom() <= 0) return;
49
+ scrollElement.scrollTop = scrollElement.scrollHeight - scrollElement.clientHeight;
50
+ });
51
+ resizeObserver.observe(contentElement);
52
+ scrollElement.addEventListener("scroll", handleScroll, { passive: true });
53
+ return () => {
54
+ resizeObserver.disconnect();
55
+ scrollElement.removeEventListener("scroll", handleScroll);
56
+ };
57
+ }, []);
58
+ return {
59
+ scrollRef,
60
+ contentRef,
61
+ unpin: useCallback(() => {
62
+ isPinnedRef.current = false;
63
+ }, [])
64
+ };
65
+ };
66
+
67
+ //#endregion
68
+ export { useStickToBottom };
69
+ //# sourceMappingURL=useStickToBottom.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useStickToBottom.mjs","names":[],"sources":["../../../src/hooks/useStickToBottom.ts"],"sourcesContent":["'use client';\n\nimport { type RefObject, useCallback, useEffect, useRef } from 'react';\n\n/**\n * Distance from the bottom, in pixels, under which the list still counts as\n * pinned. Sub-pixel scroll positions and zoom levels never land exactly on the\n * bottom, so an exact comparison would unpin a list the user never moved.\n */\nconst PIN_THRESHOLD = 32;\n\nexport type StickToBottomRefs<\n ScrollElement extends HTMLElement,\n ContentElement extends HTMLElement,\n> = {\n /** Attach to the scrolling container. */\n scrollRef: RefObject<ScrollElement | null>;\n /** Attach to the element wrapping the items, whose growth triggers the follow. */\n contentRef: RefObject<ContentElement | null>;\n /**\n * Releases the pin until the user scrolls back down to the bottom.\n *\n * Call it before growing the content upwards — prepending older items to a\n * pinned list would otherwise scroll straight past what was just revealed.\n */\n unpin: () => void;\n};\n\n/**\n * Keeps a scrolling container pinned to its bottom edge as its content grows,\n * and releases the pin as soon as the user scrolls away from the bottom.\n *\n * Growth is detected with a `ResizeObserver` rather than from a render effect,\n * so a message streaming in token by token follows without the component\n * having to describe what changed. Both the read and the write happen inside\n * the observer callback, which the browser runs once layout is already\n * computed — neither forces a reflow, unlike measuring the same container from\n * an effect that has just committed new nodes.\n *\n * @returns The refs to attach to the container and to its content wrapper.\n *\n * @example\n * const { scrollRef, contentRef } = useStickToBottom<HTMLDivElement, HTMLUListElement>();\n *\n * return (\n * <div ref={scrollRef} className=\"overflow-auto\">\n * <ul ref={contentRef}>{items}</ul>\n * </div>\n * );\n */\nexport const useStickToBottom = <\n ScrollElement extends HTMLElement,\n ContentElement extends HTMLElement,\n>(): StickToBottomRefs<ScrollElement, ContentElement> => {\n const scrollRef = useRef<ScrollElement>(null);\n const contentRef = useRef<ContentElement>(null);\n const isPinnedRef = useRef(true);\n\n useEffect(() => {\n const scrollElement = scrollRef.current;\n const contentElement = contentRef.current;\n\n if (!scrollElement || !contentElement) return;\n\n const getDistanceFromBottom = () =>\n scrollElement.scrollHeight -\n scrollElement.scrollTop -\n scrollElement.clientHeight;\n\n /*\n * Scroll events are dispatched after the browser has laid the page out, so\n * reading the geometry here is free — deferring it to an animation frame\n * would move the read *before* the next layout and force one instead.\n */\n const handleScroll = () => {\n isPinnedRef.current = getDistanceFromBottom() <= PIN_THRESHOLD;\n };\n\n const resizeObserver = new ResizeObserver(() => {\n if (!isPinnedRef.current) return;\n if (getDistanceFromBottom() <= 0) return;\n\n // Jumping rather than animating: a streamed answer resizes on nearly\n // every frame, and a smooth scroll restarted that often never reaches\n // the bottom.\n scrollElement.scrollTop =\n scrollElement.scrollHeight - scrollElement.clientHeight;\n });\n\n resizeObserver.observe(contentElement);\n scrollElement.addEventListener('scroll', handleScroll, { passive: true });\n\n return () => {\n resizeObserver.disconnect();\n scrollElement.removeEventListener('scroll', handleScroll);\n };\n }, []);\n\n const unpin = useCallback(() => {\n isPinnedRef.current = false;\n }, []);\n\n return { scrollRef, contentRef, unpin };\n};\n"],"mappings":";;;;;;;;;;AASA,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;AAyCtB,MAAa,yBAG4C;CACvD,MAAM,YAAY,OAAsB,IAAI;CAC5C,MAAM,aAAa,OAAuB,IAAI;CAC9C,MAAM,cAAc,OAAO,IAAI;CAE/B,gBAAgB;EACd,MAAM,gBAAgB,UAAU;EAChC,MAAM,iBAAiB,WAAW;EAElC,IAAI,CAAC,iBAAiB,CAAC,gBAAgB;EAEvC,MAAM,8BACJ,cAAc,eACd,cAAc,YACd,cAAc;EAOhB,MAAM,qBAAqB;GACzB,YAAY,UAAU,sBAAsB,KAAK;EACnD;EAEA,MAAM,iBAAiB,IAAI,qBAAqB;GAC9C,IAAI,CAAC,YAAY,SAAS;GAC1B,IAAI,sBAAsB,KAAK,GAAG;GAKlC,cAAc,YACZ,cAAc,eAAe,cAAc;EAC/C,CAAC;EAED,eAAe,QAAQ,cAAc;EACrC,cAAc,iBAAiB,UAAU,cAAc,EAAE,SAAS,KAAK,CAAC;EAExE,aAAa;GACX,eAAe,WAAW;GAC1B,cAAc,oBAAoB,UAAU,YAAY;EAC1D;CACF,GAAG,CAAC,CAAC;CAML,OAAO;EAAE;EAAW;EAAY,OAJlB,kBAAkB;GAC9B,YAAY,UAAU;EACxB,GAAG,CAAC,CAEgC;CAAE;AACxC"}