diff --git a/apps/marketing/input.css b/apps/marketing/input.css index 269c45076..41178f1d4 100644 --- a/apps/marketing/input.css +++ b/apps/marketing/input.css @@ -21,7 +21,8 @@ body { .bg-grid-pattern { background-size: 50px 50px; - background-image: linear-gradient(to right, rgba(100, 116, 139, 0.05) 1px, transparent 1px), + background-image: + linear-gradient(to right, rgba(100, 116, 139, 0.05) 1px, transparent 1px), linear-gradient(to bottom, rgba(100, 116, 139, 0.05) 1px, transparent 1px); } diff --git a/apps/marketing/src/app/global.css b/apps/marketing/src/app/global.css index d9d7d96aa..851553b03 100644 --- a/apps/marketing/src/app/global.css +++ b/apps/marketing/src/app/global.css @@ -107,7 +107,7 @@ } /* -! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com +! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com */ /* @@ -626,26 +626,10 @@ video { position: sticky; } -.-right-2 { - right: -0.5rem; -} - .-right-32 { right: -8rem; } -.-top-2 { - top: -0.5rem; -} - -.-top-4 { - top: -1rem; -} - -.left-1\/2 { - left: 50%; -} - .left-4 { left: 1rem; } @@ -706,11 +690,6 @@ video { margin-bottom: 1.5rem; } -.my-8 { - margin-top: 2rem; - margin-bottom: 2rem; -} - .mb-0 { margin-bottom: 0px; } @@ -949,8 +928,12 @@ video { max-height: 64vh; } -.min-h-\[20px\] { - min-height: 20px; +.min-h-0 { + min-height: 0px; +} + +.min-h-\[4\.5rem\] { + min-height: 4.5rem; } .min-h-\[60vh\] { @@ -1026,6 +1009,10 @@ video { width: 100%; } +.min-w-0 { + min-width: 0px; +} + .min-w-\[120px\] { min-width: 120px; } @@ -1050,6 +1037,10 @@ video { max-width: 64rem; } +.max-w-6xl { + max-width: 72rem; +} + .max-w-7xl { max-width: 80rem; } @@ -1094,11 +1085,6 @@ video { border-collapse: collapse; } -.-translate-x-1\/2 { - --tw-translate-x: -50%; - transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); -} - .-translate-y-1\/2 { --tw-translate-y: -50%; transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); @@ -1129,16 +1115,6 @@ video { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } -@keyframes pulse { - 50% { - opacity: .5; - } -} - -.animate-pulse { - animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; -} - @keyframes spin { to { transform: rotate(360deg); @@ -1618,11 +1594,6 @@ video { border-color: rgb(94 234 212 / var(--tw-border-opacity, 1)); } -.border-white { - --tw-border-opacity: 1; - border-color: rgb(255 255 255 / var(--tw-border-opacity, 1)); -} - .border-yellow-200 { --tw-border-opacity: 1; border-color: rgb(254 240 138 / var(--tw-border-opacity, 1)); @@ -2084,10 +2055,6 @@ video { object-fit: contain; } -.\!p-4 { - padding: 1rem !important; -} - .p-1 { padding: 0.25rem; } @@ -2124,6 +2091,11 @@ video { padding: 2rem; } +.px-0 { + padding-left: 0px; + padding-right: 0px; +} + .px-0\.5 { padding-left: 0.125rem; padding-right: 0.125rem; @@ -2144,6 +2116,11 @@ video { padding-right: 0.5rem; } +.px-2\.5 { + padding-left: 0.625rem; + padding-right: 0.625rem; +} + .px-3 { padding-left: 0.75rem; padding-right: 0.75rem; @@ -2390,11 +2367,6 @@ video { letter-spacing: 0.05em; } -.\!text-white { - --tw-text-opacity: 1 !important; - color: rgb(255 255 255 / var(--tw-text-opacity, 1)) !important; -} - .text-amber-500 { --tw-text-opacity: 1; color: rgb(245 158 11 / var(--tw-text-opacity, 1)); @@ -2750,20 +2722,16 @@ video { .backdrop-blur-md { --tw-backdrop-blur: blur(12px); - -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); } .backdrop-blur-sm { --tw-backdrop-blur: blur(4px); - -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); } .transition { - transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; - transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } @@ -2814,6 +2782,30 @@ video { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } +/* + * Pricing cards (lg+): shared row tracks via CSS subgrid so header / CTA / features / footer + * rows stay aligned across columns. A flex-1 spacer *before* the CTA cannot align CTAs when + * feature lists differ in height, because that spacer’s size is (cardHeight − header − CTA − + * features − footer), so it shrinks when features grow and the CTA moves. + */ + +@media (min-width: 1024px) { + .pricing-cards-sync { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-rows: auto auto minmax(0, 1fr) auto; + gap: 1.5rem; + align-items: stretch; + } + + .pricing-card-sync { + display: grid; + grid-template-rows: subgrid; + grid-row: span 4; + min-width: 0; + } +} + .simple-table-root { /* Already defined in the package, but reinforcing proper scoping */ /* This ensures styles don't leak out and cause conflicts with Tailwind */ @@ -2925,11 +2917,6 @@ body { border-color: rgb(59 130 246 / var(--tw-border-opacity, 1)); } -.hover\:border-blue-600:hover { - --tw-border-opacity: 1; - border-color: rgb(37 99 235 / var(--tw-border-opacity, 1)); -} - .hover\:border-gray-300:hover { --tw-border-opacity: 1; border-color: rgb(209 213 219 / var(--tw-border-opacity, 1)); @@ -2985,6 +2972,11 @@ body { background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1)); } +.hover\:bg-gray-200:hover { + --tw-bg-opacity: 1; + background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)); +} + .hover\:bg-gray-300:hover { --tw-bg-opacity: 1; background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1)); @@ -3182,6 +3174,10 @@ body { border-color: rgb(55 65 81 / var(--tw-border-opacity, 1)); } +.dark\:border-gray-700\/50:is(.dark *) { + border-color: rgb(55 65 81 / 0.5); +} + .dark\:border-gray-800:is(.dark *) { --tw-border-opacity: 1; border-color: rgb(31 41 55 / var(--tw-border-opacity, 1)); @@ -3765,11 +3761,6 @@ body { color: rgb(251 191 36 / var(--tw-text-opacity, 1)); } -.dark\:text-blue-100:is(.dark *) { - --tw-text-opacity: 1; - color: rgb(219 234 254 / var(--tw-text-opacity, 1)); -} - .dark\:text-blue-200:is(.dark *) { --tw-text-opacity: 1; color: rgb(191 219 254 / var(--tw-text-opacity, 1)); @@ -4145,10 +4136,6 @@ body { gap: 1.5rem; } - .md\:p-10 { - padding: 2.5rem; - } - .md\:p-12 { padding: 3rem; } @@ -4212,6 +4199,18 @@ body { } @media (min-width: 1024px) { + .lg\:mb-0 { + margin-bottom: 0px; + } + + .lg\:mt-0 { + margin-top: 0px; + } + + .lg\:mt-4 { + margin-top: 1rem; + } + .lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -4224,6 +4223,10 @@ body { gap: 1rem; } + .lg\:overflow-auto { + overflow: auto; + } + .lg\:p-5 { padding: 1.25rem; } diff --git a/apps/marketing/src/components/pages/PricingContent.tsx b/apps/marketing/src/components/pages/PricingContent.tsx index 19c2a84d0..daf4b13a6 100644 --- a/apps/marketing/src/components/pages/PricingContent.tsx +++ b/apps/marketing/src/components/pages/PricingContent.tsx @@ -6,19 +6,18 @@ import PageWrapper from "@/components/PageWrapper"; import { faCheck, faRocket, - faStar, faHeart, faBolt, faCrown, faGift, faCreditCard, faEnvelope, + faBuilding, } from "@fortawesome/free-solid-svg-icons"; import { Button } from "antd"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { redirectToCheckout } from "@/utils/stripe"; -import { getStripePriceId, STRIPE_CUSTOMER_PORTAL_URL } from "@/constants/stripe"; +import { useMemo, useState } from "react"; +import { openStripeCheckout } from "@/utils/stripe"; +import { STRIPE_CUSTOMER_PORTAL_URL } from "@/constants/stripe"; import ContactModal from "@/components/ContactModal"; interface PlanFeature { @@ -37,81 +36,115 @@ interface Plan { features: PlanFeature[]; cta: string; ctaVariant: "default" | "primary"; - popular?: boolean; icon: any; iconColor: string; borderColor: string; backgroundColor: string; - specialOffer?: string; } +/** One line under the price on every tier (keeps long copy out of the body paragraph). */ +const PLAN_CAPACITY_NOTE = "Unlimited users"; + const PricingContent: React.FC = () => { - const router = useRouter(); const [isAnnual, setIsAnnual] = useState(false); const [isContactModalOpen, setIsContactModalOpen] = useState(false); - const plans: Plan[] = [ - { - name: "FREE", - subtitle: "For Individuals & Startups", - price: "$0", - billingCycle: "forever", - description: - "Perfect for fun projects, bootstrapped startups, and companies with zero revenue. Any revenue requires Pro. Unlimited users per product license.", - features: [ - { text: "Full library access", included: true, highlight: true }, - { text: "All features included", included: true, highlight: true }, - { text: "Community support", included: true, highlight: true }, - { text: "MIT License - for zero revenue companies only", included: true, highlight: false }, - ], - cta: "Get Started Free", - ctaVariant: "default", - icon: faHeart, - iconColor: "text-green-500", - borderColor: "border-green-200 dark:border-green-800", - backgroundColor: "bg-green-50 dark:bg-green-950", - specialOffer: "", - }, - { - name: "PRO", - subtitle: "For Growing Businesses", - price: isAnnual ? "$850" : "$85", - originalPrice: isAnnual ? "$1,020" : undefined, - billingCycle: isAnnual ? "per year" : "per month", - description: - "For companies with any revenue. Enhanced support and priority access to new features. Unlimited users per product license.", - features: [ - { text: "Priority email & Discord support", included: true, highlight: true }, - { text: "Direct developer access", included: true, highlight: true }, - { text: "Feature request prioritization", included: true, highlight: true }, - { - text: "Commercial EULA - required for revenue-generating companies", - included: true, - highlight: false, - }, - ], - cta: "Start Pro Plan", - ctaVariant: "primary", - popular: true, - icon: faCrown, - iconColor: "text-blue-500", - borderColor: "border-blue-200 dark:border-blue-800", - backgroundColor: "bg-blue-50 dark:bg-blue-950", - specialOffer: "50% off first year - Only 2 spots left! Use code: 50OFF", - }, - ]; + const plans: Plan[] = useMemo( + () => [ + { + name: "FREE", + subtitle: "For Individuals & Startups", + price: "$0", + billingCycle: "forever", + description: + "Side projects and pre-revenue teams. Generating revenue? Use Pro or Enterprise.", + features: [ + { text: "Full library access", included: true, highlight: true }, + { text: "All features included", included: true, highlight: true }, + { text: "Community support", included: true, highlight: true }, + { + text: "MIT License - for zero revenue companies only", + included: true, + highlight: false, + }, + ], + cta: "Get Started Free", + ctaVariant: "default", + icon: faHeart, + iconColor: "text-green-500", + borderColor: "border-green-200 dark:border-green-800", + backgroundColor: "bg-green-50 dark:bg-green-950", + }, + { + name: "PRO", + subtitle: "For Growing Businesses", + price: isAnnual ? "$850" : "$85", + originalPrice: isAnnual ? "$1,020" : undefined, + billingCycle: isAnnual ? "per year" : "per month", + description: + "For any revenue-generating company. Priority support and production bug coverage.", + features: [ + { text: "Priority email & Discord support", included: true, highlight: true }, + { text: "Bug support for production issues", included: true, highlight: true }, + { + text: "Commercial EULA - required for revenue-generating companies", + included: true, + highlight: false, + }, + ], + cta: "Start Pro Plan", + ctaVariant: "primary", + icon: faCrown, + iconColor: "text-blue-500", + borderColor: "border-blue-200 dark:border-blue-800", + backgroundColor: "bg-blue-50 dark:bg-blue-950", + }, + { + name: "ENTERPRISE", + subtitle: "For teams that need hands-on support", + price: isAnnual ? "$3,500" : "$350", + originalPrice: isAnnual ? "$4,200" : undefined, + billingCycle: isAnnual ? "per year" : "per month", + description: + "Hands-on support beyond Pro: faster responses, direct access to core developers, and prioritized feature requests.", + features: [ + { text: "Premium support with faster response times", included: true, highlight: true }, + { text: "Direct access to core developers", included: true, highlight: true }, + { text: "Feature request prioritization", included: true, highlight: true }, + { + text: "Commercial EULA - required for revenue-generating companies", + included: true, + highlight: false, + }, + ], + cta: "Start Enterprise Plan", + ctaVariant: "primary", + icon: faBuilding, + iconColor: "text-purple-500 dark:text-purple-400", + borderColor: "border-purple-200 dark:border-purple-800", + backgroundColor: "bg-purple-50 dark:bg-purple-900", + }, + ], + [isAnnual], + ); const handleGetStarted = async (planName: string) => { if (planName === "FREE") { - router.push("/docs/installation"); - } else { + window.open("/docs/installation", "_blank", "noopener,noreferrer"); + return; + } + if (planName === "ENTERPRISE") { try { - // Redirect to Stripe Checkout for PRO plan - const priceId = getStripePriceId(isAnnual ? "annual" : "monthly"); - - console.log("Redirecting to checkout with:", { priceId, isAnnual }); - - await redirectToCheckout(priceId, isAnnual); + openStripeCheckout("enterprise", isAnnual); + } catch (error) { + console.error("Error starting Enterprise checkout:", error); + alert("There was an error starting the checkout process. Please try again."); + } + return; + } + if (planName === "PRO") { + try { + openStripeCheckout("pro", isAnnual); } catch (error) { console.error("Error starting checkout:", error); alert("There was an error starting the checkout process. Please try again."); @@ -119,6 +152,11 @@ const PricingContent: React.FC = () => { } }; + const ctaIconForPlan = (planName: string) => { + if (planName === "FREE") return faRocket; + return faBolt; + }; + const containerVariants = { hidden: { opacity: 0 }, visible: { @@ -219,77 +257,54 @@ const PricingContent: React.FC = () => { {/* Pricing Cards */} - {plans.map((plan, index) => ( + {plans.map((plan) => ( - {plan.popular && ( -
- - - Most Popular - -
- )} - - {plan.specialOffer && ( -
- - Limited Time - -
- )} -
-

{plan.name}

-

{plan.subtitle}

+

+ {plan.name} +

+

+ {plan.subtitle} +

-
-
+
+
{plan.price} - {plan.originalPrice && ( - + {plan.originalPrice ? ( + {plan.originalPrice} - )} + ) : null} /{plan.billingCycle}
-

- {plan.specialOffer} +

+ {PLAN_CAPACITY_NOTE}

-

{plan.description}

+

+ {plan.description} +

- - -
+
{plan.features.map((feature, featureIndex) => ( -
+
{ ))}
-
+ + + @@ -426,11 +452,11 @@ const PricingContent: React.FC = () => {

Ready to Build Amazing Tables?

-

+

Join thousands of developers who trust Simple Table for their data visualization needs. No per-user fees - one license covers unlimited users per product.

-
+
-
diff --git a/apps/marketing/src/constants/strings/seo.ts b/apps/marketing/src/constants/strings/seo.ts index d5a8b530d..8eff78108 100644 --- a/apps/marketing/src/constants/strings/seo.ts +++ b/apps/marketing/src/constants/strings/seo.ts @@ -1217,9 +1217,9 @@ export const SEO_STRINGS = { }, }, pricing: { - title: "Simple Table Pricing 2025: Free & Pro Plans | AG Grid Alternative", + title: "Simple Table Pricing 2025: Free, Pro & Enterprise | AG Grid Alternative", description: - "Simple Table pricing: FREE plan for individuals and startups, PRO plan for businesses ($85/mo). Transparent pricing with no per-user fees. Works with React, Vue, Angular, Svelte, Solid, and vanilla TypeScript.", + "Simple Table pricing: FREE for zero-revenue use. Pro $85/mo or $850/yr. Enterprise $350/mo or $3,500/yr with premium support and direct developer access. No per-user fees. React, Vue, Angular, Svelte, Solid, and vanilla TypeScript.", keywords: "simple-table pricing, free data grid, data grid pricing, data grid cost, simple table pro, free table library, data grid plans, ag grid pricing alternative, ag grid cost comparison, javascript data grid pricing", }, diff --git a/apps/marketing/src/constants/stripe.ts b/apps/marketing/src/constants/stripe.ts index ea3051cac..c7a84aacd 100644 --- a/apps/marketing/src/constants/stripe.ts +++ b/apps/marketing/src/constants/stripe.ts @@ -1,23 +1,15 @@ -// Stripe Product and Price Constants -export const STRIPE_CONFIG = { - // Price IDs - PRICES: { - MONTHLY: "price_1SBSUBI7eqwmd5zg42blEFde", - ANNUAL: "price_1SBSUBI7eqwmd5zgxwBpyiqP", - }, -} as const; - -// Helper functions -export const getStripePriceId = (plan: "monthly" | "annual") => { - return plan === "monthly" ? STRIPE_CONFIG.PRICES.MONTHLY : STRIPE_CONFIG.PRICES.ANNUAL; -}; - -// Payment Links from Stripe Dashboard +// Payment Links from Stripe Dashboard (Pro) export const STRIPE_PAYMENT_LINKS = { monthly: "https://buy.stripe.com/4gMdR95g4bTigCi1m21sQ01", annual: "https://buy.stripe.com/28EbJ14c0aPegCi3ua1sQ00", } as const; +// Enterprise tier payment links +export const STRIPE_ENTERPRISE_PAYMENT_LINKS = { + monthly: "https://buy.stripe.com/6oUdR94c02iI3Pwc0G1sQ02", + annual: "https://buy.stripe.com/cNi4gz6k80aAeua5Ci1sQ03", +} as const; + // Customer Portal for subscription management export const STRIPE_CUSTOMER_PORTAL_URL = "https://billing.stripe.com/p/login/28EbJ14c0aPegCi3ua1sQ00"; diff --git a/apps/marketing/src/utils/stripe.ts b/apps/marketing/src/utils/stripe.ts index 3a5cc099a..914dc8229 100644 --- a/apps/marketing/src/utils/stripe.ts +++ b/apps/marketing/src/utils/stripe.ts @@ -1,21 +1,18 @@ -import { STRIPE_PAYMENT_LINKS } from "@/constants/stripe"; +import { STRIPE_ENTERPRISE_PAYMENT_LINKS, STRIPE_PAYMENT_LINKS } from "@/constants/stripe"; -export const redirectToCheckout = async (priceId: string, isAnnual: boolean) => { - try { - const planType = isAnnual ? "annual" : "monthly"; - const paymentLink = STRIPE_PAYMENT_LINKS[planType]; +export type StripeCheckoutProduct = "pro" | "enterprise"; - if (!paymentLink) { - alert( - `Payment link not configured for ${planType} plan. Please create Payment Links in your Stripe Dashboard first.` - ); - throw new Error(`Payment link not configured for ${planType} plan`); - } +export const openStripeCheckout = (product: StripeCheckoutProduct, isAnnual: boolean) => { + const planType = isAnnual ? "annual" : "monthly"; + const paymentLink = + product === "pro" ? STRIPE_PAYMENT_LINKS[planType] : STRIPE_ENTERPRISE_PAYMENT_LINKS[planType]; - // Redirect to the Payment Link - window.location.href = paymentLink; - } catch (error) { - console.error("Error redirecting to checkout:", error); - throw error; + if (!paymentLink) { + alert( + `Payment link not configured for ${product} ${planType} plan. Please create Payment Links in your Stripe Dashboard first.`, + ); + throw new Error(`Payment link not configured for ${product} ${planType} plan`); } + + window.open(paymentLink, "_blank", "noopener,noreferrer"); }; diff --git a/package.json b/package.json index a4d4371f0..b477ae864 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "build:marketing": "turbo run build --filter=simple-table-marketing", "watch:react-adapter": "pnpm --filter @simple-table/react run preview", "dev:marketing": "turbo run dev --filter=simple-table-marketing", + "dev:marketing:watch": "sh -c 'pnpm --filter @simple-table/react run preview & pnpm --filter simple-table-marketing run dev & wait'", "dev": "turbo run preview --parallel", "dev:examples": "pnpm --filter=examples-react --filter=examples-vue --filter=examples-svelte --filter=examples-solid --filter=examples-angular --filter=examples-vanilla dev", "dev:examples-react": "pnpm --filter=examples-react dev", diff --git a/packages/core/src/core/rendering/TableRenderer.ts b/packages/core/src/core/rendering/TableRenderer.ts index b00cd6b85..afdde9504 100644 --- a/packages/core/src/core/rendering/TableRenderer.ts +++ b/packages/core/src/core/rendering/TableRenderer.ts @@ -10,6 +10,7 @@ import { createColumnEditor } from "../../utils/columnEditor/createColumnEditor" import { createHorizontalScrollbar, cleanupHorizontalScrollbar, + syncHorizontalScrollbarLayout, } from "../../utils/horizontalScrollbarRenderer"; import { createStickyParentsContainer, @@ -874,6 +875,18 @@ export class TableRenderer { this.horizontalScrollbarRef.current && wrapperContainer.contains(this.horizontalScrollbarRef.current) ) { + const sb = this.horizontalScrollbarRef.current; + syncHorizontalScrollbarLayout(sb, { + mainBodyRef: deps.mainBodyRef.current, + mainBodyWidth, + pinnedLeftWidth, + pinnedRightWidth, + pinnedLeftContentWidth, + pinnedRightContentWidth, + tableBodyContainerRef, + editColumns: deps.config.editColumns ?? false, + sectionScrollController: deps.sectionScrollController ?? undefined, + }); return; } @@ -898,6 +911,18 @@ export class TableRenderer { this.horizontalScrollbarRef.current && wrapperContainer.contains(this.horizontalScrollbarRef.current) ) { + const existing = this.horizontalScrollbarRef.current; + syncHorizontalScrollbarLayout(existing, { + mainBodyRef: deps.mainBodyRef.current, + mainBodyWidth, + pinnedLeftWidth, + pinnedRightWidth, + pinnedLeftContentWidth, + pinnedRightContentWidth, + tableBodyContainerRef, + editColumns: deps.config.editColumns ?? false, + sectionScrollController: deps.sectionScrollController ?? undefined, + }); this.scrollbarTimeoutId = null; return; } diff --git a/packages/core/src/utils/horizontalScrollbarRenderer.ts b/packages/core/src/utils/horizontalScrollbarRenderer.ts index eede8be82..2bd2dd55d 100644 --- a/packages/core/src/utils/horizontalScrollbarRenderer.ts +++ b/packages/core/src/utils/horizontalScrollbarRenderer.ts @@ -108,6 +108,73 @@ export const createHorizontalScrollbar = ( return container; }; +/** + * Apply width props to an existing scrollbar from {@link createHorizontalScrollbar}. + * Used when layout is recreated without tearing down the DOM node (e.g. pinned resize). + */ +export const syncHorizontalScrollbarLayout = ( + container: HTMLElement, + props: HorizontalScrollbarProps, +): void => { + const { + mainBodyWidth, + pinnedLeftWidth, + pinnedRightWidth, + pinnedLeftContentWidth, + pinnedRightContentWidth, + tableBodyContainerRef, + editColumns, + } = props; + + const isContentVerticalScrollable = + tableBodyContainerRef.scrollHeight > tableBodyContainerRef.clientHeight; + const scrollbarWidth = isContentVerticalScrollable + ? tableBodyContainerRef.offsetWidth - tableBodyContainerRef.clientWidth + : 0; + const editorWidth = editColumns ? COLUMN_EDIT_WIDTH : 0; + const rightSectionWidth = + (editColumns ? pinnedRightWidth + PINNED_BORDER_WIDTH : pinnedRightWidth) + scrollbarWidth; + + const leftSection = container.querySelector( + ".st-horizontal-scrollbar-left", + ) as HTMLElement | null; + const leftInner = leftSection?.firstElementChild as HTMLElement | null; + if (pinnedLeftWidth > 0 && leftSection && leftInner) { + leftSection.style.width = `${pinnedLeftWidth}px`; + leftInner.style.width = `${pinnedLeftContentWidth}px`; + } + + const mainSection = container.querySelector( + ".st-horizontal-scrollbar-middle", + ) as HTMLElement | null; + const mainInner = mainSection?.firstElementChild as HTMLElement | null; + if (mainBodyWidth > 0 && mainSection && mainInner) { + mainInner.style.width = `${mainBodyWidth}px`; + } + + const rightSection = container.querySelector( + ".st-horizontal-scrollbar-right", + ) as HTMLElement | null; + const rightInner = rightSection?.firstElementChild as HTMLElement | null; + if (pinnedRightWidth > 0 && rightSection && rightInner) { + rightSection.style.width = `${rightSectionWidth}px`; + rightInner.style.width = `${pinnedRightContentWidth}px`; + } + + if (editorWidth > 0) { + const spacer = Array.from(container.children).find( + (c) => + c instanceof HTMLElement && + !c.classList.contains("st-horizontal-scrollbar-left") && + !c.classList.contains("st-horizontal-scrollbar-middle") && + !c.classList.contains("st-horizontal-scrollbar-right"), + ) as HTMLElement | undefined; + if (spacer) { + spacer.style.width = `${editorWidth - 1.5}px`; + } + } +}; + export const cleanupHorizontalScrollbar = ( container: HTMLElement, sectionScrollController?: SectionScrollController | null, diff --git a/packages/core/src/utils/resizeUtils/domUpdates.ts b/packages/core/src/utils/resizeUtils/domUpdates.ts index 88c475ea2..07ea9ea0a 100644 --- a/packages/core/src/utils/resizeUtils/domUpdates.ts +++ b/packages/core/src/utils/resizeUtils/domUpdates.ts @@ -4,6 +4,7 @@ import { DEFAULT_SHOW_WHEN } from "../../types/HeaderObject"; import { findLeafHeaders, getHeaderWidthInPixels } from "../headerWidthUtils"; import { findParentHeader } from "../collapseUtils"; import { getCellId } from "../cellUtils"; +import { syncHorizontalScrollbarLayout } from "../horizontalScrollbarRenderer"; import { recalculateAllSectionWidths } from "./sectionWidths"; /** @@ -150,13 +151,20 @@ export const updateColumnWidthsInDOM = ( // During resize drag, pinned section width is only set on full render. Reuse the same logic as // TableRenderer: recalculateAllSectionWidths then apply to section DOM (header + body). // Header sections do not use display:grid (see base.css), so we only need to update width. - const tableContainer = document.querySelector(".st-body-container") as HTMLElement | null; + const tableContainer = document.querySelector(".st-body-container") as HTMLDivElement | null; const containerWidth = tableContainer?.clientWidth ?? 0; - const { leftWidth, rightWidth } = recalculateAllSectionWidths({ + const sectionWidths = recalculateAllSectionWidths({ headers, containerWidth: containerWidth > 0 ? containerWidth : undefined, collapsedHeaders: collapsedHeaders as Set | undefined, }); + const { + leftWidth, + rightWidth, + mainWidth, + leftContentWidth, + rightContentWidth, + } = sectionWidths; if (leftWidth > 0) { const leftHeaderSection = document.querySelector( @@ -174,4 +182,28 @@ export const updateColumnWidthsInDOM = ( if (rightHeaderSection) rightHeaderSection.style.width = `${rightWidth}px`; if (rightBodySection) rightBodySection.style.width = `${rightWidth}px`; } + + const root = tableContainer?.closest(".simple-table-root"); + const hScroll = root?.querySelector( + ".st-horizontal-scrollbar-container", + ) as HTMLElement | null; + const mainBody = root?.querySelector(".st-body-main") as HTMLDivElement | null; + const editColumns = Boolean(root?.querySelector(".st-column-editor")); + if ( + hScroll && + mainBody && + tableContainer && + mainBody.scrollWidth - mainBody.clientWidth > 1 + ) { + syncHorizontalScrollbarLayout(hScroll, { + mainBodyRef: mainBody, + mainBodyWidth: mainWidth, + pinnedLeftWidth: leftWidth, + pinnedRightWidth: rightWidth, + pinnedLeftContentWidth: leftContentWidth, + pinnedRightContentWidth: rightContentWidth, + tableBodyContainerRef: tableContainer, + editColumns, + }); + } }; diff --git a/packages/core/stories/tests/13-ColumnResizeTests.stories.ts b/packages/core/stories/tests/13-ColumnResizeTests.stories.ts index 789d9f517..09a087695 100644 --- a/packages/core/stories/tests/13-ColumnResizeTests.stories.ts +++ b/packages/core/stories/tests/13-ColumnResizeTests.stories.ts @@ -358,6 +358,55 @@ const createEmployeeData = () => [ { id: 3, name: "Charlie Brown", email: "charlie@example.com", department: "Engineering", salary: 140000 }, ]; +/** Bottom horizontal track left segment should stay aligned with the pinned-left body width. */ +export const PinnedResizeSyncsHorizontalScrollbar = { + parameters: { tags: ["pinned-resize-h-scrollbar"] }, + render: () => { + const headers: HeaderObject[] = [ + { accessor: "name", label: "Name", width: 160, pinned: "left", type: "string" }, + { accessor: "email", label: "Email", width: 280, type: "string" }, + { accessor: "department", label: "Department", width: 200, type: "string" }, + { accessor: "salary", label: "Salary", width: 160, type: "number" }, + ]; + const { wrapper, tableContainer } = renderVanillaTable(headers, createEmployeeData(), { + getRowId: (params) => String(params.row.id), + height: "400px", + columnResizing: true, + }); + tableContainer.style.width = "380px"; + tableContainer.style.maxWidth = "100%"; + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + const hBarLeft = canvasElement.querySelector( + ".st-horizontal-scrollbar-left", + ) as HTMLElement | null; + const pinnedBody = canvasElement.querySelector(".st-body-pinned-left") as HTMLElement | null; + expect(hBarLeft).toBeTruthy(); + expect(pinnedBody).toBeTruthy(); + + const parsePx = (el: HTMLElement) => parseFloat(window.getComputedStyle(el).width); + + const nameHeader = findHeaderCellByLabel(canvasElement, "Name"); + expect(nameHeader).toBeTruthy(); + await resizeColumn(nameHeader!, 55, () => findHeaderCellByLabel(canvasElement, "Name")); + + const hBarLeftAfter = canvasElement.querySelector( + ".st-horizontal-scrollbar-left", + ) as HTMLElement | null; + const pinnedBodyAfter = canvasElement.querySelector( + ".st-body-pinned-left", + ) as HTMLElement | null; + expect(hBarLeftAfter).toBeTruthy(); + expect(pinnedBodyAfter).toBeTruthy(); + + const barW = parsePx(hBarLeftAfter!); + const pinW = parsePx(pinnedBodyAfter!); + expect(Math.abs(barW - pinW)).toBeLessThan(4); + }, +}; + export const ResizeLeftPinnedColumn = { render: () => { const headers: HeaderObject[] = [ diff --git a/packages/react/src/SimpleTable.tsx b/packages/react/src/SimpleTable.tsx index c0ddd18af..9298fce48 100644 --- a/packages/react/src/SimpleTable.tsx +++ b/packages/react/src/SimpleTable.tsx @@ -9,10 +9,9 @@ function shallowTablePropsChanged( prev: SimpleTableReactProps, next: SimpleTableReactProps, ): boolean { - const keys = new Set([ - ...Object.keys(prev as object), - ...Object.keys(next as object), - ]) as Set; + const keys = new Set([...Object.keys(prev as object), ...Object.keys(next as object)]) as Set< + keyof SimpleTableReactProps + >; for (const key of keys) { if (prev[key] !== next[key]) return true; }