Skip to content

feat: PaginationComponent (works with Pagy/Kaminari/anything) - #18

Open
PendragonDevelopment wants to merge 46 commits into
fix/lookbook-button-previewsfrom
feat/pagination-component
Open

feat: PaginationComponent (works with Pagy/Kaminari/anything)#18
PendragonDevelopment wants to merge 46 commits into
fix/lookbook-button-previewsfrom
feat/pagination-component

Conversation

@PendragonDevelopment

Copy link
Copy Markdown
Collaborator

Summary

Paginator-agnostic pagination component. Takes plain props (`current_page`, `total_pages`) + a `page_url` Proc; optionally a pre-truncated `series` array so Pagy's `pagy.series` works directly.

Variants

  • `:numeric` — `← 1 2 3 … 9 10 →` joined in one bordered pill
  • `:numeric_loose` — same entries, each button separately bordered
  • `:simple` — "Page X of Y" label + prev/next arrows. `label_position:` = `:left` / `:right` / `:center` / `:hidden`

Rows-per-page selector

Pass `rows_per_page`, `rows_per_page_options`, and `rows_per_page_url` to render a native `` alongside the arrows. Pagy integration ```erb <%= render Shipwright::PaginationComponent.new( current_page: @pagy.page, total_pages: @pagy.pages, series: @pagy.series, page_url: ->(p) { url_for(page: p) } ) %> ``` Pagy's `pagy.series` returns string-encoded page numbers and `:gap` for ellipsis — both are normalized internally. Accessibility `` wrapper Current page: `` (not a link) Disabled prev/next: `` (not a link) Each page link has `aria-label="Go to page N"` Test plan [x] 291 tests / 509 assertions passing (+16 new) [x] Previews: numeric, numeric_loose, simple (left/right/center), rows_per_page, first_page, last_page, auto_series, pagy_example [x] Showcase page section [ ] Wire up with real Pagy in a host app to confirm URL generation 🤖 Generated with Claude Code

Matches Shipwright Pro's Badge atom (Figma node 2013:5696). Renders as
either a colored dot (no content) or a text badge (with content block).

New tokens in engine.css (Utility namespace):
- --color-utility-neutral-default: #292c39
- --color-utility-information-default: #206ac7
- --color-utility-positive-default: #48a356
- --color-utility-warning-default: #c09c17
- --color-border-inverse: #fdfdfd (for the white badge border)

BaseComponent MERGER config updated to recognize the new color tokens
so consumer class overrides resolve conflicts correctly.

API:
  <%= render Shipwright::BadgeComponent.new(sentiment: :positive, size: :md) %>
  <%= render(Shipwright::BadgeComponent.new(sentiment: :informative)) { "20" } %>

Params:
- sentiment: :neutral (default), :informative, :positive, :negative, :warning
- size: :sm (8px dot), :md (12px dot), :lg (20px dot) — default :md
- class: consumer override

When a content block is present, the badge grows to fit the text with
min-width matching height (keeps single-char badges circular) and adds
inverse text color. Text badges work at all sizes; Figma only shows
:lg but the scaling down is consistent.

15 new tests pass. Total: 40 tests, 81 assertions.
Lookbook previews: neutral_dot, all_sentiments, all_sizes, text_badges,
count_notifications (showing single/double-digit/overflow cases).
New Shipwright::IconComponent renders inline SVG icons from a manifest
of Feather Icons (https://feathericons.com, MIT licensed). Matches
Shipwright Pro's Figma icon set (node 2:163).

Starter manifest includes 21 commonly-needed icons:
info, alert-circle, alert-triangle, check, check-circle, x, x-circle,
chevron-{up,down,left,right}, plus, minus, search, menu, external-link,
settings, arrow-{left,right}, help-circle, trash.

Icons live in app/components/shipwright/icons.rb as a frozen constant
hash of name => inner-SVG fragment. Adding a new icon = paste Feather's
inner markup into the hash.

API:
  <%= render Shipwright::IconComponent.new(name: :info) %>
  <%= render Shipwright::IconComponent.new(name: :check, size: :lg, class: "text-utility-negative-default") %>
  <%= render Shipwright::IconComponent.new(name: :info, "aria-label": "More info") %>

Props:
- name: required symbol/string matching a key in ICONS
- size: :sm (16px), :md (24px, default), :lg (32px)
- class: consumer override
- **html_attrs: pass-through (id, data-*, aria-*, etc.)

Icons use stroke="currentColor" so they inherit the parent's text color —
consumers control color via text-* utilities. Decorative by default
(aria-hidden=true); providing aria-label makes the icon meaningful and
skips aria-hidden.

13 new component tests pass. Total suite: 38 tests, 81 assertions.
Lookbook previews: default, gallery (all 21), sizes, colored,
with_aria_label.
New Shipwright::LinkComponent — inline text link primitive.
Matches Shipwright Pro's Link atom (Figma node 2015:15).

Colors pulled from Figma variables (no new tokens needed):
- Default: text-interactive-primary-default (#14161c)
- Hover: text-interactive-primary-hover (#595d6a)
- Pressed: text-interactive-primary-pressed (#333747)
- Disabled: text-interactive-disable (#eaeaea)

API:
  <%= render(Shipwright::LinkComponent.new(href: "/about")) { "About" } %>
  <%= render(Shipwright::LinkComponent.new(href: "#", decoration: :none)) { "Plain link" } %>
  <%= render(Shipwright::LinkComponent.new(href: "#", disabled: true)) { "Unavailable" } %>

Props:
- href: optional; omitted when disabled so the link isn't navigable
- decoration: :underline (default) or :none — maps to Figma's Decoration variant
- disabled: bool — adds aria-disabled and tabindex=-1, removes href
- class: consumer override
- **html_attrs: pass-through (target, rel, id, data-*, etc.)

Typography matches Figma's Label/Small spec: text-sm (14px) +
leading-4 (16px) + Manrope sans (from --font-sans token).
Hover/pressed/focus states use Tailwind pseudo-class modifiers so
browser events drive the visuals rather than JS state.

11 new tests pass. Total suite: 36 tests, 83 assertions.
Lookbook previews: default, no_decoration, disabled, all_states,
external.
# Conflicts:
#	test/dummy/app/assets/builds/tailwind.css
#	test/dummy/app/views/pages/components.html.erb
Implements the Banner molecule from Shipwright Pro Figma (node
2013:5697). Composes IconComponent and LinkComponent as optional
slotted atoms, per the monorepo-style component pattern.

New token in engine.css:
- --color-background-secondary: #f3f3f3  (Banner surface)

BaseComponent MERGER config updated to recognize background-secondary.

API:
  <%= render Shipwright::BannerComponent.new(header: "Alert") do |banner| %>
    <% banner.with_icon(name: :info) %>
    <% banner.with_link(href: "#") { "Link" } %>
    Place holder text for notifications.
  <% end %>

Props:
- header: optional string rendered in the leading cluster (e.g. "Alert")
- class: consumer override
- **html_attrs: pass-through (id, role, data-*, etc.)

Slots:
- with_icon(...) → renders a Shipwright::IconComponent (accepts any
  IconComponent props: name, size, class, etc.)
- with_link(...) { ... } → renders a Shipwright::LinkComponent (href,
  decoration, disabled, etc. + block content)

Content block → body text (main message).

Layout matches Figma: flex row with p-4, optional leading cluster
(icon + header) with pr-6 spacing, flex-1 body paragraph, optional
trailing link cluster with pl-4 spacing. Typography: text-sm with
leading-4 (16px) for header and leading-5 (20px) for body.

11 new tests pass. Total suite: 60 tests, 121 assertions.
Lookbook previews: default, body_only, icon_and_body, body_and_link,
header_and_body.
# Conflicts:
#	test/dummy/app/assets/builds/tailwind.css
#	test/dummy/app/views/pages/components.html.erb
Generated from feather-icons@4.29.2 icons.json plus two Shipwright
additions to match Figma's icon page (node 2:163):
- code-horizontal: alias for Feather's 'code' (matches Shipwright
  Pro's naming convention)
- star-filled: filled variant using the star polygon with
  fill=currentColor

Previously only 21 starter icons were shipped. This adds the remaining
~270 to cover every icon on the Shipwright Pro 'Icons / General' page.

Brand icons (payment methods at node 2:824 and social icons at
2:940) are multi-color composite SVGs served from Figma's temporary
CDN and need a different rendering approach — they'll ship in a
follow-up BrandIconComponent PR.

Gallery preview updated to render all icons in an 8-column grid.
Showcase page shows a curated sample plus pointer to Lookbook for
the full set.

All 38 existing tests pass (Icon manifest expansion preserves the
starter icons we already shipped).
# Conflicts:
#	test/dummy/app/assets/builds/tailwind.css
#	test/dummy/app/views/pages/components.html.erb
Brand icons (payment methods + social platforms) from Shipwright Pro's
Figma are multi-color composite SVGs that need different rendering than
Feather's monochrome stroke icons. Added as a parallel component:

  <%= render Shipwright::BrandIconComponent.new(name: :visa-color) %>

Architecture:
- SVGs live in app/assets/images/shipwright/brand/ (one file per icon)
- Component loads and memoizes the directory on first access
- Renders SVG inline (preserves brand colors, allows CSS styling)
- Strips intrinsic width/height, applies h-* class + w-auto so
  non-square icons (e.g., Visa) keep their aspect ratio
- Raises a helpful ArgumentError directing users to the exporter
  if they reference a missing icon

New files:
- script/export_brand_icons.rb: downloads all 59 brand SVGs from
  Shipwright Pro Figma via the Figma REST API. Maps every node ID
  from the Figma Icons/Payment Method and Icons/Social pages to a
  normalized kebab-case filename. Requires FIGMA_ACCESS_TOKEN env var.
- app/components/shipwright/brand_icon_component.rb: the component.
- test fixtures at test/fixtures/brand_icons/ so tests don't need
  Figma access. BrandIconComponent.asset_path is swappable per-test.
- 4 Lookbook previews including an empty_state scenario explaining
  how to run the exporter.

14 new component tests pass. Total suite: 52 tests, 111 assertions.

Usage:
  FIGMA_ACCESS_TOKEN=xxx ruby script/export_brand_icons.rb
  # Review, commit the SVGs. 59 icons covering:
  #   Payment: amex, visa, mastercard, applepay, paypal, discover,
  #            cash, cash-dollar, card-default (color/fill/outline)
  #   Social:  facebook, instagram, youtube, google, linkedin, apple,
  #            snapchat, pinterest, medium, angelist, slack, dribbble,
  #            figma, discord, clubhouse, tumblr, telegram, tiktok,
  #            vk, signal, reddit, github, fb-messenger, skype,
  #            spectrum, zoom, facetime, google-meet, behance,
  #            invision, microsoft
# Conflicts:
#	test/dummy/app/assets/builds/tailwind.css
Single collapsible section using native HTML <details>/<summary> — no
JavaScript required. Stack multiple AccordionComponents for an FAQ-style
group; native semantics handle toggle, keyboard navigation, and
accessibility out of the box.

API:
  <%= render(Shipwright::AccordionComponent.new(title: "FAQ question")) do %>
    Answer content here.
  <% end %>

  <%# Start expanded %>
  <%= render(Shipwright::AccordionComponent.new(title: "T", open: true)) { "..." } %>

Props:
- title: required string rendered in the summary
- open: bool (default false) — sets the <details open> attribute
- class: consumer override on the <details> element
- **html_attrs: pass-through (id, data-*, etc.)

Content block → body text rendered below the summary.

Styling matches Shipwright Pro Figma (node 2013:5693):
- Default: text-interactive-primary-default, border-border-primary
- Hover: bg-background-secondary, text-interactive-primary-hover
- Focus-visible: 2px ring on interactive-primary-default
- Chevron icon (IconComponent name: :chevron-down) rotates 180deg
  when open via Tailwind's group-open: modifier — works natively with
  <details open>, no JS state management
- Default summary marker hidden cross-browser via list-none +
  ::-webkit-details-marker

New tokens:
- --color-border-primary: #eaeaea (subtle gray borders, matches
  Shipwright Pro Border/Primary)
- --color-background-secondary: #f3f3f3 (hover surface, matches
  Shipwright Pro Interactive/Secondary-Hover)

BaseComponent MERGER config updated with both new color tokens.

Uses IconComponent for the chevron (branch includes the icon-component
merge for that dependency).

12 new tests pass. Total suite: 64 tests, 141 assertions.
Lookbook previews: default, initially_open, stacked_group, long_body.
# Conflicts:
#	app/assets/tailwind/shipwright/engine.css
#	app/components/shipwright/base_component.rb
#	test/dummy/app/assets/builds/tailwind.css
#	test/dummy/app/views/pages/components.html.erb
Animates open/close via the native <details> element's ::details-content
pseudo-element, combined with interpolate-size: allow-keywords to enable
transitioning to/from height: auto. No JavaScript required.

- [&::details-content]:h-0 when collapsed, h-auto when [open]
- 200ms transition on height + content-visibility (the latter needs
  transition-behavior allow-discrete which Tailwind 4 handles via
  the content-visibility transition entry)
- Chevron rotation timing already matches at 200ms

Body gets pt-2 (8px) for breathing room below the summary — matches
Shipwright Pro's spacing scale.

Browser support: Chromium 129+, Safari 18.2+ (late 2024). Firefox
gracefully falls back to instant toggle until interpolate-size ships.
No a11y impact — <details> still works natively on all browsers.
# Conflicts:
#	test/dummy/app/assets/builds/tailwind.css
Matches Shipwright Pro's Avatar atom (Figma node 2013:5694). Renders as
image, initials, or user-icon fallback with an optional status indicator
dot in the bottom-right corner.

API:
  <%= render Shipwright::AvatarComponent.new(src: "/john.jpg", alt: "John Legend") %>
  <%= render Shipwright::AvatarComponent.new(initials: "WS") %>
  <%= render Shipwright::AvatarComponent.new(alt: "Anonymous") %>   # user icon fallback
  <%= render Shipwright::AvatarComponent.new(src: ..., alt: ..., status: :online) %>

Props:
- src: optional image URL (alt required when given)
- alt: accessible name (required with src; used as aria-label on fallbacks)
- initials: optional string — auto-derives 1-2 char uppercase initials
  ("Wendy Sullivan" → "WS"). Falls back to using the string as-is if already <= 2 chars.
- size: :xxsm (16), :xsm (24), :sm (32), :md (40), :lg (56). Default :md.
- status: :online, :away, :busy, :offline — optional indicator dot
  mapped to utility color tokens (positive/warning/negative/neutral)
- class: consumer override
- **html_attrs: pass-through

Content priority: src > initials > user icon.

New tokens (Shipwright Pro utility palette):
- --color-utility-neutral-default: #292c39
- --color-utility-positive-default: #48a356
- --color-utility-warning-default: #c09c17
- --color-border-inverse: #fdfdfd  (for the status dot's ring against the avatar)

BaseComponent MERGER config updated for all four.

21 new tests pass. Total suite: 73 tests, 137 assertions.
Lookbook: image, initials, icon_fallback, sizes, content_types, status, with_meta.
# Conflicts:
#	app/assets/tailwind/shipwright/engine.css
#	app/components/shipwright/base_component.rb
#	test/dummy/app/assets/builds/tailwind.css
#	test/dummy/app/views/pages/components.html.erb
Horizontal navigation trail matching Shipwright Pro's Breadcrumb atom.
Renders a semantic <nav aria-label="Breadcrumb"> > <ol> with each item
as an <li>. The last item is automatically marked aria-current="page"
and rendered as a non-link.

API:
  <%= render Shipwright::BreadcrumbComponent.new do |crumb| %>
    <% crumb.with_item(href: "/", icon: :home) { "Home" } %>
    <% crumb.with_item(href: "/products") { "Products" } %>
    <% crumb.with_item { "Widget" } %>       # no href = current page
  <% end %>

Props:
- separator: :chevron (default — uses chevron-right icon) or :slash
  (literal "/" character)
- class: consumer override on <nav>
- **html_attrs: pass-through

Per-item slot (with_item):
- href: optional — becomes an <a> link when present (except last item)
- icon: optional — name of an IconComponent to render as leading icon
- Block content: item label

Implementation notes:
- Uses a lambda slot returning a minimal Item ViewComponent subclass so
  the Slot wrapper's method_missing delegates href/icon/label/link? to
  it. The struct-based approach didn't work because Slot only delegates
  to component instances, not to arbitrary content values.
- Block content captured immediately via view_context.capture inside
  the slot lambda — matches how VC's block forwarding works in 4.6+.
- Separator rendered between items (not after last) with aria-hidden=true
  since it's decorative; the nav's aria-label tells assistive tech
  this is a breadcrumb.

No new tokens needed — uses existing Interactive/Text/Background palette.

14 new tests pass. Total suite: 66 tests, 130 assertions.
Lookbook: default, slash_separator, single_item, no_icons, long_trail.
# Conflicts:
#	test/dummy/app/assets/builds/tailwind.css
#	test/dummy/app/views/pages/components.html.erb
Tri-state form checkbox (unchecked / checked / indeterminate) with
optional label + caption. Matches Shipwright Pro's Checkbox atom.

API:
  <%= render Shipwright::CheckboxComponent.new(name: "terms", label: "Accept") %>
  <%= render Shipwright::CheckboxComponent.new(
    name: "notify", label: "Email me", caption: "Weekly updates only",
    checked: true) %>
  <%= render Shipwright::CheckboxComponent.new(
    name: "delete", label: "Permanent", variant: :destructive) %>

Props:
- name: form field name
- value: form field value (default "1")
- label: optional visible label text
- caption: optional secondary text beneath the label
- checked: default false
- indeterminate: default false (see note below)
- disabled: default false
- variant: :default (black) or :destructive (red)
- label_position: :right (default) or :left
- id: optional; auto-derives from name
- class: consumer override on the <label> wrapper
- **html_attrs: pass-through onto the <input>

Structure: a <label> wraps a hidden native <input type="checkbox"> (for
a11y + form submission) plus a styled <span> that is the visible box.
CSS peer-* selectors flip the visual's appearance based on the input's
:checked / :disabled / :focus-visible pseudo-classes.

Indeterminate state: sets data-indeterminate="true" on the wrapper for
CSS targeting. The native `input.indeterminate` property is JS-only —
consuming apps that need `:indeterminate` to apply on the input itself
can bootstrap it with a small script:
  document.querySelectorAll('[data-indeterminate="true"] input')
    .forEach(i => i.indeterminate = true)

The visual dash icon renders via the data-indeterminate attribute
selector, so the component looks right even without that bootstrap.

No new tokens — uses existing Interactive/Utility/Text palette.

23 new tests pass. Total suite: 48 tests, 89 assertions.
Lookbook: default, checked, with_caption, states, variants,
label_positions, group.
# Conflicts:
#	test/dummy/app/assets/builds/tailwind.css
#	test/dummy/app/views/pages/components.html.erb
Two bugs in the initial implementation:

1. Tailwind wasn't compiling `peer-checked:bg-*` utilities because
   the classes were built with Ruby string interpolation
   (`peer-checked:#{v[:bg]}`). Tailwind's scanner only picks up
   literal class strings in source files — interpolated values are
   invisible. Fixed by inlining complete class bundles per variant,
   with full literal strings for every peer-* / group-* combination.

2. The check-mark and dash SVGs were styled with `peer-checked:`
   but they are descendants of the visual span, not siblings of the
   peer input. The `peer-*` modifier only targets siblings. Fixed
   by putting `group` on the <label> and using `group-has-checked:`
   and `group-data-[indeterminate=true]:` on the icons + visual.
   These work regardless of DOM depth because they use CSS :has()
   and attribute selectors matching any descendant.

Visible result: checked, indeterminate, and destructive states now
render correctly — filled box + appropriate check/dash icon, white
on default, white on red for destructive.
# Conflicts:
#	test/dummy/app/assets/builds/tailwind.css
Two fixes:

1. Auto-generated id previously matched the `name` kwarg alone, so
   three checkboxes sharing `name: 'g1'` all got `id='g1'` and the
   `<label for='g1'>` targeted only the first input in the DOM —
   clicking any label toggled the same checkbox. Fixed by deriving
   id as `#{name}_#{value}` (Rails form-helper convention); explicit
   `id:` still wins when provided.

2. The 'Select all' parent/child demo in Lookbook had no JS wiring,
   so clicking the parent didn't toggle children and vice versa.
   Added a self-contained inline IIFE in the preview template that
   handles parent ↔ children sync (indeterminate when mixed, all-on
   / all-off when parent clicked). The docs make clear this is a
   consumer-app concern — use Stimulus or your framework's equivalent
   in production; this script just makes the demo work.

Test added: test_shared_name_with_distinct_values_gets_unique_ids.
Existing test_auto_generated_id_from_name renamed and updated to
expect 'accept_terms_1' for the default value '1'.
The CheckboxComponent forwards html_attrs (data-*, id, etc.) onto the
hidden <input>, not the outer <label>. My demo JS was doing
`querySelector('[data-select-all-demo-target="parent"]')` which matched
the input and then setting `.dataset.indeterminate` on it — but the
visual's CSS rule keys off `.group[data-indeterminate=true]` on the
LABEL. So the attribute ended up on the wrong element and the
indeterminate visual never appeared when children were mixed.

Fixed by selecting the input directly (`input[data-...target]`) then
using `.closest('label')` to get the label for dataset updates.

Also documented the component's html_attrs forwarding behavior in a
comment inside the demo script.
Custom styled select-like form control matching Shipwright Pro's
Dropdown atom. Supports items with icon + primary + secondary text —
things a native <select> can't display.

API:
  <%= render Shipwright::DropdownComponent.new(
    name: "category", label: "Category", caption: "Choose one",
    value: "starred", placeholder: "Select..."
  ) do |dropdown| %>
    <% dropdown.with_item(value: "folders", icon: :folder,
                           primary: "Folders", secondary: "Organize your files") %>
    <% dropdown.with_item(value: "starred", icon: :star,
                           primary: "Starred", secondary: "Your favorites") %>
  <% end %>

Props:
- name: form field name (rendered as hidden input carrying the value)
- value: currently selected value (trigger shows matching item's primary text)
- label: optional text above the trigger
- caption: optional text below the trigger
- placeholder: text shown in trigger when no value (default "Dropdown Menu")
- disabled: bool
- variant: :default or :destructive (red border + text)
- class: consumer override on wrapper
- **html_attrs: pass-through on wrapper

Item slot:
- value: required, matches against `value:` to show as selected
- primary: required label text
- icon: optional IconComponent name
- secondary: optional smaller subtitle text

Architecture:
- <details>/<summary> for native open/close (no JS for toggle, keyboard,
  or a11y)
- <button role="option"> items inside the menu panel
- Hidden <input> carries the value for form submission
- Inline <script> (installed once per page via a window flag) delegates
  clicks: when an item is clicked, updates the trigger label, writes
  to hidden input, marks aria-selected, and closes the <details>. Also
  closes open dropdowns when clicking outside.
- Chevron rotates via Tailwind's group-open:rotate-180 modifier

15 new tests pass. Total suite: 67 tests, 136 assertions.
Lookbook: default, with_value, no_icons, disabled, destructive, grid.
Showcase page has a 4-tile grid matching the Figma "all states" layout.
PendragonDevelopment and others added 16 commits April 18, 2026 22:03
# Conflicts:
#	test/dummy/app/assets/builds/tailwind.css
#	test/dummy/app/views/pages/components.html.erb
… Breadcrumb

LogoComponent: brand mark (image src, inline SVG block, or image-icon fallback)
+ optional wordmark, 5 sizes (xs/sm/md/lg/xl), optional href link wrapper.

Extends leading-visual slot on Dropdown items, Banner, and Breadcrumb items
to accept avatar: {...} or logo: {...} as alternatives to icon:. Mutual
exclusion enforced; prior icon: API unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inline variant: Choose-file button + filename display, configurable
button position (left/right) and variant (primary/secondary/on_primary).
Dropzone variant: dashed-border drop target with upload icon and Browse
files button; supports click-to-browse and drag-and-drop.

Both variants support single or multiple files, staged files list with
remove buttons, and client-side validation of accept types and max_size.
File removal uses DataTransfer to mutate the real <input type=file>
FileList so the form submits exactly the staged set.

Vanilla JS wiring (matching dropdown/checkbox convention) installed once
per page via window.__shipwrightFileUploadWired guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Text input with optional leading/trailing icons, label, caption, and
three state variants (default/success/destructive). Supports :text,
:email, :password, :url, :search, :tel, :number, :date, :time, and
:datetime-local. Passing error: forces the destructive variant and
wires aria-invalid + aria-describedby for accessibility.

Unconsumed keyword args are forwarded to the underlying <input>, so
native HTML5 validation attrs (pattern, minlength, maxlength, min,
max, step, autocomplete, inputmode, title, etc.) work directly:

  <%= render Shipwright::InputComponent.new(
    name: "email", type: :email, required: true,
    autocomplete: "email", inputmode: "email"
  ) %>

Documented validation usage in the component class comment and added
a with_html5_validation preview demonstrating a submit-validating form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ToastComponent — icon + title + body + optional ✕, variants success/error/
warning/info/neutral. Configurable auto-dismiss (default 5s, pass duration: 0
for sticky). Pause-on-hover. Enter/leave transitions via data-state.

ToastContainerComponent — fixed-position stacking region. Six positions
(top/bottom × left/right/center). Click-through (pointer-events-none);
individual toasts re-enable pointer events.

FlashHelper — Shipwright::FlashHelper#shipwright_flash_toasts renders one
toast per entry in Rails flash, mapping notice/success→success, alert/error
→error, warning→warning, info→info. Custom mappings supported. Auto-mixed
into ActionController::Base via engine initializer.

Turbo Stream pattern documented in ToastContainerComponent class comment:
turbo_stream.append(DOM_ID, ToastComponent.new(...)).

Vanilla JS wiring matches existing dropdown/checkbox convention. Re-scans
on turbo:load, turbo:frame-load, and turbo:before-stream-render so
Turbo-appended toasts activate correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Toast JS wiring was previously inlined only in ToastComponent's
template, so a page that renders the container alone (and appends toasts
via Turbo Stream or JS) had no __shipwrightToastActivate function to
call. Extract the script to app/views/shipwright/_toast_init_script
and render it from both ToastComponent and ToastContainerComponent.
The __shipwrightToastWired guard keeps duplicate renders a no-op.

Fixes the Lookbook container preview where live add-toast buttons did
nothing because the activator was never defined.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rendering the shared init script via render "shipwright/toast_init_script"
failed because the engine's app/views/ is not on the host application's
view lookup paths (only view_component and lookbook manage view resolution
here). Move the script to a frozen constant on ToastComponent and expose
ToastComponent.init_script_html which returns it html_safe. Both the
toast and container templates interpolate the constant directly. The
__shipwrightToastWired guard still keeps duplicate renders a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each variant now ships a meaningful default icon:
- success   → check-circle
- error     → alert-circle
- warning   → alert-triangle
- info      → info
- neutral   → bell

The existing `icon:` prop continues to override any variant default with
any Feather icon name. Documented in the class comment with an example.
Added a custom_icon preview to the ToastComponentPreview suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Data table with slot-based API for columns and rows. Each cell receives
a block so consumers render anything (Avatar, Badge, progress bar, icon
group, plain text).

API:
  table.with_column(label:, sortable:, sort_direction:, align:, width:)
  table.with_row(selected:) do |row|
    row.with_cell(align:) { ... }
  end

Options: selectable (prepends checkbox column), striped (zebra rows),
density (:comfortable / :compact).

Sort indicator: sortable columns render a chevron button with data-
attributes (data-table-sort, data-table-sort-key, data-table-sort-
direction). No client behavior included — consumer wires via Turbo
Stream, Stimulus, or server round-trip.

Implementation note: cells are captured into SafeBuffer strings inside
the table's renders_many lambda (where view_context is available) via
a RowBuilder helper, rather than via nested slots. Nested-slot lambdas
run before their component is rendered so they have no view_context.

Also adds `chevrons-up-down` bidirectional-sort icon (Lucide-style,
not in base Feather).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Works with Pagy, Kaminari, or any custom paginator — takes plain props
(current_page, total_pages) and a page_url Proc for URL generation, and
optionally a pre-truncated series array (Pagy's pagy.series format works
directly). Auto-truncates with [1, window(current), :gap, last] when no
series is passed.

Variants:
  :numeric        — ← 1 2 3 … 9 10 → joined in one bordered pill
  :numeric_loose  — same entries but each button is separately bordered
  :simple         — "Page X of Y" label + prev/next arrows
                    (label_position: :left | :right | :center | :hidden)

Also supports a rows-per-page <select> (pass rows_per_page,
rows_per_page_options, rows_per_page_url) alongside the arrows.

Previous/next buttons are disabled (rendered as spans with
aria-disabled="true") at the boundaries. Current page renders as a span
with aria-current="page" instead of a link.

Accepts Pagy's string-encoded series entries (["1", "2", :gap, ...])
directly — normalized to integers + :gap sentinel internally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant