Skip to content

feat: FileUploadComponent (inline + dropzone) - #13

Open
PendragonDevelopment wants to merge 34 commits into
fix/lookbook-button-previewsfrom
feat/file-upload-component
Open

feat: FileUploadComponent (inline + dropzone)#13
PendragonDevelopment wants to merge 34 commits into
fix/lookbook-button-previewsfrom
feat/file-upload-component

Conversation

@PendragonDevelopment

Copy link
Copy Markdown
Collaborator

Summary

  • New `Shipwright::FileUploadComponent` with two variants:
    • inline — Choose-file button + filename display; `button_position: :left|:right`, `button_variant: :primary|:secondary|:on_primary`
    • dropzone — Dashed-border drop target with upload icon, helper text, Browse files button; click-to-browse + drag-and-drop
  • Props: `name`, `label`, `caption`, `multiple`, `accept`, `max_size`, `disabled`, `error`
  • Staged files list with remove (✕) buttons per file
  • Client-side validation (accept types + per-file max size) with inline error messaging
  • Uses `DataTransfer` to mutate `input.files` so form submits exactly the staged set after removals/filtering
  • Vanilla JS wiring matching existing dropdown/checkbox convention (not Stimulus — kept consistent with current codebase)

Test plan

  • 210 tests / 357 assertions passing (+17 new)
  • Lookbook previews for all variants + Grid
  • Showcase page section added
  • Browser-test drag-and-drop, validation messaging, and remove behavior before merging

🤖 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 4 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>
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