chore(components): Refactor Page to be built from composed parts#2867
chore(components): Refactor Page to be built from composed parts#2867
Conversation
Deploying atlantis with
|
| Latest commit: |
e97dd83
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://3739f35d.atlantis.pages.dev |
| Branch Preview URL: | https://cleanup-page-refactor-compou.atlantis.pages.dev |
| <Page.TitleMeta | ||
| title={title} | ||
| titleMetaData={titleMetaData} | ||
| subtitle={subtitle} |
There was a problem hiding this comment.
is there a reason we'd want to stop at this level rather than continuing to a point where we have: Page.Subtitle, Page.Title, and Page.TitleMetaData?
all 3 of those feel like pieces that people would reasonably want to customize since 2/3 are already ReactNodes.
so we'd have something like
<Page.Wrapper>
</Page.Wrapper>
actually, do we even need a Page.Wrapper? or would it be possible to just have Page as the top level container?
either way, going back to what I was about to say!
<Page.Wrapper>
<Page.Header>
<Page.TitleBar>
<Page.Title>
// this is the default, you can give it different content but it'll be the predefined component
<Page.TitleLabel><Trans>Hello friend</Trans></Page.TitleLabel>
// this is a fully custom value that may get out of sync
<Heading level=3><Trans>Hola Amigo</Trans></Heading>
</Page.Title>
<Page.TitleMetaData>
// doesn't seem like we have a default? so it's just a slot really
<SomeComponent/>
</Page.TitleMetaData>
<Page.Subtitle>
// this is the default
<Page.SubtitleLabel><Trans>This is my subtitle that gets the default appearance</Page.Subtitle>
// this is for fully custom
<Text><Trans>I want my subtitle to look entirely different for some reason</Trans></Text>
</Page.Subtitle>
</Page.TitleBar>
// other parts
</Page.Header>
</Page.Wrapper>
I'm not 100% on the "label" naming but that's what I did in Menu so just went with the same idea.
basically we are providing the slot, that way you can customize heavily, but we also provide the default if all you want is a declarative style but are otherwise happy with what we provide
There was a problem hiding this comment.
Implemented in e5c7132.
Page.TitleMeta has been replaced with separate Page.Title and Page.Subtitle components. Page.Wrapper is gone - Page itself is the top-level container and handles width internally.
Page.Titleaccepts children (the heading text) and an optionalmetadataprop for badges/status labels.Page.Subtitleaccepts either astring(gets the default Text/Emphasis/Markdown "treatment") or aReactNodefor full customization.- I didn't go as deep as "Page.TitleLabel" / "Page.SubtitleLabel" since the string VS ReactNode pattern on children covers the same use case with less nesting.
| ref={primaryAction?.ref} | ||
| visible={!!primaryAction} | ||
| > | ||
| <Button {...getActionProps(primaryAction)} fullWidth /> |
There was a problem hiding this comment.
this getActionProps feels a bit clunky. I wonder if we couldn't find a way to abstract that or avoid people having to use this on their instances.
There was a problem hiding this comment.
getActionProps is only used internally by the legacy renderer now.
In the composable API, Page.PrimaryAction and Page.SecondaryAction now handle their own defaults, so consumers never touch this. But if/when Button gets native ref support (as you suggested below), the need for this will likely go away.
| <Page.ActionGroup visible={!!showActionGroup}> | ||
| <Page.PrimaryAction | ||
| ref={primaryAction?.ref} | ||
| visible={!!primaryAction} |
There was a problem hiding this comment.
I feel like there's gotta be an alternate approach here.
having a visible prop with a declarative style feels redundant.
ie. if I don't want it to be visible, then wouldn't I simply not render it?
{isAdmin && <Page.PrimaryAction ...>}
There was a problem hiding this comment.
I agree! The composable API is fully declarative now. I removed this prop.
| > | ||
| <Button {...getActionProps(primaryAction)} fullWidth /> | ||
| </Page.PrimaryAction> | ||
| <Page.ActionButton |
There was a problem hiding this comment.
curious how this would work now. previously we would limit you to a primary action and a single secondary.
looking at how it works now, I assume I would be able to add as many ActionButtons as I want. maybe that's fine, but if we have opinions on how many buttons a Page should have, that would be worth documenting.
There was a problem hiding this comment.
I replaced Page.ActionButton with moredistinct Page.PrimaryAction, Page.SecondaryAction, and Page.Menu.
Hopefully, this way we "document" our opinion on the page structure (at most these three action slots) while making each one self-documenting. I also added sensible Button defaults for both PrimaryAction and SecondaryAction, while accepting children (in case the consumers would want to provide a custom content).
| type="secondary" | ||
| /> | ||
| </Page.ActionButton> | ||
| <Page.ActionButton visible={!!showMenu}> |
There was a problem hiding this comment.
same idea for the ActionButtons, a visible prop feels odd to me. I wonder if we can avoid that and leverage the declarative style.
also kinda coming back to the topic of having N ActionButtons, it seems to me these are quite different.
one is a regular Button, and one is a Menu. while yes it will look the same at a glance, it has very different behavior.
how would someone know the default configuration? and how to build it?
I'm wondering about maybe a Page.PrimaryAction Page.SecondaryAction and Page.TertiaryAction or Page.ActionMenu
that way our opinions on how many elements should be there, in what order, and what they are is easy to understand and implement.
like with some other pieces, we'd still need to do a bit more work to create good simple defaults for people to use, and then also offer a "slot" where heavy customization can be applied - but they don't have to sort out the order/position/layout of these 3 actions.
There was a problem hiding this comment.
Responded in the comment above.
| ...rest | ||
| }: { | ||
| readonly title: ReactNode; | ||
| readonly titleMetaData: ReactNode; |
There was a problem hiding this comment.
currently titleMetaData is optional. let's stick to that unless there's a compelling reason to make it mandatory.
https://github.com/GetJobber/atlantis/blob/master/packages/components/src/Page/Page.tsx#L37
There was a problem hiding this comment.
I left it as optional on Page.Title.
|
after trying to build with these pieces, I would say there are a handful of things we should strongly consider changing and a few others that would also be worthwhile improvements. that said, this is a great start! one final piece of FUD I have is what we are asking of consumers through all of this. if someone has an existing Page instance, and all they want is to apply data attributes to their actions - there's no way for them to only replace that module. they are going to have to fully refactor their entire that's a pretty big chunk of work for a relatively small addition. something to keep in mind. |
| import { type SectionProps } from "../Menu"; | ||
|
|
||
| export type ButtonActionProps = ButtonProps & { | ||
| ref?: React.RefObject<HTMLDivElement | null>; |
There was a problem hiding this comment.
ugh, if we get that ref support on Button done, this can go away. I was wondering why we had these extra refs that we are stripping from the props with that extra helper function.
|
whoops, I forgot to try the entire point of all this 😅 I feel like giving the data attributes to a bit to sort out with the defaults cases, but for the fully custom where it's acting as a slot you'd just do another argument for more atomic pieces for defaults that we receive the props for and can abstract with a very thin layer. oh also |
- Uses the same TS overload pattern as Menu - Replaces ts-xor XOR with native discriminated union - Props-based usage is fully preserved; composable mode activates when title is not passed as a prop - Page.Menu uses the composable Menu internally, enabling consumers to provide Menu.Item children compatible with client-side routers - PrimaryAction/SecondaryAction provide sensible Button defaults but accept children for custom elements - All compound components support data-attributes via filterDataAttributes
- Creates PageNotes.mdx covering all sub-components, the slot pattern for custom actions, and an explanation of TSR integration via createLink(Menu.Item).
- Introduces multiple story variations for the Page component. - Demonstrates the use of Menu and Button components within the Page actions. - Highlights the flexibility of the Page component with custom action elements and additional metadata options.
| function isLegacy(props: PageProps): props is PageLegacyProps { | ||
| return "title" in props; |
There was a problem hiding this comment.
This follows the same overload + discriminator pattern as we have in Menu.tsx. The discriminant is title - the legacy API always requires it, the composable API uses Page.Title instead.
| /** Props-based renderer. Preserves the original Page behavior for existing consumers. */ | ||
| function PageLegacy({ |
There was a problem hiding this comment.
I have intentionally reverted this component to a near-exact copy of the original Page implementation (i.e. before Scott's PR/changes). The only additions I've made are ...rest / filterDataAttributes for data-attribute passthrough.
| * "More Actions" menu with a default trigger button. | ||
| * Consumers supply Menu.Item children (in case custom routing is needed, | ||
| * e.g. wrapping Menu.Item with createLink() from TanStack Router). | ||
| */ | ||
| function PageMenu({ |
There was a problem hiding this comment.
This would be the key piece needed for custom routing integrations (e.g. tanstack-router. We would handle the trigger button here, so consumers would only need to provide Menu.Item children.
Then in consumer apps, devs could wrap Menu.Item with router link (e.d. createLink() in case of TSR`), which would give "right-click" / "ctrl-click" support without Atlantis needing to be aware or tied to a custom routing solution.
| interface PageWithIntroProps extends PageBaseProps { | ||
| /** | ||
| * Content of the page. This supports basic markdown node types | ||
| * such as `_italic_`, `**bold**`, and `[link name](url)`. | ||
| */ | ||
| readonly intro: string; | ||
|
|
||
| /** | ||
| * Causes any markdown links in the `intro` prop to open in a new | ||
| * tab, i.e. with `target="_blank"`. | ||
| * | ||
| * Can only be used if `intro` prop is also specified. | ||
| * | ||
| * Defaults to `false`. | ||
| */ | ||
| readonly externalIntroLinks?: boolean; | ||
| } | ||
|
|
||
| interface PageWithoutIntroProps extends PageBaseProps { | ||
| readonly intro?: never; | ||
| readonly externalIntroLinks?: never; | ||
| } | ||
|
|
||
| export type PageLegacyProps = PageWithIntroProps | PageWithoutIntroProps; |
There was a problem hiding this comment.
Instead of relying on a 3rd party XOR, I replaced it with a native TS discriminated union using never types. This gives the same compile-time enforcement (can't pass externalIntroLinks without intro) without needing the external dependency.
There was a problem hiding this comment.
I'm fine with this for now 👍
However the big caveat is that making discriminated unions by hand does not scale well IIRC. If we need to add another case, or even just another prop, it's just more work that we need to make sure we handle manually.
There was a problem hiding this comment.
I think the complexity of adding another never or and interface would be at max equivalent to creating a new interface or type for XOR. But I'm probably not considering the scenarios that you have in mind, so I'm curious to know what type of scaling issues there are that we might face in the future.
There were discussions in the past to avoid using XOR, so that is why I wanted to substitute it with a "native" solution.
There was a problem hiding this comment.
I think Button.types.ts might include some of our most complex types. But Page is less complex and I'm fine with the manual approach 👍
| @@ -4,6 +4,8 @@ import { Heading, StatusLabel, Tooltip } from "@jobber/components"; | |||
| import { Page } from "@jobber/components/Page"; | |||
There was a problem hiding this comment.
Not a blocker, but would love to see this file migrate to the v9 storybook!
On master, try this prompt .claude/skills/migrate-stories/SKILL.md with Cursor or whatever agent you're currently using.
| @@ -4,7 +4,9 @@ import { | |||
| Glimmer, | |||
There was a problem hiding this comment.
Just thinking out loud, nothing to action here:
I love our visual tests, but I think we have room to improve and evolve them. Instead of one massive page which is difficult to visually diff (see video below), I think we should have a screenshot-per-test-case workflow.
There's a variety of ways we could implement that.
For example, could we rename our test file to .tsx, and write the render logic inline instead of loading pages.. just like our jest suites? (e.g. page.visual.ts -> page.visual.tsx).
Or, could we continue writing code in this file, but split it up into test cases that activate based on a url hash.. so instead of await page.goto("/visual-tests/page") we would have something like await page.goto("/visual-tests/page#composable-with-actions") and a bunch of other test cases.
And when I mean the screenshots are difficult to visually diff, this is what I mean.. the image is so long I can't actually tell if anything else important changed! 😅
Screen.Recording.2026-02-11.at.1.43.37.PM.mov
There was a problem hiding this comment.
I agree! When I saw this long screenshot I was also "not impressed". So it would be great to try one of the approaches that you suggested! Just not in this PR, ideally 😅.
| */ | ||
| readonly moreActionsMenu?: SectionProps[]; | ||
| /** Discriminates between the props-based API and the composable children API. */ | ||
| function isLegacy(props: PageProps): props is PageLegacyProps { |
There was a problem hiding this comment.
Is the non-composable Page truly considered legacy now?
In other cases, such as Button or Popover, we support both the old non-composable and the new composable APIs, and we don't use the "legacy" terminology.
If we're deprecating the non-composable Page, does that mean we have a bit more work to do? For example, Autocomplete v2 has a docs switcher for legacy vs supported:
Or is the legacy terminology not implying deprecation? If that's the case, I might expect the type to be called PageNonComposableProps instead of PageLegacyProps. Oh... I'm realizing we actually don't have a set pattern for this... e.g. Button has ButtonWithChildrenProps and ButtonWithoutChildrenProps and Popover has PopoverProps and PopoverProviderProps 😅. And I just noticed MenuLegacyProps which I think is where you got this strategy from!
Okay this is not a blocker, we can definitely ship this as is and in the future adjust the type name if we determine a better naming pattern
There was a problem hiding this comment.
so for Menu, I did call it "legacy" but that's all internal. nothing, to the best of my knowledge, references or cites "legacy" on the consumer side. I did it mostly for our sake, so it's easier to follow and make changes to the two distinct code paths. assuming I did it right, and didn't expose anything we are free to change the naming conventions internally as we please if we feel that "legacy" isn't the best way to refer to it.
I wouldn't necessarily say the old Menu stil is deprecated or "legacy" in the way that say InputTextv1 is "legacy" in the docs.
I agree, we should have a plan or conversation about how we want to position and document these component because they are starting to add up, and they don't all follow the same pattern.
There was a problem hiding this comment.
I did follow the Menu naming convention, mainly because it was the first component that I found to reference which included both "sensible defaults" as well as a compositional pattern.
I also did spend some time going back and forth between different naming patterns, like PageBase and PageComposable, and other variations, partially because of the same concern that to some legacy might be synonymous to "deprecated". But ultimately I wanted to use a "familiar" pattern that was already present in the code base.
I'm not "married" to this specific naming, so if you guys have other stronger preference, I'm happy to change it to something more appropriate.
| export function Page(props: PageLegacyProps): ReactElement; | ||
| export function Page(props: PageComposableProps): ReactElement; |
There was a problem hiding this comment.
I know Menu does this too, but I'm unclear on what this actually provides.
Type overloads are one way to show end users different method signatures, but the other way is discriminated unions, which we already do via PageProps below: export function Page(props: PageProps). So as far as I can tell, the function overloads don't provide anything on top of that. Perhaps I'm missing some use case though 🤔
I also asked an LLM whether overloads are better for react components, and it seemed to recommend discriminated unions.. but again, perhaps its reasoning is missing context as well.
Will tag @ZakaryH since I think he'll know why Menu needed function overloads.
There was a problem hiding this comment.
the reason for the overload is to avoid this, where it is mixing items and the new invocation that has no need for that prop at all - yet the types are silent and give you no hints or indication that this is not right.
vs
I wanted to make it clear that it's a one-or-the-other. you cannot mix and match the two invocation styles.
There was a problem hiding this comment.
⬆️ ⬆️ ⬆️ Exactly what Zak said. We get better/correct TS support to prevent us from mixing 2 different approaches to call the component that has separate/"conflicting" implementations (prop-driven VS composable).
There was a problem hiding this comment.
Great, thanks for explaining!
I guess I'm confused why the discriminated union of the props (MenuLegacyProps | MenuComposableProps) is not preventing incorrect prop combinations from erroring as expected. Anyway, I'll give this a test to see it in action 👍
There was a problem hiding this comment.
it's because of children. React implicitly allows children through its default props, so that means both sets of props have children, meaning items and children can't signal that this is incorrect.
we'd have to explicitly forbid children on legacy for it to do the same thing
readonly children?: never;
trying that out, the type error is a little bit nicer. maybe that would be preferable to the overload.
that said, on Page - it does genuinely accept children as a prop so I'm not sure we'd be able to do the same thing.

Motivations
We chatted in a recent meeting about refactoring Page to be built from composed parts + using data-attributes.
Using visual regression tests as a refactor helper, I was able to refactor page to keep the exact same structure as it does today while exposing a variety of new compound pieces to build in a compositional way if required.
Each compound piece accepts data-attributes, so in the edge case where someone needs very specific data-attributes on pieces of Page, they can now accomplish that! :)
Changes
Code Notes
We could prooooobably break down that ternary statement in PageTitleMeta that's deciding what to render. That still feels a bit smelly to me.
Testing
Page should work completely 100% as-is with no changes by consumers.
However, you should also now be able to build composed pages for advanced use cases as well!
Changes can be
tested via Pre-release
In Atlantis we use Github's built in pull request reviews.