An output only reaches a parent @binding when the component calls output.<name>(). Two separate faults meant most of the library never got there, and both failed silently at each end. HTML lowercases attribute names, so a parent's @sizeChange registered under "sizechange" while the component emitted "sizeChange". The lookup missed, fell through to a DOM dispatch, and the binding was never invoked. That made all 17 camelCase outputs undeliverable -- DataTable.pageChange and .rowClick, Map.markerClick, ChatBubble.messageClick, LayoutSplitter.sizeChange and the rest. invokeComponentOutput now falls back to a case-insensitive lookup, and a csr test fails without it. Separately, 18 components dispatched hand-built CustomEvents rather than calling output.*. A bubbling event on the component's own root never reaches a binding, because parent handlers live in a registry only the output proxy reads. Card, Footer, Breadcrumb, Accordion, alert, Badge, AnnouncementBar, AvatarGroup, ToggleCount and InputNumber now emit properly; Marquee, Map, Timeline, List and SearchBox additionally declare the outputs they were already firing. Dispatches on window are left alone -- that is how Toaster, Modal and DataTable signal across component boundaries. Verified in a browser both ways before and after: an AnnouncementBar dispatching its own bubbling "dismiss" never reached a page-level @dismiss, and reached it immediately once it called output.dismiss(). This corrects the audit, which called the LayoutSplitter failure "narrow and unexplained" and read 32 dead outputs as 16 components needing a rebuild. "Outputs work elsewhere" was an assumption; the components that worked happened to use lowercase names and output.*. The dead-output ratchet drops from 32 to 22, and a new test forbids the raw-CustomEvent pattern outright. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
118 lines
4.6 KiB
Plaintext
118 lines
4.6 KiB
Plaintext
component Marquee {
|
|
outputs {
|
|
// Fired when the reader pauses the scroll, by hover, focus or the button.
|
|
pause(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
|
resume(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
|
}
|
|
|
|
props {
|
|
size: string = "default"
|
|
color: string = "primary"
|
|
title: string = "Marquee"
|
|
description: string = ""
|
|
items: unknown[] = []
|
|
variant: string = "default"
|
|
class: string = ""
|
|
}
|
|
|
|
state paused: boolean = false
|
|
|
|
view {
|
|
<section
|
|
data-ui-component="Marquee"
|
|
aria-label='{title}'
|
|
class='overflow-hidden border-y border-[var(--wire-color-border)] bg-[var(--wire-color-surface-raised)] {class}'
|
|
>
|
|
<div class="flex items-stretch">
|
|
{#if title}
|
|
<div class="relative z-10 flex shrink-0 items-center gap-2 border-r border-[var(--wire-color-border)] bg-[var(--wire-color-primary)] px-4 py-3 text-[var(--wire-color-on-primary)] sm:px-5">
|
|
<span class="icon-[lucide--megaphone] size-4" aria-hidden="true"></span>
|
|
<span class="text-sm font-bold">{title}</span>
|
|
</div>
|
|
{/if}
|
|
|
|
<div
|
|
class="group relative min-w-0 flex-1 overflow-hidden"
|
|
@mouseenter='paused = true; output.pause({})'
|
|
@mouseleave='paused = false; output.resume({})'
|
|
@focusin='paused = true'
|
|
@focusout='paused = false'
|
|
>
|
|
<div
|
|
class="wire-marquee-track flex w-max min-w-full items-center"
|
|
class:wire-marquee-paused='paused'
|
|
>
|
|
{#each items as item}
|
|
<a
|
|
href='{item.href || "#"}'
|
|
class="flex shrink-0 items-center gap-3 px-5 py-3 text-sm font-medium text-[var(--wire-color-text)] transition hover:bg-[var(--wire-color-primary-soft)] hover:text-[var(--wire-color-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--wire-color-focus)]"
|
|
>
|
|
<span
|
|
class="size-2 shrink-0 rounded-full bg-[var(--wire-color-primary)]"
|
|
class:bg-[var(--wire-color-danger)]='item.color === "danger"'
|
|
class:bg-[var(--wire-color-warning)]='item.color === "warning"'
|
|
class:bg-[var(--wire-color-success)]='item.color === "success"'
|
|
aria-hidden="true"
|
|
></span>
|
|
<span>{item.label || item.title}</span>
|
|
{#if item.meta}
|
|
<span class="text-xs text-[var(--wire-color-text-muted)]">{item.meta}</span>
|
|
{/if}
|
|
</a>
|
|
{:empty}
|
|
<p class="px-5 py-3 text-sm text-[var(--wire-color-text-muted)]">{description || "No announcements available."}</p>
|
|
{/each}
|
|
|
|
{#if items.length > 0}
|
|
{#each items as item}
|
|
<a
|
|
href='{item.href || "#"}'
|
|
aria-hidden="true"
|
|
tabindex="-1"
|
|
class="flex shrink-0 items-center gap-3 px-5 py-3 text-sm font-medium text-[var(--wire-color-text)]"
|
|
>
|
|
<span class="size-2 shrink-0 rounded-full bg-[var(--wire-color-primary)]" aria-hidden="true"></span>
|
|
<span>{item.label || item.title}</span>
|
|
</a>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
aria-label='{paused ? "Resume announcements" : "Pause announcements"}'
|
|
class="flex shrink-0 items-center justify-center border-l border-[var(--wire-color-border)] px-4 text-[var(--wire-color-text-muted)] transition hover:bg-[var(--wire-color-surface-soft)] hover:text-[var(--wire-color-text)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--wire-color-focus)]"
|
|
@click='paused = !paused; paused ? output.pause({}) : output.resume({})'
|
|
>
|
|
<span class="icon-[lucide--pause] size-4" data-show='!paused' aria-hidden="true"></span>
|
|
<span class="icon-[lucide--play] size-4" data-show='paused' aria-hidden="true"></span>
|
|
</button>
|
|
</div>
|
|
|
|
<slot></slot>
|
|
</section>
|
|
}
|
|
|
|
style {
|
|
.wire-marquee-track {
|
|
animation: wire-marquee 32s linear infinite;
|
|
}
|
|
|
|
.wire-marquee-paused {
|
|
animation-play-state: paused;
|
|
}
|
|
|
|
@keyframes wire-marquee {
|
|
from { transform: translateX(0); }
|
|
to { transform: translateX(-50%); }
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.wire-marquee-track {
|
|
animation: none;
|
|
}
|
|
}
|
|
}
|
|
}
|