From 3cdec0b6ddbad2e52b66d003db5b095a45a87810 Mon Sep 17 00:00:00 2001 From: Rachael Thomas Date: Thu, 9 Apr 2026 18:28:44 -0600 Subject: [PATCH 1/5] Update Accordion component to add optional anchorID and add to MS Teams channel data accordion --- components/ui/Accordion.tsx | 30 +++++++++++++++++-- .../chat/microsoft-teams/overview.mdx | 2 +- .../setting-channel-data.mdx | 2 +- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/components/ui/Accordion.tsx b/components/ui/Accordion.tsx index 375ebb9bc..b80480583 100644 --- a/components/ui/Accordion.tsx +++ b/components/ui/Accordion.tsx @@ -2,7 +2,7 @@ import { Box, Stack } from "@telegraph/layout"; import { MenuItem } from "@telegraph/menu"; import { Icon } from "@telegraph/icon"; import { AnimatePresence, motion } from "framer-motion"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useLayoutEffect } from "react"; import { Text, Code } from "@telegraph/typography"; import { ChevronRight } from "lucide-react"; @@ -15,11 +15,24 @@ const AccordionGroup = ({ children }) => ( ); +function getHashFragment(): string { + if (typeof window === "undefined") return ""; + const { hash } = window.location; + if (!hash || hash === "#") return ""; + try { + return decodeURIComponent(hash.slice(1)); + } catch { + return hash.slice(1); + } +} + type AccordionProps = { children: React.ReactNode; title: string; description?: string; defaultOpen?: boolean; + /** When set, this value is used as the element `id` and the accordion opens if the URL hash matches (for deep links). */ + anchorId?: string; }; // Helper function to parse title and split into text and code parts @@ -68,12 +81,25 @@ const Accordion = ({ title, description, defaultOpen = false, + anchorId, }: AccordionProps) => { const [open, setOpen] = useState(defaultOpen); const titleParts = useMemo(() => parseTitleWithCode(title), [title]); + useLayoutEffect(() => { + if (!anchorId) return; + const syncFromHash = () => { + if (getHashFragment() === anchorId) { + setOpen(true); + } + }; + syncFromHash(); + window.addEventListener("hashchange", syncFromHash); + return () => window.removeEventListener("hashchange", syncFromHash); + }, [anchorId]); + return ( - + setOpen(!open)} diff --git a/content/integrations/chat/microsoft-teams/overview.mdx b/content/integrations/chat/microsoft-teams/overview.mdx index 1ad824271..5023ee6a8 100644 --- a/content/integrations/chat/microsoft-teams/overview.mdx +++ b/content/integrations/chat/microsoft-teams/overview.mdx @@ -163,7 +163,7 @@ To use TeamsKit, you'll need to configure the API permissions and OAuth redirect ## How to set channel data for a Microsoft Teams integration in Knock -In Knock, the [`ChannelData`](/managing-recipients/setting-channel-data) concept provides you a way of storing recipient-specific connection data for a given integration. If you reference the [channel data requirements for Microsoft Teams](/managing-recipients/setting-channel-data#chat-app-channels), you'll see that there are two different schemas for an `MsTeamsConnection` stored on a [`User`](/concepts/users) or an [`Object`](/concepts/objects) in Knock. +In Knock, the [`ChannelData`](/managing-recipients/setting-channel-data) concept provides you a way of storing recipient-specific connection data for a given integration. If you reference the [channel data requirements for Microsoft Teams](/managing-recipients/setting-channel-data#microsoft-teams-channel-data), you'll see that there are two different schemas for an `MsTeamsConnection` stored on a [`User`](/concepts/users) or an [`Object`](/concepts/objects) in Knock. Here's an example of setting channel data on an `Object` in Knock. diff --git a/content/managing-recipients/setting-channel-data.mdx b/content/managing-recipients/setting-channel-data.mdx index a2d596561..0eab7917e 100644 --- a/content/managing-recipients/setting-channel-data.mdx +++ b/content/managing-recipients/setting-channel-data.mdx @@ -241,7 +241,7 @@ The `PushDevice` object is used to optionally set device-level metadata for a pu | incoming_webhook.url | `string` | The Discord incoming webhook URL (to be used instead of the properties above) | - + | Property | Type | Description | | ----------- | --------------------- | ---------------------------------- | | connections | `MsTeamsConnection[]` | One or more connections to MsTeams | From 90d5431344d1ca6aa65549b50f4dc449574cf47d Mon Sep 17 00:00:00 2001 From: Rachael Thomas Date: Wed, 22 Apr 2026 12:02:48 -0600 Subject: [PATCH 2/5] Update anchorId to anchorSlug --- components/ui/Accordion.tsx | 14 +++++++------- .../managing-recipients/setting-channel-data.mdx | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/components/ui/Accordion.tsx b/components/ui/Accordion.tsx index b80480583..688b46a81 100644 --- a/components/ui/Accordion.tsx +++ b/components/ui/Accordion.tsx @@ -31,8 +31,8 @@ type AccordionProps = { title: string; description?: string; defaultOpen?: boolean; - /** When set, this value is used as the element `id` and the accordion opens if the URL hash matches (for deep links). */ - anchorId?: string; + /** When set, this slug is used as the element `id` and the accordion opens if the URL hash matches (for deep links). Use a URL-safe hyphenated fragment, e.g. `my-section`. */ + anchorSlug?: string; }; // Helper function to parse title and split into text and code parts @@ -81,25 +81,25 @@ const Accordion = ({ title, description, defaultOpen = false, - anchorId, + anchorSlug, }: AccordionProps) => { const [open, setOpen] = useState(defaultOpen); const titleParts = useMemo(() => parseTitleWithCode(title), [title]); useLayoutEffect(() => { - if (!anchorId) return; + if (!anchorSlug) return; const syncFromHash = () => { - if (getHashFragment() === anchorId) { + if (getHashFragment() === anchorSlug) { setOpen(true); } }; syncFromHash(); window.addEventListener("hashchange", syncFromHash); return () => window.removeEventListener("hashchange", syncFromHash); - }, [anchorId]); + }, [anchorSlug]); return ( - + setOpen(!open)} diff --git a/content/managing-recipients/setting-channel-data.mdx b/content/managing-recipients/setting-channel-data.mdx index 0eab7917e..231c47823 100644 --- a/content/managing-recipients/setting-channel-data.mdx +++ b/content/managing-recipients/setting-channel-data.mdx @@ -241,7 +241,7 @@ The `PushDevice` object is used to optionally set device-level metadata for a pu | incoming_webhook.url | `string` | The Discord incoming webhook URL (to be used instead of the properties above) | - + | Property | Type | Description | | ----------- | --------------------- | ---------------------------------- | | connections | `MsTeamsConnection[]` | One or more connections to MsTeams | From 22cda39576e93ed4b796ddf3bac393216ebcb774 Mon Sep 17 00:00:00 2001 From: Rachael Thomas Date: Wed, 22 Apr 2026 12:09:34 -0600 Subject: [PATCH 3/5] Add anchorSlug to tenant-specific-preference-merge-hierarchy accordion --- content/multi-tenancy/per-tenant-preferences.mdx | 4 ++-- content/preferences/overview.mdx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/content/multi-tenancy/per-tenant-preferences.mdx b/content/multi-tenancy/per-tenant-preferences.mdx index cf037ffa9..81218a9bb 100644 --- a/content/multi-tenancy/per-tenant-preferences.mdx +++ b/content/multi-tenancy/per-tenant-preferences.mdx @@ -170,11 +170,11 @@ The Knock workflow engine uses that `tenant` parameter to evaluate the user's `P ## Tenant preference evaluation rules -Here are a few things to keep in mind when using tenant preferences. You can learn more about how preferences are merged and evaluated [here](/preferences/overview#merging-preferences). +Here are a few things to keep in mind when using tenant preferences. You can learn more about how preferences are merged and evaluated [here](/preferences/overview#tenant-specific-preference-merge-hierarchy). - When executing a workflow trigger, passing in a `tenant` will automatically load that tenant's default `PreferenceSet` (if one exists) for all recipients of the workflow. These tenant-level defaults will override a recipient's own `default` preferences. - If the recipient has any per-tenant preferences set for that `tenant.id`, they will take precedence over the tenant-level default preferences. For more information on how to override a recipient's per-tenant preferences to respect the tenant-level default preferences, see the [frequently asked questions](#frequently-asked-questions) below. -- If there is no default `PreferenceSet` on the tenant AND the recipient has no per-tenant preferences set, the recipient's `default` preferences will be used. As always, the recipient's `default` preferences are [merged](/preferences/overview#merging-preferences) with the environment-level preference defaults. +- If there is no default `PreferenceSet` on the tenant AND the recipient has no per-tenant preferences set, the recipient's `default` preferences will be used. As always, the recipient's `default` preferences are [merged](/preferences/overview#tenant-specific-preference-merge-hierarchy) with the environment-level preference defaults. ## Frequently asked questions diff --git a/content/preferences/overview.mdx b/content/preferences/overview.mdx index b8fb80417..0bb20fdab 100644 --- a/content/preferences/overview.mdx +++ b/content/preferences/overview.mdx @@ -281,7 +281,7 @@ The following hierarchies are used when merging preferences. The environment-level setting to opt all users into `collaboration` notifications will be respected because the recipient doesn't have an explicit preference for that category, but the recipient's preference to specifically opt in to SMS messages will override the environment-level setting to opt out. - + The following hierarchy is used to merge preferences when a `tenant` is applied to the workflow trigger. Each item in the list takes precedence over the ones that follow it: 1. A recipient's [tenant-specific preference](/multi-tenancy/per-tenant-preferences) set From 55e2afae74664c6bce0752bede100aadeac0de8d Mon Sep 17 00:00:00 2001 From: Jeff Everhart Date: Wed, 29 Apr 2026 10:25:42 -0400 Subject: [PATCH 4/5] add behavior for accordion scroll --- components/ui/Accordion.tsx | 79 +++++++++++++++++++++++++++++++++---- next-env.d.ts | 2 +- styles/index.css | 4 +- 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/components/ui/Accordion.tsx b/components/ui/Accordion.tsx index 688b46a81..9e5bc457a 100644 --- a/components/ui/Accordion.tsx +++ b/components/ui/Accordion.tsx @@ -2,7 +2,7 @@ import { Box, Stack } from "@telegraph/layout"; import { MenuItem } from "@telegraph/menu"; import { Icon } from "@telegraph/icon"; import { AnimatePresence, motion } from "framer-motion"; -import { useState, useMemo, useLayoutEffect } from "react"; +import { useState, useMemo, useLayoutEffect, useRef } from "react"; import { Text, Code } from "@telegraph/typography"; import { ChevronRight } from "lucide-react"; @@ -85,21 +85,84 @@ const Accordion = ({ }: AccordionProps) => { const [open, setOpen] = useState(defaultOpen); const titleParts = useMemo(() => parseTitleWithCode(title), [title]); + const elementRef = useRef(null); useLayoutEffect(() => { if (!anchorSlug) return; - const syncFromHash = () => { - if (getHashFragment() === anchorSlug) { - setOpen(true); + + let cancelled = false; + let resizeObserver: ResizeObserver | null = null; + let stopWatchingTimeoutId: number | null = null; + + const log = (label: string, extra?: Record) => { + // eslint-disable-next-line no-console + console.log(`[Accordion:${anchorSlug}] ${label}`, { + time: performance.now().toFixed(1), + scrollY: window.scrollY, + elementTop: elementRef.current?.getBoundingClientRect().top, + bodyHeight: document.body.scrollHeight, + ...extra, + }); + }; + + const performScroll = (source: string) => { + if (cancelled) return; + const el = elementRef.current; + if (!el) return; + el.scrollIntoView({ + block: "start", + behavior: "instant" as ScrollBehavior, + }); + log(`scroll:${source}`); + }; + + const stopWatching = () => { + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = null; } + if (stopWatchingTimeoutId !== null) { + clearTimeout(stopWatchingTimeoutId); + stopWatchingTimeoutId = null; + } + }; + + const syncFromHash = (source: string) => { + log(`syncFromHash:${source}`); + if (getHashFragment() !== anchorSlug) return; + setOpen(true); + + // Scroll immediately so the user goes directly from the previous page to + // the correct position. This runs in useLayoutEffect, before paint. + performScroll("immediate"); + + // Re-scroll whenever layout shifts (images loading, async content, etc.) + // so the accordion stays anchored to its intended position even as the + // document height changes. + stopWatching(); + if (typeof ResizeObserver !== "undefined") { + resizeObserver = new ResizeObserver(() => { + performScroll("resize"); + }); + resizeObserver.observe(document.body); + } + // Stop correcting after layout has had time to settle so we don't + // fight subsequent user-initiated scrolls. + stopWatchingTimeoutId = window.setTimeout(stopWatching, 1500); + }; + + syncFromHash("mount"); + const handler = () => syncFromHash("hashchange"); + window.addEventListener("hashchange", handler); + return () => { + cancelled = true; + stopWatching(); + window.removeEventListener("hashchange", handler); }; - syncFromHash(); - window.addEventListener("hashchange", syncFromHash); - return () => window.removeEventListener("hashchange", syncFromHash); }, [anchorSlug]); return ( - + setOpen(!open)} diff --git a/next-env.d.ts b/next-env.d.ts index 2d5420eba..0c7fad710 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/styles/index.css b/styles/index.css index 197680698..d300cafa3 100644 --- a/styles/index.css +++ b/styles/index.css @@ -102,9 +102,9 @@ textarea:not([rows]) { min-height: 10em; } -/* Anything that has been anchored to should have extra scroll margin */ +/* Anything anchored to via #hash should clear the sticky page header */ :target { - scroll-margin-block: 5ex; + scroll-margin-top: calc(var(--header-height) + 1rem); } .dot-bg { From cd8d4ba5207a022225b9f2f85b1465ed90fe9904 Mon Sep 17 00:00:00 2001 From: Jeff Everhart Date: Wed, 29 Apr 2026 10:49:54 -0400 Subject: [PATCH 5/5] make scroll smooth for accordion hash --- components/ui/Accordion.tsx | 37 +++++++++---------------------------- 1 file changed, 9 insertions(+), 28 deletions(-) diff --git a/components/ui/Accordion.tsx b/components/ui/Accordion.tsx index 9e5bc457a..7cb1fdabc 100644 --- a/components/ui/Accordion.tsx +++ b/components/ui/Accordion.tsx @@ -94,26 +94,12 @@ const Accordion = ({ let resizeObserver: ResizeObserver | null = null; let stopWatchingTimeoutId: number | null = null; - const log = (label: string, extra?: Record) => { - // eslint-disable-next-line no-console - console.log(`[Accordion:${anchorSlug}] ${label}`, { - time: performance.now().toFixed(1), - scrollY: window.scrollY, - elementTop: elementRef.current?.getBoundingClientRect().top, - bodyHeight: document.body.scrollHeight, - ...extra, - }); - }; - - const performScroll = (source: string) => { + const performScroll = () => { if (cancelled) return; - const el = elementRef.current; - if (!el) return; - el.scrollIntoView({ + elementRef.current?.scrollIntoView({ block: "start", - behavior: "instant" as ScrollBehavior, + behavior: "smooth", }); - log(`scroll:${source}`); }; const stopWatching = () => { @@ -127,14 +113,10 @@ const Accordion = ({ } }; - const syncFromHash = (source: string) => { - log(`syncFromHash:${source}`); + const syncFromHash = () => { if (getHashFragment() !== anchorSlug) return; setOpen(true); - - // Scroll immediately so the user goes directly from the previous page to - // the correct position. This runs in useLayoutEffect, before paint. - performScroll("immediate"); + performScroll(); // Re-scroll whenever layout shifts (images loading, async content, etc.) // so the accordion stays anchored to its intended position even as the @@ -142,7 +124,7 @@ const Accordion = ({ stopWatching(); if (typeof ResizeObserver !== "undefined") { resizeObserver = new ResizeObserver(() => { - performScroll("resize"); + performScroll(); }); resizeObserver.observe(document.body); } @@ -151,13 +133,12 @@ const Accordion = ({ stopWatchingTimeoutId = window.setTimeout(stopWatching, 1500); }; - syncFromHash("mount"); - const handler = () => syncFromHash("hashchange"); - window.addEventListener("hashchange", handler); + syncFromHash(); + window.addEventListener("hashchange", syncFromHash); return () => { cancelled = true; stopWatching(); - window.removeEventListener("hashchange", handler); + window.removeEventListener("hashchange", syncFromHash); }; }, [anchorSlug]);