diff --git a/.agents/skills/competitive-landscape/SKILL.md b/.agents/skills/competitive-landscape/SKILL.md new file mode 100644 index 000000000..088370881 --- /dev/null +++ b/.agents/skills/competitive-landscape/SKILL.md @@ -0,0 +1,80 @@ +--- +name: competitive-landscape +description: Map SEO market leaders, winning content themes, keyword coverage, backlinks, and strategic gaps. +--- + +# OpenSEO Competitive Landscape + +## Goal + +Answer: "Who is winning this SEO market, what content is working for them, and where are the openings?" + +Use this when the user wants a market-level view across several competitors. For a deep dive on one domain, use `competitor-analysis`. + +## Required inputs + +- `projectId` +- Topic, seed keywords, market/category, or user's domain +- Optional known competitors +- Optional location/language + +## OpenSEO MCP tools + +- `research_keywords`: discover representative market queries. +- `get_keyword_metrics`: validate known query sets with volume, difficulty, intent, and trends. +- `get_serp_results`: identify recurring ranking domains across target queries. +- `find_serp_competitors`: compare domains competing across supplied keywords; use this before manual SERP counting when a keyword set is available. +- `get_domain_overview`: size organic footprint for candidate leaders. +- `get_search_console_performance`: when the user's own domain is in the comparison and Search Console is connected, anchor their position with first-party clicks/impressions/CTR rather than third-party estimates. +- `get_ranked_keywords`: find exact ranking keywords, URLs, ranks, intents, and SERP result types for leaders. +- `get_backlinks_overview`: compare backlink/referring-domain strength where relevant. +- `search_local_businesses`, `get_local_serp_results`, and `get_google_business_questions`: use for local SEO markets where proximity, Maps rankings, business categories, reviews, or Google Q&A affect who is winning. + +## Workflow + +1. Define the market query set: + - Use provided keywords, or call `research_keywords` to build 5-10 representative queries. + - Include mixed intent: informational, commercial, comparison, and tool/software terms when applicable. + - For local SEO, include neighborhood/city/service-area queries and identify the priority locations or coordinates. +2. If the query set is already known, use `get_keyword_metrics` to validate relative demand and difficulty and `find_serp_competitors` to identify recurring domains at scale. +3. For local SEO, call `search_local_businesses` and `get_local_serp_results` for the highest-priority location(s) before synthesizing winners. Use `get_serp_results` as a complement for organic pages, not as the only local evidence. +4. Call `get_serp_results` for representative queries when live SERP composition, ranking URLs, or SERP features need inspection. Send at most 10 queries per call. +5. Identify recurring domains and group them by type: + - Direct product competitors + - Publishers/media + - Marketplaces/directories + - Communities/forums + - Documentation/resources +6. For the strongest recurring domains, call `get_domain_overview`; default to the top 3-5 domains before expanding. +7. For direct competitors and relevant publishers, call `get_ranked_keywords`. +8. Use `get_backlinks_overview` when backlink authority appears important or the user asks why a domain is winning. Backlinks may be unavailable if the account has not enabled that data; continue with SERP/domain evidence if it fails. +9. Synthesize patterns: content types, themes, SERP formats, local-pack signals, authority advantages, and underserved angles. + +## Output format + +Start with the market read: + +- Market leaders +- Most winnable opportunity area +- Biggest barrier to ranking + +Then include: + +| Domain | Type | Why they matter | Organic footprint | Winning themes | Weakness/gap | +| ------ | ---- | --------------- | ----------------- | -------------- | ------------ | + +Add: + +- Query set used +- Content formats that are working +- Keyword/theme gaps +- Backlink or authority observations +- Recommended next workflows: competitor analysis, keyword clustering, or content brief + +## Guardrails + +- Distinguish SEO competitors from business competitors. +- Do not overstate exact traffic when OpenSEO returns estimates. +- If using a small query set, call the result directional. +- Do not assume a publisher is a product competitor; label domain types clearly. +- For local markets, distinguish organic-page winners from Maps/local-pack winners. diff --git a/.agents/skills/competitor-analysis/SKILL.md b/.agents/skills/competitor-analysis/SKILL.md new file mode 100644 index 000000000..1ca9333ba --- /dev/null +++ b/.agents/skills/competitor-analysis/SKILL.md @@ -0,0 +1,83 @@ +--- +name: competitor-analysis +description: "Analyze one competitor's organic footprint, ranking keywords, content themes, backlinks, and gaps." +--- + +# OpenSEO Competitor Analysis + +## Goal + +Analyze one competitor deeply enough to decide what to learn from, avoid, counter-position against, or outrank. + +Use this for a named competitor. For identifying the market leaders first, use `competitive-landscape`. + +## Required inputs + +- `projectId` +- Competitor domain +- User's domain when comparison is requested +- Optional topic/category/location/language + +## OpenSEO MCP tools + +- `get_domain_overview`: baseline organic traffic and keyword count. +- `get_search_console_performance`: when comparing to the user's own domain and Search Console is connected, use it as the first-party baseline (real clicks/impressions/CTR/position) instead of estimating the user's own performance from third-party data. +- `get_ranked_keywords`: exact keyword, URL, rank, intent, traffic, CPC, and SERP-type rows for the competitor domain or page. +- `get_backlinks_overview`: backlink/referring-domain profile. +- `find_serp_competitors`: validate whether the named competitor is a real search competitor across the target keyword set. +- `search_local_businesses`, `get_local_serp_results`, and `get_google_business_questions`: use for local SEO competitors when Maps/local-pack visibility, nearby businesses, categories, or Google Q&A matter. +- `get_serp_results`: validate direct head-to-head SERPs for important keywords. +- `research_keywords`: expand gaps or category terms when needed. + +## Workflow + +1. Call `get_domain_overview` for the competitor, passing provided location/language when supported. +2. If comparing to the user, call `get_domain_overview` for the user's domain too — and if Search Console is connected, `get_search_console_performance` for the user's real baseline. +3. Call `get_ranked_keywords` for the competitor. Use filters like `maxRank`, `minSearchVolume`, `excludeBrandTerms`, and `resultTypes` to keep rows relevant. +4. If comparing to the user, call `get_ranked_keywords` for the user's domain/page too, or use `get_serp_results` for the shared terms when a lighter check is enough. +5. For local SEO, use `search_local_businesses` and `get_local_serp_results` around the relevant business location(s) before drawing local-pack conclusions. Add `get_google_business_questions` only when Q&A evidence matters. +6. Use `find_serp_competitors` when the competitor was supplied by the user but its search overlap is unclear. +7. Group competitor keywords into themes: + - Product/category terms + - Alternatives/comparisons + - Templates/tools/calculators + - Educational guides + - Branded demand + - Local/neighborhood terms when relevant +8. Call `get_backlinks_overview` for the competitor, especially if authority appears to explain rankings. Continue without backlink evidence if it is unavailable. +9. Use `get_serp_results` for important shared or target keywords to compare positioning, passing provided location/language when supported. +10. Produce an actionable plan: + - What they are doing well + - Where they are vulnerable + - Which pages/keywords to pursue + - What to avoid copying + +## Output format + +Start with: + +- Competitor snapshot +- Biggest lesson +- Best opportunity to beat them + +Then include: + +| Area | Competitor pattern | Evidence | OpenSEO opportunity | +| ---- | ------------------ | -------- | ------------------- | + +Include sections for: + +- Top keyword themes +- Content/page types working for them +- Backlink/authority notes +- Head-to-head SERP observations +- Priority actions for the user + +## Guardrails + +- Do not treat all competitor keywords as desirable. Filter for business fit. +- Separate evidence from inference. +- Do not infer competitor page/content-type patterns from keyword rows alone; use SERP or web evidence for page-level claims. +- For local SEO, do not infer Maps/local-pack strength from national organic domain metrics alone; use local business and local SERP tools when the location is known or reasonably discoverable. +- Do not recommend copying content; recommend a stronger angle or better answer to the same intent. +- If the user's domain is unavailable, frame the analysis as competitor-only. diff --git a/.agents/skills/keyword-clustering/SKILL.md b/.agents/skills/keyword-clustering/SKILL.md new file mode 100644 index 000000000..0fbadf756 --- /dev/null +++ b/.agents/skills/keyword-clustering/SKILL.md @@ -0,0 +1,76 @@ +--- +name: keyword-clustering +description: Cluster keywords by intent and map them to existing or proposed pages. +--- + +# OpenSEO Keyword Clustering + +## Goal + +Group keywords into page-level clusters and decide which existing or new page should target each cluster. This is a keyword mapping workflow, not just a semantic grouping exercise. + +## Required inputs + +- `projectId` +- A keyword list, saved keyword tag, seed topic, or target domain +- Optional existing URLs/pages to map against + +If keywords are not provided, use `list_saved_keywords` for saved sets, `research_keywords` for seed discovery, or `get_ranked_keywords` when the user starts from a target domain. + +## OpenSEO MCP tools + +- `list_saved_keywords`: fetch an existing keyword set, optionally filtered by tags. +- `research_keywords`: expand a seed when the user starts from a topic. +- `get_ranked_keywords`: gather exact ranking keywords and URLs when the user starts from a domain or page. +- `get_search_console_performance`: when Search Console is connected, pull real queries with `dimensions: ["query","page"]` to map terms to the pages already earning impressions and to surface cannibalization (one query splitting clicks across multiple URLs). +- `get_serp_results`: validate whether keywords belong on the same page by checking SERP overlap and intent. +- `get_local_serp_results`: use for local SEO clusters when Maps/local-pack intent should affect page mapping. +- `save_keywords`: optionally tag final clusters after user confirmation. + +## Workflow + +1. Gather the candidate keyword set. + - Use `get_search_console_performance` (dimensions `["query","page"]`) when Search Console is connected to start from real queries and the pages already ranking for them. + - Use `get_ranked_keywords` for domain/page-driven clustering. + - Use `search_local_businesses` and `get_local_serp_results` when proximity, local packs, or Google Business results determine whether terms belong on location pages. +2. Remove duplicates, irrelevant terms, and terms that clearly require a different product or audience. +3. Build clusters around intent and page type: + - Same SERP intent and similar ranking pages belong together. + - Different intent, buyer stage, or SERP format should be split. + - Similar words do not guarantee the same cluster. +4. For important borderline terms, use a small `get_serp_results` batch to check overlap. +5. Assign each cluster to: + - Existing URL, if supplied and appropriate + - New page recommendation, if no existing page fits + - Do-not-target / later bucket, if weak or off-strategy +6. Identify cannibalization risk when multiple pages would target the same intent. When Search Console is connected, confirm it from real data with `get_search_console_performance` (`dimensions: ["query","page"]`) — the same query sending impressions to multiple URLs. +7. Ask before applying cluster tags with `save_keywords`. + +## Output format + +Start with a short mapping summary: + +- Number of clusters +- Pages to create +- Existing pages to update +- Cannibalization or consolidation issues + +Then include: + +| Cluster | Primary keyword | Secondary keywords | Intent | Target page | Priority | Notes | +| ------- | --------------- | ------------------ | ------ | ----------- | -------- | ----- | + +For each cluster, include a recommended page brief: + +- Page type +- Searcher problem +- Required sections +- Internal-link opportunities +- Save/tag suggestion + +## Guardrails + +- Do not over-cluster tiny keyword sets. If there are fewer than 10 usable terms, produce a simple map. +- Do not rely on lexical similarity alone. SERP intent wins. +- Do not replace tags broadly without explicit confirmation. +- If existing URL data is missing, label target pages as proposed. diff --git a/.agents/skills/keyword-research/SKILL.md b/.agents/skills/keyword-research/SKILL.md new file mode 100644 index 000000000..4bdfc40dd --- /dev/null +++ b/.agents/skills/keyword-research/SKILL.md @@ -0,0 +1,70 @@ +--- +name: keyword-research +description: "Discover keyword opportunities, evaluate metrics and SERPs, and save/tag promising terms." +--- + +# OpenSEO Keyword Research + +## Goal + +Turn seed topics into a prioritized keyword opportunity set using OpenSEO MCP data. The output should help the user decide what to target, what to save, and what to research next. + +## Required inputs + +- `projectId` +- One or more seed topics, products, pages, competitors, or audience problems +- Optional market/location/language + +If `projectId` is missing, use `list_projects` first. If the target market/location/language is unclear and would materially affect keyword metrics, ask the user; otherwise use the MCP tool defaults. + +## OpenSEO MCP tools + +- `research_keywords`: primary discovery tool. Use 1-5 seeds per call and prefer 150 results unless the user asks for exhaustive research. +- `get_keyword_metrics`: hydrate up to 700 known keywords with volume, keyword difficulty (KD), search intent, CPC, and monthly trends in one call. Use it to score candidate or known terms — including the Search Console striking-distance queries from step 1. +- `get_ranked_keywords`: pull exact ranking keyword rows when a target domain or page is part of the research brief. +- `get_search_console_performance`: when Search Console is connected, start from the project's real first-party demand — queries already earning impressions and near-ranking ("striking distance") terms. Request a high `rowLimit` and filter average position 5-20 client-side, since the API sorts by clicks and can't filter by position. Then hydrate those striking-distance queries with `get_keyword_metrics` to attach difficulty and intent. +- `get_serp_results`: inspect SERPs for the top candidate terms, especially when intent is ambiguous. +- `search_local_businesses`, `get_local_serp_results`, and `get_google_business_questions`: use for local SEO topics when a business/location radius matters. +- `list_saved_keywords`: avoid duplicating already-saved work or use existing tags as context. +- `save_keywords`: save selected keywords only after explicit user confirmation. + +## Workflow + +1. Normalize seeds into a small set of distinct research angles. If Search Console is connected for the project, first pull `get_search_console_performance` (high `rowLimit`, default lookback), filter to striking-distance positions (~5–20) client-side, and hydrate those queries with `get_keyword_metrics` to attach KD and intent. That ranked, hydrated list is your fastest opportunity set — work it before broad discovery. +2. If the request is local SEO, identify the business, location/coordinates or service area, and local categories. Use `search_local_businesses` and `get_local_serp_results` for the most important location/keyword set instead of relying only on national keyword/SERP data. +3. Call `research_keywords` for exploratory seeds. Use bulk calls when possible. +4. Use `get_keyword_metrics` to hydrate a fixed keyword list — or the striking-distance queries from step 1 — with volume, KD, and intent before prioritizing. +5. Use `get_ranked_keywords` when the user provides a domain/page and wants opportunities based on current rankings, near-misses, or competitor-owned terms. +6. Remove irrelevant, duplicate, branded-only, and off-intent terms. +7. Prioritize by practical opportunity, not volume alone: + - Strong match to the user's product/page/topic + - Clear search intent + - Reasonable difficulty + - Useful volume/CPC signal + - SERP where the user can plausibly compete + - For local SEO, local-pack/Maps visibility and proximity fit +8. Use `get_serp_results` for high-potential or ambiguous keywords when SERP intent would change the recommendation; keep the default check small. +9. Present a shortlist and a longer opportunity table. +10. Ask before saving keywords. When saving, suggest concise tags such as `topic:`, `intent:`, or `page:`. + +## Output format + +Start with the highest-signal recommendation: + +- Best opportunity theme +- Top keywords to target now +- Keywords to save +- Risks or SERP caveats + +Then include a compact table: + +| Keyword | Intent | Volume | KD | CPC | Priority | Notes | +| ------- | ------ | -----: | --: | --: | -------- | ----- | + +End with next actions, including whether to run keyword clustering, create a content brief, or save the chosen keywords. + +## Guardrails + +- Do not invent metrics. If OpenSEO does not return a value, write `unknown`. +- Do not call `save_keywords` without explicit confirmation. +- Prefer business-fit and intent-fit over chasing the largest volume term. diff --git a/.agents/skills/link-prospecting/SKILL.md b/.agents/skills/link-prospecting/SKILL.md new file mode 100644 index 000000000..e9b9fb711 --- /dev/null +++ b/.agents/skills/link-prospecting/SKILL.md @@ -0,0 +1,107 @@ +--- +name: link-prospecting +description: Find link prospects, discover contact paths, and draft outreach from SERPs and backlink signals. +--- + +# OpenSEO Link Prospecting + +## Goal + +Find realistic pages, sites, and authors that might reference the user's page, product, study, guide, or tool. Use OpenSEO for prospect discovery, then use available web/search/browser tools for contact discovery. + +## Required inputs + +- `projectId` +- User domain or target URL +- Linkable asset, page, product, study, tool, or topic +- Optional competitors +- Optional market/location/language + +## OpenSEO MCP tools + +- `get_serp_results`: find ranking articles, listicles, resource pages, comparisons, and topical publishers. +- `get_backlinks_overview`: inspect competitor domain or page backlink/referring-domain patterns. +- `get_domain_overview`: qualify important prospect domains. +- `get_ranked_keywords`: understand what a prospect or competitor ranks for when topical fit matters. +- `search_local_businesses` and `get_local_serp_results`: use for local SEO link prospecting when nearby businesses, local competitors, or Maps/category signals can reveal partnership targets. +- `research_keywords`: expand prospecting queries. + +## Contact discovery tools + +After OpenSEO identifies good prospects, use available non-OpenSEO browsing or search tools for public contact discovery. Depending on the client, this may be web search, page fetches, browser automation, or a search API. + +Look for: + +- Author byline pages +- Contact pages +- Editorial guidelines +- About/team pages +- LinkedIn, X, Bluesky, or other professional profiles +- Newsletter or publication masthead pages +- Public email addresses in page HTML or visible page text +- Structured data such as `Person`, `Organization`, `sameAs`, or `email` + +Only record contact details that were actually found. Include the source URL for any email, profile, or contact form. + +## Prospecting query patterns + +Build queries from the asset/topic: + +- ` resources` +- `best tools` +- ` alternatives` +- ` statistics` +- ` guide` +- ` examples` +- ` templates` +- ` software` +- ` for ` + +Use `get_serp_results` in batches for the most relevant patterns. Send at most 10 queries per call. + +## Workflow + +1. Clarify the linkable asset and the reason someone would reference it. +2. Build 5-10 prospecting queries by default. +3. Call `get_serp_results` for those queries. +4. If competitors are provided, call `get_backlinks_overview` for the strongest competitor domains or pages first. Continue without backlink evidence if it is unavailable. +5. For local SEO, use `search_local_businesses` and `get_local_serp_results` around priority locations to identify nearby competitors, categories, and local SERP evidence before searching for local chambers, associations, campus resources, community pages, and directories. +6. Filter prospects: + - Keep topical relevance and editorial pages. + - Prioritize articles, directories, resource pages, comparisons, statistics pages, templates, and curated lists. + - Deprioritize homepages, login pages, thin affiliate pages, spam, unrelated forums, and direct competitors unless a comparison angle is valid. +7. For each good prospect, define the outreach angle: + - Broken/missing resource + - Better current data + - Useful tool/template + - Alternative or comparison inclusion + - Expert quote or supporting reference +8. For the strongest prospects, visit or search the prospect site to find the best contact path. +9. Draft outreach messages. If contact details were found, include the source. If not, list the next best contact-discovery path. + +## Output format + +Start with: + +- Best outreach angle +- Highest-priority prospect type +- Any data limitations + +Then include: + +| Prospect URL | Site/domain | Source | Relevance | Suggested angle | Contact path | Priority | +| ------------ | ----------- | ------ | --------- | --------------- | ------------ | -------- | + +Then provide 2-3 reusable outreach drafts: + +- Resource/list inclusion +- Article update/reference suggestion +- Competitor alternative/comparison angle + +## Guardrails + +- Do not invent email addresses, social handles, or contact names. +- Do not say OpenSEO found contact details unless an OpenSEO tool returned them. Attribute contact discovery to the web/search/browser source used. +- If contact details are not available after a reasonable search, recommend specific discovery steps such as checking the author page, contact page, LinkedIn, X, or a reputable contact-enrichment tool. +- Avoid spammy mass outreach. Personalize by page and reason. +- Flag prospects that are direct competitors or likely paid placements. diff --git a/.agents/skills/seo-coach/SKILL.md b/.agents/skills/seo-coach/SKILL.md new file mode 100644 index 000000000..868841666 --- /dev/null +++ b/.agents/skills/seo-coach/SKILL.md @@ -0,0 +1,107 @@ +--- +name: seo-coach +description: Enter a friendly OpenSEO coach mode that explains workflows, recommends next steps, and helps users use agents, web search, scraping, and MCP data effectively. +--- + +# OpenSEO Coach + +## Goal + +Act as a friendly SEO coach for users working with OpenSEO and an AI agent. Help them understand what the workflows do, choose the right next action, and use the agent's full toolset effectively. + +## Tone + +Be warm, direct, and beginner-friendly. Ask whether the user is new to SEO and adapt the explanation depth. Avoid sounding like a course or a consultant deck. Make SEO feel doable. + +## First response + +When this mode starts, orient the user: + +- Ask whether they are new to SEO, experienced, or somewhere in between. +- Ask what site or project they are working on. +- Ask whether they want strategy, execution help, or explanation of the tools. +- Offer 2-4 concrete next options, not a long menu. + +Example: + +```text +I can coach you through this. Are you new to SEO, or do you mostly want help using OpenSEO faster? + +Good starting points: +- Set up SEO project context +- Find keyword opportunities +- Map keywords to pages +- Study a competitor +- Build link prospects for a page +``` + +## What each workflow does + +- `seo-project-setup`: sets up the workspace, verifies MCP, captures goals and positioning, and connects Google Search Console (or imports GSC exports). +- `keyword-research`: finds search opportunities from seed topics and evaluates volume, difficulty, CPC, intent, and SERPs. +- `keyword-clustering`: groups keywords by intent and maps clusters to existing or proposed pages. +- `competitive-landscape`: identifies who wins across a market and what content/backlink patterns are working. +- `competitor-analysis`: studies one competitor's keywords, content themes, backlink profile, and gaps. +- `link-prospecting`: finds likely link opportunities, discovers contact paths, and drafts outreach. + +## Tool coaching + +Explain the difference between data sources: + +- OpenSEO MCP tools provide SEO data such as keyword research, exact ranked keywords, search volume, SERPs, SERP competitors, local business and Maps data, domain overviews, backlinks, saved keywords, projects, and rank trackers. +- Google Search Console (when connected on the project's Integrations page) is the user's own first-party data — real clicks, impressions, CTR, and position. Read it live with `get_search_console_performance` instead of asking for CSV exports. It's free (no credits) and the best starting point for "what already ranks" and near-ranking opportunities. +- Web search can find current market context, recent pages, reviews, docs, social profiles, and contact paths outside OpenSEO. +- Browser/page scraping can extract page copy, headings, author names, contact links, schema, and content structure. +- Local files can preserve strategy, GSC CSVs, content briefs, crawls, prospect lists, and prior decisions over time. + +Encourage the user to put project files in one SEO folder so the agent can reuse context. + +## Coaching patterns + +When the user is unsure what to do: + +1. Clarify their goal. +2. Identify what data they already have. +3. Pick one workflow. +4. Explain what the agent will do. +5. Ask for only the next needed input. + +When the user asks for education: + +- Explain the concept plainly. +- Show how it maps to an OpenSEO workflow. +- Give a concrete example. +- Offer to run the next step. + +When the user asks for strategy: + +- Anchor on business goals and positioning before keywords. +- Separate SEO competitors from business competitors. +- Prioritize pages and topics that can plausibly create business value. +- Use SERPs to understand intent instead of guessing. +- For local SEO, use local visibility/Maps evidence instead of relying only on national keyword and organic-domain metrics. + +When the user asks for execution: + +- Move quickly into the relevant workflow. +- Use OpenSEO MCP data where available. +- Use web/search/browser tools for context that OpenSEO does not provide. +- Save or tag data only after confirmation. + +## Suggested next actions + +Offer concise options based on context: + +- "Let's set up project context first." +- "Let's research keywords from your seed topics." +- "Let's cluster your GSC/query export into page targets." +- "Let's map the competitive landscape before choosing pages." +- "Let's study one competitor." +- "Let's find link prospects for your best linkable asset." + +## Guardrails + +- Do not overload beginners with every SEO concept at once. +- Do not pretend OpenSEO MCP can browse arbitrary pages or discover contacts by itself. +- Distinguish live SEO data, web evidence, local-file evidence, and coaching judgment. +- Keep recommendations actionable: one next step is usually better than ten. diff --git a/.agents/skills/seo-project-setup/SKILL.md b/.agents/skills/seo-project-setup/SKILL.md new file mode 100644 index 000000000..9101f1479 --- /dev/null +++ b/.agents/skills/seo-project-setup/SKILL.md @@ -0,0 +1,165 @@ +--- +name: seo-project-setup +description: Set up a durable local SEO workspace with project context, notes, goals, positioning, preferences, MCP checks, and Search Console data intake. +--- + +# OpenSEO SEO Project Setup + +## Goal + +Help the user set up a local SEO workspace for one website or SEO project. The folder is where the agent saves notes, goals, exports, briefs, reports, preferences, and project context over time. This is a workspace and context setup workflow, not a full audit. + +## Tone + +Be friendly, practical, and structured. Ask questions in small batches. Explain why each item matters only when useful. Do not overwhelm a beginner with jargon. + +## Checklist + +### 1. Pick a working folder + +Suggest that the user choose or create a local folder for SEO work, for example: + +- `~/SEO//` +- `~/Documents/SEO//` +- A repo or workspace folder if SEO work should live beside website/content files + +Explain that keeping notes, exports, briefs, scraped pages, reports, and preferences in one folder helps the agent build context over time. Future SEO workflows can use that folder rather than starting from a blank conversation. + +Recommended starter structure: + +```text +seo-workspace/ + README.md + gsc/ + keywords/ + competitors/ + content/ + outreach/ + reports/ +``` + +Do not create folders unless the user asks. If file tools are available and the user asks, create a simple structure and a short `README.md` with the current goals, known sites, and user preferences for how the agent should approach SEO for this project. + +### 2. Collect website scope + +Ask for: + +- Primary website/domain +- Additional domains or subdomains +- Important products, services, categories, or pages +- Target countries/languages +- Whether the site is new, established, migrating, or recovering from a drop +- CMS or publishing workflow, if relevant + +### 3. Capture goals + +Ask the user what they want from SEO: + +- More qualified leads +- More signups/trials +- More ecommerce revenue +- More newsletter/audience growth +- More brand/category awareness +- Recovery from traffic loss +- Better ranking for specific pages + +Ask for success metrics and timeframe. If goals are vague, help turn them into measurable goals such as "increase non-branded organic signups" or "rank top 10 for 20 buying-intent terms." + +### 4. Capture positioning and strategy context + +Ask what research they have already done about the company, product, audience, and competitors. Request any notes, docs, customer interviews, positioning docs, pitch decks, landing pages, or strategy memos they can share. + +Probe for: + +- Who the product or site is for +- What pain it solves +- Why users choose it over alternatives +- Competitors and substitutes +- Strong opinions or positioning claims +- Best customers and bad-fit customers +- Existing content that already converts +- Topics they do not want to target + +If the user has not done this yet, offer to help research positioning using the company website, competitor pages, reviews, forums, and web search. + +### 5. Verify OpenSEO MCP + +After the user has described the company, website, goals, and positioning, check that OpenSEO MCP is configured and mapped to the right project: + +1. Use `whoami` if available. +2. Use `list_projects` to confirm the user can access projects. +3. Match the project to the website/domain they want to rank for. +4. If the project list is ambiguous, ask the user which project should be used. +5. If the MCP is unavailable, tell the user to connect OpenSEO MCP before continuing with live OpenSEO data. + +Do not run research tools just to test connectivity; `whoami` and `list_projects` are enough. + +### 6. Connect Google Search Console + +GSC is the richest first-party signal: existing impressions, near-ranking terms, cannibalization, and pages that already have search demand. + +**Preferred (hosted): connect it natively.** On the project's Integrations page, connect Google Search Console and pull live data with `get_search_console_performance`. Once connected, the agent reads it directly in `keyword-research` and `keyword-clustering` — no manual files to maintain. + +**Fallback (self-hosted, or if the user prefers files):** ask the user to export CSVs from Search Console into the SEO working folder. + +Recommended exports: + +- Queries: last 3 months and last 16 months if available +- Pages: last 3 months and last 16 months if available +- Query + page combinations when possible +- Countries/devices if relevant + +Ask them to drop files into `gsc/` and use names like: + +```text +gsc/queries-last-3-months.csv +gsc/pages-last-3-months.csv +gsc/queries-last-16-months.csv +gsc/pages-last-16-months.csv +``` + +### 7. Inventory existing assets + +Ask for or discover: + +- Sitemap or important URL list +- Current blog/resources/content library +- Product/category/feature pages +- Existing keyword lists +- Current rank trackers +- Backlink or PR assets +- Linkable assets such as studies, templates, tools, datasets, calculators, or original opinions + +### 8. Recommend first workflow + +After intake, recommend one next OpenSEO workflow: + +- `keyword-research`: when the user needs ideas from seed topics +- `keyword-clustering`: when they have keywords or GSC data to map to pages +- `competitive-landscape`: when the market is unclear +- `competitor-analysis`: when they know a competitor to study +- `link-prospecting`: when they have a linkable asset or target page + +## Output format + +Use a checklist with statuses: + +| Step | Status | Notes | Next action | +| ---- | ------ | ----- | ----------- | + +Then summarize: + +- Working folder +- OpenSEO MCP/project status +- Sites in scope +- Goals +- Known positioning +- Uploaded data/files +- Recommended next workflow + +## Guardrails + +- Keep setup lightweight. The user should feel oriented, not assigned homework. +- Do not pretend a GSC CSV has been uploaded unless you can see it, and do not claim Search Console is connected unless `get_search_console_performance` confirms it (it returns a "not connected" message otherwise). +- Keep project setup focused on setup and context unless the user asks for live research. +- If web search or scraping is used for positioning research, distinguish source evidence from inference. diff --git a/apps/marketing/src/app/blog/ag-grid-alternatives-free-react-data-grids/page.tsx b/apps/marketing/src/app/blog/ag-grid-alternatives-free-react-data-grids/page.tsx index e025ef626..e16a5be15 100644 --- a/apps/marketing/src/app/blog/ag-grid-alternatives-free-react-data-grids/page.tsx +++ b/apps/marketing/src/app/blog/ag-grid-alternatives-free-react-data-grids/page.tsx @@ -69,7 +69,7 @@ export default function AgGridAlternativesPage() { {/* Hero Section */}

- AG Grid Alternatives: 7 Best Free React Data Grids (2026) + AG Grid Alternatives 2026: 7 Best Free React Data Grids

diff --git a/apps/marketing/src/app/blog/ag-grid-pricing-license-breakdown-2026/page.tsx b/apps/marketing/src/app/blog/ag-grid-pricing-license-breakdown-2026/page.tsx index 9e941997c..727879bae 100644 --- a/apps/marketing/src/app/blog/ag-grid-pricing-license-breakdown-2026/page.tsx +++ b/apps/marketing/src/app/blog/ag-grid-pricing-license-breakdown-2026/page.tsx @@ -59,7 +59,7 @@ export default function AgGridPricingPage() { {/* Hero Section */}

- AG Grid License Cost & Pricing 2026: What You Actually Pay + AG Grid Pricing 2026: $999/Dev Enterprise License Cost

diff --git a/apps/marketing/src/app/blog/customizing-data-grids-styling-easy/page.tsx b/apps/marketing/src/app/blog/customizing-data-grids-styling-easy/page.tsx index 54e2662aa..0af224a02 100644 --- a/apps/marketing/src/app/blog/customizing-data-grids-styling-easy/page.tsx +++ b/apps/marketing/src/app/blog/customizing-data-grids-styling-easy/page.tsx @@ -195,8 +195,11 @@ export default function CustomizingDataGridsStylingEasyPage() {

- Simple Table works seamlessly with Tailwind CSS. You can apply utility classes - directly to customize the appearance: + Simple Table works with Tailwind CSS. Pass utility classes via{" "} + className{" "} + on the table, or return them from{" "} + getRowClass{" "} + for conditional row highlights:

@@ -206,10 +209,10 @@ export default function CustomizingDataGridsStylingEasyPage() {
   columns={headers}
   rows={data}
   className="rounded-xl shadow-2xl border-2 border-indigo-200"
-  headerClassName="bg-linear-to-r from-purple-500 to-pink-500 text-white"
-  rowClassName="hover:bg-blue-50 transition-colors duration-200"
-  cellClassName="border-b border-gray-100 px-4 py-3"
   theme="modern-light"
+  getRowClass={({ row }) =>
+    row.id === highlightedId ? "bg-blue-50" : undefined
+  }
 />`}
             
diff --git a/apps/marketing/src/app/blog/free-alternative-to-ag-grid/page.tsx b/apps/marketing/src/app/blog/free-alternative-to-ag-grid/page.tsx index 8da14987f..3f611985c 100644 --- a/apps/marketing/src/app/blog/free-alternative-to-ag-grid/page.tsx +++ b/apps/marketing/src/app/blog/free-alternative-to-ag-grid/page.tsx @@ -28,7 +28,8 @@ export const metadata: Metadata = { images: SEO_STRINGS.site.ogImage.url, }, alternates: { - canonical: "/blog/free-alternative-to-ag-grid", + // Consolidate generic "AG Grid alternative" intent onto the React roundup. + canonical: "/blog/ag-grid-alternatives-free-react-data-grids", }, }; @@ -56,9 +57,18 @@ export default function FreeAlternativeToAgGridPage() {
-

+

{freeAlternativeToAgGridPost.description}

+

+ Looking for a full list of options?{" "} + + Compare 7 free AG Grid alternatives for React → + +

{/* Main Content */} diff --git a/apps/marketing/src/app/blog/free-react-tables-accessibility-keyboard-navigation/page.tsx b/apps/marketing/src/app/blog/free-react-tables-accessibility-keyboard-navigation/page.tsx index d83ff67b4..7a6704118 100644 --- a/apps/marketing/src/app/blog/free-react-tables-accessibility-keyboard-navigation/page.tsx +++ b/apps/marketing/src/app/blog/free-react-tables-accessibility-keyboard-navigation/page.tsx @@ -51,7 +51,7 @@ export default function AccessibilityComparisonPage() { {/* Hero Section */}

- Free React Tables: Accessibility & Keyboard Navigation Comparison + React Table Accessibility & Keyboard Navigation (2026)

diff --git a/apps/marketing/src/app/blog/mantine-datatable-vs-simple-table/page.tsx b/apps/marketing/src/app/blog/mantine-datatable-vs-simple-table/page.tsx index 63c6bd5ea..fed5fd7dd 100644 --- a/apps/marketing/src/app/blog/mantine-datatable-vs-simple-table/page.tsx +++ b/apps/marketing/src/app/blog/mantine-datatable-vs-simple-table/page.tsx @@ -51,7 +51,7 @@ export default function MantineDatatableVsSimpleTablePage() { {/* Hero Section */}

- Mantine DataTable vs Simple Table: Mantine UI Integration vs Standalone Grid + Mantine DataTable Alternative (2026): Features & Bundle Size

diff --git a/apps/marketing/src/app/comparisons/simple-table-vs-handsontable/page.tsx b/apps/marketing/src/app/comparisons/simple-table-vs-handsontable/page.tsx index 80f45ae33..6ad72d0ab 100644 --- a/apps/marketing/src/app/comparisons/simple-table-vs-handsontable/page.tsx +++ b/apps/marketing/src/app/comparisons/simple-table-vs-handsontable/page.tsx @@ -21,7 +21,8 @@ export const metadata: Metadata = { images: SEO_STRINGS.site.ogImage.url, }, alternates: { - canonical: "/comparisons/simple-table-vs-handsontable", + // Prefer the Handsontable alternatives roundup for "alternative" intent. + canonical: "/blog/handsontable-alternatives-free-react", }, }; diff --git a/apps/marketing/src/app/comparisons/simple-table-vs-material-react/page.tsx b/apps/marketing/src/app/comparisons/simple-table-vs-material-react/page.tsx index 3ba968f03..cc5821faa 100644 --- a/apps/marketing/src/app/comparisons/simple-table-vs-material-react/page.tsx +++ b/apps/marketing/src/app/comparisons/simple-table-vs-material-react/page.tsx @@ -21,7 +21,8 @@ export const metadata: Metadata = { images: SEO_STRINGS.site.ogImage.url, }, alternates: { - canonical: "/comparisons/simple-table-vs-material-react", + // Prefer the deeper blog comparison for Material React Table intent. + canonical: "/blog/material-react-table-vs-simple-table", }, }; diff --git a/apps/marketing/src/app/comparisons/simple-table-vs-primevue-datatable/page.tsx b/apps/marketing/src/app/comparisons/simple-table-vs-primevue-datatable/page.tsx index 7f43ddc50..96fb3c7cf 100644 --- a/apps/marketing/src/app/comparisons/simple-table-vs-primevue-datatable/page.tsx +++ b/apps/marketing/src/app/comparisons/simple-table-vs-primevue-datatable/page.tsx @@ -3,10 +3,10 @@ import { faBolt, faDollarSign, faPalette, faTrophy } from "@fortawesome/free-sol import FrameworkVsCompetitorLayout from "@/components/comparisons/FrameworkVsCompetitorLayout"; import { SEO_STRINGS } from "@/constants/strings/seo"; -const TITLE = "Simple Table vs PrimeVue DataTable: Vue 3 Data Grid Comparison"; +const TITLE = "PrimeVue DataTable Alternative: Lightweight Vue 3 Grid"; const DESCRIPTION = - "Compare @simple-table/vue against PrimeVue DataTable for Vue 3 / Nuxt: features, virtualization, bundle size, theming, and migration path. Pick the right Vue data grid in 2026."; -const CANONICAL = "/comparisons/simple-table-vs-primevue-datatable"; + "Looking for a PrimeVue DataTable alternative? Compare @simple-table/vue vs PrimeVue DataTable for Vue 3 / Nuxt: features, virtualization, bundle size, and theming."; +const CANONICAL = "/blog/simple-table-vs-primevue-datatable"; export const metadata: Metadata = { title: TITLE, diff --git a/apps/marketing/src/components/pages/HomeContent.tsx b/apps/marketing/src/components/pages/HomeContent.tsx index 8dde87eb6..8dfa9c73c 100644 --- a/apps/marketing/src/components/pages/HomeContent.tsx +++ b/apps/marketing/src/components/pages/HomeContent.tsx @@ -15,6 +15,7 @@ import { import { faGithub } from "@fortawesome/free-brands-svg-icons"; import { motion } from "framer-motion"; import { useRouter } from "next/navigation"; +import Link from "next/link"; import { useGitHubStars } from "@/hooks/useGitHubStars"; import { Suspense, useState } from "react"; import dynamic from "next/dynamic"; @@ -145,7 +146,7 @@ export default function HomeContent() { + + Comparing options? See{" "} + + free AG Grid alternatives + + {" · "} + + best JavaScript table libraries + + . + + {
+
+ +
+
diff --git a/apps/marketing/src/components/pages/docs-pages/ThemesContent.tsx b/apps/marketing/src/components/pages/docs-pages/ThemesContent.tsx index 4c99c1662..74c5fdce6 100644 --- a/apps/marketing/src/components/pages/docs-pages/ThemesContent.tsx +++ b/apps/marketing/src/components/pages/docs-pages/ThemesContent.tsx @@ -18,6 +18,7 @@ import { useThemeContext } from "@/providers/ThemeProvider"; import { themeSnippets, themeStylingFlagsSnippets, + getRowClassSnippets, type CodeByFramework, } from "@/constants/docsSnippets"; @@ -67,6 +68,20 @@ const THEME_PATTERNS: ThemePattern[] = [ ), codeByFramework: themeStylingFlagsSnippets(), }, + { + title: "Conditional row classes", + body: ( + <> + Use{" "} + getRowClass to + highlight rows by data (e.g. a search jump target). Classes apply to each body{" "} + .st-cell — style + with selectors like{" "} + .jump-row. + + ), + codeByFramework: getRowClassSnippets(), + }, ]; const THEME_PROPS: PropInfo[] = [ @@ -96,6 +111,16 @@ const THEME_PROPS: PropInfo[] = [ type: "boolean", example: `oddEvenRowBackground={true}`, }, + { + key: "getRowClass", + name: "getRowClass", + required: false, + description: + "Return CSS class name(s) for a row. Applied to each body cell — style with `.st-cell.yourClass`.", + type: "(params: GetRowClassParams) => string | string[] | undefined | null", + link: "/docs/api-reference#get-row-class-params", + example: `getRowClass={({ row }) => row.id === jumpId ? "jump-row" : undefined}`, + }, { key: "oddColumnBackground", name: "oddColumnBackground", diff --git a/apps/marketing/src/constants/blogPosts.ts b/apps/marketing/src/constants/blogPosts.ts index 7b372a83c..0f49f3166 100644 --- a/apps/marketing/src/constants/blogPosts.ts +++ b/apps/marketing/src/constants/blogPosts.ts @@ -81,13 +81,13 @@ export const nestedHeadersReactTablesPost: BlogPostMetadata = { }; export const bestFreeReactDataGridPost: BlogPostMetadata = { - title: "Best Free & Open-Source React Data Grids (2026)", + title: "Best Free React Data Grid 2026 (No License Fee)", description: - "The best 100% free and open-source React data grids in 2026 — no per-seat license, no paywalled features. Compared on bundle size, features, and licensing.", + "Best free React data grid in 2026 with no per-seat license and no paywalled features. Compared on bundle size, features, and licensing vs AG Grid and TanStack.", slug: "best-free-react-data-grid-2026", tags: ["react", "comparison", "alternatives", "best-practices"], createdAt: "2025-06-21", - updatedAt: "2026-06-22", + updatedAt: "2026-07-29", }; export const customizingReactTableLookPost: BlogPostMetadata = { @@ -142,13 +142,13 @@ export const replicatingGojiberryUIPost: BlogPostMetadata = { }; export const agGridAlternativesPost: BlogPostMetadata = { - title: "AG Grid Alternatives: 7 Best Free React Data Grids (2026)", + title: "AG Grid Alternatives 2026: 7 Best Free React Data Grids", description: - "Looking for an AG Grid alternative? Discover 7 powerful, free React data grids that deliver enterprise features without the enterprise price tag or vendor lock-in.", + "Looking for AG Grid alternatives? Compare 7 free React data grids for 2026 — features, pricing, and bundle size without the enterprise license.", slug: "ag-grid-alternatives-free-react-data-grids", tags: ["react", "alternatives", "comparison", "ag-grid"], createdAt: "2025-11-15", - updatedAt: "2026-06-22", + updatedAt: "2026-07-29", }; export const bundleSizeComparisonPost: BlogPostMetadata = { @@ -182,13 +182,13 @@ export const handsontableAlternativesPost: BlogPostMetadata = { }; export const agGridPricing2026Post: BlogPostMetadata = { - title: "AG Grid License Cost & Pricing 2026 (Per Developer)", + title: "AG Grid Pricing 2026: $999/Dev Enterprise License Cost", description: - "AG Grid Enterprise license is $999/developer/year. Full 2026 license cost breakdown: Community vs Enterprise, pricing tiers, renewals, real team costs, and free alternatives.", + "AG Grid Enterprise is $999 per developer per year. 2026 pricing breakdown: Community (MIT) vs Enterprise, license cost, renewals, team totals, and free alternatives.", slug: "ag-grid-pricing-license-breakdown-2026", tags: ["react", "ag-grid", "pricing", "comparison"], createdAt: "2025-11-22", - updatedAt: "2026-07-12", + updatedAt: "2026-07-29", }; export const tanstackVsSimpleTablePost: BlogPostMetadata = { @@ -201,13 +201,13 @@ export const tanstackVsSimpleTablePost: BlogPostMetadata = { }; export const mitLicensedAccessibilityPost: BlogPostMetadata = { - title: "Free React Tables: Accessibility & Keyboard Navigation Comparison", + title: "React Table Accessibility & Keyboard Navigation (2026)", description: - "Which free React data grids actually work for keyboard users and screen readers? Comprehensive WCAG 2.1 comparison of accessibility features across free React table libraries.", + "React table accessibility guide: keyboard navigation, screen readers, and WCAG 2.1 across free React data grids including Simple Table and TanStack Table.", slug: "free-react-tables-accessibility-keyboard-navigation", tags: ["react", "accessibility"], createdAt: "2025-11-22", - updatedAt: "2025-11-22", + updatedAt: "2026-07-29", }; export const columnPinningTutorialPost: BlogPostMetadata = { @@ -379,13 +379,13 @@ export const kaTableVsSimpleTablePost: BlogPostMetadata = { }; export const mantineDatatableVsSimpleTablePost: BlogPostMetadata = { - title: "Mantine DataTable vs Simple Table (2026 Comparison)", + title: "Mantine DataTable Alternative (2026): Features & Bundle Size", description: - "Mantine DataTable vs Simple Table: compare bundle size (95KB vs 42KB), the Mantine UI dependency, features, and which React grid fits your project in 2026.", + "Looking for a Mantine DataTable alternative? Compare bundle size, the Mantine UI dependency, features, and which React grid fits your project in 2026.", slug: "mantine-datatable-vs-simple-table", tags: ["react", "comparison", "alternatives"], createdAt: "2026-02-05", - updatedAt: "2026-06-22", + updatedAt: "2026-07-29", }; export const muiDatatablesVsSimpleTablePost: BlogPostMetadata = { @@ -557,13 +557,13 @@ export const simpleTableVsKendoGridAngularPost: BlogPostMetadata = { }; export const agGridAlternativesAngularPost: BlogPostMetadata = { - title: "AG Grid Alternatives: Best Free Angular Data Grids in 2026", + title: "Angular AG Grid Alternatives 2026: Best Free Data Grids", description: "Looking for AG Grid Angular alternatives? Compare Simple Table for Angular, ngx-datatable, PrimeNG Table, Angular Material mat-table, and others. Free, signals-friendly options without enterprise licensing.", slug: "ag-grid-alternatives-free-angular-data-grids-2026", tags: ["angular", "alternatives", "comparison", "ag-grid"], createdAt: "2026-04-26", - updatedAt: "2026-04-26", + updatedAt: "2026-07-29", }; // Vue @@ -578,13 +578,13 @@ export const simpleTableVsVuetifyDataTablePost: BlogPostMetadata = { }; export const simpleTableVsPrimeVueDatatablePost: BlogPostMetadata = { - title: "Simple Table vs PrimeVue DataTable: Lightweight Vue 3 Data Grid", + title: "PrimeVue DataTable Alternative: Lightweight Vue 3 Grid", description: - "PrimeVue DataTable bundles in PrimeVue's runtime + theme + PrimeIcons. Simple Table for Vue is a focused source-available alternative for Vue 3 / Nuxt with virtualization, pinning, grouping, and editing in one library.", + "Looking for a PrimeVue DataTable alternative? Compare runtime/theme cost vs Simple Table for Vue — virtualization, pinning, grouping, and editing without the PrimeVue bundle tax.", slug: "simple-table-vs-primevue-datatable", tags: ["vue", "comparison", "alternatives"], createdAt: "2026-04-26", - updatedAt: "2026-04-26", + updatedAt: "2026-07-29", }; export const simpleTableVsVueGoodTablePost: BlogPostMetadata = { @@ -742,13 +742,13 @@ export const simpleTableVsHandsontableVanillaPost: BlogPostMetadata = { }; export const bestVanillaJsDataGridPost: BlogPostMetadata = { - title: "Best Vanilla JS Data Grid 2026: Free Options Compared", + title: "Best JavaScript Table Library 2026: Vanilla JS Data Grids", description: - "The best free vanilla JS & TypeScript data grids in 2026 — simple-table-core, Tabulator, Jspreadsheet, Grid.js, Handsontable — compared on size, TS, and license.", + "Best JavaScript table library and vanilla JS/TypeScript data grids in 2026 — simple-table-core, Tabulator, Jspreadsheet, Grid.js, Handsontable — compared on size, TS, and license.", slug: "best-vanilla-js-data-grid-2026", tags: ["vanilla", "comparison", "alternatives", "best-practices"], createdAt: "2026-04-26", - updatedAt: "2026-06-22", + updatedAt: "2026-07-29", }; // Tier 2 — framework-mirrored tutorial posts (column resizing, row selection, diff --git a/apps/marketing/src/constants/changelog.ts b/apps/marketing/src/constants/changelog.ts index 52f53d800..558e917e8 100644 --- a/apps/marketing/src/constants/changelog.ts +++ b/apps/marketing/src/constants/changelog.ts @@ -11,6 +11,40 @@ export interface ChangelogEntry { }[]; } +export const v4_1_1: ChangelogEntry = { + version: "4.1.1", + date: "2026-07-29", + title: "getRowClass, row grouping alignment, and column editor click fix", + description: + "Add getRowClass for data-driven row styling, restore caret-space alignment for non-expandable rows at an expandable depth, and keep column editor checkboxes responsive on heavy nested tables.", + changes: [ + { + type: "feature", + description: + "New getRowClass callback for data-driven row styling (e.g. search jump, compare highlights). Classes apply to each body cell — see Themes docs.", + link: "/docs/themes", + }, + { + type: "bugfix", + description: + "Leaf and otherwise non-expandable row-group siblings render an invisible expand-icon placeholder (same icon, opacity 0) so labels line up with expandable rows — restoring v2 alignment.", + link: "/docs/row-grouping", + }, + { + type: "bugfix", + description: + "Column editor visibility toggles sync checkbox state in place when the editor list structure is unchanged, so nested checkboxes on heavy tables no longer need multiple clicks after setHeaders re-renders the table.", + link: "/docs/column-visibility", + }, + { + type: "bugfix", + description: + "Rapid column hide/show no longer stacks horizontal accordion grow/shrink (especially in pinned sections); interrupting toggles cancel in-flight ghosts and snap to the latest layout.", + link: "/docs/column-visibility", + }, + ], +}; + export const v4_1_0: ChangelogEntry = { version: "4.1.0", date: "2026-07-28", @@ -2468,6 +2502,7 @@ export const v1_4_4: ChangelogEntry = { // Array of all changelog entries (newest first) export const CHANGELOG_ENTRIES: ChangelogEntry[] = [ + v4_1_1, v4_1_0, v4_0_9, v4_0_8, diff --git a/apps/marketing/src/constants/docsSnippets.ts b/apps/marketing/src/constants/docsSnippets.ts index 325ad7208..1510c626f 100644 --- a/apps/marketing/src/constants/docsSnippets.ts +++ b/apps/marketing/src/constants/docsSnippets.ts @@ -1745,6 +1745,73 @@ export function themeStylingFlagsSnippets(): Record { }; } +/** Highlight specific rows with getRowClass (classes apply to each body cell). */ +export function getRowClassSnippets(): Record { + return { + react: `const [jumpId, setJumpId] = useState(null); + + + jumpId && row.id === jumpId ? "jump-row" : undefined + } +/> + +/* CSS */ +.jump-row { background-color: #fef3c7; }`, + solid: ` + jumpId() && row.id === jumpId() ? "jump-row" : undefined + } +/> + +/* CSS */ +.jump-row { background-color: #fef3c7; }`, + vue: ` + +/* CSS */ +.jump-row { background-color: #fef3c7; }`, + angular: ` + +/* getRowClass = ({ row }) => jumpId && row.id === jumpId ? 'jump-row' : undefined */ + +/* CSS */ +.jump-row { background-color: #fef3c7; }`, + svelte: ` + jumpId && row.id === jumpId ? "jump-row" : undefined + } +/> + +/* CSS */ +.jump-row { background-color: #fef3c7; }`, + vanilla: `new SimpleTableVanilla(container, { + columns, + rows, + getRowClass: ({ row }) => + jumpId && row.id === jumpId ? "jump-row" : undefined, +}); + +/* CSS */ +.jump-row { background-color: #fef3c7; }`, + }; +} + /** Call TableAPI.exportToCSV from a button / handler. */ export function exportToCSVSnippets(): Record { return { diff --git a/apps/marketing/src/constants/propDefinitions/callbackProps.ts b/apps/marketing/src/constants/propDefinitions/callbackProps.ts index fc170abdb..25c7cb2a2 100644 --- a/apps/marketing/src/constants/propDefinitions/callbackProps.ts +++ b/apps/marketing/src/constants/propDefinitions/callbackProps.ts @@ -704,3 +704,38 @@ if (data.length === 0) { props.setEmpty(false);`, }, ]; + +export const GET_ROW_CLASS_PARAMS: PropInfo[] = [ + { + key: "row", + name: "row", + required: true, + description: "The row data object for the row being styled.", + type: "TData", + example: `params.row // { id: 3, name: "Carol" }`, + }, + { + key: "rowId", + name: "rowId", + required: true, + description: "Table identity string for the row. Prefer matching on `row` for business ids.", + type: "string", + example: `params.rowId // "2-3"`, + }, + { + key: "position", + name: "position", + required: true, + description: "0-based index of the row in the table.", + type: "number", + example: `params.position // 2`, + }, + { + key: "depth", + name: "depth", + required: true, + description: "Nesting depth of the row (0 for top-level rows).", + type: "number", + example: `params.depth // 0`, + }, +]; diff --git a/apps/marketing/src/constants/propDefinitions/index.ts b/apps/marketing/src/constants/propDefinitions/index.ts index f220b88bd..0d6717c70 100644 --- a/apps/marketing/src/constants/propDefinitions/index.ts +++ b/apps/marketing/src/constants/propDefinitions/index.ts @@ -21,6 +21,7 @@ export { EXPORT_VALUE_PROPS, CELL_CLICK_PROPS, ON_ROW_GROUP_EXPAND_PROPS, + GET_ROW_CLASS_PARAMS, HEADER_RENDERER_PROPS, COLUMN_EDITOR_ROW_RENDERER_PROPS, } from "./callbackProps"; diff --git a/apps/marketing/src/constants/propDefinitions/simpleTableProps.ts b/apps/marketing/src/constants/propDefinitions/simpleTableProps.ts index 3fda62dc7..79c3691bf 100644 --- a/apps/marketing/src/constants/propDefinitions/simpleTableProps.ts +++ b/apps/marketing/src/constants/propDefinitions/simpleTableProps.ts @@ -670,6 +670,30 @@ useEffect(() => { type: "boolean", example: `oddEvenRowBackground={true}`, }, + { + key: "getRowClass", + name: "getRowClass", + required: false, + description: + "Return CSS class name(s) for a row. Applied to each body cell — style with `.st-cell.yourClass`. Return a string, string[], or null/undefined for default styling.", + type: "(params: GetRowClassParams) => string | string[] | undefined | null", + link: "#get-row-class-params", + example: `// artists items have an \`id\` field on the data itself +const [jumpArtistId, setJumpArtistId] = useState(null); + + + jumpArtistId && row.id === jumpArtistId ? "jump-row" : undefined + } +/> + +/* CSS */ +.jump-row { + background-color: #fef3c7; +}`, + }, { key: "enableRowSelection", name: "enableRowSelection", diff --git a/apps/marketing/src/constants/strings/seo.ts b/apps/marketing/src/constants/strings/seo.ts index 574034fac..b9bae508d 100644 --- a/apps/marketing/src/constants/strings/seo.ts +++ b/apps/marketing/src/constants/strings/seo.ts @@ -1,5 +1,6 @@ import { freeAlternativeToAgGridPost, + mitLicensedAccessibilityPost, handlingOneMillionRowsPost, customizingDataGridsStylingEasyPost, nestedHeadersReactTablesPost, @@ -10,6 +11,7 @@ import { replicatingGojiberryUIPost, tanstackVsAgGridPost, handsontableAlternativesPost, + agGridAlternativesPost, agGridPricing2026Post, tanstackVsSimpleTablePost, bundleSizeComparisonPost, @@ -62,10 +64,10 @@ export const SEO_STRINGS = { site: { url: "https://www.simple-table.com", name: "Simple Table", - title: "Simple Table: JavaScript Data Grid & Table Library | Free Plan Available", - description: `Simple Table: A ${SIMPLE_TABLE_INFO.bundleSizeMinGzip} JavaScript data grid for React, Vue, Angular, Svelte, Solid, and vanilla JavaScript or TypeScript (simple-table-core). Build responsive datagrids with sorting, filtering, editing, and full TypeScript support. Free plan available! The lightweight alternative to AG Grid, TanStack Table, and Handsontable.`, + title: "Simple Table: Multi-Framework JavaScript Data Grid | Free Plan", + description: `Simple Table is a ${SIMPLE_TABLE_INFO.bundleSizeMinGzip} multi-framework data grid with official adapters for React, Vue, Angular, Svelte, Solid, and vanilla TypeScript (simple-table-core). Sorting, filtering, editing, and full TypeScript support. Free plan available — a lightweight alternative to AG Grid and TanStack Table.`, defaultKeywords: - "simple-table, simple-table-core, @simple-table/react, @simple-table/vue, @simple-table/angular, @simple-table/svelte, @simple-table/solid, javascript data grid, typescript data grid, typescript table, react data grid, react table, vue 3 datagrid, vue data grid, nuxt data table, angular data grid, svelte data grid, sveltekit data grid, solidjs table, solid table, vanilla js data grid, vanilla typescript table, multi-framework data grid, data-grid, datagrid, data table, table, grid, spreadsheet, ag grid alternative, handsontable alternative, tanstack table alternative, free data grid, lightweight data grid, best data grid library", + "simple-table, simple-table-core, @simple-table/react, @simple-table/vue, @simple-table/angular, @simple-table/svelte, @simple-table/solid, react data grid, vue data grid, angular data grid, svelte data grid, solidjs table, vanilla js data grid, multi-framework data grid, ag grid alternative, handsontable alternative, tanstack table alternative, free data grid, lightweight data grid", creator: "@simpletable", ogImage: { url: "https://www.simple-table.com/og-image.png", @@ -75,10 +77,10 @@ export const SEO_STRINGS = { }, }, home: { - title: "Simple Table: JavaScript Data Grid & Table Library | Free Plan Available", - description: `Simple Table: A ${SIMPLE_TABLE_INFO.bundleSizeMinGzip} JavaScript data grid for React, Vue, Angular, Svelte, Solid, and vanilla JavaScript or TypeScript (simple-table-core). Build responsive datagrids with sorting, filtering, editing, and full TypeScript support. Free plan available! The lightweight alternative to AG Grid, TanStack Table, and Handsontable.`, + title: "Simple Table: Multi-Framework JavaScript Data Grid | Free Plan", + description: `Simple Table is a ${SIMPLE_TABLE_INFO.bundleSizeMinGzip} multi-framework data grid with official adapters for React, Vue, Angular, Svelte, Solid, and vanilla TypeScript (simple-table-core). Sorting, filtering, editing, and full TypeScript support. Free plan available — a lightweight alternative to AG Grid and TanStack Table.`, keywords: - "simple-table, simple-table-core, @simple-table/react, @simple-table/vue, @simple-table/angular, @simple-table/svelte, @simple-table/solid, javascript data grid, typescript data grid, typescript table, react data grid, react table, vue 3 datagrid, vue data grid, nuxt data table, angular data grid, svelte data grid, sveltekit data grid, solidjs table, solid table, vanilla js data grid, multi-framework data grid, data-grid, datagrid, data table, table, grid, spreadsheet, ag grid alternative, handsontable alternative, tanstack table alternative, free data grid, lightweight data grid, best data grid library", + "simple-table, simple-table-core, @simple-table/react, @simple-table/vue, @simple-table/angular, @simple-table/svelte, @simple-table/solid, react data grid, vue data grid, angular data grid, svelte data grid, solidjs table, vanilla js data grid, multi-framework data grid, ag grid alternative, handsontable alternative, tanstack table alternative, free data grid, lightweight data grid", }, blog: { title: "Data Grid Blog: Tutorials, Comparisons & Best Practices", @@ -165,6 +167,8 @@ export const SEO_STRINGS = { description: bestFreeReactDataGridPost.description, keywords: [ "best free react data grid", + "best react data grid 2026", + "best free react data grid 2026", "react table 2026", "free data grid comparison", "simple table vs ag grid", @@ -172,6 +176,7 @@ export const SEO_STRINGS = { "typescript data grid", "lightweight table", "open source react table", + "no license fee react data grid", ], }, freeAlternativeToAgGrid: { @@ -247,13 +252,12 @@ export const SEO_STRINGS = { ], }, agGridAlternatives: { - title: "Best AG Grid Alternatives 2025: Free & Affordable React Data Grids", - description: - "Looking for AG Grid alternatives? Compare the best free and affordable React data grid libraries in 2025. Simple Table, TanStack Table, Tabulator, and more. See features, pricing, and bundle sizes.", + title: agGridAlternativesPost.title, + description: agGridAlternativesPost.description, keywords: [ "ag grid alternatives", "ag grid alternative", - "ag grid alternatives 2025", + "ag grid alternatives 2026", "ag grid alternative free", "ag grid open source alternatives", "free react data grid", @@ -335,6 +339,8 @@ export const SEO_STRINGS = { "ag grid license", "ag grid licence", "ag grid community license", + "ag grid community license mit", + "ag grid community mit license", "ag grid enterprise license", "ag grid pricing", "ag grid cost", @@ -344,6 +350,9 @@ export const SEO_STRINGS = { "ag grid license pricing", "ag grid enterprise pricing", "ag grid price", + "aggrid pricing", + "ag-grid pricing", + "ag-grid license", "how much does ag grid cost", "ag grid pricing per developer", "ag grid subscription cost", @@ -374,9 +383,8 @@ export const SEO_STRINGS = { ], }, mitLicensedAccessibility: { - title: "React Table Keyboard Navigation & Accessibility | WCAG 2.1 Compliant", - description: - "Build accessible React tables with keyboard navigation, screen reader support, and WCAG 2.1 compliance. Compare accessibility features in Simple Table, TanStack Table, and other free React grids.", + title: mitLicensedAccessibilityPost.title, + description: mitLicensedAccessibilityPost.description, keywords: [ "react table accessibility", "react table keyboard navigation", diff --git a/packages/angular/package.json b/packages/angular/package.json index ee6b1f398..fc6e468cd 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/angular", - "version": "4.1.0", + "version": "4.1.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/index.d.ts", diff --git a/packages/angular/src/index.ts b/packages/angular/src/index.ts index c54acbc50..5affd5a43 100644 --- a/packages/angular/src/index.ts +++ b/packages/angular/src/index.ts @@ -79,6 +79,8 @@ export type { FooterPosition, GetRowId, GetRowIdParams, + GetRowClass, + GetRowClassParams, HeaderDropdown, HeaderDropdownProps, HeaderRenderer, diff --git a/packages/core/package.json b/packages/core/package.json index 01dfe09fa..e56a78757 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "simple-table-core", - "version": "4.1.0", + "version": "4.1.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/index.d.ts", diff --git a/packages/core/src/core/SimpleTableVanilla.ts b/packages/core/src/core/SimpleTableVanilla.ts index eab6aee24..398593874 100644 --- a/packages/core/src/core/SimpleTableVanilla.ts +++ b/packages/core/src/core/SimpleTableVanilla.ts @@ -499,6 +499,39 @@ export class SimpleTableVanilla { if (!this.animationCoordinator.isEnabled()) return; if (axis === null) return; if (axis === "vertical" && (this.getEffectiveRowGrouping()?.length ?? 0) > 0) return; + + // Apply to `.simple-table-root` (not the user-supplied outer container) so + // the CSS scope and the test/marketing surface match the documented root. + const root = this.domManager.getElements()?.rootElement ?? this.container; + + // Rapid hide/show (column editor spam-clicks, especially pinned columns) + // used to restart accordion grow/shrink while prior ghosts were still + // in flight — dropping stale shrink-outs mid-transition looks broken. + // Interrupt: cancel in-flight accordion DOM and snap to the latest layout. + const interrupting = + axis === "horizontal" && + (this.accordionCleanupTimerId !== null || + root.classList.contains(ACCORDION_ANIMATION_CLASS)); + if (interrupting) { + this.animationCoordinator.cancel(); + if (this.accordionCleanupTimerId !== null) { + window.clearTimeout(this.accordionCleanupTimerId); + this.accordionCleanupTimerId = null; + } + root.classList.remove(ACCORDION_ANIMATION_CLASS); + root.style.removeProperty(ACCORDION_DURATION_VAR); + root.style.removeProperty(ACCORDION_EASING_VAR); + this.pendingAccordionAxis = null; + this.captureAnimationSnapshot(); + // Keep the cleanup timer alive (without the CSS class) so further rapid + // toggles also snap instead of restarting grow/shrink every other click. + const duration = this.animationCoordinator.getDuration(); + this.accordionCleanupTimerId = window.setTimeout(() => { + this.accordionCleanupTimerId = null; + }, duration + ACCORDION_CLEANUP_BUFFER_MS); + return; + } + this.captureAnimationSnapshot(); // Record which columns are renderable in the current (pre-change) layout so // the grow-from-zero gate can tell a freshly-expanded column apart from one @@ -511,9 +544,6 @@ export class SimpleTableVanilla { const duration = this.animationCoordinator.getDuration(); const easing = this.animationCoordinator.getEasing(); - // Apply to `.simple-table-root` (not the user-supplied outer container) so - // the CSS scope and the test/marketing surface match the documented root. - const root = this.domManager.getElements()?.rootElement ?? this.container; root.style.setProperty(ACCORDION_DURATION_VAR, `${duration}ms`); root.style.setProperty(ACCORDION_EASING_VAR, easing); root.classList.add(ACCORDION_ANIMATION_CLASS); diff --git a/packages/core/src/core/rendering/SectionRenderer.ts b/packages/core/src/core/rendering/SectionRenderer.ts index 1f5fd7da4..0230e121a 100644 --- a/packages/core/src/core/rendering/SectionRenderer.ts +++ b/packages/core/src/core/rendering/SectionRenderer.ts @@ -27,6 +27,20 @@ import { } from "../../utils/nestedGridRowRenderer"; import { createStateRow, type StateRowRenderContext } from "../../utils/stateRowRenderer"; +/** Stable ids for callback refs so context cache invalidates when identity changes. */ +const callbackIdentityIds = new WeakMap(); +let nextCallbackIdentityId = 1; + +const callbackIdentityKey = (fn: unknown): string => { + if (typeof fn !== "function") return "none"; + let id = callbackIdentityIds.get(fn); + if (id === undefined) { + id = nextCallbackIdentityId++; + callbackIdentityIds.set(fn, id); + } + return String(id); +}; + export interface HeaderSectionParams { headers: ColumnDef[]; collapsedHeaders: Set; @@ -1083,6 +1097,11 @@ export class SectionRenderer { ]; let hash = keys.map((k) => `${k}:${context[k]}`).join("|"); + // Callback identity (not toString) so a new `getRowClass` closure — e.g. + // React useCallback deps changing for a jump/highlight target — invalidates + // the cached body context and refreshes cell classes. + hash += `|getRowClass:${callbackIdentityKey(context.getRowClass)}`; + // Include heightOffsets so contexts captured for body sections invalidate // when nested tables expand/collapse above an existing nested row. Without // this, the cached context's stale heightOffsets is reused and rows below diff --git a/packages/core/src/core/rendering/TableRenderer.ts b/packages/core/src/core/rendering/TableRenderer.ts index 8d448a7d7..bd40aacb6 100644 --- a/packages/core/src/core/rendering/TableRenderer.ts +++ b/packages/core/src/core/rendering/TableRenderer.ts @@ -548,6 +548,7 @@ export class TableRenderer { hoverRowBackground: deps.config.hoverRowBackground ?? true, hoverScopeId: deps.hoverScopeId, oddEvenRowBackground: deps.config.oddEvenRowBackground, + getRowClass: deps.config.getRowClass, rowGrouping: deps.config.rowGrouping, headers: deps.effectiveHeaders, rowHeaderAccessor, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 55d1788d6..8890fa4ce 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -89,6 +89,7 @@ import type { CustomTheme, CustomThemeProps } from "./types/CustomTheme"; import type { ColumnEditorConfig, ColumnEditorSearchFunction } from "./types/ColumnEditorConfig"; import type { IconsConfig } from "./types/IconsConfig"; import type { GetRowId, GetRowIdParams } from "./types/GetRowId"; +import type { GetRowClass, GetRowClassParams } from "./types/GetRowClass"; import type { SimpleTableConfig } from "./types/SimpleTableConfig"; import type { SimpleTableProps } from "./types/SimpleTableProps"; import type { AnimationsConfig } from "./types/AnimationsConfig"; @@ -171,6 +172,8 @@ export type { FooterPosition, GetRowId, GetRowIdParams, + GetRowClass, + GetRowClassParams, IconsConfig, LoadingStateRenderer, LoadingStateRendererProps, diff --git a/packages/core/src/types/GetRowClass.ts b/packages/core/src/types/GetRowClass.ts new file mode 100644 index 000000000..972ea21b8 --- /dev/null +++ b/packages/core/src/types/GetRowClass.ts @@ -0,0 +1,19 @@ +import Row from "./Row"; +import type { RowData } from "./Row"; + +export interface GetRowClassParams { + row: TData; + /** Table identity string for the row. Prefer matching on `row` for business ids. */ + rowId: string; + /** 0-based index of the row in the table. */ + position: number; + depth: number; +} + +/** + * Return CSS class name(s) for the row, or `undefined` / `null` for default styling. + * Classes are applied to each body cell — style with `.st-cell.yourClass`. + */ +export type GetRowClass = ( + params: GetRowClassParams +) => string | string[] | undefined | null; diff --git a/packages/core/src/types/SimpleTableConfig.ts b/packages/core/src/types/SimpleTableConfig.ts index e76570c87..387c33abc 100644 --- a/packages/core/src/types/SimpleTableConfig.ts +++ b/packages/core/src/types/SimpleTableConfig.ts @@ -22,6 +22,7 @@ import { RowButton } from "./RowButton"; import Theme from "./Theme"; import { CustomThemeProps } from "./CustomTheme"; import { GetRowId } from "./GetRowId"; +import { GetRowClass } from "./GetRowClass"; import { ColumnEditorConfig } from "./ColumnEditorConfig"; import { VanillaIconsConfig } from "./IconsConfig"; import { QuickFilterConfig } from "./QuickFilterTypes"; @@ -142,6 +143,8 @@ export interface SimpleTableConfig { */ rowGrouping?: Accessor[]; getRowId?: GetRowId; + /** @see SimpleTableProps.getRowClass */ + getRowClass?: GetRowClass; rows: TData[]; rowsPerPage?: number; scrollParent?: HTMLElement | "window" | (() => HTMLElement | null); diff --git a/packages/core/src/types/SimpleTableProps.ts b/packages/core/src/types/SimpleTableProps.ts index 74fe7c67d..8be0514b0 100644 --- a/packages/core/src/types/SimpleTableProps.ts +++ b/packages/core/src/types/SimpleTableProps.ts @@ -22,6 +22,7 @@ import { RowButton } from "./RowButton"; import Theme from "./Theme"; import { CustomThemeProps } from "./CustomTheme"; import { GetRowId } from "./GetRowId"; +import { GetRowClass } from "./GetRowClass"; import { ColumnEditorConfig } from "./ColumnEditorConfig"; import { IconsConfig } from "./IconsConfig"; import { QuickFilterConfig } from "./QuickFilterTypes"; @@ -142,6 +143,11 @@ export interface SimpleTableProps { */ rowGrouping?: Accessor[]; getRowId?: GetRowId; // Stable business id for a row. Return null/undefined when the row has no id (pivot aggregates, loading) to use reference-based identity. + /** + * Return CSS class name(s) for the row. Applied to each body cell — style with + * `.st-cell.yourClass`. Return null/undefined for default styling. + */ + getRowClass?: GetRowClass; rows: TData[]; // Rows data rowsPerPage?: number; // Rows per page scrollParent?: HTMLElement | "window" | (() => HTMLElement | null); // External scroll container that drives virtualization and onLoadMore when neither height nor maxHeight is set. Accepts an element, the string "window", or a getter (useful for refs that resolve after first render). diff --git a/packages/core/src/utils/bodyCell/content.ts b/packages/core/src/utils/bodyCell/content.ts index a5bae0bf6..7cc7f2b6e 100644 --- a/packages/core/src/utils/bodyCell/content.ts +++ b/packages/core/src/utils/bodyCell/content.ts @@ -111,21 +111,25 @@ export const createCellContent = ( // Check if we need to render expand icon const currentGroupingKey = context.rowGrouping && context.rowGrouping[depth]; const cellHasChildren = currentGroupingKey ? hasNestedRows(row, currentGroupingKey) : false; - const canExpandFurther = context.rowGrouping && depth < context.rowGrouping.length; + const canExpandFurther = Boolean(context.rowGrouping && depth < context.rowGrouping.length); const isRowExpandable = context.canExpandRowGroup ? context.canExpandRowGroup(row) : true; const hasNestedTableConfig = !!header.nestedTable; - + // Support dynamic row loading: show expand icon if onRowGroupExpand is provided // even when row has no children yet (they'll be loaded on expand) const hasDynamicLoading = !!context.onRowGroupExpand; - + const shouldShowExpandIcon = header.expandable && - ((cellHasChildren && canExpandFurther && isRowExpandable) || - hasNestedTableConfig || - (hasDynamicLoading && canExpandFurther && isRowExpandable)); + ((cellHasChildren && canExpandFurther && isRowExpandable) || + hasNestedTableConfig || + (hasDynamicLoading && canExpandFurther && isRowExpandable)); - if (shouldShowExpandIcon) { + // Reserve caret width for non-expandable siblings at an expandable depth so + // mixed nested / leaf rows stay text-aligned (v2 placeholder behavior). + const shouldReserveExpandSpace = Boolean(header.expandable && canExpandFurther); + + if (shouldShowExpandIcon || shouldReserveExpandSpace) { const expandedDepthsSet = new Set(context.expandedDepths); const expandRowKey = expandStateKey(cell.tableRow); const isExpanded = getIsRowExpanded( @@ -136,7 +140,9 @@ export const createCellContent = ( context.collapsedRows, ); - const expandIcon = createExpandIcon(cell, context, isExpanded); + const expandIcon = createExpandIcon(cell, context, isExpanded, { + placeholder: !shouldShowExpandIcon, + }); contentSpan.appendChild(expandIcon); } diff --git a/packages/core/src/utils/bodyCell/expansion.ts b/packages/core/src/utils/bodyCell/expansion.ts index 26ac4aecd..f72c447e6 100644 --- a/packages/core/src/utils/bodyCell/expansion.ts +++ b/packages/core/src/utils/bodyCell/expansion.ts @@ -3,24 +3,32 @@ import { addTrackedEventListener } from "./eventTracking"; import { isRowExpanded, expandStateKey } from "../rowUtils"; import { cellLiveRefMap } from "./cellLiveRef"; +export type CreateExpandIconOptions = { + /** + * Invisible spacer that reserves the same width as a real expand caret. + * Used so leaf / non-expandable rows align with expandable siblings. + */ + placeholder?: boolean; +}; + // Create expand/collapse icon container for row grouping // Uses the icon from context.icons.expand (configured by user or default) export const createExpandIcon = ( cell: AbsoluteBodyCell, context: CellRenderContext, isExpanded: boolean, + options?: CreateExpandIconOptions, ): HTMLElement => { + const isPlaceholder = options?.placeholder === true; + // Create outer container with proper classes matching old React implementation const outerContainer = document.createElement("div"); outerContainer.className = `st-icon-container st-expand-icon-container ${ - isExpanded ? "expanded" : "collapsed" + isPlaceholder ? "placeholder" : isExpanded ? "expanded" : "collapsed" }`; - outerContainer.setAttribute("role", "button"); - outerContainer.setAttribute("aria-label", isExpanded ? "Collapse row" : "Expand row"); - outerContainer.setAttribute("aria-expanded", String(isExpanded)); - outerContainer.setAttribute("tabindex", "0"); - // Use the icon from context (matches React implementation: {icons.expand}) + // Use the icon from context (matches React implementation: {icons.expand}). + // Placeholders clone the same icon so custom expand icons keep leaf rows aligned. const icon = context.icons.expand; if (icon) { if (typeof icon === "string") { @@ -32,6 +40,18 @@ export const createExpandIcon = ( } } + if (isPlaceholder) { + outerContainer.setAttribute("role", "presentation"); + outerContainer.setAttribute("aria-hidden", "true"); + outerContainer.setAttribute("tabindex", "-1"); + return outerContainer; + } + + outerContainer.setAttribute("role", "button"); + outerContainer.setAttribute("aria-label", isExpanded ? "Collapse row" : "Expand row"); + outerContainer.setAttribute("aria-expanded", String(isExpanded)); + outerContainer.setAttribute("tabindex", "0"); + const handleToggle = (event: Event) => { event.stopPropagation(); @@ -201,6 +221,8 @@ export const updateExpandIconState = ( ): void => { const iconContainer = cellElement.querySelector(".st-expand-icon-container"); if (!iconContainer || !(iconContainer instanceof HTMLElement)) return; + // Invisible alignment spacers are not interactive expand controls. + if (iconContainer.classList.contains("placeholder")) return; const currentlyExpanded = iconContainer.classList.contains("expanded"); diff --git a/packages/core/src/utils/bodyCell/styling.ts b/packages/core/src/utils/bodyCell/styling.ts index 31028e111..fe61a05b0 100644 --- a/packages/core/src/utils/bodyCell/styling.ts +++ b/packages/core/src/utils/bodyCell/styling.ts @@ -197,11 +197,35 @@ const calculateBodyCellClasses = (cell: AbsoluteBodyCell, context: CellRenderCon context.activeRowId != null && String(context.activeRowId) === String(rowId) ? "st-row-active" : "", + ...normalizeGetRowClassResult( + context.getRowClass?.({ + row: cell.row, + rowId, + position: cell.tableRow.position, + depth, + }), + ), ] .filter(Boolean) .join(" "); }; +/** Flatten `getRowClass` return into discrete class tokens. */ +const normalizeGetRowClassResult = ( + result: string | string[] | undefined | null, +): string[] => { + if (result == null) return []; + const parts = Array.isArray(result) ? result : [result]; + const out: string[] = []; + for (const part of parts) { + if (typeof part !== "string") continue; + for (const token of part.trim().split(/\s+/)) { + if (token) out.push(token); + } + } + return out; +}; + // Create a single body cell element export const createBodyCellElement = ( cell: AbsoluteBodyCell, diff --git a/packages/core/src/utils/bodyCell/types.ts b/packages/core/src/utils/bodyCell/types.ts index 0fa64884a..cde05451c 100644 --- a/packages/core/src/utils/bodyCell/types.ts +++ b/packages/core/src/utils/bodyCell/types.ts @@ -7,6 +7,7 @@ import type TableRow from "../../types/TableRow"; import type RowState from "../../types/RowState"; import type { RowButton } from "../../types/RowButton"; import type { CustomTheme } from "../../types/CustomTheme"; +import type { GetRowClass } from "../../types/GetRowClass"; import type { HeightOffsets } from "../infiniteScrollUtils"; import type { AccordionAxis } from "../accordionAnimation"; import type { @@ -100,6 +101,8 @@ export interface CellRenderContext { */ hoverScopeId: string; oddEvenRowBackground?: boolean; + /** Optional callback that returns CSS class name(s) for every body cell in a row. */ + getRowClass?: GetRowClass; rowGrouping?: string[]; headers: ColumnDef[]; /** diff --git a/packages/core/src/utils/columnEditor/columnEditorUtils.ts b/packages/core/src/utils/columnEditor/columnEditorUtils.ts index 855348696..48362cd82 100644 --- a/packages/core/src/utils/columnEditor/columnEditorUtils.ts +++ b/packages/core/src/utils/columnEditor/columnEditorUtils.ts @@ -92,6 +92,47 @@ export const buildColumnVisibilityState = (headers: ColumnDef[]): ColumnVisibili return visibilityState; }; +/** Find a header node by accessor anywhere in the tree. */ +export const findHeaderByAccessor = ( + headers: ColumnDef[], + accessor: Accessor, +): ColumnDef | null => { + for (const header of headers) { + if (header.accessor === accessor) return header; + if (header.children?.length) { + const nested = findHeaderByAccessor(header.children, accessor); + if (nested) return nested; + } + } + return null; +}; + +/** + * Tri-state checkbox values for a column-editor row (matches createColumnEditorRow). + */ +export const getColumnEditorCheckboxState = ( + header: ColumnDef, +): { checked: boolean; indeterminate: boolean } => { + const hasChildren = Boolean(header.children && header.children.length > 0); + let checked = !header.hide; + let indeterminate = false; + if (hasChildren && header.children) { + const allHidden = areAllChildrenHidden(header.children); + const allVisible = areAllChildrenVisible(header.children); + if (header.hide || allHidden) { + checked = false; + indeterminate = false; + } else if (allVisible) { + checked = true; + indeterminate = false; + } else { + checked = false; + indeterminate = true; + } + } + return { checked, indeterminate }; +}; + export const findClosestValidSeparatorIndex = ({ flattenedHeaders, draggingRow, diff --git a/packages/core/src/utils/columnEditor/createCheckbox.ts b/packages/core/src/utils/columnEditor/createCheckbox.ts index f5bf434b7..1a8c377b0 100644 --- a/packages/core/src/utils/columnEditor/createCheckbox.ts +++ b/packages/core/src/utils/columnEditor/createCheckbox.ts @@ -44,18 +44,25 @@ const applyCheckboxVisual = ( /** * Updates an existing checkbox DOM (created by createCheckbox) to match the given checked state. * Use when the checkbox element is reused (e.g. from cache) and selection state changed. - * Clears any indeterminate state. + * Clears any indeterminate state unless `indeterminate` is passed as true. * @param container - Element that contains .st-checkbox-input and .st-checkbox-custom (the label or a parent) */ export const updateCheckboxElement = ( container: HTMLElement, checked: boolean, + indeterminate = false, ): void => { const input = container.querySelector(".st-checkbox-input"); const customCheckbox = container.querySelector(".st-checkbox-custom"); if (!input || !customCheckbox) return; - if (input.checked === checked && !input.indeterminate) return; - applyCheckboxVisual(input, customCheckbox, checked, false); + if ( + input.checked === checked && + input.indeterminate === indeterminate && + input.getAttribute("aria-checked") === (indeterminate ? "mixed" : String(checked)) + ) { + return; + } + applyCheckboxVisual(input, customCheckbox, checked, indeterminate); }; export const createCheckbox = ({ diff --git a/packages/core/src/utils/columnEditor/createColumnEditorPopout.ts b/packages/core/src/utils/columnEditor/createColumnEditorPopout.ts index 8b51fe20c..f413b1463 100644 --- a/packages/core/src/utils/columnEditor/createColumnEditorPopout.ts +++ b/packages/core/src/utils/columnEditor/createColumnEditorPopout.ts @@ -3,10 +3,14 @@ import { ColumnEditorSearchFunction, ColumnEditorConfig } from "../../types/Colu import { ColumnEditorCustomRenderer } from "../../types/ColumnEditorCustomRendererProps"; import { FlattenedHeader } from "../../types/FlattenedHeader"; import { createColumnEditorRow } from "./createColumnEditorRow"; -import { HoveredSeparator } from "./columnEditorUtils"; +import { + getColumnEditorCheckboxState, + HoveredSeparator, +} from "./columnEditorUtils"; import { ColumnVisibilityState } from "../../types/ColumnVisibilityTypes"; import { IconsConfig } from "../../types/IconsConfig"; import { partitionRootHeadersByPin, PanelSection } from "../../utils/pinnedColumnUtils"; +import { updateCheckboxElement } from "./createCheckbox"; export interface CreateColumnEditorPopoutOptions { headers: ColumnDef[]; @@ -370,6 +374,7 @@ export const createColumnEditorPopout = (initialOptions: CreateColumnEditorPopou icons, essentialAccessors: essentialAccessors ?? new Set(), headers, + getHeaders: () => headers, setHeaders, onColumnVisibilityChange, onColumnOrderChange, @@ -397,6 +402,52 @@ export const createColumnEditorPopout = (initialOptions: CreateColumnEditorPopou } }; + /** + * When the visible editor row structure is unchanged, sync checkbox visuals + * in place instead of wiping the list (avoids destroying the checkbox under + * the cursor during heavy setHeaders → onRender work). + */ + const syncVisibilityInPlace = (nextHeaders: ColumnDef[]): boolean => { + const items = Array.from( + listsContainer.querySelectorAll(".st-header-checkbox-item"), + ); + if (items.length === 0) return false; + + const allowColumnPinning = columnEditorConfig.allowColumnPinning !== false; + const { pinnedLeft, unpinned, pinnedRight } = partitionRootHeadersByPin(nextHeaders); + const sections: Array<{ headers: ColumnDef[]; panelSection: PanelSection }> = allowColumnPinning + ? [ + { headers: pinnedLeft, panelSection: "left" }, + { headers: unpinned, panelSection: "main" }, + { headers: pinnedRight, panelSection: "right" }, + ] + : [ + { + headers: [...pinnedLeft, ...unpinned, ...pinnedRight], + panelSection: "main", + }, + ]; + + const expected: FlattenedHeader[] = []; + for (const section of sections) { + if (section.headers.length === 0) continue; + expected.push(...getFlattenedHeaders(section.headers, section.panelSection)); + } + + if (expected.length !== items.length) return false; + for (let i = 0; i < expected.length; i++) { + if (items[i].dataset.accessor !== String(expected[i].header.accessor)) { + return false; + } + } + + for (let i = 0; i < expected.length; i++) { + const { checked, indeterminate } = getColumnEditorCheckboxState(expected[i].header); + updateCheckboxElement(items[i], checked, indeterminate); + } + return true; + }; + render(); const rebuildContentLayout = () => { @@ -413,11 +464,19 @@ export const createColumnEditorPopout = (initialOptions: CreateColumnEditorPopou }; const update = (newOptions: Partial) => { - if (newOptions.headers !== undefined) headers = newOptions.headers; - if (newOptions.searchEnabled !== undefined) searchEnabled = newOptions.searchEnabled; + let structureDirty = false; + let headersUpdated = false; + + if (newOptions.searchEnabled !== undefined && newOptions.searchEnabled !== searchEnabled) { + searchEnabled = newOptions.searchEnabled; + structureDirty = true; + } if (newOptions.searchPlaceholder !== undefined) searchPlaceholder = newOptions.searchPlaceholder; - if (newOptions.searchFunction !== undefined) searchFunction = newOptions.searchFunction; + if (newOptions.searchFunction !== undefined) { + searchFunction = newOptions.searchFunction; + structureDirty = true; + } if (newOptions.icons !== undefined) icons = newOptions.icons; if (newOptions.essentialAccessors !== undefined) essentialAccessors = newOptions.essentialAccessors; if (newOptions.setHeaders !== undefined) setHeaders = newOptions.setHeaders; @@ -434,6 +493,7 @@ export const createColumnEditorPopout = (initialOptions: CreateColumnEditorPopou if (newCustomRenderer !== activeCustomRenderer) { activeCustomRenderer = newCustomRenderer; needsLayoutRebuild = true; + structureDirty = true; } columnEditorConfig = newOptions.columnEditorConfig; } @@ -451,11 +511,22 @@ export const createColumnEditorPopout = (initialOptions: CreateColumnEditorPopou searchInput.placeholder = newOptions.searchPlaceholder; } + if (newOptions.headers !== undefined) { + headers = newOptions.headers; + headersUpdated = true; + } + if (needsLayoutRebuild) { rebuildContentLayout(); } - render(); + if (!structureDirty && headersUpdated && syncVisibilityInPlace(headers)) { + return; + } + + if (structureDirty || headersUpdated || needsLayoutRebuild) { + render(); + } }; const destroy = () => { diff --git a/packages/core/src/utils/columnEditor/createColumnEditorRow.ts b/packages/core/src/utils/columnEditor/createColumnEditorRow.ts index e6fed8379..1db08b8d2 100644 --- a/packages/core/src/utils/columnEditor/createColumnEditorRow.ts +++ b/packages/core/src/utils/columnEditor/createColumnEditorRow.ts @@ -6,6 +6,8 @@ import { areAllChildrenHidden, areAllChildrenVisible, findAndMarkParentsVisible, + findHeaderByAccessor, + getColumnEditorCheckboxState, showAllDescendants, updateParentHeaders, buildColumnVisibilityState, @@ -49,6 +51,12 @@ export interface CreateColumnEditorRowOptions { icons?: IconsConfig; essentialAccessors?: ReadonlySet; headers: ColumnDef[]; + /** + * When set, visibility toggles read the latest header tree from here so the + * popout can keep row DOM across setHeaders (in-place checkbox sync) without + * stale closures overwriting newer hide flags. + */ + getHeaders?: () => ColumnDef[]; setHeaders: (headers: ColumnDef[]) => void; onColumnVisibilityChange?: (state: ColumnVisibilityState) => void; onColumnOrderChange?: (headers: ColumnDef[]) => void; @@ -64,7 +72,7 @@ export interface CreateColumnEditorRowResult { export const createColumnEditorRow = (options: CreateColumnEditorRowOptions): CreateColumnEditorRowResult => { const { - allHeaders, + allHeaders: _allHeaders, clearHoverSeparator, depth, doesAnyHeaderHaveChildren, @@ -82,12 +90,15 @@ export const createColumnEditorRow = (options: CreateColumnEditorRowOptions): Cr setExpandedHeaders, setHoveredSeparator, headers, + getHeaders, setHeaders, onColumnVisibilityChange, onColumnOrderChange, previousExpandedHeaders, } = options; + const resolveHeaders = (): ColumnDef[] => (getHeaders ? getHeaders() : headers); + const essentialAccessors: ReadonlySet = options.essentialAccessors ?? new Set(); const allowColumnPinning = options.columnEditorConfig.allowColumnPinning !== false; const essential = isHeaderEssential(header, essentialAccessors); @@ -98,22 +109,8 @@ export const createColumnEditorRow = (options: CreateColumnEditorRowOptions): Cr const hasChildren = header.children && header.children.length > 0; // Group rows use a tri-state checkbox: unchecked / indeterminate (partial) / checked. - let isChecked = !header.hide; - let isIndeterminate = false; - if (hasChildren && header.children) { - const allHidden = areAllChildrenHidden(header.children); - const allVisible = areAllChildrenVisible(header.children); - if (header.hide || allHidden) { - isChecked = false; - isIndeterminate = false; - } else if (allVisible) { - isChecked = true; - isIndeterminate = false; - } else { - isChecked = false; - isIndeterminate = true; - } - } + const { checked: isChecked, indeterminate: isIndeterminate } = + getColumnEditorCheckboxState(header); const isExpanded = expandedHeaders.has(header.accessor); const shouldExpand = forceExpanded || isExpanded; @@ -135,6 +132,7 @@ export const createColumnEditorRow = (options: CreateColumnEditorRowOptions): Cr const rowContainer = document.createElement("div"); rowContainer.className = "st-header-checkbox-item"; + rowContainer.dataset.accessor = String(header.accessor); rowContainer.style.paddingLeft = paddingLeft; rowContainer.draggable = true; @@ -153,29 +151,34 @@ export const createColumnEditorRow = (options: CreateColumnEditorRowOptions): Cr const handleCheckboxChange = (checked: boolean) => { if (!canToggleVisibility) return; - header.hide = !checked; + const latestHeaders = resolveHeaders(); + const target = findHeaderByAccessor(latestHeaders, header.accessor); + if (!target) return; + + const targetHasChildren = Boolean(target.children && target.children.length > 0); + target.hide = !checked; if (!checked) { - updateParentHeaders(allHeaders); + updateParentHeaders(latestHeaders); } else { - findAndMarkParentsVisible(allHeaders, header.accessor); + findAndMarkParentsVisible(latestHeaders, target.accessor); - if (hasChildren && header.children && header.children.length > 0) { + if (targetHasChildren && target.children && target.children.length > 0) { const wasPartial = - !areAllChildrenHidden(header.children) && !areAllChildrenVisible(header.children); + !areAllChildrenHidden(target.children) && !areAllChildrenVisible(target.children); if (wasPartial) { // Indeterminate → checked: show every descendant under this group. - showAllDescendants(header.children); - } else if (areAllChildrenHidden(header.children) && header.children[0]) { + showAllDescendants(target.children); + } else if (areAllChildrenHidden(target.children) && target.children[0]) { // Fully hidden group → checked: reveal the first child (existing behavior). - header.children[0].hide = false; - findAndMarkParentsVisible(allHeaders, header.children[0].accessor); + target.children[0].hide = false; + findAndMarkParentsVisible(latestHeaders, target.children[0].accessor); } } } - const updatedHeaders = [...headers]; + const updatedHeaders = [...latestHeaders]; setHeaders(deepClone(updatedHeaders)); if (onColumnVisibilityChange) { @@ -299,8 +302,9 @@ export const createColumnEditorRow = (options: CreateColumnEditorRowOptions): Cr // section is flattened independently), but `swapHeaders` mutates the full // `headers` tree. Resolve the real tree paths from the accessors so the // swap targets the correct columns when pinned sections offset the indices. - const draggedGlobalPath = getHeaderIndexPath(headers, currentDraggingRow.header.accessor); - const hoveredGlobalPath = getHeaderIndexPath(headers, hoveredHeader.header.accessor); + const latestHeaders = resolveHeaders(); + const draggedGlobalPath = getHeaderIndexPath(latestHeaders, currentDraggingRow.header.accessor); + const hoveredGlobalPath = getHeaderIndexPath(latestHeaders, hoveredHeader.header.accessor); if (!draggedGlobalPath || !hoveredGlobalPath) { cancelDrag(); @@ -308,7 +312,7 @@ export const createColumnEditorRow = (options: CreateColumnEditorRowOptions): Cr } const { newHeaders, emergencyBreak } = swapHeaders( - headers, + latestHeaders, draggedGlobalPath, hoveredGlobalPath, ); @@ -423,17 +427,17 @@ export const createColumnEditorRow = (options: CreateColumnEditorRowOptions): Cr const canPinRight = !pinnedSide && panelSection === "main"; const pinLeft = () => { - const next = moveRootColumnPinSide(headers, header.accessor, "left", essentialAccessors); + const next = moveRootColumnPinSide(resolveHeaders(), header.accessor, "left", essentialAccessors); if (next) applyHeaderOrder(next); }; const pinRight = () => { - const next = moveRootColumnPinSide(headers, header.accessor, "right", essentialAccessors); + const next = moveRootColumnPinSide(resolveHeaders(), header.accessor, "right", essentialAccessors); if (next) applyHeaderOrder(next); }; const unpin = () => { - const next = moveRootColumnPinSide(headers, header.accessor, "main", essentialAccessors); + const next = moveRootColumnPinSide(resolveHeaders(), header.accessor, "main", essentialAccessors); if (next) applyHeaderOrder(next); }; diff --git a/packages/core/stories/tests/05-RowGroupingTests.stories.ts b/packages/core/stories/tests/05-RowGroupingTests.stories.ts index 23937aaa7..a9be35a69 100644 --- a/packages/core/stories/tests/05-RowGroupingTests.stories.ts +++ b/packages/core/stories/tests/05-RowGroupingTests.stories.ts @@ -987,8 +987,16 @@ export const CanExpandRowGroupConditional = { const secondRowCells = bodyContainer.querySelectorAll( '.st-cell[data-row-index="1"]', ); + // Non-expandable siblings keep an invisible placeholder caret for alignment, + // but it must not be exposed as an interactive expand control. const secondRowIcon = findExpandIconInRow(Array.from(secondRowCells)); expect(secondRowIcon).toBeFalsy(); + const secondRowPlaceholder = Array.from(secondRowCells) + .map((cell) => + (cell as Element).querySelector(".st-expand-icon-container.placeholder"), + ) + .find(Boolean); + expect(secondRowPlaceholder).toBeTruthy(); }, }; diff --git a/packages/core/stories/tests/32-ThemesTests.stories.ts b/packages/core/stories/tests/32-ThemesTests.stories.ts index 038045470..0b2a2708c 100644 --- a/packages/core/stories/tests/32-ThemesTests.stories.ts +++ b/packages/core/stories/tests/32-ThemesTests.stories.ts @@ -6,7 +6,7 @@ import type { Meta } from "@storybook/html"; import { expect } from "@storybook/test"; import { ColumnDef } from "../../src/index"; -import { waitForTable } from "./testUtils"; +import { waitForTable, getRowCount } from "./testUtils"; import { renderVanillaTable } from "../utils"; const meta: Meta = { @@ -361,3 +361,151 @@ export const ColumnBorders = { expect(hasColumnBordersClass || root !== null).toBe(true); }, }; + +// ============================================================================ +// getRowClass +// ============================================================================ + +const jumpHighlightData = () => + Array.from({ length: 500 }, (_, i) => ({ + id: i + 1, + name: `Person ${i + 1}`, + })); + +export const GetRowClassHighlightsMatchingRow = { + tags: ["get-row-class"], + render: () => { + const { wrapper } = renderVanillaTable(headers, stripedData(), { + getRowId: (p) => String((p.row as { id?: number })?.id), + height: "250px", + getRowClass: ({ row }) => + (row as { id?: number }).id === 3 ? "test-jump-row" : undefined, + }); + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + // Path-based row id: [index, getRowId] → "2-3" for id 3 at index 2 + const highlighted = canvasElement.querySelectorAll( + '.st-cell[data-row-id="2-3"].test-jump-row', + ); + expect(highlighted.length).toBeGreaterThan(0); + // Every cell of that row should carry the class + const allInRow = canvasElement.querySelectorAll('.st-cell[data-row-id="2-3"]'); + expect(allInRow.length).toBeGreaterThan(0); + allInRow.forEach((cell) => { + expect(cell.classList.contains("test-jump-row")).toBe(true); + }); + // Other rows must not + const other = canvasElement.querySelectorAll( + '.st-cell[data-row-id="0-1"].test-jump-row', + ); + expect(other.length).toBe(0); + }, +}; + +export const GetRowClassUpdatesWhenCallbackIdentityChanges = { + tags: ["get-row-class"], + render: () => { + const result = renderVanillaTable(headers, stripedData(), { + getRowId: (p) => String((p.row as { id?: number })?.id), + height: "250px", + getRowClass: ({ row }) => + (row as { id?: number }).id === 1 ? "test-jump-row" : undefined, + }); + (globalThis as unknown as Record)[ + "__storybook_get_row_class_table" + ] = result.table; + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + const table = (globalThis as unknown as Record void }>)[ + "__storybook_get_row_class_table" + ]; + expect(table).toBeTruthy(); + + expect( + canvasElement.querySelectorAll('.st-cell[data-row-id="0-1"].test-jump-row').length, + ).toBeGreaterThan(0); + + table.update({ + getRowClass: ({ row }: { row: { id?: number } }) => + row.id === 4 ? "test-jump-row" : undefined, + }); + await new Promise((r) => setTimeout(r, 50)); + + expect( + canvasElement.querySelectorAll('.st-cell[data-row-id="0-1"].test-jump-row').length, + ).toBe(0); + expect( + canvasElement.querySelectorAll('.st-cell[data-row-id="3-4"].test-jump-row').length, + ).toBeGreaterThan(0); + }, +}; + +export const GetRowClassSurvivesVirtualizationReuse = { + tags: ["get-row-class"], + render: () => { + const { wrapper } = renderVanillaTable(headers, jumpHighlightData(), { + getRowId: (p) => String((p.row as { id?: number })?.id), + // Small viewport + 500 rows so only a band of rows is mounted + height: "200px", + getRowClass: ({ row }) => + (row as { id?: number }).id === 2 ? "test-jump-row" : undefined, + }); + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + const body = canvasElement.querySelector(".st-body-container") as HTMLElement | null; + expect(body).toBeTruthy(); + + // Virtualization must be active — far fewer than 500 rows in the DOM + const renderedAtTop = getRowCount(canvasElement); + expect(renderedAtTop).toBeGreaterThan(0); + expect(renderedAtTop).toBeLessThan(100); + + // Highlighted early row is visible at the top + const targetSelector = '.st-cell[data-row-id="1-2"].test-jump-row'; + expect(canvasElement.querySelectorAll(targetSelector).length).toBeGreaterThan(0); + + const indicesAtTop = new Set( + Array.from(canvasElement.querySelectorAll(".st-cell[data-row-index]")).map((c) => + c.getAttribute("data-row-index"), + ), + ); + + // Scroll deep enough that the early band (including id 2) leaves the viewport + body!.scrollTop = Math.min(8000, body!.scrollHeight - body!.clientHeight); + body!.dispatchEvent(new Event("scroll", { bubbles: true })); + await new Promise((r) => setTimeout(r, 200)); + + const indicesScrolled = new Set( + Array.from(canvasElement.querySelectorAll(".st-cell[data-row-index]")).map((c) => + c.getAttribute("data-row-index"), + ), + ); + const newlyVisible = Array.from(indicesScrolled).filter((idx) => !indicesAtTop.has(idx)); + expect(newlyVisible.length).toBeGreaterThan(0); + + // Target row is no longer in the virtualized band + expect(canvasElement.querySelectorAll(targetSelector).length).toBe(0); + + // Scroll back to top — recycled cells must re-apply the class for id 2 + body!.scrollTop = 0; + body!.dispatchEvent(new Event("scroll", { bubbles: true })); + await new Promise((r) => setTimeout(r, 200)); + + const after = canvasElement.querySelectorAll(targetSelector); + expect(after.length).toBeGreaterThan(0); + const allInRow = canvasElement.querySelectorAll('.st-cell[data-row-id="1-2"]'); + expect(allInRow.length).toBeGreaterThan(0); + allInRow.forEach((cell) => { + expect(cell.classList.contains("test-jump-row")).toBe(true); + }); + expect( + canvasElement.querySelectorAll('.st-cell[data-row-id="0-1"].test-jump-row').length, + ).toBe(0); + }, +}; diff --git a/packages/core/stories/tests/52-ColumnEditorHeavyClickReproTests.stories.ts b/packages/core/stories/tests/52-ColumnEditorHeavyClickReproTests.stories.ts new file mode 100644 index 000000000..06fd14233 --- /dev/null +++ b/packages/core/stories/tests/52-ColumnEditorHeavyClickReproTests.stories.ts @@ -0,0 +1,440 @@ +/** + * COLUMN EDITOR HEAVY-CLICK / HEADER-REORDER REPRO + * + * Chartmetric-style Track List stress case for two client-reported issues: + * 1. Column editor checkboxes sometimes need multiple clicks (esp. nested columns + * on a heavy table) — suspected cause: setHeaders → full header re-render + + * column-editor popout rebuild (twice) destroying the checkbox mid-interaction. + * 2. Header drag reorder can feel sticky; final animation sometimes settles at the + * previous position rather than the new one. + * + * Manual: + * - Open Storybook → Tests/52 - Column Editor Heavy Click Repro + * - Rapidly toggle nested checkboxes in the column editor (groups + leafs) + * - Drag column headers left/right and watch settle animation + * + * Light vs Heavy stories isolate whether render cost correlates with missed clicks + * (customer could repro on Track List but not lighter Influencer List). + */ + +import type { Meta } from "@storybook/html"; +import { expect } from "@storybook/test"; +import { + SimpleTableVanilla, + type CellRendererProps, + type ColumnDef, + type Row, +} from "../../src/index"; +import { waitForTable, waitUntil } from "./testUtils"; + +const meta: Meta = { + title: "Tests/52 - Column Editor Heavy Click Repro", + // Helpers like resetClickRepro must not become blank CSF stories. + excludeStories: ["resetClickRepro"], + parameters: { + layout: "fullscreen", + chromatic: { disableSnapshot: true }, + docs: { + description: { + component: + "Track-List-style nested columns + expensive cells to reproduce column-editor multi-click and header-reorder settle glitches.", + }, + }, + }, +}; + +export default meta; + +// --------------------------------------------------------------------------- +// Play-test counters (not Storybook stories — see meta.excludeStories) +// --------------------------------------------------------------------------- + +interface ClickReproSnapshot { + checkboxClickAttempts: number; + visibilityChangeCount: number; +} + +declare global { + interface Window { + __columnEditorClickRepro?: ClickReproSnapshot; + } +} + +const createSnapshot = (): ClickReproSnapshot => ({ + checkboxClickAttempts: 0, + visibilityChangeCount: 0, +}); + +const getSnapshot = (): ClickReproSnapshot => { + if (!window.__columnEditorClickRepro) { + window.__columnEditorClickRepro = createSnapshot(); + } + return window.__columnEditorClickRepro; +}; + +export const resetClickRepro = (): void => { + window.__columnEditorClickRepro = createSnapshot(); +}; + +// --------------------------------------------------------------------------- +// Track-List-style nested headers (deeper/wider than Influencers light case) +// --------------------------------------------------------------------------- + +const PLATFORM_GROUPS = [ + { id: "spotify", label: "Spotify" }, + { id: "apple", label: "Apple Music" }, + { id: "youtube", label: "YouTube" }, + { id: "amazon", label: "Amazon" }, + { id: "tidal", label: "Tidal" }, +] as const; + +const METRIC_LEAVES = [ + "streams", + "listeners", + "followers", + "saves", + "shares", + "playlists", + "skipRate", + "completion", +] as const; + +const PERIODS = ["7d", "28d", "90d"] as const; + +type TrackRow = Row & { + id: number; + track: string; + artist: string; + album: string; + genre: string; + [key: string]: string | number; +}; + +const expensiveCell = ({ row, accessor }: CellRendererProps): HTMLElement => { + // Deliberately DOM-heavy so each visibility toggle + full onRender is costly + // (mirrors custom renderers on Track List). + const wrap = document.createElement("div"); + wrap.style.display = "flex"; + wrap.style.flexDirection = "column"; + wrap.style.gap = "4px"; + wrap.style.width = "100%"; + wrap.style.padding = "2px 0"; + + const value = String((row as TrackRow)[accessor] ?? ""); + const top = document.createElement("div"); + top.style.display = "flex"; + top.style.alignItems = "center"; + top.style.gap = "6px"; + + const spark = document.createElement("div"); + spark.style.display = "flex"; + spark.style.alignItems = "flex-end"; + spark.style.gap = "1px"; + spark.style.height = "18px"; + const seed = Number(value) || 1; + for (let i = 0; i < 8; i++) { + const bar = document.createElement("span"); + const h = 4 + ((seed * (i + 3)) % 14); + bar.style.width = "3px"; + bar.style.height = `${h}px`; + bar.style.background = i % 2 === 0 ? "#94a3b8" : "#64748b"; + bar.style.borderRadius = "1px"; + spark.appendChild(bar); + } + + const text = document.createElement("span"); + text.style.fontVariantNumeric = "tabular-nums"; + text.style.fontSize = "12px"; + text.textContent = Number.isFinite(Number(value)) + ? Number(value).toLocaleString() + : value; + + top.appendChild(spark); + top.appendChild(text); + + const sub = document.createElement("div"); + sub.style.fontSize = "10px"; + sub.style.color = "#64748b"; + sub.textContent = `${accessor} · ${(row as TrackRow).track}`; + + wrap.appendChild(top); + wrap.appendChild(sub); + return wrap; +}; + +const createTrackHeaders = (): ColumnDef[] => { + const identity: ColumnDef[] = [ + { + accessor: "id", + label: "#", + width: 64, + type: "number", + pinned: "left", + sortable: true, + }, + { + accessor: "track", + label: "Track", + width: 220, + type: "string", + pinned: "left", + sortable: true, + }, + { + accessor: "artist", + label: "Artist", + width: 160, + type: "string", + pinned: "left", + }, + { + accessor: "meta", + label: "Metadata", + width: 280, + type: "string", + children: [ + { accessor: "album", label: "Album", width: 160, type: "string" }, + { accessor: "genre", label: "Genre", width: 120, type: "string" }, + ], + }, + ]; + + const platformGroups: ColumnDef[] = PLATFORM_GROUPS.map((platform) => ({ + accessor: `${platform.id}_group`, + label: platform.label, + width: 960, + type: "string", + children: PERIODS.map((period) => ({ + accessor: `${platform.id}_${period}_group`, + label: period.toUpperCase(), + width: 320, + type: "string", + children: METRIC_LEAVES.map((metric) => ({ + accessor: `${platform.id}_${period}_${metric}`, + label: metric.charAt(0).toUpperCase() + metric.slice(1), + width: 120, + type: "number" as const, + align: "right" as const, + sortable: true, + cellRenderer: expensiveCell, + })), + })), + })); + + return [...identity, ...platformGroups]; +}; + +const createTrackRows = (count: number): TrackRow[] => + Array.from({ length: count }, (_, index) => { + const row: TrackRow = { + id: index + 1, + track: `Track ${index + 1}`, + artist: `Artist ${(index % 40) + 1}`, + album: `Album ${(index % 25) + 1}`, + genre: ["Pop", "Hip-Hop", "Rock", "Electronic", "R&B"][index % 5], + }; + PLATFORM_GROUPS.forEach((platform, pIdx) => { + PERIODS.forEach((period, periodIdx) => { + METRIC_LEAVES.forEach((metric, metricIdx) => { + row[`${platform.id}_${period}_${metric}`] = Math.round( + (index + 1) * (pIdx + 2) * (periodIdx + 1) * (metricIdx + 3) * 17.3, + ); + }); + }); + }); + return row; + }); + +const createLightHeaders = (): ColumnDef[] => [ + { accessor: "id", label: "ID", width: 80, type: "number" }, + { + accessor: "location", + label: "Location", + width: 150, + type: "string", + children: [ + { accessor: "city", label: "City", width: 150, type: "string" }, + { accessor: "region", label: "Region", width: 120, type: "string" }, + ], + }, + { accessor: "name", label: "Name", width: 160, type: "string" }, +]; + +const createLightRows = (count: number): Row[] => + Array.from({ length: count }, (_, index) => ({ + id: index + 1, + name: `Row ${index + 1}`, + city: ["NYC", "LA", "CHI", "AUS", "SEA"][index % 5], + region: ["East", "West", "Central"][index % 3], + })); + +// --------------------------------------------------------------------------- +// Layout +// --------------------------------------------------------------------------- + +interface LayoutOptions { + mode: "heavy" | "light"; + rowCount: number; + enableReorder: boolean; +} + +function buildReproLayout(options: LayoutOptions): HTMLDivElement { + resetClickRepro(); + + const root = document.createElement("div"); + root.style.display = "flex"; + root.style.flexDirection = "column"; + root.style.height = "100vh"; + root.style.boxSizing = "border-box"; + root.style.padding = "12px 16px"; + root.style.background = "#f8fafc"; + root.style.fontFamily = "system-ui, sans-serif"; + + const tableHost = document.createElement("div"); + tableHost.dataset.testid = "table-host"; + tableHost.style.flex = "1"; + tableHost.style.minHeight = "0"; + root.appendChild(tableHost); + + const headers = + options.mode === "heavy" ? createTrackHeaders() : createLightHeaders(); + const rows = + options.mode === "heavy" + ? createTrackRows(options.rowCount) + : createLightRows(options.rowCount); + + // Capture-phase listener for play-test metrics (no on-screen HUD). + root.addEventListener( + "click", + (event) => { + const target = event.target as HTMLElement | null; + if (!target) return; + if ( + target.closest(".st-checkbox-label") || + target.classList.contains("st-checkbox-input") || + target.classList.contains("st-checkbox-custom") + ) { + getSnapshot().checkboxClickAttempts += 1; + } + }, + true, + ); + + const table = new SimpleTableVanilla(tableHost, { + columns: headers, + rows, + getRowId: (p) => String((p.row as TrackRow).id), + height: "100%", + theme: "modern-light", + columnResizing: true, + columnReordering: options.enableReorder, + enableColumnEditor: true, + enableColumnEditorInitOpen: true, + columnEditorConfig: { + searchEnabled: true, + }, + onColumnVisibilityChange: () => { + getSnapshot().visibilityChangeCount += 1; + }, + }); + + table.mount(); + + (root as HTMLDivElement & { __table?: SimpleTableVanilla }).__table = table; + return root; +} + +// --------------------------------------------------------------------------- +// Stories +// --------------------------------------------------------------------------- + +export const HeavyTrackListColumnEditor = { + name: "Heavy Track List (column editor multi-click)", + render: () => + buildReproLayout({ + mode: "heavy", + rowCount: 120, + enableReorder: true, + }), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(canvasElement); + await waitUntil( + () => + !!canvasElement.querySelector( + ".st-column-editor-popout.open, .st-column-editor-popout", + ), + { timeoutMs: 5000 }, + ); + + const popout = + canvasElement.querySelector(".st-column-editor-popout.open") ?? + canvasElement.querySelector(".st-column-editor-popout"); + expect(popout).toBeTruthy(); + + const items = () => + Array.from(canvasElement.querySelectorAll(".st-header-checkbox-item")); + + // Prefer nested leaf rows (indented) — these are the ones that felt sticky. + const nestedLeaves = items().filter((item) => { + const pad = parseInt((item as HTMLElement).style.paddingLeft || "0", 10); + return pad >= 32; + }); + expect(nestedLeaves.length).toBeGreaterThan(5); + + resetClickRepro(); + const beforeVisibility = getSnapshot().visibilityChangeCount; + + // Re-query after each toggle so play does not click detached nodes. + const toggleCount = 3; + for (let i = 0; i < toggleCount; i++) { + const leaves = items().filter((item) => { + const pad = parseInt((item as HTMLElement).style.paddingLeft || "0", 10); + return pad >= 32; + }); + const input = leaves[i]?.querySelector(".st-checkbox-input") as HTMLInputElement | null; + expect(input, `missing nested checkbox at index ${i}`).toBeTruthy(); + input!.click(); + await waitUntil( + () => getSnapshot().visibilityChangeCount > beforeVisibility + i, + { timeoutMs: 3000 }, + ); + } + + const after = getSnapshot(); + expect(after.visibilityChangeCount - beforeVisibility).toBeGreaterThanOrEqual(toggleCount); + }, +}; + +export const LightNestedColumnEditorControl = { + name: "Light nested (control)", + render: () => + buildReproLayout({ + mode: "light", + rowCount: 8, + enableReorder: true, + }), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + await waitUntil( + () => !!canvasElement.querySelector(".st-header-checkbox-item"), + { timeoutMs: 3000 }, + ); + const items = canvasElement.querySelectorAll(".st-header-checkbox-item"); + expect(items.length).toBeGreaterThan(2); + }, +}; + +export const HeavyHeaderReorderSettle = { + name: "Heavy Track List (header drag settle)", + render: () => + buildReproLayout({ + mode: "heavy", + rowCount: 80, + enableReorder: true, + }), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + const labels = canvasElement.querySelectorAll(".st-header-label[draggable='true']"); + expect(labels.length).toBeGreaterThan(3); + }, +}; diff --git a/packages/react/package.json b/packages/react/package.json index 90735b249..7dc46bdca 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/react", - "version": "4.1.0", + "version": "4.1.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/index.d.ts", diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e7f0d9349..7f3e871e8 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -77,6 +77,8 @@ export type { FooterPosition, GetRowId, GetRowIdParams, + GetRowClass, + GetRowClassParams, HeaderDropdown, HeaderRenderer, IconsConfig, diff --git a/packages/solid/package.json b/packages/solid/package.json index 313d9bcdb..840823e3b 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/solid", - "version": "4.1.0", + "version": "4.1.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/index.d.ts", diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index dd205d0f3..72f969f15 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -74,6 +74,8 @@ export type { FooterPosition, GetRowId, GetRowIdParams, + GetRowClass, + GetRowClassParams, HeaderDropdown, HeaderDropdownProps, HeaderRenderer, diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 1454c3c43..71e2854fa 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/svelte", - "version": "4.1.0", + "version": "4.1.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/index.d.ts", diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts index 94d83e1e9..e0497ea11 100644 --- a/packages/svelte/src/index.ts +++ b/packages/svelte/src/index.ts @@ -74,6 +74,8 @@ export type { FooterPosition, GetRowId, GetRowIdParams, + GetRowClass, + GetRowClassParams, HeaderDropdown, HeaderDropdownProps, HeaderRenderer, diff --git a/packages/vue/package.json b/packages/vue/package.json index f9dbee76d..71329d886 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/vue", - "version": "4.1.0", + "version": "4.1.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/index.d.ts", diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 1e06ef20f..402c5e7a9 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -75,6 +75,8 @@ export type { FooterPosition, GetRowId, GetRowIdParams, + GetRowClass, + GetRowClassParams, HeaderDropdown, HeaderDropdownProps, HeaderRenderer, diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 000000000..9885a596a --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,47 @@ +{ + "version": 1, + "skills": { + "competitive-landscape": { + "source": "every-app/open-seo", + "sourceType": "github", + "skillPath": ".agents/skills/competitive-landscape/SKILL.md", + "computedHash": "8c98a19868c913450d222ea3261cd17ffb15f85a9f0e68aee2b885b7e8540511" + }, + "competitor-analysis": { + "source": "every-app/open-seo", + "sourceType": "github", + "skillPath": ".agents/skills/competitor-analysis/SKILL.md", + "computedHash": "9645a4829de2bedaa2b91418ddd877eab8ea1d2e9da9683c660d47d033428244" + }, + "keyword-clustering": { + "source": "every-app/open-seo", + "sourceType": "github", + "skillPath": ".agents/skills/keyword-clustering/SKILL.md", + "computedHash": "968f0a36800e42d2a931f6877955209d1335687bdd55a14c4be2a825b1399951" + }, + "keyword-research": { + "source": "every-app/open-seo", + "sourceType": "github", + "skillPath": ".agents/skills/keyword-research/SKILL.md", + "computedHash": "bbb7aeabe35c3a6f5925ee838c2d27e3d00c763bfb2f70ade3b5be32bb5d98c9" + }, + "link-prospecting": { + "source": "every-app/open-seo", + "sourceType": "github", + "skillPath": ".agents/skills/link-prospecting/SKILL.md", + "computedHash": "73adb5a6df599aae5611e48c12468114e2ca63a2d466f27936aa2ccaed2abf2a" + }, + "seo-coach": { + "source": "every-app/open-seo", + "sourceType": "github", + "skillPath": ".agents/skills/seo-coach/SKILL.md", + "computedHash": "aa5e8fa77971f6f08905b923f217555fd60a583c3f6fb274c389106b12f2fae7" + }, + "seo-project-setup": { + "source": "every-app/open-seo", + "sourceType": "github", + "skillPath": ".agents/skills/seo-project-setup/SKILL.md", + "computedHash": "5872bc785e0ab88674a5787f70da513ecf487b6778acbbf99c35f6a8ff59fc99" + } + } +}