Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/tall-pillows-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@batik-prototype/core': minor
---

Add `Accordion`, a stack of disclosure rows in the same token-driven shape as the rest of
the set: `contained` groups the rows on one surface and divides them with the border token,
`separated` gives each row its own outline.

It is `<details>` and `<summary>` underneath, so the keyboard, the screen reader and
find-in-page behaviour are the platform's. `exclusive` names the group rather than tracking
which row is open, which is why the rows arrive as an `items` array instead of as children:
nothing has to read the group's name or variant out of React context to render.
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
# The changeset gate below diffs against `origin/main`, and the default
# shallow clone has neither that ref nor enough history to find where
# this branch left it. Same reason release.yml asks for it.
fetch-depth: 0

# setup-vp installs Vite+, Node and pnpm, and caches package-manager data.
# The version is pinned deliberately: the docs call out that the `v1` tag
Expand Down
78 changes: 77 additions & 1 deletion apps/example/src/showcase.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Badge, Button, Card, Input } from '@batik-prototype/core';
import { Accordion, Badge, Button, Card, Input, type AccordionItem } from '@batik-prototype/core';
import { color } from '@batik-prototype/core/tokens/color.stylex';
import { font } from '@batik-prototype/core/tokens/font.stylex';
import { radius } from '@batik-prototype/core/tokens/shape.stylex';
import { space } from '@batik-prototype/core/tokens/space.stylex';
import * as stylex from '@stylexjs/stylex';
import { useId, type ReactNode } from 'react';
Expand Down Expand Up @@ -33,8 +34,73 @@ const styles = stylex.create({
cardTitle: { fontSize: font.sizeMd, fontWeight: font.weightSemibold, margin: 0 },

cardBody: { color: color.muted, fontSize: font.sizeSm, margin: 0 },

code: {
backgroundColor: color.neutralSurface,
borderRadius: radius.sm,
color: color.onNeutralSurface,
fontFamily: font.familyMono,
fontSize: '0.85em',
paddingInline: space.xs,
},
});

const FAQ: readonly AccordionItem[] = [
{
id: 'tokens',
title: 'Where do the colours come from?',
content: (
<p {...stylex.props(styles.cardBody)}>
Every one of them is a StyleX variable declared by{' '}
<code {...stylex.props(styles.code)}>@batik-prototype/core</code>. A theme overrides the
variable; this row never learns which theme won.
</p>
),
defaultOpen: true,
},
{
id: 'platform',
title: 'What is this built on?',
content: (
<p {...stylex.props(styles.cardBody)}>
A <code {...stylex.props(styles.code)}>&lt;details&gt;</code> and a{' '}
<code {...stylex.props(styles.code)}>&lt;summary&gt;</code>, so the keyboard and the screen
reader work without a line of JavaScript. Opening one row closes the others because they
share a <code {...stylex.props(styles.code)}>name</code>, which is a browser feature rather
than a component one.
</p>
),
},
{
id: 'locked',
title: 'A row that cannot be opened',
content: <p {...stylex.props(styles.cardBody)}>Unreachable, by design.</p>,
disabled: true,
},
];

const SEPARATED: readonly AccordionItem[] = [
{
id: 'radius',
title: 'Ocean rounds the corners',
content: (
<p {...stylex.props(styles.cardBody)}>
The radius token moves and every row follows, in both variants at once.
</p>
),
},
{
id: 'divider',
title: 'Sunset squares them off',
content: (
<p {...stylex.props(styles.cardBody)}>
In the contained variant the divider between rows is the border token, so a theme that
thickens its outlines thickens these too.
</p>
),
},
];

function Section({
title,
caption,
Expand Down Expand Up @@ -172,6 +238,16 @@ export function Showcase() {
</Card>
</div>
</Section>

<Section
title="Accordion"
caption="Contained shares one surface and divides it; separated gives every row its own."
>
<div {...stylex.props(styles.grid)}>
<Accordion exclusive items={FAQ} />
<Accordion variant="separated" items={SEPARATED} />
</div>
</Section>
</>
);
}
9 changes: 6 additions & 3 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,17 @@ anyway to compile your own StyleX. See [Setting up the compiler](#setting-up-the
| `Input` | Three sizes, `invalid`, disabled, themed placeholder |
| `Card` | `elevated` / `outlined`, four padding steps |
| `Badge` | Five tones: neutral, accent, success, warning, danger |
| `Accordion` | `contained` / `separated`, optional `exclusive` rows |
| `ThemeProvider` | Applies a theme, resolves the colour scheme |
| `useTheme` / `useColorScheme` | Read the active theme and the resolved scheme |
| `usePreferredColorScheme` | The OS preference on its own, kept live |
| `defineTheme` / `resolveTheme` | The theme contract, also at `@batik-prototype/core/theme` |

Components are plain elements underneath — `<button>`, `<input>`, `<div>`, `<span>` — and
forward every prop those accept, `ref` included. There is no wrapper, no portal and no
context lookup in the render path.
Components are plain elements underneath — `<button>`, `<input>`, `<div>`, `<span>`,
`<details>` — and forward every prop those accept, `ref` included. There is no wrapper, no
portal and no context lookup in the render path. `Accordion` takes its rows as an `items`
array for that last reason: a compound `<Accordion.Item>` would have to read the group's
variant and its `exclusive` group name out of context on every render.

## Themes

Expand Down
245 changes: 245 additions & 0 deletions packages/core/src/components/accordion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import * as stylex from '@stylexjs/stylex';
import type { StyleXStyles } from '@stylexjs/stylex';
import {
useId,
useState,
type ComponentPropsWithRef,
type ReactNode,
type SyntheticEvent,
} from 'react';

import { color } from '../tokens/color.stylex';
import { font } from '../tokens/font.stylex';
import { border, radius } from '../tokens/shape.stylex';
import { space } from '../tokens/space.stylex';

export type AccordionVariant = 'contained' | 'separated';

export type AccordionItem = {
/** Identifies the row across renders. */
readonly id: string;
/** The always-visible row that toggles the panel. */
readonly title: ReactNode;
/** Revealed while the row is open. */
readonly content: ReactNode;
/** Whether the row starts open. Defaults to `false`. */
readonly defaultOpen?: boolean;
/** A row that cannot be toggled. Defaults to `false`. */
readonly disabled?: boolean;
};

export type AccordionProps = Omit<
ComponentPropsWithRef<'div'>,
'children' | 'className' | 'style'
> & {
/** The rows, in the order they render. */
readonly items: readonly AccordionItem[];
/** How the rows are grouped. Defaults to `'contained'`. */
readonly variant?: AccordionVariant;
/** Whether opening a row closes the rest. Defaults to `false`. */
readonly exclusive?: boolean;
/** Extra StyleX styles, merged last so they win. */
readonly style?: StyleXStyles;
};

const styles = stylex.create({
group: {
display: 'flex',
flexDirection: 'column',
},

item: {
backgroundColor: color.surface,
color: color.foreground,
fontFamily: font.family,
},

itemSeparated: {
borderColor: color.border,
borderRadius: radius.lg,
borderStyle: 'solid',
borderWidth: border.width,
overflow: 'hidden',
},

// On the row rather than the summary, because a summary that ignores the
// pointer cannot set a cursor either.
itemDisabled: {
cursor: 'not-allowed',
},

summary: {
alignItems: 'center',
backgroundColor: { default: 'transparent', ':hover': color.surfaceHover },
cursor: 'pointer',
display: 'flex',
fontSize: font.sizeMd,
fontWeight: font.weightMedium,
gap: space.md,
justifyContent: 'space-between',
lineHeight: font.lineHeightTight,

// Two ways of saying the same thing, because browsers disagree on which one
// hides the disclosure triangle: any `display` other than `list-item` drops
// it in WebKit, `list-style` drops it everywhere else.
listStyleType: 'none',

outlineColor: color.ring,
// Inset, unlike Button's. `contained` clips the group, so a ring drawn
// outside the row would be cut off by that `overflow: hidden`.
outlineOffset: '-2px',
outlineStyle: { default: 'none', ':focus-visible': 'solid' },
outlineWidth: border.widthStrong,
paddingBlock: space.md,
paddingInline: space.xl,
transitionDuration: '120ms',
transitionProperty: 'background-color',
},

summaryDisabled: {
color: color.muted,
// Cheaper than cancelling the click: a summary the pointer cannot reach
// cannot be toggled, and `tabIndex={-1}` takes the keyboard route away too.
// Both routes have to go, since Enter on a focused summary *is* a click.
pointerEvents: 'none',
},

panel: {
fontSize: font.sizeMd,
lineHeight: font.lineHeightNormal,
paddingBlockEnd: space.xl,
paddingBlockStart: space.xs,
paddingInline: space.xl,
},

chevron: {
color: color.muted,
flexShrink: 0,
height: '1rem',
transform: 'rotate(0deg)',
transitionDuration: '120ms',
transitionProperty: 'transform',
width: '1rem',
},

chevronOpen: {
transform: 'rotate(180deg)',
},
});

const groups = stylex.create({
// The background is the divider: the group paints itself in the border
// colour and the rows cover all of it but the `gap`, leaving a hairline
// between neighbours and none above the first or below the last.
contained: {
backgroundColor: color.border,
borderColor: color.border,
borderRadius: radius.lg,
borderStyle: 'solid',
borderWidth: border.width,
gap: border.width,
overflow: 'hidden',
},

separated: {
gap: space.md,
},
});

function Chevron({ open }: { readonly open: boolean }) {
return (
<svg
aria-hidden="true"
viewBox="0 0 16 16"
{...stylex.props(styles.chevron, open && styles.chevronOpen)}
>
<path
d="m4 6 4 4 4-4"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</svg>
);
}

type RowProps = {
readonly item: AccordionItem;
readonly variant: AccordionVariant;
/** The `exclusive` group this row belongs to, or `undefined` for none. */
readonly name: string | undefined;
};

function Row({ item, variant, name }: RowProps) {
// A mirror of the element's own state, never the source of it: `open` below
// is an initial value and `details` takes it from there, including the rows
// the browser closes on its own inside an `exclusive` group - each of those
// fires `toggle` too, so this stays in step without being told.
//
// It exists because only the chevron needs to know. Turning it in CSS
// instead, off the ancestor `[open]`, would reach every chevron underneath an
// open row - and rotate the closed ones of a nested Accordion with it.
const [open, setOpen] = useState(item.defaultOpen ?? false);

function handleToggle(event: SyntheticEvent<HTMLDetailsElement>) {
setOpen(event.currentTarget.open);
}

return (
<details
name={name}
open={item.defaultOpen}
onToggle={handleToggle}
{...stylex.props(
styles.item,
variant === 'separated' && styles.itemSeparated,
item.disabled && styles.itemDisabled,
)}
>
<summary
aria-disabled={item.disabled || undefined}
tabIndex={item.disabled ? -1 : undefined}
{...stylex.props(styles.summary, item.disabled && styles.summaryDisabled)}
>
<span>{item.title}</span>
<Chevron open={open} />
</summary>
<div {...stylex.props(styles.panel)}>{item.content}</div>
</details>
);
}

/**
* A stack of disclosure rows.
*
* Rows arrive as data rather than as children, which is the one place this
* component departs from the rest of the set. A compound `<Accordion.Item>`
* would have to read the group's variant and its `exclusive` group name out of
* React context on every render, and nothing else here does that. Passing an
* array keeps the whole component a function of its props.
*
* Underneath it is `<details>` and `<summary>`, so the keyboard, the screen
* reader and find-in-page behaviour are the platform's rather than ours.
*/
export function Accordion({
items,
variant = 'contained',
exclusive = false,
style,
...props
}: AccordionProps) {
// One name per Accordion, which is the whole of `exclusive`: the browser
// closes the open row when another of the same name opens, so nothing here
// has to track which row that was.
const name = useId();

return (
<div {...props} {...stylex.props(styles.group, groups[variant], style)}>
{items.map((item) => (
<Row key={item.id} item={item} variant={variant} name={exclusive ? name : undefined} />
))}
</div>
);
}
Loading