Skip to content

feat(kbd,separator,collapsible): micro primitives for everyday parity - #626

Merged
nhobes merged 6 commits into
mainfrom
feat/kbd-separator-collapsible
Aug 24, 2026
Merged

nhobes merged 6 commits into
mainfrom
feat/kbd-separator-collapsible

Conversation

@mplatts

@mplatts mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member

Closes #604

Three micro primitives in one PR, as the issue asked: <.kbd>, <.separator>, <.collapsible>. All pure HEEx + CSS + Phoenix.LiveView.JS. No hooks, no new dependencies, no Alpine.

What shipped

<.kbd> - lib/petal_components/kbd.ex

Semantic <kbd> chips. Single key through the default slot, or a sequence through keys={["cmd", "K"]} which renders one <kbd> per key with aria-hidden separator glyphs between them. A private symbol map folds known names to their glyph, case-insensitively, and anything unknown renders verbatim. Two sizes, sm and md.

The existing shared .pc-kbd rule was extended rather than forked, so the command palette trigger and input group addons pick up the key cap treatment for free. The treatment is an inset box-shadow border with a heavier bottom edge rather than a real border, specifically so existing consumers get the new look without their box size changing - the palette trigger and the search field addon both sit inside tight chrome where 2px would show.

<.separator> - lib/petal_components/separator.ex

A hairline with no margin of its own. Optional label via the attr or the slot (slot wins), positioned start / center / end by collapsing one flank to a stub. Vertical mode is a w-px self-stretch rule; the caller sets the height. decorative defaults to true (aria-hidden, no role) to match Radix, and decorative={false} renders role="separator" plus aria-orientation="vertical" only when vertical.

Typography.hr/1 is untouched. Both moduledocs now say which one to reach for.

<.collapsible> - lib/petal_components/collapsible.ex

One disclosure region. The trigger is a real <button type="button"> carrying aria-expanded and aria-controls; the panel is role="region", labelled by the trigger, and inert while collapsed so it is out of the tab order and out of the a11y tree instead of being an invisible trap. disabled uses the native attribute. Chevron rotation is synced to state on both the server render and the client toggle.

Toggling is JS.toggle_attribute on data-state / aria-expanded / inert plus JS.toggle_class on the chevron, composed with the caller's on_toggle through compose_js/2 (user commands run first). The open attr is the server-rendered state, so LiveView can drive it by re-rendering.

The height animation is grid-template-rows: 0fr -> 1fr on a grid wrapper with the inner row clipping, and it is dropped entirely under prefers-reduced-motion: reduce.

Deviations from the issue's API sketch

Four, all small:

  1. esc maps to Esc, not ⎋. Every other name in the map folds to a glyph. ⎋ is obscure enough that most people read it as a rendering failure, and shadcn/reui both print Esc. Same reasoning for pageup / pagedown -> PgUp / PgDn.
  2. No interpolate-size: allow-keywords progressive enhancement on the collapsible. The issue floated it as an optional layer on top of the grid-rows baseline. Layering a height: auto transition over a grid-template-rows transition means two mechanisms animating the same box, and they fight on browsers that support both. The grid-rows approach already animates content of any height with nothing measured in JS, so the enhancement buys nothing and risks a double transition. Happy to add it if you disagree.
  3. The collapsible's JS command is public as toggle_collapsible/2, not toggle/2. These modules are imported unqualified by use PetalComponents, and exporting toggle/1,2 into every consumer's namespace is asking for a collision. The name is uglier; the collision isn't worth it.
  4. <.separator> ignores label when orientation="vertical" rather than rendering a broken flex row. Labelled vertical separators are out of scope per the issue, so this makes the out-of-scope combination degrade to a plain vertical rule. Asserted in the tests.

Implementation decisions worth a look

  • inert on the collapsed panel. The alternative was display: none, which cannot animate, or leaving it visible-but-clipped, which leaves focusable content reachable inside a zero-height box. inert toggles instantly on click while the visual collapse animates, which is the right tradeoff.
  • :id is a declared attr defaulting to nil, so assign_new/3 never fires. The component tests the value instead. Worth knowing if you copy the pattern.
  • The new CSS section is wrapped in its own @layer components { }. The tail of assets/default.css after line ~7015 is currently unlayered, which means those rules beat consumer utilities passed through class. Appending the new section inside a layer keeps class overrides working the way callers expect. Nothing existing was reflowed. The pre-existing unlayered tail is left alone - flagging it as a separate observation, not fixing it here.
  • Client toggle state survives server re-renders that do not change open. LiveView only diffs changed dynamics, so flipping an unrelated assign will not stomp a client-side toggle. Documented in the moduledoc. It is also why the playground's disabled dial does not close an already-open region.

Playground

New nav entry under Display: Primitives (/c/primitives). One page, a section per primitive, following the existing dial pattern.

  • Kbd: size dial, separator glyph dial (+, ·, then, none). Examples: a command palette hint row, the command trigger dogfooding the same chip, a shortcuts cheat sheet, a menu with trailing shortcuts.
  • Separator: orientation dial, label position dial, decorative toggle. Examples: the OR divider between a login form and OAuth, a date row in an activity feed, a vertical rule in a toolbar.
  • Collapsible: open and disabled dials. Examples: an advanced options block in a settings form, a changelog entry, an API keys list.

Verification

Ran in the playground at mix run dev.exs, both themes:

  • Every dial on all three sections.
  • Collapsible toggled by mouse and by keyboard only (Tab to the trigger, Enter) - focus ring lands on the trigger, focus stays there across toggles, chevron rotates, content expands and collapses.
  • Command palette trigger and input group kbd addon re-checked after the shared .pc-kbd change: both render at the same size with the new cap treatment, in light and dark.

Screenshots were captured locally during the walkthrough but are not attached here - happy to drop light/dark captures in a comment if you want them on the PR before review.

Tests

Suite Before After
mix test 913 tests, 0 failures, 1 skipped 949 tests, 0 failures, 1 skipped
npm test 157 passing 157 passing (no JS hook, so nothing to add)

36 new component tests across test/petal/kbd_test.exs, separator_test.exs and collapsible_test.exs. Every attr, variant and slot has at least one rendering assertion, and the a11y attributes are asserted explicitly.

mix format --check-formatted is clean. mix credo reports the same issues as main (verified by stashing) - nothing new in the added files.

Three small parts every app ends up hand-rolling, shipped together
because each is under a day of work. Pure HEEx + CSS + LiveView.JS.
No hooks, no new deps, no Alpine.

- <.kbd>: semantic <kbd> chips. Single key via the slot, or a sequence
  via keys={["cmd", "K"]} with aria-hidden separators between. A private
  symbol map folds known names (cmd, shift, alt, ctrl, enter, esc, tab,
  backspace, delete, space, the arrows, pageup/pagedown, capslock) to
  their glyph case-insensitively; anything else renders verbatim. Sizes
  sm and md. Adopts and extends the existing shared .pc-kbd rule rather
  than forking it, so the command palette trigger and input group addons
  pick up the key cap treatment (inset border, heavier bottom edge)
  without changing size.
- <.separator>: hairline divider with no margin of its own. Optional
  label via attr or slot, positioned start/center/end. Vertical mode is
  a w-px self-stretch rule. decorative defaults to true (aria-hidden, no
  role) per Radix; decorative={false} renders role="separator" plus
  aria-orientation only when vertical. Typography.hr/1 is untouched and
  stays the prose rule.
- <.collapsible>: one disclosure region, distinct from accordion. Real
  <button type="button"> trigger with aria-expanded and aria-controls,
  a role="region" panel that is inert while collapsed, chevron rotation
  synced to state, native disabled. Client toggle via LiveView.JS
  toggle_attribute/toggle_class, composed with the caller's on_toggle.
  The open attr is the server-rendered state so LiveView can drive it.
  Height animates with grid-template-rows 0fr -> 1fr and drops out under
  prefers-reduced-motion.

Showcase: 5 kbd, 6 separator, 4 collapsible examples in the shared
registry, so the playground and petal.build render the same source.
Playground: /c/primitives, one page with a section per primitive -
kbd size + separator glyph dials, separator orientation, label position
and decorative dials, collapsible open + disabled dials, plus real
product moments (shortcuts cheat sheet, menu shortcuts, OR divider,
activity feed date row, toolbar, advanced options block).

36 new component tests. 949 Elixir (was 913) / 157 JS, all green.
Live-verified in the playground: every dial, light and dark, keyboard-
only toggle of the collapsible, and the command trigger + input group
kbd addons unregressed after the shared rule change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.61%. Comparing base (04769cc) to head (474dbe4).
⚠️ Report is 25 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #626      +/-   ##
==========================================
+ Coverage   92.40%   92.61%   +0.21%     
==========================================
  Files         119      125       +6     
  Lines        5066     5211     +145     
==========================================
+ Hits         4681     4826     +145     
  Misses        385      385              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mplatts

mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Screenshots

All three primitives live on one playground page, /c/primitives. Captured at 1280px wide; each pair below is that page scrolled to the relevant section.

Kbd

Kbd, light
Kbd, dark

Separator

Separator, light
Separator, dark

Collapsible

Advanced options expanded by click; Open on render expanded by default.

Collapsible, light
Collapsible, dark


Two things surfaced while capturing these, both worth a decision before merge:

1. The vertical separator is faint. Two independent reviewers looked at the Bold | Italic | Link | Code toolbar and read the vertical rule the same way: legible, but a single-pixel gray-200 hairline against white that could be mistaken for whitespace on a quick scan. The horizontal variant gets away with gray-200 because its label and spacing reinforce it; the vertical one has nothing to lean on. Suggest bumping the vertical variant to gray-300.

2. One combined page breaks the playground's 1:1 convention. main has exactly 60 nav slugs and 60 render_page/1 clauses - one page per component. This PR adds a single primitives slug covering three components, so Kbd, Separator and Collapsible don't appear in the playground nav by name and can't be linked to individually. The showcase registry does register all three separately, so petal.build docs will have three pages while the playground has one. Splitting into three slugs would restore the convention.

Neither is a correctness problem and both are quick to change - flagging rather than assuming.

Images live on the pr-assets branch, which exists only to host PR screenshots - it never merges to main and ships in no Hex release.

mplatts and others added 2 commits August 12, 2026 19:37
gray-200 works for the horizontal separator, which sits in open layout
with a label and whitespace reinforcing it. The vertical rule is a bare
hairline wedged between toolbar buttons, where the same value reads as a
gap rather than a divider. Two independent visual reviews flagged it.

gray-300 in light, gray-700 in dark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The playground has always been a strict 1:1 map - one nav slug per
component, one render_page clause per slug. The kbd/separator/collapsible
branch broke that by wiring all three into a single "primitives" page, so
none of them appeared in the nav by name, none could be linked to on their
own, and the showcase registry (which registers all three separately) was
about to give petal.build three doc pages against the playground's one.

Split into "kbd", "separator" and "collapsible": three nav entries in the
Display group (collapsible next to accordion), three render_page clauses,
each with its own intro, examples, dials and Properties table for just that
component. The shared @prim assign and ctl_prim handlers become per-component
@kbd / @Separator / @collapsible state with ctl_kbd / ctl_separator /
ctl_collapsible handlers, matching how every other single-component page in
dev.exs is structured. Every example and dial carries over unchanged.

Back to 63 slugs and 63 render_page clauses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mplatts added a commit that referenced this pull request Aug 12, 2026
@mplatts

mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Both review points addressed

1. Vertical separator darkened (98a16a0) — gray-200 → gray-300 in light, gray-800 → gray-700 in dark, vertical variant only. The horizontal rule keeps gray-200: it sits in open layout with a label and whitespace reinforcing it, while the vertical one is a bare hairline wedged between toolbar buttons.

A second reviewer checked the result at 4× and made a point worth recording: the divider now sits darker than the toolbar's own container border, which is the relationship you want. At gray-200 the divider and the frame were competing at equal weight and the eye read the whole control as one undifferentiated pill. Their recommendation was to stop here rather than go louder, since a separator more prominent than the labels beside it would be worse.

2. Split into one page per component (e8ef49b) — the combined primitives slug is gone, replaced by collapsible, kbd and separator. Playground is back to a strict 1:1 mapping: 63 slugs, 63 render_page/1 clauses. Shared @prim state became per-component kbd: / separator: / collapsible: assigns and ctl_prim became ctl_kbd / ctl_separator / ctl_collapsible, matching the shape neighbouring single-component pages already use. Every example and dial carried over; nothing was trimmed.

All three pages were confirmed to render their own component rather than the silent button fallback, and each ctl_* handler was exercised rather than assumed: separator orientation flips to the vertical toolbar, decorative off puts role="separator" on the element, collapsible open flips aria-expanded, kbd size/separator round-trip.

Verified independently

Check Result
mix test 949 tests, 0 failures, 1 skipped
npm test 157 passing
mix format --check-formatted clean
slugs : clauses 63 : 63

Screenshots (regenerated after the split)

Kbd

Kbd, light
Kbd, dark

Separator

Vertical rule at the new gray-300, in the Bold | Italic | Link | Code toolbar.

Separator, light
Separator, dark

Collapsible

Advanced options expanded in both schemes.

Collapsible, light
Collapsible, dark

…y names, real none

Audit round. (1) A labelled separator hid its own LABEL from assistive
tech: the decorative default aria-hid the whole container (the label is
real content - only the flank lines should hide), and the semantic form
put role=separator on an element whose children that role makes
presentational, swallowing the text. Decorative+labelled now carries no
aria at all; semantic+labelled carries aria-label with the text. Both
pinned, plus the previously-untested slot-only labelled path. (2) The
exceed-shadcn move on kbd: symbol-mapped keys speak their canonical
names via aria-label (VoiceOver reads nothing for several of the bare
glyphs) - Command/Shift/Option/arrows and friends; verbatim keys stay
unlabelled. (3) The playground's 'none' separator dial was a lie: it
sent a space, which still rendered an aria-hidden whitespace span plus
the group gap. separator={nil} now renders no separator span at all,
and the dial maps through it. (4) :rest attrs in all three modules
gained doc strings for the MCP surface. 954 tests green.
@nhobes

nhobes commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Polish pass done. Deviations for your sign-off when reviewing (all deliberate, none blocking):

  • Three pages instead of the brief's single /c/primitives page - Matt made this call explicitly (head commit message says so). It matches the sidebar's one-page-per-family taxonomy, so I'd bless it; the PR description should just say so.
  • The brief's 'Starred projects sidebar' collapsible example isn't in the playground page - fine to omit or add later; note it when closing Component: Kbd, Separator & Collapsible (micro primitives) #604.
  • Vertical separator renders gray-300/700, one step darker than the brief's 'match pc-hr' - commented as deliberate in the CSS, just needs a line in the PR description's deviations list.
  • Light + dark screenshots still pending on the PR body per process step 4.

nhobes added 2 commits August 21, 2026 09:32
…the middle

Two maintainer eye-test reads. The activity-feed example start-aligned
its date label, which at feed width reads as an accident rather than a
choice - and centred is how feeds conventionally break days (the
label_positions example above still teaches start and end). The vertical
toolbar example gave the rule an explicit h-6, which defeats
self-stretch and lets flexbox park it at the TOP of the row - it now
pairs the height with self-center, and both the example description and
the moduledoc spell out the trap.
…llapsible

# Conflicts:
#	CHANGELOG.md
#	assets/default.css
#	dev.exs
@nhobes
nhobes marked this pull request as ready for review August 24, 2026 03:05
@nhobes
nhobes merged commit 86ef5c5 into main Aug 24, 2026
3 checks passed
@greptile-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds public keyboard-chip, separator, and collapsible primitives, along with shared styling, documentation, tests, and playground examples. It also exposes the components through the main import surface.

  • Adds semantic single-key and shortcut-sequence rendering with key-name normalization.
  • Adds decorative or semantic horizontal/vertical separators with optional labels.
  • Adds a LiveView.JS-driven disclosure component with animated content and accessible trigger/panel relationships.
  • Extends the showcase, changelog, CSS bundle, and component tests for all three primitives.

Confidence Score: 3/5

The PR should not merge until collapsibles retain stable identity across LiveView patches and semantic rich-slot separators receive the correct accessible name.

Omitted collapsible IDs are regenerated during rerenders, which can discard client-local disclosure state, while semantic separators can expose an accessible name that differs from or omits their rendered slot label; the chevron cascade issue is visual and non-blocking.

Files Needing Attention: lib/petal_components/collapsible.ex, lib/petal_components/separator.ex, assets/default.css

Important Files Changed

Filename Overview
lib/petal_components/collapsible.ex Adds the disclosure API and synchronized DOM toggles, but autogenerated IDs are unstable across LiveView rerenders and can reset client state.
lib/petal_components/separator.ex Adds flexible visual and semantic separators, but semantic rich-slot labels can receive a missing or stale accessible name.
lib/petal_components/kbd.ex Adds semantic key and shortcut-sequence rendering with normalized glyphs and accessible names; no actionable defect identified.
assets/default.css Adds styles for all three primitives, with a non-blocking cascade weakness in the collapsible chevron sizing rule.
lib/petal_components.ex Exposes all three new components through the intended public import boundary.
dev.exs Adds playground navigation, controls, and examples for the new primitives.
test/petal/collapsible_test.exs Covers static markup and encoded toggle commands but does not exercise an omitted-id disclosure across LiveView patches.
test/petal/separator_test.exs Covers semantic labels and rich slots independently but misses their problematic combined path.
test/petal/kbd_test.exs Thoroughly covers rendering variants, symbol mapping, separators, passthrough attributes, and accessible names.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Server render: open attr] --> B[Collapsible DOM state]
  C[Trigger click] --> D[LiveView.JS toggles attributes]
  D --> B
  B --> E[data-state drives panel animation]
  B --> F[aria-expanded describes trigger state]
  B --> G[inert controls content accessibility]
  H[Later LiveView patch] --> A
Loading

Reviews (1): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

# the value, not the key.
assigns = if assigns.id, do: assigns, else: assign(assigns, :id, uniq_id("collapsible"))

~H"""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Generated IDs reset disclosure state

When a LiveView rerenders a collapsible without an explicit id, uniq_id/1 assigns a new root, trigger, and content identity, causing client-selected open state to reset and external toggle commands holding the former ID to target elements that no longer exist.

Knowledge Base Used: Component runtime and assets

# role="separator" swallows; rich slot content should pass the label attr
# alongside for this reason.
defp labelled_aria(true, _label), do: %{}
defp labelled_aria(false, label), do: %{role: "separator", "aria-label": label}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Slot labels lose accessible names

When decorative={false} is combined with slot-only content or a slot that overrides label, the visible text comes from the slot while aria-label still comes from @label, causing assistive technology to announce an unnamed or stale separator label.

Knowledge Base Used: Layout, navigation, and content primitives

Comment thread assets/default.css
Comment on lines +9054 to +9057
.pc-collapsible__chevron.pc-collapsible__chevron {
width: 1rem;
height: 1rem;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Chevron sizing loses the cascade

In Tailwind v4 consumers where Heroicons styles are emitted in the utilities layer or unlayered CSS, these ordinary width and height declarations lose the cascade, leaving the collapsible chevron oversized or misaligned instead of preserving its intended 1rem geometry.

Suggested change
.pc-collapsible__chevron.pc-collapsible__chevron {
width: 1rem;
height: 1rem;
}
.pc-collapsible__chevron.pc-collapsible__chevron {
width: 1rem !important;
height: 1rem !important;
}

Knowledge Base Used:

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Component: Kbd, Separator & Collapsible (micro primitives)

2 participants