From 0ca8b98da3aaee4b8397fd08385f40e260493e17 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 15:52:58 +0200 Subject: [PATCH 001/632] added bootstrap_components.md --- bootstrap_components.md | 409 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 bootstrap_components.md diff --git a/bootstrap_components.md b/bootstrap_components.md new file mode 100644 index 00000000..3db9ddba --- /dev/null +++ b/bootstrap_components.md @@ -0,0 +1,409 @@ +# Bootstrap Components — How They Work + +Analysis of `node_modules/bootstrap/scss` (Bootstrap **5.3.8**). Explains every component layer in the import stack of `bootstrap.scss`: what it emits, which CSS custom properties it uses, and the SCSS mechanism behind it. + +## Table of contents + +- [Architecture overview](#architecture-overview) +- [Foundation](#foundation): [root](#root), [reboot](#reboot), [type](#type), [images](#images), [containers](#containers), [grid](#grid), [tables](#tables) +- [Forms](#forms): [form-label](#form-label--form-text), [form-control](#form-control), [form-select](#form-select), [form-check](#form-check-checkbox--radio--switch), [form-range](#form-range), [floating-labels](#floating-labels), [input-group](#input-group), [validation](#validation) +- [Buttons & interactions](#buttons--interactions): [buttons](#buttons), [button-group](#button-group), [close](#close-button), [transitions](#transitions) +- [Navigation & content](#navigation--content): [dropdown](#dropdown), [nav](#nav), [navbar](#navbar), [card](#card), [accordion](#accordion), [breadcrumb](#breadcrumb), [pagination](#pagination), [badge](#badge), [alert](#alert), [progress](#progress), [list-group](#list-group) +- [Overlays & feedback](#overlays--feedback): [toasts](#toasts), [modal](#modal), [tooltip](#tooltip), [popover](#popover), [carousel](#carousel), [spinners](#spinners), [offcanvas](#offcanvas), [placeholders](#placeholders) +- [Helpers](#helpers) +- [Utilities API](#utilities-api) +- [JS plugin dependency summary](#js-plugin-dependency-summary) + +--- + +## Architecture overview + +Every component in Bootstrap 5.3 is built on the same five mechanisms. Understanding these makes each individual component file readable. + +### 1. CSS custom properties (`--bs-*`) + +Each component class defines its full set of design tokens as CSS variables on the root class (e.g. `.btn` defines `--bs-btn-bg`, `--bs-btn-color`, `--bs-btn-padding-y`, …) and then consumes them via `var()`. Variants and states don't restyle properties — they **reassign the variables**. This is why `.btn-primary` is just a block of `--bs-btn-*` redefinitions and why runtime theming works without recompiling Sass. The `bs-` prefix comes from the `$prefix` variable. + +### 2. Theme color maps + +`_variables.scss` defines `$theme-colors` (primary, secondary, success, info, warning, danger, light, dark). `_maps.scss` derives three more maps from it: + +- `$theme-colors-text` → `--bs-{color}-text-emphasis` (dark shade for text) +- `$theme-colors-bg-subtle` → `--bs-{color}-bg-subtle` (light tint for backgrounds) +- `$theme-colors-border-subtle` → `--bs-{color}-border-subtle` (medium tint for borders) + +Components loop these maps with `@each` to generate variants (`.alert-success`, `.list-group-item-danger`, `.btn-primary`, `.table-warning`, …). Dark-mode counterparts (`*-dark` maps) feed the same variables under `[data-bs-theme="dark"]`. + +### 3. Breakpoint loops + +`$grid-breakpoints` (xs 0, sm 576px, md 768px, lg 992px, xl 1200px, xxl 1400px) drives every responsive variant. Components loop `map-keys($grid-breakpoints)`, build a class infix with `breakpoint-infix()` (empty for xs, `-sm`, `-md`, …), and wrap rules in `media-breakpoint-up()` / `media-breakpoint-down()` mixins. This generates `.col-md-6`, `.navbar-expand-lg`, `.modal-fullscreen-sm-down`, `.list-group-horizontal-xl`, `.offcanvas-md`, etc. + +### 4. Dark mode + +Gated by `$enable-dark-mode`. The `color-mode(dark)` mixin emits either `[data-bs-theme="dark"]` selectors or a `prefers-color-scheme: dark` media query depending on `$color-mode-type`. Components that embed colored SVGs (accordion chevron, navbar toggler, close button, form checks) swap the SVG data-URI or apply a `filter: invert(1)` in dark mode. + +### 5. SVG data-URIs + +All built-in icons (checkbox check, radio dot, select chevron, accordion chevron, close X, carousel arrows, validation icons, navbar hamburger) are inline SVGs embedded as `url("data:image/svg+xml,...")` background images, passed through the `escape-svg()` function. Colors are interpolated into the SVG at compile time (`fill='#{$color}'`), which is why changing icon colors at runtime requires swapping the whole variable (or using a `filter`). + +Also pervasive: **RFS** (responsive font sizing via `@include font-size()`), `$enable-*` feature flags (shadows, gradients, rounded, transitions, reduced-motion), and flexbox as the layout model for nearly everything. + +--- + +## Foundation + +### Root + +`_root.scss` — emits the design-token layer. No classes; it populates `:root` (and `[data-bs-theme]` scopes) with CSS variables by looping the color maps: `--bs-{color}` and `--bs-{color}-rgb` for every theme color, `--bs-gray-{100..900}`, body typography tokens (`--bs-body-font-family/size/color/bg`), link tokens, border tokens (`--bs-border-width/color/radius` + sm/lg/xl/xxl/pill radii), shadow tokens, focus-ring tokens (`--bs-focus-ring-width/opacity/color`), and form validation colors. The `-rgb` triplet variants exist so components can do `rgba(var(--bs-primary-rgb), .5)` for opacity control. Dark-mode values are emitted inside `@include color-mode(dark, true)`. + +### Reboot + +`_reboot.scss` — cross-browser normalization (forked from Normalize.css). Sets `box-sizing: border-box` universally, strips default margins, wires `body` to the `--bs-body-*` variables, styles bare elements (headings via a shared `%heading` placeholder, links via `rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1))`, tables, form elements, `code`/`kbd`/`pre`). Mostly static rules — no loops. Includes webkit-specific form resets and optional smooth scrolling behind `$enable-smooth-scroll` with a `prefers-reduced-motion` guard. + +### Type + +`_type.scss` — typography utility classes. `.h1`–`.h6` reuse element styles via `@extend`; `.display-1`–`.display-6` are generated by looping the `$display-font-sizes` map with RFS-scaled font sizes; plus `.lead`, `.list-unstyled`, `.list-inline`/`.list-inline-item`, `.initialism`, `.blockquote`/`.blockquote-footer`. + +### Images + +`_images.scss` — `.img-fluid` (`max-width: 100%; height: auto` via the `img-fluid()` mixin), `.img-thumbnail` (padding + border + radius + shadow), and `.figure`/`.figure-img`/`.figure-caption`. + +### Containers + +`_containers.scss` — `.container`, `.container-fluid`, and `.container-{sm..xxl}`. All use the `make-container()` mixin (sets `--bs-gutter-x/y`, 100% width, horizontal padding, auto margins). The responsive containers extend `.container-fluid` and then, looping `$container-max-widths`, gain a `max-width` at their breakpoint and every breakpoint above it via `%responsive-container-*` placeholders — mobile-first: `.container-sm` is fluid below `sm`, capped from `sm` up. Gated by `$enable-container-classes`. + +### Grid + +`_grid.scss` — the flexbox grid. The mechanism: + +- `.row` (`make-row()` mixin): flex + wrap, defines `--bs-gutter-x/y`, applies negative horizontal margins to cancel column padding at row edges. +- All direct children of `.row` get `make-col-ready()`: `width: 100%`, horizontal padding of half a gutter. +- `make-grid-columns()` loops every breakpoint and generates per-infix: `.col` (`flex: 1 1 0`), `.col-auto`, `.col-1..12` (`flex: 0 0 auto; width: percentage(i/12)`), `.row-cols-1..6`, `.offset-0..11` (`margin-left` percentage), and gutter classes `.g-*`/`.gx-*`/`.gy-*` (which just reassign `--bs-gutter-x/y` from the `$gutters` map). +- `:root` also gets `--bs-breakpoint-{name}` variables so JS can read breakpoints. +- Optional CSS Grid mode (`$enable-cssgrid`): `.grid` with `grid-template-columns: repeat(var(--bs-columns, 12), 1fr)` and `gap: var(--bs-gap)`. + +Key knobs: `$grid-columns` (12), `$grid-gutter-width` (1.5rem), `$grid-breakpoints`. + +### Tables + +`_tables.scss` — the cleverest variable cascade in the framework. `.table` defines base tokens plus three *layers* of override variables, consumed on every cell as: + +```css +color: var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color))); +box-shadow: inset 0 0 0 9999px var(--bs-table-bg-state, var(--bs-table-bg-type, var(--bs-table-accent-bg))); +``` + +- Base layer: `--bs-table-color` / `--bs-table-bg`. +- Type layer: `.table-striped` / `.table-striped-columns` set `--bs-table-*-type` on `nth-of-type` rows/columns. +- State layer (wins over everything): `.table-active` and `.table-hover tr:hover` set `--bs-table-*-state`. + +Backgrounds use a huge inset `box-shadow` instead of `background-color` so they don't fight cell borders. Color variants (`.table-primary` …) come from looping `$table-variants` through the `table-variant()` mixin, which computes hover/striped/active shades with `mix()` and contrast text with `color-contrast()`. `.table-responsive{-breakpoint}` wrappers add `overflow-x: auto` below each breakpoint. Also: `.table-sm`, `.table-bordered`, `.table-borderless`, `.table-group-divider`, `.caption-top`. + +--- + +## Forms + +`_forms.scss` is just an import hub for the `forms/` directory. + +### Form label & form text + +`forms/_labels.scss` — `.form-label` (margin/font/color) and `.col-form-label{-sm,-lg}`, whose vertical padding is computed as `add($input-padding-y, $input-border-width)` so labels line up with adjacent inputs in grid layouts. +`forms/_form-text.scss` — `.form-text` helper text: small font, `var(--bs-secondary-color)`, top margin. + +### Form control + +`forms/_form-control.scss` — `.form-control` for text inputs/textareas: block-level, full width, `appearance: none`, `background-clip: padding-box`, border from `var(--bs-border-*)`. Focus swaps `border-color` to `$input-focus-border-color` (a 50% tint of primary) and adds the focus box-shadow when `$enable-shadows`. File inputs are styled through the `::file-selector-button` pseudo-element (gradient bg, hover state, its own transition). Several webkit pseudo-elements (`::-webkit-date-and-time-value`, `::-webkit-datetime-edit`) patch date/time input rendering. Variants: `.form-control-sm/-lg` (padding/font/radius), `.form-control-plaintext` (borderless read-only), `.form-control-color` (fixed 3rem color swatch, swatch borders removed per engine). + +### Form select + +`forms/_form-select.scss` — `.form-select` replaces the native select UI: `appearance: none`, then a chevron SVG as `background-image: var(--bs-form-select-bg-img), var(--bs-form-select-bg-icon, none)` positioned right, with `padding-right` widened (~3×) to clear it. The second background slot is reserved for the validation icon — that's how a select can show both chevron and valid/invalid icon at once. `[multiple]` / `[size]` selects drop the chevron. Dark mode swaps to a light-stroke chevron SVG. Sizes: `.form-select-sm/-lg`. + +### Form check (checkbox / radio / switch) + +`forms/_form-check.scss` — fully custom-drawn controls: + +- `.form-check-input`: `appearance: none`, 1em square, `background-color: var(--bs-form-check-bg)`, centered `background-image`. `[type=checkbox]` gets a small radius; `[type=radio]` gets `border-radius: 50%`. +- `:checked` sets background to `$form-check-input-checked-bg-color` (component active color = primary) and swaps `--bs-form-check-bg-image` to the check SVG (checkbox) or dot SVG (radio). `:indeterminate` shows a dash SVG. Active state applies `filter: brightness(90%)`. +- `.form-switch`: widens the input to 2em and uses a circle-knob SVG as the background image; `:checked` slides it via `background-position: right center` with a `background-position .15s ease-in-out` transition — the toggle animation is purely a background-position move. +- `.btn-check`: visually hidden input (`position: absolute; clip: rect(0,0,0,0)`); the sibling selector `.btn-check:checked + .btn` is what powers toggle buttons — the button's `--bs-btn-*` active variables kick in with no JS state. +- Layout helpers: `.form-check` (padding-start reserve for the box), `.form-check-inline`, `.form-check-reverse`. + +### Form range + +`forms/_form-range.scss` — `.form-range` styles the native slider. Track and thumb must be styled per engine with duplicated rulesets (`::-webkit-slider-thumb`/`::-webkit-slider-runnable-track`, `::-moz-range-thumb`/`::-moz-range-track`) because browsers drop grouped selectors they don't recognize. Thumb: 1rem circle, primary background, lightens to a 70% tint while dragging (`:active`). Focus shadow goes on the thumb pseudo-elements. No JS. + +### Floating labels + +`forms/_floating-labels.scss` — `.form-floating` wraps an input + label. The label is absolutely positioned over the input (full-height, semi-transparent, `transform-origin: 0 0`); the input is taller than normal (3.5rem). When the input is `:focus` or `:not(:placeholder-shown)` (i.e. has a value — this is why the input needs a `placeholder` attribute), the label shrinks and lifts: `transform: scale(.85) translateY(-.5rem) translateX(.15rem)`, and the input's top padding grows to make room. `:-webkit-autofill` gets a duplicated ruleset because it invalidates grouped selectors in WebKit. Textareas add an `::after` backing rectangle so text doesn't show through the floated label. + +### Input group + +`forms/_input-group.scss` — `.input-group` is a stretch-aligned flex row. Controls take `flex: 1 1 auto; width: 1%`; `.input-group-text` addons are flex-centered boxes with input-matching borders. Adjacent borders are merged by `margin-left: calc(-1 * border-width)` on every non-first child, and a z-index ladder keeps the focused element's border on top: focused control/`:focus-within` = 5, buttons = 2 (5 on focus). Inner corners are squared via `nth-child` selectors — with a special selector branch for `.has-validation`, where feedback elements occupy the last child slots. `.input-group-sm/-lg` resize all children at once. + +### Validation + +`forms/_validation.scss` — entirely generated by looping the `$form-validation-states` map (`valid`, `invalid`) through the `form-validation-state()` mixin. Each state produces: + +- `.{state}-feedback` (hidden; displayed by sibling selector when the control is in that state) and `.{state}-tooltip` (absolutely positioned colored bubble). +- Two trigger modes wired into the same selectors: client-side `.was-validated` parent + native `:valid`/`:invalid`, or server-side `.is-valid`/`.is-invalid` classes. +- Per control type: `.form-control` gets a state border color and (if `$enable-validation-icons`) a right-aligned state icon SVG with `padding-right: $input-height-inner`; `.form-select` puts the icon in its reserved second background slot; `.form-check-input` recolors border/background and label; input-group members get z-index 3 (valid) / 4 (invalid). + +The map is extensible — adding a key to `$form-validation-states` produces a complete new state. + +--- + +## Buttons & interactions + +### Buttons + +`_buttons.scss` — `.btn` defines ~20 `--bs-btn-*` variables (padding, font, color, bg, border, hover/active/disabled/focus variants) and consumes them for every state. States: `:hover`, `:focus-visible` (modern focus, shadow from `--bs-btn-focus-box-shadow`), `:active`/`.active`/`.show`, `:disabled`/`.disabled`/`fieldset:disabled`, plus `.btn-check:checked + .btn` for toggle buttons. + +Variants are loops over `$theme-colors`: + +- `.btn-{color}` → `button-variant()` mixin: computes hover/active backgrounds with `shade-color()` (light buttons) or `tint-color()` (dark buttons) at 15%/20%, contrast text via `color-contrast()`, and the focus-ring rgb by mixing button color with border color — all emitted as variable reassignments. +- `.btn-outline-{color}` → `button-outline-variant()`: transparent bg, colored border/text; hover inverts to solid. +- `.btn-link`: no bg/border, link color + underline behavior. +- `.btn-lg`/`.btn-sm` → `button-size()` mixin (padding/font-size/radius variables). + +No JS for styling; JS toggles `.active`/`.show` where needed. + +### Button group + +`_button-group.scss` — `.btn-group`/`.btn-group-vertical` are inline-flex containers that merge child button borders with negative margins (`margin-left: calc(-1 * border-width)`; `margin-top` for vertical) and square off inner corners with `border-start/end-radius(0)` on positional selectors (nth-child logic skips hidden `.btn-check` inputs). Hover/focus/active children get `z-index: 1` so their border renders above neighbors. `.dropdown-toggle-split` halves the padding for split-button dropdowns. `.btn-toolbar` is a wrapping flex container for multiple groups. `.btn-group-sm/-lg` cascade sizing to children. + +### Close button + +`_close.scss` — `.btn-close` is a 1em square button whose X is an SVG data-URI background (`--bs-btn-close-bg`), with opacity-stepped states: 0.5 base → 0.75 hover → 1 focus → 0.25 disabled, plus the standard focus-ring shadow. `.btn-close-white` (and dark mode globally) applies `filter: invert(1) grayscale(100%) brightness(200%)` via `--bs-btn-close-filter` to flip the icon for dark backgrounds. The click behavior (dismissing an alert/modal/toast) comes from the parent component's JS. + +### Transitions + +`_transitions.scss` — three primitives the JS plugins drive: + +- `.fade`: `transition: opacity .15s linear`; `:not(.show)` → `opacity: 0`. +- `.collapse`: `:not(.show)` → `display: none`. +- `.collapsing`: the in-between state — `height: 0; overflow: hidden; transition: height .35s ease`. The Collapse plugin measures target height, applies `.collapsing`, sets the pixel height, then swaps to `.collapse.show` when the transition ends. `.collapse-horizontal` does the same with `width`. + +--- + +## Navigation & content + +### Dropdown + +`_dropdown.scss` — `.dropdown-menu` is `position: absolute; display: none; z-index: var(--bs-dropdown-zindex)` (1000); JS adds `.show`. Positioning is two-tier: static CSS fallbacks (`top: 100%`, margins from `--bs-dropdown-spacer`) apply via `[data-bs-popper]` selectors, but when Popper.js manages the menu it sets inline styles instead. The toggle caret is a `::after` border-triangle from the `caret()` mixin; direction wrappers `.dropup`/`.dropend`/`.dropstart`/`.dropdown-center` flip the caret and the static position. Responsive alignment classes `.dropdown-menu{-sm..-xxl}-start/end` come from the breakpoint loop. Items: `.dropdown-item` (block-width links with hover bg `var(--bs-tertiary-bg)`, active bg = component active color, disabled state), `.dropdown-divider`, `.dropdown-header`, `.dropdown-item-text`. `.dropdown-menu-dark` is a legacy variable-override block (superseded by `data-bs-theme="dark"`). **JS:** Dropdown plugin + Popper. + +### Nav + +`_nav.scss` — `.nav` is a wrapping flex list, fully reset; `.nav-link` carries padding/color/transition variables. Variants restyle via their own variable sets: + +- `.nav-tabs`: bottom border on the container; links get transparent borders that fill in on hover; `.active` link gets solid bg + borders, and `margin-bottom: calc(-1 * var(--bs-nav-tabs-border-width))` so it overlaps the container border (the classic tab "notch" effect). +- `.nav-pills`: `.active` link gets rounded primary background (`gradient-bg()`). +- `.nav-underline`: gap-spaced links with a transparent `border-bottom` that becomes `currentcolor` when active; active text turns semibold. +- `.nav-fill` / `.nav-justified`: flex sizing of items (`flex: 1 1 auto` vs `flex-basis: 0; flex-grow: 1`). + +Tab panes: `.tab-content > .tab-pane { display: none }`, `.active { display: block }`. **JS:** Tab plugin swaps `.active`. + +### Navbar + +`_navbar.scss` — `.navbar` is a wrap-enabled flex row with `justify-content: space-between`. Sub-parts: `.navbar-brand`, `.navbar-nav` (column by default — mobile-first; its own link color variables), `.navbar-text`, `.navbar-collapse` (`flex-basis: 100%`, paired with the Collapse plugin), `.navbar-toggler` (border + focus styles) and `.navbar-toggler-icon`, a hamburger SVG in `--bs-navbar-toggler-icon-bg`. + +The core mechanism is the **expand loop**: `.navbar-expand{-breakpoint}` iterates breakpoints with `breakpoint-next()` and, inside `media-breakpoint-up($next)`, switches `.navbar-nav` to row, restores absolute dropdowns, force-shows `.navbar-collapse` (`display: flex !important`), and hides the toggler. Below the breakpoint everything stays stacked and the offcanvas integration rules apply (a nested `.offcanvas` becomes a plain inline container above the breakpoint). + +Color modes: navbar reads `--bs-navbar-*` color variables; `.navbar-dark` or `[data-bs-theme=dark]` reassigns them (lighter text, alternate hamburger SVG). **JS:** Collapse (and Offcanvas if used). + +### Card + +`_card.scss` — `.card` is a column flex container (`min-width: 0` to avoid flexbox overflow) with `--bs-card-*` tokens for spacing, borders, caps, and colors. `.card-body` takes `flex: 1 1 auto` so footers pin to the bottom. `.card-header`/`.card-footer` use the "cap" tokens (`--bs-card-cap-bg` = a 3% body-color tint) and their corner radii are derived as `--bs-card-inner-border-radius` (outer radius minus border width) — same trick used for images: `.card-img-top`/`.card-img-bottom`/`.card-img` round only the matching corners. `.card-img-overlay` absolutely fills the card. `.card-header-tabs` pulls nav-tabs into the header with negative margins so the tab border merges with the cap border. `.card-group` (from `sm` up) flexes cards into a row and collapses adjacent borders/radii. A `.card > .list-group` gets its borders/radii reconciled with the card frame. CSS-only. + +### Accordion + +`_accordion.scss` — built on the Collapse plugin. `.accordion-button` is a full-width flex button; its chevron is an `::after` element whose `background-image` is `var(--bs-accordion-btn-icon)`. Expanded state is expressed as `:not(.collapsed)`: icon swaps to `--bs-accordion-btn-active-icon` and rotates via `transform: var(--bs-accordion-btn-icon-transform)` (−180°, transitioned over .2s); the button takes the active colors (`primary-bg-subtle` bg) and an inset bottom box-shadow that draws the separator line. `.accordion-item` borders collapse between items (`:not(:first-of-type) { border-top: 0 }`), with first/last radius handling that respects collapsed state. `.accordion-flush` zeroes side borders and radii for edge-to-edge lists. Dark mode swaps both chevron SVGs for light-colored versions. **JS:** Collapse. + +### Breadcrumb + +`_breadcrumb.scss` — `.breadcrumb` is a reset, wrapping flex list. The separator is generated content: `.breadcrumb-item + .breadcrumb-item::before` with `content: var(--bs-breadcrumb-divider, "/")` — overridable per-instance by setting that variable inline (the official customization path), with an RTL-flipped fallback baked in via rtlcss comment directives. `.active` recolors to the secondary color. CSS-only, tiny. + +### Pagination + +`_pagination.scss` — `.pagination` is a flex list; `.page-link` carries the full `--bs-pagination-*` variable set with hover/focus/active/disabled blocks. Like input groups, adjacent borders merge via negative `margin-left` on `.page-item:not(:first-child)`, with z-index laddering (hover 2, focus/active 3). Border-radius is applied conditionally: if `$pagination-margin-start` keeps the default border-merging value, only first/last links get rounded; if you space items out, every link gets rounded. `.pagination-lg/-sm` reassign size variables via the `pagination-size()` mixin. CSS-only. + +### Badge + +`_badge.scss` — smallest component: `.badge` is an inline-block label with em-based padding (scales with parent font), `font-size: .75em`, bold, `line-height: 1`, nowrap, optional gradient. `&:empty { display: none }` hides empty badges. **It sets no background color** — you pair it with `.text-bg-{color}` or `.bg-{color}` utilities. CSS-only. + +### Alert + +`_alert.scss` — `.alert` is a padded, bordered, relative-positioned box reading `--bs-alert-*` variables. Variants loop `$theme-colors` keys and map straight onto the subtle-color system: + +```scss +.alert-#{$state} { + --bs-alert-color: var(--bs-#{$state}-text-emphasis); + --bs-alert-bg: var(--bs-#{$state}-bg-subtle); + --bs-alert-border-color: var(--bs-#{$state}-border-subtle); + --bs-alert-link-color: var(--bs-#{$state}-text-emphasis); +} +``` + +Because these reference root-level variables, alerts adapt to dark mode automatically with no extra CSS. `.alert-dismissible` reserves 3× padding on the right and absolutely positions the `.btn-close`. `.alert-heading` inherits color; `.alert-link` is bold. **JS:** Alert plugin handles dismissal (removes the element after fading). + +### Progress + +`_progress.scss` — `.progress` (and `.progress-stacked`) is a fixed-height flex track with `overflow: hidden`; `.progress-bar` is a flex column whose **width is set inline** (`style="width: 25%"`) and transitions via `width .6s ease`. `.progress-bar-striped` paints diagonal stripes with the `gradient-striped()` mixin (a 45° `linear-gradient` sized to `var(--bs-progress-height)`); `.progress-bar-animated` scrolls the stripes with a `progress-bar-stripes` keyframe animation (1s linear infinite, disabled under `prefers-reduced-motion`). Stacking: `.progress-stacked > .progress` keeps `overflow: visible` so multiple bars sit side by side. CSS-only. + +### List group + +`_list-group.scss` — `.list-group` is a reset flex column; `.list-group-item` is a bordered block where adjacent items drop their top border (`& + & { border-top-width: 0 }`) and first/last inherit the container radius. `.active` raises `z-index: 2` so its border shows over neighbors. Feature classes: + +- `.list-group-item-action`: hover/focus bg (`--bs-list-group-action-hover-bg`), active press state — for ``/` + +
+
+
`.trim() + } + + private _updateOpenState(): void { + const isOpen = this.hasAttribute('open') + const btn = this.querySelector('.accordion-button') + const collapseDiv = this.querySelector('.accordion-collapse') + if (btn) { + btn.classList.toggle('collapsed', !isOpen) + btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false') + } + if (collapseDiv) { + collapseDiv.classList.toggle('show', isOpen) + const instance = Collapse.getInstance(collapseDiv) + if (instance) { + if (isOpen) instance.show() + else instance.hide() + } + } + } + + private _onShow = (): void => { + this.dispatchEvent(new CustomEvent('tc-show', { bubbles: true, composed: true })) + } + private _onShown = (): void => { + this.dispatchEvent(new CustomEvent('tc-shown', { bubbles: true, composed: true })) + } + private _onHide = (): void => { + this.dispatchEvent(new CustomEvent('tc-hide', { bubbles: true, composed: true })) + } + private _onHidden = (): void => { + this.dispatchEvent(new CustomEvent('tc-hidden', { bubbles: true, composed: true })) + } + + private _attachListeners(): void { + const el = this._collapseEl + if (!el) return + el.addEventListener('show.bs.collapse', this._onShow) + el.addEventListener('shown.bs.collapse', this._onShown) + el.addEventListener('hide.bs.collapse', this._onHide) + el.addEventListener('hidden.bs.collapse', this._onHidden) + } + + private _detachListeners(): void { + const el = this._collapseEl + if (!el) return + el.removeEventListener('show.bs.collapse', this._onShow) + el.removeEventListener('shown.bs.collapse', this._onShown) + el.removeEventListener('hide.bs.collapse', this._onHide) + el.removeEventListener('hidden.bs.collapse', this._onHidden) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: AccordionItem + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index ef2c7117..4cc1bda8 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -2,3 +2,5 @@ export { register } from './register' export * from './Container' export * from './Row' export * from './Col' +export * from './Accordion' +export * from './AccordionItem' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 41b212f2..59d5bd3d 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -1,6 +1,8 @@ import { Container } from './Container' import { Row } from './Row' import { Col } from './Col' +import { Accordion } from './Accordion' +import { AccordionItem } from './AccordionItem' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -9,4 +11,6 @@ export function register(): void { customElements.define('tc-container', Container) customElements.define('tc-row', Row) customElements.define('tc-col', Col) + customElements.define('tc-accordion', Accordion) + customElements.define('tc-accordion-item', AccordionItem) } diff --git a/web-components/style/components/_accordion.scss b/web-components/style/components/_accordion.scss new file mode 100644 index 00000000..42bf69f3 --- /dev/null +++ b/web-components/style/components/_accordion.scss @@ -0,0 +1 @@ +// tc-accordion / tc-accordion-item — no additional theme overrides needed; Bootstrap accordion classes handle layout. diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 557d698b..b6d76ba9 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -4,3 +4,4 @@ @forward 'container'; @forward 'row'; @forward 'col'; +@forward 'accordion'; From ec4f6f94c13953855960acf75c115da3ea499ee8 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 16:05:35 +0000 Subject: [PATCH 010/632] 009-tc-alert: Implement the tc-alert Web Component --- examples/src/web-components/AlertDemo.tsx | 63 ++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Alert.ts | 103 ++++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_alert.scss | 1 + web-components/style/components/_index.scss | 1 + 7 files changed, 173 insertions(+) create mode 100644 examples/src/web-components/AlertDemo.tsx create mode 100644 web-components/src/Alert.ts create mode 100644 web-components/style/components/_alert.scss diff --git a/examples/src/web-components/AlertDemo.tsx b/examples/src/web-components/AlertDemo.tsx new file mode 100644 index 00000000..426cd149 --- /dev/null +++ b/examples/src/web-components/AlertDemo.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const AlertDemo: React.FC = () => ( +
+
+
+
+ Web Components} + title="Alert" + description="Contextual feedback messages for user actions. Supports all Bootstrap variants and an optional dismissible close button backed by Bootstrap's Alert plugin." + /> + +
+ +
+ {/* @ts-ignore */} + A primary alert — check it out! + {/* @ts-ignore */} + A secondary alert — check it out! + {/* @ts-ignore */} + A success alert — check it out! + {/* @ts-ignore */} + A danger alert — check it out! + {/* @ts-ignore */} + A warning alert — check it out! + {/* @ts-ignore */} + An info alert — check it out! + {/* @ts-ignore */} + A light alert — check it out! + {/* @ts-ignore */} + A dark alert — check it out! +
+
+ + +
+ {/* @ts-ignore */} + + Well done! You successfully read this important alert message. + {/* @ts-ignore */} + + {/* @ts-ignore */} + + Warning! Better check yourself, you're not looking too good. + {/* @ts-ignore */} + + {/* @ts-ignore */} + + Oh snap! Change a few things up and try submitting again. + {/* @ts-ignore */} + +
+
+
+
+
+
+
+) + +export default AlertDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 48bc7173..6620d212 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -3,6 +3,7 @@ import ContainerDemo from './ContainerDemo' import RowDemo from './RowDemo' import ColDemo from './ColDemo' import AccordionDemo from './AccordionDemo' +import AlertDemo from './AlertDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -26,4 +27,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'row', category: 'Layout', element: }, { key: 'col', category: 'Layout', element: }, { key: 'accordion', category: 'Components', element: }, + { key: 'alert', category: 'Components', element: }, ] diff --git a/web-components/src/Alert.ts b/web-components/src/Alert.ts new file mode 100644 index 00000000..2d1773f0 --- /dev/null +++ b/web-components/src/Alert.ts @@ -0,0 +1,103 @@ +import { Alert as BsAlert } from 'bootstrap' + +const TAG_NAME = 'tc-alert' + +export type AlertVariant = 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark' + +const VARIANTS: AlertVariant[] = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'] + +export class Alert extends HTMLElement { + + private _bsAlert: BsAlert | null = null + private _initialised = false + + static get observedAttributes(): string[] { + return ['variant', 'dismissible'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + const slotContent = Array.from(this.childNodes) + this.render() + const inner = this.querySelector('.tc-alert-content') + if (inner) slotContent.forEach(n => inner.appendChild(n)) + this._initialised = true + } + this._initBsAlert() + } + + disconnectedCallback(): void { + this._teardown() + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this._teardown() + const inner = this.querySelector('.tc-alert-content') + const slotContent = inner ? Array.from(inner.childNodes) : [] + this.render() + const newInner = this.querySelector('.tc-alert-content') + if (newInner) slotContent.forEach(n => newInner.appendChild(n)) + this._initBsAlert() + } + + get variant(): AlertVariant { + const v = this.getAttribute('variant') as AlertVariant + return VARIANTS.includes(v) ? v : 'primary' + } + set variant(v: AlertVariant) { + this.setAttribute('variant', v) + } + + get dismissible(): boolean { + return this.hasAttribute('dismissible') + } + set dismissible(v: boolean) { + if (v) this.setAttribute('dismissible', '') + else this.removeAttribute('dismissible') + } + + close(): void { + if (this._bsAlert) { + this._bsAlert.close() + } else { + this.remove() + } + } + + private _onClosed = (): void => { + this.dispatchEvent(new CustomEvent('tc-closed', { bubbles: true, composed: true })) + } + + private render(): void { + const variant = this.variant + const dismissible = this.dismissible + this.setAttribute('role', 'alert') + this.className = `alert alert-${variant}${dismissible ? ' alert-dismissible fade show' : ''}` + this.innerHTML = `${dismissible ? '' : ''}` + } + + private _initBsAlert(): void { + if (!this.dismissible) return + this._bsAlert = new BsAlert(this) + this.addEventListener('closed.bs.alert', this._onClosed) + } + + private _teardown(): void { + this.removeEventListener('closed.bs.alert', this._onClosed) + if (this._bsAlert) { + this._bsAlert.dispose() + this._bsAlert = null + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Alert + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 4cc1bda8..64a07d98 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -4,3 +4,4 @@ export * from './Row' export * from './Col' export * from './Accordion' export * from './AccordionItem' +export * from './Alert' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 59d5bd3d..8345769f 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -3,6 +3,7 @@ import { Row } from './Row' import { Col } from './Col' import { Accordion } from './Accordion' import { AccordionItem } from './AccordionItem' +import { Alert } from './Alert' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -13,4 +14,5 @@ export function register(): void { customElements.define('tc-col', Col) customElements.define('tc-accordion', Accordion) customElements.define('tc-accordion-item', AccordionItem) + customElements.define('tc-alert', Alert) } diff --git a/web-components/style/components/_alert.scss b/web-components/style/components/_alert.scss new file mode 100644 index 00000000..823ff03d --- /dev/null +++ b/web-components/style/components/_alert.scss @@ -0,0 +1 @@ +// tc-alert — theme tweaks only; Bootstrap alert classes handle layout. diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index b6d76ba9..571cf088 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -5,3 +5,4 @@ @forward 'row'; @forward 'col'; @forward 'accordion'; +@forward 'alert'; From cc3c8e270f7ab6424a02ca7c474279489a60efe5 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 16:08:12 +0000 Subject: [PATCH 011/632] 010-tc-badge: Implement the tc-badge Web Component --- examples/src/web-components/BadgeDemo.tsx | 75 +++++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Badge.ts | 83 +++++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_badge.scss | 1 + web-components/style/components/_index.scss | 1 + 7 files changed, 165 insertions(+) create mode 100644 examples/src/web-components/BadgeDemo.tsx create mode 100644 web-components/src/Badge.ts create mode 100644 web-components/style/components/_badge.scss diff --git a/examples/src/web-components/BadgeDemo.tsx b/examples/src/web-components/BadgeDemo.tsx new file mode 100644 index 00000000..87f12e26 --- /dev/null +++ b/examples/src/web-components/BadgeDemo.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const BadgeDemo: React.FC = () => ( +
+
+
+
+ Web Components} + title="Badge" + description="Small count and labelling components. Supports all Bootstrap theme variants and an optional pill shape." + /> + +
+ +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + 42 + {/* @ts-ignore */} + New + {/* @ts-ignore */} + 99+ +
+
+
+
+
+
+
+) + +export default BadgeDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 6620d212..0d4f4bde 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -4,6 +4,7 @@ import RowDemo from './RowDemo' import ColDemo from './ColDemo' import AccordionDemo from './AccordionDemo' import AlertDemo from './AlertDemo' +import BadgeDemo from './BadgeDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -28,4 +29,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'col', category: 'Layout', element: }, { key: 'accordion', category: 'Components', element: }, { key: 'alert', category: 'Components', element: }, + { key: 'badge', category: 'Components', element: }, ] diff --git a/web-components/src/Badge.ts b/web-components/src/Badge.ts new file mode 100644 index 00000000..8f922d83 --- /dev/null +++ b/web-components/src/Badge.ts @@ -0,0 +1,83 @@ +const TAG_NAME = 'tc-badge' + +export type BadgeVariant = 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark' + +const VARIANTS: BadgeVariant[] = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'] + +export class Badge extends HTMLElement { + + private _initialised = false + + static get observedAttributes(): string[] { + return ['variant', 'pill', 'text'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + const slotContent = Array.from(this.childNodes) + this.render() + if (!this.hasAttribute('text')) { + const inner = this.querySelector('.tc-badge-content') + if (inner) slotContent.forEach(n => inner.appendChild(n)) + } + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + const inner = this.querySelector('.tc-badge-content') + const slotContent = inner ? Array.from(inner.childNodes) : [] + this.render() + if (!this.hasAttribute('text')) { + const newInner = this.querySelector('.tc-badge-content') + if (newInner) slotContent.forEach(n => newInner.appendChild(n)) + } + } + + get variant(): BadgeVariant { + const v = this.getAttribute('variant') as BadgeVariant + return VARIANTS.includes(v) ? v : 'primary' + } + set variant(v: BadgeVariant) { + this.setAttribute('variant', v) + } + + get pill(): boolean { + return this.hasAttribute('pill') + } + set pill(v: boolean) { + if (v) this.setAttribute('pill', '') + else this.removeAttribute('pill') + } + + get text(): string | null { + return this.getAttribute('text') + } + set text(v: string | null) { + if (v != null) this.setAttribute('text', v) + else this.removeAttribute('text') + } + + private render(): void { + const variant = this.variant + const pill = this.pill + const text = this.getAttribute('text') + const pillClass = pill ? ' rounded-pill' : '' + if (text != null) { + this.innerHTML = `${text}` + } else { + this.innerHTML = `` + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Badge + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 64a07d98..0176372b 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -5,3 +5,4 @@ export * from './Col' export * from './Accordion' export * from './AccordionItem' export * from './Alert' +export * from './Badge' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 8345769f..ea64066a 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -4,6 +4,7 @@ import { Col } from './Col' import { Accordion } from './Accordion' import { AccordionItem } from './AccordionItem' import { Alert } from './Alert' +import { Badge } from './Badge' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -15,4 +16,5 @@ export function register(): void { customElements.define('tc-accordion', Accordion) customElements.define('tc-accordion-item', AccordionItem) customElements.define('tc-alert', Alert) + customElements.define('tc-badge', Badge) } diff --git a/web-components/style/components/_badge.scss b/web-components/style/components/_badge.scss new file mode 100644 index 00000000..74dff544 --- /dev/null +++ b/web-components/style/components/_badge.scss @@ -0,0 +1 @@ +// tc-badge — theme tweaks only; Bootstrap badge classes handle layout. diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 571cf088..71c535b8 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -6,3 +6,4 @@ @forward 'col'; @forward 'accordion'; @forward 'alert'; +@forward 'badge'; From ecaf0329c893496ecb9bb5d8ebe6c44db5be4f48 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 16:12:32 +0000 Subject: [PATCH 012/632] 011-tc-breadcrumb: Implement the tc-breadcrumb + tc-breadcrumb-item Web Components --- .../src/web-components/BreadcrumbDemo.tsx | 57 ++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Breadcrumb.ts | 60 +++++++++++++++ web-components/src/BreadcrumbItem.ts | 75 +++++++++++++++++++ web-components/src/index.ts | 2 + web-components/src/register.ts | 4 + .../style/components/_breadcrumb.scss | 1 + web-components/style/components/_index.scss | 1 + 8 files changed, 202 insertions(+) create mode 100644 examples/src/web-components/BreadcrumbDemo.tsx create mode 100644 web-components/src/Breadcrumb.ts create mode 100644 web-components/src/BreadcrumbItem.ts create mode 100644 web-components/style/components/_breadcrumb.scss diff --git a/examples/src/web-components/BreadcrumbDemo.tsx b/examples/src/web-components/BreadcrumbDemo.tsx new file mode 100644 index 00000000..94c0b2ea --- /dev/null +++ b/examples/src/web-components/BreadcrumbDemo.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const BreadcrumbDemo: React.FC = () => ( +
+
+
+
+ Web Components} + title="Breadcrumb" + description="Navigation trail backed by Bootstrap's breadcrumb. Wrap tc-breadcrumb-item elements inside tc-breadcrumb. Use href for linked items, active to mark the current page, and divider to customise the separator character." + /> + +
+ + {/* @ts-ignore */} + + {/* @ts-ignore */} + Home + {/* @ts-ignore */} + Library + {/* @ts-ignore */} + Data + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Home + {/* @ts-ignore */} + Products + {/* @ts-ignore */} + Widget + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Home + {/* @ts-ignore */} + + +
+
+
+
+
+) + +export default BreadcrumbDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 0d4f4bde..c22a7dce 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -5,6 +5,7 @@ import ColDemo from './ColDemo' import AccordionDemo from './AccordionDemo' import AlertDemo from './AlertDemo' import BadgeDemo from './BadgeDemo' +import BreadcrumbDemo from './BreadcrumbDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -30,4 +31,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'accordion', category: 'Components', element: }, { key: 'alert', category: 'Components', element: }, { key: 'badge', category: 'Components', element: }, + { key: 'breadcrumb', category: 'Navigation', element: }, ] diff --git a/web-components/src/Breadcrumb.ts b/web-components/src/Breadcrumb.ts new file mode 100644 index 00000000..28c21f39 --- /dev/null +++ b/web-components/src/Breadcrumb.ts @@ -0,0 +1,60 @@ +const TAG_NAME = 'tc-breadcrumb' + +export class Breadcrumb extends HTMLElement { + + private _initialised = false + + static get observedAttributes(): string[] { + return ['divider'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + const slotContent = Array.from(this.childNodes) + this.render() + const ol = this.querySelector('ol.breadcrumb') + if (ol) slotContent.forEach(n => ol.appendChild(n)) + this._updateDivider() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this._updateDivider() + } + + get divider(): string | null { + return this.getAttribute('divider') + } + set divider(v: string | null) { + if (v != null) this.setAttribute('divider', v) + else this.removeAttribute('divider') + } + + private _updateDivider(): void { + const nav = this.querySelector('nav') + if (!nav) return + const d = this.getAttribute('divider') + if (d != null) { + const escaped = d.replace(/'/g, "\\'") + nav.style.setProperty('--bs-breadcrumb-divider', `'${escaped}'`) + } else { + nav.style.removeProperty('--bs-breadcrumb-divider') + } + } + + private render(): void { + this.innerHTML = `` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Breadcrumb + } +} diff --git a/web-components/src/BreadcrumbItem.ts b/web-components/src/BreadcrumbItem.ts new file mode 100644 index 00000000..cc73739e --- /dev/null +++ b/web-components/src/BreadcrumbItem.ts @@ -0,0 +1,75 @@ +const TAG_NAME = 'tc-breadcrumb-item' + +export class BreadcrumbItem extends HTMLElement { + + private _initialised = false + + static get observedAttributes(): string[] { + return ['href', 'active'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + const slotContent = Array.from(this.childNodes) + this.render() + const inner = this.querySelector('.tc-breadcrumb-item-content') + if (inner) slotContent.forEach(n => inner.appendChild(n)) + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + const inner = this.querySelector('.tc-breadcrumb-item-content') + const slotContent = inner ? Array.from(inner.childNodes) : [] + this.render() + const newInner = this.querySelector('.tc-breadcrumb-item-content') + if (newInner) slotContent.forEach(n => newInner.appendChild(n)) + } + + get href(): string | null { + return this.getAttribute('href') + } + set href(v: string | null) { + if (v != null) this.setAttribute('href', v) + else this.removeAttribute('href') + } + + get active(): boolean { + return this.hasAttribute('active') + } + set active(v: boolean) { + if (v) this.setAttribute('active', '') + else this.removeAttribute('active') + } + + private render(): void { + const active = this.active + const href = this.getAttribute('href') + + this.classList.add('breadcrumb-item') + this.classList.toggle('active', active) + + if (active) { + this.setAttribute('aria-current', 'page') + } else { + this.removeAttribute('aria-current') + } + + if (!active && href != null) { + this.innerHTML = `
` + } else { + this.innerHTML = `` + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: BreadcrumbItem + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 0176372b..8aa33198 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -6,3 +6,5 @@ export * from './Accordion' export * from './AccordionItem' export * from './Alert' export * from './Badge' +export * from './Breadcrumb' +export * from './BreadcrumbItem' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index ea64066a..25d5f411 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -5,6 +5,8 @@ import { Accordion } from './Accordion' import { AccordionItem } from './AccordionItem' import { Alert } from './Alert' import { Badge } from './Badge' +import { Breadcrumb } from './Breadcrumb' +import { BreadcrumbItem } from './BreadcrumbItem' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -17,4 +19,6 @@ export function register(): void { customElements.define('tc-accordion-item', AccordionItem) customElements.define('tc-alert', Alert) customElements.define('tc-badge', Badge) + customElements.define('tc-breadcrumb', Breadcrumb) + customElements.define('tc-breadcrumb-item', BreadcrumbItem) } diff --git a/web-components/style/components/_breadcrumb.scss b/web-components/style/components/_breadcrumb.scss new file mode 100644 index 00000000..73b86dbb --- /dev/null +++ b/web-components/style/components/_breadcrumb.scss @@ -0,0 +1 @@ +// tc-breadcrumb / tc-breadcrumb-item — no additional theme overrides needed; Bootstrap breadcrumb classes handle layout. diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 71c535b8..f0bd7ab3 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -7,3 +7,4 @@ @forward 'accordion'; @forward 'alert'; @forward 'badge'; +@forward 'breadcrumb'; From 39948d88e8ee40c426e688c520ff4b4d15e1c239 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 16:14:19 +0000 Subject: [PATCH 013/632] 012-tc-button: Implement the tc-button Web Component --- examples/src/web-components/ButtonDemo.tsx | 106 +++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Button.ts | 131 +++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_button.scss | 1 + web-components/style/components/_index.scss | 1 + 7 files changed, 244 insertions(+) create mode 100644 examples/src/web-components/ButtonDemo.tsx create mode 100644 web-components/src/Button.ts create mode 100644 web-components/style/components/_button.scss diff --git a/examples/src/web-components/ButtonDemo.tsx b/examples/src/web-components/ButtonDemo.tsx new file mode 100644 index 00000000..a8e1571b --- /dev/null +++ b/examples/src/web-components/ButtonDemo.tsx @@ -0,0 +1,106 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const ButtonDemo: React.FC = () => ( +
+
+
+
+ Web Components} + title="Button" + description="Bootstrap button wrapper with variant, outline, size, loading, and link support." + /> + +
+ +
+ {/* @ts-ignore */} + Primary + {/* @ts-ignore */} + Secondary + {/* @ts-ignore */} + Success + {/* @ts-ignore */} + Danger + {/* @ts-ignore */} + Warning + {/* @ts-ignore */} + Info + {/* @ts-ignore */} + Light + {/* @ts-ignore */} + Dark +
+
+ + +
+ {/* @ts-ignore */} + Primary + {/* @ts-ignore */} + Secondary + {/* @ts-ignore */} + Success + {/* @ts-ignore */} + Danger + {/* @ts-ignore */} + Warning + {/* @ts-ignore */} + Info + {/* @ts-ignore */} + Light + {/* @ts-ignore */} + Dark +
+
+ + +
+ {/* @ts-ignore */} + Large + {/* @ts-ignore */} + Default + {/* @ts-ignore */} + Small +
+
+ + +
+ {/* @ts-ignore */} + Saving… + {/* @ts-ignore */} + Loading… + {/* @ts-ignore */} + Processing… +
+
+ + +
+ {/* @ts-ignore */} + Disabled + {/* @ts-ignore */} + Disabled +
+
+ + +
+ {/* @ts-ignore */} + Link button + {/* @ts-ignore */} + Outline link + {/* @ts-ignore */} + Disabled link +
+
+
+
+
+
+
+) + +export default ButtonDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index c22a7dce..b9bb0c64 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -6,6 +6,7 @@ import AccordionDemo from './AccordionDemo' import AlertDemo from './AlertDemo' import BadgeDemo from './BadgeDemo' import BreadcrumbDemo from './BreadcrumbDemo' +import ButtonDemo from './ButtonDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -31,5 +32,6 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'accordion', category: 'Components', element: }, { key: 'alert', category: 'Components', element: }, { key: 'badge', category: 'Components', element: }, + { key: 'button', category: 'Components', element: }, { key: 'breadcrumb', category: 'Navigation', element: }, ] diff --git a/web-components/src/Button.ts b/web-components/src/Button.ts new file mode 100644 index 00000000..aa461849 --- /dev/null +++ b/web-components/src/Button.ts @@ -0,0 +1,131 @@ +const TAG_NAME = 'tc-button' + +export type ButtonVariant = 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark' +export type ButtonSize = 'sm' | 'lg' +export type ButtonType = 'button' | 'submit' | 'reset' + +const VARIANTS: ButtonVariant[] = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'] +const SIZES: ButtonSize[] = ['sm', 'lg'] + +export class Button extends HTMLElement { + + private _initialised = false + + static get observedAttributes(): string[] { + return ['variant', 'outline', 'size', 'disabled', 'loading', 'href', 'type'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + const slotContent = Array.from(this.childNodes) + this.render() + const inner = this.querySelector('.tc-button-content') + if (inner) slotContent.forEach(n => inner.appendChild(n)) + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + const inner = this.querySelector('.tc-button-content') + const slotContent = inner ? Array.from(inner.childNodes) : [] + this.render() + const newInner = this.querySelector('.tc-button-content') + if (newInner) slotContent.forEach(n => newInner.appendChild(n)) + } + + get variant(): ButtonVariant { + const v = this.getAttribute('variant') as ButtonVariant + return VARIANTS.includes(v) ? v : 'primary' + } + set variant(v: ButtonVariant) { + this.setAttribute('variant', v) + } + + get outline(): boolean { + return this.hasAttribute('outline') + } + set outline(v: boolean) { + if (v) this.setAttribute('outline', '') + else this.removeAttribute('outline') + } + + get size(): ButtonSize | null { + const v = this.getAttribute('size') as ButtonSize + return SIZES.includes(v) ? v : null + } + set size(v: ButtonSize | null) { + if (v != null) this.setAttribute('size', v) + else this.removeAttribute('size') + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + get loading(): boolean { + return this.hasAttribute('loading') + } + set loading(v: boolean) { + if (v) this.setAttribute('loading', '') + else this.removeAttribute('loading') + } + + get href(): string | null { + return this.getAttribute('href') + } + set href(v: string | null) { + if (v != null) this.setAttribute('href', v) + else this.removeAttribute('href') + } + + get type(): ButtonType { + const v = this.getAttribute('type') as ButtonType + return v === 'submit' || v === 'reset' ? v : 'button' + } + set type(v: ButtonType) { + this.setAttribute('type', v) + } + + private render(): void { + const variant = this.variant + const outline = this.outline + const size = this.size + const disabled = this.disabled + const loading = this.loading + const href = this.href + const isDisabled = disabled || loading + + const variantClass = outline ? `btn-outline-${variant}` : `btn-${variant}` + const sizeClass = size ? ` btn-${size}` : '' + const classes = `btn ${variantClass}${sizeClass}` + + const spinnerHtml = loading + ? `` + : '' + + if (href != null) { + const disabledAttr = isDisabled ? ' aria-disabled="true" tabindex="-1"' : '' + const disabledClass = isDisabled ? ' disabled' : '' + this.innerHTML = `${spinnerHtml}` + } else { + const disabledAttr = isDisabled ? ' disabled' : '' + const typeAttr = this.type + this.innerHTML = `` + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Button + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 8aa33198..c5a03f12 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -8,3 +8,4 @@ export * from './Alert' export * from './Badge' export * from './Breadcrumb' export * from './BreadcrumbItem' +export * from './Button' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 25d5f411..9ea85069 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -7,6 +7,7 @@ import { Alert } from './Alert' import { Badge } from './Badge' import { Breadcrumb } from './Breadcrumb' import { BreadcrumbItem } from './BreadcrumbItem' +import { Button } from './Button' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -21,4 +22,5 @@ export function register(): void { customElements.define('tc-badge', Badge) customElements.define('tc-breadcrumb', Breadcrumb) customElements.define('tc-breadcrumb-item', BreadcrumbItem) + customElements.define('tc-button', Button) } diff --git a/web-components/style/components/_button.scss b/web-components/style/components/_button.scss new file mode 100644 index 00000000..393cf026 --- /dev/null +++ b/web-components/style/components/_button.scss @@ -0,0 +1 @@ +// tc-button — theme tweaks only; Bootstrap button classes handle layout. diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index f0bd7ab3..bfa53ab8 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -8,3 +8,4 @@ @forward 'alert'; @forward 'badge'; @forward 'breadcrumb'; +@forward 'button'; From d4acc56cad1ec0f428fc61128f70ca477e50d079 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 19:24:34 +0000 Subject: [PATCH 014/632] 013-tc-button-group: Implement the tc-button-group Web Component --- .../src/web-components/ButtonGroupDemo.tsx | 96 +++++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/ButtonGroup.ts | 86 +++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + .../style/components/_button-group.scss | 1 + web-components/style/components/_index.scss | 1 + 7 files changed, 189 insertions(+) create mode 100644 examples/src/web-components/ButtonGroupDemo.tsx create mode 100644 web-components/src/ButtonGroup.ts create mode 100644 web-components/style/components/_button-group.scss diff --git a/examples/src/web-components/ButtonGroupDemo.tsx b/examples/src/web-components/ButtonGroupDemo.tsx new file mode 100644 index 00000000..5a291f97 --- /dev/null +++ b/examples/src/web-components/ButtonGroupDemo.tsx @@ -0,0 +1,96 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const ButtonGroupDemo: React.FC = () => ( +
+
+
+
+ Web Components} + title="Button Group" + description="Bootstrap button group wrapper supporting horizontal and vertical layouts with optional size variants." + /> + +
+ + {/* @ts-ignore */} + + {/* @ts-ignore */} + Left + {/* @ts-ignore */} + Middle + {/* @ts-ignore */} + Right + {/* @ts-ignore */} + + + + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + Left + {/* @ts-ignore */} + Middle + {/* @ts-ignore */} + Right + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Left + {/* @ts-ignore */} + Middle + {/* @ts-ignore */} + Right + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Left + {/* @ts-ignore */} + Middle + {/* @ts-ignore */} + Right + {/* @ts-ignore */} + +
+
+ + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Top + {/* @ts-ignore */} + Middle + {/* @ts-ignore */} + Bottom + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Delete + {/* @ts-ignore */} + Archive + {/* @ts-ignore */} + Save + {/* @ts-ignore */} + + +
+
+
+
+
+) + +export default ButtonGroupDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index b9bb0c64..3e88035b 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -7,6 +7,7 @@ import AlertDemo from './AlertDemo' import BadgeDemo from './BadgeDemo' import BreadcrumbDemo from './BreadcrumbDemo' import ButtonDemo from './ButtonDemo' +import ButtonGroupDemo from './ButtonGroupDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -33,5 +34,6 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'alert', category: 'Components', element: }, { key: 'badge', category: 'Components', element: }, { key: 'button', category: 'Components', element: }, + { key: 'button-group', category: 'Components', element: }, { key: 'breadcrumb', category: 'Navigation', element: }, ] diff --git a/web-components/src/ButtonGroup.ts b/web-components/src/ButtonGroup.ts new file mode 100644 index 00000000..81e732ed --- /dev/null +++ b/web-components/src/ButtonGroup.ts @@ -0,0 +1,86 @@ +const TAG_NAME = 'tc-button-group' + +export type ButtonGroupSize = 'sm' | 'lg' + +const SIZES: ButtonGroupSize[] = ['sm', 'lg'] + +export class ButtonGroup extends HTMLElement { + + private _initialised = false + + static get observedAttributes(): string[] { + return ['vertical', 'size', 'aria-label'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + const slotContent = Array.from(this.childNodes) + this.render() + const inner = this.querySelector('[role="group"]') + if (inner) slotContent.forEach(n => inner.appendChild(n)) + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this._updateGroup() + } + + get vertical(): boolean { + return this.hasAttribute('vertical') + } + set vertical(v: boolean) { + if (v) this.setAttribute('vertical', '') + else this.removeAttribute('vertical') + } + + get size(): ButtonGroupSize | null { + const v = this.getAttribute('size') as ButtonGroupSize + return SIZES.includes(v) ? v : null + } + set size(v: ButtonGroupSize | null) { + if (v != null) this.setAttribute('size', v) + else this.removeAttribute('size') + } + + private render(): void { + const vertical = this.vertical + const size = this.size + const label = this.getAttribute('aria-label') ?? '' + + const sizeClass = size ? ` btn-group-${size}` : '' + const dirClass = vertical ? 'btn-group-vertical' : 'btn-group' + const labelAttr = label ? ` aria-label="${label}"` : '' + + this.innerHTML = `
` + } + + private _updateGroup(): void { + const inner = this.querySelector('[role="group"]') + if (!inner) return + + const vertical = this.vertical + const size = this.size + const label = this.getAttribute('aria-label') + + const sizeClass = size ? ` btn-group-${size}` : '' + inner.className = vertical ? `btn-group-vertical${sizeClass}` : `btn-group${sizeClass}` + + if (label != null) { + inner.setAttribute('aria-label', label) + } else { + inner.removeAttribute('aria-label') + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: ButtonGroup + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index c5a03f12..ba71c111 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -9,3 +9,4 @@ export * from './Badge' export * from './Breadcrumb' export * from './BreadcrumbItem' export * from './Button' +export * from './ButtonGroup' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 9ea85069..ed20e72f 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -8,6 +8,7 @@ import { Badge } from './Badge' import { Breadcrumb } from './Breadcrumb' import { BreadcrumbItem } from './BreadcrumbItem' import { Button } from './Button' +import { ButtonGroup } from './ButtonGroup' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -23,4 +24,5 @@ export function register(): void { customElements.define('tc-breadcrumb', Breadcrumb) customElements.define('tc-breadcrumb-item', BreadcrumbItem) customElements.define('tc-button', Button) + customElements.define('tc-button-group', ButtonGroup) } diff --git a/web-components/style/components/_button-group.scss b/web-components/style/components/_button-group.scss new file mode 100644 index 00000000..4dcece3c --- /dev/null +++ b/web-components/style/components/_button-group.scss @@ -0,0 +1 @@ +// tc-button-group — theme tweaks only; Bootstrap btn-group classes handle layout. diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index bfa53ab8..65509f6a 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -9,3 +9,4 @@ @forward 'badge'; @forward 'breadcrumb'; @forward 'button'; +@forward 'button-group'; From e8f920b4da906ce31eaf1f44cdfc973e375d23c5 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 19:31:08 +0000 Subject: [PATCH 015/632] 014-tc-card: Implement the tc-card Web Component --- examples/src/web-components/CardDemo.tsx | 88 +++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Card.ts | 118 ++++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_card.scss | 1 + web-components/style/components/_index.scss | 1 + 7 files changed, 213 insertions(+) create mode 100644 examples/src/web-components/CardDemo.tsx create mode 100644 web-components/src/Card.ts create mode 100644 web-components/style/components/_card.scss diff --git a/examples/src/web-components/CardDemo.tsx b/examples/src/web-components/CardDemo.tsx new file mode 100644 index 00000000..ed1e942b --- /dev/null +++ b/examples/src/web-components/CardDemo.tsx @@ -0,0 +1,88 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const CardDemo: React.FC = () => ( +
+
+
+
+ Web Components} + title="Card" + description="Flexible content container built on Bootstrap's card classes. Supports title, subtitle, image (top or bottom), theme variants, and optional header/footer slots." + /> + +
+ +
+
+ {/* @ts-ignore */} + +

Some quick example text to build on the card title and make up the bulk of the card's content.

+ Go somewhere + {/* @ts-ignore */} +
+
+
+
+ + +
+
+ {/* @ts-ignore */} + +

A card with an image at the top. The image uses .card-img-top and rounds the upper corners.

+ {/* @ts-ignore */} +
+
+
+
+ + +
+
+ {/* @ts-ignore */} + +

A card with an image at the bottom. The image uses .card-img-bottom and rounds the lower corners.

+ {/* @ts-ignore */} +
+
+
+
+ + +
+
+ {/* @ts-ignore */} + + Featured +

With supporting text below as a natural lead-in to additional content.

+ Go somewhere + Last updated 3 mins ago + {/* @ts-ignore */} +
+
+
+
+ + +
+ {(['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'] as const).map(v => ( +
+ {/* @ts-ignore */} + +

A {v} card using the variant attribute.

+ {/* @ts-ignore */} +
+
+ ))} +
+
+
+
+
+
+
+) + +export default CardDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 3e88035b..9e5c09f9 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -8,6 +8,7 @@ import BadgeDemo from './BadgeDemo' import BreadcrumbDemo from './BreadcrumbDemo' import ButtonDemo from './ButtonDemo' import ButtonGroupDemo from './ButtonGroupDemo' +import CardDemo from './CardDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -35,5 +36,6 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'badge', category: 'Components', element: }, { key: 'button', category: 'Components', element: }, { key: 'button-group', category: 'Components', element: }, + { key: 'card', category: 'Components', element: }, { key: 'breadcrumb', category: 'Navigation', element: }, ] diff --git a/web-components/src/Card.ts b/web-components/src/Card.ts new file mode 100644 index 00000000..5f64f7d2 --- /dev/null +++ b/web-components/src/Card.ts @@ -0,0 +1,118 @@ +const TAG_NAME = 'tc-card' + +export type CardVariant = 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark' +export type CardImgPosition = 'top' | 'bottom' + +const VARIANTS: CardVariant[] = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'] + +function escAttr(v: string): string { + return v.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') +} + +export class Card extends HTMLElement { + + private _initialised = false + private _headerNodes: Node[] = [] + private _footerNodes: Node[] = [] + private _bodyNodes: Node[] = [] + + static get observedAttributes(): string[] { + return ['title', 'subtitle', 'img', 'img-position', 'variant'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + const children = Array.from(this.childNodes) + this._headerNodes = children.filter(n => (n as Element).getAttribute?.('slot') === 'header') + this._footerNodes = children.filter(n => (n as Element).getAttribute?.('slot') === 'footer') + this._bodyNodes = children.filter(n => { + const slot = (n as Element).getAttribute?.('slot') + return slot !== 'header' && slot !== 'footer' + }) + this._initialised = true + } + this.render() + this._reattach() + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + this._reattach() + } + + get variant(): CardVariant | null { + const v = this.getAttribute('variant') as CardVariant + return VARIANTS.includes(v) ? v : null + } + set variant(v: CardVariant | null) { + if (v != null) this.setAttribute('variant', v) + else this.removeAttribute('variant') + } + + get img(): string | null { + return this.getAttribute('img') + } + set img(v: string | null) { + if (v != null) this.setAttribute('img', v) + else this.removeAttribute('img') + } + + get imgPosition(): CardImgPosition { + return this.getAttribute('img-position') === 'bottom' ? 'bottom' : 'top' + } + set imgPosition(v: CardImgPosition) { + this.setAttribute('img-position', v) + } + + private render(): void { + const title = this.getAttribute('title') + const subtitle = this.getAttribute('subtitle') + const img = this.img + const imgPosition = this.imgPosition + const variant = this.variant + + this.className = `card${variant ? ` text-bg-${variant}` : ''}` + + const headerHtml = this._headerNodes.length > 0 + ? `
` + : '' + const footerHtml = this._footerNodes.length > 0 + ? `` + : '' + const imgHtml = img + ? `` + : '' + const titleHtml = title ? `
${escAttr(title)}
` : '' + const subtitleHtml = subtitle + ? `
${escAttr(subtitle)}
` + : '' + + const bodyHtml = `
${titleHtml}${subtitleHtml}
` + + if (imgPosition === 'bottom') { + this.innerHTML = `${headerHtml}${bodyHtml}${imgHtml}${footerHtml}` + } else { + this.innerHTML = `${headerHtml}${imgHtml}${bodyHtml}${footerHtml}` + } + } + + private _reattach(): void { + const headerEl = this.querySelector('.tc-card-header') + const bodyEl = this.querySelector('.tc-card-body') + const footerEl = this.querySelector('.tc-card-footer') + this._headerNodes.forEach(n => headerEl?.appendChild(n)) + this._bodyNodes.forEach(n => bodyEl?.appendChild(n)) + this._footerNodes.forEach(n => footerEl?.appendChild(n)) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Card + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index ba71c111..75866f86 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -10,3 +10,4 @@ export * from './Breadcrumb' export * from './BreadcrumbItem' export * from './Button' export * from './ButtonGroup' +export * from './Card' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index ed20e72f..3f2c1e8e 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -9,6 +9,7 @@ import { Breadcrumb } from './Breadcrumb' import { BreadcrumbItem } from './BreadcrumbItem' import { Button } from './Button' import { ButtonGroup } from './ButtonGroup' +import { Card } from './Card' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -25,4 +26,5 @@ export function register(): void { customElements.define('tc-breadcrumb-item', BreadcrumbItem) customElements.define('tc-button', Button) customElements.define('tc-button-group', ButtonGroup) + customElements.define('tc-card', Card) } diff --git a/web-components/style/components/_card.scss b/web-components/style/components/_card.scss new file mode 100644 index 00000000..aa051f8e --- /dev/null +++ b/web-components/style/components/_card.scss @@ -0,0 +1 @@ +// tc-card — theme tweaks only; Bootstrap card classes handle layout. diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 65509f6a..acf7a1de 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -10,3 +10,4 @@ @forward 'breadcrumb'; @forward 'button'; @forward 'button-group'; +@forward 'card'; From 43abc2ae9ea163793e81e43ef6fec082b06beb12 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 19:35:12 +0000 Subject: [PATCH 016/632] 015-tc-carousel: Implement the tc-carousel Web Component --- examples/src/web-components/CarouselDemo.tsx | 72 +++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Carousel.ts | 195 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + .../style/components/_carousel.scss | 1 + web-components/style/components/_index.scss | 1 + 7 files changed, 274 insertions(+) create mode 100644 examples/src/web-components/CarouselDemo.tsx create mode 100644 web-components/src/Carousel.ts create mode 100644 web-components/style/components/_carousel.scss diff --git a/examples/src/web-components/CarouselDemo.tsx b/examples/src/web-components/CarouselDemo.tsx new file mode 100644 index 00000000..506b292e --- /dev/null +++ b/examples/src/web-components/CarouselDemo.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const CarouselDemo: React.FC = () => ( +
+
+
+
+ Web Components} + title="Carousel" + description="Slideshow component backed by Bootstrap's Carousel plugin. Supports indicators, prev/next controls, fade transitions, and autoplay via the ride attribute. Each direct child becomes a slide." + /> + +
+ + {/* @ts-ignore */} + +
Slide 1
+
Slide 2
+
Slide 3
+ {/* @ts-ignore */} +
+
+ + + {/* @ts-ignore */} + +
Slide 1
+
Slide 2
+
Slide 3
+ {/* @ts-ignore */} +
+
+ + + {/* @ts-ignore */} + +
Slide 1
+
Slide 2
+
Slide 3
+ {/* @ts-ignore */} +
+
+ + + {/* @ts-ignore */} + +
Slide 1 (fade)
+
Slide 2 (fade)
+
Slide 3 (fade)
+ {/* @ts-ignore */} +
+
+ + + {/* @ts-ignore */} + +
Auto 1
+
Auto 2
+
Auto 3
+ {/* @ts-ignore */} +
+
+
+
+
+
+
+) + +export default CarouselDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 9e5c09f9..06471c82 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -9,6 +9,7 @@ import BreadcrumbDemo from './BreadcrumbDemo' import ButtonDemo from './ButtonDemo' import ButtonGroupDemo from './ButtonGroupDemo' import CardDemo from './CardDemo' +import CarouselDemo from './CarouselDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -37,5 +38,6 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'button', category: 'Components', element: }, { key: 'button-group', category: 'Components', element: }, { key: 'card', category: 'Components', element: }, + { key: 'carousel', category: 'Components', element: }, { key: 'breadcrumb', category: 'Navigation', element: }, ] diff --git a/web-components/src/Carousel.ts b/web-components/src/Carousel.ts new file mode 100644 index 00000000..67c1ad7b --- /dev/null +++ b/web-components/src/Carousel.ts @@ -0,0 +1,195 @@ +import { Carousel as BsCarousel } from 'bootstrap' + +const TAG_NAME = 'tc-carousel' + +let counter = 0 + +export class Carousel extends HTMLElement { + + private _bsCarousel: BsCarousel | null = null + private _carouselId: string + private _slides: Node[] = [] + private _initialised = false + + static get observedAttributes(): string[] { + return ['interval', 'controls', 'indicators', 'fade', 'ride', 'pause'] + } + + constructor() { + super() + this._carouselId = `tc-carousel-${++counter}` + } + + connectedCallback(): void { + if (!this.id) this.id = this._carouselId + if (!this._initialised) { + this._slides = Array.from(this.childNodes) + this._initialised = true + } + this.render() + this._initPlugin() + } + + disconnectedCallback(): void { + this._teardown() + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this._teardown() + this.render() + this._initPlugin() + } + + get interval(): number { + const v = this.getAttribute('interval') + return v !== null ? parseInt(v, 10) : 5000 + } + set interval(v: number) { + this.setAttribute('interval', String(v)) + } + + get controls(): boolean { + return this.hasAttribute('controls') + } + set controls(v: boolean) { + if (v) this.setAttribute('controls', '') + else this.removeAttribute('controls') + } + + get indicators(): boolean { + return this.hasAttribute('indicators') + } + set indicators(v: boolean) { + if (v) this.setAttribute('indicators', '') + else this.removeAttribute('indicators') + } + + get fade(): boolean { + return this.hasAttribute('fade') + } + set fade(v: boolean) { + if (v) this.setAttribute('fade', '') + else this.removeAttribute('fade') + } + + get ride(): string { + return this.getAttribute('ride') ?? 'false' + } + set ride(v: string) { + this.setAttribute('ride', v) + } + + get pauseMode(): string { + return this.getAttribute('pause') ?? 'hover' + } + set pauseMode(v: string) { + this.setAttribute('pause', v) + } + + next(): void { + this._bsCarousel?.next() + } + + prev(): void { + this._bsCarousel?.prev() + } + + to(i: number): void { + this._bsCarousel?.to(i) + } + + cycle(): void { + this._bsCarousel?.cycle() + } + + pause(): void { + this._bsCarousel?.pause() + } + + private _onSlide = (e: Event): void => { + const ce = e as any + this.dispatchEvent(new CustomEvent('tc-slide', { + bubbles: true, + composed: true, + detail: { from: ce.from, to: ce.to, direction: ce.direction }, + })) + } + + private _onSlid = (e: Event): void => { + const ce = e as any + this.dispatchEvent(new CustomEvent('tc-slid', { + bubbles: true, + composed: true, + detail: { from: ce.from, to: ce.to, direction: ce.direction }, + })) + } + + private render(): void { + const id = this.id || this._carouselId + + this.className = `carousel slide${this.fade ? ' carousel-fade' : ''}` + + const indicatorsHtml = this.indicators + ? `` + : '' + + const slidesHtml = this._slides + .map((_, i) => ``) + .join('') + + const controlsHtml = this.controls + ? ` + ` + : '' + + this.innerHTML = `${indicatorsHtml}${controlsHtml}` + + this._slides.forEach((node, i) => { + const slot = this.querySelector(`[data-tc-slide="${i}"]`) + if (slot) slot.appendChild(node) + }) + } + + private _initPlugin(): void { + const rideAttr = this.getAttribute('ride') + const ride: 'carousel' | boolean = + rideAttr === 'carousel' ? 'carousel' : rideAttr === 'true' ? true : false + + const pauseAttr = this.getAttribute('pause') + const pause: 'hover' | false = pauseAttr === 'false' ? false : 'hover' + + this._bsCarousel = new BsCarousel(this, { + interval: this.interval, + ride, + pause, + }) + this.addEventListener('slide.bs.carousel', this._onSlide) + this.addEventListener('slid.bs.carousel', this._onSlid) + } + + private _teardown(): void { + this.removeEventListener('slide.bs.carousel', this._onSlide) + this.removeEventListener('slid.bs.carousel', this._onSlid) + if (this._bsCarousel) { + this._bsCarousel.dispose() + this._bsCarousel = null + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Carousel + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 75866f86..9863a7b6 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -11,3 +11,4 @@ export * from './BreadcrumbItem' export * from './Button' export * from './ButtonGroup' export * from './Card' +export * from './Carousel' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 3f2c1e8e..d3fb8d9a 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -10,6 +10,7 @@ import { BreadcrumbItem } from './BreadcrumbItem' import { Button } from './Button' import { ButtonGroup } from './ButtonGroup' import { Card } from './Card' +import { Carousel } from './Carousel' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -27,4 +28,5 @@ export function register(): void { customElements.define('tc-button', Button) customElements.define('tc-button-group', ButtonGroup) customElements.define('tc-card', Card) + customElements.define('tc-carousel', Carousel) } diff --git a/web-components/style/components/_carousel.scss b/web-components/style/components/_carousel.scss new file mode 100644 index 00000000..5b94c3ed --- /dev/null +++ b/web-components/style/components/_carousel.scss @@ -0,0 +1 @@ +// tc-carousel — theme tweaks only; Bootstrap carousel classes handle layout. diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index acf7a1de..11bf412b 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -11,3 +11,4 @@ @forward 'button'; @forward 'button-group'; @forward 'card'; +@forward 'carousel'; From f3f6f423d70fd00c49c0778c32a9852af84d3b3c Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 19:39:45 +0000 Subject: [PATCH 017/632] 016-tc-close-button: Implement the tc-close-button Web Component --- .../src/web-components/CloseButtonDemo.tsx | 43 +++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/CloseButton.ts | 47 +++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + .../style/components/_close-button.scss | 1 + web-components/style/components/_index.scss | 1 + 7 files changed, 97 insertions(+) create mode 100644 examples/src/web-components/CloseButtonDemo.tsx create mode 100644 web-components/src/CloseButton.ts create mode 100644 web-components/style/components/_close-button.scss diff --git a/examples/src/web-components/CloseButtonDemo.tsx b/examples/src/web-components/CloseButtonDemo.tsx new file mode 100644 index 00000000..51783228 --- /dev/null +++ b/examples/src/web-components/CloseButtonDemo.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const CloseButtonDemo: React.FC = () => ( +
+
+
+
+ Web Components} + title="Close Button" + description="Bootstrap standalone close button (×) with disabled and aria-label support." + /> + +
+ +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + +
+
+
+
+
+
+
+) + +export default CloseButtonDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 06471c82..ab2119b1 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -10,6 +10,7 @@ import ButtonDemo from './ButtonDemo' import ButtonGroupDemo from './ButtonGroupDemo' import CardDemo from './CardDemo' import CarouselDemo from './CarouselDemo' +import CloseButtonDemo from './CloseButtonDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -39,5 +40,6 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'button-group', category: 'Components', element: }, { key: 'card', category: 'Components', element: }, { key: 'carousel', category: 'Components', element: }, + { key: 'close-button', category: 'Components', element: }, { key: 'breadcrumb', category: 'Navigation', element: }, ] diff --git a/web-components/src/CloseButton.ts b/web-components/src/CloseButton.ts new file mode 100644 index 00000000..d30c146f --- /dev/null +++ b/web-components/src/CloseButton.ts @@ -0,0 +1,47 @@ +const TAG_NAME = 'tc-close-button' + +export class CloseButton extends HTMLElement { + + static get observedAttributes(): string[] { + return ['disabled', 'aria-label'] + } + + constructor() { + super() + } + + connectedCallback(): void { + this.render() + } + + attributeChangedCallback(): void { + if (this.isConnected) this.render() + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + get ariaLabel(): string { + return this.getAttribute('aria-label') ?? 'Close' + } + set ariaLabel(v: string) { + this.setAttribute('aria-label', v) + } + + private render(): void { + const label = this.ariaLabel + const disabledAttr = this.disabled ? ' disabled' : '' + this.innerHTML = `` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: CloseButton + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 9863a7b6..ee7cc0c8 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -12,3 +12,4 @@ export * from './Button' export * from './ButtonGroup' export * from './Card' export * from './Carousel' +export * from './CloseButton' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index d3fb8d9a..0d75ab9b 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -11,6 +11,7 @@ import { Button } from './Button' import { ButtonGroup } from './ButtonGroup' import { Card } from './Card' import { Carousel } from './Carousel' +import { CloseButton } from './CloseButton' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -29,4 +30,5 @@ export function register(): void { customElements.define('tc-button-group', ButtonGroup) customElements.define('tc-card', Card) customElements.define('tc-carousel', Carousel) + customElements.define('tc-close-button', CloseButton) } diff --git a/web-components/style/components/_close-button.scss b/web-components/style/components/_close-button.scss new file mode 100644 index 00000000..9c501f2b --- /dev/null +++ b/web-components/style/components/_close-button.scss @@ -0,0 +1 @@ +// tc-close-button — theme tweaks only; Bootstrap btn-close handles layout. diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 11bf412b..a10da8ca 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -12,3 +12,4 @@ @forward 'button-group'; @forward 'card'; @forward 'carousel'; +@forward 'close-button'; From 88cdfc6ddc83068dcbbaa714051c9052fbdbf248 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 19:43:29 +0000 Subject: [PATCH 018/632] 017-tc-collapse: Implement the tc-collapse Web Component --- examples/src/web-components/CollapseDemo.tsx | 85 ++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Collapse.ts | 145 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + .../style/components/_collapse.scss | 1 + web-components/style/components/_index.scss | 1 + 7 files changed, 237 insertions(+) create mode 100644 examples/src/web-components/CollapseDemo.tsx create mode 100644 web-components/src/Collapse.ts create mode 100644 web-components/style/components/_collapse.scss diff --git a/examples/src/web-components/CollapseDemo.tsx b/examples/src/web-components/CollapseDemo.tsx new file mode 100644 index 00000000..39084bd1 --- /dev/null +++ b/examples/src/web-components/CollapseDemo.tsx @@ -0,0 +1,85 @@ +import React, { useRef } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const CollapseDemo: React.FC = () => { + const collapseRef = useRef(null) + const hCollapseRef = useRef(null) + + return ( +
+
+
+
+ Web Components} + title="Collapse" + description="Toggleable content region backed by Bootstrap's Collapse plugin. Use the open attribute to start expanded and horizontal for a width-based transition." + /> + +
+ +
+ + + +
+ {/* @ts-ignore */} + +
+ This content is collapsible. Click Toggle to show or hide it using Bootstrap's Collapse plugin. +
+ {/* @ts-ignore */} +
+
+ + + {/* @ts-ignore */} + +
+ This panel starts expanded because the open attribute is present. +
+ {/* @ts-ignore */} +
+
+ + +
+ +
+ {/* @ts-ignore */} + +
+ This collapses horizontally (width transition) instead of vertically. +
+ {/* @ts-ignore */} +
+
+
+
+
+
+
+ ) +} + +export default CollapseDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index ab2119b1..da86d700 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -11,6 +11,7 @@ import ButtonGroupDemo from './ButtonGroupDemo' import CardDemo from './CardDemo' import CarouselDemo from './CarouselDemo' import CloseButtonDemo from './CloseButtonDemo' +import CollapseDemo from './CollapseDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -41,5 +42,6 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'card', category: 'Components', element: }, { key: 'carousel', category: 'Components', element: }, { key: 'close-button', category: 'Components', element: }, + { key: 'collapse', category: 'Components', element: }, { key: 'breadcrumb', category: 'Navigation', element: }, ] diff --git a/web-components/src/Collapse.ts b/web-components/src/Collapse.ts new file mode 100644 index 00000000..5ee0bd40 --- /dev/null +++ b/web-components/src/Collapse.ts @@ -0,0 +1,145 @@ +import { Collapse as BsCollapse } from 'bootstrap' + +const TAG_NAME = 'tc-collapse' + +export class Collapse extends HTMLElement { + + private _bsCollapse: BsCollapse | null = null + private _collapseEl: HTMLElement | null = null + private _initialised = false + private _syncing = false + + static get observedAttributes(): string[] { + return ['open', 'horizontal'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + const slotContent = Array.from(this.childNodes) + this.render() + const inner = this._collapseEl + if (inner) slotContent.forEach(n => inner.appendChild(n)) + this._initialised = true + } + this._initPlugin() + } + + disconnectedCallback(): void { + this._teardown() + } + + attributeChangedCallback(name: string): void { + if (!this.isConnected || !this._initialised || this._syncing) return + if (name === 'horizontal') { + const inner = this._collapseEl + const slotContent = inner ? Array.from(inner.childNodes) : [] + this._teardown() + this.render() + const newInner = this._collapseEl + if (newInner) slotContent.forEach(n => newInner.appendChild(n)) + this._initPlugin() + } else if (name === 'open') { + if (this.open) { + this._bsCollapse?.show() + } else { + this._bsCollapse?.hide() + } + } + } + + get open(): boolean { + return this.hasAttribute('open') + } + set open(v: boolean) { + if (v) this.setAttribute('open', '') + else this.removeAttribute('open') + } + + get horizontal(): boolean { + return this.hasAttribute('horizontal') + } + set horizontal(v: boolean) { + if (v) this.setAttribute('horizontal', '') + else this.removeAttribute('horizontal') + } + + show(): void { + this._bsCollapse?.show() + } + + hide(): void { + this._bsCollapse?.hide() + } + + toggle(): void { + this._bsCollapse?.toggle() + } + + private _onShow = (): void => { + this._syncing = true + this.setAttribute('open', '') + this._syncing = false + this.dispatchEvent(new CustomEvent('tc-show', { bubbles: true, composed: true })) + } + + private _onShown = (): void => { + this.dispatchEvent(new CustomEvent('tc-shown', { bubbles: true, composed: true })) + } + + private _onHide = (): void => { + this._syncing = true + this.removeAttribute('open') + this._syncing = false + this.dispatchEvent(new CustomEvent('tc-hide', { bubbles: true, composed: true })) + } + + private _onHidden = (): void => { + this.dispatchEvent(new CustomEvent('tc-hidden', { bubbles: true, composed: true })) + } + + private render(): void { + const isOpen = this.hasAttribute('open') + const isHorizontal = this.hasAttribute('horizontal') + const classes = ['collapse', isHorizontal ? 'collapse-horizontal' : '', isOpen ? 'show' : ''] + .filter(Boolean).join(' ') + const div = document.createElement('div') + div.className = classes + this.innerHTML = '' + this.appendChild(div) + this._collapseEl = div + } + + private _initPlugin(): void { + const el = this._collapseEl + if (!el) return + this._bsCollapse = new BsCollapse(el, { toggle: false }) + el.addEventListener('show.bs.collapse', this._onShow) + el.addEventListener('shown.bs.collapse', this._onShown) + el.addEventListener('hide.bs.collapse', this._onHide) + el.addEventListener('hidden.bs.collapse', this._onHidden) + } + + private _teardown(): void { + const el = this._collapseEl + if (el) { + el.removeEventListener('show.bs.collapse', this._onShow) + el.removeEventListener('shown.bs.collapse', this._onShown) + el.removeEventListener('hide.bs.collapse', this._onHide) + el.removeEventListener('hidden.bs.collapse', this._onHidden) + } + if (this._bsCollapse) { + this._bsCollapse.dispose() + this._bsCollapse = null + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Collapse + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index ee7cc0c8..13ed8a3c 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -13,3 +13,4 @@ export * from './ButtonGroup' export * from './Card' export * from './Carousel' export * from './CloseButton' +export * from './Collapse' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 0d75ab9b..185118bd 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -12,6 +12,7 @@ import { ButtonGroup } from './ButtonGroup' import { Card } from './Card' import { Carousel } from './Carousel' import { CloseButton } from './CloseButton' +import { Collapse } from './Collapse' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -31,4 +32,5 @@ export function register(): void { customElements.define('tc-card', Card) customElements.define('tc-carousel', Carousel) customElements.define('tc-close-button', CloseButton) + customElements.define('tc-collapse', Collapse) } diff --git a/web-components/style/components/_collapse.scss b/web-components/style/components/_collapse.scss new file mode 100644 index 00000000..2289ec35 --- /dev/null +++ b/web-components/style/components/_collapse.scss @@ -0,0 +1 @@ +// tc-collapse — theme tweaks only; Bootstrap collapse classes handle layout. diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index a10da8ca..472e57f1 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -13,3 +13,4 @@ @forward 'card'; @forward 'carousel'; @forward 'close-button'; +@forward 'collapse'; From 6ef50156b61676af348d5538162ef1775024d3ba Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 19:48:41 +0000 Subject: [PATCH 019/632] 018-tc-dropdown: Implement the tc-dropdown + tc-dropdown-item Web Components --- examples/src/web-components/DropdownDemo.tsx | 170 +++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Dropdown.ts | 177 ++++++++++++++++++ web-components/src/DropdownItem.ts | 98 ++++++++++ web-components/src/index.ts | 2 + web-components/src/register.ts | 4 + .../style/components/_dropdown.scss | 1 + web-components/style/components/_index.scss | 1 + 8 files changed, 455 insertions(+) create mode 100644 examples/src/web-components/DropdownDemo.tsx create mode 100644 web-components/src/Dropdown.ts create mode 100644 web-components/src/DropdownItem.ts create mode 100644 web-components/style/components/_dropdown.scss diff --git a/examples/src/web-components/DropdownDemo.tsx b/examples/src/web-components/DropdownDemo.tsx new file mode 100644 index 00000000..b7ea8044 --- /dev/null +++ b/examples/src/web-components/DropdownDemo.tsx @@ -0,0 +1,170 @@ +import React, { useRef } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const DropdownDemo: React.FC = () => { + const dropdownRef = useRef(null) + + return ( +
+
+
+
+ Web Components} + title="Dropdown" + description="Bootstrap Dropdown plugin wrapper. Use tc-dropdown-item children to build menus; supports split buttons, direction variants, and auto-close behaviour." + /> + +
+ + {/* @ts-ignore */} + + {/* @ts-ignore */} + View profile + {/* @ts-ignore */} + Edit settings + {/* @ts-ignore */} + + {/* @ts-ignore */} + Sign out + {/* @ts-ignore */} + + + + +
+ {['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'].map(v => ( + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Option one + {/* @ts-ignore */} + Option two + {/* @ts-ignore */} + + + ))} +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + Action + {/* @ts-ignore */} + Another action + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Action + {/* @ts-ignore */} + Another action + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + Item + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Item + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Item + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Item + {/* @ts-ignore */} + +
+
+ + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + Closes on any click + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Closes only on inside click + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Closes only on outside click + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Never auto-closes + {/* @ts-ignore */} + +
+
+ + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Current page + {/* @ts-ignore */} + Settings + {/* @ts-ignore */} + + {/* @ts-ignore */} + Unavailable + {/* @ts-ignore */} + + + + +
+ + + +
+ {/* @ts-ignore */} + + {/* @ts-ignore */} + Alpha + {/* @ts-ignore */} + Beta + {/* @ts-ignore */} + Gamma + {/* @ts-ignore */} + +
+
+
+
+
+
+ ) +} + +export default DropdownDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index da86d700..f43e3d8f 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -12,6 +12,7 @@ import CardDemo from './CardDemo' import CarouselDemo from './CarouselDemo' import CloseButtonDemo from './CloseButtonDemo' import CollapseDemo from './CollapseDemo' +import DropdownDemo from './DropdownDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -43,5 +44,6 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'carousel', category: 'Components', element: }, { key: 'close-button', category: 'Components', element: }, { key: 'collapse', category: 'Components', element: }, + { key: 'dropdown', category: 'Components', element: }, { key: 'breadcrumb', category: 'Navigation', element: }, ] diff --git a/web-components/src/Dropdown.ts b/web-components/src/Dropdown.ts new file mode 100644 index 00000000..9f2420aa --- /dev/null +++ b/web-components/src/Dropdown.ts @@ -0,0 +1,177 @@ +import { Dropdown as BsDropdown } from 'bootstrap' + +const TAG_NAME = 'tc-dropdown' + +export type DropdownVariant = 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark' +export type DropdownDirection = 'down' | 'up' | 'start' | 'end' +export type DropdownAutoClose = 'true' | 'inside' | 'outside' | 'false' + +const DIRECTION_CLASS: Record = { + down: 'dropdown', + up: 'dropup', + start: 'dropstart', + end: 'dropend', +} + +export class Dropdown extends HTMLElement { + + private _bsDropdown: BsDropdown | null = null + private _toggleEl: HTMLElement | null = null + private _initialised = false + + static get observedAttributes(): string[] { + return ['label', 'variant', 'split', 'direction', 'auto-close'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + const slotContent = Array.from(this.childNodes) + this.render() + const menu = this.querySelector('.dropdown-menu') + if (menu) slotContent.forEach(n => menu.appendChild(n)) + this._initialised = true + } + this._initPlugin() + } + + disconnectedCallback(): void { + this._teardown() + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + const menu = this.querySelector('.dropdown-menu') + const slotContent = menu ? Array.from(menu.childNodes) : [] + this._teardown() + this.render() + const newMenu = this.querySelector('.dropdown-menu') + if (newMenu) slotContent.forEach(n => newMenu.appendChild(n)) + this._initPlugin() + } + + get label(): string { + return this.getAttribute('label') ?? '' + } + set label(v: string) { + this.setAttribute('label', v) + } + + get variant(): DropdownVariant { + return (this.getAttribute('variant') as DropdownVariant) ?? 'primary' + } + set variant(v: DropdownVariant) { + this.setAttribute('variant', v) + } + + get split(): boolean { + return this.hasAttribute('split') + } + set split(v: boolean) { + if (v) this.setAttribute('split', '') + else this.removeAttribute('split') + } + + get direction(): DropdownDirection { + const v = this.getAttribute('direction') as DropdownDirection + return v in DIRECTION_CLASS ? v : 'down' + } + set direction(v: DropdownDirection) { + this.setAttribute('direction', v) + } + + get autoClose(): DropdownAutoClose { + return (this.getAttribute('auto-close') as DropdownAutoClose) ?? 'true' + } + set autoClose(v: DropdownAutoClose) { + this.setAttribute('auto-close', v) + } + + show(): void { + this._bsDropdown?.show() + } + + hide(): void { + this._bsDropdown?.hide() + } + + toggle(): void { + this._bsDropdown?.toggle() + } + + private _onShow = (): void => { + this.dispatchEvent(new CustomEvent('tc-show', { bubbles: true, composed: true })) + } + + private _onShown = (): void => { + this.dispatchEvent(new CustomEvent('tc-shown', { bubbles: true, composed: true })) + } + + private _onHide = (): void => { + this.dispatchEvent(new CustomEvent('tc-hide', { bubbles: true, composed: true })) + } + + private _onHidden = (): void => { + this.dispatchEvent(new CustomEvent('tc-hidden', { bubbles: true, composed: true })) + } + + private render(): void { + const label = this.getAttribute('label') ?? '' + const variant = this.getAttribute('variant') ?? 'primary' + const isSplit = this.hasAttribute('split') + const direction = (this.getAttribute('direction') as DropdownDirection) || 'down' + const dirClass = DIRECTION_CLASS[direction] ?? 'dropdown' + const autoClose = this.getAttribute('auto-close') ?? 'true' + + const outerClass = isSplit ? `btn-group ${dirClass}` : dirClass + + let toggleHtml: string + if (isSplit) { + toggleHtml = `` + + `` + } else { + toggleHtml = `` + } + + this.innerHTML = `
${toggleHtml}
` + } + + private _initPlugin(): void { + const toggle = this.querySelector('[data-bs-toggle="dropdown"]') + if (!toggle) return + this._toggleEl = toggle + this._bsDropdown = new BsDropdown(toggle) + toggle.addEventListener('show.bs.dropdown', this._onShow) + toggle.addEventListener('shown.bs.dropdown', this._onShown) + toggle.addEventListener('hide.bs.dropdown', this._onHide) + toggle.addEventListener('hidden.bs.dropdown', this._onHidden) + } + + private _teardown(): void { + const toggle = this._toggleEl + if (toggle) { + toggle.removeEventListener('show.bs.dropdown', this._onShow) + toggle.removeEventListener('shown.bs.dropdown', this._onShown) + toggle.removeEventListener('hide.bs.dropdown', this._onHide) + toggle.removeEventListener('hidden.bs.dropdown', this._onHidden) + } + if (this._bsDropdown) { + this._bsDropdown.dispose() + this._bsDropdown = null + } + this._toggleEl = null + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Dropdown + } +} diff --git a/web-components/src/DropdownItem.ts b/web-components/src/DropdownItem.ts new file mode 100644 index 00000000..1a44297e --- /dev/null +++ b/web-components/src/DropdownItem.ts @@ -0,0 +1,98 @@ +const TAG_NAME = 'tc-dropdown-item' + +export class DropdownItem extends HTMLElement { + + private _initialised = false + + static get observedAttributes(): string[] { + return ['href', 'active', 'disabled', 'divider'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + const slotContent = Array.from(this.childNodes) + this.render() + if (!this.hasAttribute('divider')) { + const inner = this.querySelector('a, button') + if (inner) slotContent.forEach(n => inner.appendChild(n)) + } + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + const inner = this.querySelector('a, button') + const slotContent = inner ? Array.from(inner.childNodes) : [] + this.render() + if (!this.hasAttribute('divider')) { + const newInner = this.querySelector('a, button') + if (newInner) slotContent.forEach(n => newInner.appendChild(n)) + } + } + + get href(): string | null { + return this.getAttribute('href') + } + set href(v: string | null) { + if (v != null) this.setAttribute('href', v) + else this.removeAttribute('href') + } + + get active(): boolean { + return this.hasAttribute('active') + } + set active(v: boolean) { + if (v) this.setAttribute('active', '') + else this.removeAttribute('active') + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + get divider(): boolean { + return this.hasAttribute('divider') + } + set divider(v: boolean) { + if (v) this.setAttribute('divider', '') + else this.removeAttribute('divider') + } + + private render(): void { + if (this.hasAttribute('divider')) { + this.innerHTML = `
  • ` + return + } + + const href = this.getAttribute('href') + const isActive = this.hasAttribute('active') + const isDisabled = this.hasAttribute('disabled') + const activeClass = isActive ? ' active' : '' + const disabledClass = isDisabled ? ' disabled' : '' + const classes = `dropdown-item${activeClass}${disabledClass}` + + if (href != null) { + const ariaDisabled = isDisabled ? ' aria-disabled="true" tabindex="-1"' : '' + const ariaCurrent = isActive ? ' aria-current="true"' : '' + this.innerHTML = `
  • ` + } else { + const disabledAttr = isDisabled ? ' disabled' : '' + this.innerHTML = `
  • ` + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: DropdownItem + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 13ed8a3c..2b30a68b 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -14,3 +14,5 @@ export * from './Card' export * from './Carousel' export * from './CloseButton' export * from './Collapse' +export * from './Dropdown' +export * from './DropdownItem' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 185118bd..14ca93b5 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -13,6 +13,8 @@ import { Card } from './Card' import { Carousel } from './Carousel' import { CloseButton } from './CloseButton' import { Collapse } from './Collapse' +import { Dropdown } from './Dropdown' +import { DropdownItem } from './DropdownItem' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -33,4 +35,6 @@ export function register(): void { customElements.define('tc-carousel', Carousel) customElements.define('tc-close-button', CloseButton) customElements.define('tc-collapse', Collapse) + customElements.define('tc-dropdown', Dropdown) + customElements.define('tc-dropdown-item', DropdownItem) } diff --git a/web-components/style/components/_dropdown.scss b/web-components/style/components/_dropdown.scss new file mode 100644 index 00000000..80767994 --- /dev/null +++ b/web-components/style/components/_dropdown.scss @@ -0,0 +1 @@ +// tc-dropdown — theme tweaks only; Bootstrap dropdown classes handle layout. diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 472e57f1..373a2157 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -14,3 +14,4 @@ @forward 'carousel'; @forward 'close-button'; @forward 'collapse'; +@forward 'dropdown'; From 56df00ed19730dbb9168c20e371199131774a97f Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 19:54:56 +0000 Subject: [PATCH 020/632] 019-tc-list-group: Implement the tc-list-group + tc-list-group-item Web Components --- examples/src/web-components/ListGroupDemo.tsx | 159 ++++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/ListGroup.ts | 89 ++++++++++ web-components/src/ListGroupItem.ts | 123 ++++++++++++++ web-components/src/index.ts | 2 + web-components/src/register.ts | 4 + web-components/style/components/_index.scss | 1 + .../style/components/_list-group.scss | 22 +++ 8 files changed, 402 insertions(+) create mode 100644 examples/src/web-components/ListGroupDemo.tsx create mode 100644 web-components/src/ListGroup.ts create mode 100644 web-components/src/ListGroupItem.ts create mode 100644 web-components/style/components/_list-group.scss diff --git a/examples/src/web-components/ListGroupDemo.tsx b/examples/src/web-components/ListGroupDemo.tsx new file mode 100644 index 00000000..a76da5be --- /dev/null +++ b/examples/src/web-components/ListGroupDemo.tsx @@ -0,0 +1,159 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const ListGroupDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="List Group" + description="Flexible component for displaying a series of content. Use tc-list-group-item elements inside tc-list-group. Add flush for borderless style, numbered for an ordered list, and horizontal for inline layout." + /> + +
    + + {/* @ts-ignore */} + + {/* @ts-ignore */} + An item + {/* @ts-ignore */} + A second item + {/* @ts-ignore */} + A third item + {/* @ts-ignore */} + A fourth item + {/* @ts-ignore */} + And a fifth one + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + An active item + {/* @ts-ignore */} + A second item + {/* @ts-ignore */} + A disabled item + {/* @ts-ignore */} + A fourth item + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + An active link item + {/* @ts-ignore */} + A second link item + {/* @ts-ignore */} + A disabled link item + {/* @ts-ignore */} + A fourth link item + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + The current button + {/* @ts-ignore */} + A second button item + {/* @ts-ignore */} + A disabled button item + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Default item + {/* @ts-ignore */} + A primary item + {/* @ts-ignore */} + A secondary item + {/* @ts-ignore */} + A success item + {/* @ts-ignore */} + A danger item + {/* @ts-ignore */} + A warning item + {/* @ts-ignore */} + An info item + {/* @ts-ignore */} + A light item + {/* @ts-ignore */} + A dark item + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + An item + {/* @ts-ignore */} + A second item + {/* @ts-ignore */} + A third item + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + A list item + {/* @ts-ignore */} + A list item + {/* @ts-ignore */} + A list item + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + An item + {/* @ts-ignore */} + A second item + {/* @ts-ignore */} + A third item + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + An item + {/* @ts-ignore */} + A second item + {/* @ts-ignore */} + A third item + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    +) + +export default ListGroupDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index f43e3d8f..f6372b89 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -13,6 +13,7 @@ import CarouselDemo from './CarouselDemo' import CloseButtonDemo from './CloseButtonDemo' import CollapseDemo from './CollapseDemo' import DropdownDemo from './DropdownDemo' +import ListGroupDemo from './ListGroupDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -45,5 +46,6 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'close-button', category: 'Components', element: }, { key: 'collapse', category: 'Components', element: }, { key: 'dropdown', category: 'Components', element: }, + { key: 'list-group', category: 'Components', element: }, { key: 'breadcrumb', category: 'Navigation', element: }, ] diff --git a/web-components/src/ListGroup.ts b/web-components/src/ListGroup.ts new file mode 100644 index 00000000..95c09998 --- /dev/null +++ b/web-components/src/ListGroup.ts @@ -0,0 +1,89 @@ +const TAG_NAME = 'tc-list-group' + +const BREAKPOINTS = ['sm', 'md', 'lg', 'xl', 'xxl'] + +export class ListGroup extends HTMLElement { + + private _initialised = false + + static get observedAttributes(): string[] { + return ['flush', 'numbered', 'horizontal'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + const slotContent = Array.from(this.childNodes) + this.render() + const list = this._listEl() + if (list) slotContent.forEach(n => list.appendChild(n)) + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + const list = this._listEl() + const slotContent = list ? Array.from(list.childNodes) : [] + this.render() + const newList = this._listEl() + if (newList) slotContent.forEach(n => newList.appendChild(n)) + } + + get flush(): boolean { + return this.hasAttribute('flush') + } + set flush(v: boolean) { + if (v) this.setAttribute('flush', '') + else this.removeAttribute('flush') + } + + get numbered(): boolean { + return this.hasAttribute('numbered') + } + set numbered(v: boolean) { + if (v) this.setAttribute('numbered', '') + else this.removeAttribute('numbered') + } + + get horizontal(): string | null { + return this.getAttribute('horizontal') + } + set horizontal(v: string | null) { + if (v != null) this.setAttribute('horizontal', v) + else this.removeAttribute('horizontal') + } + + private _listEl(): Element | null { + return this.querySelector('ul.list-group, ol.list-group') + } + + private render(): void { + const numbered = this.hasAttribute('numbered') + const flush = this.hasAttribute('flush') + const horizontal = this.getAttribute('horizontal') + + const classes = ['list-group'] + if (flush) classes.push('list-group-flush') + if (numbered) classes.push('list-group-numbered') + if (horizontal !== null) { + if (horizontal && BREAKPOINTS.includes(horizontal)) { + classes.push(`list-group-horizontal-${horizontal}`) + } else { + classes.push('list-group-horizontal') + } + } + + const tag = numbered ? 'ol' : 'ul' + this.innerHTML = `<${tag} class="${classes.join(' ')}">` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: ListGroup + } +} diff --git a/web-components/src/ListGroupItem.ts b/web-components/src/ListGroupItem.ts new file mode 100644 index 00000000..76f54fb0 --- /dev/null +++ b/web-components/src/ListGroupItem.ts @@ -0,0 +1,123 @@ +const TAG_NAME = 'tc-list-group-item' + +export type ListGroupItemVariant = 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark' + +const VARIANTS: ListGroupItemVariant[] = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'] + +export class ListGroupItem extends HTMLElement { + + private _initialised = false + + static get observedAttributes(): string[] { + return ['active', 'disabled', 'variant', 'action', 'href'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + const slotContent = Array.from(this.childNodes) + this.render() + const inner = this.querySelector('.tc-lgi-content') + if (inner) slotContent.forEach(n => inner.appendChild(n)) + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + const inner = this.querySelector('.tc-lgi-content') + const slotContent = inner ? Array.from(inner.childNodes) : [] + this.render() + const newInner = this.querySelector('.tc-lgi-content') + if (newInner) slotContent.forEach(n => newInner.appendChild(n)) + } + + get active(): boolean { + return this.hasAttribute('active') + } + set active(v: boolean) { + if (v) this.setAttribute('active', '') + else this.removeAttribute('active') + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + get variant(): ListGroupItemVariant | null { + const v = this.getAttribute('variant') as ListGroupItemVariant + return VARIANTS.includes(v) ? v : null + } + set variant(v: ListGroupItemVariant | null) { + if (v != null) this.setAttribute('variant', v) + else this.removeAttribute('variant') + } + + get action(): boolean { + return this.hasAttribute('action') + } + set action(v: boolean) { + if (v) this.setAttribute('action', '') + else this.removeAttribute('action') + } + + get href(): string | null { + return this.getAttribute('href') + } + set href(v: string | null) { + if (v != null) this.setAttribute('href', v) + else this.removeAttribute('href') + } + + private render(): void { + const active = this.hasAttribute('active') + const disabled = this.hasAttribute('disabled') + const variant = this.getAttribute('variant') as ListGroupItemVariant + const action = this.hasAttribute('action') + const href = this.getAttribute('href') + const isInteractive = href != null || action + + const classes = ['list-group-item'] + if (isInteractive) classes.push('list-group-item-action') + if (variant && VARIANTS.includes(variant)) classes.push(`list-group-item-${variant}`) + if (active) classes.push('active') + if (disabled) classes.push('disabled') + + this.className = classes.join(' ') + + if (active) { + this.setAttribute('aria-current', 'true') + } else { + this.removeAttribute('aria-current') + } + + if (disabled) { + this.setAttribute('aria-disabled', 'true') + } else { + this.removeAttribute('aria-disabled') + } + + if (href != null) { + const disabledAttr = disabled ? ' tabindex="-1"' : '' + this.innerHTML = `` + } else if (action) { + const disabledAttr = disabled ? ' disabled' : '' + this.innerHTML = `` + } else { + this.innerHTML = `` + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: ListGroupItem + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 2b30a68b..2ac40f73 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -16,3 +16,5 @@ export * from './CloseButton' export * from './Collapse' export * from './Dropdown' export * from './DropdownItem' +export * from './ListGroup' +export * from './ListGroupItem' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 14ca93b5..d8dd8863 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -15,6 +15,8 @@ import { CloseButton } from './CloseButton' import { Collapse } from './Collapse' import { Dropdown } from './Dropdown' import { DropdownItem } from './DropdownItem' +import { ListGroup } from './ListGroup' +import { ListGroupItem } from './ListGroupItem' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -37,4 +39,6 @@ export function register(): void { customElements.define('tc-collapse', Collapse) customElements.define('tc-dropdown', Dropdown) customElements.define('tc-dropdown-item', DropdownItem) + customElements.define('tc-list-group', ListGroup) + customElements.define('tc-list-group-item', ListGroupItem) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 373a2157..4737846d 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -15,3 +15,4 @@ @forward 'close-button'; @forward 'collapse'; @forward 'dropdown'; +@forward 'list-group'; diff --git a/web-components/style/components/_list-group.scss b/web-components/style/components/_list-group.scss new file mode 100644 index 00000000..dbc86fd6 --- /dev/null +++ b/web-components/style/components/_list-group.scss @@ -0,0 +1,22 @@ +// tc-list-group / tc-list-group-item — theme tweaks only. +// Bootstrap list-group classes handle layout; we only need to reset inner +// and + {/* @ts-ignore */} + +

    This is the modal body. Add any content here as children of tc-modal.

    + {/* @ts-ignore */} +
    + + + + + {/* @ts-ignore */} + +

    This modal is vertically centred on the screen.

    + {/* @ts-ignore */} +
    +
    + + + + {/* @ts-ignore */} + + {Array.from({ length: 20 }, (_, i) => ( +

    Paragraph {i + 1} — scroll down to see more content inside the modal body.

    + ))} + {/* @ts-ignore */} +
    +
    + + + + {/* @ts-ignore */} + +

    Clicking outside this modal will not close it. Use the Close button.

    + {/* @ts-ignore */} +
    +
    + + + + {/* @ts-ignore */} + +

    This modal uses size="lg" for a wider dialog. Also available: sm and xl.

    + {/* @ts-ignore */} +
    +
    + + + + {/* @ts-ignore */} + +

    Are you sure you want to proceed? This action cannot be undone.

    + + + {/* @ts-ignore */} +
    +
    + + + + + + ) +} + +export default ModalDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index f6372b89..06396eb5 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -14,6 +14,7 @@ import CloseButtonDemo from './CloseButtonDemo' import CollapseDemo from './CollapseDemo' import DropdownDemo from './DropdownDemo' import ListGroupDemo from './ListGroupDemo' +import ModalDemo from './ModalDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -48,4 +49,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'dropdown', category: 'Components', element: }, { key: 'list-group', category: 'Components', element: }, { key: 'breadcrumb', category: 'Navigation', element: }, + { key: 'modal', category: 'Overlays & Feedback', element: }, ] diff --git a/web-components/src/Modal.ts b/web-components/src/Modal.ts new file mode 100644 index 00000000..0ac32f33 --- /dev/null +++ b/web-components/src/Modal.ts @@ -0,0 +1,225 @@ +import { Modal as BsModal } from 'bootstrap' + +const TAG_NAME = 'tc-modal' + +export class Modal extends HTMLElement { + + private _bsModal: BsModal | null = null + private _bodyNodes: Node[] = [] + private _footerNodes: Node[] = [] + private _initialised = false + private _syncing = false + + static get observedAttributes(): string[] { + return ['open', 'title', 'size', 'centered', 'scrollable', 'static-backdrop', 'fullscreen'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + this._bodyNodes = Array.from(this.childNodes).filter( + n => !(n instanceof Element && n.getAttribute('slot') === 'footer'), + ) + this._footerNodes = Array.from(this.childNodes).filter( + n => n instanceof Element && n.getAttribute('slot') === 'footer', + ) + this._initialised = true + } + this.render() + this._initPlugin() + if (this.open) this._bsModal?.show() + } + + disconnectedCallback(): void { + this._teardown() + } + + attributeChangedCallback(name: string): void { + if (!this.isConnected || !this._initialised || this._syncing) return + if (name === 'open') { + if (this.open) { + this._bsModal?.show() + } else { + this._bsModal?.hide() + } + } else { + this._teardown() + this.render() + this._initPlugin() + } + } + + get open(): boolean { + return this.hasAttribute('open') + } + set open(v: boolean) { + if (v) this.setAttribute('open', '') + else this.removeAttribute('open') + } + + get title(): string { + return this.getAttribute('title') ?? '' + } + set title(v: string) { + this.setAttribute('title', v) + } + + get size(): string { + return this.getAttribute('size') ?? '' + } + set size(v: string) { + this.setAttribute('size', v) + } + + get centered(): boolean { + return this.hasAttribute('centered') + } + set centered(v: boolean) { + if (v) this.setAttribute('centered', '') + else this.removeAttribute('centered') + } + + get scrollable(): boolean { + return this.hasAttribute('scrollable') + } + set scrollable(v: boolean) { + if (v) this.setAttribute('scrollable', '') + else this.removeAttribute('scrollable') + } + + get staticBackdrop(): boolean { + return this.hasAttribute('static-backdrop') + } + set staticBackdrop(v: boolean) { + if (v) this.setAttribute('static-backdrop', '') + else this.removeAttribute('static-backdrop') + } + + get fullscreen(): string { + return this.getAttribute('fullscreen') ?? '' + } + set fullscreen(v: string) { + this.setAttribute('fullscreen', v) + } + + show(): void { + this._bsModal?.show() + } + + hide(): void { + this._bsModal?.hide() + } + + toggle(): void { + this._bsModal?.toggle() + } + + private _onShow = (): void => { + this._syncing = true + this.setAttribute('open', '') + this._syncing = false + this.dispatchEvent(new CustomEvent('tc-show', { bubbles: true, composed: true })) + } + + private _onShown = (): void => { + this.dispatchEvent(new CustomEvent('tc-shown', { bubbles: true, composed: true })) + } + + private _onHide = (): void => { + this._syncing = true + this.removeAttribute('open') + this._syncing = false + this.dispatchEvent(new CustomEvent('tc-hide', { bubbles: true, composed: true })) + } + + private _onHidden = (): void => { + this.dispatchEvent(new CustomEvent('tc-hidden', { bubbles: true, composed: true })) + } + + private render(): void { + const dialogClasses = ['modal-dialog'] + + if (this.centered) dialogClasses.push('modal-dialog-centered') + if (this.scrollable) dialogClasses.push('modal-dialog-scrollable') + + const size = this.getAttribute('size') + if (size === 'sm' || size === 'lg' || size === 'xl') { + dialogClasses.push(`modal-${size}`) + } + + const fs = this.getAttribute('fullscreen') + if (fs === 'true' || fs === '') { + dialogClasses.push('modal-fullscreen') + } else if (fs === 'sm' || fs === 'md' || fs === 'lg' || fs === 'xl' || fs === 'xxl') { + dialogClasses.push(`modal-fullscreen-${fs}-down`) + } + + this.className = 'modal fade' + this.setAttribute('tabindex', '-1') + if (this.staticBackdrop) { + this.setAttribute('data-bs-backdrop', 'static') + } else { + this.removeAttribute('data-bs-backdrop') + } + + const hasFooter = this._footerNodes.length > 0 + const footerHtml = hasFooter ? '' : '' + const titleText = this._escapeHtml(this.getAttribute('title') ?? '') + + this.innerHTML = `
    ` + + `` + + `
    ` + + const body = this.querySelector('.modal-body') + if (body) this._bodyNodes.forEach(n => body.appendChild(n)) + + if (hasFooter) { + const footer = this.querySelector('.modal-footer') + if (footer) this._footerNodes.forEach(n => footer.appendChild(n)) + } + } + + private _escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + } + + private _initPlugin(): void { + const backdrop: boolean | 'static' = this.staticBackdrop ? 'static' : true + this._bsModal = new BsModal(this, { backdrop }) + this.addEventListener('show.bs.modal', this._onShow) + this.addEventListener('shown.bs.modal', this._onShown) + this.addEventListener('hide.bs.modal', this._onHide) + this.addEventListener('hidden.bs.modal', this._onHidden) + } + + private _teardown(): void { + this.removeEventListener('show.bs.modal', this._onShow) + this.removeEventListener('shown.bs.modal', this._onShown) + this.removeEventListener('hide.bs.modal', this._onHide) + this.removeEventListener('hidden.bs.modal', this._onHidden) + if (this._bsModal) { + this._bsModal.dispose() + this._bsModal = null + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Modal + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 2ac40f73..cc67510b 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -18,3 +18,4 @@ export * from './Dropdown' export * from './DropdownItem' export * from './ListGroup' export * from './ListGroupItem' +export * from './Modal' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index d8dd8863..d2ab804c 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -17,6 +17,7 @@ import { Dropdown } from './Dropdown' import { DropdownItem } from './DropdownItem' import { ListGroup } from './ListGroup' import { ListGroupItem } from './ListGroupItem' +import { Modal } from './Modal' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -41,4 +42,5 @@ export function register(): void { customElements.define('tc-dropdown-item', DropdownItem) customElements.define('tc-list-group', ListGroup) customElements.define('tc-list-group-item', ListGroupItem) + customElements.define('tc-modal', Modal) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 4737846d..b2427434 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -16,3 +16,4 @@ @forward 'collapse'; @forward 'dropdown'; @forward 'list-group'; +@forward 'modal'; diff --git a/web-components/style/components/_modal.scss b/web-components/style/components/_modal.scss new file mode 100644 index 00000000..91c1f363 --- /dev/null +++ b/web-components/style/components/_modal.scss @@ -0,0 +1 @@ +// tc-modal — theme tweaks only; Bootstrap modal classes handle layout. From 1ce528f4d8c772fbe45aae29557e5d6ffb3c030d Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 20:08:30 +0000 Subject: [PATCH 022/632] 021-tc-nav: Implement the tc-nav + tc-nav-item Web Components --- examples/src/web-components/NavDemo.tsx | 131 ++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Nav.ts | 95 ++++++++++++ web-components/src/NavItem.ts | 162 ++++++++++++++++++++ web-components/src/index.ts | 2 + web-components/src/register.ts | 4 + web-components/style/components/_index.scss | 1 + web-components/style/components/_nav.scss | 1 + 8 files changed, 398 insertions(+) create mode 100644 examples/src/web-components/NavDemo.tsx create mode 100644 web-components/src/Nav.ts create mode 100644 web-components/src/NavItem.ts create mode 100644 web-components/style/components/_nav.scss diff --git a/examples/src/web-components/NavDemo.tsx b/examples/src/web-components/NavDemo.tsx new file mode 100644 index 00000000..2a2fbc4e --- /dev/null +++ b/examples/src/web-components/NavDemo.tsx @@ -0,0 +1,131 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const NavDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="Nav" + description="Bootstrap nav strip wrapper. Use variant to choose tabs, pills, or underline. Items wire Bootstrap's Tab plugin automatically when the parent variant is tabs or pills, enabling switchable content panes." + /> + +
    + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Home + {/* @ts-ignore */} + Profile + {/* @ts-ignore */} + Contact + {/* @ts-ignore */} + Disabled + {/* @ts-ignore */} + +
    +
    +

    Home tab content. This pane is shown first because the item has the active attribute.

    +
    +
    +

    Profile tab content. Click the Profile tab above to show this pane.

    +
    +
    +

    Contact tab content. Click the Contact tab above to show this pane.

    +
    +
    +

    Disabled tab — this pane is unreachable via the nav.

    +
    +
    +
    + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Overview + {/* @ts-ignore */} + Settings + {/* @ts-ignore */} + Activity + {/* @ts-ignore */} + +
    +
    +

    Overview content. Pills use the same Bootstrap Tab plugin as tabs.

    +
    +
    +

    Settings content. Click the Settings pill to navigate here.

    +
    +
    +

    Activity content. Click the Activity pill to navigate here.

    +
    +
    +
    + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Active + {/* @ts-ignore */} + Link + {/* @ts-ignore */} + Another + {/* @ts-ignore */} + Disabled + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Active + {/* @ts-ignore */} + Much longer label + {/* @ts-ignore */} + Short + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Active + {/* @ts-ignore */} + Link + {/* @ts-ignore */} + Another + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Dashboard + {/* @ts-ignore */} + Profile + {/* @ts-ignore */} + Settings + {/* @ts-ignore */} + Billing + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    +) + +export default NavDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 06396eb5..6b9d4a8b 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -15,6 +15,7 @@ import CollapseDemo from './CollapseDemo' import DropdownDemo from './DropdownDemo' import ListGroupDemo from './ListGroupDemo' import ModalDemo from './ModalDemo' +import NavDemo from './NavDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -49,5 +50,6 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'dropdown', category: 'Components', element: }, { key: 'list-group', category: 'Components', element: }, { key: 'breadcrumb', category: 'Navigation', element: }, + { key: 'nav', category: 'Navigation', element: }, { key: 'modal', category: 'Overlays & Feedback', element: }, ] diff --git a/web-components/src/Nav.ts b/web-components/src/Nav.ts new file mode 100644 index 00000000..2bcb5198 --- /dev/null +++ b/web-components/src/Nav.ts @@ -0,0 +1,95 @@ +const TAG_NAME = 'tc-nav' + +export class Nav extends HTMLElement { + + private _ul: HTMLUListElement | null = null + private _initialised = false + + static get observedAttributes(): string[] { + return ['variant', 'fill', 'justified', 'vertical'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + const slotContent = Array.from(this.childNodes) + this.render() + const ul = this._ul + if (ul) slotContent.forEach(n => ul.appendChild(n)) + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + const ul = this._ul + const slotContent = ul ? Array.from(ul.childNodes) : [] + this.render() + const newUl = this._ul + if (newUl) slotContent.forEach(n => newUl.appendChild(n)) + this._syncNavItems() + } + + get variant(): string { + return this.getAttribute('variant') ?? '' + } + set variant(v: string) { + this.setAttribute('variant', v) + } + + get fill(): boolean { + return this.hasAttribute('fill') + } + set fill(v: boolean) { + if (v) this.setAttribute('fill', '') + else this.removeAttribute('fill') + } + + get justified(): boolean { + return this.hasAttribute('justified') + } + set justified(v: boolean) { + if (v) this.setAttribute('justified', '') + else this.removeAttribute('justified') + } + + get vertical(): boolean { + return this.hasAttribute('vertical') + } + set vertical(v: boolean) { + if (v) this.setAttribute('vertical', '') + else this.removeAttribute('vertical') + } + + private _syncNavItems(): void { + this._ul?.querySelectorAll('tc-nav-item').forEach((item: any) => { + item._parentVariantChanged?.() + }) + } + + private render(): void { + const variant = this.getAttribute('variant') + const classes = ['nav'] + if (variant === 'tabs') classes.push('nav-tabs') + else if (variant === 'pills') classes.push('nav-pills') + else if (variant === 'underline') classes.push('nav-underline') + if (this.hasAttribute('fill')) classes.push('nav-fill') + if (this.hasAttribute('justified')) classes.push('nav-justified') + if (this.hasAttribute('vertical')) classes.push('flex-column') + + const ul = document.createElement('ul') + ul.className = classes.join(' ') + this.innerHTML = '' + this.appendChild(ul) + this._ul = ul + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Nav + } +} diff --git a/web-components/src/NavItem.ts b/web-components/src/NavItem.ts new file mode 100644 index 00000000..559d2656 --- /dev/null +++ b/web-components/src/NavItem.ts @@ -0,0 +1,162 @@ +import { Tab as BsTab } from 'bootstrap' + +const TAG_NAME = 'tc-nav-item' + +export class NavItem extends HTMLElement { + + private _bsTab: BsTab | null = null + private _anchorEl: HTMLAnchorElement | null = null + private _slotContent: Node[] = [] + private _initialised = false + + static get observedAttributes(): string[] { + return ['href', 'target', 'active', 'disabled'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + this._slotContent = Array.from(this.childNodes) + this._initialised = true + } else { + const existing = this.querySelector('a.nav-link') + if (existing) this._slotContent = Array.from(existing.childNodes) + } + this.render() + this._initPlugin() + } + + disconnectedCallback(): void { + this._teardown() + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + const existing = this.querySelector('a.nav-link') + if (existing) this._slotContent = Array.from(existing.childNodes) + this._teardown() + this.render() + this._initPlugin() + } + + _parentVariantChanged(): void { + if (!this.isConnected || !this._initialised) return + const existing = this.querySelector('a.nav-link') + if (existing) this._slotContent = Array.from(existing.childNodes) + this._teardown() + this.render() + this._initPlugin() + } + + get href(): string | null { + return this.getAttribute('href') + } + set href(v: string | null) { + if (v != null) this.setAttribute('href', v) + else this.removeAttribute('href') + } + + get target(): string | null { + return this.getAttribute('target') + } + set target(v: string | null) { + if (v != null) this.setAttribute('target', v) + else this.removeAttribute('target') + } + + get active(): boolean { + return this.hasAttribute('active') + } + set active(v: boolean) { + if (v) this.setAttribute('active', '') + else this.removeAttribute('active') + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + private _getToggleType(): 'tab' | 'pill' | null { + const parentNav = this.closest('tc-nav') + if (!parentNav) return null + const variant = parentNav.getAttribute('variant') + if (variant === 'tabs') return 'tab' + if (variant === 'pills') return 'pill' + return null + } + + private _onShow = (): void => { + this.dispatchEvent(new CustomEvent('tc-show', { bubbles: true, composed: true })) + } + + private _onShown = (): void => { + this.dispatchEvent(new CustomEvent('tc-shown', { bubbles: true, composed: true })) + } + + private render(): void { + const active = this.hasAttribute('active') + const disabled = this.hasAttribute('disabled') + const href = this.getAttribute('href') ?? '#' + const target = this.getAttribute('target') + const toggleType = this._getToggleType() + + this.classList.add('nav-item') + + const linkClasses = ['nav-link'] + if (active) linkClasses.push('active') + if (disabled) linkClasses.push('disabled') + + const a = document.createElement('a') + a.className = linkClasses.join(' ') + a.setAttribute('href', href) + if (target) a.target = target + if (toggleType) { + a.setAttribute('data-bs-toggle', toggleType) + if (active) a.setAttribute('aria-selected', 'true') + } else if (active) { + a.setAttribute('aria-current', 'page') + } + if (disabled) a.setAttribute('aria-disabled', 'true') + + this.innerHTML = '' + this.appendChild(a) + this._slotContent.forEach(n => a.appendChild(n)) + this._anchorEl = a + } + + private _initPlugin(): void { + const a = this._anchorEl + if (!a) return + const toggle = a.getAttribute('data-bs-toggle') + if (toggle !== 'tab' && toggle !== 'pill') return + this._bsTab = new BsTab(a) + a.addEventListener('show.bs.tab', this._onShow) + a.addEventListener('shown.bs.tab', this._onShown) + } + + private _teardown(): void { + const a = this._anchorEl + if (a) { + a.removeEventListener('show.bs.tab', this._onShow) + a.removeEventListener('shown.bs.tab', this._onShown) + } + if (this._bsTab) { + this._bsTab.dispose() + this._bsTab = null + } + this._anchorEl = null + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: NavItem + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index cc67510b..01d5565b 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -19,3 +19,5 @@ export * from './DropdownItem' export * from './ListGroup' export * from './ListGroupItem' export * from './Modal' +export * from './Nav' +export * from './NavItem' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index d2ab804c..96d75174 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -18,6 +18,8 @@ import { DropdownItem } from './DropdownItem' import { ListGroup } from './ListGroup' import { ListGroupItem } from './ListGroupItem' import { Modal } from './Modal' +import { Nav } from './Nav' +import { NavItem } from './NavItem' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -43,4 +45,6 @@ export function register(): void { customElements.define('tc-list-group', ListGroup) customElements.define('tc-list-group-item', ListGroupItem) customElements.define('tc-modal', Modal) + customElements.define('tc-nav', Nav) + customElements.define('tc-nav-item', NavItem) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index b2427434..d8246050 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -17,3 +17,4 @@ @forward 'dropdown'; @forward 'list-group'; @forward 'modal'; +@forward 'nav'; diff --git a/web-components/style/components/_nav.scss b/web-components/style/components/_nav.scss new file mode 100644 index 00000000..ea256ea0 --- /dev/null +++ b/web-components/style/components/_nav.scss @@ -0,0 +1 @@ +// tc-nav / tc-nav-item — theme tweaks only; Bootstrap nav classes handle layout. From 5233fa873f024832485c7032670fbb6bd3b74cbd Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 20:13:30 +0000 Subject: [PATCH 023/632] 022-tc-navbar: Implement the tc-navbar Web Component --- examples/src/web-components/NavbarDemo.tsx | 105 ++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Navbar.ts | 159 +++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + web-components/style/components/_navbar.scss | 1 + 7 files changed, 271 insertions(+) create mode 100644 examples/src/web-components/NavbarDemo.tsx create mode 100644 web-components/src/Navbar.ts create mode 100644 web-components/style/components/_navbar.scss diff --git a/examples/src/web-components/NavbarDemo.tsx b/examples/src/web-components/NavbarDemo.tsx new file mode 100644 index 00000000..cc4c96a7 --- /dev/null +++ b/examples/src/web-components/NavbarDemo.tsx @@ -0,0 +1,105 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const NavbarDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="Navbar" + description="Bootstrap responsive navbar wrapper. Use brand for the brand text, expand for the collapse breakpoint, variant for light/dark theming, and bg for background colour. Place nav links directly as children — they are projected into the collapsible region." + /> + +
    + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
    + +
    + {/* @ts-ignore */} +
    +
    + + +
    + {/* @ts-ignore */} + + + {/* @ts-ignore */} + +
    +

    Scroll this region to see the navbar stay at the top.

    +

    More content below…

    +

    Even more content…

    +

    Keeps going…

    +
    +
    +
    +
    +
    +
    +
    +
    +) + +export default NavbarDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 6b9d4a8b..ed34d724 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -16,6 +16,7 @@ import DropdownDemo from './DropdownDemo' import ListGroupDemo from './ListGroupDemo' import ModalDemo from './ModalDemo' import NavDemo from './NavDemo' +import NavbarDemo from './NavbarDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -51,5 +52,6 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'list-group', category: 'Components', element: }, { key: 'breadcrumb', category: 'Navigation', element: }, { key: 'nav', category: 'Navigation', element: }, + { key: 'navbar', category: 'Navigation', element: }, { key: 'modal', category: 'Overlays & Feedback', element: }, ] diff --git a/web-components/src/Navbar.ts b/web-components/src/Navbar.ts new file mode 100644 index 00000000..7d9730c3 --- /dev/null +++ b/web-components/src/Navbar.ts @@ -0,0 +1,159 @@ +import { Collapse as BsCollapse } from 'bootstrap' + +const TAG_NAME = 'tc-navbar' + +let _uid = 0 + +export class Navbar extends HTMLElement { + + private _bsCollapse: BsCollapse | null = null + private _collapseEl: HTMLElement | null = null + private _navContent: Node[] = [] + private _initialised = false + private _collapseId: string + + static get observedAttributes(): string[] { + return ['brand', 'expand', 'variant', 'bg', 'fixed', 'sticky'] + } + + constructor() { + super() + this._collapseId = `tc-navbar-collapse-${++_uid}` + } + + connectedCallback(): void { + if (!this._initialised) { + this._navContent = Array.from(this.childNodes) + this._initialised = true + } + this.render() + this._initPlugin() + } + + disconnectedCallback(): void { + this._teardown() + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this._teardown() + this.render() + this._initPlugin() + } + + get brand(): string { + return this.getAttribute('brand') ?? '' + } + set brand(v: string) { + this.setAttribute('brand', v) + } + + get expand(): string { + return this.getAttribute('expand') ?? 'lg' + } + set expand(v: string) { + this.setAttribute('expand', v) + } + + get variant(): string { + return this.getAttribute('variant') ?? '' + } + set variant(v: string) { + if (v) this.setAttribute('variant', v) + else this.removeAttribute('variant') + } + + get bg(): string { + return this.getAttribute('bg') ?? '' + } + set bg(v: string) { + if (v) this.setAttribute('bg', v) + else this.removeAttribute('bg') + } + + get fixed(): string { + return this.getAttribute('fixed') ?? '' + } + set fixed(v: string) { + if (v) this.setAttribute('fixed', v) + else this.removeAttribute('fixed') + } + + get sticky(): string { + return this.getAttribute('sticky') ?? '' + } + set sticky(v: string) { + if (v) this.setAttribute('sticky', v) + else this.removeAttribute('sticky') + } + + private render(): void { + const brand = this.getAttribute('brand') ?? '' + const expand = this.getAttribute('expand') ?? 'lg' + const variant = this.getAttribute('variant') + const bg = this.getAttribute('bg') + const fixed = this.getAttribute('fixed') + const sticky = this.getAttribute('sticky') + + const classes = ['navbar', `navbar-expand-${expand}`] + if (bg) classes.push(`bg-${bg}`) + if (fixed) classes.push(`fixed-${fixed}`) + if (sticky) classes.push(`sticky-${sticky}`) + + this.className = classes.join(' ') + if (variant) { + this.setAttribute('data-bs-theme', variant) + } else { + this.removeAttribute('data-bs-theme') + } + + const brandHtml = brand + ? `${this._escape(brand)}` + : '' + const id = this._collapseId + + this.innerHTML = + `
    ` + + brandHtml + + `` + + `` + + `
    ` + + const collapseEl = this.querySelector(`#${id}`) + this._collapseEl = collapseEl + if (collapseEl) { + this._navContent.forEach(n => collapseEl.appendChild(n)) + } + } + + private _initPlugin(): void { + const el = this._collapseEl + if (!el) return + this._bsCollapse = new BsCollapse(el, { toggle: false }) + } + + private _teardown(): void { + if (this._bsCollapse) { + this._bsCollapse.dispose() + this._bsCollapse = null + } + } + + private _escape(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Navbar + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 01d5565b..ec464991 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -21,3 +21,4 @@ export * from './ListGroupItem' export * from './Modal' export * from './Nav' export * from './NavItem' +export * from './Navbar' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 96d75174..47a4c961 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -20,6 +20,7 @@ import { ListGroupItem } from './ListGroupItem' import { Modal } from './Modal' import { Nav } from './Nav' import { NavItem } from './NavItem' +import { Navbar } from './Navbar' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -47,4 +48,5 @@ export function register(): void { customElements.define('tc-modal', Modal) customElements.define('tc-nav', Nav) customElements.define('tc-nav-item', NavItem) + customElements.define('tc-navbar', Navbar) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index d8246050..2d6bc295 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -18,3 +18,4 @@ @forward 'list-group'; @forward 'modal'; @forward 'nav'; +@forward 'navbar'; diff --git a/web-components/style/components/_navbar.scss b/web-components/style/components/_navbar.scss new file mode 100644 index 00000000..e6b26a55 --- /dev/null +++ b/web-components/style/components/_navbar.scss @@ -0,0 +1 @@ +// tc-navbar — theme tweaks only; Bootstrap navbar classes handle layout. From cb5e5671b8aadc01712e14f07b3523c7c139fc9a Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 20:20:26 +0000 Subject: [PATCH 024/632] 023-tc-offcanvas: Implement the tc-offcanvas Web Component --- examples/src/web-components/OffcanvasDemo.tsx | 115 +++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Offcanvas.ts | 185 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_offcanvas.scss | 1 + 7 files changed, 307 insertions(+) create mode 100644 examples/src/web-components/OffcanvasDemo.tsx create mode 100644 web-components/src/Offcanvas.ts create mode 100644 web-components/style/components/_offcanvas.scss diff --git a/examples/src/web-components/OffcanvasDemo.tsx b/examples/src/web-components/OffcanvasDemo.tsx new file mode 100644 index 00000000..cf4f7e8e --- /dev/null +++ b/examples/src/web-components/OffcanvasDemo.tsx @@ -0,0 +1,115 @@ +import React, { useRef } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const OffcanvasDemo: React.FC = () => { + const startRef = useRef(null) + const endRef = useRef(null) + const topRef = useRef(null) + const bottomRef = useRef(null) + const staticRef = useRef(null) + const scrollRef = useRef(null) + + return ( +
    +
    +
    +
    + Web Components} + title="Offcanvas" + description="Bootstrap Offcanvas plugin wrapper. Use the placement attribute to control the slide-in direction, title for the header, and children for the body." + /> + +
    + + + {/* @ts-ignore */} + +

    This panel slides in from the left (start). Add any content here as children of tc-offcanvas.

    + {/* @ts-ignore */} +
    +
    + + + + {/* @ts-ignore */} + +

    This panel slides in from the right.

    + {/* @ts-ignore */} +
    +
    + + + + {/* @ts-ignore */} + +

    This panel slides in from the top of the viewport.

    + {/* @ts-ignore */} +
    +
    + + + + {/* @ts-ignore */} + +

    This panel slides in from the bottom of the viewport.

    + {/* @ts-ignore */} +
    +
    + + + + {/* @ts-ignore */} + +

    Clicking outside this panel will not close it. Use the Close button.

    + {/* @ts-ignore */} +
    +
    + + + + {/* @ts-ignore */} + +

    The page body remains scrollable while this panel is open.

    + {/* @ts-ignore */} +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default OffcanvasDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index ed34d724..2b93d93e 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -15,6 +15,7 @@ import CollapseDemo from './CollapseDemo' import DropdownDemo from './DropdownDemo' import ListGroupDemo from './ListGroupDemo' import ModalDemo from './ModalDemo' +import OffcanvasDemo from './OffcanvasDemo' import NavDemo from './NavDemo' import NavbarDemo from './NavbarDemo' @@ -54,4 +55,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'nav', category: 'Navigation', element: }, { key: 'navbar', category: 'Navigation', element: }, { key: 'modal', category: 'Overlays & Feedback', element: }, + { key: 'offcanvas', category: 'Overlays & Feedback', element: }, ] diff --git a/web-components/src/Offcanvas.ts b/web-components/src/Offcanvas.ts new file mode 100644 index 00000000..00d0cf0c --- /dev/null +++ b/web-components/src/Offcanvas.ts @@ -0,0 +1,185 @@ +import { Offcanvas as BsOffcanvas } from 'bootstrap' + +const TAG_NAME = 'tc-offcanvas' + +type BackdropValue = boolean | 'static' + +export class Offcanvas extends HTMLElement { + + private _bsOffcanvas: BsOffcanvas | null = null + private _bodyNodes: Node[] = [] + private _initialised = false + private _syncing = false + + static get observedAttributes(): string[] { + return ['open', 'placement', 'title', 'backdrop', 'scroll'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + this._bodyNodes = Array.from(this.childNodes) + this._initialised = true + } + this.render() + this._initPlugin() + if (this.open) this._bsOffcanvas?.show() + } + + disconnectedCallback(): void { + this._teardown() + } + + attributeChangedCallback(name: string): void { + if (!this.isConnected || !this._initialised || this._syncing) return + if (name === 'open') { + if (this.open) { + this._bsOffcanvas?.show() + } else { + this._bsOffcanvas?.hide() + } + } else { + this._teardown() + this.render() + this._initPlugin() + } + } + + get open(): boolean { + return this.hasAttribute('open') + } + set open(v: boolean) { + if (v) this.setAttribute('open', '') + else this.removeAttribute('open') + } + + get placement(): string { + return this.getAttribute('placement') ?? 'start' + } + set placement(v: string) { + this.setAttribute('placement', v) + } + + get title(): string { + return this.getAttribute('title') ?? '' + } + set title(v: string) { + this.setAttribute('title', v) + } + + get backdrop(): string { + return this.getAttribute('backdrop') ?? 'true' + } + set backdrop(v: string) { + this.setAttribute('backdrop', v) + } + + get scroll(): boolean { + return this.hasAttribute('scroll') + } + set scroll(v: boolean) { + if (v) this.setAttribute('scroll', '') + else this.removeAttribute('scroll') + } + + show(): void { + this._bsOffcanvas?.show() + } + + hide(): void { + this._bsOffcanvas?.hide() + } + + toggle(): void { + this._bsOffcanvas?.toggle() + } + + private _onShow = (): void => { + this._syncing = true + this.setAttribute('open', '') + this._syncing = false + this.dispatchEvent(new CustomEvent('tc-show', { bubbles: true, composed: true })) + } + + private _onShown = (): void => { + this.dispatchEvent(new CustomEvent('tc-shown', { bubbles: true, composed: true })) + } + + private _onHide = (): void => { + this._syncing = true + this.removeAttribute('open') + this._syncing = false + this.dispatchEvent(new CustomEvent('tc-hide', { bubbles: true, composed: true })) + } + + private _onHidden = (): void => { + this.dispatchEvent(new CustomEvent('tc-hidden', { bubbles: true, composed: true })) + } + + private _resolveBackdrop(): BackdropValue { + const val = this.getAttribute('backdrop') + if (val === 'false') return false + if (val === 'static') return 'static' + return true + } + + private render(): void { + const placement = this.getAttribute('placement') ?? 'start' + const validPlacements = ['start', 'end', 'top', 'bottom'] + const safePlacement = validPlacements.includes(placement) ? placement : 'start' + + this.className = `offcanvas offcanvas-${safePlacement}` + this.setAttribute('tabindex', '-1') + + const titleText = this._escapeHtml(this.getAttribute('title') ?? '') + + this.innerHTML = + `
    ` + + `
    ${titleText}
    ` + + `` + + `
    ` + + `
    ` + + const body = this.querySelector('.offcanvas-body') + if (body) this._bodyNodes.forEach(n => body.appendChild(n)) + } + + private _escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + } + + private _initPlugin(): void { + this._bsOffcanvas = new BsOffcanvas(this, { + backdrop: this._resolveBackdrop(), + scroll: this.scroll, + }) + this.addEventListener('show.bs.offcanvas', this._onShow) + this.addEventListener('shown.bs.offcanvas', this._onShown) + this.addEventListener('hide.bs.offcanvas', this._onHide) + this.addEventListener('hidden.bs.offcanvas', this._onHidden) + } + + private _teardown(): void { + this.removeEventListener('show.bs.offcanvas', this._onShow) + this.removeEventListener('shown.bs.offcanvas', this._onShown) + this.removeEventListener('hide.bs.offcanvas', this._onHide) + this.removeEventListener('hidden.bs.offcanvas', this._onHidden) + if (this._bsOffcanvas) { + this._bsOffcanvas.dispose() + this._bsOffcanvas = null + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Offcanvas + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index ec464991..ae99ba88 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -19,6 +19,7 @@ export * from './DropdownItem' export * from './ListGroup' export * from './ListGroupItem' export * from './Modal' +export * from './Offcanvas' export * from './Nav' export * from './NavItem' export * from './Navbar' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 47a4c961..fdaf39fe 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -18,6 +18,7 @@ import { DropdownItem } from './DropdownItem' import { ListGroup } from './ListGroup' import { ListGroupItem } from './ListGroupItem' import { Modal } from './Modal' +import { Offcanvas } from './Offcanvas' import { Nav } from './Nav' import { NavItem } from './NavItem' import { Navbar } from './Navbar' @@ -46,6 +47,7 @@ export function register(): void { customElements.define('tc-list-group', ListGroup) customElements.define('tc-list-group-item', ListGroupItem) customElements.define('tc-modal', Modal) + customElements.define('tc-offcanvas', Offcanvas) customElements.define('tc-nav', Nav) customElements.define('tc-nav-item', NavItem) customElements.define('tc-navbar', Navbar) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 2d6bc295..f175c434 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -19,3 +19,4 @@ @forward 'modal'; @forward 'nav'; @forward 'navbar'; +@forward 'offcanvas'; diff --git a/web-components/style/components/_offcanvas.scss b/web-components/style/components/_offcanvas.scss new file mode 100644 index 00000000..22e1ccf7 --- /dev/null +++ b/web-components/style/components/_offcanvas.scss @@ -0,0 +1 @@ +// tc-offcanvas — theme tweaks only; Bootstrap offcanvas classes handle layout. From 840f815c342a66ba0ba1fcc3c05fdf8d0d1e8f42 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 20:24:25 +0000 Subject: [PATCH 025/632] 024-tc-pagination: Implement the tc-pagination Web Component --- .../src/web-components/PaginationDemo.tsx | 85 +++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Pagination.ts | 162 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_pagination.scss | 1 + 7 files changed, 254 insertions(+) create mode 100644 examples/src/web-components/PaginationDemo.tsx create mode 100644 web-components/src/Pagination.ts create mode 100644 web-components/style/components/_pagination.scss diff --git a/examples/src/web-components/PaginationDemo.tsx b/examples/src/web-components/PaginationDemo.tsx new file mode 100644 index 00000000..c58915ca --- /dev/null +++ b/examples/src/web-components/PaginationDemo.tsx @@ -0,0 +1,85 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +function usePaginationPage(initial: number): [number, React.RefObject] { + const [current, setCurrent] = useState(initial) + const ref = useRef(null) + + useEffect(() => { + const el = ref.current + if (!el) return + const handler = (e: Event) => setCurrent((e as CustomEvent).detail.page) + el.addEventListener('tc-page-change', handler) + return () => el.removeEventListener('tc-page-change', handler) + }, []) + + return [current, ref] +} + +const PaginationDemo: React.FC = () => { + const [page1, ref1] = usePaginationPage(1) + const [page2, ref2] = usePaginationPage(5) + const [page3, ref3] = usePaginationPage(3) + const [page4, ref4] = usePaginationPage(4) + const [page5, ref5] = usePaginationPage(1) + + return ( +
    +
    +
    +
    + Web Components} + title="Pagination" + description="Bootstrap pagination wrapper. Set total for page count, current for the active page (1-based), and max-visible to control the window of visible page links (with automatic ellipsis). Use size for sm/lg variants, and align for flex alignment." + /> + +
    + + {/* @ts-ignore */} + +

    Current page: {page1}

    +
    + + + {/* @ts-ignore */} + +

    Current page: {page2}

    +
    + + + {/* @ts-ignore */} + +

    Current page: {page3}

    +
    + + + {/* @ts-ignore */} + +

    Current page: {page4}

    +
    + + + {/* @ts-ignore */} + +

    Current page: {page5}

    +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    + ) +} + +export default PaginationDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 2b93d93e..90fd76cc 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -18,6 +18,7 @@ import ModalDemo from './ModalDemo' import OffcanvasDemo from './OffcanvasDemo' import NavDemo from './NavDemo' import NavbarDemo from './NavbarDemo' +import PaginationDemo from './PaginationDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -54,6 +55,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'breadcrumb', category: 'Navigation', element: }, { key: 'nav', category: 'Navigation', element: }, { key: 'navbar', category: 'Navigation', element: }, + { key: 'pagination', category: 'Navigation', element: }, { key: 'modal', category: 'Overlays & Feedback', element: }, { key: 'offcanvas', category: 'Overlays & Feedback', element: }, ] diff --git a/web-components/src/Pagination.ts b/web-components/src/Pagination.ts new file mode 100644 index 00000000..7c564796 --- /dev/null +++ b/web-components/src/Pagination.ts @@ -0,0 +1,162 @@ +const TAG_NAME = 'tc-pagination' + +export type PaginationSize = 'sm' | 'lg' +export type PaginationAlign = 'start' | 'center' | 'end' + +const ALIGNS: PaginationAlign[] = ['start', 'center', 'end'] + +export class Pagination extends HTMLElement { + + private _onClick: (e: Event) => void + + static get observedAttributes(): string[] { + return ['total', 'current', 'size', 'max-visible', 'align'] + } + + constructor() { + super() + this._onClick = (e: Event) => { + const target = (e.target as HTMLElement).closest('[data-page]') as HTMLElement | null + if (!target) return + e.preventDefault() + + const page = parseInt(target.dataset['page'] ?? '', 10) + if (isNaN(page) || page === this.current) return + + this.setAttribute('current', String(page)) + this.dispatchEvent(new CustomEvent('tc-page-change', { + bubbles: true, + composed: true, + detail: { page }, + })) + } + } + + connectedCallback(): void { + this.addEventListener('click', this._onClick) + this.render() + } + + disconnectedCallback(): void { + this.removeEventListener('click', this._onClick) + } + + attributeChangedCallback(): void { + if (this.isConnected) this.render() + } + + get total(): number { + return Math.max(1, parseInt(this.getAttribute('total') ?? '1', 10) || 1) + } + set total(v: number) { + this.setAttribute('total', String(v)) + } + + get current(): number { + const c = parseInt(this.getAttribute('current') ?? '1', 10) || 1 + return Math.min(Math.max(1, c), this.total) + } + set current(v: number) { + this.setAttribute('current', String(v)) + } + + get size(): PaginationSize | null { + const v = this.getAttribute('size') as PaginationSize + return v === 'sm' || v === 'lg' ? v : null + } + set size(v: PaginationSize | null) { + if (v != null) this.setAttribute('size', v) + else this.removeAttribute('size') + } + + get maxVisible(): number { + const v = parseInt(this.getAttribute('max-visible') ?? '7', 10) || 7 + return Math.max(3, v) + } + set maxVisible(v: number) { + this.setAttribute('max-visible', String(v)) + } + + get align(): PaginationAlign | null { + const v = this.getAttribute('align') as PaginationAlign + return ALIGNS.includes(v) ? v : null + } + set align(v: PaginationAlign | null) { + if (v != null) this.setAttribute('align', v) + else this.removeAttribute('align') + } + + private _computePages(): Array { + const total = this.total + const current = this.current + const maxVisible = this.maxVisible + + if (total <= maxVisible) { + return Array.from({ length: total }, (_, i) => i + 1) + } + + // Show first, last, current ± neighbours; fill gaps with ellipsis + const sideCount = Math.max(1, Math.floor((maxVisible - 3) / 2)) + const pages: Array = [] + + const rangeStart = Math.min( + Math.max(2, current - sideCount), + total - sideCount * 2 - 1 + ) + const rangeEnd = Math.max( + Math.min(total - 1, current + sideCount), + sideCount * 2 + 2 + ) + + pages.push(1) + if (rangeStart > 2) pages.push('...') + for (let p = rangeStart; p <= rangeEnd; p++) pages.push(p) + if (rangeEnd < total - 1) pages.push('...') + pages.push(total) + + return pages + } + + private render(): void { + const total = this.total + const current = this.current + const size = this.size + const align = this.align + const pages = this._computePages() + + const sizeClass = size ? ` pagination-${size}` : '' + const prevDisabled = current === 1 + const nextDisabled = current === total + + const pageItems = pages.map(p => { + if (p === '...') { + return `
  • ` + } + const active = p === current ? ' active' : '' + const ariaCurrent = p === current ? ' aria-current="page"' : '' + return `
  • ${p}
  • ` + }).join('') + + const ul = `
      ` + + `
    • ` + + `«
    • ` + + pageItems + + `
    • ` + + `»
    • ` + + `
    ` + + const nav = `` + + if (align) { + this.innerHTML = `
    ${nav}
    ` + } else { + this.innerHTML = nav + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Pagination + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index ae99ba88..46426af6 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -23,3 +23,4 @@ export * from './Offcanvas' export * from './Nav' export * from './NavItem' export * from './Navbar' +export * from './Pagination' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index fdaf39fe..2a4223bd 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -22,6 +22,7 @@ import { Offcanvas } from './Offcanvas' import { Nav } from './Nav' import { NavItem } from './NavItem' import { Navbar } from './Navbar' +import { Pagination } from './Pagination' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -51,4 +52,5 @@ export function register(): void { customElements.define('tc-nav', Nav) customElements.define('tc-nav-item', NavItem) customElements.define('tc-navbar', Navbar) + customElements.define('tc-pagination', Pagination) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index f175c434..78b3a596 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -20,3 +20,4 @@ @forward 'nav'; @forward 'navbar'; @forward 'offcanvas'; +@forward 'pagination'; diff --git a/web-components/style/components/_pagination.scss b/web-components/style/components/_pagination.scss new file mode 100644 index 00000000..80e7992c --- /dev/null +++ b/web-components/style/components/_pagination.scss @@ -0,0 +1 @@ +// tc-pagination — theme tweaks only; Bootstrap pagination classes handle layout. From e0eb6ec1321116bc034f695b92c3f39d294f5c2b Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 20:28:54 +0000 Subject: [PATCH 026/632] 025-tc-placeholder: Implement the tc-placeholder Web Component --- .../src/web-components/PlaceholderDemo.tsx | 104 ++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Placeholder.ts | 114 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_placeholder.scss | 9 ++ 7 files changed, 233 insertions(+) create mode 100644 examples/src/web-components/PlaceholderDemo.tsx create mode 100644 web-components/src/Placeholder.ts create mode 100644 web-components/style/components/_placeholder.scss diff --git a/examples/src/web-components/PlaceholderDemo.tsx b/examples/src/web-components/PlaceholderDemo.tsx new file mode 100644 index 00000000..9cf0e125 --- /dev/null +++ b/examples/src/web-components/PlaceholderDemo.tsx @@ -0,0 +1,104 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const PlaceholderDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="Placeholder" + description="Bootstrap loading skeletons wrapped as a custom element. Supports col-span widths, size variants, glow/wave animations, and theme colours." + /> + +
    + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    +) + +export default PlaceholderDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 90fd76cc..f2241021 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -19,6 +19,7 @@ import OffcanvasDemo from './OffcanvasDemo' import NavDemo from './NavDemo' import NavbarDemo from './NavbarDemo' import PaginationDemo from './PaginationDemo' +import PlaceholderDemo from './PlaceholderDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -58,4 +59,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'pagination', category: 'Navigation', element: }, { key: 'modal', category: 'Overlays & Feedback', element: }, { key: 'offcanvas', category: 'Overlays & Feedback', element: }, + { key: 'placeholder', category: 'Components', element: }, ] diff --git a/web-components/src/Placeholder.ts b/web-components/src/Placeholder.ts new file mode 100644 index 00000000..c0f789d2 --- /dev/null +++ b/web-components/src/Placeholder.ts @@ -0,0 +1,114 @@ +const TAG_NAME = 'tc-placeholder' + +export type PlaceholderSize = 'xs' | 'sm' | 'lg' +export type PlaceholderAnimation = 'glow' | 'wave' +export type PlaceholderVariant = 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark' + +const SIZES: PlaceholderSize[] = ['xs', 'sm', 'lg'] +const ANIMATIONS: PlaceholderAnimation[] = ['glow', 'wave'] +const VARIANTS: PlaceholderVariant[] = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'] + +export class Placeholder extends HTMLElement { + + private _initialised = false + + static get observedAttributes(): string[] { + return ['width', 'size', 'animation', 'variant'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + const slotContent = Array.from(this.childNodes) + this._render(slotContent) + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + const inner = this.querySelector('.tc-placeholder-content') + const isUserContent = inner?.hasAttribute('data-tc-user') ?? false + if (isUserContent && inner) { + const slotContent = Array.from(inner.childNodes) + this._render(slotContent) + } else { + this._render([]) + } + } + + get width(): string | null { + return this.getAttribute('width') + } + set width(v: string | null) { + if (v != null) this.setAttribute('width', v) + else this.removeAttribute('width') + } + + get size(): PlaceholderSize | null { + const v = this.getAttribute('size') as PlaceholderSize + return SIZES.includes(v) ? v : null + } + set size(v: PlaceholderSize | null) { + if (v != null) this.setAttribute('size', v) + else this.removeAttribute('size') + } + + get animation(): PlaceholderAnimation | null { + const v = this.getAttribute('animation') as PlaceholderAnimation + return ANIMATIONS.includes(v) ? v : null + } + set animation(v: PlaceholderAnimation | null) { + if (v != null) this.setAttribute('animation', v) + else this.removeAttribute('animation') + } + + get variant(): PlaceholderVariant | null { + const v = this.getAttribute('variant') as PlaceholderVariant + return VARIANTS.includes(v) ? v : null + } + set variant(v: PlaceholderVariant | null) { + if (v != null) this.setAttribute('variant', v) + else this.removeAttribute('variant') + } + + private _render(slotContent: Node[]): void { + const animation = this.animation + const animClass = animation ? ` placeholder-${animation}` : '' + + if (slotContent.length > 0) { + this.innerHTML = `
    ` + const wrapper = this.querySelector('.tc-placeholder-content')! + slotContent.forEach(n => wrapper.appendChild(n)) + } else { + const rawWidth = this.getAttribute('width') + const size = this.size + const variant = this.variant + + let colClass = '' + let styleAttr = '' + if (rawWidth !== null) { + const colNum = parseInt(rawWidth, 10) + if (!isNaN(colNum) && colNum >= 1 && colNum <= 12) { + colClass = ` col-${colNum}` + } else { + styleAttr = ` style="width:${rawWidth}"` + } + } + + const sizeClass = size ? ` placeholder-${size}` : '' + const variantClass = variant ? ` bg-${variant}` : '' + + this.innerHTML = `
    ` + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Placeholder + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 46426af6..f34f36d1 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -24,3 +24,4 @@ export * from './Nav' export * from './NavItem' export * from './Navbar' export * from './Pagination' +export * from './Placeholder' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 2a4223bd..34c7cb4e 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -23,6 +23,7 @@ import { Nav } from './Nav' import { NavItem } from './NavItem' import { Navbar } from './Navbar' import { Pagination } from './Pagination' +import { Placeholder } from './Placeholder' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -53,4 +54,5 @@ export function register(): void { customElements.define('tc-nav-item', NavItem) customElements.define('tc-navbar', Navbar) customElements.define('tc-pagination', Pagination) + customElements.define('tc-placeholder', Placeholder) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 78b3a596..9c9e375c 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -21,3 +21,4 @@ @forward 'navbar'; @forward 'offcanvas'; @forward 'pagination'; +@forward 'placeholder'; diff --git a/web-components/style/components/_placeholder.scss b/web-components/style/components/_placeholder.scss new file mode 100644 index 00000000..1aefa1a6 --- /dev/null +++ b/web-components/style/components/_placeholder.scss @@ -0,0 +1,9 @@ +// tc-placeholder — theme tweaks only; Bootstrap placeholder classes handle layout and animation. + +tc-placeholder { + display: block; + + .tc-placeholder-content { + display: block; + } +} From 0421cb02ea3bc9d2cc6da7d80fc51261f2c2113d Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 20:33:53 +0000 Subject: [PATCH 027/632] 026-tc-popover: Implement the tc-popover Web Component --- examples/src/web-components/PopoverDemo.tsx | 77 ++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Popover.ts | 144 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + web-components/style/components/_popover.scss | 1 + 7 files changed, 228 insertions(+) create mode 100644 examples/src/web-components/PopoverDemo.tsx create mode 100644 web-components/src/Popover.ts create mode 100644 web-components/style/components/_popover.scss diff --git a/examples/src/web-components/PopoverDemo.tsx b/examples/src/web-components/PopoverDemo.tsx new file mode 100644 index 00000000..cbb6b105 --- /dev/null +++ b/examples/src/web-components/PopoverDemo.tsx @@ -0,0 +1,77 @@ +import React, { useRef } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const PopoverDemo: React.FC = () => { + const manualRef = useRef(null) + + return ( +
    +
    +
    +
    + Web Components} + title="Popover" + description="Bootstrap Popover plugin wrapper. Wrap any trigger element with tc-popover and use title and content attributes to control the popover text." + /> + +
    + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + + +
    + {(['top', 'right', 'bottom', 'left'] as const).map(p => ( + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + ))} +
    +
    + + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + + +
    + + + +
    + {/* @ts-ignore */} + + + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    + ) +} + +export default PopoverDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index f2241021..d83bf415 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -20,6 +20,7 @@ import NavDemo from './NavDemo' import NavbarDemo from './NavbarDemo' import PaginationDemo from './PaginationDemo' import PlaceholderDemo from './PlaceholderDemo' +import PopoverDemo from './PopoverDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -59,5 +60,6 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'pagination', category: 'Navigation', element: }, { key: 'modal', category: 'Overlays & Feedback', element: }, { key: 'offcanvas', category: 'Overlays & Feedback', element: }, + { key: 'popover', category: 'Overlays & Feedback', element: }, { key: 'placeholder', category: 'Components', element: }, ] diff --git a/web-components/src/Popover.ts b/web-components/src/Popover.ts new file mode 100644 index 00000000..2fe7c2db --- /dev/null +++ b/web-components/src/Popover.ts @@ -0,0 +1,144 @@ +import { Popover as BsPopover } from 'bootstrap' + +const TAG_NAME = 'tc-popover' + +const VALID_PLACEMENTS = ['top', 'right', 'bottom', 'left', 'auto'] + +export class Popover extends HTMLElement { + + private _bsPopover: BsPopover | null = null + private _triggerEl: HTMLElement | null = null + private _initialised = false + + static get observedAttributes(): string[] { + return ['title', 'content', 'placement', 'trigger', 'html'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + this._initialised = true + } + this._initPlugin() + } + + disconnectedCallback(): void { + this._teardown() + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this._teardown() + this._initPlugin() + } + + get title(): string { + return this.getAttribute('title') ?? '' + } + set title(v: string) { + this.setAttribute('title', v) + } + + get content(): string { + return this.getAttribute('content') ?? '' + } + set content(v: string) { + this.setAttribute('content', v) + } + + get placement(): string { + return this.getAttribute('placement') ?? 'auto' + } + set placement(v: string) { + this.setAttribute('placement', v) + } + + get trigger(): string { + return this.getAttribute('trigger') ?? 'click' + } + set trigger(v: string) { + this.setAttribute('trigger', v) + } + + get html(): boolean { + return this.hasAttribute('html') + } + set html(v: boolean) { + if (v) this.setAttribute('html', '') + else this.removeAttribute('html') + } + + show(): void { + this._bsPopover?.show() + } + + hide(): void { + this._bsPopover?.hide() + } + + toggle(): void { + this._bsPopover?.toggle() + } + + private _onShow = (): void => { + this.dispatchEvent(new CustomEvent('tc-show', { bubbles: true, composed: true })) + } + + private _onShown = (): void => { + this.dispatchEvent(new CustomEvent('tc-shown', { bubbles: true, composed: true })) + } + + private _onHide = (): void => { + this.dispatchEvent(new CustomEvent('tc-hide', { bubbles: true, composed: true })) + } + + private _onHidden = (): void => { + this.dispatchEvent(new CustomEvent('tc-hidden', { bubbles: true, composed: true })) + } + + private _initPlugin(): void { + const triggerEl = this.firstElementChild as HTMLElement | null + if (!triggerEl) return + this._triggerEl = triggerEl + + const rawPlacement = this.getAttribute('placement') ?? 'auto' + const placement = VALID_PLACEMENTS.includes(rawPlacement) ? rawPlacement : 'auto' + + this._bsPopover = new BsPopover(triggerEl, { + title: this.getAttribute('title') ?? '', + content: this.getAttribute('content') ?? '', + placement: placement as any, + trigger: (this.getAttribute('trigger') ?? 'click') as any, + html: this.hasAttribute('html'), + }) + + triggerEl.addEventListener('show.bs.popover', this._onShow) + triggerEl.addEventListener('shown.bs.popover', this._onShown) + triggerEl.addEventListener('hide.bs.popover', this._onHide) + triggerEl.addEventListener('hidden.bs.popover', this._onHidden) + } + + private _teardown(): void { + const triggerEl = this._triggerEl + if (triggerEl) { + triggerEl.removeEventListener('show.bs.popover', this._onShow) + triggerEl.removeEventListener('shown.bs.popover', this._onShown) + triggerEl.removeEventListener('hide.bs.popover', this._onHide) + triggerEl.removeEventListener('hidden.bs.popover', this._onHidden) + } + if (this._bsPopover) { + this._bsPopover.dispose() + this._bsPopover = null + } + this._triggerEl = null + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Popover + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index f34f36d1..76b87888 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -25,3 +25,4 @@ export * from './NavItem' export * from './Navbar' export * from './Pagination' export * from './Placeholder' +export * from './Popover' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 34c7cb4e..6f75479a 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -24,6 +24,7 @@ import { NavItem } from './NavItem' import { Navbar } from './Navbar' import { Pagination } from './Pagination' import { Placeholder } from './Placeholder' +import { Popover } from './Popover' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -55,4 +56,5 @@ export function register(): void { customElements.define('tc-navbar', Navbar) customElements.define('tc-pagination', Pagination) customElements.define('tc-placeholder', Placeholder) + customElements.define('tc-popover', Popover) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 9c9e375c..0c0c9df5 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -22,3 +22,4 @@ @forward 'offcanvas'; @forward 'pagination'; @forward 'placeholder'; +@forward 'popover'; diff --git a/web-components/style/components/_popover.scss b/web-components/style/components/_popover.scss new file mode 100644 index 00000000..19fca114 --- /dev/null +++ b/web-components/style/components/_popover.scss @@ -0,0 +1 @@ +// tc-popover — theme tweaks only; Bootstrap popover classes handle layout. From 0535cba7fa9fd56d06f7698afd89b56e3476d15f Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 20:41:01 +0000 Subject: [PATCH 028/632] 027-tc-progress: Implement the tc-progress + tc-progress-bar Web Components --- examples/src/web-components/ProgressDemo.tsx | 87 +++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Progress.ts | 122 ++++++++++++++++++ web-components/src/ProgressBar.ts | 93 +++++++++++++ web-components/src/index.ts | 2 + web-components/src/register.ts | 4 + web-components/style/components/_index.scss | 1 + .../style/components/_progress.scss | 1 + 8 files changed, 312 insertions(+) create mode 100644 examples/src/web-components/ProgressDemo.tsx create mode 100644 web-components/src/Progress.ts create mode 100644 web-components/src/ProgressBar.ts create mode 100644 web-components/style/components/_progress.scss diff --git a/examples/src/web-components/ProgressDemo.tsx b/examples/src/web-components/ProgressDemo.tsx new file mode 100644 index 00000000..d32a8858 --- /dev/null +++ b/examples/src/web-components/ProgressDemo.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const ProgressDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="Progress" + description="Bootstrap progress bars wrapped as custom elements. Supports single bars with variants, striped and animated patterns, and stacked bars." + /> + +
    + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    +) + +export default ProgressDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index d83bf415..d12e586a 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -21,6 +21,7 @@ import NavbarDemo from './NavbarDemo' import PaginationDemo from './PaginationDemo' import PlaceholderDemo from './PlaceholderDemo' import PopoverDemo from './PopoverDemo' +import ProgressDemo from './ProgressDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -62,4 +63,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'offcanvas', category: 'Overlays & Feedback', element: }, { key: 'popover', category: 'Overlays & Feedback', element: }, { key: 'placeholder', category: 'Components', element: }, + { key: 'progress', category: 'Components', element: }, ] diff --git a/web-components/src/Progress.ts b/web-components/src/Progress.ts new file mode 100644 index 00000000..e965c495 --- /dev/null +++ b/web-components/src/Progress.ts @@ -0,0 +1,122 @@ +const TAG_NAME = 'tc-progress' + +export type ProgressVariant = 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark' + +const VARIANTS: ProgressVariant[] = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'] + +export class Progress extends HTMLElement { + + private _initialised = false + private _stacked = false + + static get observedAttributes(): string[] { + return ['value', 'min', 'max', 'variant', 'striped', 'animated', 'label'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + const barChildren = Array.from(this.querySelectorAll(':scope > tc-progress-bar')) + this._stacked = barChildren.length > 0 + this._render(barChildren) + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + if (!this._stacked) { + this._render([]) + } + } + + get value(): number { + return parseFloat(this.getAttribute('value') ?? '0') || 0 + } + set value(v: number) { + this.setAttribute('value', String(v)) + } + + get min(): number { + return parseFloat(this.getAttribute('min') ?? '0') || 0 + } + set min(v: number) { + this.setAttribute('min', String(v)) + } + + get max(): number { + const raw = this.getAttribute('max') + return raw !== null ? (parseFloat(raw) || 100) : 100 + } + set max(v: number) { + this.setAttribute('max', String(v)) + } + + get variant(): ProgressVariant | null { + const v = this.getAttribute('variant') as ProgressVariant + return VARIANTS.includes(v) ? v : null + } + set variant(v: ProgressVariant | null) { + if (v != null) this.setAttribute('variant', v) + else this.removeAttribute('variant') + } + + get striped(): boolean { + return this.hasAttribute('striped') + } + set striped(v: boolean) { + if (v) this.setAttribute('striped', '') + else this.removeAttribute('striped') + } + + get animated(): boolean { + return this.hasAttribute('animated') + } + set animated(v: boolean) { + if (v) this.setAttribute('animated', '') + else this.removeAttribute('animated') + } + + get label(): boolean { + return this.hasAttribute('label') + } + set label(v: boolean) { + if (v) this.setAttribute('label', '') + else this.removeAttribute('label') + } + + private _render(barChildren: Element[]): void { + if (barChildren.length > 0) { + this.innerHTML = `
    ` + const stacked = this.querySelector('.progress-stacked')! + barChildren.forEach(bar => stacked.appendChild(bar)) + } else { + const value = this.value + const min = this.min + const max = this.max + const variant = this.variant + const striped = this.striped + const animated = this.animated + const showLabel = this.label + + const range = max - min || 1 + const pct = Math.min(100, Math.max(0, ((value - min) / range) * 100)) + + const variantClass = variant ? ` bg-${variant}` : '' + const stripedClass = striped ? ' progress-bar-striped' : '' + const animatedClass = animated ? ' progress-bar-animated' : '' + const labelText = showLabel ? `${Math.round(pct)}%` : '' + + this.innerHTML = `
    ${labelText}
    ` + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Progress + } +} diff --git a/web-components/src/ProgressBar.ts b/web-components/src/ProgressBar.ts new file mode 100644 index 00000000..0906e00f --- /dev/null +++ b/web-components/src/ProgressBar.ts @@ -0,0 +1,93 @@ +const TAG_NAME = 'tc-progress-bar' + +export type ProgressBarVariant = 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark' + +const VARIANTS: ProgressBarVariant[] = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'] + +export class ProgressBar extends HTMLElement { + + private _initialised = false + + static get observedAttributes(): string[] { + return ['value', 'variant', 'striped', 'animated', 'label'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + this._render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this._render() + } + + get value(): number { + return parseFloat(this.getAttribute('value') ?? '0') || 0 + } + set value(v: number) { + this.setAttribute('value', String(v)) + } + + get variant(): ProgressBarVariant | null { + const v = this.getAttribute('variant') as ProgressBarVariant + return VARIANTS.includes(v) ? v : null + } + set variant(v: ProgressBarVariant | null) { + if (v != null) this.setAttribute('variant', v) + else this.removeAttribute('variant') + } + + get striped(): boolean { + return this.hasAttribute('striped') + } + set striped(v: boolean) { + if (v) this.setAttribute('striped', '') + else this.removeAttribute('striped') + } + + get animated(): boolean { + return this.hasAttribute('animated') + } + set animated(v: boolean) { + if (v) this.setAttribute('animated', '') + else this.removeAttribute('animated') + } + + get label(): boolean { + return this.hasAttribute('label') + } + set label(v: boolean) { + if (v) this.setAttribute('label', '') + else this.removeAttribute('label') + } + + private _render(): void { + const value = this.value + const variant = this.variant + const striped = this.striped + const animated = this.animated + const showLabel = this.label + + const pct = Math.min(100, Math.max(0, value)) + + const variantClass = variant ? ` bg-${variant}` : '' + const stripedClass = striped ? ' progress-bar-striped' : '' + const animatedClass = animated ? ' progress-bar-animated' : '' + const labelText = showLabel ? `${Math.round(pct)}%` : '' + + this.innerHTML = `
    ${labelText}
    ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: ProgressBar + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 76b87888..aea0a23a 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -26,3 +26,5 @@ export * from './Navbar' export * from './Pagination' export * from './Placeholder' export * from './Popover' +export * from './Progress' +export * from './ProgressBar' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 6f75479a..6993ce50 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -25,6 +25,8 @@ import { Navbar } from './Navbar' import { Pagination } from './Pagination' import { Placeholder } from './Placeholder' import { Popover } from './Popover' +import { Progress } from './Progress' +import { ProgressBar } from './ProgressBar' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -57,4 +59,6 @@ export function register(): void { customElements.define('tc-pagination', Pagination) customElements.define('tc-placeholder', Placeholder) customElements.define('tc-popover', Popover) + customElements.define('tc-progress', Progress) + customElements.define('tc-progress-bar', ProgressBar) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 0c0c9df5..639769ed 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -23,3 +23,4 @@ @forward 'pagination'; @forward 'placeholder'; @forward 'popover'; +@forward 'progress'; diff --git a/web-components/style/components/_progress.scss b/web-components/style/components/_progress.scss new file mode 100644 index 00000000..00a6a5c5 --- /dev/null +++ b/web-components/style/components/_progress.scss @@ -0,0 +1 @@ +// tc-progress / tc-progress-bar — theme tweaks only; Bootstrap progress classes handle layout. From 4415df9539bd80502de35abc4e05e2767d47202f Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 20:46:46 +0000 Subject: [PATCH 029/632] 028-tc-scrollspy: Implement the tc-scrollspy Web Component --- examples/src/web-components/ScrollspyDemo.tsx | 120 ++++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Scrollspy.ts | 105 +++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_scrollspy.scss | 1 + 7 files changed, 232 insertions(+) create mode 100644 examples/src/web-components/ScrollspyDemo.tsx create mode 100644 web-components/src/Scrollspy.ts create mode 100644 web-components/style/components/_scrollspy.scss diff --git a/examples/src/web-components/ScrollspyDemo.tsx b/examples/src/web-components/ScrollspyDemo.tsx new file mode 100644 index 00000000..44a2a26d --- /dev/null +++ b/examples/src/web-components/ScrollspyDemo.tsx @@ -0,0 +1,120 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const ScrollspyDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="Scrollspy" + description="Bootstrap ScrollSpy plugin wrapper. Highlights nav links as the user scrolls through a scrollable content region. Use the target attribute to point at the nav, offset to adjust the trigger threshold, and smooth-scroll to enable animated anchoring." + /> + +
    + + + {/* @ts-ignore */} + +
    +
    Section 1
    +

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    +
    +
    +
    Section 2
    +

    Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

    +
    +
    +
    Section 3
    +

    Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.

    +
    + {/* @ts-ignore */} +
    +
    + + + + {/* @ts-ignore */} + +
    +
    Alpha
    +

    With offset="80" the link activates 80 px before the section scrolls to the top of the container.

    +

    Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Donec velit neque, auctor sit amet aliquam vel, ullamcorper sit amet ligula.

    +
    +
    +
    Beta
    +

    Curabitur aliquet quam id dui posuere blandit. Cras ultricies ligula sed magna dictum porta. Vivamus magna justo, lacinia eget consectetur sed, convallis at tellus.

    +
    +
    +
    Gamma
    +

    Proin eget tortor risus. Curabitur non nulla sit amet nisl tempus convallis quis ac lectus. Donec rutrum congue leo eget malesuada.

    +
    + {/* @ts-ignore */} +
    +
    + + + + {/* @ts-ignore */} + +
    +
    Intro
    +

    Click a nav link above — with smooth-scroll set, Bootstrap animates the scroll instead of jumping instantly.

    +

    Quisque velit nisi, pretium ut lacinia in, elementum id enim. Vivamus magna justo, lacinia eget consectetur sed, convallis at tellus.

    +
    +
    +
    Details
    +

    Mauris blandit aliquet elit, eget tincidunt nibh pulvinar a. Curabitur aliquet quam id dui posuere blandit. Donec sollicitudin molestie malesuada.

    +
    +
    +
    Summary
    +

    Nulla quis lorem ut libero malesuada feugiat. Curabitur arcu erat, accumsan id imperdiet et, porttitor at sem. Sed porttitor lectus nibh.

    +
    + {/* @ts-ignore */} +
    +
    +
    +
    +
    +
    +
    +) + +export default ScrollspyDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index d12e586a..a7f271bf 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -22,6 +22,7 @@ import PaginationDemo from './PaginationDemo' import PlaceholderDemo from './PlaceholderDemo' import PopoverDemo from './PopoverDemo' import ProgressDemo from './ProgressDemo' +import ScrollspyDemo from './ScrollspyDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -64,4 +65,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'popover', category: 'Overlays & Feedback', element: }, { key: 'placeholder', category: 'Components', element: }, { key: 'progress', category: 'Components', element: }, + { key: 'scrollspy', category: 'Navigation', element: }, ] diff --git a/web-components/src/Scrollspy.ts b/web-components/src/Scrollspy.ts new file mode 100644 index 00000000..57334af2 --- /dev/null +++ b/web-components/src/Scrollspy.ts @@ -0,0 +1,105 @@ +import { ScrollSpy as BsScrollSpy } from 'bootstrap' + +const TAG_NAME = 'tc-scrollspy' + +export class Scrollspy extends HTMLElement { + + private _bsScrollspy: BsScrollSpy | null = null + + static get observedAttributes(): string[] { + return ['target', 'offset', 'smooth-scroll'] + } + + constructor() { + super() + } + + connectedCallback(): void { + this._applyAttrs() + this._initPlugin() + } + + disconnectedCallback(): void { + this._teardown() + } + + attributeChangedCallback(): void { + if (!this.isConnected) return + this._teardown() + this._applyAttrs() + this._initPlugin() + } + + get target(): string { + return this.getAttribute('target') ?? '' + } + set target(v: string) { + this.setAttribute('target', v) + } + + get offset(): number { + return parseInt(this.getAttribute('offset') ?? '0', 10) + } + set offset(v: number) { + this.setAttribute('offset', String(v)) + } + + get smoothScroll(): boolean { + return this.hasAttribute('smooth-scroll') + } + set smoothScroll(v: boolean) { + if (v) this.setAttribute('smooth-scroll', '') + else this.removeAttribute('smooth-scroll') + } + + private _onActivate = (event: Event): void => { + const relatedTarget = (event as any).relatedTarget + this.dispatchEvent(new CustomEvent('tc-activate', { + bubbles: true, + composed: true, + detail: { relatedTarget }, + })) + } + + private _applyAttrs(): void { + this.setAttribute('data-bs-spy', 'scroll') + const target = this.getAttribute('target') + if (target) { + this.setAttribute('data-bs-target', target) + } else { + this.removeAttribute('data-bs-target') + } + if (this.hasAttribute('smooth-scroll')) { + this.setAttribute('data-bs-smooth-scroll', 'true') + } else { + this.removeAttribute('data-bs-smooth-scroll') + } + if (!this.hasAttribute('tabindex')) { + this.setAttribute('tabindex', '0') + } + } + + private _initPlugin(): void { + const target = this.getAttribute('target') ?? '' + const offsetAttr = this.getAttribute('offset') + const offset = offsetAttr ? parseInt(offsetAttr, 10) : 0 + const smoothScroll = this.hasAttribute('smooth-scroll') + + this._bsScrollspy = new BsScrollSpy(this, { target, offset, smoothScroll }) + this.addEventListener('activate.bs.scrollspy', this._onActivate) + } + + private _teardown(): void { + this.removeEventListener('activate.bs.scrollspy', this._onActivate) + if (this._bsScrollspy) { + this._bsScrollspy.dispose() + this._bsScrollspy = null + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Scrollspy + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index aea0a23a..a417d87c 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -28,3 +28,4 @@ export * from './Placeholder' export * from './Popover' export * from './Progress' export * from './ProgressBar' +export * from './Scrollspy' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 6993ce50..1b7b0cd3 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -27,6 +27,7 @@ import { Placeholder } from './Placeholder' import { Popover } from './Popover' import { Progress } from './Progress' import { ProgressBar } from './ProgressBar' +import { Scrollspy } from './Scrollspy' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -61,4 +62,5 @@ export function register(): void { customElements.define('tc-popover', Popover) customElements.define('tc-progress', Progress) customElements.define('tc-progress-bar', ProgressBar) + customElements.define('tc-scrollspy', Scrollspy) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 639769ed..dcc0e10d 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -24,3 +24,4 @@ @forward 'placeholder'; @forward 'popover'; @forward 'progress'; +@forward 'scrollspy'; diff --git a/web-components/style/components/_scrollspy.scss b/web-components/style/components/_scrollspy.scss new file mode 100644 index 00000000..6f3e796c --- /dev/null +++ b/web-components/style/components/_scrollspy.scss @@ -0,0 +1 @@ +// tc-scrollspy — theme tweaks only; Bootstrap scrollspy classes handle highlighting. From 84524328e0586e91faa6f9990473a3c1fbd955e5 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 20:50:00 +0000 Subject: [PATCH 030/632] 029-tc-spinner: Implement the tc-spinner Web Component --- examples/src/web-components/SpinnerDemo.tsx | 96 +++++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Spinner.ts | 76 +++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + web-components/style/components/_spinner.scss | 1 + 7 files changed, 179 insertions(+) create mode 100644 examples/src/web-components/SpinnerDemo.tsx create mode 100644 web-components/src/Spinner.ts create mode 100644 web-components/style/components/_spinner.scss diff --git a/examples/src/web-components/SpinnerDemo.tsx b/examples/src/web-components/SpinnerDemo.tsx new file mode 100644 index 00000000..6358a762 --- /dev/null +++ b/examples/src/web-components/SpinnerDemo.tsx @@ -0,0 +1,96 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const SpinnerDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="Spinner" + description="Loading indicators wrapping Bootstrap's border and grow spinner classes. Supports all theme variants and a small size." + /> + +
    + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    +) + +export default SpinnerDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index a7f271bf..83aee8e1 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -23,6 +23,7 @@ import PlaceholderDemo from './PlaceholderDemo' import PopoverDemo from './PopoverDemo' import ProgressDemo from './ProgressDemo' import ScrollspyDemo from './ScrollspyDemo' +import SpinnerDemo from './SpinnerDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -65,5 +66,6 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'popover', category: 'Overlays & Feedback', element: }, { key: 'placeholder', category: 'Components', element: }, { key: 'progress', category: 'Components', element: }, + { key: 'spinner', category: 'Components', element: }, { key: 'scrollspy', category: 'Navigation', element: }, ] diff --git a/web-components/src/Spinner.ts b/web-components/src/Spinner.ts new file mode 100644 index 00000000..defd6296 --- /dev/null +++ b/web-components/src/Spinner.ts @@ -0,0 +1,76 @@ +const TAG_NAME = 'tc-spinner' + +export type SpinnerType = 'border' | 'grow' +export type SpinnerVariant = 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark' + +const TYPES: SpinnerType[] = ['border', 'grow'] +const VARIANTS: SpinnerVariant[] = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'] + +export class Spinner extends HTMLElement { + + static get observedAttributes(): string[] { + return ['type', 'variant', 'size', 'label'] + } + + constructor() { + super() + } + + connectedCallback(): void { + this.render() + } + + attributeChangedCallback(): void { + if (this.isConnected) this.render() + } + + get type(): SpinnerType { + const t = this.getAttribute('type') as SpinnerType + return TYPES.includes(t) ? t : 'border' + } + set type(v: SpinnerType) { + this.setAttribute('type', v) + } + + get variant(): SpinnerVariant | null { + const v = this.getAttribute('variant') as SpinnerVariant + return VARIANTS.includes(v) ? v : null + } + set variant(v: SpinnerVariant | null) { + if (v != null) this.setAttribute('variant', v) + else this.removeAttribute('variant') + } + + get size(): boolean { + return this.getAttribute('size') === 'sm' + } + set size(v: boolean) { + if (v) this.setAttribute('size', 'sm') + else this.removeAttribute('size') + } + + get label(): string { + return this.getAttribute('label') ?? 'Loading…' + } + set label(v: string) { + this.setAttribute('label', v) + } + + private render(): void { + const type = this.type + const variant = this.variant + const small = this.size + const label = this.label + + const variantClass = variant ? ` text-${variant}` : '' + const sizeClass = small ? ` spinner-${type}-sm` : '' + + this.innerHTML = `
    ${label}
    ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Spinner + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index a417d87c..5969d5bd 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -29,3 +29,4 @@ export * from './Popover' export * from './Progress' export * from './ProgressBar' export * from './Scrollspy' +export * from './Spinner' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 1b7b0cd3..8ae3069c 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -28,6 +28,7 @@ import { Popover } from './Popover' import { Progress } from './Progress' import { ProgressBar } from './ProgressBar' import { Scrollspy } from './Scrollspy' +import { Spinner } from './Spinner' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -63,4 +64,5 @@ export function register(): void { customElements.define('tc-progress', Progress) customElements.define('tc-progress-bar', ProgressBar) customElements.define('tc-scrollspy', Scrollspy) + customElements.define('tc-spinner', Spinner) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index dcc0e10d..3fc5b272 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -25,3 +25,4 @@ @forward 'popover'; @forward 'progress'; @forward 'scrollspy'; +@forward 'spinner'; diff --git a/web-components/style/components/_spinner.scss b/web-components/style/components/_spinner.scss new file mode 100644 index 00000000..15338b60 --- /dev/null +++ b/web-components/style/components/_spinner.scss @@ -0,0 +1 @@ +// tc-spinner — theme tweaks only; Bootstrap spinner classes handle layout. From ec0bf427315cb095d64f9b251c09508ec2a0aaeb Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 20:55:22 +0000 Subject: [PATCH 031/632] 030-tc-toast: Implement the tc-toast Web Component --- examples/src/web-components/ToastDemo.tsx | 110 ++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Toast.ts | 179 ++++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + web-components/style/components/_toast.scss | 1 + 7 files changed, 296 insertions(+) create mode 100644 examples/src/web-components/ToastDemo.tsx create mode 100644 web-components/src/Toast.ts create mode 100644 web-components/style/components/_toast.scss diff --git a/examples/src/web-components/ToastDemo.tsx b/examples/src/web-components/ToastDemo.tsx new file mode 100644 index 00000000..e4b1319a --- /dev/null +++ b/examples/src/web-components/ToastDemo.tsx @@ -0,0 +1,110 @@ +import React, { useRef } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const ToastDemo: React.FC = () => { + const basicRef = useRef(null) + const successRef = useRef(null) + const dangerRef = useRef(null) + const noAutohideRef = useRef(null) + const noTitleRef = useRef(null) + + return ( +
    +
    +
    +
    + Web Components} + title="Toast" + description="Bootstrap Toast plugin wrapper. Use the title attribute for the header, variant for theme colour, and children for the body. Autohides after delay (default 5 s) unless autohide="false"." + /> + +
    + + +
    + {/* @ts-ignore */} + + Your changes have been saved. + {/* @ts-ignore */} + +
    +
    + + + +
    + {/* @ts-ignore */} + + Operation completed successfully. + {/* @ts-ignore */} + +
    +
    + + + +
    + {/* @ts-ignore */} + + Something went wrong. Please try again. + {/* @ts-ignore */} + +
    +
    + + + +
    + {/* @ts-ignore */} + + This toast stays open until you close it manually. + {/* @ts-ignore */} + +
    +
    + + + +
    + {/* @ts-ignore */} + + No header — just a simple message. + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default ToastDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 83aee8e1..258052a5 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -24,6 +24,7 @@ import PopoverDemo from './PopoverDemo' import ProgressDemo from './ProgressDemo' import ScrollspyDemo from './ScrollspyDemo' import SpinnerDemo from './SpinnerDemo' +import ToastDemo from './ToastDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -64,6 +65,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'modal', category: 'Overlays & Feedback', element: }, { key: 'offcanvas', category: 'Overlays & Feedback', element: }, { key: 'popover', category: 'Overlays & Feedback', element: }, + { key: 'toast', category: 'Overlays & Feedback', element: }, { key: 'placeholder', category: 'Components', element: }, { key: 'progress', category: 'Components', element: }, { key: 'spinner', category: 'Components', element: }, diff --git a/web-components/src/Toast.ts b/web-components/src/Toast.ts new file mode 100644 index 00000000..8323b383 --- /dev/null +++ b/web-components/src/Toast.ts @@ -0,0 +1,179 @@ +import { Toast as BsToast } from 'bootstrap' + +const TAG_NAME = 'tc-toast' + +export class Toast extends HTMLElement { + + private _bsToast: BsToast | null = null + private _bodyNodes: Node[] = [] + private _initialised = false + private _syncing = false + + static get observedAttributes(): string[] { + return ['open', 'autohide', 'delay', 'variant', 'title'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + this._bodyNodes = Array.from(this.childNodes) + this._initialised = true + } + this.render() + this._initPlugin() + if (this.open) this._bsToast?.show() + } + + disconnectedCallback(): void { + this._teardown() + } + + attributeChangedCallback(name: string): void { + if (!this.isConnected || !this._initialised || this._syncing) return + if (name === 'open') { + if (this.open) { + this._bsToast?.show() + } else { + this._bsToast?.hide() + } + } else { + this._teardown() + this.render() + this._initPlugin() + if (this.open) this._bsToast?.show() + } + } + + get open(): boolean { + return this.hasAttribute('open') + } + set open(v: boolean) { + if (v) this.setAttribute('open', '') + else this.removeAttribute('open') + } + + get autohide(): boolean { + return this.getAttribute('autohide') !== 'false' + } + set autohide(v: boolean) { + this.setAttribute('autohide', String(v)) + } + + get delay(): number { + const d = parseInt(this.getAttribute('delay') ?? '', 10) + return isNaN(d) ? 5000 : d + } + set delay(v: number) { + this.setAttribute('delay', String(v)) + } + + get variant(): string { + return this.getAttribute('variant') ?? '' + } + set variant(v: string) { + if (v) this.setAttribute('variant', v) + else this.removeAttribute('variant') + } + + get title(): string { + return this.getAttribute('title') ?? '' + } + set title(v: string) { + this.setAttribute('title', v) + } + + show(): void { + this._bsToast?.show() + } + + hide(): void { + this._bsToast?.hide() + } + + private _onShow = (): void => { + this._syncing = true + this.setAttribute('open', '') + this._syncing = false + this.dispatchEvent(new CustomEvent('tc-show', { bubbles: true, composed: true })) + } + + private _onShown = (): void => { + this.dispatchEvent(new CustomEvent('tc-shown', { bubbles: true, composed: true })) + } + + private _onHide = (): void => { + this._syncing = true + this.removeAttribute('open') + this._syncing = false + this.dispatchEvent(new CustomEvent('tc-hide', { bubbles: true, composed: true })) + } + + private _onHidden = (): void => { + this.dispatchEvent(new CustomEvent('tc-hidden', { bubbles: true, composed: true })) + } + + private render(): void { + const variant = this.getAttribute('variant') + const titleText = this._escapeHtml(this.getAttribute('title') ?? '') + const hasTitle = titleText.length > 0 + + const classes = ['toast', 'fade'] + if (variant) classes.push(`text-bg-${variant}`) + this.className = classes.join(' ') + + this.setAttribute('role', 'alert') + this.setAttribute('aria-live', 'assertive') + this.setAttribute('aria-atomic', 'true') + + const headerHtml = hasTitle + ? `
    ` + + `${titleText}` + + `` + + `
    ` + : '' + + this.innerHTML = headerHtml + `
    ` + + const body = this.querySelector('.toast-body') + if (body) this._bodyNodes.forEach(n => body.appendChild(n)) + } + + private _escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + } + + private _initPlugin(): void { + this._bsToast = new BsToast(this, { + autohide: this.autohide, + delay: this.delay, + }) + this.addEventListener('show.bs.toast', this._onShow) + this.addEventListener('shown.bs.toast', this._onShown) + this.addEventListener('hide.bs.toast', this._onHide) + this.addEventListener('hidden.bs.toast', this._onHidden) + } + + private _teardown(): void { + this.removeEventListener('show.bs.toast', this._onShow) + this.removeEventListener('shown.bs.toast', this._onShown) + this.removeEventListener('hide.bs.toast', this._onHide) + this.removeEventListener('hidden.bs.toast', this._onHidden) + if (this._bsToast) { + this._bsToast.dispose() + this._bsToast = null + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Toast + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 5969d5bd..0dab7ef4 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -30,3 +30,4 @@ export * from './Progress' export * from './ProgressBar' export * from './Scrollspy' export * from './Spinner' +export * from './Toast' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 8ae3069c..b89db86c 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -29,6 +29,7 @@ import { Progress } from './Progress' import { ProgressBar } from './ProgressBar' import { Scrollspy } from './Scrollspy' import { Spinner } from './Spinner' +import { Toast } from './Toast' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -65,4 +66,5 @@ export function register(): void { customElements.define('tc-progress-bar', ProgressBar) customElements.define('tc-scrollspy', Scrollspy) customElements.define('tc-spinner', Spinner) + customElements.define('tc-toast', Toast) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 3fc5b272..9ec7d9c2 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -26,3 +26,4 @@ @forward 'progress'; @forward 'scrollspy'; @forward 'spinner'; +@forward 'toast'; diff --git a/web-components/style/components/_toast.scss b/web-components/style/components/_toast.scss new file mode 100644 index 00000000..64b2f4c1 --- /dev/null +++ b/web-components/style/components/_toast.scss @@ -0,0 +1 @@ +// tc-toast — theme tweaks only; Bootstrap toast classes handle layout. From 903f02ebb4c025696ab530fea89e640daebe6015 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 21:08:00 +0000 Subject: [PATCH 032/632] 031-tc-tooltip: Implement the tc-tooltip Web Component --- examples/src/web-components/TooltipDemo.tsx | 77 ++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Tooltip.ts | 145 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + web-components/style/components/_tooltip.scss | 1 + 7 files changed, 229 insertions(+) create mode 100644 examples/src/web-components/TooltipDemo.tsx create mode 100644 web-components/src/Tooltip.ts create mode 100644 web-components/style/components/_tooltip.scss diff --git a/examples/src/web-components/TooltipDemo.tsx b/examples/src/web-components/TooltipDemo.tsx new file mode 100644 index 00000000..004823b3 --- /dev/null +++ b/examples/src/web-components/TooltipDemo.tsx @@ -0,0 +1,77 @@ +import React, { useRef } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const TooltipDemo: React.FC = () => { + const manualRef = useRef(null) + + return ( +
    +
    +
    +
    + Web Components} + title="Tooltip" + description="Bootstrap Tooltip plugin wrapper. Wrap any trigger element with tc-tooltip and use the title attribute to control the tooltip text." + /> + +
    + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + + +
    + {(['top', 'right', 'bottom', 'left'] as const).map(p => ( + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + ))} +
    +
    + + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + + +
    + + + +
    + {/* @ts-ignore */} + + + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    + ) +} + +export default TooltipDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 258052a5..9e803b80 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -25,6 +25,7 @@ import ProgressDemo from './ProgressDemo' import ScrollspyDemo from './ScrollspyDemo' import SpinnerDemo from './SpinnerDemo' import ToastDemo from './ToastDemo' +import TooltipDemo from './TooltipDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -65,6 +66,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'modal', category: 'Overlays & Feedback', element: }, { key: 'offcanvas', category: 'Overlays & Feedback', element: }, { key: 'popover', category: 'Overlays & Feedback', element: }, + { key: 'tooltip', category: 'Overlays & Feedback', element: }, { key: 'toast', category: 'Overlays & Feedback', element: }, { key: 'placeholder', category: 'Components', element: }, { key: 'progress', category: 'Components', element: }, diff --git a/web-components/src/Tooltip.ts b/web-components/src/Tooltip.ts new file mode 100644 index 00000000..00683e69 --- /dev/null +++ b/web-components/src/Tooltip.ts @@ -0,0 +1,145 @@ +import { Tooltip as BsTooltip } from 'bootstrap' + +const TAG_NAME = 'tc-tooltip' + +const VALID_PLACEMENTS = ['top', 'right', 'bottom', 'left', 'auto'] + +export class Tooltip extends HTMLElement { + + private _bsTooltip: BsTooltip | null = null + private _triggerEl: HTMLElement | null = null + private _initialised = false + + static get observedAttributes(): string[] { + return ['title', 'content', 'placement', 'trigger', 'html'] + } + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + this._initialised = true + } + this._initPlugin() + } + + disconnectedCallback(): void { + this._teardown() + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this._teardown() + this._initPlugin() + } + + get title(): string { + return this.getAttribute('title') ?? '' + } + set title(v: string) { + this.setAttribute('title', v) + } + + get content(): string { + return this.getAttribute('content') ?? '' + } + set content(v: string) { + this.setAttribute('content', v) + } + + get placement(): string { + return this.getAttribute('placement') ?? 'auto' + } + set placement(v: string) { + this.setAttribute('placement', v) + } + + get trigger(): string { + return this.getAttribute('trigger') ?? 'hover focus' + } + set trigger(v: string) { + this.setAttribute('trigger', v) + } + + get html(): boolean { + return this.hasAttribute('html') + } + set html(v: boolean) { + if (v) this.setAttribute('html', '') + else this.removeAttribute('html') + } + + show(): void { + this._bsTooltip?.show() + } + + hide(): void { + this._bsTooltip?.hide() + } + + toggle(): void { + this._bsTooltip?.toggle() + } + + private _onShow = (): void => { + this.dispatchEvent(new CustomEvent('tc-show', { bubbles: true, composed: true })) + } + + private _onShown = (): void => { + this.dispatchEvent(new CustomEvent('tc-shown', { bubbles: true, composed: true })) + } + + private _onHide = (): void => { + this.dispatchEvent(new CustomEvent('tc-hide', { bubbles: true, composed: true })) + } + + private _onHidden = (): void => { + this.dispatchEvent(new CustomEvent('tc-hidden', { bubbles: true, composed: true })) + } + + private _initPlugin(): void { + const triggerEl = this.firstElementChild as HTMLElement | null + if (!triggerEl) return + this._triggerEl = triggerEl + + const rawPlacement = this.getAttribute('placement') ?? 'auto' + const placement = VALID_PLACEMENTS.includes(rawPlacement) ? rawPlacement : 'auto' + + const titleVal = this.getAttribute('title') ?? this.getAttribute('content') ?? '' + + this._bsTooltip = new BsTooltip(triggerEl, { + title: titleVal, + placement: placement as any, + trigger: (this.getAttribute('trigger') ?? 'hover focus') as any, + html: this.hasAttribute('html'), + }) + + triggerEl.addEventListener('show.bs.tooltip', this._onShow) + triggerEl.addEventListener('shown.bs.tooltip', this._onShown) + triggerEl.addEventListener('hide.bs.tooltip', this._onHide) + triggerEl.addEventListener('hidden.bs.tooltip', this._onHidden) + } + + private _teardown(): void { + const triggerEl = this._triggerEl + if (triggerEl) { + triggerEl.removeEventListener('show.bs.tooltip', this._onShow) + triggerEl.removeEventListener('shown.bs.tooltip', this._onShown) + triggerEl.removeEventListener('hide.bs.tooltip', this._onHide) + triggerEl.removeEventListener('hidden.bs.tooltip', this._onHidden) + } + if (this._bsTooltip) { + this._bsTooltip.dispose() + this._bsTooltip = null + } + this._triggerEl = null + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Tooltip + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 0dab7ef4..d22691a8 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -31,3 +31,4 @@ export * from './ProgressBar' export * from './Scrollspy' export * from './Spinner' export * from './Toast' +export * from './Tooltip' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index b89db86c..0e4d531b 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -30,6 +30,7 @@ import { ProgressBar } from './ProgressBar' import { Scrollspy } from './Scrollspy' import { Spinner } from './Spinner' import { Toast } from './Toast' +import { Tooltip } from './Tooltip' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -67,4 +68,5 @@ export function register(): void { customElements.define('tc-scrollspy', Scrollspy) customElements.define('tc-spinner', Spinner) customElements.define('tc-toast', Toast) + customElements.define('tc-tooltip', Tooltip) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 9ec7d9c2..f4de7607 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -27,3 +27,4 @@ @forward 'scrollspy'; @forward 'spinner'; @forward 'toast'; +@forward 'tooltip'; diff --git a/web-components/style/components/_tooltip.scss b/web-components/style/components/_tooltip.scss new file mode 100644 index 00000000..80e3dc3e --- /dev/null +++ b/web-components/style/components/_tooltip.scss @@ -0,0 +1 @@ +// tc-tooltip — theme tweaks only; Bootstrap tooltip classes handle layout. From 9e20e7a6ff0b83a601f622dc55bfa4291f4c0299 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 11 Jun 2026 21:12:25 +0000 Subject: [PATCH 033/632] 032-tc-input: Implement the tc-input Web Component --- examples/src/web-components/InputDemo.tsx | 71 ++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Input.ts | 174 ++++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + web-components/style/components/_input.scss | 1 + 7 files changed, 252 insertions(+) create mode 100644 examples/src/web-components/InputDemo.tsx create mode 100644 web-components/src/Input.ts create mode 100644 web-components/style/components/_input.scss diff --git a/examples/src/web-components/InputDemo.tsx b/examples/src/web-components/InputDemo.tsx new file mode 100644 index 00000000..ea72ac1e --- /dev/null +++ b/examples/src/web-components/InputDemo.tsx @@ -0,0 +1,71 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const InputDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="Input" + description="Bootstrap form-control wrapper with label, sizes, validation states, and help text." + /> + +
    + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    +) + +export default InputDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 9e803b80..35fed3f6 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -26,6 +26,7 @@ import ScrollspyDemo from './ScrollspyDemo' import SpinnerDemo from './SpinnerDemo' import ToastDemo from './ToastDemo' import TooltipDemo from './TooltipDemo' +import InputDemo from './InputDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -72,4 +73,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'progress', category: 'Components', element: }, { key: 'spinner', category: 'Components', element: }, { key: 'scrollspy', category: 'Navigation', element: }, + { key: 'input', category: 'Forms', element: }, ] diff --git a/web-components/src/Input.ts b/web-components/src/Input.ts new file mode 100644 index 00000000..055d70fe --- /dev/null +++ b/web-components/src/Input.ts @@ -0,0 +1,174 @@ +const TAG_NAME = 'tc-input' + +let _idCounter = 0 + +export type InputSize = 'sm' | 'lg' +export type InputState = 'valid' | 'invalid' + +const SIZES: InputSize[] = ['sm', 'lg'] +const STATES: InputState[] = ['valid', 'invalid'] + +export class Input extends HTMLElement { + + private _inputId: string + private _helpId: string + private _initialised = false + + static get observedAttributes(): string[] { + return ['type', 'value', 'placeholder', 'label', 'size', 'disabled', 'readonly', 'required', 'state', 'help'] + } + + constructor() { + super() + const uid = ++_idCounter + this._inputId = `tc-input-${uid}` + this._helpId = `tc-input-help-${uid}` + } + + connectedCallback(): void { + this.render() + this._initialised = true + } + + attributeChangedCallback(name: string, _old: string | null, next: string | null): void { + if (!this.isConnected || !this._initialised) return + if (name === 'value') { + const input = this.querySelector('input') + if (input && input.value !== (next ?? '')) input.value = next ?? '' + return + } + this.render() + } + + get type(): string { + return this.getAttribute('type') ?? 'text' + } + set type(v: string) { + this.setAttribute('type', v) + } + + get value(): string { + return this.querySelector('input')?.value ?? this.getAttribute('value') ?? '' + } + set value(v: string) { + const input = this.querySelector('input') + if (input) input.value = v + this.setAttribute('value', v) + } + + get placeholder(): string { + return this.getAttribute('placeholder') ?? '' + } + set placeholder(v: string) { + this.setAttribute('placeholder', v) + } + + get label(): string | null { + return this.getAttribute('label') + } + set label(v: string | null) { + if (v != null) this.setAttribute('label', v) + else this.removeAttribute('label') + } + + get size(): InputSize | null { + const v = this.getAttribute('size') as InputSize + return SIZES.includes(v) ? v : null + } + set size(v: InputSize | null) { + if (v != null) this.setAttribute('size', v) + else this.removeAttribute('size') + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + get readonly(): boolean { + return this.hasAttribute('readonly') + } + set readonly(v: boolean) { + if (v) this.setAttribute('readonly', '') + else this.removeAttribute('readonly') + } + + get required(): boolean { + return this.hasAttribute('required') + } + set required(v: boolean) { + if (v) this.setAttribute('required', '') + else this.removeAttribute('required') + } + + get state(): InputState | null { + const v = this.getAttribute('state') as InputState + return STATES.includes(v) ? v : null + } + set state(v: InputState | null) { + if (v != null) this.setAttribute('state', v) + else this.removeAttribute('state') + } + + get help(): string | null { + return this.getAttribute('help') + } + set help(v: string | null) { + if (v != null) this.setAttribute('help', v) + else this.removeAttribute('help') + } + + private render(): void { + const label = this.label + const size = this.size + const state = this.state + const help = this.help + const disabled = this.disabled + const readonly = this.readonly + const required = this.required + const placeholder = this.placeholder + const currentValue = this.querySelector('input')?.value ?? this.getAttribute('value') ?? '' + + const sizeClass = size ? ` form-control-${size}` : '' + const stateClass = state === 'valid' ? ' is-valid' : state === 'invalid' ? ' is-invalid' : '' + const ariaDescribedBy = help ? ` aria-describedby="${this._helpId}"` : '' + const disabledAttr = disabled ? ' disabled' : '' + const readonlyAttr = readonly ? ' readonly' : '' + const requiredAttr = required ? ' required' : '' + + const labelHtml = label + ? `` + : '' + + const feedbackHtml = state === 'valid' + ? `
    Looks good!
    ` + : state === 'invalid' + ? `
    Please provide a valid value.
    ` + : '' + + const helpHtml = help + ? `
    ${esc(help)}
    ` + : '' + + this.innerHTML = [ + labelHtml, + ``, + feedbackHtml, + helpHtml, + ].join('') + } +} + +function esc(str: string): string { + return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Input + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index d22691a8..b0c7a8b6 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -32,3 +32,4 @@ export * from './Scrollspy' export * from './Spinner' export * from './Toast' export * from './Tooltip' +export * from './Input' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 0e4d531b..1fc95058 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -31,6 +31,7 @@ import { Scrollspy } from './Scrollspy' import { Spinner } from './Spinner' import { Toast } from './Toast' import { Tooltip } from './Tooltip' +import { Input } from './Input' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -69,4 +70,5 @@ export function register(): void { customElements.define('tc-spinner', Spinner) customElements.define('tc-toast', Toast) customElements.define('tc-tooltip', Tooltip) + customElements.define('tc-input', Input) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index f4de7607..b0273597 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -28,3 +28,4 @@ @forward 'spinner'; @forward 'toast'; @forward 'tooltip'; +@forward 'input'; diff --git a/web-components/style/components/_input.scss b/web-components/style/components/_input.scss new file mode 100644 index 00000000..4cda7e34 --- /dev/null +++ b/web-components/style/components/_input.scss @@ -0,0 +1 @@ +// tc-input — theme tweaks only; Bootstrap form-control classes handle layout. From a18fbc83f920fd535a129e082274215769ed28b8 Mon Sep 17 00:00:00 2001 From: kalevski Date: Fri, 12 Jun 2026 00:45:14 +0000 Subject: [PATCH 034/632] 033-tc-textarea: Implement the tc-textarea Web Component --- examples/src/web-components/TextareaDemo.tsx | 78 ++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Textarea.ts | 177 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_textarea.scss | 1 + 7 files changed, 262 insertions(+) create mode 100644 examples/src/web-components/TextareaDemo.tsx create mode 100644 web-components/src/Textarea.ts create mode 100644 web-components/style/components/_textarea.scss diff --git a/examples/src/web-components/TextareaDemo.tsx b/examples/src/web-components/TextareaDemo.tsx new file mode 100644 index 00000000..65c164cd --- /dev/null +++ b/examples/src/web-components/TextareaDemo.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const TextareaDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="Textarea" + description="Bootstrap form-control multiline wrapper with label, rows, sizes, validation states, and help text." + /> + +
    + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    +) + +export default TextareaDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 35fed3f6..b6bd9aba 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -27,6 +27,7 @@ import SpinnerDemo from './SpinnerDemo' import ToastDemo from './ToastDemo' import TooltipDemo from './TooltipDemo' import InputDemo from './InputDemo' +import TextareaDemo from './TextareaDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -74,4 +75,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'spinner', category: 'Components', element: }, { key: 'scrollspy', category: 'Navigation', element: }, { key: 'input', category: 'Forms', element: }, + { key: 'textarea', category: 'Forms', element: }, ] diff --git a/web-components/src/Textarea.ts b/web-components/src/Textarea.ts new file mode 100644 index 00000000..42d7a6d8 --- /dev/null +++ b/web-components/src/Textarea.ts @@ -0,0 +1,177 @@ +const TAG_NAME = 'tc-textarea' + +let _idCounter = 0 + +export type TextareaSize = 'sm' | 'lg' +export type TextareaState = 'valid' | 'invalid' + +const SIZES: TextareaSize[] = ['sm', 'lg'] +const STATES: TextareaState[] = ['valid', 'invalid'] + +export class Textarea extends HTMLElement { + + private _textareaId: string + private _helpId: string + private _initialised = false + + static get observedAttributes(): string[] { + return ['value', 'placeholder', 'label', 'size', 'disabled', 'readonly', 'required', 'state', 'help', 'rows'] + } + + constructor() { + super() + const uid = ++_idCounter + this._textareaId = `tc-textarea-${uid}` + this._helpId = `tc-textarea-help-${uid}` + } + + connectedCallback(): void { + this.render() + this._initialised = true + } + + attributeChangedCallback(name: string, _old: string | null, next: string | null): void { + if (!this.isConnected || !this._initialised) return + if (name === 'value') { + const ta = this.querySelector('textarea') + if (ta && ta.value !== (next ?? '')) ta.value = next ?? '' + return + } + this.render() + } + + get value(): string { + return this.querySelector('textarea')?.value ?? this.getAttribute('value') ?? '' + } + set value(v: string) { + const ta = this.querySelector('textarea') + if (ta) ta.value = v + this.setAttribute('value', v) + } + + get placeholder(): string { + return this.getAttribute('placeholder') ?? '' + } + set placeholder(v: string) { + this.setAttribute('placeholder', v) + } + + get label(): string | null { + return this.getAttribute('label') + } + set label(v: string | null) { + if (v != null) this.setAttribute('label', v) + else this.removeAttribute('label') + } + + get size(): TextareaSize | null { + const v = this.getAttribute('size') as TextareaSize + return SIZES.includes(v) ? v : null + } + set size(v: TextareaSize | null) { + if (v != null) this.setAttribute('size', v) + else this.removeAttribute('size') + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + get readonly(): boolean { + return this.hasAttribute('readonly') + } + set readonly(v: boolean) { + if (v) this.setAttribute('readonly', '') + else this.removeAttribute('readonly') + } + + get required(): boolean { + return this.hasAttribute('required') + } + set required(v: boolean) { + if (v) this.setAttribute('required', '') + else this.removeAttribute('required') + } + + get state(): TextareaState | null { + const v = this.getAttribute('state') as TextareaState + return STATES.includes(v) ? v : null + } + set state(v: TextareaState | null) { + if (v != null) this.setAttribute('state', v) + else this.removeAttribute('state') + } + + get help(): string | null { + return this.getAttribute('help') + } + set help(v: string | null) { + if (v != null) this.setAttribute('help', v) + else this.removeAttribute('help') + } + + get rows(): number { + const v = parseInt(this.getAttribute('rows') ?? '', 10) + return isNaN(v) || v < 1 ? 3 : v + } + set rows(v: number) { + this.setAttribute('rows', String(v)) + } + + private render(): void { + const label = this.label + const size = this.size + const state = this.state + const help = this.help + const disabled = this.disabled + const readonly = this.readonly + const required = this.required + const placeholder = this.placeholder + const rows = this.rows + const currentValue = this.querySelector('textarea')?.value ?? this.getAttribute('value') ?? '' + + const sizeClass = size ? ` form-control-${size}` : '' + const stateClass = state === 'valid' ? ' is-valid' : state === 'invalid' ? ' is-invalid' : '' + const ariaDescribedBy = help ? ` aria-describedby="${this._helpId}"` : '' + const disabledAttr = disabled ? ' disabled' : '' + const readonlyAttr = readonly ? ' readonly' : '' + const requiredAttr = required ? ' required' : '' + + const labelHtml = label + ? `` + : '' + + const feedbackHtml = state === 'valid' + ? `
    Looks good!
    ` + : state === 'invalid' + ? `
    Please provide a valid value.
    ` + : '' + + const helpHtml = help + ? `
    ${esc(help)}
    ` + : '' + + this.innerHTML = [ + labelHtml, + ``, + feedbackHtml, + helpHtml, + ].join('') + } +} + +function esc(str: string): string { + return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Textarea + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index b0c7a8b6..b659573c 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -33,3 +33,4 @@ export * from './Spinner' export * from './Toast' export * from './Tooltip' export * from './Input' +export * from './Textarea' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 1fc95058..c961d0fa 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -32,6 +32,7 @@ import { Spinner } from './Spinner' import { Toast } from './Toast' import { Tooltip } from './Tooltip' import { Input } from './Input' +import { Textarea } from './Textarea' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -71,4 +72,5 @@ export function register(): void { customElements.define('tc-toast', Toast) customElements.define('tc-tooltip', Tooltip) customElements.define('tc-input', Input) + customElements.define('tc-textarea', Textarea) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index b0273597..580f3ea4 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -29,3 +29,4 @@ @forward 'toast'; @forward 'tooltip'; @forward 'input'; +@forward 'textarea'; diff --git a/web-components/style/components/_textarea.scss b/web-components/style/components/_textarea.scss new file mode 100644 index 00000000..b7ed8ca5 --- /dev/null +++ b/web-components/style/components/_textarea.scss @@ -0,0 +1 @@ +// tc-textarea — theme tweaks only; Bootstrap form-control classes handle layout. From d23769e561cc2271a7360a17ce592c8586743c7e Mon Sep 17 00:00:00 2001 From: kalevski Date: Fri, 12 Jun 2026 00:55:04 +0000 Subject: [PATCH 035/632] 034-tc-select: Implement the tc-select (+ tc-option) Web Component --- examples/src/web-components/SelectDemo.tsx | 131 ++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Option.ts | 50 +++++ web-components/src/Select.ts | 206 +++++++++++++++++++ web-components/src/index.ts | 2 + web-components/src/register.ts | 4 + web-components/style/components/_index.scss | 1 + web-components/style/components/_select.scss | 8 + 8 files changed, 404 insertions(+) create mode 100644 examples/src/web-components/SelectDemo.tsx create mode 100644 web-components/src/Option.ts create mode 100644 web-components/src/Select.ts create mode 100644 web-components/style/components/_select.scss diff --git a/examples/src/web-components/SelectDemo.tsx b/examples/src/web-components/SelectDemo.tsx new file mode 100644 index 00000000..bc507990 --- /dev/null +++ b/examples/src/web-components/SelectDemo.tsx @@ -0,0 +1,131 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const SelectDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="Select" + description="Bootstrap form-select wrapper with label, sizes, validation states, and tc-option children." + /> + +
    + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + Choose a country… + {/* @ts-ignore */} + United States + {/* @ts-ignore */} + United Kingdom + {/* @ts-ignore */} + Germany + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + No label + {/* @ts-ignore */} + Option B + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + Option 1 + {/* @ts-ignore */} + Option 2 + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Option 1 + {/* @ts-ignore */} + Option 2 + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Option 1 + {/* @ts-ignore */} + Option 2 + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + United Kingdom + {/* @ts-ignore */} + United States + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + Choose one… + {/* @ts-ignore */} + Option A + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + Option A + {/* @ts-ignore */} + Option B (disabled) + {/* @ts-ignore */} + Option C + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + HTML + {/* @ts-ignore */} + CSS + {/* @ts-ignore */} + JavaScript + {/* @ts-ignore */} + TypeScript + {/* @ts-ignore */} + Rust + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    +) + +export default SelectDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index b6bd9aba..1cc81931 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -28,6 +28,7 @@ import ToastDemo from './ToastDemo' import TooltipDemo from './TooltipDemo' import InputDemo from './InputDemo' import TextareaDemo from './TextareaDemo' +import SelectDemo from './SelectDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -76,4 +77,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'scrollspy', category: 'Navigation', element: }, { key: 'input', category: 'Forms', element: }, { key: 'textarea', category: 'Forms', element: }, + { key: 'select', category: 'Forms', element: }, ] diff --git a/web-components/src/Option.ts b/web-components/src/Option.ts new file mode 100644 index 00000000..58418285 --- /dev/null +++ b/web-components/src/Option.ts @@ -0,0 +1,50 @@ +const TAG_NAME = 'tc-option' + +export class Option extends HTMLElement { + + static get observedAttributes(): string[] { + return ['value', 'selected', 'disabled'] + } + + connectedCallback(): void { + this._notifyParent() + } + + attributeChangedCallback(): void { + if (this.isConnected) this._notifyParent() + } + + get value(): string { + return this.getAttribute('value') ?? '' + } + set value(v: string) { + this.setAttribute('value', v) + } + + get selected(): boolean { + return this.hasAttribute('selected') + } + set selected(v: boolean) { + if (v) this.setAttribute('selected', '') + else this.removeAttribute('selected') + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + private _notifyParent(): void { + const parent = this.closest('tc-select') + if (parent) (parent as any)._scheduleRender() + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Option + } +} diff --git a/web-components/src/Select.ts b/web-components/src/Select.ts new file mode 100644 index 00000000..5140c6c1 --- /dev/null +++ b/web-components/src/Select.ts @@ -0,0 +1,206 @@ +const TAG_NAME = 'tc-select' + +let _idCounter = 0 + +export type SelectSize = 'sm' | 'lg' +export type SelectState = 'valid' | 'invalid' + +const SIZES: SelectSize[] = ['sm', 'lg'] +const STATES: SelectState[] = ['valid', 'invalid'] + +interface OptionData { + value: string + label: string + selected: boolean + disabled: boolean +} + +export class Select extends HTMLElement { + + private _selectId: string + private _optionData: OptionData[] = [] + private _renderPending = false + private _initialised = false + + static get observedAttributes(): string[] { + return ['value', 'multiple', 'size', 'disabled', 'state', 'label'] + } + + constructor() { + super() + this._selectId = `tc-select-${++_idCounter}` + } + + connectedCallback(): void { + this.addEventListener('change', this._onNativeChange) + this._scheduleRender() + } + + disconnectedCallback(): void { + this.removeEventListener('change', this._onNativeChange) + } + + attributeChangedCallback(name: string, _old: string | null, next: string | null): void { + if (!this.isConnected || !this._initialised) return + if (name === 'value') { + this._syncValue(next) + return + } + this.render() + } + + // Called by tc-option children when they connect or change. + _scheduleRender(): void { + if (this._renderPending) return + this._renderPending = true + Promise.resolve().then(() => { + this._renderPending = false + if (this.isConnected) { + this.render() + this._initialised = true + } + }) + } + + get value(): string { + return this.querySelector('select')?.value ?? this.getAttribute('value') ?? '' + } + set value(v: string) { + const sel = this.querySelector('select') + if (sel) sel.value = v + this.setAttribute('value', v) + } + + get multiple(): boolean { + return this.hasAttribute('multiple') + } + set multiple(v: boolean) { + if (v) this.setAttribute('multiple', '') + else this.removeAttribute('multiple') + } + + get size(): SelectSize | null { + const v = this.getAttribute('size') as SelectSize + return SIZES.includes(v) ? v : null + } + set size(v: SelectSize | null) { + if (v != null) this.setAttribute('size', v) + else this.removeAttribute('size') + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + get state(): SelectState | null { + const v = this.getAttribute('state') as SelectState + return STATES.includes(v) ? v : null + } + set state(v: SelectState | null) { + if (v != null) this.setAttribute('state', v) + else this.removeAttribute('state') + } + + get label(): string | null { + return this.getAttribute('label') + } + set label(v: string | null) { + if (v != null) this.setAttribute('label', v) + else this.removeAttribute('label') + } + + private _syncValue(next: string | null): void { + const sel = this.querySelector('select') + if (sel) sel.value = next ?? '' + } + + private _onNativeChange = (): void => { + const sel = this.querySelector('select') + if (sel) this.setAttribute('value', sel.value) + } + + private render(): void { + const label = this.label + const size = this.size + const state = this.state + const multiple = this.multiple + const disabled = this.disabled + const currentValue = this.querySelector('select')?.value ?? this.getAttribute('value') ?? '' + + // Snapshot tc-option / native option direct-children whenever they are present. + // After this render they will be destroyed by innerHTML; _optionData persists. + const hasOptionChildren = Array.from(this.children).some(c => { + const t = c.tagName.toLowerCase() + return t === 'tc-option' || t === 'option' + }) + if (hasOptionChildren) { + this._optionData = [] + for (const child of Array.from(this.children)) { + const t = child.tagName.toLowerCase() + if (t === 'tc-option') { + this._optionData.push({ + value: child.getAttribute('value') ?? '', + label: child.textContent?.trim() ?? '', + selected: child.hasAttribute('selected'), + disabled: child.hasAttribute('disabled'), + }) + } else if (t === 'option') { + const opt = child as HTMLOptionElement + this._optionData.push({ + value: opt.value, + label: opt.text, + selected: opt.defaultSelected, + disabled: opt.disabled, + }) + } + } + } + + const sizeClass = size ? ` form-select-${size}` : '' + const stateClass = state === 'valid' ? ' is-valid' : state === 'invalid' ? ' is-invalid' : '' + const disabledAttr = disabled ? ' disabled' : '' + const multipleAttr = multiple ? ' multiple' : '' + + const labelHtml = label + ? `` + : '' + + const optionsHtml = this._optionData.map(opt => { + const sel = opt.selected ? ' selected' : '' + const dis = opt.disabled ? ' disabled' : '' + return `` + }).join('') + + const feedbackHtml = state === 'valid' + ? `
    Looks good!
    ` + : state === 'invalid' + ? `
    Please provide a valid selection.
    ` + : '' + + this.innerHTML = [ + labelHtml, + ``, + feedbackHtml, + ].join('') + + // Restore the selected value from before the re-render. + const sel = this.querySelector('select') + if (sel && currentValue) sel.value = currentValue + } +} + +function esc(str: string): string { + return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Select + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index b659573c..db6b4657 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -34,3 +34,5 @@ export * from './Toast' export * from './Tooltip' export * from './Input' export * from './Textarea' +export * from './Select' +export * from './Option' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index c961d0fa..18c9618d 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -33,6 +33,8 @@ import { Toast } from './Toast' import { Tooltip } from './Tooltip' import { Input } from './Input' import { Textarea } from './Textarea' +import { Select } from './Select' +import { Option } from './Option' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -73,4 +75,6 @@ export function register(): void { customElements.define('tc-tooltip', Tooltip) customElements.define('tc-input', Input) customElements.define('tc-textarea', Textarea) + customElements.define('tc-select', Select) + customElements.define('tc-option', Option) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 580f3ea4..972b72de 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -30,3 +30,4 @@ @forward 'tooltip'; @forward 'input'; @forward 'textarea'; +@forward 'select'; diff --git a/web-components/style/components/_select.scss b/web-components/style/components/_select.scss new file mode 100644 index 00000000..2c266b6e --- /dev/null +++ b/web-components/style/components/_select.scss @@ -0,0 +1,8 @@ +// tc-select — theme tweaks only; Bootstrap form-select classes handle layout. +// The `size` attribute maps to Bootstrap's visual-size classes (form-select-sm / +// form-select-lg), NOT to the native directly. + +tc-option { + display: none; +} From abc1b37c14d5df22e4b914764259e212816dbe32 Mon Sep 17 00:00:00 2001 From: kalevski Date: Fri, 12 Jun 2026 00:58:37 +0000 Subject: [PATCH 036/632] 035-tc-check: Implement the tc-check Web Component --- examples/src/web-components/CheckDemo.tsx | 76 ++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Check.ts | 160 ++++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_check.scss | 1 + web-components/style/components/_index.scss | 1 + 7 files changed, 243 insertions(+) create mode 100644 examples/src/web-components/CheckDemo.tsx create mode 100644 web-components/src/Check.ts create mode 100644 web-components/style/components/_check.scss diff --git a/examples/src/web-components/CheckDemo.tsx b/examples/src/web-components/CheckDemo.tsx new file mode 100644 index 00000000..08481df5 --- /dev/null +++ b/examples/src/web-components/CheckDemo.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const CheckDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="Check" + description="Bootstrap checkbox wrapper with label, inline/reverse layout, indeterminate state, validation, and disabled support." + /> + +
    + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    +) + +export default CheckDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 1cc81931..319c6336 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -29,6 +29,7 @@ import TooltipDemo from './TooltipDemo' import InputDemo from './InputDemo' import TextareaDemo from './TextareaDemo' import SelectDemo from './SelectDemo' +import CheckDemo from './CheckDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -78,4 +79,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'input', category: 'Forms', element: }, { key: 'textarea', category: 'Forms', element: }, { key: 'select', category: 'Forms', element: }, + { key: 'check', category: 'Forms', element: }, ] diff --git a/web-components/src/Check.ts b/web-components/src/Check.ts new file mode 100644 index 00000000..6986fd9c --- /dev/null +++ b/web-components/src/Check.ts @@ -0,0 +1,160 @@ +const TAG_NAME = 'tc-check' + +let _idCounter = 0 + +export type CheckState = 'valid' | 'invalid' + +const STATES: CheckState[] = ['valid', 'invalid'] + +export class Check extends HTMLElement { + + private _inputId: string + private _initialised = false + + static get observedAttributes(): string[] { + return ['checked', 'value', 'label', 'indeterminate', 'disabled', 'inline', 'reverse', 'state'] + } + + constructor() { + super() + this._inputId = `tc-check-${++_idCounter}` + } + + connectedCallback(): void { + this.addEventListener('change', this._onNativeChange) + this.render() + this._initialised = true + } + + disconnectedCallback(): void { + this.removeEventListener('change', this._onNativeChange) + } + + attributeChangedCallback(_name: string, _old: string | null, _next: string | null): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get checked(): boolean { + return this.querySelector('input')?.checked ?? this.hasAttribute('checked') + } + set checked(v: boolean) { + const input = this.querySelector('input') + if (input) input.checked = v + if (v) this.setAttribute('checked', '') + else this.removeAttribute('checked') + } + + get value(): string { + return this.getAttribute('value') ?? '' + } + set value(v: string) { + this.setAttribute('value', v) + } + + get label(): string | null { + return this.getAttribute('label') + } + set label(v: string | null) { + if (v != null) this.setAttribute('label', v) + else this.removeAttribute('label') + } + + get indeterminate(): boolean { + return this.hasAttribute('indeterminate') + } + set indeterminate(v: boolean) { + if (v) this.setAttribute('indeterminate', '') + else this.removeAttribute('indeterminate') + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + get inline(): boolean { + return this.hasAttribute('inline') + } + set inline(v: boolean) { + if (v) this.setAttribute('inline', '') + else this.removeAttribute('inline') + } + + get reverse(): boolean { + return this.hasAttribute('reverse') + } + set reverse(v: boolean) { + if (v) this.setAttribute('reverse', '') + else this.removeAttribute('reverse') + } + + get state(): CheckState | null { + const v = this.getAttribute('state') as CheckState + return STATES.includes(v) ? v : null + } + set state(v: CheckState | null) { + if (v != null) this.setAttribute('state', v) + else this.removeAttribute('state') + } + + private _onNativeChange = (e: Event): void => { + const input = e.target as HTMLInputElement + if (input.tagName === 'INPUT') { + if (input.checked) this.setAttribute('checked', '') + else this.removeAttribute('checked') + } + } + + private render(): void { + const label = this.label + const state = this.state + const checkedAttr = this.hasAttribute('checked') ? ' checked' : '' + const indeterminate = this.indeterminate + const disabled = this.disabled + const inline = this.inline + const reverse = this.reverse + const value = this.value + + const inlineClass = inline ? ' form-check-inline' : '' + const reverseClass = reverse ? ' form-check-reverse' : '' + const stateClass = state === 'valid' ? ' is-valid' : state === 'invalid' ? ' is-invalid' : '' + const disabledAttr = disabled ? ' disabled' : '' + const valueAttr = value ? ` value="${esc(value)}"` : '' + + const labelHtml = label != null + ? `` + : '' + + const feedbackHtml = state === 'valid' + ? `
    Looks good!
    ` + : state === 'invalid' + ? `
    Please check this field.
    ` + : '' + + this.innerHTML = [ + `
    `, + ``, + labelHtml, + feedbackHtml, + `
    `, + ].join('') + + // indeterminate cannot be set via HTML attribute — must be applied imperatively. + const input = this.querySelector('input') + if (input) input.indeterminate = indeterminate + } +} + +function esc(str: string): string { + return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Check + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index db6b4657..3a9c3854 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -36,3 +36,4 @@ export * from './Input' export * from './Textarea' export * from './Select' export * from './Option' +export * from './Check' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 18c9618d..dc9a842d 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -35,6 +35,7 @@ import { Input } from './Input' import { Textarea } from './Textarea' import { Select } from './Select' import { Option } from './Option' +import { Check } from './Check' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -77,4 +78,5 @@ export function register(): void { customElements.define('tc-textarea', Textarea) customElements.define('tc-select', Select) customElements.define('tc-option', Option) + customElements.define('tc-check', Check) } diff --git a/web-components/style/components/_check.scss b/web-components/style/components/_check.scss new file mode 100644 index 00000000..fcbba60e --- /dev/null +++ b/web-components/style/components/_check.scss @@ -0,0 +1 @@ +// tc-check — theme tweaks only; Bootstrap form-check classes handle layout. diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 972b72de..302eaf57 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -31,3 +31,4 @@ @forward 'input'; @forward 'textarea'; @forward 'select'; +@forward 'check'; From 87fde6b63e926c47abade57aac92b2b55890c03c Mon Sep 17 00:00:00 2001 From: kalevski Date: Fri, 12 Jun 2026 01:01:34 +0000 Subject: [PATCH 037/632] 036-tc-radio: Implement the tc-radio Web Component --- examples/src/web-components/RadioDemo.tsx | 62 +++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Radio.ts | 134 ++++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + web-components/style/components/_radio.scss | 1 + 7 files changed, 203 insertions(+) create mode 100644 examples/src/web-components/RadioDemo.tsx create mode 100644 web-components/src/Radio.ts create mode 100644 web-components/style/components/_radio.scss diff --git a/examples/src/web-components/RadioDemo.tsx b/examples/src/web-components/RadioDemo.tsx new file mode 100644 index 00000000..b3d28f01 --- /dev/null +++ b/examples/src/web-components/RadioDemo.tsx @@ -0,0 +1,62 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const RadioDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="Radio" + description="Bootstrap radio button wrapper with label, grouping via name, inline/reverse layout, and disabled support." + /> + +
    + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    +) + +export default RadioDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 319c6336..1f29eaf6 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -30,6 +30,7 @@ import InputDemo from './InputDemo' import TextareaDemo from './TextareaDemo' import SelectDemo from './SelectDemo' import CheckDemo from './CheckDemo' +import RadioDemo from './RadioDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -80,4 +81,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'textarea', category: 'Forms', element: }, { key: 'select', category: 'Forms', element: }, { key: 'check', category: 'Forms', element: }, + { key: 'radio', category: 'Forms', element: }, ] diff --git a/web-components/src/Radio.ts b/web-components/src/Radio.ts new file mode 100644 index 00000000..e44cf504 --- /dev/null +++ b/web-components/src/Radio.ts @@ -0,0 +1,134 @@ +const TAG_NAME = 'tc-radio' + +let _idCounter = 0 + +export class Radio extends HTMLElement { + + private _inputId: string + private _initialised = false + + static get observedAttributes(): string[] { + return ['checked', 'value', 'name', 'label', 'disabled', 'inline', 'reverse'] + } + + constructor() { + super() + this._inputId = `tc-radio-${++_idCounter}` + } + + connectedCallback(): void { + this.addEventListener('change', this._onNativeChange) + this.render() + this._initialised = true + } + + disconnectedCallback(): void { + this.removeEventListener('change', this._onNativeChange) + } + + attributeChangedCallback(_name: string, _old: string | null, _next: string | null): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get checked(): boolean { + return this.querySelector('input')?.checked ?? this.hasAttribute('checked') + } + set checked(v: boolean) { + const input = this.querySelector('input') + if (input) input.checked = v + if (v) this.setAttribute('checked', '') + else this.removeAttribute('checked') + } + + get value(): string { + return this.getAttribute('value') ?? '' + } + set value(v: string) { + this.setAttribute('value', v) + } + + get name(): string { + return this.getAttribute('name') ?? '' + } + set name(v: string) { + this.setAttribute('name', v) + } + + get label(): string | null { + return this.getAttribute('label') + } + set label(v: string | null) { + if (v != null) this.setAttribute('label', v) + else this.removeAttribute('label') + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + get inline(): boolean { + return this.hasAttribute('inline') + } + set inline(v: boolean) { + if (v) this.setAttribute('inline', '') + else this.removeAttribute('inline') + } + + get reverse(): boolean { + return this.hasAttribute('reverse') + } + set reverse(v: boolean) { + if (v) this.setAttribute('reverse', '') + else this.removeAttribute('reverse') + } + + private _onNativeChange = (e: Event): void => { + const input = e.target as HTMLInputElement + if (input.tagName === 'INPUT') { + if (input.checked) this.setAttribute('checked', '') + else this.removeAttribute('checked') + } + } + + private render(): void { + const label = this.label + const checkedAttr = this.hasAttribute('checked') ? ' checked' : '' + const disabled = this.disabled + const inline = this.inline + const reverse = this.reverse + const value = this.value + const name = this.name + + const inlineClass = inline ? ' form-check-inline' : '' + const reverseClass = reverse ? ' form-check-reverse' : '' + const disabledAttr = disabled ? ' disabled' : '' + const valueAttr = value ? ` value="${esc(value)}"` : '' + const nameAttr = name ? ` name="${esc(name)}"` : '' + + const labelHtml = label != null + ? `` + : '' + + this.innerHTML = [ + `
    `, + ``, + labelHtml, + `
    `, + ].join('') + } +} + +function esc(str: string): string { + return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Radio + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 3a9c3854..8614f4a7 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -37,3 +37,4 @@ export * from './Textarea' export * from './Select' export * from './Option' export * from './Check' +export * from './Radio' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index dc9a842d..c8910b53 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -36,6 +36,7 @@ import { Textarea } from './Textarea' import { Select } from './Select' import { Option } from './Option' import { Check } from './Check' +import { Radio } from './Radio' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -79,4 +80,5 @@ export function register(): void { customElements.define('tc-select', Select) customElements.define('tc-option', Option) customElements.define('tc-check', Check) + customElements.define('tc-radio', Radio) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 302eaf57..0a145f52 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -32,3 +32,4 @@ @forward 'textarea'; @forward 'select'; @forward 'check'; +@forward 'radio'; diff --git a/web-components/style/components/_radio.scss b/web-components/style/components/_radio.scss new file mode 100644 index 00000000..6bd634cf --- /dev/null +++ b/web-components/style/components/_radio.scss @@ -0,0 +1 @@ +// tc-radio — theme tweaks only; Bootstrap form-check classes handle layout. From 026c5a3102fd7e83037e369b25e1e260f7411452 Mon Sep 17 00:00:00 2001 From: kalevski Date: Fri, 12 Jun 2026 01:04:21 +0000 Subject: [PATCH 038/632] 037-tc-switch: Implement the tc-switch Web Component --- examples/src/web-components/SwitchDemo.tsx | 49 ++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Switch.ts | 115 +++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + web-components/style/components/_switch.scss | 1 + 7 files changed, 171 insertions(+) create mode 100644 examples/src/web-components/SwitchDemo.tsx create mode 100644 web-components/src/Switch.ts create mode 100644 web-components/style/components/_switch.scss diff --git a/examples/src/web-components/SwitchDemo.tsx b/examples/src/web-components/SwitchDemo.tsx new file mode 100644 index 00000000..47166a8b --- /dev/null +++ b/examples/src/web-components/SwitchDemo.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const SwitchDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="Switch" + description="Bootstrap toggle-switch wrapper with label, reverse layout, and disabled support." + /> + +
    + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    +) + +export default SwitchDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 1f29eaf6..f98da7b8 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -31,6 +31,7 @@ import TextareaDemo from './TextareaDemo' import SelectDemo from './SelectDemo' import CheckDemo from './CheckDemo' import RadioDemo from './RadioDemo' +import SwitchDemo from './SwitchDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -82,4 +83,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'select', category: 'Forms', element: }, { key: 'check', category: 'Forms', element: }, { key: 'radio', category: 'Forms', element: }, + { key: 'switch', category: 'Forms', element: }, ] diff --git a/web-components/src/Switch.ts b/web-components/src/Switch.ts new file mode 100644 index 00000000..3dda01e0 --- /dev/null +++ b/web-components/src/Switch.ts @@ -0,0 +1,115 @@ +const TAG_NAME = 'tc-switch' + +let _idCounter = 0 + +export class Switch extends HTMLElement { + + private _inputId: string + private _initialised = false + + static get observedAttributes(): string[] { + return ['checked', 'value', 'label', 'disabled', 'reverse'] + } + + constructor() { + super() + this._inputId = `tc-switch-${++_idCounter}` + } + + connectedCallback(): void { + this.addEventListener('change', this._onNativeChange) + this.render() + this._initialised = true + } + + disconnectedCallback(): void { + this.removeEventListener('change', this._onNativeChange) + } + + attributeChangedCallback(_name: string, _old: string | null, _next: string | null): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get checked(): boolean { + return this.querySelector('input')?.checked ?? this.hasAttribute('checked') + } + set checked(v: boolean) { + const input = this.querySelector('input') + if (input) input.checked = v + if (v) this.setAttribute('checked', '') + else this.removeAttribute('checked') + } + + get value(): string { + return this.getAttribute('value') ?? '' + } + set value(v: string) { + this.setAttribute('value', v) + } + + get label(): string | null { + return this.getAttribute('label') + } + set label(v: string | null) { + if (v != null) this.setAttribute('label', v) + else this.removeAttribute('label') + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + get reverse(): boolean { + return this.hasAttribute('reverse') + } + set reverse(v: boolean) { + if (v) this.setAttribute('reverse', '') + else this.removeAttribute('reverse') + } + + private _onNativeChange = (e: Event): void => { + const input = e.target as HTMLInputElement + if (input.tagName === 'INPUT') { + if (input.checked) this.setAttribute('checked', '') + else this.removeAttribute('checked') + } + } + + private render(): void { + const label = this.label + const checkedAttr = this.hasAttribute('checked') ? ' checked' : '' + const disabled = this.disabled + const reverse = this.reverse + const value = this.value + + const reverseClass = reverse ? ' form-check-reverse' : '' + const disabledAttr = disabled ? ' disabled' : '' + const valueAttr = value ? ` value="${esc(value)}"` : '' + + const labelHtml = label != null + ? `` + : '' + + this.innerHTML = [ + `
    `, + ``, + labelHtml, + `
    `, + ].join('') + } +} + +function esc(str: string): string { + return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Switch + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 8614f4a7..6c3e3446 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -38,3 +38,4 @@ export * from './Select' export * from './Option' export * from './Check' export * from './Radio' +export * from './Switch' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index c8910b53..4d59b674 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -37,6 +37,7 @@ import { Select } from './Select' import { Option } from './Option' import { Check } from './Check' import { Radio } from './Radio' +import { Switch } from './Switch' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -81,4 +82,5 @@ export function register(): void { customElements.define('tc-option', Option) customElements.define('tc-check', Check) customElements.define('tc-radio', Radio) + customElements.define('tc-switch', Switch) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 0a145f52..d1ff4973 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -33,3 +33,4 @@ @forward 'select'; @forward 'check'; @forward 'radio'; +@forward 'switch'; diff --git a/web-components/style/components/_switch.scss b/web-components/style/components/_switch.scss new file mode 100644 index 00000000..795dc23c --- /dev/null +++ b/web-components/style/components/_switch.scss @@ -0,0 +1 @@ +// tc-switch — theme tweaks only; Bootstrap form-switch classes handle layout. From ecb5a2d3dc22917af8ab95577fc58910aa3a1653 Mon Sep 17 00:00:00 2001 From: kalevski Date: Fri, 12 Jun 2026 01:08:39 +0000 Subject: [PATCH 039/632] 038-tc-range: Implement the tc-range Web Component --- examples/src/web-components/RangeDemo.tsx | 84 +++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Range.ts | 113 ++++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + web-components/style/components/_range.scss | 1 + 7 files changed, 204 insertions(+) create mode 100644 examples/src/web-components/RangeDemo.tsx create mode 100644 web-components/src/Range.ts create mode 100644 web-components/style/components/_range.scss diff --git a/examples/src/web-components/RangeDemo.tsx b/examples/src/web-components/RangeDemo.tsx new file mode 100644 index 00000000..d19e08d2 --- /dev/null +++ b/examples/src/web-components/RangeDemo.tsx @@ -0,0 +1,84 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +function useRangeValue(initial: string): [string, React.RefObject] { + const [value, setValue] = useState(initial) + const ref = useRef(null) + + useEffect(() => { + const el = ref.current + if (!el) return + const handler = (e: Event) => { + const input = e.target as HTMLInputElement + if (input.tagName === 'INPUT') setValue(input.value) + } + el.addEventListener('input', handler) + return () => el.removeEventListener('input', handler) + }, []) + + return [value, ref] +} + +const RangeDemo: React.FC = () => { + const [v1, ref1] = useRangeValue('50') + const [v2, ref2] = useRangeValue('0') + const [v3, ref3] = useRangeValue('5') + + return ( +
    +
    +
    +
    + Web Components} + title="Range" + description="Bootstrap range slider wrapper with optional label, min/max/step control, and disabled support." + /> + +
    + +
    + {/* @ts-ignore */} + +
    Current value: {v1}
    +
    +
    + + +
    + {/* @ts-ignore */} + +
    Current value: {v2}
    +
    +
    + + +
    + {/* @ts-ignore */} + +
    Current value: {v3}
    +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default RangeDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index f98da7b8..c794a7c3 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -32,6 +32,7 @@ import SelectDemo from './SelectDemo' import CheckDemo from './CheckDemo' import RadioDemo from './RadioDemo' import SwitchDemo from './SwitchDemo' +import RangeDemo from './RangeDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -84,4 +85,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'check', category: 'Forms', element: }, { key: 'radio', category: 'Forms', element: }, { key: 'switch', category: 'Forms', element: }, + { key: 'range', category: 'Forms', element: }, ] diff --git a/web-components/src/Range.ts b/web-components/src/Range.ts new file mode 100644 index 00000000..19c1176f --- /dev/null +++ b/web-components/src/Range.ts @@ -0,0 +1,113 @@ +const TAG_NAME = 'tc-range' + +let _idCounter = 0 + +export class Range extends HTMLElement { + + private _inputId: string + private _initialised = false + + static get observedAttributes(): string[] { + return ['min', 'max', 'step', 'value', 'disabled', 'label'] + } + + constructor() { + super() + this._inputId = `tc-range-${++_idCounter}` + } + + connectedCallback(): void { + this.render() + this._initialised = true + } + + attributeChangedCallback(name: string, _old: string | null, next: string | null): void { + if (!this.isConnected || !this._initialised) return + if (name === 'value') { + const input = this.querySelector('input') + if (input && input.value !== (next ?? '')) input.value = next ?? '' + return + } + this.render() + } + + get min(): string { + return this.getAttribute('min') ?? '0' + } + set min(v: string) { + this.setAttribute('min', v) + } + + get max(): string { + return this.getAttribute('max') ?? '100' + } + set max(v: string) { + this.setAttribute('max', v) + } + + get step(): string | null { + return this.getAttribute('step') + } + set step(v: string | null) { + if (v != null) this.setAttribute('step', v) + else this.removeAttribute('step') + } + + get value(): string { + return this.querySelector('input')?.value ?? this.getAttribute('value') ?? '' + } + set value(v: string) { + const input = this.querySelector('input') + if (input) input.value = v + this.setAttribute('value', v) + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + get label(): string | null { + return this.getAttribute('label') + } + set label(v: string | null) { + if (v != null) this.setAttribute('label', v) + else this.removeAttribute('label') + } + + private render(): void { + const label = this.label + const min = this.min + const max = this.max + const step = this.step + const disabled = this.disabled + const currentValue = this.querySelector('input')?.value ?? this.getAttribute('value') ?? '' + + const stepAttr = step != null ? ` step="${esc(step)}"` : '' + const disabledAttr = disabled ? ' disabled' : '' + const valueAttr = currentValue !== '' ? ` value="${esc(currentValue)}"` : '' + + const labelHtml = label != null + ? `` + : '' + + this.innerHTML = [ + labelHtml, + ``, + ].join('') + } +} + +function esc(str: string): string { + return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Range + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 6c3e3446..eb624497 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -39,3 +39,4 @@ export * from './Option' export * from './Check' export * from './Radio' export * from './Switch' +export * from './Range' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 4d59b674..31d2a424 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -38,6 +38,7 @@ import { Option } from './Option' import { Check } from './Check' import { Radio } from './Radio' import { Switch } from './Switch' +import { Range } from './Range' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -83,4 +84,5 @@ export function register(): void { customElements.define('tc-check', Check) customElements.define('tc-radio', Radio) customElements.define('tc-switch', Switch) + customElements.define('tc-range', Range) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index d1ff4973..04922fca 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -34,3 +34,4 @@ @forward 'check'; @forward 'radio'; @forward 'switch'; +@forward 'range'; diff --git a/web-components/style/components/_range.scss b/web-components/style/components/_range.scss new file mode 100644 index 00000000..af9ab1c0 --- /dev/null +++ b/web-components/style/components/_range.scss @@ -0,0 +1 @@ +// tc-range — theme tweaks only; Bootstrap form-range classes handle layout. From b81ec20dd4e1fdf8f6c5a7d262a6897a128e08a6 Mon Sep 17 00:00:00 2001 From: kalevski Date: Fri, 12 Jun 2026 01:12:43 +0000 Subject: [PATCH 040/632] 039-tc-floating-label: Implement the tc-floating-label Web Component --- .../src/web-components/FloatingLabelDemo.tsx | 72 +++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/FloatingLabel.ts | 79 +++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + .../style/components/_floating-label.scss | 1 + web-components/style/components/_index.scss | 1 + 7 files changed, 158 insertions(+) create mode 100644 examples/src/web-components/FloatingLabelDemo.tsx create mode 100644 web-components/src/FloatingLabel.ts create mode 100644 web-components/style/components/_floating-label.scss diff --git a/examples/src/web-components/FloatingLabelDemo.tsx b/examples/src/web-components/FloatingLabelDemo.tsx new file mode 100644 index 00000000..baf550e7 --- /dev/null +++ b/examples/src/web-components/FloatingLabelDemo.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const FloatingLabelDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="Floating Label" + description="Bootstrap form-floating wrapper that animates the label above a control when focused or filled." + /> + +
    + +
    + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + `) + + `
    ` + + this.innerHTML = + `
    ` + + `
    ` + + fontGroup + + effectsGroup + + layoutGroup + + contentGroup + + `
    ` + + `
    ` + + `` + + `
    ` + + `` + + `` + + `
    ` + + `` + + `
    ` + + `
    ` + + this._canvas = this._ctl('canvas') + + // Delegated listeners live on the fresh container; replaced wholesale on + // every render so old listeners are garbage-collected with the old DOM. + const root = this.querySelector('.tc-bfg') + if (root) { + root.addEventListener('input', this._onInput) + root.addEventListener('change', this._onInput) + root.addEventListener('click', this._onClick) + } + + this._syncControlValues() + this._renderOutput() + this._drawPreview() + } + + /** Pushes current state into the freshly-rendered controls (avoids escaping). */ + private _syncControlValues(): void { + const setVal = (field: string, value: string) => { + const el = this._ctl(field) + if (el) el.value = value + } + const setChk = (field: string, value: boolean) => { + const el = this._ctl(field) + if (el) el.checked = value + } + + setVal('font-family', this.fontFamily) + setVal('font-size', String(this.fontSize)) + setVal('text', this.text) + setVal('glyphs', this.glyphs) + setVal('letter-spacing', String(this.letterSpacing)) + setVal('padding', String(this.padding)) + setVal('glyphs-per-row', String(this.glyphsPerRow)) + setVal('line-height', String(this.lineHeight)) + setChk('power-of-two', this.powerOfTwo) + setVal('scale', String(this.scale)) + setVal('export-format', this.exportFormat) + setChk('bg-enabled', this.background != null) + setVal('bg-color', this.background ?? '#1a1a2e') + + // Fill + const fill = this._fill + setVal('fill-type', fill.type) + setVal('fill-color', fill.color ?? '#ffffff') + setVal('grad-type', fill.gradientType ?? 'linear') + const gc = fill.gradientColors ?? [] + setVal('grad-color1', gc[0] ?? '#ff6b6b') + setVal('grad-color2', gc[1] ?? '#ffd93d') + setVal('grad-color3', gc[2] ?? '#6bccff') + setChk('grad-third', gc.length >= 3) + setVal('grad-angle', String(fill.gradientAngle ?? 90)) + + // Borders + const rb = this._resolveBorders() + setChk('border-enabled', rb.length > 0) + setVal('border-color', rb[0]?.color ?? '#000000') + setVal('border-thickness', String(rb[0]?.thickness ?? 2)) + setVal('border-align', rb[0]?.align ?? 'center') + setChk('border2-enabled', rb.length > 1) + setVal('border2-color', rb[1]?.color ?? '#ffffff') + setVal('border2-thickness', String(rb[1]?.thickness ?? 5)) + + // Drop shadow + const ds = this._dropShadow + setChk('shadow-enabled', ds != null) + setVal('shadow-color', ds?.color ?? '#000000') + setVal('shadow-size', String(ds?.size ?? 4)) + setVal('shadow-offset-x', String(ds?.offsetX ?? 2)) + setVal('shadow-offset-y', String(ds?.offsetY ?? 2)) + setVal('shadow-blur', String(ds?.blur ?? 4)) + + // Glow + const glow = this._glow + setChk('glow-enabled', glow != null) + setVal('glow-color', glow?.color ?? '#00e5ff') + setVal('glow-size', String(glow?.size ?? 8)) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: BitmapFontGenerator + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 9dd249e4..00300e27 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -17,6 +17,7 @@ export * from './AnnouncementBar' export * from './Badge' export * from './BadgeRow' export * from './BenchmarkChart' +export * from './BitmapFontGenerator' export * from './Breadcrumb' export * from './BreadcrumbItem' export * from './Button' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 686131b2..532a9bc7 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -16,6 +16,7 @@ import { AnnouncementBar } from './AnnouncementBar' import { Badge } from './Badge' import { BadgeRow } from './BadgeRow' import { BenchmarkChart } from './BenchmarkChart' +import { BitmapFontGenerator } from './BitmapFontGenerator' import { Breadcrumb } from './Breadcrumb' import { BreadcrumbItem } from './BreadcrumbItem' import { Button } from './Button' @@ -227,6 +228,7 @@ export function register(): void { customElements.define('tc-badge', Badge) customElements.define('tc-badge-row', BadgeRow) customElements.define('tc-benchmark-chart', BenchmarkChart) + customElements.define('tc-bitmap-font-generator', BitmapFontGenerator) customElements.define('tc-breadcrumb', Breadcrumb) customElements.define('tc-breadcrumb-item', BreadcrumbItem) customElements.define('tc-button', Button) diff --git a/web-components/style/components/_bitmap-font-generator.scss b/web-components/style/components/_bitmap-font-generator.scss new file mode 100644 index 00000000..ac11a0e5 --- /dev/null +++ b/web-components/style/components/_bitmap-font-generator.scss @@ -0,0 +1,361 @@ +// tc-bitmap-font-generator — canvas bitmap-font atlas generator: a control panel +// (font/fill/effects/layout/content) beside a live preview canvas, generate +// button, and a multi-format export descriptor block. Dense, businesslike slate +// surfaces; sharp corners everywhere (border-radius: 0). Colour is status only; +// the cyan accent stays rare (focus). Mono (JetBrains Mono) for numeric inputs, +// selects, and the descriptor code block. Every cosmetic value flows through +// --bs-bitmap-font-generator-* backed by --tc-* tokens. + +@use '../foundation/tokens' as *; + +tc-bitmap-font-generator { + --bs-bitmap-font-generator-surface: var(--tc-surface); + --bs-bitmap-font-generator-surface-muted: var(--tc-surface-muted); + --bs-bitmap-font-generator-border: var(--tc-border); + --bs-bitmap-font-generator-border-strong: var(--tc-border-strong); + --bs-bitmap-font-generator-hairline: var(--tc-slate-100, #f1f5f9); + --bs-bitmap-font-generator-text: var(--tc-text); + --bs-bitmap-font-generator-muted: var(--tc-text-muted); + --bs-bitmap-font-generator-faint: var(--tc-text-faint); + --bs-bitmap-font-generator-mono: var(--tc-font-mono, ui-monospace, 'JetBrains Mono', 'SFMono-Regular', monospace); + + --bs-bitmap-font-generator-group-title-color: var(--tc-text-muted); + --bs-bitmap-font-generator-label-color: var(--tc-text-muted); + + --bs-bitmap-font-generator-input-bg: var(--tc-surface); + --bs-bitmap-font-generator-input-border: var(--tc-border-strong); + --bs-bitmap-font-generator-input-color: var(--tc-text); + --bs-bitmap-font-generator-input-focus-border: rgba(30, 41, 59, 0.5); + --bs-bitmap-font-generator-input-focus-ring: 0 0 0 0.2rem rgba(30, 41, 59, 0.12); + + --bs-bitmap-font-generator-canvas-bg: var(--tc-surface-muted); + --bs-bitmap-font-generator-code-bg: var(--tc-ink); + --bs-bitmap-font-generator-code-color: var(--tc-text-inverse, #e2e8f0); + + // Primary (slate-ink) button ladder + --bs-bitmap-font-generator-btn-bg: var(--tc-surface); + --bs-bitmap-font-generator-btn-border: var(--tc-border-strong); + --bs-bitmap-font-generator-btn-color: var(--tc-text); + --bs-bitmap-font-generator-btn-hover-bg: var(--tc-surface-muted); + --bs-bitmap-font-generator-btn-active-bg: var(--tc-app-accent); + --bs-bitmap-font-generator-btn-active-color: #fff; + --bs-bitmap-font-generator-btn-primary-bg: linear-gradient(135deg, var(--tc-app-accent), #2b3a51); + --bs-bitmap-font-generator-btn-primary-color: #fff; + --bs-bitmap-font-generator-btn-primary-lift: 0 4px 15px rgba(30, 41, 59, 0.3); + + --bs-bitmap-font-generator-disabled-opacity: 0.55; + --bs-bitmap-font-generator-icon-size: 1rem; +} + +tc-bitmap-font-generator[disabled] { + opacity: var(--bs-bitmap-font-generator-disabled-opacity); +} + +.tc-bfg { + display: grid; + grid-template-columns: minmax(0, 320px) minmax(0, 1fr); + gap: 1rem; + border: 1px solid var(--bs-bitmap-font-generator-border); + border-radius: 0; + background: var(--bs-bitmap-font-generator-surface); + color: var(--bs-bitmap-font-generator-text); + padding: 1rem; +} + +@media (max-width: 720px) { + .tc-bfg { + grid-template-columns: minmax(0, 1fr); + } +} + +// Disabled = opacity + pointer-events (never colour-only). +.tc-bfg--disabled .tc-bfg-controls, +.tc-bfg--disabled .tc-bfg-toolbar { + pointer-events: none; +} + +// ── Controls panel ────────────────────────────────────────────────────────── + +.tc-bfg-controls { + display: flex; + flex-direction: column; + gap: 1rem; + min-width: 0; +} + +.tc-bfg-group { + border: 1px solid var(--bs-bitmap-font-generator-border); + border-radius: 0; + background: var(--bs-bitmap-font-generator-surface); +} + +.tc-bfg-group-title { + padding: 0.5rem 0.75rem; + font-family: var(--bs-bitmap-font-generator-mono); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--bs-bitmap-font-generator-group-title-color); + border-bottom: 1px solid var(--bs-bitmap-font-generator-border); + background: var(--bs-bitmap-font-generator-surface-muted); +} + +.tc-bfg-row, +.tc-bfg-check { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.4rem 0.75rem; + // slate-100 inner hairlines between control rows + border-bottom: 1px solid var(--bs-bitmap-font-generator-hairline); +} + +.tc-bfg-group > .tc-bfg-row:last-child, +.tc-bfg-group > .tc-bfg-check:last-child { + border-bottom: 0; +} + +.tc-bfg-label { + flex: 1 1 auto; + min-width: 0; + font-size: 0.8125rem; + color: var(--bs-bitmap-font-generator-label-color); +} + +.tc-bfg-input, +.tc-bfg-select { + flex: 0 0 auto; + width: 9rem; + max-width: 55%; + padding: 0.3rem 0.5rem; + font-size: 0.8125rem; + color: var(--bs-bitmap-font-generator-input-color); + background: var(--bs-bitmap-font-generator-input-bg); + border: 1px solid var(--bs-bitmap-font-generator-input-border); + border-radius: 0; +} + +// Mono for numeric inputs / selects (machine-facing text). +.tc-bfg-input--mono { + font-family: var(--bs-bitmap-font-generator-mono); +} + +.tc-bfg-textarea { + width: 100%; + max-width: 100%; + resize: vertical; + line-height: 1.4; +} + +.tc-bfg-row:has(.tc-bfg-textarea) { + flex-direction: column; + align-items: stretch; + gap: 0.4rem; +} + +.tc-bfg-color { + flex: 0 0 auto; + width: 3rem; + height: 2rem; + padding: 0.15rem; + background: var(--bs-bitmap-font-generator-input-bg); + border: 1px solid var(--bs-bitmap-font-generator-input-border); + border-radius: 0; + cursor: pointer; +} + +.tc-bfg-input:focus, +.tc-bfg-select:focus, +.tc-bfg-color:focus, +.tc-bfg-textarea:focus { + outline: none; + border-color: var(--bs-bitmap-font-generator-input-focus-border); + box-shadow: var(--bs-bitmap-font-generator-input-focus-ring); +} + +.tc-bfg-check { + gap: 0.5rem; +} + +.tc-bfg-check input[type='checkbox'] { + flex: 0 0 auto; + width: 1rem; + height: 1rem; + accent-color: var(--tc-app-accent); +} + +.tc-bfg-check .tc-bfg-label { + order: 1; + font-size: 0.8125rem; +} + +// ── Preview / export stage ────────────────────────────────────────────────── + +.tc-bfg-stage { + display: flex; + flex-direction: column; + gap: 0.75rem; + min-width: 0; +} + +.tc-bfg-canvas { + display: block; + width: 100%; + height: 240px; + border: 1px solid var(--bs-bitmap-font-generator-border); + border-radius: 0; + background: var(--bs-bitmap-font-generator-canvas-bg); +} + +.tc-bfg-toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.tc-bfg-meta { + font-family: var(--bs-bitmap-font-generator-mono); + font-size: 0.75rem; + letter-spacing: 0.04em; + color: var(--bs-bitmap-font-generator-muted); +} + +// ── Buttons (standard ladder) ─────────────────────────────────────────────── + +.tc-bfg-btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.75rem; + font-size: 0.8125rem; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--bs-bitmap-font-generator-btn-color); + background: var(--bs-bitmap-font-generator-btn-bg); + border: 1px solid var(--bs-bitmap-font-generator-btn-border); + border-radius: 0; + cursor: pointer; + transition: + background-color var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease), + transform var(--tc-transition-base, 0.2s ease), + box-shadow var(--tc-transition-base, 0.2s ease); +} + +.tc-bfg-btn:hover { + // hover well + background: var(--bs-bitmap-font-generator-btn-hover-bg); +} + +.tc-bfg-btn:active { + // active = ink fill, white text + background: var(--bs-bitmap-font-generator-btn-active-bg); + color: var(--bs-bitmap-font-generator-btn-active-color); +} + +.tc-bfg-btn svg { + width: var(--bs-bitmap-font-generator-icon-size); + height: var(--bs-bitmap-font-generator-icon-size); + flex: 0 0 auto; +} + +.tc-bfg-btn--primary { + color: var(--bs-bitmap-font-generator-btn-primary-color); + background: var(--bs-bitmap-font-generator-btn-primary-bg); + border-color: transparent; +} + +.tc-bfg-btn--primary:hover { + // primary lift; solid variants never darken — the 1px lift is the feedback + background: var(--bs-bitmap-font-generator-btn-primary-bg); + color: var(--bs-bitmap-font-generator-btn-primary-color); + transform: translateY(-1px); + box-shadow: var(--bs-bitmap-font-generator-btn-primary-lift); +} + +.tc-bfg-btn--primary:active { + color: var(--bs-bitmap-font-generator-btn-primary-color); + transform: translateY(0); +} + +.tc-bfg-btn:disabled { + opacity: var(--bs-bitmap-font-generator-disabled-opacity); + pointer-events: none; +} + +// ── Export descriptor block ───────────────────────────────────────────────── + +.tc-bfg-output { + border: 1px solid var(--bs-bitmap-font-generator-border); + border-radius: 0; +} + +.tc-bfg-output-head { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.5rem 0.4rem 0.75rem; + border-bottom: 1px solid var(--bs-bitmap-font-generator-border); + background: var(--bs-bitmap-font-generator-surface-muted); +} + +.tc-bfg-output-title { + flex: 1 1 auto; + font-family: var(--bs-bitmap-font-generator-mono); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--bs-bitmap-font-generator-group-title-color); +} + +.tc-bfg-pre { + margin: 0; + max-height: 280px; + overflow: auto; + padding: 0.75rem 1rem; + background: var(--bs-bitmap-font-generator-code-bg); + border-radius: 0; +} + +.tc-bfg-code { + font-family: var(--bs-bitmap-font-generator-mono); + font-size: 0.72rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + color: var(--bs-bitmap-font-generator-code-color); +} + +// Focus always visible for keyboard users. +.tc-bfg-btn:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; +} + +// 44px touch targets under coarse pointers. +@media (pointer: coarse) { + .tc-bfg-btn { + min-height: 44px; + } + + .tc-bfg-input, + .tc-bfg-select { + min-height: 44px; + } +} + +// Respect reduced-motion: freeze the decorative lift, keep state colour shifts. +@media (prefers-reduced-motion: reduce) { + .tc-bfg-btn { + transition: + background-color var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease); + } + + .tc-bfg-btn--primary:hover { + transform: none; + } +} diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index de35f937..1c4c9668 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -16,6 +16,7 @@ @forward 'badge'; @forward 'badge-row'; @forward 'benchmark-chart'; +@forward 'bitmap-font-generator'; @forward 'breadcrumb'; @forward 'button'; @forward 'button-group'; diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index e8301496..d5165365 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -123,6 +123,7 @@ tc-asset-bundle, tc-build, tc-badge-row, tc-benchmark-chart, +tc-bitmap-font-generator, tc-basic-layout, tc-heading, tc-helper-text, From 665b443cf0d78e0ab6447664a0225e0fdcccd5b2 Mon Sep 17 00:00:00 2001 From: kalevski Date: Mon, 15 Jun 2026 18:00:50 +0000 Subject: [PATCH 246/632] 244-area-chart: Build the tc-area-chart web component (AreaChart parity) --- examples/public/web-components/SKILL.md | 66 +++ examples/src/web-components/AreaChartDemo.tsx | 133 +++++ examples/src/web-components/index.tsx | 2 + web-components/src/AreaChart.ts | 531 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + .../style/components/_area-chart.scss | 239 ++++++++ web-components/style/components/_index.scss | 1 + web-components/style/foundation/_reset.scss | 1 + 9 files changed, 976 insertions(+) create mode 100644 examples/src/web-components/AreaChartDemo.tsx create mode 100644 web-components/src/AreaChart.ts create mode 100644 web-components/style/components/_area-chart.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 57ba4d2f..6185d56e 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -49,6 +49,7 @@ After `register()` you can author markup directly: - [tc-audio-mixer](#tc-audio-mixer) - [tc-badge](#tc-badge) - [tc-badge-row](#tc-badge-row) + - [tc-area-chart](#tc-area-chart) - [tc-benchmark-chart](#tc-benchmark-chart) - [tc-bitmap-font-generator](#tc-bitmap-font-generator) - [tc-brand](#tc-brand) @@ -1212,6 +1213,71 @@ row.badges = [ --- +### tc-area-chart + +SVG area chart with filled regions, gridlines, an interactive tooltip, and an optional legend. Sharp square corners, slate-neutral chart frame on `--tc-surface` behind a 1px hairline; faint slate gridlines; mono micro axis labels. The primary series rides the slate ink ramp, the cyan accent is spent on at most one emphasized series, and additional series step down the slate ramp; area fills are low-opacity tints of the series stroke color. The tooltip is overlay tier — ink surface, white text, elevation shadow, a 3px colored left stripe. Series are set via the `series` JS property; `xFormatter`/`yFormatter` are JS function properties. + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `title` | `string` | — | Header text rendered above the chart | +| `subtitle` | `string` | — | Secondary header line below the title | +| `height` | `number` | `260` | Chart height in px (drives the SVG viewBox) | +| `stacked` | boolean | `false` | Stack series areas cumulatively instead of overlaying them | +| `show-grid` | boolean (default true) | `true` | Render horizontal gridlines + Y-axis labels; set `show-grid="false"` to hide | +| `show-legend` | boolean (default true) | `true` | Render the series legend; set `show-legend="false"` to hide | +| `loading` | boolean | `false` | Render a shimmer skeleton and set `aria-busy="true"` | + +**Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `series` | `AreaChartSeries[]` | `[]` | Array of series descriptors (see below) | +| `xFormatter` | `(v: string \| number) => string` | `String` | Formats x-axis tick + tooltip x labels | +| `yFormatter` | `(v: number) => string` | `String` | Formats y-axis tick + tooltip y labels | +| `onPointHover` | `(detail) => void \| null` | `null` | Optional callback fired (alongside `tc-point-hover`) as the pointer moves over the chart | + +**AreaChartSeries shape** + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `string` | Series name (legend + tooltip) | +| `points` | `{ x: string \| number, y: number }[]` | The data points | +| `color` | `string` | Optional explicit series color (any CSS color); otherwise the slate/accent ramp is used | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-point-hover` | `{ series: AreaChartSeries, point: { x, y }, index: number }` | Fired as the pointer moves near a data point (`index` is the point index within its series) | + +**Slots** + +_None._ The component owns its ``; arbitrary slotted children are not preserved. + +```html + + + + + + + + + +``` + +--- + ### tc-benchmark-chart Horizontal SVG bar chart comparing benchmark values, with leader highlighting and a linear or log scale. Sharp square corners, slate-neutral track, slate-ink bar fill, JetBrains Mono value numbers; the leader bar gets the one sanctioned emphasis (a 135° slate-ink gradient). Set bars exclusively via the `bars` JS property. diff --git a/examples/src/web-components/AreaChartDemo.tsx b/examples/src/web-components/AreaChartDemo.tsx new file mode 100644 index 00000000..c6ff46e2 --- /dev/null +++ b/examples/src/web-components/AreaChartDemo.tsx @@ -0,0 +1,133 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug'] + +const traffic = [ + { + name: 'Visitors', + points: months.map((m, i) => ({ x: m, y: [1200, 1900, 1700, 2400, 2200, 3100, 3600, 4200][i] })), + }, + { + name: 'Signups', + points: months.map((m, i) => ({ x: m, y: [180, 240, 210, 360, 340, 520, 610, 740][i] })), + }, +] + +const revenue = [ + { + name: 'Pro', + points: months.map((m, i) => ({ x: m, y: [4, 6, 7, 9, 11, 13, 15, 18][i] * 1000 })), + }, + { + name: 'Team', + points: months.map((m, i) => ({ x: m, y: [2, 3, 3, 5, 6, 8, 9, 11][i] * 1000 })), + }, + { + name: 'Enterprise', + points: months.map((m, i) => ({ x: m, y: [1, 1, 2, 2, 3, 4, 5, 7][i] * 1000 })), + }, +] + +const yMoney = (v: number): string => { + if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(1)}M` + if (v >= 1_000) return `$${(v / 1_000).toFixed(1)}k` + return `$${Math.round(v)}` +} +const yCompact = (v: number): string => { + if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k` + return String(Math.round(v)) +} + +const AreaChartDemo: React.FC = () => { + const basicRef = useRef(null) + const stackedRef = useRef(null) + const noGridRef = useRef(null) + const [hover, setHover] = useState(null) + + useEffect(() => { + if (basicRef.current) { + basicRef.current.series = traffic + basicRef.current.yFormatter = yCompact + basicRef.current.xFormatter = (v: string | number) => String(v) + } + if (stackedRef.current) { + stackedRef.current.series = revenue + stackedRef.current.yFormatter = yMoney + } + if (noGridRef.current) { + noGridRef.current.series = traffic + noGridRef.current.yFormatter = yCompact + } + + const el = basicRef.current + if (el) { + const handler = (e: any) => + setHover(`${e.detail.series.name}: ${e.detail.point.x} = ${e.detail.point.y}`) + el.addEventListener('tc-point-hover', handler) + return () => el.removeEventListener('tc-point-hover', handler) + } + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="AreaChart" + description="SVG area chart with filled regions, gridlines, an interactive tooltip, and an optional legend. Series, xFormatter and yFormatter are set via JS properties; title/subtitle/height/stacked/show-grid/show-legend/loading are attributes." + /> + +
    + + + {/* @ts-ignore */} + +

    + Hover a point — last: {hover ?? 'none'} +

    +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + +
    +
    +
    +
    +
    + ) +} + +export default AreaChartDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 495673bf..55d72d9a 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -12,6 +12,7 @@ import AnnouncementBarDemo from './AnnouncementBarDemo' import BadgeDemo from './BadgeDemo' import BrandDemo from './BrandDemo' import BadgeRowDemo from './BadgeRowDemo' +import AreaChartDemo from './AreaChartDemo' import BenchmarkChartDemo from './BenchmarkChartDemo' import BitmapFontGeneratorDemo from './BitmapFontGeneratorDemo' import BreadcrumbDemo from './BreadcrumbDemo' @@ -233,6 +234,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'badge', category: 'Components', element: }, { key: 'brand', category: 'Content', element: }, { key: 'badge-row', category: 'Components', element: }, + { key: 'area-chart', category: 'Components', element: }, { key: 'benchmark-chart', category: 'Components', element: }, { key: 'bitmap-font-generator', category: 'Components', element: }, { key: 'button', category: 'Components', element: }, diff --git a/web-components/src/AreaChart.ts b/web-components/src/AreaChart.ts new file mode 100644 index 00000000..0112cbff --- /dev/null +++ b/web-components/src/AreaChart.ts @@ -0,0 +1,531 @@ +const TAG_NAME = 'tc-area-chart' + +// A single data point. `x` is categorical/ordinal (string or number); `y` is +// the numeric measure. +export interface AreaChartPoint { + x: string | number + y: number +} + +// One series = a named array of points, optionally with an explicit color. +// `points` is the canonical field; `data` (the React `LineChartSeries` field) +// and `label` (its name field) are accepted as fallbacks so callers can pass +// react-components series objects unchanged. +export interface AreaChartSeries { + name: string + points: AreaChartPoint[] + color?: string + // react-components compatibility aliases + label?: string + data?: AreaChartPoint[] +} + +export interface PointHoverDetail { + series: AreaChartSeries + point: AreaChartPoint + index: number +} + +// How many distinct slate/accent ramp slots the SCSS exposes as +// --bs-area-chart-series-N. Series cycle through these when no explicit color +// is given. +const PALETTE_SIZE = 6 + +// Stroke dash patterns keep series distinguishable beyond color alone (a11y). +// The first series is a solid line; the rest cycle through subtle dashes. +const DASHES = ['', '', '6 3', '2 3', '8 3 2 3', '1 4'] + +const DEFAULT_HEIGHT = 260 + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +// Round a raw axis maximum up to a visually "nice" number (mirrors the React +// AreaChart niceMax). +function niceMax(v: number): number { + if (v <= 0) return 10 + const e = Math.pow(10, Math.floor(Math.log10(v))) + return Math.ceil(v / e) * e +} + +interface PointGeom { + sx: number + sy: number + seriesIndex: number + pointIndex: number +} + +export class AreaChart extends HTMLElement { + private _initialised = false + private _series: AreaChartSeries[] = [] + private _xFormatter: (v: string | number) => string = String + private _yFormatter: (v: number) => string = (v) => String(v) + private _resizeObserver: ResizeObserver | null = null + // guards the single post-render width-reconciliation pass + private _reconciling = false + private _lastHostWidth = 0 + private _pointGeom: PointGeom[] = [] + // viewBox dimensions of the most recent render — used to map pointer + // coordinates back into SVG user space on hover. + private _vw = 0 + private _vh = 0 + // optional callback fired alongside the tc-point-hover event + private _onPointHover: ((detail: PointHoverDetail) => void) | null = null + + static get observedAttributes(): string[] { + // NB: `title` is a natively-reflected HTMLElement property — listed here + // so attributeChangedCallback fires, but read via getAttribute (no + // getter/setter, see the porting recipe). + return ['title', 'subtitle', 'height', 'stacked', 'show-grid', 'show-legend', 'loading'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + this._attachResizeObserver() + } + + disconnectedCallback(): void { + this._detachResizeObserver() + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + // ---- JS properties ---- + + get series(): AreaChartSeries[] { + return this._series + } + set series(v: AreaChartSeries[]) { + this._series = Array.isArray(v) ? v : [] + if (this._initialised) this.render() + } + + get xFormatter(): (v: string | number) => string { + return this._xFormatter + } + set xFormatter(fn: (v: string | number) => string) { + this._xFormatter = typeof fn === 'function' ? fn : String + if (this._initialised) this.render() + } + + get yFormatter(): (v: number) => string { + return this._yFormatter + } + set yFormatter(fn: (v: number) => string) { + this._yFormatter = typeof fn === 'function' ? fn : (v) => String(v) + if (this._initialised) this.render() + } + + get onPointHover(): ((detail: PointHoverDetail) => void) | null { + return this._onPointHover + } + set onPointHover(fn: ((detail: PointHoverDetail) => void) | null) { + this._onPointHover = typeof fn === 'function' ? fn : null + } + + // ---- attribute-backed props ---- + + get subtitle(): string { + return this.getAttribute('subtitle') ?? '' + } + set subtitle(v: string) { + this.setAttribute('subtitle', v) + } + + get height(): number { + const v = parseInt(this.getAttribute('height') ?? '', 10) + return v > 0 ? v : DEFAULT_HEIGHT + } + set height(v: number) { + this.setAttribute('height', String(v)) + } + + get stacked(): boolean { + return this.hasAttribute('stacked') + } + set stacked(v: boolean) { + if (v) this.setAttribute('stacked', '') + else this.removeAttribute('stacked') + } + + // show-grid / show-legend default to TRUE — absence means on; only an + // explicit show-grid="false" disables them. + get showGrid(): boolean { + return this.getAttribute('show-grid') !== 'false' + } + set showGrid(v: boolean) { + this.setAttribute('show-grid', v ? 'true' : 'false') + } + + get showLegend(): boolean { + return this.getAttribute('show-legend') !== 'false' + } + set showLegend(v: boolean) { + this.setAttribute('show-legend', v ? 'true' : 'false') + } + + get loading(): boolean { + return this.hasAttribute('loading') + } + set loading(v: boolean) { + if (v) this.setAttribute('loading', '') + else this.removeAttribute('loading') + } + + // ---- resize handling ---- + + // Width the SVG should use for its viewBox: prefer the actual rendered SVG + // box (so 1 user unit == 1 px and nothing is stretched), falling back to the + // host width, then to 560 before the element has been laid out. + private _measurePlot(): number { + const svg = this.querySelector('.tc-area-chart__svg') + if (svg) { + const w = Math.round(svg.getBoundingClientRect().width) + if (w > 0) return w + } + const hostW = Math.round(this.getBoundingClientRect().width) + return hostW > 0 ? hostW : 560 + } + + private _attachResizeObserver(): void { + if (this._resizeObserver) return + this._resizeObserver = new ResizeObserver(() => { + const w = Math.round(this.getBoundingClientRect().width) + if (w > 0 && w !== this._lastHostWidth) { + this._lastHostWidth = w + if (this._initialised) this.render() + } + }) + this._resizeObserver.observe(this) + } + + private _detachResizeObserver(): void { + if (this._resizeObserver) { + this._resizeObserver.disconnect() + this._resizeObserver = null + } + } + + // ---- normalisation ---- + + private _normSeries(): { name: string; color?: string; points: AreaChartPoint[] }[] { + return this._series.map((s) => ({ + name: s.name ?? s.label ?? '', + color: s.color, + points: Array.isArray(s.points) ? s.points : Array.isArray(s.data) ? s.data : [], + })) + } + + private _seriesColorVar(index: number, explicit?: string): string { + return explicit ? esc(explicit) : `var(--bs-area-chart-series-${(index % PALETTE_SIZE) + 1})` + } + + // ---- render ---- + + private render(): void { + if (this.loading) { + this._renderLoading() + return + } + this.removeAttribute('role') + this.removeAttribute('aria-busy') + + const series = this._normSeries() + const titleAttr = this.getAttribute('title') + const subtitle = this.getAttribute('subtitle') + const headerHtml = (titleAttr || subtitle) + ? `
    ` + + (titleAttr ? `
    ${esc(titleAttr)}
    ` : '') + + (subtitle ? `
    ${esc(subtitle)}
    ` : '') + + `
    ` + : '' + + if (!series.length || series.every((s) => !s.points.length)) { + this.innerHTML = `
    ${headerHtml}
    No data
    ` + this._pointGeom = [] + return + } + + const VH = this.height + const VW = this._measurePlot() + const PL = 52, PR = 20, PT = 18, PB = 40 + const cW = Math.max(VW - PL - PR, 10) + const cH = Math.max(VH - PT - PB, 10) + this._vw = VW + this._vh = VH + + // Ordered union of x categories across every series. + const allX: string[] = [] + const seen = new Set() + for (const s of series) { + for (const p of s.points) { + const xs = String(p.x) + if (!seen.has(xs)) { seen.add(xs); allX.push(xs) } + } + } + + const stacked = this.stacked + + // Per-series value at each x (0 when missing) — needed for stacking and + // for computing the axis maximum. + const valueAt = series.map((s) => { + const m = new Map() + for (const p of s.points) m.set(String(p.x), Number(p.y) || 0) + return m + }) + + let yMaxRaw: number + if (stacked) { + yMaxRaw = 0 + for (const xs of allX) { + let sum = 0 + for (const m of valueAt) sum += m.get(xs) ?? 0 + if (sum > yMaxRaw) yMaxRaw = sum + } + } else { + yMaxRaw = 0 + for (const s of series) for (const p of s.points) { + const y = Number(p.y) || 0 + if (y > yMaxRaw) yMaxRaw = y + } + } + const yMax = niceMax(yMaxRaw * 1.1) + + const xPos = (xs: string): number => { + const idx = allX.indexOf(xs) + if (allX.length <= 1) return PL + cW / 2 + return PL + (idx / (allX.length - 1)) * cW + } + const yPos = (val: number): number => PT + cH - (val / yMax) * cH + const baseline = PT + cH + + // --- grid + Y axis labels --- + const YTICKS = 5 + let gridHtml = '' + if (this.showGrid) { + for (let i = 0; i <= YTICKS; i++) { + const t = (yMax / YTICKS) * i + const y = yPos(t) + gridHtml += `` + gridHtml += `${esc(this._yFormatter(t))}` + } + } + + // --- X axis labels (thinned to ~10 max) --- + let xLabelsHtml = '' + const skip = Math.max(1, Math.ceil(allX.length / 10)) + allX.forEach((xs, i) => { + if (i % skip !== 0 && i !== allX.length - 1) return + const raw: string | number = xs + xLabelsHtml += `${esc(this._xFormatter(raw))}` + }) + + // --- axis lines --- + const axesHtml = + `` + + `` + + // --- areas + lines + point geometry --- + this._pointGeom = [] + const cumulative = new Map() + allX.forEach((xs) => cumulative.set(xs, 0)) + + let areasHtml = '' + let linesHtml = '' + + series.forEach((s, si) => { + const colorVar = this._seriesColorVar(si, s.color) + const dash = DASHES[si % DASHES.length] + const dashAttr = dash ? ` stroke-dasharray="${dash}"` : '' + + // The x's this series participates in. For stacked we walk every x + // (missing → 0) so bands stack cleanly; for unstacked we follow only + // the x's the series actually has, like the React AreaChart. + const xsForSeries = stacked + ? allX + : s.points.map((p) => String(p.x)).filter((xs) => seen.has(xs)) + + if (!xsForSeries.length) return + + const tops: { x: number; y: number; xs: string }[] = [] + const bottoms: { x: number; y: number }[] = [] + + xsForSeries.forEach((xs) => { + const val = valueAt[si].get(xs) ?? 0 + const bottomVal = stacked ? (cumulative.get(xs) ?? 0) : 0 + const topVal = bottomVal + val + if (stacked) cumulative.set(xs, topVal) + const px = xPos(xs) + tops.push({ x: px, y: yPos(topVal), xs }) + bottoms.push({ x: px, y: yPos(bottomVal) }) + }) + + // area path: along the tops, back along the bottoms (reversed) + const topCmds = tops.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`) + const botCmds = [...bottoms].reverse().map((p) => `L ${p.x.toFixed(1)} ${p.y.toFixed(1)}`) + const areaD = [...topCmds, ...botCmds, 'Z'].join(' ') + areasHtml += `` + + // top stroke line + const lineD = tops.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ') + linesHtml += `` + + // record point geometry for hover nearest-point lookup, mapping back + // to the original point objects of this series + tops.forEach((p) => { + const original = s.points.findIndex((pt) => String(pt.x) === p.xs) + if (original >= 0) { + this._pointGeom.push({ sx: p.x, sy: p.y, seriesIndex: si, pointIndex: original }) + } + }) + }) + + // dots on the top lines (visual anchors for the tooltip) + let dotsHtml = '' + this._pointGeom.forEach((g) => { + const colorVar = this._seriesColorVar(g.seriesIndex, series[g.seriesIndex].color) + dotsHtml += `` + }) + + const summary = this._summary(series, allX, stacked) + const svgHtml = + `` + + `${esc(summary)}${esc(summary)}` + + gridHtml + axesHtml + areasHtml + linesHtml + dotsHtml + xLabelsHtml + + `` + + // legend + let legendHtml = '' + if (this.showLegend && series.length) { + const chips = series.map((s, si) => { + const colorVar = this._seriesColorVar(si, s.color) + return `${esc(s.name)}` + }).join('') + legendHtml = `
    ${chips}
    ` + } + + const tooltipHtml = `` + + this.innerHTML = `
    ${headerHtml}
    ${svgHtml}${tooltipHtml}
    ${legendHtml}
    ` + + const svg = this.querySelector('.tc-area-chart__svg') + if (svg) { + // If the freshly laid-out SVG is a different width than the value we + // computed the viewBox from, re-render once so geometry maps 1:1 to + // pixels (keeps axis fonts undistorted and tooltip mapping exact). + const realW = Math.round(svg.getBoundingClientRect().width) + if (!this._reconciling && realW > 0 && realW !== VW) { + this._reconciling = true + this.render() + this._reconciling = false + return + } + svg.addEventListener('pointermove', this._onPointerMove) + svg.addEventListener('pointerleave', this._onPointerLeave) + } + } + + private _renderLoading(): void { + this.setAttribute('role', 'status') + this.setAttribute('aria-busy', 'true') + const titleAttr = this.getAttribute('title') + const headHtml = titleAttr + ? `
    ` + : '' + this.innerHTML = + `
    ` + + headHtml + + `` + + `Loading…` + + `
    ` + } + + private _summary(series: { name: string; points: AreaChartPoint[] }[], allX: string[], stacked: boolean): string { + const names = series.map((s) => s.name).filter(Boolean).join(', ') + const kind = stacked ? 'Stacked area chart' : 'Area chart' + const trend = series.map((s) => { + if (s.points.length < 2) return '' + const first = Number(s.points[0].y) || 0 + const last = Number(s.points[s.points.length - 1].y) || 0 + const dir = last > first ? 'rising' : last < first ? 'falling' : 'flat' + return `${s.name} ${dir}` + }).filter(Boolean).join('; ') + return `${kind} of ${series.length} series (${names}) across ${allX.length} points.${trend ? ` Trend: ${trend}.` : ''}` + } + + // ---- hover ---- + + private _onPointerMove = (e: PointerEvent): void => { + if (!this._pointGeom.length) return + const svg = e.currentTarget as SVGSVGElement + const rect = svg.getBoundingClientRect() + if (rect.width === 0 || rect.height === 0) return + // map client coords → SVG user space (viewBox is 0 0 _vw _vh) + const ux = ((e.clientX - rect.left) / rect.width) * this._vw + const uy = ((e.clientY - rect.top) / rect.height) * this._vh + + let best: PointGeom | null = null + let bestDist = Infinity + for (const g of this._pointGeom) { + const dx = g.sx - ux + const dy = g.sy - uy + const d = dx * dx + dy * dy + if (d < bestDist) { bestDist = d; best = g } + } + if (!best) return + + const series = this._series[best.seriesIndex] + const points = Array.isArray(series.points) ? series.points : (series.data ?? []) + const point = points[best.pointIndex] + if (!point) return + + this._showTooltip(best, series, point) + + const detail: PointHoverDetail = { series, point, index: best.pointIndex } + this.dispatchEvent(new CustomEvent('tc-point-hover', { bubbles: true, composed: true, detail })) + if (typeof this._onPointHover === 'function') this._onPointHover(detail) + } + + private _onPointerLeave = (): void => { + const tip = this.querySelector('.tc-area-chart__tooltip') + if (tip) tip.hidden = true + } + + private _showTooltip(g: PointGeom, series: { name: string; color?: string }, point: AreaChartPoint): void { + const tip = this.querySelector('.tc-area-chart__tooltip') + if (!tip) return + const colorVar = this._seriesColorVar(g.seriesIndex, series.color) + const xText = esc(this._xFormatter(point.x)) + const yText = esc(this._yFormatter(Number(point.y) || 0)) + tip.style.setProperty('--tc-series-color', colorVar) + tip.innerHTML = + `${esc(series.name)}` + + `${xText} · ${yText}` + // position in plot pixels: the SVG fills the plot box, viewBox maps 1:1 + // to the rendered box, so scale user coords by the rendered/viewBox ratio + const svg = this.querySelector('.tc-area-chart__svg') + const rect = svg ? svg.getBoundingClientRect() : null + const scaleX = rect && this._vw ? rect.width / this._vw : 1 + const scaleY = rect && this._vh ? rect.height / this._vh : 1 + tip.style.left = `${g.sx * scaleX}px` + tip.style.top = `${g.sy * scaleY}px` + tip.hidden = false + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: AreaChart + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 00300e27..1dcb7b22 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -1,5 +1,6 @@ export { register } from './register' export * from './Brand' +export * from './AreaChart' export * from './Avatar' export * from './AudioMixer' export * from './ActionHeader' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 532a9bc7..60422bc8 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -1,4 +1,5 @@ import { Brand } from './Brand' +import { AreaChart } from './AreaChart' import { Avatar } from './Avatar' import { AdvancedTable } from './AdvancedTable' import { AudioMixer } from './AudioMixer' @@ -227,6 +228,7 @@ export function register(): void { customElements.define('tc-announcement-bar', AnnouncementBar) customElements.define('tc-badge', Badge) customElements.define('tc-badge-row', BadgeRow) + customElements.define('tc-area-chart', AreaChart) customElements.define('tc-benchmark-chart', BenchmarkChart) customElements.define('tc-bitmap-font-generator', BitmapFontGenerator) customElements.define('tc-breadcrumb', Breadcrumb) diff --git a/web-components/style/components/_area-chart.scss b/web-components/style/components/_area-chart.scss new file mode 100644 index 00000000..2fe97e9f --- /dev/null +++ b/web-components/style/components/_area-chart.scss @@ -0,0 +1,239 @@ +// tc-area-chart — SVG area chart with filled regions, gridlines, an interactive +// tooltip, and an optional legend. Slate neutrals carry it: the chart frame sits +// on --tc-surface behind a 1px hairline; gridlines are faint slate hairlines; +// axis labels are mono micro-labels in --tc-text-muted. The primary series rides +// the slate ink ramp; the cyan --tc-accent is spent on at most one emphasized +// series, with the rest stepping down the slate ramp. Area fills are low-opacity +// tints of the series stroke color. The tooltip is overlay tier — ink surface, +// white text, elevation shadow, a 3px colored left stripe per the toast/tooltip +// motif. Sharp corners everywhere. Every cosmetic value flows through a +// --bs-area-chart-* custom property. + +tc-area-chart { + // series ramp — slate ink primary, one cyan emphasis, then down the slate ramp + --bs-area-chart-series-1: var(--tc-app-accent); + --bs-area-chart-series-2: var(--tc-accent); + --bs-area-chart-series-3: var(--tc-slate-500); + --bs-area-chart-series-4: var(--tc-slate-400); + --bs-area-chart-series-5: var(--tc-slate-600); + --bs-area-chart-series-6: var(--tc-slate-300); + + --bs-area-chart-bg: var(--tc-surface); + --bs-area-chart-border: 1px solid var(--tc-border); + --bs-area-chart-grid-color: var(--tc-slate-100); + --bs-area-chart-axis-color: var(--tc-border-strong); + --bs-area-chart-axis-faint-color: var(--tc-slate-200); + --bs-area-chart-label-color: var(--tc-text-muted); + --bs-area-chart-label-size: 11px; + --bs-area-chart-title-color: var(--tc-text); + --bs-area-chart-subtitle-color: var(--tc-text-muted); + --bs-area-chart-area-opacity: 0.12; + --bs-area-chart-line-width: 2px; + --bs-area-chart-dot-stroke: var(--tc-surface); + + --bs-area-chart-tooltip-bg: var(--tc-ink); + --bs-area-chart-tooltip-color: #fff; + --bs-area-chart-tooltip-stripe-width: 3px; + --bs-area-chart-tooltip-shadow: var(--tc-shadow-lg); + + --bs-area-chart-legend-chip-bg: var(--tc-surface-muted); + --bs-area-chart-legend-chip-color: var(--tc-text); + --bs-area-chart-legend-chip-border: 1px solid var(--tc-border); + + --bs-area-chart-skeleton-bg: var(--tc-surface-muted); + + display: block; +} + +.tc-area-chart__inner { + display: flex; + flex-direction: column; + gap: 0.625rem; + padding: 0.875rem 1rem; + background: var(--bs-area-chart-bg); + border: var(--bs-area-chart-border); + border-radius: 0; +} + +// --- header --- +.tc-area-chart__header { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.tc-area-chart__title { + font-size: 0.95rem; + font-weight: 600; + line-height: 1.3; + color: var(--bs-area-chart-title-color); +} + +.tc-area-chart__subtitle { + font-size: 0.8125rem; + line-height: 1.3; + color: var(--bs-area-chart-subtitle-color); +} + +// --- plot --- +.tc-area-chart__plot { + position: relative; +} + +.tc-area-chart__svg { + display: block; + width: 100%; + overflow: visible; + touch-action: none; +} + +.tc-area-chart__grid { + stroke: var(--bs-area-chart-grid-color); + stroke-width: 1; + shape-rendering: crispEdges; +} + +.tc-area-chart__axis { + stroke: var(--bs-area-chart-axis-color); + stroke-width: 1; + shape-rendering: crispEdges; +} + +.tc-area-chart__axis--faint { + stroke: var(--bs-area-chart-axis-faint-color); +} + +.tc-area-chart__y-label, +.tc-area-chart__x-label { + fill: var(--bs-area-chart-label-color); + font-family: var(--tc-font-mono); + font-size: var(--bs-area-chart-label-size); + font-variant-numeric: tabular-nums; +} + +.tc-area-chart__area { + fill: var(--tc-series-color, var(--bs-area-chart-series-1)); + fill-opacity: var(--bs-area-chart-area-opacity); + stroke: none; +} + +.tc-area-chart__line { + stroke: var(--tc-series-color, var(--bs-area-chart-series-1)); + stroke-width: var(--bs-area-chart-line-width); + stroke-linecap: round; + stroke-linejoin: round; + fill: none; +} + +.tc-area-chart__dot { + fill: var(--tc-series-color, var(--bs-area-chart-series-1)); + stroke: var(--bs-area-chart-dot-stroke); + stroke-width: 1.5; +} + +// --- empty --- +.tc-area-chart__empty { + padding: 1.5rem 0; + font-size: 0.8125rem; + color: var(--tc-text-muted); + text-align: center; +} + +// --- tooltip (overlay tier) --- +.tc-area-chart__tooltip { + position: absolute; + z-index: var(--tc-z-tooltip, 1070); + transform: translate(-50%, calc(-100% - 10px)); + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; + padding: 0.3125rem 0.5rem 0.3125rem 0.5rem; + background: var(--bs-area-chart-tooltip-bg); + color: var(--bs-area-chart-tooltip-color); + border-radius: 0; + border-left: var(--bs-area-chart-tooltip-stripe-width) solid var(--tc-series-color, var(--tc-accent)); + box-shadow: var(--bs-area-chart-tooltip-shadow); + white-space: nowrap; + pointer-events: none; +} + +.tc-area-chart__tooltip[hidden] { + display: none; +} + +.tc-area-chart__tooltip-name { + font-family: var(--tc-font-mono); + font-size: 0.625rem; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.75; +} + +.tc-area-chart__tooltip-value { + font-size: 0.78rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +// --- legend --- +.tc-area-chart__legend { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.tc-area-chart__legend-chip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + color: var(--bs-area-chart-legend-chip-color); + background: var(--bs-area-chart-legend-chip-bg); + border: var(--bs-area-chart-legend-chip-border); + border-radius: 0; +} + +.tc-area-chart__legend-dot { + width: 0.625rem; + height: 0.625rem; + background: var(--tc-series-color, var(--bs-area-chart-series-1)); +} + +// --- loading skeleton --- +.tc-area-chart__skeleton { + background: linear-gradient( + 90deg, + var(--bs-area-chart-skeleton-bg) 25%, + var(--tc-surface-hover) 37%, + var(--bs-area-chart-skeleton-bg) 63% + ); + background-size: 200% 100%; + animation: tc-area-chart-shimmer 1.4s ease infinite; +} + +.tc-area-chart__skeleton--title { + width: 40%; + height: 0.95rem; +} + +.tc-area-chart__skeleton--plot { + width: 100%; +} + +@keyframes tc-area-chart-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +@media (prefers-reduced-motion: reduce) { + .tc-area-chart__skeleton { + animation: none; + background: var(--bs-area-chart-skeleton-bg); + } + + .tc-area-chart__tooltip { + transition: none; + } +} diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 1c4c9668..3c033420 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -2,6 +2,7 @@ // Each component task appends a @forward line here, e.g.: // @forward 'button'; @forward 'brand'; +@forward 'area-chart'; @forward 'avatar'; @forward 'build'; @forward 'action-header'; diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index d5165365..5cfd141b 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -122,6 +122,7 @@ tc-asset-row, tc-asset-bundle, tc-build, tc-badge-row, +tc-area-chart, tc-benchmark-chart, tc-bitmap-font-generator, tc-basic-layout, From 9e716c4ebaa760ebb918551c563e50c042ed4945 Mon Sep 17 00:00:00 2001 From: kalevski Date: Mon, 15 Jun 2026 18:08:26 +0000 Subject: [PATCH 247/632] 245-bar-chart: Build the tc-bar-chart web component (BarChart parity) --- examples/public/web-components/SKILL.md | 73 +++ examples/src/web-components/BarChartDemo.tsx | 126 +++++ examples/src/web-components/index.tsx | 2 + web-components/src/BarChart.ts | 436 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + .../style/components/_bar-chart.scss | 238 ++++++++++ web-components/style/components/_index.scss | 1 + web-components/style/foundation/_reset.scss | 1 + 9 files changed, 880 insertions(+) create mode 100644 examples/src/web-components/BarChartDemo.tsx create mode 100644 web-components/src/BarChart.ts create mode 100644 web-components/style/components/_bar-chart.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 6185d56e..8f3e362c 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -50,6 +50,7 @@ After `register()` you can author markup directly: - [tc-badge](#tc-badge) - [tc-badge-row](#tc-badge-row) - [tc-area-chart](#tc-area-chart) + - [tc-bar-chart](#tc-bar-chart) - [tc-benchmark-chart](#tc-benchmark-chart) - [tc-bitmap-font-generator](#tc-bitmap-font-generator) - [tc-brand](#tc-brand) @@ -1278,6 +1279,78 @@ chart.addEventListener('tc-point-hover', (e) => console.log(e.detail.series.name --- +### tc-bar-chart + +SVG bar chart in vertical (columns) or horizontal (rows) orientation, with category/value axis labels, an interactive tooltip, and optional per-bar value labels. Sharp square corners, slate-neutral chart frame on `--tc-surface` behind a 1px hairline; faint slate baseline/axis hairlines; mono micro category + value labels. Bar fills default to the slate ink ramp (`--tc-app-accent`) unless an item supplies its own `color`; the cyan accent is the one rare emphasis slot. The tooltip is overlay tier — ink surface, white text, elevation shadow, a 3px colored left stripe. Every bar is keyboard-reachable (`role="button"`) and clicking/activating a bar fires `tc-bar-click`. Data is set via the `data` JS property; `yFormatter` is a JS function property. + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `title` | `string` | — | Header text rendered above the chart | +| `subtitle` | `string` | — | Secondary header line below the title | +| `orientation` | `vertical\|horizontal` | `vertical` | Lay bars out as columns (vertical) or rows (horizontal) | +| `height` | `number` | `260` | Chart height in px (drives the SVG viewBox) | +| `show-values` | boolean | `false` | Render the formatted value at each bar's end | +| `loading` | boolean | `false` | Render a shimmer skeleton and set `aria-busy="true"` | + +**Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `data` | `BarChartDataItem[]` | `[]` | Array of bar descriptors (see below) | +| `yFormatter` | `(v: number) => string` | `String` | Formats axis ticks, value labels, and the tooltip value | +| `onClick` | `(item, index) => void \| null` | `null` | Optional callback fired (alongside `tc-bar-click`) when a bar is clicked | + +**BarChartDataItem shape** + +| Field | Type | Description | +|-------|------|-------------| +| `label` | `string` | Category label shown on the axis (and in the tooltip) | +| `value` | `number` | Numeric value driving the bar size and the formatted value label | +| `color` | `string` | Optional explicit bar color (any CSS color); otherwise the slate/accent ramp is used | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-bar-click` | `{ item: BarChartDataItem, index: number }` | Fired when a bar is clicked or activated with Enter/Space | + +**Slots** + +_None._ The component owns its ``; arbitrary slotted children are not preserved. + +```html + + + + + + + + + + + + +``` + +--- + ### tc-benchmark-chart Horizontal SVG bar chart comparing benchmark values, with leader highlighting and a linear or log scale. Sharp square corners, slate-neutral track, slate-ink bar fill, JetBrains Mono value numbers; the leader bar gets the one sanctioned emphasis (a 135° slate-ink gradient). Set bars exclusively via the `bars` JS property. diff --git a/examples/src/web-components/BarChartDemo.tsx b/examples/src/web-components/BarChartDemo.tsx new file mode 100644 index 00000000..47a31c95 --- /dev/null +++ b/examples/src/web-components/BarChartDemo.tsx @@ -0,0 +1,126 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const traffic = [ + { label: 'Jan', value: 1200 }, + { label: 'Feb', value: 1900 }, + { label: 'Mar', value: 1700 }, + { label: 'Apr', value: 2400 }, + { label: 'May', value: 2200 }, + { label: 'Jun', value: 3100 }, + { label: 'Jul', value: 3600 }, + { label: 'Aug', value: 4200 }, +] + +const languages = [ + { label: 'TypeScript', value: 48200 }, + { label: 'Rust', value: 31400 }, + { label: 'Go', value: 22800 }, + { label: 'Python', value: 18600 }, + { label: 'Shell', value: 5400 }, +] + +const status = [ + { label: 'Passing', value: 142, color: 'var(--tc-success)' }, + { label: 'Flaky', value: 18, color: 'var(--tc-warning)' }, + { label: 'Failing', value: 6, color: 'var(--tc-danger)' }, +] + +const yCompact = (v: number): string => { + if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k` + return String(Math.round(v)) +} +const yPlain = (v: number): string => String(Math.round(v)) + +const BarChartDemo: React.FC = () => { + const verticalRef = useRef(null) + const horizontalRef = useRef(null) + const statusRef = useRef(null) + const [clicked, setClicked] = useState(null) + + useEffect(() => { + if (verticalRef.current) { + verticalRef.current.data = traffic + verticalRef.current.yFormatter = yCompact + } + if (horizontalRef.current) { + horizontalRef.current.data = languages + horizontalRef.current.yFormatter = yCompact + } + if (statusRef.current) { + statusRef.current.data = status + statusRef.current.yFormatter = yPlain + } + + const el = verticalRef.current + if (el) { + const handler = (e: any) => + setClicked(`${e.detail.item.label} = ${e.detail.item.value} (index ${e.detail.index})`) + el.addEventListener('tc-bar-click', handler) + return () => el.removeEventListener('tc-bar-click', handler) + } + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="BarChart" + description="SVG bar chart in vertical (columns) or horizontal (rows) orientation, with category/value axis labels, an interactive tooltip, and optional per-bar value labels. data and yFormatter are set via JS properties; title/subtitle/orientation/height/show-values/loading are attributes. Clicking a bar fires tc-bar-click." + /> + +
    + + + {/* @ts-ignore */} + +

    + Last clicked bar: {clicked ?? 'none'} +

    +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + +
    +
    +
    +
    +
    + ) +} + +export default BarChartDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 55d72d9a..814c6b2b 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -13,6 +13,7 @@ import BadgeDemo from './BadgeDemo' import BrandDemo from './BrandDemo' import BadgeRowDemo from './BadgeRowDemo' import AreaChartDemo from './AreaChartDemo' +import BarChartDemo from './BarChartDemo' import BenchmarkChartDemo from './BenchmarkChartDemo' import BitmapFontGeneratorDemo from './BitmapFontGeneratorDemo' import BreadcrumbDemo from './BreadcrumbDemo' @@ -235,6 +236,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'brand', category: 'Content', element: }, { key: 'badge-row', category: 'Components', element: }, { key: 'area-chart', category: 'Components', element: }, + { key: 'bar-chart', category: 'Components', element: }, { key: 'benchmark-chart', category: 'Components', element: }, { key: 'bitmap-font-generator', category: 'Components', element: }, { key: 'button', category: 'Components', element: }, diff --git a/web-components/src/BarChart.ts b/web-components/src/BarChart.ts new file mode 100644 index 00000000..e95db988 --- /dev/null +++ b/web-components/src/BarChart.ts @@ -0,0 +1,436 @@ +const TAG_NAME = 'tc-bar-chart' + +// One bar = a category label, a numeric value, and an optional explicit color. +export interface BarChartDataItem { + label: string + value: number + color?: string +} + +export type BarChartOrientation = 'vertical' | 'horizontal' +const ORIENTATIONS: BarChartOrientation[] = ['vertical', 'horizontal'] + +export interface BarClickDetail { + item: BarChartDataItem + index: number +} + +// How many distinct slate/accent ramp slots the SCSS exposes as +// --bs-bar-chart-series-N. Bars cycle through these when no explicit color +// is given. +const PALETTE_SIZE = 6 + +const DEFAULT_HEIGHT = 260 + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +// Round a raw axis maximum up to a visually "nice" number (mirrors the React +// BarChart niceMax). +function niceMax(v: number): number { + if (v <= 0) return 10 + const e = Math.pow(10, Math.floor(Math.log10(v))) + return Math.ceil(v / e) * e +} + +// Tooltip anchor (in SVG user space) + the colour used for the left stripe. +interface BarAnchor { + ax: number + ay: number + color: string +} + +export class BarChart extends HTMLElement { + private _initialised = false + private _data: BarChartDataItem[] = [] + private _yFormatter: (v: number) => string = String + private _resizeObserver: ResizeObserver | null = null + // guards the single post-render width-reconciliation pass + private _reconciling = false + private _lastHostWidth = 0 + // viewBox dimensions of the most recent render — used to scale anchor + // coordinates back into rendered pixels for tooltip positioning. + private _vw = 0 + private _vh = 0 + // per-bar tooltip anchors, indexed by bar index + private _anchors: BarAnchor[] = [] + // optional callback fired alongside the tc-bar-click event + private _onClick: ((item: BarChartDataItem, index: number) => void) | null = null + + static get observedAttributes(): string[] { + // NB: `title` is a natively-reflected HTMLElement property — listed here + // so attributeChangedCallback fires, but read via getAttribute (no + // getter/setter, see the porting recipe). + return ['title', 'subtitle', 'orientation', 'height', 'show-values', 'loading'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + this._attachResizeObserver() + } + + disconnectedCallback(): void { + this._detachResizeObserver() + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + // ---- JS properties ---- + + get data(): BarChartDataItem[] { + return this._data + } + set data(v: BarChartDataItem[]) { + this._data = Array.isArray(v) ? v : [] + if (this._initialised) this.render() + } + + get yFormatter(): (v: number) => string { + return this._yFormatter + } + set yFormatter(fn: (v: number) => string) { + this._yFormatter = typeof fn === 'function' ? fn : String + if (this._initialised) this.render() + } + + get onClick(): ((item: BarChartDataItem, index: number) => void) | null { + return this._onClick + } + set onClick(fn: ((item: BarChartDataItem, index: number) => void) | null) { + this._onClick = typeof fn === 'function' ? fn : null + } + + // ---- attribute-backed props ---- + + get subtitle(): string { + return this.getAttribute('subtitle') ?? '' + } + set subtitle(v: string) { + this.setAttribute('subtitle', v) + } + + get orientation(): BarChartOrientation { + const v = this.getAttribute('orientation') as BarChartOrientation + return ORIENTATIONS.includes(v) ? v : 'vertical' + } + set orientation(v: BarChartOrientation) { + this.setAttribute('orientation', v) + } + + get height(): number { + const v = parseInt(this.getAttribute('height') ?? '', 10) + return v > 0 ? v : DEFAULT_HEIGHT + } + set height(v: number) { + this.setAttribute('height', String(v)) + } + + get showValues(): boolean { + return this.hasAttribute('show-values') + } + set showValues(v: boolean) { + if (v) this.setAttribute('show-values', '') + else this.removeAttribute('show-values') + } + + get loading(): boolean { + return this.hasAttribute('loading') + } + set loading(v: boolean) { + if (v) this.setAttribute('loading', '') + else this.removeAttribute('loading') + } + + // ---- resize handling ---- + + // Width the SVG should use for its viewBox: prefer the actual rendered SVG + // box (so 1 user unit == 1 px and nothing is stretched), falling back to the + // host width, then to 560 before the element has been laid out. + private _measurePlot(): number { + const svg = this.querySelector('.tc-bar-chart__svg') + if (svg) { + const w = Math.round(svg.getBoundingClientRect().width) + if (w > 0) return w + } + const hostW = Math.round(this.getBoundingClientRect().width) + return hostW > 0 ? hostW : 560 + } + + private _attachResizeObserver(): void { + if (this._resizeObserver) return + this._resizeObserver = new ResizeObserver(() => { + const w = Math.round(this.getBoundingClientRect().width) + if (w > 0 && w !== this._lastHostWidth) { + this._lastHostWidth = w + if (this._initialised) this.render() + } + }) + this._resizeObserver.observe(this) + } + + private _detachResizeObserver(): void { + if (this._resizeObserver) { + this._resizeObserver.disconnect() + this._resizeObserver = null + } + } + + // ---- normalisation ---- + + private _barColor(index: number, explicit?: string): string { + return explicit ? esc(explicit) : `var(--bs-bar-chart-series-${(index % PALETTE_SIZE) + 1})` + } + + // ---- render ---- + + private render(): void { + if (this.loading) { + this._renderLoading() + return + } + this.removeAttribute('role') + this.removeAttribute('aria-busy') + + const data = this._data + const titleAttr = this.getAttribute('title') + const subtitle = this.getAttribute('subtitle') + const headerHtml = (titleAttr || subtitle) + ? `
    ` + + (titleAttr ? `
    ${esc(titleAttr)}
    ` : '') + + (subtitle ? `
    ${esc(subtitle)}
    ` : '') + + `
    ` + : '' + + if (!data.length) { + this.innerHTML = `
    ${headerHtml}
    No data
    ` + this._anchors = [] + return + } + + const VH = this.height + const VW = this._measurePlot() + const PL = 52, PR = 14, PT = 18, PB = 38 + const cW = Math.max(VW - PL - PR, 10) + const cH = Math.max(VH - PT - PB, 10) + this._vw = VW + this._vh = VH + + const maxVal = niceMax(Math.max(...data.map((d) => Number(d.value) || 0))) + const TICKS = 5 + const orientation = this.orientation + const showValues = this.showValues + const fmt = this._yFormatter + + this._anchors = [] + let gridHtml = '' + let barsHtml = '' + + if (orientation === 'vertical') { + const bGroup = cW / data.length + const bW = bGroup * 0.65 + const bOff = bGroup * 0.175 + const baseline = PT + cH + + // gridlines + Y axis labels + for (let i = 0; i <= TICKS; i++) { + const t = (maxVal / TICKS) * i + const y = baseline - (t / maxVal) * cH + gridHtml += `` + gridHtml += `${esc(fmt(t))}` + } + // baseline axis + gridHtml += `` + + data.forEach((d, i) => { + const value = Number(d.value) || 0 + const bH = Math.max((value / maxVal) * cH, 0) + const x = PL + i * bGroup + bOff + const y = baseline - bH + const cx = x + bW / 2 + const color = this._barColor(i, d.color) + const valueText = esc(fmt(value)) + + this._anchors[i] = { ax: cx, ay: y, color } + + barsHtml += `` + // generous transparent hit/focus target spanning the whole column cell + + `` + + `` + + (showValues ? `${valueText}` : '') + + `${esc(d.label)}` + + `` + }) + } else { + const bGroup = cH / data.length + const bH = bGroup * 0.65 + const bOff = bGroup * 0.175 + + // vertical gridlines + X axis labels + for (let i = 0; i <= TICKS; i++) { + const t = (maxVal / TICKS) * i + const x = PL + (t / maxVal) * cW + gridHtml += `` + gridHtml += `${esc(fmt(t))}` + } + // value axis (left) + gridHtml += `` + + data.forEach((d, i) => { + const value = Number(d.value) || 0 + const bW = Math.max((value / maxVal) * cW, 0) + const y = PT + i * bGroup + bOff + const cy = y + bH / 2 + const color = this._barColor(i, d.color) + const valueText = esc(fmt(value)) + + this._anchors[i] = { ax: PL + bW, ay: cy, color } + + barsHtml += `` + // generous transparent hit/focus target spanning the whole row cell + + `` + + `` + + (showValues ? `${valueText}` : '') + + `${esc(d.label)}` + + `` + }) + } + + const summary = this._summary(data, orientation) + const svgHtml = + `` + + `${esc(summary)}${esc(summary)}` + + gridHtml + barsHtml + + `` + + const tooltipHtml = `` + + this.innerHTML = `
    ${headerHtml}
    ${svgHtml}${tooltipHtml}
    ` + + const svg = this.querySelector('.tc-bar-chart__svg') + if (svg) { + // If the freshly laid-out SVG is a different width than the value we + // computed the viewBox from, re-render once so geometry maps 1:1 to + // pixels (keeps axis fonts undistorted and tooltip mapping exact). + const realW = Math.round(svg.getBoundingClientRect().width) + if (!this._reconciling && realW > 0 && realW !== VW) { + this._reconciling = true + this.render() + this._reconciling = false + return + } + svg.addEventListener('pointermove', this._onPointerMove) + svg.addEventListener('pointerleave', this._onPointerLeave) + svg.addEventListener('click', this._onBarActivate) + svg.addEventListener('keydown', this._onBarKeydown) + } + } + + private _renderLoading(): void { + this.setAttribute('role', 'status') + this.setAttribute('aria-busy', 'true') + const titleAttr = this.getAttribute('title') + const headHtml = titleAttr + ? `
    ` + : '' + this.innerHTML = + `
    ` + + headHtml + + `` + + `Loading…` + + `
    ` + } + + private _summary(data: BarChartDataItem[], orientation: BarChartOrientation): string { + const fmt = this._yFormatter + const parts = data.slice(0, 12).map((d) => `${d.label} ${fmt(Number(d.value) || 0)}`).join(', ') + const more = data.length > 12 ? `, …` : '' + return `${orientation === 'horizontal' ? 'Horizontal bar' : 'Bar'} chart of ${data.length} items: ${parts}${more}.` + } + + // ---- bar lookup helpers ---- + + private _barIndexFromEvent(e: Event): number { + const target = e.target as Element | null + const g = target?.closest('.tc-bar-chart__bar') + if (!g) return -1 + const idx = parseInt(g.getAttribute('data-idx') ?? '', 10) + if (isNaN(idx) || idx < 0 || idx >= this._data.length) return -1 + return idx + } + + // ---- hover ---- + + private _onPointerMove = (e: PointerEvent): void => { + const idx = this._barIndexFromEvent(e) + if (idx < 0) { + this._onPointerLeave() + return + } + this._showTooltip(idx) + } + + private _onPointerLeave = (): void => { + const tip = this.querySelector('.tc-bar-chart__tooltip') + if (tip) tip.hidden = true + } + + private _showTooltip(idx: number): void { + const tip = this.querySelector('.tc-bar-chart__tooltip') + const anchor = this._anchors[idx] + const item = this._data[idx] + if (!tip || !anchor || !item) return + const valueText = esc(this._yFormatter(Number(item.value) || 0)) + tip.style.setProperty('--tc-bar-color', anchor.color) + tip.innerHTML = + `${esc(item.label)}` + + `${valueText}` + // position in plot pixels: the SVG fills the plot box, viewBox maps 1:1 + // to the rendered box, so scale user coords by the rendered/viewBox ratio + const svg = this.querySelector('.tc-bar-chart__svg') + const rect = svg ? svg.getBoundingClientRect() : null + const scaleX = rect && this._vw ? rect.width / this._vw : 1 + const scaleY = rect && this._vh ? rect.height / this._vh : 1 + tip.style.left = `${anchor.ax * scaleX}px` + tip.style.top = `${anchor.ay * scaleY}px` + tip.hidden = false + } + + // ---- click / keyboard ---- + + private _onBarActivate = (e: Event): void => { + const idx = this._barIndexFromEvent(e) + if (idx < 0) return + const item = this._data[idx] + this.dispatchEvent(new CustomEvent('tc-bar-click', { + bubbles: true, + composed: true, + detail: { item, index: idx } as BarClickDetail, + })) + if (typeof this._onClick === 'function') this._onClick(item, idx) + } + + private _onBarKeydown = (e: KeyboardEvent): void => { + if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return + const g = (e.target as Element | null)?.closest('.tc-bar-chart__bar') + if (!g) return + e.preventDefault() + this._onBarActivate(e) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: BarChart + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 1dcb7b22..0edfcd85 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -17,6 +17,7 @@ export * from './Alert' export * from './AnnouncementBar' export * from './Badge' export * from './BadgeRow' +export * from './BarChart' export * from './BenchmarkChart' export * from './BitmapFontGenerator' export * from './Breadcrumb' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 60422bc8..e788e75c 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -16,6 +16,7 @@ import { Alert } from './Alert' import { AnnouncementBar } from './AnnouncementBar' import { Badge } from './Badge' import { BadgeRow } from './BadgeRow' +import { BarChart } from './BarChart' import { BenchmarkChart } from './BenchmarkChart' import { BitmapFontGenerator } from './BitmapFontGenerator' import { Breadcrumb } from './Breadcrumb' @@ -229,6 +230,7 @@ export function register(): void { customElements.define('tc-badge', Badge) customElements.define('tc-badge-row', BadgeRow) customElements.define('tc-area-chart', AreaChart) + customElements.define('tc-bar-chart', BarChart) customElements.define('tc-benchmark-chart', BenchmarkChart) customElements.define('tc-bitmap-font-generator', BitmapFontGenerator) customElements.define('tc-breadcrumb', Breadcrumb) diff --git a/web-components/style/components/_bar-chart.scss b/web-components/style/components/_bar-chart.scss new file mode 100644 index 00000000..e1934e06 --- /dev/null +++ b/web-components/style/components/_bar-chart.scss @@ -0,0 +1,238 @@ +// tc-bar-chart — SVG bar chart in vertical (columns) or horizontal (rows) +// orientation, with category/value axis labels, an interactive tooltip, and +// optional per-bar value labels. Slate neutrals carry it: the chart frame sits +// on --tc-surface behind a 1px hairline; baseline/axis lines are faint slate +// hairlines; category + value labels are mono micro-labels in --tc-text-muted. +// Bar fills default to the slate ink ramp (--tc-app-accent) unless an item +// supplies its own colour; the cyan --tc-accent is the one rare emphasis slot. +// The tooltip is overlay tier — ink surface, white text, elevation shadow, a +// 3px colored left stripe per the toast/tooltip motif. Sharp corners (radius 0) +// on every bar, the frame, and the tooltip. No shadows on the flat chart +// surface itself (only the overlay tooltip). Every cosmetic value flows through +// a --bs-bar-chart-* custom property. + +tc-bar-chart { + // bar ramp — slate ink primary, one cyan emphasis, then down the slate ramp + --bs-bar-chart-series-1: var(--tc-app-accent); + --bs-bar-chart-series-2: var(--tc-accent); + --bs-bar-chart-series-3: var(--tc-slate-500); + --bs-bar-chart-series-4: var(--tc-slate-400); + --bs-bar-chart-series-5: var(--tc-slate-600); + --bs-bar-chart-series-6: var(--tc-slate-300); + + --bs-bar-chart-bg: var(--tc-surface); + --bs-bar-chart-border: 1px solid var(--tc-border); + --bs-bar-chart-grid-color: var(--tc-slate-100); + --bs-bar-chart-axis-color: var(--tc-slate-200); + --bs-bar-chart-label-color: var(--tc-text-muted); + --bs-bar-chart-label-size: 11px; + --bs-bar-chart-value-color: var(--tc-text); + --bs-bar-chart-title-color: var(--tc-text); + --bs-bar-chart-subtitle-color: var(--tc-text-muted); + + // bar state ladder + --bs-bar-chart-bar-opacity: 1; + --bs-bar-chart-bar-hover-opacity: 0.82; + --bs-bar-chart-focus-ring: var(--tc-focus-ring, var(--tc-accent)); + + --bs-bar-chart-tooltip-bg: var(--tc-ink); + --bs-bar-chart-tooltip-color: #fff; + --bs-bar-chart-tooltip-stripe-width: 3px; + --bs-bar-chart-tooltip-shadow: var(--tc-shadow-md); + + --bs-bar-chart-skeleton-bg: var(--tc-surface-muted); + + display: block; +} + +.tc-bar-chart__inner { + display: flex; + flex-direction: column; + gap: 0.625rem; + padding: 0.875rem 1rem; + background: var(--bs-bar-chart-bg); + border: var(--bs-bar-chart-border); + border-radius: 0; +} + +// --- header --- +.tc-bar-chart__header { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.tc-bar-chart__title { + font-size: 0.95rem; + font-weight: 600; + line-height: 1.3; + color: var(--bs-bar-chart-title-color); +} + +.tc-bar-chart__subtitle { + font-size: 0.8125rem; + line-height: 1.3; + color: var(--bs-bar-chart-subtitle-color); +} + +// --- plot --- +.tc-bar-chart__plot { + position: relative; +} + +.tc-bar-chart__svg { + display: block; + width: 100%; + overflow: visible; + touch-action: none; +} + +.tc-bar-chart__grid { + stroke: var(--bs-bar-chart-grid-color); + stroke-width: 1; + shape-rendering: crispEdges; +} + +.tc-bar-chart__axis { + stroke: var(--bs-bar-chart-axis-color); + stroke-width: 1; + shape-rendering: crispEdges; +} + +.tc-bar-chart__axis-label, +.tc-bar-chart__cat-label { + fill: var(--bs-bar-chart-label-color); + font-family: var(--tc-font-mono); + font-size: var(--bs-bar-chart-label-size); + font-variant-numeric: tabular-nums; +} + +.tc-bar-chart__value { + fill: var(--bs-bar-chart-value-color); + font-family: var(--tc-font-mono); + font-size: var(--bs-bar-chart-label-size); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +// --- bars + state ladder --- +.tc-bar-chart__bar { + cursor: pointer; + outline: none; +} + +.tc-bar-chart__hit { + fill: transparent; +} + +.tc-bar-chart__rect { + fill: var(--tc-bar-color, var(--bs-bar-chart-series-1)); + fill-opacity: var(--bs-bar-chart-bar-opacity); + transition: fill-opacity 0.12s ease; +} + +// hover = a subtle slate well/brighten over the whole bar group +.tc-bar-chart__bar:hover .tc-bar-chart__rect { + fill-opacity: var(--bs-bar-chart-bar-hover-opacity); +} + +// clickable bars show a visible focus ring on keyboard focus. SVG `outline` +// renders inconsistently, so paint the ring as a stroke on the bar rect plus +// an outline fallback on the hit rect for UAs that honour it. +.tc-bar-chart__bar:focus-visible { + outline: none; +} + +.tc-bar-chart__bar:focus-visible .tc-bar-chart__rect { + stroke: var(--bs-bar-chart-focus-ring); + stroke-width: 2; + paint-order: stroke; +} + +.tc-bar-chart__bar:focus-visible .tc-bar-chart__hit { + outline: 2px solid var(--bs-bar-chart-focus-ring); + outline-offset: -1px; +} + +// --- empty --- +.tc-bar-chart__empty { + padding: 1.5rem 0; + font-size: 0.8125rem; + color: var(--tc-text-muted); + text-align: center; +} + +// --- tooltip (overlay tier) --- +.tc-bar-chart__tooltip { + position: absolute; + z-index: var(--tc-z-tooltip, 1070); + transform: translate(-50%, calc(-100% - 10px)); + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; + padding: 0.3125rem 0.5rem; + background: var(--bs-bar-chart-tooltip-bg); + color: var(--bs-bar-chart-tooltip-color); + border-radius: 0; + border-left: var(--bs-bar-chart-tooltip-stripe-width) solid var(--tc-bar-color, var(--tc-accent)); + box-shadow: var(--bs-bar-chart-tooltip-shadow); + white-space: nowrap; + pointer-events: none; +} + +.tc-bar-chart__tooltip[hidden] { + display: none; +} + +.tc-bar-chart__tooltip-name { + font-family: var(--tc-font-mono); + font-size: 0.625rem; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.75; +} + +.tc-bar-chart__tooltip-value { + font-size: 0.78rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +// --- loading skeleton --- +.tc-bar-chart__skeleton { + background: linear-gradient( + 90deg, + var(--bs-bar-chart-skeleton-bg) 25%, + var(--tc-surface-hover) 37%, + var(--bs-bar-chart-skeleton-bg) 63% + ); + background-size: 200% 100%; + animation: tc-bar-chart-shimmer 1.4s ease infinite; +} + +.tc-bar-chart__skeleton--title { + width: 40%; + height: 0.95rem; +} + +.tc-bar-chart__skeleton--plot { + width: 100%; +} + +@keyframes tc-bar-chart-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +@media (prefers-reduced-motion: reduce) { + .tc-bar-chart__skeleton { + animation: none; + background: var(--bs-bar-chart-skeleton-bg); + } + + .tc-bar-chart__rect, + .tc-bar-chart__tooltip { + transition: none; + } +} diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 3c033420..56ff4987 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -16,6 +16,7 @@ @forward 'announcement-bar'; @forward 'badge'; @forward 'badge-row'; +@forward 'bar-chart'; @forward 'benchmark-chart'; @forward 'bitmap-font-generator'; @forward 'breadcrumb'; diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 5cfd141b..96ee9c5c 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -123,6 +123,7 @@ tc-asset-bundle, tc-build, tc-badge-row, tc-area-chart, +tc-bar-chart, tc-benchmark-chart, tc-bitmap-font-generator, tc-basic-layout, From 8b9525f320fe81ab4846bdcb21d5f3f820095625 Mon Sep 17 00:00:00 2001 From: kalevski Date: Mon, 15 Jun 2026 18:16:08 +0000 Subject: [PATCH 248/632] 246-funnel-chart: Build the tc-funnel-chart web component (FunnelChart parity) --- examples/public/web-components/SKILL.md | 73 ++++ .../src/web-components/FunnelChartDemo.tsx | 102 +++++ examples/src/web-components/index.tsx | 2 + web-components/src/FunnelChart.ts | 394 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + .../style/components/_funnel-chart.scss | 232 +++++++++++ web-components/style/components/_index.scss | 1 + web-components/style/foundation/_reset.scss | 1 + 9 files changed, 808 insertions(+) create mode 100644 examples/src/web-components/FunnelChartDemo.tsx create mode 100644 web-components/src/FunnelChart.ts create mode 100644 web-components/style/components/_funnel-chart.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 8f3e362c..a943977a 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -51,6 +51,7 @@ After `register()` you can author markup directly: - [tc-badge-row](#tc-badge-row) - [tc-area-chart](#tc-area-chart) - [tc-bar-chart](#tc-bar-chart) + - [tc-funnel-chart](#tc-funnel-chart) - [tc-benchmark-chart](#tc-benchmark-chart) - [tc-bitmap-font-generator](#tc-bitmap-font-generator) - [tc-brand](#tc-brand) @@ -1351,6 +1352,78 @@ document.querySelector('tc-bar-chart[orientation="horizontal"]').data = [ --- +### tc-funnel-chart + +SVG funnel chart visualising a conversion flow as a vertical stack of tapering trapezoids: each step's top width is proportional to its value and its bottom width matches the next step's value, producing the funnel taper. Each segment is labelled with its stage name (Inter) and its percentage of the first step (JetBrains Mono). Sharp square corners; the chart frame sits on `--tc-surface` behind a 1px hairline. Segment fills default to a graduated slate ramp (kept dark enough for the white in-segment labels) unless a step supplies its own `color` — the one sanctioned data-encoding override. Hover is a subtle highlight (opacity + a hairline outline), focus is a visible 2px accent outline. The tooltip is overlay tier — ink surface, white text, elevation shadow, a 3px colored left stripe. Every segment is keyboard-reachable (`role="button"`, `tabindex="0"`) and clicking/activating one fires `tc-select` and calls the optional `onSelect` callback. Data is set via the `data` JS property. + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `title` | `string` | — | Header text rendered above the chart | +| `subtitle` | `string` | — | Secondary header line below the title | +| `height` | `number` | `320` | Chart height in px (drives the SVG viewBox) | +| `show-labels` | boolean | `true` | Render the per-step label + percentage text; set `show-labels="false"` to hide | +| `loading` | boolean | `false` | Render a shimmer skeleton and set `aria-busy="true"` | + +**Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `data` | `FunnelStep[]` | `[]` | Array of funnel step descriptors (see below) | +| `onSelect` | `(step, index) => void \| null` | `null` | Optional callback fired (alongside `tc-select`) when a segment is clicked/activated | + +**FunnelStep shape** + +| Field | Type | Description | +|-------|------|-------------| +| `label` | `string` | Stage label shown in the segment and the tooltip | +| `value` | `number` | Numeric value driving the segment width; percentage is `value / data[0].value * 100` | +| `color` | `string` | Optional explicit segment color (any CSS color); otherwise the slate ramp is used | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-select` | `{ step: FunnelStep, index: number }` | Fired when a segment is clicked or activated with Enter/Space | + +**Slots** + +_None._ The component owns its ``; arbitrary slotted children are not preserved. + +```html + + + + + + + + + + + + +``` + +--- + ### tc-benchmark-chart Horizontal SVG bar chart comparing benchmark values, with leader highlighting and a linear or log scale. Sharp square corners, slate-neutral track, slate-ink bar fill, JetBrains Mono value numbers; the leader bar gets the one sanctioned emphasis (a 135° slate-ink gradient). Set bars exclusively via the `bars` JS property. diff --git a/examples/src/web-components/FunnelChartDemo.tsx b/examples/src/web-components/FunnelChartDemo.tsx new file mode 100644 index 00000000..44c3149d --- /dev/null +++ b/examples/src/web-components/FunnelChartDemo.tsx @@ -0,0 +1,102 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const conversion = [ + { label: 'Visitors', value: 12400 }, + { label: 'Signups', value: 5600 }, + { label: 'Activated', value: 3100 }, + { label: 'Paying', value: 1450 }, + { label: 'Renewed', value: 820 }, +] + +const checkout = [ + { label: 'Cart', value: 4800, color: 'var(--tc-app-accent)' }, + { label: 'Shipping', value: 3600, color: 'var(--tc-slate-600)' }, + { label: 'Payment', value: 2400, color: 'var(--tc-warning)' }, + { label: 'Confirmed', value: 1900, color: 'var(--tc-success)' }, +] + +const FunnelChartDemo: React.FC = () => { + const mainRef = useRef(null) + const noLabelsRef = useRef(null) + const coloredRef = useRef(null) + const [selected, setSelected] = useState(null) + + useEffect(() => { + if (mainRef.current) mainRef.current.data = conversion + if (noLabelsRef.current) noLabelsRef.current.data = conversion + if (coloredRef.current) coloredRef.current.data = checkout + + const el = mainRef.current + if (el) { + const handler = (e: any) => + setSelected(`${e.detail.step.label} = ${e.detail.step.value} (index ${e.detail.index})`) + el.addEventListener('tc-select', handler) + // also exercise the onSelect callback property + el.onSelect = (step: any, index: number) => + console.log('onSelect', step.label, step.value, index) + return () => el.removeEventListener('tc-select', handler) + } + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="FunnelChart" + description="SVG funnel chart visualising a conversion flow as a vertical stack of tapering trapezoids, each labelled with its stage name and its percentage of the first step. data is set via a JS property; title/subtitle/height/show-labels/loading are attributes. Hovering a segment shows a tooltip; clicking/activating one fires tc-select and calls the onSelect callback." + /> + +
    + + + {/* @ts-ignore */} + +

    + Last selected step: {selected ?? 'none'} +

    +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + +
    +
    +
    +
    +
    + ) +} + +export default FunnelChartDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 814c6b2b..bb6a3700 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -15,6 +15,7 @@ import BadgeRowDemo from './BadgeRowDemo' import AreaChartDemo from './AreaChartDemo' import BarChartDemo from './BarChartDemo' import BenchmarkChartDemo from './BenchmarkChartDemo' +import FunnelChartDemo from './FunnelChartDemo' import BitmapFontGeneratorDemo from './BitmapFontGeneratorDemo' import BreadcrumbDemo from './BreadcrumbDemo' import ButtonDemo from './ButtonDemo' @@ -237,6 +238,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'badge-row', category: 'Components', element: }, { key: 'area-chart', category: 'Components', element: }, { key: 'bar-chart', category: 'Components', element: }, + { key: 'funnel-chart', category: 'Components', element: }, { key: 'benchmark-chart', category: 'Components', element: }, { key: 'bitmap-font-generator', category: 'Components', element: }, { key: 'button', category: 'Components', element: }, diff --git a/web-components/src/FunnelChart.ts b/web-components/src/FunnelChart.ts new file mode 100644 index 00000000..370b1b66 --- /dev/null +++ b/web-components/src/FunnelChart.ts @@ -0,0 +1,394 @@ +const TAG_NAME = 'tc-funnel-chart' + +// One funnel step = a stage label, a numeric value, and an optional explicit colour. +export interface FunnelStep { + label: string + value: number + color?: string +} + +export interface FunnelSelectDetail { + step: FunnelStep + index: number +} + +// How many distinct slate ramp slots the SCSS exposes as --bs-funnel-chart-series-N. +// Segments cycle through these when no explicit colour is given. All tones are kept +// in the darker half of the slate ramp so the white in-segment labels stay legible. +const PALETTE_SIZE = 6 + +const DEFAULT_HEIGHT = 320 +// 1px gap between stacked trapezoids (design: hairline separation, not boxes). +const SEG_GAP = 1 + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +// Tooltip anchor (in SVG user space) + the colour used for the left stripe. +interface FunnelAnchor { + ax: number + ay: number + color: string +} + +export class FunnelChart extends HTMLElement { + private _initialised = false + private _data: FunnelStep[] = [] + private _resizeObserver: ResizeObserver | null = null + // guards the single post-render width-reconciliation pass + private _reconciling = false + private _lastHostWidth = 0 + // viewBox dimensions of the most recent render — used to scale anchor + // coordinates back into rendered pixels for tooltip positioning. + private _vw = 0 + private _vh = 0 + // per-segment tooltip anchors, indexed by step index + private _anchors: FunnelAnchor[] = [] + // optional callback fired alongside the tc-select event + private _onSelect: ((step: FunnelStep, index: number) => void) | null = null + + static get observedAttributes(): string[] { + // NB: `title` is a natively-reflected HTMLElement property — listed here + // so attributeChangedCallback fires, but read via getAttribute (no + // getter/setter, see the porting recipe). + return ['title', 'subtitle', 'height', 'show-labels', 'loading'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + this._attachResizeObserver() + } + + disconnectedCallback(): void { + this._detachResizeObserver() + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + // ---- JS properties ---- + + get data(): FunnelStep[] { + return this._data + } + set data(v: FunnelStep[]) { + this._data = Array.isArray(v) ? v : [] + if (this._initialised) this.render() + } + + get onSelect(): ((step: FunnelStep, index: number) => void) | null { + return this._onSelect + } + set onSelect(fn: ((step: FunnelStep, index: number) => void) | null) { + this._onSelect = typeof fn === 'function' ? fn : null + } + + // ---- attribute-backed props ---- + + get subtitle(): string { + return this.getAttribute('subtitle') ?? '' + } + set subtitle(v: string) { + this.setAttribute('subtitle', v) + } + + get height(): number { + const v = parseInt(this.getAttribute('height') ?? '', 10) + return v > 0 ? v : DEFAULT_HEIGHT + } + set height(v: number) { + this.setAttribute('height', String(v)) + } + + // show-labels defaults to TRUE — labels render unless the attribute is + // explicitly set to "false". + get showLabels(): boolean { + const raw = this.getAttribute('show-labels') + return raw !== 'false' + } + set showLabels(v: boolean) { + if (v) this.removeAttribute('show-labels') + else this.setAttribute('show-labels', 'false') + } + + get loading(): boolean { + return this.hasAttribute('loading') + } + set loading(v: boolean) { + if (v) this.setAttribute('loading', '') + else this.removeAttribute('loading') + } + + // ---- resize handling ---- + + private _attachResizeObserver(): void { + if (this._resizeObserver) return + this._resizeObserver = new ResizeObserver(() => { + const w = Math.round(this.getBoundingClientRect().width) + if (w > 0 && w !== this._lastHostWidth) { + this._lastHostWidth = w + if (this._initialised) this.render() + } + }) + this._resizeObserver.observe(this) + } + + private _detachResizeObserver(): void { + if (this._resizeObserver) { + this._resizeObserver.disconnect() + this._resizeObserver = null + } + } + + // Width the SVG should use for its viewBox: prefer the actual rendered SVG + // box (so 1 user unit == 1 px and nothing is stretched), falling back to the + // host width, then to 480 before the element has been laid out. + private _measurePlot(): number { + const svg = this.querySelector('.tc-funnel-chart__svg') + if (svg) { + const w = Math.round(svg.getBoundingClientRect().width) + if (w > 0) return w + } + const hostW = Math.round(this.getBoundingClientRect().width) + return hostW > 0 ? hostW : 480 + } + + // ---- normalisation ---- + + private _segColor(index: number, explicit?: string): string { + return explicit ? esc(explicit) : `var(--bs-funnel-chart-series-${(index % PALETTE_SIZE) + 1})` + } + + private _percent(value: number): number { + const ref = Number(this._data[0]?.value) || 0 + if (ref <= 0) return 0 + return Math.round((value / ref) * 100) + } + + // ---- render ---- + + private render(): void { + if (this.loading) { + this._renderLoading() + return + } + this.removeAttribute('role') + this.removeAttribute('aria-busy') + + const data = this._data + const titleAttr = this.getAttribute('title') + const subtitle = this.getAttribute('subtitle') + const headerHtml = (titleAttr || subtitle) + ? `
    ` + + (titleAttr ? `
    ${esc(titleAttr)}
    ` : '') + + (subtitle ? `
    ${esc(subtitle)}
    ` : '') + + `
    ` + : '' + + if (!data.length) { + this.innerHTML = `
    ${headerHtml}
    No data
    ` + this._anchors = [] + return + } + + const VH = this.height + const VW = this._measurePlot() + // padding keeps focus rings + tapered edges off the SVG border + const PX = 8, PT = 6, PB = 6 + const maxW = Math.max(VW - PX * 2, 10) + const cx = VW / 2 + const availH = Math.max(VH - PT - PB, 10) + const n = data.length + const segH = Math.max((availH - SEG_GAP * (n - 1)) / n, 1) + this._vw = VW + this._vh = VH + + const values = data.map((d) => Number(d.value) || 0) + const maxVal = Math.max(...values, 0) + const widthFor = (v: number): number => (maxVal > 0 ? (v / maxVal) * maxW : 0) + + const showLabels = this.showLabels + this._anchors = [] + let segsHtml = '' + + data.forEach((step, i) => { + const value = values[i] + // top width follows this step; bottom width tapers to the next step + // (the last segment keeps a straight bottom edge). + const topW = widthFor(value) + const botW = i < n - 1 ? widthFor(values[i + 1]) : topW + const y = PT + i * (segH + SEG_GAP) + const yBottom = y + segH + const color = this._segColor(i, step.color) + const pct = this._percent(value) + + const tlx = cx - topW / 2 + const trx = cx + topW / 2 + const blx = cx - botW / 2 + const brx = cx + botW / 2 + const points = [ + `${tlx.toFixed(1)},${y.toFixed(1)}`, + `${trx.toFixed(1)},${y.toFixed(1)}`, + `${brx.toFixed(1)},${yBottom.toFixed(1)}`, + `${blx.toFixed(1)},${yBottom.toFixed(1)}`, + ].join(' ') + + const midY = y + segH / 2 + this._anchors[i] = { ax: cx, ay: y, color } + + const labelsHtml = showLabels + ? `${esc(step.label)}` + + `${pct}%` + : '' + + const ariaName = esc(`${step.label}: ${value} (${pct}%)`) + segsHtml += `` + // generous transparent hit/focus target spanning the full row band + + `` + + `` + + labelsHtml + + `` + }) + + const summary = this._summary(data) + const svgHtml = + `` + + `${esc(summary)}${esc(summary)}` + + segsHtml + + `` + + const tooltipHtml = `` + + this.innerHTML = `
    ${headerHtml}
    ${svgHtml}${tooltipHtml}
    ` + + const svg = this.querySelector('.tc-funnel-chart__svg') + if (svg) { + // If the freshly laid-out SVG is a different width than the value we + // computed the viewBox from, re-render once so geometry maps 1:1 to + // pixels (keeps labels undistorted and tooltip mapping exact). + const realW = Math.round(svg.getBoundingClientRect().width) + if (!this._reconciling && realW > 0 && realW !== VW) { + this._reconciling = true + this.render() + this._reconciling = false + return + } + svg.addEventListener('pointermove', this._onPointerMove) + svg.addEventListener('pointerleave', this._onPointerLeave) + svg.addEventListener('click', this._onSegmentActivate) + svg.addEventListener('keydown', this._onSegmentKeydown) + } + } + + private _renderLoading(): void { + this.setAttribute('role', 'status') + this.setAttribute('aria-busy', 'true') + const titleAttr = this.getAttribute('title') + const headHtml = titleAttr + ? `
    ` + : '' + this.innerHTML = + `
    ` + + headHtml + + `` + + `Loading…` + + `
    ` + } + + private _summary(data: FunnelStep[]): string { + const parts = data.slice(0, 12) + .map((d) => `${d.label} ${this._percent(Number(d.value) || 0)}%`) + .join(', ') + const more = data.length > 12 ? `, …` : '' + return `Funnel chart of ${data.length} step${data.length !== 1 ? 's' : ''}: ${parts}${more}.` + } + + // ---- segment lookup helpers ---- + + private _segIndexFromEvent(e: Event): number { + const target = e.target as Element | null + const g = target?.closest('.tc-funnel-chart__segment') + if (!g) return -1 + const idx = parseInt(g.getAttribute('data-idx') ?? '', 10) + if (isNaN(idx) || idx < 0 || idx >= this._data.length) return -1 + return idx + } + + // ---- hover ---- + + private _onPointerMove = (e: PointerEvent): void => { + const idx = this._segIndexFromEvent(e) + if (idx < 0) { + this._onPointerLeave() + return + } + this._showTooltip(idx) + } + + private _onPointerLeave = (): void => { + const tip = this.querySelector('.tc-funnel-chart__tooltip') + if (tip) tip.hidden = true + } + + private _showTooltip(idx: number): void { + const tip = this.querySelector('.tc-funnel-chart__tooltip') + const anchor = this._anchors[idx] + const step = this._data[idx] + if (!tip || !anchor || !step) return + const value = Number(step.value) || 0 + const pct = this._percent(value) + // surface the same content via aria-label for assistive tech + tip.setAttribute('aria-label', `${step.label}: ${value} (${pct}%)`) + tip.style.setProperty('--tc-funnel-color', anchor.color) + tip.innerHTML = + `${esc(step.label)}` + + `${value.toLocaleString()} · ${pct}%` + // position in plot pixels: the SVG fills the plot box, viewBox maps 1:1 + // to the rendered box, so scale user coords by the rendered/viewBox ratio + const svg = this.querySelector('.tc-funnel-chart__svg') + const rect = svg ? svg.getBoundingClientRect() : null + const scaleX = rect && this._vw ? rect.width / this._vw : 1 + const scaleY = rect && this._vh ? rect.height / this._vh : 1 + tip.style.left = `${anchor.ax * scaleX}px` + tip.style.top = `${anchor.ay * scaleY}px` + tip.hidden = false + } + + // ---- click / keyboard ---- + + private _onSegmentActivate = (e: Event): void => { + const idx = this._segIndexFromEvent(e) + if (idx < 0) return + const step = this._data[idx] + this.dispatchEvent(new CustomEvent('tc-select', { + bubbles: true, + composed: true, + detail: { step, index: idx } as FunnelSelectDetail, + })) + if (typeof this._onSelect === 'function') this._onSelect(step, idx) + } + + private _onSegmentKeydown = (e: KeyboardEvent): void => { + if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return + const g = (e.target as Element | null)?.closest('.tc-funnel-chart__segment') + if (!g) return + e.preventDefault() + this._onSegmentActivate(e) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: FunnelChart + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 0edfcd85..59b5b201 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -176,6 +176,7 @@ export * from './File' export * from './FileDropzone' export * from './FileTags' export * from './FormWizard' +export * from './FunnelChart' export * from './GameShowcaseCard' export * from './GithubStarsCard' export * from './Group' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index e788e75c..367c2d59 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -174,6 +174,7 @@ import { File } from './File' import { FileDropzone } from './FileDropzone' import { FileTags } from './FileTags' import { FormWizard } from './FormWizard' +import { FunnelChart } from './FunnelChart' import { GameShowcaseCard } from './GameShowcaseCard' import { GithubStarsCard } from './GithubStarsCard' import { Group } from './Group' @@ -390,6 +391,7 @@ export function register(): void { customElements.define('tc-file-dropzone', FileDropzone) customElements.define('tc-file-tags', FileTags) customElements.define('tc-form-wizard', FormWizard) + customElements.define('tc-funnel-chart', FunnelChart) customElements.define('tc-game-showcase-card', GameShowcaseCard) customElements.define('tc-github-stars-card', GithubStarsCard) customElements.define('tc-group', Group) diff --git a/web-components/style/components/_funnel-chart.scss b/web-components/style/components/_funnel-chart.scss new file mode 100644 index 00000000..1b98155c --- /dev/null +++ b/web-components/style/components/_funnel-chart.scss @@ -0,0 +1,232 @@ +// tc-funnel-chart — SVG funnel chart visualising a conversion flow: a vertical +// stack of trapezoids that taper from the widest first step down, each labelled +// with its stage name (Inter) and its percentage of the first step (JetBrains +// Mono). Slate neutrals carry it: the chart frame sits on --tc-surface behind a +// 1px hairline; segment fills default to a graduated slate ramp (kept in the +// darker half so the white in-segment labels stay legible). A per-step `color` +// from data is the one sanctioned override (status/data encoding). Hover is a +// subtle highlight (opacity + a hairline outline), never a new hue; focus is a +// visible 2px accent outline. The tooltip is overlay tier — ink surface, white +// text, elevation shadow, a 3px colored left stripe per the toast/tooltip motif. +// Sharp corners (radius 0) everywhere; trapezoids are geometric, not rounded. +// Every cosmetic value flows through a --bs-funnel-chart-* custom property. + +tc-funnel-chart { + // segment ramp — graduated slate ink tones, all dark enough for white labels + --bs-funnel-chart-series-1: var(--tc-app-accent); + --bs-funnel-chart-series-2: var(--tc-slate-700); + --bs-funnel-chart-series-3: var(--tc-slate-600); + --bs-funnel-chart-series-4: var(--tc-slate-500); + --bs-funnel-chart-series-5: var(--tc-slate-700); + --bs-funnel-chart-series-6: var(--tc-slate-600); + + --bs-funnel-chart-bg: var(--tc-surface); + --bs-funnel-chart-border: 1px solid var(--tc-border); + --bs-funnel-chart-title-color: var(--tc-text); + --bs-funnel-chart-subtitle-color: var(--tc-text-muted); + + // in-segment labels — white on the dark slate fills + --bs-funnel-chart-label-color: #fff; + --bs-funnel-chart-label-size: 0.8125rem; + --bs-funnel-chart-percent-color: rgba(255, 255, 255, 0.85); + --bs-funnel-chart-percent-size: 0.6875rem; + + // segment state ladder + --bs-funnel-chart-segment-opacity: 1; + --bs-funnel-chart-segment-hover-opacity: 0.85; + --bs-funnel-chart-hover-outline: rgba(255, 255, 255, 0.6); + --bs-funnel-chart-focus-ring: var(--tc-app-accent); + + --bs-funnel-chart-tooltip-bg: var(--tc-ink); + --bs-funnel-chart-tooltip-color: #fff; + --bs-funnel-chart-tooltip-stripe-width: 3px; + --bs-funnel-chart-tooltip-shadow: var(--tc-shadow-md); + + --bs-funnel-chart-skeleton-bg: var(--tc-surface-muted); + + display: block; +} + +.tc-funnel-chart__inner { + display: flex; + flex-direction: column; + gap: 0.625rem; + padding: 0.875rem 1rem; + background: var(--bs-funnel-chart-bg); + border: var(--bs-funnel-chart-border); + border-radius: 0; +} + +// --- header --- +.tc-funnel-chart__header { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.tc-funnel-chart__title { + font-size: 0.95rem; + font-weight: 600; + line-height: 1.3; + color: var(--bs-funnel-chart-title-color); +} + +.tc-funnel-chart__subtitle { + font-size: 0.8125rem; + line-height: 1.3; + color: var(--bs-funnel-chart-subtitle-color); +} + +// --- plot --- +.tc-funnel-chart__plot { + position: relative; +} + +.tc-funnel-chart__svg { + display: block; + width: 100%; + overflow: visible; + touch-action: none; +} + +// --- segments + state ladder --- +.tc-funnel-chart__segment { + cursor: pointer; + outline: none; +} + +.tc-funnel-chart__hit { + fill: transparent; +} + +.tc-funnel-chart__shape { + fill: var(--tc-funnel-color, var(--bs-funnel-chart-series-1)); + fill-opacity: var(--bs-funnel-chart-segment-opacity); + transition: fill-opacity 0.12s ease; +} + +// hover = a subtle brighten + a hairline outline over the trapezoid (no new hue) +.tc-funnel-chart__segment:hover .tc-funnel-chart__shape { + fill-opacity: var(--bs-funnel-chart-segment-hover-opacity); + stroke: var(--bs-funnel-chart-hover-outline); + stroke-width: 1; + paint-order: stroke; +} + +// in-segment text labels +.tc-funnel-chart__label { + fill: var(--bs-funnel-chart-label-color); + font-family: var(--tc-font-sans, 'Inter', sans-serif); + font-size: var(--bs-funnel-chart-label-size); + font-weight: 500; + pointer-events: none; +} + +.tc-funnel-chart__percent { + fill: var(--bs-funnel-chart-percent-color); + font-family: var(--tc-font-mono); + font-size: var(--bs-funnel-chart-percent-size); + font-variant-numeric: tabular-nums; + pointer-events: none; +} + +// clickable segments show a visible focus ring on keyboard focus. SVG `outline` +// renders inconsistently, so paint the ring as a stroke on the shape plus an +// outline fallback on the hit rect for UAs that honour it. +.tc-funnel-chart__segment:focus-visible { + outline: none; +} + +.tc-funnel-chart__segment:focus-visible .tc-funnel-chart__shape { + stroke: var(--bs-funnel-chart-focus-ring); + stroke-width: 2; + paint-order: stroke; +} + +.tc-funnel-chart__segment:focus-visible .tc-funnel-chart__hit { + outline: 2px solid var(--bs-funnel-chart-focus-ring); + outline-offset: -1px; +} + +// --- empty --- +.tc-funnel-chart__empty { + padding: 1.5rem 0; + font-size: 0.8125rem; + color: var(--tc-text-muted); + text-align: center; +} + +// --- tooltip (overlay tier) --- +.tc-funnel-chart__tooltip { + position: absolute; + z-index: var(--tc-z-tooltip, 1070); + transform: translate(-50%, calc(-100% - 10px)); + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; + padding: 0.3125rem 0.5rem; + background: var(--bs-funnel-chart-tooltip-bg); + color: var(--bs-funnel-chart-tooltip-color); + border-radius: 0; + border-left: var(--bs-funnel-chart-tooltip-stripe-width) solid var(--tc-funnel-color, var(--tc-accent)); + box-shadow: var(--bs-funnel-chart-tooltip-shadow); + white-space: nowrap; + pointer-events: none; +} + +.tc-funnel-chart__tooltip[hidden] { + display: none; +} + +.tc-funnel-chart__tooltip-name { + font-family: var(--tc-font-mono); + font-size: 0.625rem; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.75; +} + +.tc-funnel-chart__tooltip-value { + font-size: 0.78rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +// --- loading skeleton (slate neutrals only) --- +.tc-funnel-chart__skeleton { + background: linear-gradient( + 90deg, + var(--bs-funnel-chart-skeleton-bg) 25%, + var(--tc-surface-hover) 37%, + var(--bs-funnel-chart-skeleton-bg) 63% + ); + background-size: 200% 100%; + animation: tc-funnel-chart-shimmer 1.4s ease infinite; +} + +.tc-funnel-chart__skeleton--title { + width: 40%; + height: 0.95rem; +} + +.tc-funnel-chart__skeleton--plot { + width: 100%; +} + +@keyframes tc-funnel-chart-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +@media (prefers-reduced-motion: reduce) { + .tc-funnel-chart__skeleton { + animation: none; + background: var(--bs-funnel-chart-skeleton-bg); + } + + .tc-funnel-chart__shape, + .tc-funnel-chart__tooltip { + transition: none; + } +} diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 56ff4987..f53f1e78 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -177,6 +177,7 @@ @forward 'file-dropzone'; @forward 'file-tags'; @forward 'form-wizard'; +@forward 'funnel-chart'; @forward 'game-showcase-card'; @forward 'github-stars-card'; @forward 'group'; diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 96ee9c5c..36f199d1 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -239,6 +239,7 @@ tc-file, tc-file-dropzone, tc-file-tags, tc-form-wizard, +tc-funnel-chart, tc-game-showcase-card, tc-github-stars-card, tc-group, From 5acf49098f8d452c87a9af0a6201d08dc935de71 Mon Sep 17 00:00:00 2001 From: kalevski Date: Mon, 15 Jun 2026 18:25:04 +0000 Subject: [PATCH 249/632] 247-gantt-chart: Build the tc-gantt-chart web component (GanttChart parity) --- examples/public/web-components/SKILL.md | 74 ++++ .../src/web-components/GanttChartDemo.tsx | 88 +++++ examples/src/web-components/index.tsx | 2 + web-components/src/GanttChart.ts | 367 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + .../style/components/_gantt-chart.scss | 333 ++++++++++++++++ web-components/style/components/_index.scss | 1 + web-components/style/foundation/_reset.scss | 1 + 9 files changed, 869 insertions(+) create mode 100644 examples/src/web-components/GanttChartDemo.tsx create mode 100644 web-components/src/GanttChart.ts create mode 100644 web-components/style/components/_gantt-chart.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index a943977a..fdbddecd 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -52,6 +52,7 @@ After `register()` you can author markup directly: - [tc-area-chart](#tc-area-chart) - [tc-bar-chart](#tc-bar-chart) - [tc-funnel-chart](#tc-funnel-chart) + - [tc-gantt-chart](#tc-gantt-chart) - [tc-benchmark-chart](#tc-benchmark-chart) - [tc-bitmap-font-generator](#tc-bitmap-font-generator) - [tc-brand](#tc-brand) @@ -1424,6 +1425,79 @@ document.querySelector('tc-funnel-chart').data = [ --- +### tc-gantt-chart + +Gantt chart with time-based task bars, progress fills, a date-marker axis, and a horizontally-scrolling body. Each bar's `left`/`width` is computed as a percentage of the overall span (`start-date` → `end-date`, or derived from the earliest/latest task date when those attributes are omitted). Sharp square corners; the frame is the Card surface (white `--tc-surface`, 1px hairline, `--tc-shadow-sm`). Row separators are slate-100 inner hairlines; axis ticks are JetBrains Mono micro-labels. Bar bases default to a muted slate fill; the inner progress fill uses `--tc-app-accent` ink (or the task's own `color` — the one sanctioned data-encoding override) and its width tracks `progress`%. Hover shows an overlay-tier tooltip (ink surface, white text, `--tc-shadow-md`, 3px colored left stripe) with the task label, dates, and progress; the body scrolls horizontally when content exceeds the width. Every bar is keyboard-reachable (`role="button"`, `tabindex="0"`, Enter/Space activates) with a visible focus outline; clicking/activating one fires `tc-task-click` and calls the optional `onTaskClick` callback. Tasks are set via the `tasks` JS property. + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `title` | `string` | — | Header text rendered above the chart; also the chart's accessible name | +| `subtitle` | `string` | — | Secondary header line below the title | +| `start-date` | `string` (ISO date) | earliest task start | Pins the left edge of the span | +| `end-date` | `string` (ISO date) | latest task end | Pins the right edge of the span | +| `loading` | boolean | `false` | Render a shimmer skeleton and set `aria-busy="true"` | + +**Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `tasks` | `GanttTask[]` | `[]` | Array of task bar descriptors (see below) | +| `onTaskClick` | `(task) => void \| null` | `null` | Optional callback fired (alongside `tc-task-click`) when a bar is clicked/activated | + +**GanttTask shape** + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | Stable identifier for the task | +| `label` | `string` | Display label shown in the left cell, tooltip, and accessible name (`name` is accepted as an alias) | +| `start` | `string` (ISO date) | Task start date | +| `end` | `string` (ISO date) | Task end date | +| `progress` | `number` | Optional 0–100 completion; drives the inner fill width | +| `color` | `string` | Optional explicit fill color (any CSS color); otherwise the ink accent is used | +| `group` | `string` | Optional grouping key surfaced in the accessible name + tooltip | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-task-click` | `{ task: GanttTask }` | Fired when a task bar is clicked or activated with Enter/Space | + +**Slots** + +_None._ The component owns its markup; arbitrary slotted children are not preserved. + +```html + + + + + + + + + + +``` + +--- + ### tc-benchmark-chart Horizontal SVG bar chart comparing benchmark values, with leader highlighting and a linear or log scale. Sharp square corners, slate-neutral track, slate-ink bar fill, JetBrains Mono value numbers; the leader bar gets the one sanctioned emphasis (a 135° slate-ink gradient). Set bars exclusively via the `bars` JS property. diff --git a/examples/src/web-components/GanttChartDemo.tsx b/examples/src/web-components/GanttChartDemo.tsx new file mode 100644 index 00000000..0fba4923 --- /dev/null +++ b/examples/src/web-components/GanttChartDemo.tsx @@ -0,0 +1,88 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const roadmap = [ + { id: 'design', label: 'Design system', start: '2026-01-05', end: '2026-01-20', progress: 100 }, + { id: 'api', label: 'API contract', start: '2026-01-15', end: '2026-02-05', progress: 80 }, + { id: 'frontend', label: 'Frontend build', start: '2026-01-25', end: '2026-03-01', progress: 55 }, + { id: 'backend', label: 'Backend build', start: '2026-02-01', end: '2026-03-10', progress: 40 }, + { id: 'qa', label: 'QA & hardening', start: '2026-03-05', end: '2026-03-25', progress: 10 }, + { id: 'launch', label: 'Launch', start: '2026-03-25', end: '2026-04-01' }, +] + +const colored = [ + { id: 'a', label: 'Discovery', start: '2026-02-01', end: '2026-02-10', progress: 100, color: 'var(--tc-success)' }, + { id: 'b', label: 'Prototype', start: '2026-02-08', end: '2026-02-22', progress: 70, color: 'var(--tc-app-accent)' }, + { id: 'c', label: 'Review', start: '2026-02-20', end: '2026-02-28', progress: 30, color: 'var(--tc-warning)' }, +] + +const GanttChartDemo: React.FC = () => { + const mainRef = useRef(null) + const coloredRef = useRef(null) + const [clicked, setClicked] = useState(null) + + useEffect(() => { + if (coloredRef.current) coloredRef.current.tasks = colored + + const el = mainRef.current + if (el) { + el.tasks = roadmap + const handler = (e: any) => + setClicked(`${e.detail.task.label} (${e.detail.task.start} → ${e.detail.task.end})`) + el.addEventListener('tc-task-click', handler) + // also exercise the onTaskClick callback property + el.onTaskClick = (task: any) => console.log('onTaskClick', task.label) + return () => el.removeEventListener('tc-task-click', handler) + } + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="GanttChart" + description="Gantt chart with time-based task bars, progress fills, a date-marker axis, and a horizontally-scrolling body. tasks are set via a JS property; title/subtitle/start-date/end-date/loading are attributes. Each bar is keyboard-reachable; hovering shows a tooltip and clicking/activating one fires tc-task-click and calls the onTaskClick callback." + /> + +
    + + + {/* @ts-ignore */} + +

    + Last clicked task: {clicked ?? 'none'} +

    +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + +
    +
    +
    +
    +
    + ) +} + +export default GanttChartDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index bb6a3700..2be800f5 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -16,6 +16,7 @@ import AreaChartDemo from './AreaChartDemo' import BarChartDemo from './BarChartDemo' import BenchmarkChartDemo from './BenchmarkChartDemo' import FunnelChartDemo from './FunnelChartDemo' +import GanttChartDemo from './GanttChartDemo' import BitmapFontGeneratorDemo from './BitmapFontGeneratorDemo' import BreadcrumbDemo from './BreadcrumbDemo' import ButtonDemo from './ButtonDemo' @@ -239,6 +240,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'area-chart', category: 'Components', element: }, { key: 'bar-chart', category: 'Components', element: }, { key: 'funnel-chart', category: 'Components', element: }, + { key: 'gantt-chart', category: 'Components', element: }, { key: 'benchmark-chart', category: 'Components', element: }, { key: 'bitmap-font-generator', category: 'Components', element: }, { key: 'button', category: 'Components', element: }, diff --git a/web-components/src/GanttChart.ts b/web-components/src/GanttChart.ts new file mode 100644 index 00000000..4ff49025 --- /dev/null +++ b/web-components/src/GanttChart.ts @@ -0,0 +1,367 @@ +const TAG_NAME = 'tc-gantt-chart' + +// One task bar on the Gantt timeline. `label` is the canonical display field; +// `name` is accepted as a fallback so callers can pass react-components task +// objects unchanged. `start`/`end` are ISO date strings. `progress` (0–100) +// drives the inner fill; `color` overrides the default slate bar tint; `group` +// is an optional grouping key surfaced in the accessible name + tooltip. +export interface GanttTask { + id: string + label?: string + // react-components compatibility alias for `label` + name?: string + start: string + end: string + progress?: number + color?: string + group?: string +} + +export interface GanttTaskClickDetail { + task: GanttTask +} + +const DAY_MS = 86_400_000 +// How many date markers to aim for across the span before thinning. +const TARGET_MARKERS = 8 +// Minimum bar width as a percentage of the span so single-day tasks stay visible. +const MIN_BAR_PCT = 1.5 + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +// Short "M/D" tick label from an epoch ms (mirrors the React GanttChart marker). +function fmtTick(ms: number): string { + const d = new Date(ms) + return `${d.getMonth() + 1}/${d.getDate()}` +} + +interface ResolvedTask { + task: GanttTask + label: string + leftPct: number + widthPct: number + progress: number | null + color: string | null +} + +export class GanttChart extends HTMLElement { + private _initialised = false + private _tasks: GanttTask[] = [] + // resolved geometry of the most recent render, indexed by data-idx + private _resolved: ResolvedTask[] = [] + // optional callback fired alongside the tc-task-click event + private _onTaskClick: ((task: GanttTask) => void) | null = null + + static get observedAttributes(): string[] { + // NB: `title` is a natively-reflected HTMLElement property — listed here + // so attributeChangedCallback fires, but read via getAttribute (no + // getter/setter, see the porting recipe). + return ['title', 'subtitle', 'start-date', 'end-date', 'loading'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + // ---- JS properties ---- + + get tasks(): GanttTask[] { + return this._tasks + } + set tasks(v: GanttTask[]) { + this._tasks = Array.isArray(v) ? v : [] + if (this._initialised) this.render() + } + + get onTaskClick(): ((task: GanttTask) => void) | null { + return this._onTaskClick + } + set onTaskClick(fn: ((task: GanttTask) => void) | null) { + this._onTaskClick = typeof fn === 'function' ? fn : null + } + + // ---- attribute-backed props ---- + + get subtitle(): string { + return this.getAttribute('subtitle') ?? '' + } + set subtitle(v: string) { + this.setAttribute('subtitle', v) + } + + get startDate(): string | null { + return this.getAttribute('start-date') + } + set startDate(v: string | null) { + if (v != null) this.setAttribute('start-date', v) + else this.removeAttribute('start-date') + } + + get endDate(): string | null { + return this.getAttribute('end-date') + } + set endDate(v: string | null) { + if (v != null) this.setAttribute('end-date', v) + else this.removeAttribute('end-date') + } + + get loading(): boolean { + return this.hasAttribute('loading') + } + set loading(v: boolean) { + if (v) this.setAttribute('loading', '') + else this.removeAttribute('loading') + } + + // ---- geometry ---- + + private _taskLabel(t: GanttTask): string { + return t.label ?? t.name ?? '' + } + + // Build the resolved bar geometry + the date markers for the current span. + private _layout(): { resolved: ResolvedTask[]; markers: { pct: number; label: string }[] } { + const tasks = this._tasks + if (!tasks.length) return { resolved: [], markers: [] } + + const starts = tasks.map((t) => new Date(t.start).getTime()).filter((n) => !isNaN(n)) + const ends = tasks.map((t) => new Date(t.end).getTime()).filter((n) => !isNaN(n)) + + const startAttr = this.startDate ? new Date(this.startDate).getTime() : NaN + const endAttr = this.endDate ? new Date(this.endDate).getTime() : NaN + const ts = !isNaN(startAttr) ? startAttr : (starts.length ? Math.min(...starts) : 0) + const te = !isNaN(endAttr) ? endAttr : (ends.length ? Math.max(...ends) : ts + DAY_MS) + const totalMs = te - ts || DAY_MS + + const pctFor = (ms: number): number => ((ms - ts) / totalMs) * 100 + + const resolved: ResolvedTask[] = tasks.map((t) => { + const s = new Date(t.start).getTime() + const e = new Date(t.end).getTime() + const sValid = isNaN(s) ? ts : s + const eValid = isNaN(e) ? sValid : e + const left = Math.max(0, Math.min(100, pctFor(sValid))) + const rawWidth = pctFor(eValid) - pctFor(sValid) + const width = Math.max(MIN_BAR_PCT, Math.min(100 - left, rawWidth)) + const progress = typeof t.progress === 'number' + ? Math.max(0, Math.min(100, t.progress)) + : null + return { + task: t, + label: this._taskLabel(t), + leftPct: left, + widthPct: width, + progress, + color: typeof t.color === 'string' && t.color ? t.color : null, + } + }) + + // Date markers at a sensible interval across the span. + const totalDays = Math.max(1, Math.ceil(totalMs / DAY_MS)) + const stepDays = Math.max(1, Math.ceil(totalDays / TARGET_MARKERS)) + const markers: { pct: number; label: string }[] = [] + for (let d = 0; d <= totalDays; d += stepDays) { + const ms = ts + d * DAY_MS + markers.push({ pct: Math.min(100, (d * DAY_MS / totalMs) * 100), label: fmtTick(ms) }) + } + + return { resolved, markers } + } + + // ---- render ---- + + private render(): void { + if (this.loading) { + this._renderLoading() + return + } + this.removeAttribute('aria-busy') + + const titleAttr = this.getAttribute('title') + const subtitle = this.getAttribute('subtitle') + this.setAttribute('role', 'group') + this.setAttribute('aria-label', titleAttr || 'Gantt chart') + + const headerHtml = (titleAttr || subtitle) + ? `
    ` + + (titleAttr ? `
    ${esc(titleAttr)}
    ` : '') + + (subtitle ? `
    ${esc(subtitle)}
    ` : '') + + `
    ` + : '' + + if (!this._tasks.length) { + this._resolved = [] + this.innerHTML = `
    ${headerHtml}
    No tasks
    ` + return + } + + const { resolved, markers } = this._layout() + this._resolved = resolved + + // Content scales with the number of days so wide spans overflow the + // scroll container horizontally; a floor keeps short spans readable. + const contentMin = Math.max(560, markers.length * 90) + + const ticksHtml = markers.map((m) => + `${esc(m.label)}` + ).join('') + + const gridlinesHtml = markers.map((m) => + `` + ).join('') + + const rowsHtml = resolved.map((r, i) => { + const colorStyle = r.color ? `--bs-gantt-chart-bar-color:${esc(r.color)};` : '' + const fillPct = r.progress != null ? r.progress : 100 + const progressLabel = r.progress != null ? `, ${r.progress}% complete` : '' + const groupLabel = r.task.group ? ` (${r.task.group})` : '' + const ariaName = esc(`${r.label}${groupLabel}: ${r.task.start} to ${r.task.end}${progressLabel}`) + return `
    ` + + `
    ${esc(r.label)}
    ` + + `
    ` + + `
    ` + + `
    ` + + `
    ` + + `
    ` + + `
    ` + }).join('') + + const tooltipHtml = `` + + this.innerHTML = + `
    ` + + headerHtml + + `
    ` + + `
    ` + + `
    ${ticksHtml}
    ` + + `
    ${gridlinesHtml}
    ${rowsHtml}
    ` + + `
    ` + + `
    ` + + tooltipHtml + + `
    ` + + const grid = this.querySelector('.tc-gantt-chart-grid') + if (grid) { + grid.addEventListener('pointermove', this._onPointerMove) + grid.addEventListener('pointerleave', this._onPointerLeave) + grid.addEventListener('click', this._onBarActivate) + grid.addEventListener('keydown', this._onBarKeydown) + } + } + + private _renderLoading(): void { + this.setAttribute('role', 'status') + this.setAttribute('aria-busy', 'true') + const titleAttr = this.getAttribute('title') + const headHtml = titleAttr + ? `
    ` + : '' + const rows = Array.from({ length: 5 }, () => + `` + ).join('') + this.innerHTML = + `
    ` + + headHtml + + `
    ${rows}
    ` + + `Loading…` + + `
    ` + } + + // ---- bar lookup ---- + + private _barIndexFromEvent(e: Event): number { + const target = e.target as Element | null + const bar = target?.closest('.tc-gantt-chart-bar') + if (!bar) return -1 + const idx = parseInt(bar.getAttribute('data-idx') ?? '', 10) + if (isNaN(idx) || idx < 0 || idx >= this._resolved.length) return -1 + return idx + } + + // ---- hover ---- + + private _onPointerMove = (e: PointerEvent): void => { + const idx = this._barIndexFromEvent(e) + if (idx < 0) { + this._onPointerLeave() + return + } + this._showTooltip(idx) + } + + private _onPointerLeave = (): void => { + const tip = this.querySelector('.tc-gantt-chart-tooltip') + if (tip) tip.hidden = true + } + + private _showTooltip(idx: number): void { + const tip = this.querySelector('.tc-gantt-chart-tooltip') + const root = this.querySelector('.tc-gantt-chart') + const bar = this.querySelector(`.tc-gantt-chart-bar[data-idx="${idx}"]`) + const r = this._resolved[idx] + if (!tip || !root || !bar || !r) return + + const progressText = r.progress != null ? ` · ${r.progress}%` : '' + // mirror the visible content for assistive tech + tip.setAttribute('aria-label', `${r.label}: ${r.task.start} – ${r.task.end}${progressText}`) + if (r.color) tip.style.setProperty('--bs-gantt-chart-bar-color', r.color) + else tip.style.removeProperty('--bs-gantt-chart-bar-color') + tip.innerHTML = + `${esc(r.label)}` + + `${esc(r.task.start)} – ${esc(r.task.end)}${progressText}` + + // position relative to the chart root (which is position: relative); the + // bar may be scrolled, so derive its centre from live bounding boxes. + const rootRect = root.getBoundingClientRect() + const barRect = bar.getBoundingClientRect() + tip.style.left = `${barRect.left + barRect.width / 2 - rootRect.left}px` + tip.style.top = `${barRect.top - rootRect.top}px` + tip.hidden = false + } + + // ---- click / keyboard ---- + + private _onBarActivate = (e: Event): void => { + const idx = this._barIndexFromEvent(e) + if (idx < 0) return + const task = this._resolved[idx].task + this.dispatchEvent(new CustomEvent('tc-task-click', { + bubbles: true, + composed: true, + detail: { task } as GanttTaskClickDetail, + })) + if (typeof this._onTaskClick === 'function') this._onTaskClick(task) + } + + private _onBarKeydown = (e: KeyboardEvent): void => { + if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return + const bar = (e.target as Element | null)?.closest('.tc-gantt-chart-bar') + if (!bar) return + e.preventDefault() + this._onBarActivate(e) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: GanttChart + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 59b5b201..90752613 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -177,6 +177,7 @@ export * from './FileDropzone' export * from './FileTags' export * from './FormWizard' export * from './FunnelChart' +export * from './GanttChart' export * from './GameShowcaseCard' export * from './GithubStarsCard' export * from './Group' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 367c2d59..299335bb 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -175,6 +175,7 @@ import { FileDropzone } from './FileDropzone' import { FileTags } from './FileTags' import { FormWizard } from './FormWizard' import { FunnelChart } from './FunnelChart' +import { GanttChart } from './GanttChart' import { GameShowcaseCard } from './GameShowcaseCard' import { GithubStarsCard } from './GithubStarsCard' import { Group } from './Group' @@ -392,6 +393,7 @@ export function register(): void { customElements.define('tc-file-tags', FileTags) customElements.define('tc-form-wizard', FormWizard) customElements.define('tc-funnel-chart', FunnelChart) + customElements.define('tc-gantt-chart', GanttChart) customElements.define('tc-game-showcase-card', GameShowcaseCard) customElements.define('tc-github-stars-card', GithubStarsCard) customElements.define('tc-group', Group) diff --git a/web-components/style/components/_gantt-chart.scss b/web-components/style/components/_gantt-chart.scss new file mode 100644 index 00000000..1c3cc2c5 --- /dev/null +++ b/web-components/style/components/_gantt-chart.scss @@ -0,0 +1,333 @@ +// tc-gantt-chart — a light-DOM Gantt chart: time-based task bars positioned by +// date math against an overall span, with progress fills, a date-marker axis, +// and a horizontally-scrolling body. Slate neutrals carry it: the frame is the +// Card surface (white --tc-surface behind a 1px hairline with --tc-shadow-sm); +// row separators are slate-100 inner hairlines under the --tc-border outer +// frame. Bar bases default to a muted slate fill; the inner progress fill uses +// --tc-app-accent ink (or the task's own `color` — the one sanctioned data +// encoding). Axis ticks are JetBrains Mono micro-labels in --tc-text-muted. +// Hover is a subtle outline/well (never a new hue); focus is a visible 2px +// accent outline; the tooltip is overlay tier — ink surface, white text, +// --tc-shadow-md, a 3px colored left stripe. Sharp corners (radius 0) +// everywhere — bars are rectangles, never rounded. Every cosmetic value flows +// through a --bs-gantt-chart-* custom property. + +tc-gantt-chart { + --bs-gantt-chart-bg: var(--tc-surface); + --bs-gantt-chart-border: 1px solid var(--tc-border); + --bs-gantt-chart-shadow: var(--tc-shadow-sm); + --bs-gantt-chart-title-color: var(--tc-text); + --bs-gantt-chart-subtitle-color: var(--tc-text-muted); + + // left label column width — the axis spacer matches it so ticks align + --bs-gantt-chart-label-width: 130px; + --bs-gantt-chart-row-height: 36px; + + --bs-gantt-chart-axis-bg: var(--tc-surface-muted); + --bs-gantt-chart-tick-color: var(--tc-text-muted); + --bs-gantt-chart-gridline-color: var(--tc-border); + --bs-gantt-chart-row-separator: var(--tc-slate-100); + --bs-gantt-chart-label-color: var(--tc-text); + + // bar base = muted slate well; progress/solid fill = ink accent (overridable + // per-task via --bs-gantt-chart-bar-color) + --bs-gantt-chart-bar-bg: var(--tc-slate-200); + --bs-gantt-chart-progress-fill: var(--tc-app-accent); + --bs-gantt-chart-bar-color: var(--bs-gantt-chart-progress-fill); + + // state ladder + --bs-gantt-chart-bar-hover-outline: var(--tc-slate-400); + --bs-gantt-chart-focus-ring: var(--tc-app-accent); + + --bs-gantt-chart-tooltip-bg: var(--tc-ink); + --bs-gantt-chart-tooltip-color: #fff; + --bs-gantt-chart-tooltip-stripe-width: 3px; + --bs-gantt-chart-tooltip-shadow: var(--tc-shadow-md); + + --bs-gantt-chart-skeleton-bg: var(--tc-surface-muted); + + display: block; +} + +// --- frame (Card surface) --- +.tc-gantt-chart { + position: relative; + display: flex; + flex-direction: column; + gap: 0.625rem; + padding: 0.875rem 1rem; + background: var(--bs-gantt-chart-bg); + border: var(--bs-gantt-chart-border); + border-radius: 0; + box-shadow: var(--bs-gantt-chart-shadow); +} + +// --- header --- +.tc-gantt-chart-header { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.tc-gantt-chart-title { + font-size: 0.95rem; + font-weight: 600; + line-height: 1.3; + color: var(--bs-gantt-chart-title-color); +} + +.tc-gantt-chart-subtitle { + font-size: 0.8125rem; + line-height: 1.3; + color: var(--bs-gantt-chart-subtitle-color); +} + +// --- scroll region (horizontal overflow) --- +.tc-gantt-chart-scroll { + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + // minimal scrollbar per the ScrollArea convention + scrollbar-width: thin; + scrollbar-color: var(--tc-slate-300) transparent; +} + +.tc-gantt-chart-scroll::-webkit-scrollbar { + height: 8px; +} + +.tc-gantt-chart-scroll::-webkit-scrollbar-thumb { + background: var(--tc-slate-300); + border-radius: 0; +} + +.tc-gantt-chart-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.tc-gantt-chart-scroll:focus-visible { + outline: 2px solid var(--bs-gantt-chart-focus-ring); + outline-offset: 2px; +} + +.tc-gantt-chart-grid { + display: flex; + flex-direction: column; +} + +// --- axis (date markers) --- +.tc-gantt-chart-axis { + display: flex; + align-items: stretch; + height: var(--bs-gantt-chart-row-height); + background: var(--bs-gantt-chart-axis-bg); + border-bottom: 1px solid var(--bs-gantt-chart-gridline-color); +} + +.tc-gantt-chart-axis-spacer { + flex: 0 0 var(--bs-gantt-chart-label-width); + border-right: 1px solid var(--bs-gantt-chart-gridline-color); +} + +.tc-gantt-chart-axis-track { + position: relative; + flex: 1 1 auto; +} + +.tc-gantt-chart-tick { + position: absolute; + bottom: 0.375rem; + transform: translateX(-50%); + font-family: var(--tc-font-mono); + font-size: 0.625rem; + letter-spacing: 0.04em; + color: var(--bs-gantt-chart-tick-color); + white-space: nowrap; + font-variant-numeric: tabular-nums; + pointer-events: none; +} + +// --- body (rows + gridline overlay) --- +.tc-gantt-chart-body { + position: relative; +} + +.tc-gantt-chart-gridlines { + position: absolute; + top: 0; + bottom: 0; + left: var(--bs-gantt-chart-label-width); + right: 0; + pointer-events: none; + z-index: 0; +} + +.tc-gantt-chart-gridline { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: var(--bs-gantt-chart-gridline-color); +} + +// --- rows --- +.tc-gantt-chart-row { + position: relative; + z-index: 1; + display: flex; + align-items: stretch; + height: var(--bs-gantt-chart-row-height); + border-bottom: 1px solid var(--bs-gantt-chart-row-separator); +} + +.tc-gantt-chart-row:last-child { + border-bottom: 0; +} + +.tc-gantt-chart-label { + flex: 0 0 var(--bs-gantt-chart-label-width); + display: flex; + align-items: center; + padding-right: 0.5rem; + border-right: 1px solid var(--bs-gantt-chart-gridline-color); + font-size: 0.75rem; + color: var(--bs-gantt-chart-label-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tc-gantt-chart-track { + position: relative; + flex: 1 1 auto; +} + +// --- bars --- +.tc-gantt-chart-bar { + position: absolute; + top: 25%; + height: 50%; + min-width: 4px; + background: var(--bs-gantt-chart-bar-bg); + border-radius: 0; + cursor: pointer; + overflow: hidden; + outline: none; + transition: box-shadow var(--tc-transition-fast, 0.15s ease); +} + +.tc-gantt-chart-progress { + height: 100%; + background: var(--bs-gantt-chart-bar-color); +} + +// hover = a subtle hairline outline + faint well, never a new hue +.tc-gantt-chart-bar:hover { + box-shadow: inset 0 0 0 1px var(--bs-gantt-chart-bar-hover-outline); +} + +.tc-gantt-chart-bar:focus-visible { + outline: 2px solid var(--bs-gantt-chart-focus-ring); + outline-offset: 2px; +} + +// --- empty --- +.tc-gantt-chart-empty { + padding: 1.5rem 0; + font-size: 0.8125rem; + color: var(--tc-text-muted); + text-align: center; +} + +// --- tooltip (overlay tier) --- +.tc-gantt-chart-tooltip { + position: absolute; + z-index: var(--tc-z-tooltip, 1070); + transform: translate(-50%, calc(-100% - 8px)); + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; + padding: 0.3125rem 0.5rem; + background: var(--bs-gantt-chart-tooltip-bg); + color: var(--bs-gantt-chart-tooltip-color); + border-radius: 0; + border-left: var(--bs-gantt-chart-tooltip-stripe-width) solid var(--bs-gantt-chart-bar-color, var(--tc-accent)); + box-shadow: var(--bs-gantt-chart-tooltip-shadow); + white-space: nowrap; + pointer-events: none; +} + +.tc-gantt-chart-tooltip[hidden] { + display: none; +} + +.tc-gantt-chart-tooltip-name { + font-family: var(--tc-font-mono); + font-size: 0.625rem; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.75; +} + +.tc-gantt-chart-tooltip-meta { + font-size: 0.78rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +// --- loading skeleton (slate neutrals only) --- +.tc-gantt-chart-skeleton { + background: linear-gradient( + 90deg, + var(--bs-gantt-chart-skeleton-bg) 25%, + var(--tc-surface-hover) 37%, + var(--bs-gantt-chart-skeleton-bg) 63% + ); + background-size: 200% 100%; + animation: tc-gantt-chart-shimmer 1.4s ease infinite; +} + +.tc-gantt-chart-skeleton--title { + width: 40%; + height: 0.95rem; +} + +.tc-gantt-chart-skeleton-body { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.tc-gantt-chart-skeleton-row { + display: flex; + align-items: center; + gap: 0.75rem; + height: calc(var(--bs-gantt-chart-row-height) - 8px); +} + +.tc-gantt-chart-skeleton--label { + flex: 0 0 calc(var(--bs-gantt-chart-label-width) - 20px); + height: 0.75rem; +} + +.tc-gantt-chart-skeleton--bar { + flex: 1 1 auto; + height: 0.875rem; +} + +@keyframes tc-gantt-chart-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +@media (prefers-reduced-motion: reduce) { + .tc-gantt-chart-skeleton { + animation: none; + background: var(--bs-gantt-chart-skeleton-bg); + } + + .tc-gantt-chart-bar { + transition: none; + } +} diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index f53f1e78..2d2367a4 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -178,6 +178,7 @@ @forward 'file-tags'; @forward 'form-wizard'; @forward 'funnel-chart'; +@forward 'gantt-chart'; @forward 'game-showcase-card'; @forward 'github-stars-card'; @forward 'group'; diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 36f199d1..a3e562d3 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -240,6 +240,7 @@ tc-file-dropzone, tc-file-tags, tc-form-wizard, tc-funnel-chart, +tc-gantt-chart, tc-game-showcase-card, tc-github-stars-card, tc-group, From f42658dfa3143ed60aaac56595367ff5c5f9eef3 Mon Sep 17 00:00:00 2001 From: kalevski Date: Mon, 15 Jun 2026 18:33:56 +0000 Subject: [PATCH 250/632] 248-heatmap: Build the tc-heatmap web component (Heatmap parity) --- examples/public/web-components/SKILL.md | 87 +++++ examples/src/web-components/HeatmapDemo.tsx | 90 +++++ examples/src/web-components/index.tsx | 2 + web-components/src/Heatmap.ts | 321 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_heatmap.scss | 244 +++++++++++++ web-components/style/components/_index.scss | 1 + web-components/style/foundation/_reset.scss | 1 + 9 files changed, 749 insertions(+) create mode 100644 examples/src/web-components/HeatmapDemo.tsx create mode 100644 web-components/src/Heatmap.ts create mode 100644 web-components/style/components/_heatmap.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index fdbddecd..0586b687 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -158,6 +158,7 @@ After `register()` you can author markup directly: - [tc-entity-profile-card](#tc-entity-profile-card) - [tc-faq-list](#tc-faq-list) - [tc-feature-matrix](#tc-feature-matrix) + - [tc-heatmap](#tc-heatmap) - [tc-game-showcase-card](#tc-game-showcase-card) - [tc-github-stars-card](#tc-github-stars-card) - [tc-group](#tc-group) @@ -12934,6 +12935,92 @@ Comparison table of features vs columns, supporting boolean, partial, and custom ] +### tc-heatmap + +Heatmap grid with colour-interpolated cells and hover tooltips. Computes the value domain (min→max) across `data` and interpolates each cell's fill along `colorScale`; cells with no matching datum render muted. Includes axis micro-labels, an optional min→max legend bar, and a tooltip on hover. The grid is a 1px-gap hairline grid with sharp-cornered cells; the default scale is slate→ink neutrals so the chart carries on the slate ramp by default. + +**Tag:** `tc-heatmap` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `title` | string | — | Title rendered above the grid (weight 600) and used as the grid's `aria-label` | +| `subtitle` | string | — | Muted sub-label beneath the title | +| `cell-size` | number | `32` | Pixel size of each square grid cell (min `12`) | +| `loading` | boolean | `false` | When present, renders a shimmering skeleton grid and sets `aria-busy="true"` | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `data` | `HeatmapCell[]` | The cell data — set via JS property; setting it re-renders | +| `rows` | `(string \| number)[]` | Y-axis categories, top→bottom | +| `cols` | `(string \| number)[]` | X-axis categories, left→right | +| `colorScale` | `string[]` | Array of hex stops to interpolate across; when omitted, a slate→ink neutral scale is used | +| `cellSize` | number | Reflects the `cell-size` attribute | + +`HeatmapCell`: + +| Field | Type | Description | +|-------|------|-------------| +| `row` | `string \| number` | Row (y-axis) category this cell belongs to | +| `col` | `string \| number` | Column (x-axis) category this cell belongs to | +| `value` | number | Numeric value mapped to a colour across the data's min→max | +| `label` | string? | Optional extra label shown in the tooltip and accessible name | + +**Events** + +| Event | `detail` | Description | +|-------|----------|-------------| +| `tc-cell-hover` | `{ cell: HeatmapCell, row, col, value }` | Fired when a data cell is hovered (bubbles, composed) | + +**CSS Custom Properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-heatmap-cell-size` | `32px` | Cell size (also set inline from the `cell-size` attribute) | +| `--bs-heatmap-frame-border` | `1px solid var(--tc-border)` | Outer grid frame | +| `--bs-heatmap-gap-color` | `var(--tc-slate-100)` | Hairline grid-gap colour | +| `--bs-heatmap-empty-bg` | `var(--tc-surface-muted)` | Fill for cells with no datum | +| `--bs-heatmap-hover-outline` | `var(--tc-app-accent)` | Hover hairline outline colour | +| `--bs-heatmap-title-color` | `var(--tc-text)` | Title colour | +| `--bs-heatmap-label-color` | `var(--tc-text-muted)` | Axis label colour | +| `--bs-heatmap-tooltip-bg` | `var(--tc-ink)` | Tooltip surface | +| `--bs-heatmap-tooltip-color` | `#fff` | Tooltip text colour | +| `--bs-heatmap-legend-bar-width` | `7rem` | Legend gradient bar width | +| `--bs-heatmap-skeleton-bg` | `var(--tc-surface-muted)` | Skeleton base fill | + +```html + + + + + + + +``` + ### tc-file-dropzone Drag-and-drop upload zone with optional supported-format chips. Fires a `tc-files` custom event (and calls the `onFiles` callback) with the selected `File[]` array on both drop and native file-picker selection. Sharp dashed border, neutral ink; cyan `--tc-accent` border on drag-active. diff --git a/examples/src/web-components/HeatmapDemo.tsx b/examples/src/web-components/HeatmapDemo.tsx new file mode 100644 index 00000000..72908a3c --- /dev/null +++ b/examples/src/web-components/HeatmapDemo.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useRef } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] +const HOURS = ['00', '04', '08', '12', '16', '20'] + +// Deterministic pseudo-activity matrix so the demo renders stable data. +const buildData = () => { + const data: { row: string; col: string; value: number; label?: string }[] = [] + DAYS.forEach((day, di) => { + HOURS.forEach((hour, hi) => { + const value = Math.round((Math.sin(di + 1) * Math.cos(hi + 1) + 1) * 45 + hi * 3) + data.push({ row: day, col: hour, value, label: `${value} events` }) + }) + }) + return data +} + +const HeatmapDemo: React.FC = () => { + const defaultRef = useRef(null) + const colorRef = useRef(null) + const sizeRef = useRef(null) + const loadingRef = useRef(null) + + useEffect(() => { + const data = buildData() + for (const ref of [defaultRef, colorRef, sizeRef]) { + if (!ref.current) continue + ref.current.rows = DAYS + ref.current.cols = HOURS + ref.current.data = data + } + // Custom warm→hot colour scale. + if (colorRef.current) { + colorRef.current.colorScale = ['#f1f5f9', '#fde68a', '#fb923c', '#ef4444', '#991b1b'] + } + if (loadingRef.current) { + loadingRef.current.rows = DAYS + loadingRef.current.cols = HOURS + } + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="Heatmap" + description="Heatmap grid with colour-interpolated cells, axis labels, a min→max legend, and hover tooltips. Cell colour is the sanctioned data encoding; the default scale is slate→ink neutrals." + /> + +
    + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    + ) +} + +export default HeatmapDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 2be800f5..e8bd2aaa 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -169,6 +169,7 @@ import FormWizardDemo from './FormWizardDemo' import GameShowcaseCardDemo from './GameShowcaseCardDemo' import GithubStarsCardDemo from './GithubStarsCardDemo' import GroupDemo from './GroupDemo' +import HeatmapDemo from './HeatmapDemo' import HeroDemo from './HeroDemo' import ImageDemo from './ImageDemo' import InfiniteScrollDemo from './InfiniteScrollDemo' @@ -397,6 +398,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'game-showcase-card', category: 'Components', element: }, { key: 'github-stars-card', category: 'Components', element: }, { key: 'group', category: 'Components', element: }, + { key: 'heatmap', category: 'Components', element: }, { key: 'hero', category: 'Content', element: }, { key: 'image', category: 'Content', element: }, { key: 'infinite-scroll', category: 'Components', element: }, diff --git a/web-components/src/Heatmap.ts b/web-components/src/Heatmap.ts new file mode 100644 index 00000000..a1c03bdf --- /dev/null +++ b/web-components/src/Heatmap.ts @@ -0,0 +1,321 @@ +const TAG_NAME = 'tc-heatmap' + +export interface HeatmapCell { + row: string | number + col: string | number + value: number + label?: string +} + +// Default scale — slate→ink neutrals so the chart carries on the slate ramp by +// default (mirrors the --tc-slate-* token hex values). An explicit `colorScale` +// may bring colour; the neutrals are the sanctioned baseline. +const DEFAULT_SCALE = ['#f1f5f9', '#cbd5e1', '#94a3b8', '#475569', '#1e293b', '#0f172a'] + +const DEFAULT_CELL_SIZE = 32 +const MIN_CELL_SIZE = 12 + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const r = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) + return r ? { r: parseInt(r[1], 16), g: parseInt(r[2], 16), b: parseInt(r[3], 16) } : null +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t +} + +// Interpolate a colour across a multi-stop scale; t in [0,1]. +function interpolateColor(scale: string[], t: number): string { + if (scale.length === 0) return 'transparent' + if (scale.length === 1) return scale[0] + if (t <= 0) return scale[0] + if (t >= 1) return scale[scale.length - 1] + const seg = (scale.length - 1) * t + const lo = Math.floor(seg) + const hi = Math.ceil(seg) + const f = seg - lo + const c1 = hexToRgb(scale[lo]) + const c2 = hexToRgb(scale[hi]) + if (!c1 || !c2) return scale[lo] + return `rgb(${Math.round(lerp(c1.r, c2.r, f))},${Math.round(lerp(c1.g, c2.g, f))},${Math.round(lerp(c1.b, c2.b, f))})` +} + +export class Heatmap extends HTMLElement { + private _initialised = false + private _data: HeatmapCell[] = [] + private _rows: (string | number)[] = [] + private _cols: (string | number)[] = [] + private _colorScale: string[] | null = null + // Lookup populated on every render so hover/event handlers can resolve cells. + private _cellLookup = new Map() + + static get observedAttributes(): string[] { + return ['title', 'subtitle', 'cell-size', 'loading'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + // NOTE: `title` getter/setter intentionally omitted — HTMLElement already + // reflects the `title` attribute natively; defining our own would collide. + + get subtitle(): string | null { + return this.getAttribute('subtitle') + } + set subtitle(v: string | null) { + if (v != null) this.setAttribute('subtitle', v) + else this.removeAttribute('subtitle') + } + + get cellSize(): number { + const raw = parseInt(this.getAttribute('cell-size') ?? '', 10) + return Number.isFinite(raw) ? Math.max(MIN_CELL_SIZE, raw) : DEFAULT_CELL_SIZE + } + set cellSize(v: number) { + if (v != null) this.setAttribute('cell-size', String(v)) + else this.removeAttribute('cell-size') + } + + get loading(): boolean { + return this.hasAttribute('loading') + } + set loading(v: boolean) { + if (v) this.setAttribute('loading', '') + else this.removeAttribute('loading') + } + + get data(): HeatmapCell[] { + return this._data + } + set data(v: HeatmapCell[]) { + this._data = Array.isArray(v) ? v : [] + if (this._initialised) this.render() + } + + get rows(): (string | number)[] { + return this._rows + } + set rows(v: (string | number)[]) { + this._rows = Array.isArray(v) ? v : [] + if (this._initialised) this.render() + } + + get cols(): (string | number)[] { + return this._cols + } + set cols(v: (string | number)[]) { + this._cols = Array.isArray(v) ? v : [] + if (this._initialised) this.render() + } + + get colorScale(): string[] | null { + return this._colorScale + } + set colorScale(v: string[] | null) { + this._colorScale = Array.isArray(v) && v.length > 0 ? v : null + if (this._initialised) this.render() + } + + private render(): void { + const title = this.getAttribute('title') + const subtitle = this.getAttribute('subtitle') + const cellSize = this.cellSize + const loading = this.loading + + // ── Loading skeleton ─────────────────────────────────────────────── + if (loading) { + this.setAttribute('role', 'status') + this.setAttribute('aria-busy', 'true') + const skelCols = this._cols.length || 8 + const skelRows = this._rows.length || 5 + const skelCells = Array.from({ length: skelCols * skelRows }) + .map(() => `
    `) + .join('') + this.innerHTML = + `` + + `Loading…` + return + } + + this.removeAttribute('role') + this.removeAttribute('aria-busy') + + const rows = this._rows + const cols = this._cols + const scale = this._colorScale ?? DEFAULT_SCALE + + // Value domain across the dataset. + const vals = this._data.map(d => d.value).filter(v => Number.isFinite(v)) + const minVal = vals.length ? Math.min(...vals) : 0 + const maxVal = vals.length ? Math.max(...vals) : 0 + const range = maxVal - minVal || 1 + + // Cell lookup keyed by row::col, rebuilt every render. + this._cellLookup = new Map(this._data.map(d => [`${d.row}::${d.col}`, d])) + + // ── Header ───────────────────────────────────────────────────────── + const headerHtml = + title || subtitle + ? `
    ` + + (title ? `
    ${esc(title)}
    ` : '') + + (subtitle ? `
    ${esc(subtitle)}
    ` : '') + + `
    ` + : '' + + // ── Grid ─────────────────────────────────────────────────────────── + const summary = title || 'Heatmap' + const colHeadCells = cols + .map(col => `
    ${esc(String(col))}
    `) + .join('') + const headRow = + `
    ` + + `` + + colHeadCells + + `
    ` + + const bodyRows = rows + .map(row => { + const cells = cols + .map(col => { + const cell = this._cellLookup.get(`${row}::${col}`) + if (cell === undefined) { + return ( + `
    ` + ) + } + const t = (cell.value - minVal) / range + const fill = interpolateColor(scale, t) + const labelPart = cell.label ? `, ${esc(cell.label)}` : '' + const aria = `${esc(String(row))}, ${esc(String(col))}: ${esc(String(cell.value))}${labelPart}` + return ( + `
    ` + ) + }) + .join('') + return ( + `
    ` + + `
    ${esc(String(row))}
    ` + + cells + + `
    ` + ) + }) + .join('') + + const gridStyle = + `grid-template-columns: minmax(0, auto) repeat(${cols.length}, ${cellSize}px);` + + ` --bs-heatmap-cell-size: ${cellSize}px;` + const gridHtml = + `
    ` + + headRow + + bodyRows + + `
    ` + + // ── Legend ───────────────────────────────────────────────────────── + let legendHtml = '' + if (vals.length) { + const gradient = `linear-gradient(to right, ${scale.join(', ')})` + legendHtml = + `` + } + + this.innerHTML = + `
    ` + + headerHtml + + `
    ` + + gridHtml + + `` + + `
    ` + + legendHtml + + `
    ` + + this._attachGridListeners() + } + + private _attachGridListeners(): void { + const grid = this.querySelector('.tc-heatmap-grid') + const scroll = this.querySelector('.tc-heatmap-scroll') + const tooltip = this.querySelector('.tc-heatmap-tooltip') + if (!grid || !scroll || !tooltip) return + + grid.addEventListener('mouseover', (e: MouseEvent) => { + const cell = (e.target as HTMLElement).closest('.tc-heatmap-cell') + if (!cell || cell.classList.contains('tc-heatmap-cell--empty')) return + const row = cell.dataset.row ?? '' + const col = cell.dataset.col ?? '' + const data = this._cellLookup.get(`${row}::${col}`) + if (!data) return + + const text = data.label + ? `${data.label} · ${row} / ${col}: ${data.value}` + : `${row} / ${col}: ${data.value}` + tooltip.textContent = text + tooltip.hidden = false + + // Position the tooltip centred above the cell, relative to the + // scroll container (the positioned ancestor). + const cellRect = cell.getBoundingClientRect() + const scrollRect = scroll.getBoundingClientRect() + tooltip.style.left = `${cellRect.left - scrollRect.left + scroll.scrollLeft + cellRect.width / 2}px` + tooltip.style.top = `${cellRect.top - scrollRect.top + scroll.scrollTop}px` + + cell.classList.add('tc-heatmap-cell--active') + + this.dispatchEvent( + new CustomEvent('tc-cell-hover', { + bubbles: true, + composed: true, + detail: { cell: data, row: data.row, col: data.col, value: data.value }, + }) + ) + }) + + grid.addEventListener('mouseout', (e: MouseEvent) => { + const cell = (e.target as HTMLElement).closest('.tc-heatmap-cell') + if (cell) cell.classList.remove('tc-heatmap-cell--active') + const related = e.relatedTarget as Node | null + if (!related || !grid.contains(related)) { + tooltip.hidden = true + } + }) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Heatmap + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 90752613..5dc1ac38 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -181,6 +181,7 @@ export * from './GanttChart' export * from './GameShowcaseCard' export * from './GithubStarsCard' export * from './Group' +export * from './Heatmap' export * from './Hero' export * from './Image' export * from './InfiniteScroll' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 299335bb..f937f7f5 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -179,6 +179,7 @@ import { GanttChart } from './GanttChart' import { GameShowcaseCard } from './GameShowcaseCard' import { GithubStarsCard } from './GithubStarsCard' import { Group } from './Group' +import { Heatmap } from './Heatmap' import { Hero } from './Hero' import { Image as TcImage } from './Image' import { InfiniteScroll } from './InfiniteScroll' @@ -397,6 +398,7 @@ export function register(): void { customElements.define('tc-game-showcase-card', GameShowcaseCard) customElements.define('tc-github-stars-card', GithubStarsCard) customElements.define('tc-group', Group) + customElements.define('tc-heatmap', Heatmap) customElements.define('tc-hero', Hero) customElements.define('tc-image', TcImage) customElements.define('tc-infinite-scroll', InfiniteScroll) diff --git a/web-components/style/components/_heatmap.scss b/web-components/style/components/_heatmap.scss new file mode 100644 index 00000000..cd167831 --- /dev/null +++ b/web-components/style/components/_heatmap.scss @@ -0,0 +1,244 @@ +// tc-heatmap — heatmap grid with colour-interpolated cells and hover tooltips. +// Structure with hairlines, not boxes: a 1px-gap grid (slate-100 gaps) sits over +// a --tc-border frame, so the gaps read as the grid lines. Sharp corners by +// mandate (border-radius: 0 everywhere). Cell colour is the sanctioned data +// encoding — the default scale is slate→ink neutrals; an explicit colorScale may +// bring colour. Axis labels are JetBrains Mono micro in --tc-text-muted; the +// title is weight 600. Hover paints a hairline --tc-app-accent outline on the +// cell (not a hue change). Tooltip rides the --tc-ink surface with white text. +// All cosmetics flow through --bs-heatmap-* custom properties backed by --tc-*. + +tc-heatmap { + --bs-heatmap-cell-size: 32px; + --bs-heatmap-frame-border: 1px solid var(--tc-border); + --bs-heatmap-gap-color: var(--tc-slate-100); + --bs-heatmap-empty-bg: var(--tc-surface-muted); + --bs-heatmap-hover-outline: var(--tc-app-accent); + + --bs-heatmap-title-color: var(--tc-text); + --bs-heatmap-title-font-size: 0.9375rem; + --bs-heatmap-title-font-weight: 600; + --bs-heatmap-subtitle-color: var(--tc-text-muted); + --bs-heatmap-subtitle-font-size: 0.8125rem; + + --bs-heatmap-label-color: var(--tc-text-muted); + --bs-heatmap-label-font-size: 0.6875rem; + --bs-heatmap-label-letter-spacing: 0.05em; + + --bs-heatmap-tooltip-bg: var(--tc-ink); + --bs-heatmap-tooltip-color: #fff; + --bs-heatmap-tooltip-shadow: var(--tc-shadow-md); + --bs-heatmap-tooltip-font-size: 0.6875rem; + + --bs-heatmap-legend-bar-width: 7rem; + --bs-heatmap-legend-bar-height: 0.5rem; + --bs-heatmap-legend-label-color: var(--tc-text-muted); + --bs-heatmap-legend-label-font-size: 0.6875rem; + + --bs-heatmap-skeleton-bg: var(--tc-surface-muted); + --bs-heatmap-skeleton-shimmer: rgba(255, 255, 255, 0.6); +} + +.tc-heatmap { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +// ── Header ────────────────────────────────────────────────────────────────── + +.tc-heatmap-header { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.tc-heatmap-title { + font-size: var(--bs-heatmap-title-font-size); + font-weight: var(--bs-heatmap-title-font-weight); + line-height: 1.3; + color: var(--bs-heatmap-title-color); +} + +.tc-heatmap-subtitle { + font-size: var(--bs-heatmap-subtitle-font-size); + line-height: 1.4; + color: var(--bs-heatmap-subtitle-color); +} + +// ── Scroll viewport ───────────────────────────────────────────────────────── + +.tc-heatmap-scroll { + position: relative; // tooltip anchor + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +// ── Grid ──────────────────────────────────────────────────────────────────── +// 1px gap over a frame: the gap colour shows through as hairlines between every +// track, and the frame border closes the outer edge. + +.tc-heatmap-grid { + display: grid; + width: max-content; + gap: 1px; + background: var(--bs-heatmap-gap-color); + border: var(--bs-heatmap-frame-border); + border-radius: 0; +} + +// role="row" wrappers must not generate a box — display:contents lets their +// cells participate directly in the grid so the gap is uniform across rows. +.tc-heatmap-row { + display: contents; +} + +// Axis micro-labels — JetBrains Mono, muted, uppercase tracking. +.tc-heatmap-col-label, +.tc-heatmap-row-label { + display: flex; + align-items: center; + font-family: 'JetBrains Mono', var(--bs-font-monospace, monospace); + font-size: var(--bs-heatmap-label-font-size); + letter-spacing: var(--bs-heatmap-label-letter-spacing); + color: var(--bs-heatmap-label-color); + background: var(--tc-surface); + white-space: nowrap; +} + +.tc-heatmap-col-label { + justify-content: center; + padding: 0.25rem 0.375rem; + text-align: center; +} + +.tc-heatmap-row-label { + justify-content: flex-end; + padding: 0 0.5rem; + text-align: right; +} + +// Empty corner cell. +.tc-heatmap-corner { + background: var(--tc-surface); +} + +// Data cells — sharp rectangles sized by --bs-heatmap-cell-size. +.tc-heatmap-cell { + height: var(--bs-heatmap-cell-size); + min-width: var(--bs-heatmap-cell-size); + border-radius: 0; + background: var(--bs-heatmap-empty-bg); + transition: outline-color var(--tc-transition-fast); +} + +.tc-heatmap-cell--empty { + background: var(--bs-heatmap-empty-bg); +} + +// Hover = hairline ink outline, drawn inside the cell so it never shifts layout. +.tc-heatmap-cell--active:not(.tc-heatmap-cell--empty) { + outline: 1px solid var(--bs-heatmap-hover-outline); + outline-offset: -1px; + position: relative; + z-index: 1; +} + +// ── Tooltip ───────────────────────────────────────────────────────────────── +// Ink surface, white text, overlay shadow; centred above the hovered cell. + +.tc-heatmap-tooltip { + position: absolute; + z-index: var(--tc-z-tooltip, 1070); + transform: translate(-50%, calc(-100% - 6px)); + padding: 0.25rem 0.5rem; + font-family: 'JetBrains Mono', var(--bs-font-monospace, monospace); + font-size: var(--bs-heatmap-tooltip-font-size); + line-height: 1.3; + white-space: nowrap; + color: var(--bs-heatmap-tooltip-color); + background: var(--bs-heatmap-tooltip-bg); + box-shadow: var(--bs-heatmap-tooltip-shadow); + border-radius: 0; + pointer-events: none; + + &[hidden] { + display: none; + } +} + +// ── Legend ────────────────────────────────────────────────────────────────── + +.tc-heatmap-legend { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.tc-heatmap-legend-bar { + display: block; + width: var(--bs-heatmap-legend-bar-width); + height: var(--bs-heatmap-legend-bar-height); + border: var(--bs-heatmap-frame-border); + border-radius: 0; +} + +.tc-heatmap-legend-label { + font-family: 'JetBrains Mono', var(--bs-font-monospace, monospace); + font-size: var(--bs-heatmap-legend-label-font-size); + color: var(--bs-heatmap-legend-label-color); +} + +// ── Loading skeleton ──────────────────────────────────────────────────────── + +@keyframes tc-heatmap-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +%tc-heatmap-shimmer { + background: linear-gradient( + 90deg, + var(--bs-heatmap-skeleton-bg) 25%, + var(--bs-heatmap-skeleton-shimmer) 50%, + var(--bs-heatmap-skeleton-bg) 75% + ); + background-size: 200% 100%; + animation: tc-heatmap-shimmer 1.5s infinite linear; + border-radius: 0; +} + +.tc-heatmap-skeleton { + display: block; + @extend %tc-heatmap-shimmer; + + &--title { + width: 30%; + height: 0.875rem; + } +} + +.tc-heatmap-skeleton-grid { + display: grid; + gap: 1px; + background: var(--bs-heatmap-gap-color); + border: var(--bs-heatmap-frame-border); + width: max-content; +} + +.tc-heatmap-skeleton-cell { + height: var(--bs-heatmap-cell-size); + min-width: var(--bs-heatmap-cell-size); + @extend %tc-heatmap-shimmer; +} + +// Honour prefers-reduced-motion: freeze the shimmer to a static fill but keep +// the state-conveying colour transition on cells. +@media (prefers-reduced-motion: reduce) { + .tc-heatmap-skeleton, + .tc-heatmap-skeleton-cell { + animation: none; + background: var(--bs-heatmap-skeleton-bg); + } +} diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 2d2367a4..b36f097e 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -182,6 +182,7 @@ @forward 'game-showcase-card'; @forward 'github-stars-card'; @forward 'group'; +@forward 'heatmap'; @forward 'hero'; @forward 'image'; @forward 'infinite-scroll'; diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index a3e562d3..5a4f80b4 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -244,6 +244,7 @@ tc-gantt-chart, tc-game-showcase-card, tc-github-stars-card, tc-group, +tc-heatmap, tc-hero, tc-image, tc-infinite-scroll, From 3818eddc396d46dcd4b841b302b9c9f14a80f3db Mon Sep 17 00:00:00 2001 From: kalevski Date: Mon, 15 Jun 2026 18:41:40 +0000 Subject: [PATCH 251/632] 249-line-chart: Build the tc-line-chart web component (LineChart parity) --- examples/public/web-components/SKILL.md | 66 +++ examples/src/web-components/LineChartDemo.tsx | 119 +++++ examples/src/web-components/index.tsx | 2 + web-components/src/LineChart.ts | 477 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_line-chart.scss | 279 ++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 948 insertions(+) create mode 100644 examples/src/web-components/LineChartDemo.tsx create mode 100644 web-components/src/LineChart.ts create mode 100644 web-components/style/components/_line-chart.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 0586b687..9733be2b 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -52,6 +52,7 @@ After `register()` you can author markup directly: - [tc-area-chart](#tc-area-chart) - [tc-bar-chart](#tc-bar-chart) - [tc-funnel-chart](#tc-funnel-chart) + - [tc-line-chart](#tc-line-chart) - [tc-gantt-chart](#tc-gantt-chart) - [tc-benchmark-chart](#tc-benchmark-chart) - [tc-bitmap-font-generator](#tc-bitmap-font-generator) @@ -1426,6 +1427,71 @@ document.querySelector('tc-funnel-chart').data = [ --- +### tc-line-chart + +Inline-SVG line chart plotting multiple series on shared axes, with grid hairlines, axis tick labels, point markers, hover tooltips, and an optional toggle legend. Sharp square corners; the chart frame sits on `--tc-surface` behind a 1px hairline. Grid lines and axes are slate-100/`--tc-border` hairlines; axis tick labels are JetBrains Mono micro text. Series strokes default to a small slate-leaning ramp (neutrals first, exposed as `--bs-line-chart-series-N`) unless a series supplies its own `color` — the one sanctioned data-encoding override. The x domain is the union of every series' x values; the y domain is computed across the currently-visible series and rounded up to a clean max. Moving the pointer near a point enlarges its marker and shows an overlay-tier tooltip (ink surface, white text, `--tc-shadow-md`, 3px colored left stripe) with the x label and that series' formatted y value, and fires `tc-point-hover`. The legend (shown for 2+ series) renders each series name with a square colour swatch as a real focusable `` + }) + legendHtml += `
    ` + } + + this.innerHTML = + `
    ` + + headerHtml + + `
    ${svgHtml}${tooltipHtml}
    ` + + legendHtml + + `
    ` + + const svg = this.querySelector('.tc-line-chart-svg') + if (svg) { + svg.addEventListener('pointermove', this._onPointerMove) + svg.addEventListener('pointerleave', this._onPointerLeave) + } + const legend = this.querySelector('.tc-line-chart-legend') + if (legend) legend.addEventListener('click', this._onLegendClick) + } + + private _renderLoading(): void { + this.setAttribute('role', 'status') + this.setAttribute('aria-busy', 'true') + const titleAttr = this.getAttribute('title') + const headHtml = titleAttr + ? `
    ` + : '' + this.innerHTML = + `
    ` + + headHtml + + `` + + `Loading…` + + `
    ` + } + + private _summary(series: LineChartSeries[], xCount: number): string { + const names = series + .map((s, i) => seriesName(s, i)) + .slice(0, 8) + .join(', ') + const more = series.length > 8 ? ', …' : '' + return `Line chart with ${series.length} series (${names}${more}) over ${xCount} point${xCount !== 1 ? 's' : ''}.` + } + + // ---- hover ---- + + // nearest anchor to the pointer, mapping client pixels back into user space + private _nearestAnchor(e: PointerEvent): PointAnchor | null { + if (!this._anchors.length) return null + const svg = this.querySelector('.tc-line-chart-svg') + if (!svg) return null + const rect = svg.getBoundingClientRect() + if (!rect.width || !rect.height) return null + const ux = ((e.clientX - rect.left) / rect.width) * this._vw + const uy = ((e.clientY - rect.top) / rect.height) * this._vh + let best: PointAnchor | null = null + let bestD = Infinity + for (const a of this._anchors) { + const dx = a.cx - ux + const dy = a.cy - uy + const d = dx * dx + dy * dy + if (d < bestD) { bestD = d; best = a } + } + // only surface when reasonably close (within ~40 user units) + return bestD <= 40 * 40 ? best : null + } + + private _onPointerMove = (e: PointerEvent): void => { + const a = this._nearestAnchor(e) + if (!a) { this._onPointerLeave(); return } + this._showTooltip(a) + } + + private _onPointerLeave = (): void => { + const tip = this.querySelector('.tc-line-chart-tooltip') + if (tip) tip.hidden = true + this.querySelectorAll('.tc-line-chart-point--active') + .forEach(el => el.classList.remove('tc-line-chart-point--active')) + } + + private _showTooltip(a: PointAnchor): void { + const tip = this.querySelector('.tc-line-chart-tooltip') + const s = this._series[a.si] + if (!tip || !s) return + const pts = seriesPoints(s) + const point = pts[a.pi] + if (!point) return + + // highlight the active marker + this.querySelectorAll('.tc-line-chart-point--active') + .forEach(el => el.classList.remove('tc-line-chart-point--active')) + const idx = this._anchors.indexOf(a) + const marker = this.querySelector(`.tc-line-chart-point[data-ai="${idx}"]`) + if (marker) marker.classList.add('tc-line-chart-point--active') + + const xLabel = this._fmtX(point.x) + const yLabel = this._fmtY(point.y) + tip.setAttribute('aria-label', `${seriesName(s, a.si)}, ${xLabel}: ${yLabel}`) + tip.style.setProperty('--tc-line-color', a.color) + tip.innerHTML = + `${esc(xLabel)}` + + `` + + `${esc(seriesName(s, a.si))}` + + `${esc(yLabel)}` + + `` + + const svg = this.querySelector('.tc-line-chart-svg') + const rect = svg ? svg.getBoundingClientRect() : null + const scaleX = rect && this._vw ? rect.width / this._vw : 1 + const scaleY = rect && this._vh ? rect.height / this._vh : 1 + tip.style.left = `${a.cx * scaleX}px` + tip.style.top = `${a.cy * scaleY}px` + tip.hidden = false + + this.dispatchEvent(new CustomEvent('tc-point-hover', { + bubbles: true, + composed: true, + detail: { series: s, point } as LinePointHoverDetail, + })) + } + + // ---- legend toggle ---- + + private _onLegendClick = (e: Event): void => { + const btn = (e.target as Element | null)?.closest('.tc-line-chart-legend-item') + if (!btn) return + const si = parseInt(btn.dataset.si ?? '', 10) + if (isNaN(si) || si < 0 || si >= this._series.length) return + // never let the user hide the last visible series + if (!this._hidden.has(si) && this._hidden.size >= this._series.length - 1) return + if (this._hidden.has(si)) this._hidden.delete(si) + else this._hidden.add(si) + this.render() + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: LineChart + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 5dc1ac38..d9ec063e 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -112,6 +112,7 @@ export * from './GoodFirstIssues' export * from './HeroStatsBar' export * from './Leaderboard' export * from './LeaderboardTrend' +export * from './LineChart' export * from './LinkedProvidersCard' export * from './LogoCloud' export * from './MaintainerCard' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index f937f7f5..95a3a3ed 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -185,6 +185,7 @@ import { Image as TcImage } from './Image' import { InfiniteScroll } from './InfiniteScroll' import { InstallTabs } from './InstallTabs' import { Leaderboard } from './Leaderboard' +import { LineChart } from './LineChart' import { LiveFeed } from './LiveFeed' import { Login } from './Login' import { Marquee } from './Marquee' @@ -404,6 +405,7 @@ export function register(): void { customElements.define('tc-infinite-scroll', InfiniteScroll) customElements.define('tc-install-tabs', InstallTabs) customElements.define('tc-leaderboard', Leaderboard) + customElements.define('tc-line-chart', LineChart) customElements.define('tc-live-feed', LiveFeed) customElements.define('tc-login', Login) customElements.define('tc-marquee', Marquee) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index b36f097e..a6ffb0be 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -178,6 +178,7 @@ @forward 'file-tags'; @forward 'form-wizard'; @forward 'funnel-chart'; +@forward 'line-chart'; @forward 'gantt-chart'; @forward 'game-showcase-card'; @forward 'github-stars-card'; diff --git a/web-components/style/components/_line-chart.scss b/web-components/style/components/_line-chart.scss new file mode 100644 index 00000000..9129212a --- /dev/null +++ b/web-components/style/components/_line-chart.scss @@ -0,0 +1,279 @@ +// tc-line-chart — inline-SVG line chart with multiple series, grid hairlines, +// point markers, hover tooltips and an optional toggle legend. Slate neutrals +// carry it: the chart frame sits on --tc-surface behind a 1px hairline; grid +// lines and axes are slate-100/--tc-border hairlines; axis labels are JetBrains +// Mono micro text in --tc-text-muted. Default series strokes come from a small +// slate-leaning ramp (neutrals first) exposed as --bs-line-chart-series-N; an +// explicit per-series `color` from data is the one sanctioned override. Point +// markers are sanctioned small circles. The tooltip is overlay tier — ink +// surface, white text, elevation shadow, a 3px colored left stripe per the +// toast/tooltip motif. Legend entries are real buttons following the state ladder +// (active vs dimmed) with a visible focus outline. Sharp corners everywhere. +// Every cosmetic value flows through a --bs-line-chart-* custom property. + +tc-line-chart { + // series stroke ramp — neutrals first, then a couple of restrained data hues + --bs-line-chart-series-1: var(--tc-app-accent); + --bs-line-chart-series-2: var(--tc-slate-500); + --bs-line-chart-series-3: var(--tc-info); + --bs-line-chart-series-4: var(--tc-slate-700); + --bs-line-chart-series-5: var(--tc-success); + --bs-line-chart-series-6: var(--tc-warning); + + --bs-line-chart-bg: var(--tc-surface); + --bs-line-chart-border: 1px solid var(--tc-border); + --bs-line-chart-title-color: var(--tc-text); + --bs-line-chart-subtitle-color: var(--tc-text-muted); + + // grid + axes — hairlines, never boxes + --bs-line-chart-grid-color: var(--tc-slate-100); + --bs-line-chart-axis-color: var(--tc-border-strong); + + // axis tick labels — mono micro + --bs-line-chart-axis-label-color: var(--tc-text-muted); + --bs-line-chart-axis-label-size: 0.6875rem; + + --bs-line-chart-line-width: 2; + --bs-line-chart-point-fill: var(--tc-surface); + --bs-line-chart-point-radius: 3.5px; + + // tooltip (overlay tier) + --bs-line-chart-tooltip-bg: var(--tc-ink); + --bs-line-chart-tooltip-color: #fff; + --bs-line-chart-tooltip-stripe-width: 3px; + --bs-line-chart-tooltip-shadow: var(--tc-shadow-md); + + // legend + --bs-line-chart-legend-color: var(--tc-text); + --bs-line-chart-legend-off-opacity: 0.45; + + --bs-line-chart-skeleton-bg: var(--tc-surface-muted); + + display: block; +} + +.tc-line-chart-inner { + display: flex; + flex-direction: column; + gap: 0.625rem; + padding: 0.875rem 1rem; + background: var(--bs-line-chart-bg); + border: var(--bs-line-chart-border); + border-radius: 0; +} + +// --- header --- +.tc-line-chart-header { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.tc-line-chart-title { + font-size: 0.95rem; + font-weight: 600; + line-height: 1.3; + color: var(--bs-line-chart-title-color); +} + +.tc-line-chart-subtitle { + font-size: 0.8125rem; + line-height: 1.3; + color: var(--bs-line-chart-subtitle-color); +} + +// --- plot --- +.tc-line-chart-plot { + position: relative; +} + +.tc-line-chart-svg { + display: block; + width: 100%; + overflow: visible; + touch-action: none; +} + +// --- grid + axes --- +.tc-line-chart-gridline { + stroke: var(--bs-line-chart-grid-color); + stroke-width: 1; +} + +.tc-line-chart-axis { + stroke: var(--bs-line-chart-axis-color); + stroke-width: 1; +} + +// --- axis tick labels (mono micro) --- +.tc-line-chart-axis-label { + fill: var(--bs-line-chart-axis-label-color); + font-family: var(--tc-font-mono); + font-size: var(--bs-line-chart-axis-label-size); + font-variant-numeric: tabular-nums; +} + +// --- series --- +.tc-line-chart-line { + fill: none; + stroke: var(--tc-line-color, var(--bs-line-chart-series-1)); + stroke-width: var(--bs-line-chart-line-width); + stroke-linecap: round; + stroke-linejoin: round; +} + +.tc-line-chart-point { + fill: var(--bs-line-chart-point-fill); + stroke: var(--tc-line-color, var(--bs-line-chart-series-1)); + stroke-width: 1.5; + cursor: pointer; + transition: r 0.12s ease, stroke-width 0.12s ease; +} + +// hover marker enlarges subtly + thickens its outline (no new hue) +.tc-line-chart-point:hover, +.tc-line-chart-point--active { + r: 5px; + stroke-width: 2.5; +} + +// --- empty --- +.tc-line-chart-empty { + padding: 1.5rem 0; + font-size: 0.8125rem; + color: var(--tc-text-muted); + text-align: center; +} + +// --- tooltip (overlay tier) --- +.tc-line-chart-tooltip { + position: absolute; + z-index: var(--tc-z-tooltip, 1070); + transform: translate(-50%, calc(-100% - 10px)); + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; + padding: 0.3125rem 0.5rem; + background: var(--bs-line-chart-tooltip-bg); + color: var(--bs-line-chart-tooltip-color); + border-radius: 0; + border-left: var(--bs-line-chart-tooltip-stripe-width) solid var(--tc-line-color, var(--tc-accent)); + box-shadow: var(--bs-line-chart-tooltip-shadow); + white-space: nowrap; + pointer-events: none; +} + +.tc-line-chart-tooltip[hidden] { + display: none; +} + +.tc-line-chart-tooltip-x { + font-family: var(--tc-font-mono); + font-size: 0.625rem; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.75; +} + +.tc-line-chart-tooltip-row { + display: flex; + align-items: baseline; + gap: 0.5rem; +} + +.tc-line-chart-tooltip-name { + font-size: 0.75rem; + opacity: 0.85; +} + +.tc-line-chart-tooltip-value { + margin-left: auto; + font-size: 0.78rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +// --- legend (toggle buttons) --- +.tc-line-chart-legend { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 0.875rem; +} + +.tc-line-chart-legend-item { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.125rem 0.25rem; + margin: 0; + border: 0; + border-radius: 0; + background: none; + color: var(--bs-line-chart-legend-color); + font-size: 0.78rem; + line-height: 1.2; + cursor: pointer; + transition: opacity var(--tc-transition-fast, 0.15s ease); +} + +// dimmed (toggled off) — state ladder for any toggle +.tc-line-chart-legend-item--off { + opacity: var(--bs-line-chart-legend-off-opacity); +} + +.tc-line-chart-legend-item--off .tc-line-chart-legend-label { + text-decoration: line-through; +} + +.tc-line-chart-legend-item:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; +} + +// small square swatch (sharp) +.tc-line-chart-legend-swatch { + width: 0.75rem; + height: 0.75rem; + border-radius: 0; + background: var(--tc-line-color, var(--bs-line-chart-series-1)); + flex: 0 0 auto; +} + +// --- loading skeleton (slate neutrals only) --- +.tc-line-chart-skeleton { + background: linear-gradient( + 90deg, + var(--bs-line-chart-skeleton-bg) 25%, + var(--tc-surface-hover) 37%, + var(--bs-line-chart-skeleton-bg) 63% + ); + background-size: 200% 100%; + animation: tc-line-chart-shimmer 1.4s ease infinite; +} + +.tc-line-chart-skeleton--title { + width: 40%; + height: 0.95rem; +} + +.tc-line-chart-skeleton--plot { + width: 100%; +} + +@keyframes tc-line-chart-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +@media (prefers-reduced-motion: reduce) { + .tc-line-chart-skeleton { + animation: none; + background: var(--bs-line-chart-skeleton-bg); + } + + .tc-line-chart-point, + .tc-line-chart-legend-item { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 5a4f80b4..a322c675 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -250,6 +250,7 @@ tc-image, tc-infinite-scroll, tc-install-tabs, tc-leaderboard, +tc-line-chart, tc-live-feed, tc-login, tc-marquee, From 96d145fe51b1f744558e6bacc00fb9b8776d9045 Mon Sep 17 00:00:00 2001 From: kalevski Date: Mon, 15 Jun 2026 18:49:59 +0000 Subject: [PATCH 252/632] 250-pie-chart: Build the tc-pie-chart web component (PieChart parity) --- examples/public/web-components/SKILL.md | 67 +++ examples/src/web-components/PieChartDemo.tsx | 100 ++++ examples/src/web-components/index.tsx | 2 + web-components/src/PieChart.ts | 512 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_pie-chart.scss | 330 +++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 1016 insertions(+) create mode 100644 examples/src/web-components/PieChartDemo.tsx create mode 100644 web-components/src/PieChart.ts create mode 100644 web-components/style/components/_pie-chart.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 9733be2b..cae35219 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -53,6 +53,7 @@ After `register()` you can author markup directly: - [tc-bar-chart](#tc-bar-chart) - [tc-funnel-chart](#tc-funnel-chart) - [tc-line-chart](#tc-line-chart) + - [tc-pie-chart](#tc-pie-chart) - [tc-gantt-chart](#tc-gantt-chart) - [tc-benchmark-chart](#tc-benchmark-chart) - [tc-bitmap-font-generator](#tc-bitmap-font-generator) @@ -1492,6 +1493,72 @@ chart.addEventListener('tc-point-hover', (e) => console.log(e.detail.series.name --- +### tc-pie-chart + +Inline-SVG pie or donut chart showing a percentage distribution with an interactive (hoverable + toggleable) legend and an optional donut centre label. Sharp square corners on the frame, legend rows and swatches; the pie/donut disc is the one sanctioned circular shape. Slice fills default to a small slate-leaning ramp (neutrals first, exposed as `--bs-pie-chart-slice-N`) unless a slice supplies its own `color` — the one sanctioned data-encoding override. Slices are drawn from accumulated angles; the visible total (toggled-off slices excluded) drives the geometry and the visible slices' percentages. Hovering a slice — or its legend row — recedes the other slices (highlight via opacity, not a new hue), pulls the active slice out slightly (frozen under `prefers-reduced-motion`), and shows an overlay-tier tooltip (ink surface, white text, `--tc-shadow-md`, 3px coloured left stripe) with the label, value and percentage; for a donut the centre label swaps to the active slice's label + percentage. The legend renders every slice as a real focusable `` + + `` + }) + legendHtml += `` + } + + this.innerHTML = + `
    ` + + headerHtml + + `
    ` + + `
    ${svgHtml}${tooltipHtml}
    ` + + legendHtml + + `
    ` + + `
    ` + + // post-render listeners (the fresh nodes get fresh handlers; old ones GC together) + const slicesGroup = this.querySelector('.tc-pie-chart-slices') + if (slicesGroup) { + slicesGroup.addEventListener('pointermove', this._onSlicePointer) + slicesGroup.addEventListener('pointerleave', this._onPointerLeave) + slicesGroup.addEventListener('click', this._onSliceClick) + } + const legend = this.querySelector('.tc-pie-chart-legend') + if (legend) { + legend.addEventListener('pointerover', this._onLegendHover) + legend.addEventListener('pointerleave', this._onPointerLeave) + legend.addEventListener('click', this._onLegendClick) + } + + // restore highlight if the active slice is still visible after a re-render + if (this._active !== null && this._geo.some(g => g.index === this._active)) { + this._applyActive(this._active) + } else { + this._active = null + } + } + + private _renderLoading(): void { + this.setAttribute('role', 'status') + this.setAttribute('aria-busy', 'true') + const titleAttr = this.getAttribute('title') + const headHtml = titleAttr + ? `
    ` + : '' + const size = Math.max(80, this.height) + this.innerHTML = + `
    ` + + headHtml + + `` + + `Loading…` + + `
    ` + } + + private _summary(data: PieChartSlice[], total: number): string { + const kind = this.donut ? 'Donut' : 'Pie' + const names = data.map(d => d.label).slice(0, 8).join(', ') + const more = data.length > 8 ? ', …' : '' + return `${kind} chart with ${data.length} slice${data.length !== 1 ? 's' : ''} (${names}${more}), total ${total.toLocaleString()}.` + } + + // ---- hover highlight + tooltip ---- + + // Highlight a slice (and its legend row), pull it out, show the tooltip and — + // for a donut — swap the centre label to the slice's label + percentage. + private _applyActive(index: number): void { + this._active = index + const geo = this._geo.find(g => g.index === index) + + const group = this.querySelector('.tc-pie-chart-slices') + if (group) group.classList.add('tc-pie-chart-slices--has-active') + + const reduce = this._prefersReducedMotion() + this.querySelectorAll('.tc-pie-chart-slice').forEach(path => { + const pi = parseInt(path.dataset.i ?? '', 10) + const isActive = pi === index + path.classList.toggle('tc-pie-chart-slice--active', isActive) + const g = this._geo.find(x => x.index === pi) + if (isActive && g && !reduce) { + const dx = (Math.cos(g.midAngle) * PULL).toFixed(2) + const dy = (Math.sin(g.midAngle) * PULL).toFixed(2) + path.setAttribute('transform', `translate(${dx} ${dy})`) + } else { + path.removeAttribute('transform') + } + }) + + this.querySelectorAll('.tc-pie-chart-legend-item').forEach(item => { + const pi = parseInt(item.dataset.i ?? '', 10) + item.classList.toggle('tc-pie-chart-legend-item--active', pi === index) + }) + + if (!geo) return + + // donut centre label mirrors the active slice + const centerLabel = this.querySelector('.tc-pie-chart-center-label') + const centerValue = this.querySelector('.tc-pie-chart-center-value') + if (centerLabel) centerLabel.textContent = geo.label + if (centerValue) centerValue.textContent = `${geo.pct}%` + + this._showTooltip(geo) + } + + private _clearActive(): void { + this._active = null + const group = this.querySelector('.tc-pie-chart-slices') + if (group) group.classList.remove('tc-pie-chart-slices--has-active') + this.querySelectorAll('.tc-pie-chart-slice--active').forEach(p => { + p.classList.remove('tc-pie-chart-slice--active') + p.removeAttribute('transform') + }) + this.querySelectorAll('.tc-pie-chart-legend-item--active') + .forEach(el => el.classList.remove('tc-pie-chart-legend-item--active')) + + // restore the donut centre to its resting label + total + const centerLabel = this.querySelector('.tc-pie-chart-center-label') + const centerValue = this.querySelector('.tc-pie-chart-center-value') + if (centerLabel) centerLabel.textContent = this.getAttribute('center-label') ?? '' + if (centerValue) { + const visibleTotal = this._data.reduce( + (s, d, i) => (!this._hidden.has(i) && d.value > 0 ? s + d.value : s), + 0, + ) + centerValue.textContent = visibleTotal.toLocaleString() + } + + const tip = this.querySelector('.tc-pie-chart-tooltip') + if (tip) tip.hidden = true + } + + private _showTooltip(geo: SliceGeo): void { + const tip = this.querySelector('.tc-pie-chart-tooltip') + const svg = this.querySelector('.tc-pie-chart-svg') + if (!tip || !svg) return + + tip.style.setProperty('--tc-pie-color', geo.color) + tip.setAttribute('aria-label', `${geo.label}: ${geo.value.toLocaleString()} (${geo.pct}%)`) + tip.innerHTML = + `${esc(geo.label)}` + + `` + + `${esc(geo.value.toLocaleString())}` + + `${geo.pct}%` + + `` + + // position at the slice centroid, mapping user units → rendered pixels + const rect = svg.getBoundingClientRect() + const scale = rect.width && this._size ? rect.width / this._size : 1 + const cx = this._size / 2 + const cy = this._size / 2 + const tr = this._size / 2 - PULL - 4 + const cr = this.donut ? ((tr + tr * 0.58) / 2) : (tr * 0.6) + const px = (cx + Math.cos(geo.midAngle) * cr) * scale + const py = (cy + Math.sin(geo.midAngle) * cr) * scale + tip.style.left = `${px}px` + tip.style.top = `${py}px` + tip.hidden = false + } + + private _sliceIndexFromEvent(e: Event): number | null { + const path = (e.target as Element | null)?.closest('.tc-pie-chart-slice') + if (!path) return null + const i = parseInt(path.dataset.i ?? '', 10) + return isNaN(i) ? null : i + } + + private _legendIndexFromEvent(e: Event): number | null { + const btn = (e.target as Element | null)?.closest('.tc-pie-chart-legend-item') + if (!btn) return null + const i = parseInt(btn.dataset.i ?? '', 10) + return isNaN(i) ? null : i + } + + private _onSlicePointer = (e: Event): void => { + const i = this._sliceIndexFromEvent(e) + if (i === null) { this._clearActive(); return } + if (i !== this._active) this._applyActive(i) + } + + private _onLegendHover = (e: Event): void => { + const i = this._legendIndexFromEvent(e) + // only highlight slices that are actually visible + if (i === null || this._hidden.has(i)) return + if (i !== this._active) this._applyActive(i) + } + + private _onPointerLeave = (): void => { + this._clearActive() + } + + // ---- legend / slice toggle + select ---- + + private _toggle(i: number): void { + // never let the user hide the last visible slice + if (!this._hidden.has(i) && this._hidden.size >= this._data.length - 1) return + if (this._hidden.has(i)) this._hidden.delete(i) + else this._hidden.add(i) + this.render() + } + + private _emitSelect(i: number): void { + const slice = this._data[i] + if (!slice) return + this.dispatchEvent(new CustomEvent('tc-slice-select', { + bubbles: true, + composed: true, + detail: { slice, index: i } as PieSliceSelectDetail, + })) + } + + private _onLegendClick = (e: Event): void => { + const i = this._legendIndexFromEvent(e) + if (i === null) return + this._emitSelect(i) + this._toggle(i) + } + + private _onSliceClick = (e: Event): void => { + const i = this._sliceIndexFromEvent(e) + if (i === null) return + this._emitSelect(i) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: PieChart + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index d9ec063e..c89a1934 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -113,6 +113,7 @@ export * from './HeroStatsBar' export * from './Leaderboard' export * from './LeaderboardTrend' export * from './LineChart' +export * from './PieChart' export * from './LinkedProvidersCard' export * from './LogoCloud' export * from './MaintainerCard' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 95a3a3ed..58f342c7 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -186,6 +186,7 @@ import { InfiniteScroll } from './InfiniteScroll' import { InstallTabs } from './InstallTabs' import { Leaderboard } from './Leaderboard' import { LineChart } from './LineChart' +import { PieChart } from './PieChart' import { LiveFeed } from './LiveFeed' import { Login } from './Login' import { Marquee } from './Marquee' @@ -406,6 +407,7 @@ export function register(): void { customElements.define('tc-install-tabs', InstallTabs) customElements.define('tc-leaderboard', Leaderboard) customElements.define('tc-line-chart', LineChart) + customElements.define('tc-pie-chart', PieChart) customElements.define('tc-live-feed', LiveFeed) customElements.define('tc-login', Login) customElements.define('tc-marquee', Marquee) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index a6ffb0be..e812f480 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -179,6 +179,7 @@ @forward 'form-wizard'; @forward 'funnel-chart'; @forward 'line-chart'; +@forward 'pie-chart'; @forward 'gantt-chart'; @forward 'game-showcase-card'; @forward 'github-stars-card'; diff --git a/web-components/style/components/_pie-chart.scss b/web-components/style/components/_pie-chart.scss new file mode 100644 index 00000000..fab64b70 --- /dev/null +++ b/web-components/style/components/_pie-chart.scss @@ -0,0 +1,330 @@ +// tc-pie-chart — inline-SVG pie / donut chart with a percentage distribution, an +// interactive (hoverable + toggleable) legend, and an optional donut centre +// label. Slate neutrals carry it: the chart frame sits on --tc-surface behind a +// 1px hairline. Default slice fills come from a small slate-leaning ramp (neutrals +// first, exposed as --bs-pie-chart-slice-N); an explicit per-slice `color` from +// the data is the one sanctioned override. The pie/donut disc is a sanctioned +// circular shape; everything else (wrapper, legend swatches) is sharp. Hover +// highlights via opacity + a subtle slice-pull (frozen under reduced motion) — never +// a new hue. The tooltip is overlay tier — ink surface, white text, elevation +// shadow, a 3px colored left stripe per the toast/tooltip motif. Legend entries are +// real buttons following the state ladder (active = ink, dimmed when toggled off) +// with a visible focus outline. The centre label uses Inter weight 600 with a mono +// sub-value. Every cosmetic value flows through a --bs-pie-chart-* custom property. + +tc-pie-chart { + // slice fill ramp — neutrals first, then a few restrained data hues + --bs-pie-chart-slice-1: var(--tc-app-accent); + --bs-pie-chart-slice-2: var(--tc-slate-500); + --bs-pie-chart-slice-3: var(--tc-info); + --bs-pie-chart-slice-4: var(--tc-slate-700); + --bs-pie-chart-slice-5: var(--tc-success); + --bs-pie-chart-slice-6: var(--tc-slate-400); + --bs-pie-chart-slice-7: var(--tc-warning); + --bs-pie-chart-slice-8: var(--tc-slate-600); + + --bs-pie-chart-bg: var(--tc-surface); + --bs-pie-chart-border: 1px solid var(--tc-border); + --bs-pie-chart-title-color: var(--tc-text); + --bs-pie-chart-subtitle-color: var(--tc-text-muted); + + // gap between slices (the surface shows through) + --bs-pie-chart-slice-stroke: var(--tc-surface); + --bs-pie-chart-slice-stroke-width: 2; + --bs-pie-chart-dim-opacity: 0.5; + + // donut hole + centre label + --bs-pie-chart-hole-fill: var(--tc-surface); + --bs-pie-chart-center-label-color: var(--tc-text-muted); + --bs-pie-chart-center-value-color: var(--tc-text); + + // tooltip (overlay tier) + --bs-pie-chart-tooltip-bg: var(--tc-ink); + --bs-pie-chart-tooltip-color: #fff; + --bs-pie-chart-tooltip-stripe-width: 3px; + --bs-pie-chart-tooltip-shadow: var(--tc-shadow-md); + + // legend + --bs-pie-chart-legend-color: var(--tc-text); + --bs-pie-chart-legend-pct-color: var(--tc-text-muted); + --bs-pie-chart-legend-off-opacity: 0.45; + + --bs-pie-chart-skeleton-bg: var(--tc-surface-muted); + + display: block; +} + +.tc-pie-chart-inner { + display: flex; + flex-direction: column; + gap: 0.625rem; + padding: 0.875rem 1rem; + background: var(--bs-pie-chart-bg); + border: var(--bs-pie-chart-border); + border-radius: 0; +} + +// --- header --- +.tc-pie-chart-header { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.tc-pie-chart-title { + font-size: 0.95rem; + font-weight: 600; + line-height: 1.3; + color: var(--bs-pie-chart-title-color); +} + +.tc-pie-chart-subtitle { + font-size: 0.8125rem; + line-height: 1.3; + color: var(--bs-pie-chart-subtitle-color); +} + +// --- body: chart + legend side by side --- +.tc-pie-chart-body { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1.5rem; +} + +.tc-pie-chart-plot { + position: relative; + flex: 0 0 auto; +} + +.tc-pie-chart-svg { + display: block; + overflow: visible; + touch-action: none; + max-width: 100%; + height: auto; +} + +// --- slices --- +.tc-pie-chart-slice { + stroke: var(--bs-pie-chart-slice-stroke); + stroke-width: var(--bs-pie-chart-slice-stroke-width); + stroke-linejoin: round; + fill: var(--tc-pie-color, var(--bs-pie-chart-slice-1)); + cursor: pointer; + transition: transform 0.15s ease, opacity var(--tc-transition-fast, 0.15s ease); +} + +// when one slice is active, the rest recede (highlight via opacity, not a new hue) +.tc-pie-chart-slices--has-active .tc-pie-chart-slice { + opacity: var(--bs-pie-chart-dim-opacity); +} + +.tc-pie-chart-slice--active { + opacity: 1; +} + +// --- donut hole + centre label --- +.tc-pie-chart-hole { + fill: var(--bs-pie-chart-hole-fill); +} + +.tc-pie-chart-center-label { + fill: var(--bs-pie-chart-center-label-color); + font-family: var(--tc-font-sans, 'Inter', sans-serif); + font-size: 0.75rem; + font-weight: 600; +} + +.tc-pie-chart-center-value { + fill: var(--bs-pie-chart-center-value-color); + font-family: var(--tc-font-mono); + font-size: 1.25rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +// --- empty --- +.tc-pie-chart-empty { + padding: 1.5rem 0; + font-size: 0.8125rem; + color: var(--tc-text-muted); + text-align: center; +} + +// --- tooltip (overlay tier) --- +.tc-pie-chart-tooltip { + position: absolute; + z-index: var(--tc-z-tooltip, 1070); + transform: translate(-50%, calc(-100% - 8px)); + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; + padding: 0.3125rem 0.5rem; + background: var(--bs-pie-chart-tooltip-bg); + color: var(--bs-pie-chart-tooltip-color); + border-radius: 0; + border-left: var(--bs-pie-chart-tooltip-stripe-width) solid var(--tc-pie-color, var(--tc-accent)); + box-shadow: var(--bs-pie-chart-tooltip-shadow); + white-space: nowrap; + pointer-events: none; +} + +.tc-pie-chart-tooltip[hidden] { + display: none; +} + +.tc-pie-chart-tooltip-label { + font-size: 0.75rem; + opacity: 0.85; +} + +.tc-pie-chart-tooltip-row { + display: flex; + align-items: baseline; + gap: 0.5rem; +} + +.tc-pie-chart-tooltip-value { + font-size: 0.78rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.tc-pie-chart-tooltip-pct { + margin-left: auto; + font-family: var(--tc-font-mono); + font-size: 0.6875rem; + letter-spacing: 0.04em; + opacity: 0.75; +} + +// --- legend (toggle buttons) --- +.tc-pie-chart-legend { + display: flex; + flex-direction: column; + gap: 0.125rem; + margin: 0; + padding: 0; + list-style: none; + min-width: 0; +} + +.tc-pie-chart-legend-row { + margin: 0; +} + +.tc-pie-chart-legend-item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.1875rem 0.375rem; + margin: 0; + border: 0; + border-radius: 0; + background: none; + color: var(--bs-pie-chart-legend-color); + font-size: 0.8125rem; + line-height: 1.2; + text-align: left; + cursor: pointer; + transition: background-color var(--tc-transition-fast, 0.15s ease), + opacity var(--tc-transition-fast, 0.15s ease); +} + +// hover / hovered-slice mirror → slate well (state ladder) +.tc-pie-chart-legend-item:hover, +.tc-pie-chart-legend-item--active { + background: var(--tc-surface-muted); +} + +// dimmed (toggled off) +.tc-pie-chart-legend-item--off { + opacity: var(--bs-pie-chart-legend-off-opacity); +} + +.tc-pie-chart-legend-item--off .tc-pie-chart-legend-label { + text-decoration: line-through; +} + +.tc-pie-chart-legend-item:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; +} + +// small square swatch (sharp) +.tc-pie-chart-legend-swatch { + width: 0.75rem; + height: 0.75rem; + border-radius: 0; + background: var(--tc-pie-color, var(--bs-pie-chart-slice-1)); + flex: 0 0 auto; +} + +.tc-pie-chart-legend-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tc-pie-chart-legend-pct { + flex: 0 0 auto; + margin-left: auto; + font-family: var(--tc-font-mono); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + color: var(--bs-pie-chart-legend-pct-color); +} + +// --- loading skeleton (slate neutrals only) --- +.tc-pie-chart-skeleton { + background: linear-gradient( + 90deg, + var(--bs-pie-chart-skeleton-bg) 25%, + var(--tc-surface-hover) 37%, + var(--bs-pie-chart-skeleton-bg) 63% + ); + background-size: 200% 100%; + animation: tc-pie-chart-shimmer 1.4s ease infinite; +} + +.tc-pie-chart-skeleton--title { + width: 40%; + height: 0.95rem; +} + +// the resting pie disc is circular — the skeleton mirrors that one sanctioned curve +.tc-pie-chart-skeleton--disc { + flex: 0 0 auto; + border-radius: 50%; +} + +.tc-pie-chart-skeleton--legend { + width: 8rem; + height: 0.85rem; +} + +@keyframes tc-pie-chart-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +@media (prefers-reduced-motion: reduce) { + .tc-pie-chart-skeleton { + animation: none; + background: var(--bs-pie-chart-skeleton-bg); + } + + // freeze the decorative slice-pull transform; keep the opacity state change + .tc-pie-chart-slice { + transition: opacity var(--tc-transition-fast, 0.15s ease); + } + + .tc-pie-chart-legend-item { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index a322c675..8293c3f7 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -251,6 +251,7 @@ tc-infinite-scroll, tc-install-tabs, tc-leaderboard, tc-line-chart, +tc-pie-chart, tc-live-feed, tc-login, tc-marquee, From 7510831c51e60b4f12e6276bfcfee5d18f973d51 Mon Sep 17 00:00:00 2001 From: kalevski Date: Mon, 15 Jun 2026 19:01:23 +0000 Subject: [PATCH 253/632] 251-command-palette: Build the tc-command-palette web component (CommandPalette parity) --- examples/public/web-components/SKILL.md | 52 +++ .../src/web-components/CommandPaletteDemo.tsx | 100 ++++ examples/src/web-components/index.tsx | 2 + web-components/src/CommandPalette.ts | 437 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + .../style/components/_command-palette.scss | 325 +++++++++++++ web-components/style/components/_index.scss | 1 + web-components/style/foundation/_reset.scss | 1 + 9 files changed, 921 insertions(+) create mode 100644 examples/src/web-components/CommandPaletteDemo.tsx create mode 100644 web-components/src/CommandPalette.ts create mode 100644 web-components/style/components/_command-palette.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index cae35219..a8549611 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -190,6 +190,7 @@ After `register()` you can author markup directly: - [tc-tab-sections](#tc-tab-sections) - [tc-vertical-item-list](#tc-vertical-item-list) - [Overlays & Feedback](#overlays--feedback) + - [tc-command-palette](#tc-command-palette) - [tc-context-menu](#tc-context-menu) - [tc-drawer](#tc-drawer) - [tc-modal](#tc-modal) @@ -4751,6 +4752,57 @@ Vertical navigation menu with icons and badges beside an associated content area ## Overlays & Feedback +### tc-command-palette + +Modal command-search overlay with fuzzy/substring filtering, results grouped by `group`, optional per-item icon and shortcut chips, and full keyboard navigation. Controlled component — fires `tc-select` and `tc-close`; the host does **not** self-close, so set `open=false` in those handlers. + +**Tag:** `tc-command-palette` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `open` | boolean | false | Visible state — add/remove to show/hide the overlay | +| `placeholder` | string | `Type a command or search…` | Search input placeholder | +| `loading` | boolean | false | Renders a skeleton list instead of results | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `items` | `CommandPaletteItem[]` | Commands to search. Each: `{ id: string; label: string; group?: string; icon?: string; shortcut?: string; keywords?: string[] }`. `icon` is a lucide name; `keywords` extend the search space without being shown. Re-renders on set. | +| `onClose` | `(() => void) \| null` | Callback fired alongside the `tc-close` event | +| `onSelect` | `((item: CommandPaletteItem) => void) \| null` | Callback fired alongside the `tc-select` event | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-select` | `{ item: CommandPaletteItem }` | Fired when an item is chosen (Enter on the highlighted row or click). `tc-close` follows. | +| `tc-close` | `{}` | Fired on backdrop click, Escape, or after a selection. Set `open=false` in this handler — the host does not self-close. | + +**Keyboard:** ↑/↓ move the highlight (wrapping), ↵ selects, Esc closes. Typing filters and resets the highlight to the first match. + +**Slots:** none — content is driven entirely by the `items` property. + +```html + + + +``` + +--- + ### tc-context-menu Right-click / long-press context menu with nested submenu support and full keyboard navigation (ArrowUp/Down/Left/Right, Enter, Space, Escape). Fires `tc-select` when a leaf item is chosen. diff --git a/examples/src/web-components/CommandPaletteDemo.tsx b/examples/src/web-components/CommandPaletteDemo.tsx new file mode 100644 index 00000000..e7c4d4a1 --- /dev/null +++ b/examples/src/web-components/CommandPaletteDemo.tsx @@ -0,0 +1,100 @@ +import React, { useRef, useState, useEffect } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +interface CommandPaletteItem { + id: string + label: string + group?: string + icon?: string + shortcut?: string + keywords?: string[] +} + +const ITEMS: CommandPaletteItem[] = [ + { id: 'new-file', label: 'New File', group: 'File', icon: 'file-plus', shortcut: '⌘ N', keywords: ['create', 'document'] }, + { id: 'open-file', label: 'Open File…', group: 'File', icon: 'folder-open', shortcut: '⌘ O', keywords: ['browse'] }, + { id: 'save', label: 'Save', group: 'File', icon: 'save', shortcut: '⌘ S' }, + { id: 'cut', label: 'Cut', group: 'Edit', icon: 'scissors', shortcut: '⌘ X' }, + { id: 'copy', label: 'Copy', group: 'Edit', icon: 'copy', shortcut: '⌘ C' }, + { id: 'paste', label: 'Paste', group: 'Edit', icon: 'clipboard', shortcut: '⌘ V' }, + { id: 'find', label: 'Find in File', group: 'Edit', icon: 'search', shortcut: '⌘ F', keywords: ['search'] }, + { id: 'toggle-theme', label: 'Toggle Dark Theme', group: 'View', icon: 'moon', keywords: ['dark', 'light', 'appearance'] }, + { id: 'zen', label: 'Enter Zen Mode', group: 'View', icon: 'maximize', keywords: ['fullscreen', 'focus'] }, + { id: 'settings', label: 'Open Settings', group: 'View', icon: 'settings', shortcut: '⌘ ,' }, +] + +const CommandPaletteDemo: React.FC = () => { + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [lastSelected, setLastSelected] = useState(null) + const paletteRef = useRef(null) + + // JS-property + events must be wired via ref (React can't set object props + // as attributes, and listens for the tc-* CustomEvents). + useEffect(() => { + const el = paletteRef.current + if (!el) return + el.items = ITEMS + + const onSelect = (e: Event) => { + const item = (e as CustomEvent).detail.item as CommandPaletteItem + setLastSelected(item.label) + setOpen(false) + } + const onClose = () => setOpen(false) + + el.addEventListener('tc-select', onSelect) + el.addEventListener('tc-close', onClose) + return () => { + el.removeEventListener('tc-select', onSelect) + el.removeEventListener('tc-close', onClose) + } + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="Command Palette" + description="Modal command-search overlay with fuzzy filtering, grouped results, shortcut chips, and full keyboard navigation. Controlled component — fires tc-select / tc-close; set open to false to dismiss." + /> + +
    + +
    + + + {lastSelected && ( + + Last selected: {lastSelected} + + )} +
    +

    + Type to filter, ↑/↓ to navigate, ↵ to select, Esc or backdrop click to close. +

    + + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    + ) +} + +export default CommandPaletteDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index d8c26e31..591473ff 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -158,6 +158,7 @@ import SlicesCardDemo from './SlicesCardDemo' import DatePickerDemo from './DatePickerDemo' import DiffViewerDemo from './DiffViewerDemo' import DrawerDemo from './DrawerDemo' +import CommandPaletteDemo from './CommandPaletteDemo' import EarlySignupFormDemo from './EarlySignupFormDemo' import EcosystemMapDemo from './EcosystemMapDemo' import EditableTextDemo from './EditableTextDemo' @@ -383,6 +384,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'compatibility-matrix', category: 'Components', element: }, { key: 'context-menu', category: 'Overlays & Feedback', element: }, { key: 'drawer', category: 'Overlays & Feedback', element: }, + { key: 'command-palette', category: 'Overlays & Feedback', element: }, { key: 'cool-nav', category: 'Navigation', element: }, { key: 'countdown-timer', category: 'Components', element: }, { key: 'danger-zone-actions', category: 'Components', element: }, diff --git a/web-components/src/CommandPalette.ts b/web-components/src/CommandPalette.ts new file mode 100644 index 00000000..cf431896 --- /dev/null +++ b/web-components/src/CommandPalette.ts @@ -0,0 +1,437 @@ +import * as LucideIcons from 'lucide-static' +import { icon } from './icons' + +const TAG_NAME = 'tc-command-palette' + +const DEFAULT_PLACEHOLDER = 'Type a command or search…' + +let _idCounter = 0 + +export interface CommandPaletteItem { + id: string + label: string + group?: string + icon?: string + shortcut?: string + keywords?: string[] +} + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function lucideByName(name: string): string { + const pascal = name + .split('-') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') + const svgStr = (LucideIcons as Record)[pascal] + if (!svgStr) return '' + return icon(svgStr) +} + +const searchIconHtml = lucideByName('search') + +interface PaletteGroup { + group: string + items: CommandPaletteItem[] +} + +export class CommandPalette extends HTMLElement { + private _initialised = false + private _items: CommandPaletteItem[] = [] + private _query = '' + private _highlighted = 0 + private _previousFocus: Element | null = null + private _keydownAttached = false + private _idPrefix: string + + onClose: (() => void) | null = null + onSelect: ((item: CommandPaletteItem) => void) | null = null + + constructor() { + super() + this._idPrefix = `tc-command-palette-${++_idCounter}` + } + + static get observedAttributes(): string[] { + return ['open', 'placeholder', 'loading'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + // Host-level delegation persists for the element's lifetime; it only + // does anything while the palette is open (closed → no markup). + this._detachHostHandlers() + this._attachHostHandlers() + if (this.open) this._openSideEffects() + } + + disconnectedCallback(): void { + this._detachHostHandlers() + this._detachKeydown() + this._restoreScroll() + } + + attributeChangedCallback(name: string): void { + if (!this.isConnected || !this._initialised) return + + if (name === 'open') { + if (this.open) { + this._query = '' + this._highlighted = 0 + this.render() + this._openSideEffects() + } else { + this._closeSideEffects() + this.render() + } + return + } + + // placeholder / loading — re-render only matters while visible. + if (this.open) this.render() + } + + // ── Props ────────────────────────────────────────────────────────────────── + + get open(): boolean { + return this.hasAttribute('open') + } + set open(v: boolean) { + if (v) this.setAttribute('open', '') + else this.removeAttribute('open') + } + + get placeholder(): string { + return this.getAttribute('placeholder') ?? DEFAULT_PLACEHOLDER + } + set placeholder(v: string) { + this.setAttribute('placeholder', v) + } + + get loading(): boolean { + return this.hasAttribute('loading') + } + set loading(v: boolean) { + if (v) this.setAttribute('loading', '') + else this.removeAttribute('loading') + } + + get items(): CommandPaletteItem[] { + return this._items + } + set items(v: CommandPaletteItem[]) { + this._items = Array.isArray(v) ? v : [] + if (this._initialised && this.open) { + this._highlighted = 0 + this.render() + } + } + + // ── Filtering / grouping ───────────────────────────────────────────────────── + + private _matches(item: CommandPaletteItem, query: string): boolean { + if (!query) return true + const haystack = [item.label, ...(item.keywords ?? [])].join(' ').toLowerCase() + return query.split(/\s+/).every(word => haystack.includes(word)) + } + + private _computeView(): { groups: PaletteGroup[]; flat: CommandPaletteItem[] } { + const q = this._query.trim().toLowerCase() + const filtered = this._items.filter(it => this._matches(it, q)) + const groups: PaletteGroup[] = [] + const seen = new Map() + filtered.forEach(it => { + const g = it.group ?? '' + if (!seen.has(g)) { + seen.set(g, groups.length) + groups.push({ group: g, items: [] }) + } + groups[seen.get(g)!].items.push(it) + }) + const flat = groups.flatMap(g => g.items) + return { groups, flat } + } + + // ── Side effects (open/close lifecycle) ─────────────────────────────────────── + + private _openSideEffects(): void { + this._previousFocus = document.activeElement + this._lockScroll() + this._attachKeydown() + requestAnimationFrame(() => { + this.querySelector('.tc-command-palette-input')?.focus() + }) + } + + private _closeSideEffects(): void { + this._detachKeydown() + this._restoreScroll() + this._restoreFocus() + } + + private _lockScroll(): void { + document.body.style.overflow = 'hidden' + } + + private _restoreScroll(): void { + document.body.style.overflow = '' + } + + private _restoreFocus(): void { + if (this._previousFocus instanceof HTMLElement) this._previousFocus.focus() + this._previousFocus = null + } + + private _requestClose(): void { + this.dispatchEvent(new CustomEvent('tc-close', { + bubbles: true, + composed: true, + detail: {}, + })) + if (typeof this.onClose === 'function') this.onClose() + } + + private _selectItem(flatIdx: number): void { + const { flat } = this._computeView() + const item = flat[flatIdx] + if (!item) return + this.dispatchEvent(new CustomEvent('tc-select', { + bubbles: true, + composed: true, + detail: { item }, + })) + if (typeof this.onSelect === 'function') this.onSelect(item) + this._requestClose() + } + + // ── Highlight (surgical, no full re-render) ─────────────────────────────────── + + private _setHighlight(idx: number): void { + this._highlighted = idx + const list = this.querySelector('.tc-command-palette-list') + if (!list) return + list.querySelectorAll('.tc-command-palette-item').forEach(el => { + const isActive = el.getAttribute('data-idx') === String(idx) + el.classList.toggle('tc-command-palette-item--active', isActive) + el.setAttribute('aria-selected', isActive ? 'true' : 'false') + if (isActive) el.scrollIntoView({ block: 'nearest' }) + }) + this._updateActiveDescendant() + } + + private _updateActiveDescendant(): void { + const input = this.querySelector('.tc-command-palette-input') + if (!input) return + const active = this.querySelector( + `.tc-command-palette-item[data-idx="${this._highlighted}"]`, + ) + if (active) input.setAttribute('aria-activedescendant', active.id) + else input.removeAttribute('aria-activedescendant') + } + + // ── Handlers ─────────────────────────────────────────────────────────────── + + private _onClick = (e: MouseEvent): void => { + const target = e.target as Element + const itemEl = target.closest('.tc-command-palette-item') + if (itemEl) { + this._selectItem(parseInt(itemEl.getAttribute('data-idx') ?? '-1', 10)) + return + } + // Backdrop click — target is outside the dialog surface. + if (!target.closest('.tc-command-palette')) this._requestClose() + } + + private _onMouseMove = (e: MouseEvent): void => { + const itemEl = (e.target as Element).closest('.tc-command-palette-item') + if (!itemEl) return + const idx = parseInt(itemEl.getAttribute('data-idx') ?? '-1', 10) + if (idx >= 0 && idx !== this._highlighted) this._setHighlight(idx) + } + + private _onInput = (e: Event): void => { + this._query = (e.target as HTMLInputElement).value + this._highlighted = 0 + this._patchList() + } + + private _onKeydown = (e: KeyboardEvent): void => { + if (!this.open) return + const { flat } = this._computeView() + + if (e.key === 'Escape') { + e.preventDefault() + this._requestClose() + return + } + if (e.key === 'ArrowDown') { + e.preventDefault() + if (flat.length === 0) return + this._setHighlight((this._highlighted + 1) % flat.length) + return + } + if (e.key === 'ArrowUp') { + e.preventDefault() + if (flat.length === 0) return + this._setHighlight((this._highlighted - 1 + flat.length) % flat.length) + return + } + if (e.key === 'Enter') { + e.preventDefault() + this._selectItem(this._highlighted) + return + } + if (e.key === 'Tab') { + // Only the search input is focusable inside the dialog — keep focus + // trapped on it (rows are navigated via aria-activedescendant). + e.preventDefault() + this.querySelector('.tc-command-palette-input')?.focus() + } + } + + private _attachHostHandlers(): void { + this.addEventListener('click', this._onClick) + this.addEventListener('mousemove', this._onMouseMove) + } + + private _detachHostHandlers(): void { + this.removeEventListener('click', this._onClick) + this.removeEventListener('mousemove', this._onMouseMove) + } + + private _attachKeydown(): void { + if (this._keydownAttached) return + document.addEventListener('keydown', this._onKeydown) + this._keydownAttached = true + } + + private _detachKeydown(): void { + if (!this._keydownAttached) return + document.removeEventListener('keydown', this._onKeydown) + this._keydownAttached = false + } + + // ── Render ─────────────────────────────────────────────────────────────────── + + private _renderShortcut(shortcut: string): string { + return shortcut + .trim() + .split(/\s+/) + .map(k => `${esc(k)}`) + .join('') + } + + private _renderListInner(): string { + const listId = `${this._idPrefix}-list` + + if (this.loading) { + const rows = Array.from({ length: 5 }) + .map( + () => + ``, + ) + .join('') + return rows + `` + } + + const { groups, flat } = this._computeView() + + if (flat.length === 0) { + const q = this._query.trim() + const msg = q ? `No results for “${esc(q)}”` : 'No commands available' + return `` + } + + let idx = 0 + let html = '' + for (const grp of groups) { + if (grp.group) { + html += `` + } + for (const item of grp.items) { + const flatIdx = idx++ + const selected = flatIdx === this._highlighted + const optId = `${listId}-opt-${flatIdx}` + const iconHtml = item.icon + ? `` + : '' + const shortcutHtml = item.shortcut + ? `${this._renderShortcut(item.shortcut)}` + : '' + html += + `
  • ` + + iconHtml + + `${esc(item.label)}` + + shortcutHtml + + `
  • ` + } + } + return html + } + + private _patchList(): void { + const list = this.querySelector('.tc-command-palette-list') + if (!list) return + if (this.loading) list.setAttribute('aria-busy', 'true') + else list.removeAttribute('aria-busy') + list.innerHTML = this._renderListInner() + this._updateActiveDescendant() + } + + private render(): void { + if (!this.open) { + this.innerHTML = '' + return + } + + const placeholder = esc(this.placeholder) + const listId = `${this._idPrefix}-list` + const inputId = `${this._idPrefix}-input` + const busyAttr = this.loading ? ' aria-busy="true"' : '' + + this.innerHTML = + `
    ` + + `` + + `
    ` + + this.querySelector('.tc-command-palette-input')?.addEventListener( + 'input', + this._onInput, + ) + this._updateActiveDescendant() + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: CommandPalette + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index c89a1934..831bfa24 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -153,6 +153,7 @@ export * from './Chip' export * from './ChipGroup' export * from './CodeSnippet' export * from './ColorPicker' +export * from './CommandPalette' export * from './CommandReference' export * from './Comparator' export * from './CompatibilityMatrix' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 58f342c7..cdfd604c 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -149,6 +149,7 @@ import { Chip } from './Chip' import { ChipGroup } from './ChipGroup' import { CodeSnippet } from './CodeSnippet' import { ColorPicker } from './ColorPicker' +import { CommandPalette } from './CommandPalette' import { CommandReference } from './CommandReference' import { Comparator } from './Comparator' import { CompatibilityMatrix } from './CompatibilityMatrix' @@ -370,6 +371,7 @@ export function register(): void { customElements.define('tc-chip-group', ChipGroup) customElements.define('tc-code-snippet', CodeSnippet) customElements.define('tc-color-picker', ColorPicker) + customElements.define('tc-command-palette', CommandPalette) customElements.define('tc-command-reference', CommandReference) customElements.define('tc-comparator', Comparator) customElements.define('tc-compatibility-matrix', CompatibilityMatrix) diff --git a/web-components/style/components/_command-palette.scss b/web-components/style/components/_command-palette.scss new file mode 100644 index 00000000..84c5df27 --- /dev/null +++ b/web-components/style/components/_command-palette.scss @@ -0,0 +1,325 @@ +// tc-command-palette — modal command-search overlay. Fully custom (no Bootstrap +// plugin). All cosmetics flow through --bs-command-palette-* vars backed by +// --tc-* tokens. Sharp corners everywhere; the active row uses the ink ladder. + +tc-command-palette { + // Surface + chrome. + --bs-command-palette-bg: var(--tc-surface); + --bs-command-palette-border-color: var(--tc-border); + --bs-command-palette-shadow: var(--tc-shadow-lg); + --bs-command-palette-width: min(640px, calc(100vw - 2rem)); + --bs-command-palette-max-height: 60vh; + --bs-command-palette-top: 12vh; + + // Backdrop scrim — translucent ink. + --bs-command-palette-backdrop-bg: rgba(15, 23, 42, 0.5); + + // Z-index band — backdrop below dialog, both in overlay tier. + --bs-command-palette-z-backdrop: var(--tc-z-modal-backdrop); + --bs-command-palette-z-dialog: var(--tc-z-modal); + + // Search header. + --bs-command-palette-input-color: var(--tc-text); + --bs-command-palette-placeholder-color: var(--tc-text-faint); + --bs-command-palette-icon-color: var(--tc-text-muted); + + // Rows. + --bs-command-palette-item-color: var(--tc-text); + --bs-command-palette-item-hover-bg: var(--tc-surface-muted); + --bs-command-palette-item-active-bg: var(--tc-app-accent); + --bs-command-palette-item-active-color: var(--tc-app-accent-contrast); + + // Muted machine-facing text (group headings, shortcuts, footer). + --bs-command-palette-muted-color: var(--tc-text-muted); + + // Motion. + --bs-command-palette-transition: var(--tc-transition-base); +} + +// ── Backdrop ────────────────────────────────────────────────────────────────── + +.tc-command-palette-backdrop { + position: fixed; + inset: 0; + z-index: var(--bs-command-palette-z-backdrop); + display: flex; + align-items: flex-start; + justify-content: center; + padding: var(--bs-command-palette-top) 1rem 1rem; + background: var(--bs-command-palette-backdrop-bg); + animation: tc-command-palette-fade var(--bs-command-palette-transition); +} + +// ── Dialog ────────────────────────────────────────────────────────────────────── + +.tc-command-palette { + position: relative; + z-index: var(--bs-command-palette-z-dialog); + display: flex; + flex-direction: column; + width: var(--bs-command-palette-width); + max-height: var(--bs-command-palette-max-height); + background: var(--bs-command-palette-bg); + border: 1px solid var(--bs-command-palette-border-color); + border-radius: 0; + box-shadow: var(--bs-command-palette-shadow); + overflow: hidden; + animation: tc-command-palette-pop var(--bs-command-palette-transition); +} + +// ── Search header ───────────────────────────────────────────────────────────── + +.tc-command-palette-search { + display: flex; + align-items: center; + gap: 0.625rem; + flex-shrink: 0; + padding: 0.75rem 1rem; + // Hairline separating the borderless input from the results list. + border-bottom: 1px solid var(--bs-command-palette-border-color); +} + +.tc-command-palette-search-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--bs-command-palette-icon-color); + + svg { + width: 1.125rem; + height: 1.125rem; + } +} + +.tc-command-palette-input { + flex: 1 1 auto; + min-width: 0; + margin: 0; + padding: 0; + border: 0; + background: none; + outline: 0; + font-family: var(--tc-font-sans); + font-size: 0.9375rem; + line-height: 1.5; + color: var(--bs-command-palette-input-color); + + &::placeholder { + color: var(--bs-command-palette-placeholder-color); + } +} + +// ── Results list ────────────────────────────────────────────────────────────── + +.tc-command-palette-list { + flex: 1 1 auto; + margin: 0; + padding: 0.375rem 0; + list-style: none; + overflow-y: auto; +} + +// Group heading — mono uppercase micro-label. +.tc-command-palette-group { + padding: 0.5rem 1rem 0.25rem; + font-family: var(--tc-font-mono); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--bs-command-palette-muted-color); +} + +.tc-command-palette-item { + display: flex; + align-items: center; + gap: 0.75rem; + min-height: 2.25rem; + padding: 0.5rem 1rem; + border-radius: 0; + font-size: 0.875rem; + color: var(--bs-command-palette-item-color); + cursor: pointer; + transition: background-color var(--tc-transition-fast), color var(--tc-transition-fast); + + // 44px coarse-pointer touch target. + @media (pointer: coarse) { + min-height: 2.75rem; + } +} + +// Hover-only (non-highlighted) row — slate well. +.tc-command-palette-item:hover:not(.tc-command-palette-item--active) { + background: var(--bs-command-palette-item-hover-bg); +} + +// Highlighted row — solid ink fill, white text (active = ink). +.tc-command-palette-item--active { + background: var(--bs-command-palette-item-active-bg); + color: var(--bs-command-palette-item-active-color); +} + +.tc-command-palette-item-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--bs-command-palette-icon-color); + + .tc-command-palette-item--active & { + color: var(--bs-command-palette-item-active-color); + } + + svg { + width: 1rem; + height: 1rem; + } +} + +.tc-command-palette-item-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tc-command-palette-item-shortcut { + display: inline-flex; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; + margin-left: auto; +} + +// ── kbd chips (shortcuts + footer hints) ─────────────────────────────────────── + +.tc-command-palette-key { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.25rem; + padding: 0.0625rem 0.375rem; + border: 1px solid var(--bs-command-palette-border-color); + border-radius: 0; + background: var(--tc-surface); + font-family: var(--tc-font-mono); + font-size: 0.6875rem; + font-weight: 500; + line-height: 1.4; + color: var(--bs-command-palette-muted-color); + + // On the active row, chips invert to read against the ink fill. + .tc-command-palette-item--active & { + border-color: rgba(255, 255, 255, 0.4); + background: transparent; + color: var(--bs-command-palette-item-active-color); + } +} + +// ── Empty / loading states ────────────────────────────────────────────────────── + +.tc-command-palette-empty { + padding: 1.5rem 1rem; + text-align: center; + font-size: 0.875rem; + color: var(--bs-command-palette-muted-color); +} + +.tc-command-palette-skeleton { + padding: 0.5rem 1rem; +} + +.tc-command-palette-skeleton-bar { + display: block; + height: 0.75rem; + border-radius: 0; + background: linear-gradient( + 90deg, + var(--tc-surface-muted) 25%, + var(--tc-surface-hover) 37%, + var(--tc-surface-muted) 63% + ); + background-size: 200% 100%; + animation: tc-command-palette-shimmer 1.4s ease-in-out infinite; + + .tc-command-palette-skeleton:nth-child(2) & { + width: 80%; + } + .tc-command-palette-skeleton:nth-child(3) & { + width: 65%; + } + .tc-command-palette-skeleton:nth-child(4) & { + width: 72%; + } + .tc-command-palette-skeleton:nth-child(5) & { + width: 55%; + } +} + +// ── Footer hint bar ───────────────────────────────────────────────────────────── + +.tc-command-palette-footer { + display: flex; + align-items: center; + gap: 1rem; + flex-shrink: 0; + padding: 0.5rem 1rem; + border-top: 1px solid var(--bs-command-palette-border-color); + font-family: var(--tc-font-mono); + font-size: 0.6875rem; + letter-spacing: 0.05em; + color: var(--bs-command-palette-muted-color); + + span { + display: inline-flex; + align-items: center; + gap: 0.25rem; + } +} + +// ── Keyframes ──────────────────────────────────────────────────────────────────── + +@keyframes tc-command-palette-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes tc-command-palette-pop { + from { + opacity: 0; + transform: translateY(-8px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes tc-command-palette-shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} + +// ── Reduced motion — no slide/scale/shimmer, keep static visuals ──────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-command-palette-backdrop, + .tc-command-palette { + animation: none; + } + + .tc-command-palette-skeleton-bar { + animation: none; + background: var(--tc-surface-muted); + } +} diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index e812f480..9b2348e2 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -150,6 +150,7 @@ @forward 'chip-group'; @forward 'code-snippet'; @forward 'color-picker'; +@forward 'command-palette'; @forward 'command-reference'; @forward 'comparator'; @forward 'compatibility-matrix'; diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 8293c3f7..85d4079f 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -217,6 +217,7 @@ tc-cdn-map, tc-changelog, tc-chip-group, tc-code-snippet, +tc-command-palette, tc-command-reference, tc-comparator, tc-compatibility-matrix, From 2ec41551d70ee7e2155111b576592eeeddfc176c Mon Sep 17 00:00:00 2001 From: kalevski Date: Mon, 15 Jun 2026 19:10:20 +0000 Subject: [PATCH 254/632] 252-cycle-wheel: Build the tc-cycle-wheel web component (CycleWheel parity) --- examples/public/web-components/SKILL.md | 91 +++++++++ .../src/web-components/CycleWheelDemo.tsx | 102 +++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/CycleWheel.ts | 173 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + .../style/components/_cycle-wheel.scss | 173 ++++++++++++++++++ web-components/style/components/_index.scss | 1 + web-components/style/foundation/_reset.scss | 1 + 9 files changed, 546 insertions(+) create mode 100644 examples/src/web-components/CycleWheelDemo.tsx create mode 100644 web-components/src/CycleWheel.ts create mode 100644 web-components/style/components/_cycle-wheel.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index a8549611..c6e1782d 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -152,6 +152,7 @@ After `register()` you can author markup directly: - [tc-comparator](#tc-comparator) - [tc-compatibility-matrix](#tc-compatibility-matrix) - [tc-countdown-timer](#tc-countdown-timer) + - [tc-cycle-wheel](#tc-cycle-wheel) - [tc-danger-zone-actions](#tc-danger-zone-actions) - [tc-metric-card](#tc-metric-card) - [tc-slices-card](#tc-slices-card) @@ -12140,6 +12141,96 @@ None. `tc-countdown-timer` is a purely attribute/property-driven component. ``` +### tc-cycle-wheel + +Animated circular wheel with a continuously rotating SVG ring of phase labels around a fixed centre stack. The active phase is emphasised in the cyan accent while the others are muted slate; a static pointer arrow orients the wheel to the active phase. Purely presentational/animated — no events. The spin stops under `prefers-reduced-motion` (the static, oriented ring is shown instead). + +**Tag:** `tc-cycle-wheel` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `current-index` | number | `0` | Index of the active phase. Highlights the matching ring label and orients the pointer arrow. | +| `center-label` | string | — | Optional small mono micro-label rendered above the centre value. | +| `center-value` | string | — (required) | The prominent centre content. Required for a meaningful render. | +| `center-pill` | string | — | Optional small rounded-pill rendered beneath the centre value. | +| `center-sub` | string | — | Optional muted sub-line rendered below the pill. | +| `spin-seconds` | number | `20` | Continuous rotation period of the ring, in seconds (maps to `animation-duration`). | +| `paused` | boolean | `false` | When present, stops the spin (`animation-play-state: paused`). | + +**JS Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `phases` | `string[]` | `[]` | The phase labels arranged evenly around the ring. **Property only** (not an attribute). Setting it re-renders the ring. | +| `currentIndex` | `number` | `0` | Reflects the `current-index` attribute. | +| `centerLabel` | `string \| null` | `null` | Reflects the `center-label` attribute. | +| `centerValue` | `string \| null` | `null` | Reflects the `center-value` attribute. | +| `centerPill` | `string \| null` | `null` | Reflects the `center-pill` attribute. | +| `centerSub` | `string \| null` | `null` | Reflects the `center-sub` attribute. | +| `spinSeconds` | `number` | `20` | Reflects the `spin-seconds` attribute. | +| `paused` | `boolean` | `false` | Reflects the `paused` attribute. | + +**Events** + +None. `tc-cycle-wheel` is purely presentational/animated. + +**Slots** + +None. All content is supplied via attributes and the `phases` JS property. + +**Accessibility** + +The decorative SVGs are `aria-hidden`. The host carries `role="img"` and an `aria-label` summarising the centre label, the active phase, the centre value, and the sub-line — meaning is never color-only. + +**CSS Custom Properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-cycle-wheel-accent` | `var(--tc-accent)` | Active phase label + pointer arrow color (the one accent in the component). | +| `--bs-cycle-wheel-value` | `var(--tc-text)` | Centre value color. | +| `--bs-cycle-wheel-bg` | `var(--tc-surface)` | Centre core background. | +| `--bs-cycle-wheel-muted` | `var(--tc-text-muted)` | Muted phase labels + centre label/sub color. | +| `--bs-cycle-wheel-border` | `var(--tc-border)` | Centre core border. | +| `--bs-cycle-wheel-track` | `var(--tc-border)` | Ring hairline track stroke. | +| `--bs-cycle-wheel-tick` | `var(--tc-text-faint)` | Per-slot tick mark stroke. | +| `--bs-cycle-wheel-pill-bg` | `var(--tc-success-bg)` | Centre pill background. | +| `--bs-cycle-wheel-pill-color` | `var(--tc-success)` | Centre pill text color. | +| `--bs-cycle-wheel-label-font-size` | `11px` | Ring phase label font size. | +| `--bs-cycle-wheel-label-letter-spacing` | `0.12em` | Ring phase label letter-spacing. | +| `--bs-cycle-wheel-value-font-size` | `3rem` | Centre value font size. | +| `--bs-cycle-wheel-max-size` | `480px` | Maximum diameter of the wheel. | +| `--bs-cycle-wheel-spin-duration` | `20s` | Default spin duration (overridden inline by `spin-seconds`). | + +```html + + + + + + + + + + + +``` + ### tc-danger-zone-actions List of destructive actions rendered inside a danger-bordered panel. Each action row shows a title, optional description, and an `btn-outline-danger` button. Driven entirely by the `actions` JS property. Fires `tc-action-click` when a button is clicked. diff --git a/examples/src/web-components/CycleWheelDemo.tsx b/examples/src/web-components/CycleWheelDemo.tsx new file mode 100644 index 00000000..d5f98aa1 --- /dev/null +++ b/examples/src/web-components/CycleWheelDemo.tsx @@ -0,0 +1,102 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const CycleWheelDemo: React.FC = () => { + const mainRef = useRef(null) + const threeRef = useRef(null) + const pausedRef = useRef(null) + + const phases = ['Roll', 'Build', 'Attest', 'Ship', 'Close'] + const [current, setCurrent] = useState(1) + const [paused, setPaused] = useState(false) + + // phases is a JS property (array of strings) — must be set via ref, not attribute. + useEffect(() => { + if (mainRef.current) mainRef.current.phases = phases + }, []) + + useEffect(() => { + if (threeRef.current) threeRef.current.phases = ['Draft', 'Review', 'Ship'] + }, []) + + useEffect(() => { + if (pausedRef.current) pausedRef.current.phases = phases + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="CycleWheel" + description="Rotating SVG ring of phase labels around a fixed centre showing the current state, with an arrow pointer that orients to the active phase." + /> + +
    + +
    + {/* @ts-ignore */} + +
    +
    + + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default CycleWheelDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 591473ff..2bdcb871 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -152,6 +152,7 @@ import CompatibilityMatrixDemo from './CompatibilityMatrixDemo' import ContextMenuDemo from './ContextMenuDemo' import CoolNavDemo from './CoolNavDemo' import CountdownTimerDemo from './CountdownTimerDemo' +import CycleWheelDemo from './CycleWheelDemo' import DangerZoneActionsDemo from './DangerZoneActionsDemo' import MetricCardDemo from './MetricCardDemo' import SlicesCardDemo from './SlicesCardDemo' @@ -434,4 +435,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'time-picker', category: 'Forms', element: }, { key: 'toggle-card', category: 'Forms', element: }, { key: 'video-embed', category: 'Components', element: }, + { key: 'cycle-wheel', category: 'Components', element: }, ] diff --git a/web-components/src/CycleWheel.ts b/web-components/src/CycleWheel.ts new file mode 100644 index 00000000..c4e9f98d --- /dev/null +++ b/web-components/src/CycleWheel.ts @@ -0,0 +1,173 @@ +const TAG_NAME = 'tc-cycle-wheel' + +// Minimal HTML-escape for user-supplied strings injected into innerHTML. +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +const DEFAULT_SPIN = 20 + +/** + * tc-cycle-wheel — animated circular wheel with a continuously rotating ring of + * phase labels around a fixed centre stack. Presentational/animated: no events. + * Port of `@toolcase/react-components` `CycleWheel`. + */ +export class CycleWheel extends HTMLElement { + + private _initialised = false + private _phases: string[] = [] + + static get observedAttributes(): string[] { + return ['current-index', 'center-label', 'center-value', 'center-pill', 'center-sub', 'spin-seconds', 'paused'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + // phases — JS PROPERTY only (array of strings). Setting re-renders the ring. + get phases(): string[] { + return this._phases + } + set phases(v: string[]) { + this._phases = Array.isArray(v) ? v.map(String) : [] + if (this._initialised) this.render() + } + + get currentIndex(): number { + return parseInt(this.getAttribute('current-index') ?? '0', 10) || 0 + } + set currentIndex(v: number) { + this.setAttribute('current-index', String(v)) + } + + get centerLabel(): string | null { + return this.getAttribute('center-label') + } + set centerLabel(v: string | null) { + if (v != null) this.setAttribute('center-label', v) + else this.removeAttribute('center-label') + } + + get centerValue(): string | null { + return this.getAttribute('center-value') + } + set centerValue(v: string | null) { + if (v != null) this.setAttribute('center-value', v) + else this.removeAttribute('center-value') + } + + get centerPill(): string | null { + return this.getAttribute('center-pill') + } + set centerPill(v: string | null) { + if (v != null) this.setAttribute('center-pill', v) + else this.removeAttribute('center-pill') + } + + get centerSub(): string | null { + return this.getAttribute('center-sub') + } + set centerSub(v: string | null) { + if (v != null) this.setAttribute('center-sub', v) + else this.removeAttribute('center-sub') + } + + get spinSeconds(): number { + const v = parseFloat(this.getAttribute('spin-seconds') ?? '') + return Number.isFinite(v) && v > 0 ? v : DEFAULT_SPIN + } + set spinSeconds(v: number) { + this.setAttribute('spin-seconds', String(v)) + } + + get paused(): boolean { + return this.hasAttribute('paused') + } + set paused(v: boolean) { + if (v) this.setAttribute('paused', '') + else this.removeAttribute('paused') + } + + private render(): void { + const phases = this._phases + const count = Math.max(phases.length, 1) + const slotAngle = 360 / count + const active = phases.length ? ((this.currentIndex % phases.length) + phases.length) % phases.length : -1 + const arrowRot = active >= 0 ? active * slotAngle : 0 + const spin = this.spinSeconds + + // Host-owned state classes — managed surgically so author classes survive. + this.classList.add('tc-cycle-wheel') + this.classList.toggle('tc-cycle-wheel--paused', this.paused) + + // Accessible summary (the SVGs are decorative / aria-hidden). + const activePhase = active >= 0 ? phases[active] : '' + const value = this.centerValue ?? '' + const label = this.centerLabel ?? '' + const sub = this.centerSub ?? '' + const ariaParts = [ + label, + activePhase ? `${activePhase} phase` : '', + value ? `value ${value}` : '', + sub, + ].filter(Boolean) + this.setAttribute('role', 'img') + this.setAttribute('aria-label', ariaParts.join(', ')) + + // Ring labels — each phase placed at its slot via a per-label rotation. + // The whole ring spins; the active label is the only accented element. + const labelsHtml = phases.map((p, i) => { + const rot = (i * slotAngle).toFixed(3) + const cls = i === active + ? 'tc-cycle-wheel__label tc-cycle-wheel__label--active' + : 'tc-cycle-wheel__label' + return `${esc(p.toUpperCase())}` + }).join('') + + // Static pointer (does not spin): per-slot ticks + an arrow oriented to + // the active phase, so the wheel reads as "oriented" even at rest. + const ticksHtml = phases.map((_, i) => + `` + ).join('') + + const labelEl = label ? `
    ${esc(label)}
    ` : '' + const valueEl = `
    ${esc(value)}
    ` + const pillEl = this.centerPill ? `
    ${esc(this.centerPill)}
    ` : '' + const subEl = sub ? `
    ${esc(sub)}
    ` : '' + + this.innerHTML = `
    + + +
    ${labelEl}${valueEl}${pillEl}${subEl}
    +
    ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: CycleWheel + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 831bfa24..37acd359 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -160,6 +160,7 @@ export * from './CompatibilityMatrix' export * from './ContextMenu' export * from './CoolNav' export * from './CountdownTimer' +export * from './CycleWheel' export * from './DangerZoneActions' export * from './MetricCard' export * from './SlicesCard' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index cdfd604c..ed7b8962 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -156,6 +156,7 @@ import { CompatibilityMatrix } from './CompatibilityMatrix' import { ContextMenu } from './ContextMenu' import { CoolNav } from './CoolNav' import { CountdownTimer } from './CountdownTimer' +import { CycleWheel } from './CycleWheel' import { DangerZoneActions } from './DangerZoneActions' import { MetricCard } from './MetricCard' import { SlicesCard } from './SlicesCard' @@ -378,6 +379,7 @@ export function register(): void { customElements.define('tc-context-menu', ContextMenu) customElements.define('tc-cool-nav', CoolNav) customElements.define('tc-countdown-timer', CountdownTimer) + customElements.define('tc-cycle-wheel', CycleWheel) customElements.define('tc-danger-zone-actions', DangerZoneActions) customElements.define('tc-metric-card', MetricCard) customElements.define('tc-slices-card', SlicesCard) diff --git a/web-components/style/components/_cycle-wheel.scss b/web-components/style/components/_cycle-wheel.scss new file mode 100644 index 00000000..c1c527d5 --- /dev/null +++ b/web-components/style/components/_cycle-wheel.scss @@ -0,0 +1,173 @@ +// tc-cycle-wheel — animated circular wheel with a rotating ring of phase labels +// around a fixed centre stack. +// SANCTIONED CIRCLES: the ring tracks, the centre core, and the pointer dot are +// genuinely circular shapes — an explicit exception to the sharp-corner rule. +// SANCTIONED PILL: the centre pill is the one rounded-pill in this component. +// The continuous spin is the one allowed decorative loop — it MUST stop under +// prefers-reduced-motion (the static, oriented ring is shown instead). +// All cosmetics flow through --bs-cycle-wheel-* custom properties backed by --tc-* tokens. + +@use '../foundation/tokens' as *; + +tc-cycle-wheel { + --bs-cycle-wheel-accent: var(--tc-accent); + --bs-cycle-wheel-value: var(--tc-text); + --bs-cycle-wheel-bg: var(--tc-surface); + --bs-cycle-wheel-muted: var(--tc-text-muted); + --bs-cycle-wheel-border: var(--tc-border); + --bs-cycle-wheel-track: var(--tc-border); + --bs-cycle-wheel-tick: var(--tc-text-faint); + --bs-cycle-wheel-pill-bg: var(--tc-success-bg); + --bs-cycle-wheel-pill-color: var(--tc-success); + --bs-cycle-wheel-label-font-size: 11px; + --bs-cycle-wheel-label-letter-spacing: 0.12em; + --bs-cycle-wheel-value-font-size: 3rem; + --bs-cycle-wheel-max-size: 480px; + --bs-cycle-wheel-spin-duration: 20s; + + display: grid; + place-items: center; + width: 100%; +} + +.tc-cycle-wheel { + &__inner { + position: relative; + aspect-ratio: 1 / 1; + width: 100%; + max-width: var(--bs-cycle-wheel-max-size); + } + + &__ring, + &__pointer { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + } + + // The rotating ring — the one sanctioned decorative loop. + &__ring { + animation: tc-cycle-wheel-spin linear infinite; + animation-duration: var(--bs-cycle-wheel-spin-duration); + will-change: transform; + transform-origin: 50% 50%; + } + + &__track { + stroke: var(--bs-cycle-wheel-track); + stroke-width: 1; // 1px hairline track + } + + &__track--outer { + opacity: 0.5; + } + + &__track--inner { + opacity: 0.2; + } + + // Phase labels — mono micro-labels (uppercase, letter-spaced), muted slate. + &__label { + font-family: var(--bs-font-monospace, monospace); + font-size: var(--bs-cycle-wheel-label-font-size); + font-weight: 500; + letter-spacing: var(--bs-cycle-wheel-label-letter-spacing); + fill: var(--bs-cycle-wheel-muted); + } + + // The active phase is the only place the ink/cyan accent appears. + &__label--active { + fill: var(--bs-cycle-wheel-accent); + font-weight: 700; + } + + // Static pointer: per-slot ticks (hairline) + accent arrow toward the active phase. + &__ticks line { + stroke: var(--bs-cycle-wheel-tick); + stroke-width: 1; + } + + &__arrow { + line { + stroke: var(--bs-cycle-wheel-accent); + stroke-width: 3; + } + + circle { + fill: var(--bs-cycle-wheel-accent); + } + } + + // Centre stack — a sanctioned circle housing the prominent value. + &__core { + position: absolute; + inset: 22%; + border: 1px solid var(--bs-cycle-wheel-border); + border-radius: 50%; + background: var(--bs-cycle-wheel-bg); + display: grid; + place-items: center; + text-align: center; + padding: 1rem; + } + + &__core-label { + font-family: var(--bs-font-sans-serif, sans-serif); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--bs-cycle-wheel-muted); + } + + &__core-value { + font-family: var(--bs-font-monospace, monospace); + font-size: var(--bs-cycle-wheel-value-font-size); + font-weight: 700; + color: var(--bs-cycle-wheel-value); + margin: 0.75rem 0 0.5rem; + line-height: 1; + letter-spacing: -0.02em; + } + + // The one sanctioned rounded-pill in this component. + &__core-pill { + font-family: var(--bs-font-sans-serif, sans-serif); + font-size: 0.7rem; + font-weight: 600; + color: var(--bs-cycle-wheel-pill-color); + background: var(--bs-cycle-wheel-pill-bg); + padding: 0.25rem 0.75rem; + display: inline-block; + border-radius: 50rem; // rounded-pill + letter-spacing: 0.08em; + text-transform: uppercase; + } + + &__core-sub { + font-family: var(--bs-font-sans-serif, sans-serif); + font-size: 0.875rem; + color: var(--bs-cycle-wheel-muted); + margin-top: 0.75rem; + } +} + +// paused stops the spin via animation-play-state. +.tc-cycle-wheel--paused .tc-cycle-wheel__ring { + animation-play-state: paused; +} + +@keyframes tc-cycle-wheel-spin { + from { transform: rotate(0); } + to { transform: rotate(360deg); } +} + +// The decorative loop must stop entirely under reduced motion. The ring rests at +// rotation 0 (labels at their home slots) and the static pointer keeps the wheel +// oriented to the active phase. +@media (prefers-reduced-motion: reduce) { + .tc-cycle-wheel__ring { + animation: none; + } +} diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 9b2348e2..b4bbb0c9 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -157,6 +157,7 @@ @forward 'context-menu'; @forward 'cool-nav'; @forward 'countdown-timer'; +@forward 'cycle-wheel'; @forward 'danger-zone-actions'; @forward 'metric-card'; @forward 'slices-card'; diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 85d4079f..99f236c5 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -224,6 +224,7 @@ tc-compatibility-matrix, tc-context-menu, tc-cool-nav, tc-countdown-timer, +tc-cycle-wheel, tc-danger-zone-actions, tc-metric-card, tc-slices-card, From 29382e5b59e2905dcf2db0a6079ce04674e695a5 Mon Sep 17 00:00:00 2001 From: kalevski Date: Mon, 15 Jun 2026 19:22:26 +0000 Subject: [PATCH 255/632] 253-form-input: Build the tc-form-input web component (FormInput parity) --- examples/public/web-components/SKILL.md | 59 ++ examples/src/web-components/FormInputDemo.tsx | 155 +++++ examples/src/web-components/index.tsx | 2 + web-components/src/FormInput.ts | 593 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + .../style/components/_form-input.scss | 125 ++++ web-components/style/components/_index.scss | 1 + web-components/style/foundation/_reset.scss | 1 + 9 files changed, 939 insertions(+) create mode 100644 examples/src/web-components/FormInputDemo.tsx create mode 100644 web-components/src/FormInput.ts create mode 100644 web-components/style/components/_form-input.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index c6e1782d..28d0c7d0 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -209,6 +209,7 @@ After `register()` you can author markup directly: - [tc-icon-picker](#tc-icon-picker) - [tc-floating-label](#tc-floating-label) - [tc-form](#tc-form) + - [tc-form-input](#tc-form-input) - [tc-helper-text](#tc-helper-text) - [tc-input](#tc-input) - [tc-input-group](#tc-input-group) @@ -5668,6 +5669,64 @@ Form wrapper with HTML5 constraint validation. --- +### tc-form-input + +Universal form-input dispatcher (port of react-components `FormInput`). The `type` attribute selects which native control to render, with built-in validation, a helper line, a danger-toned error line, and full ARIA wiring. Composes the shared form classes (`.form-control`, `.form-select`, `.form-check`, `.form-range`) so it stays self-contained. On every input/change it reads the control value, coerces it to the right JS type (boolean for checkbox/switch/single-radio, number for number/range, string otherwise), runs each `validate` function, computes the error message (preferring the `error` attribute, then a validator message, then `onErrorMessage`), toggles `is-invalid` + `aria-invalid` + `aria-describedby`, then fires `tc-change` and calls `onChange(value, hasError)`. + +**Tag:** `tc-form-input` + +**`type` values (18):** `text`, `email`, `password`, `number`, `tel`, `url`, `search`, `textarea`, `dropdown` (alias `select`), `checkbox`, `radio`, `switch`, `date`, `time`, `datetime`, `color`, `range`, `file`. `datetime` renders a native `datetime-local` input; `radio` renders a single boolean radio unless `options` are supplied, in which case it renders a radiogroup. + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `type` | string | `text` | Which control to render (see list above) | +| `label` | string | — | Field label; gets a required asterisk when `required` | +| `help` / `helper` | string | — | Helper/hint text shown under the control | +| `error` | string | — | Externally-forced error message (overrides validators) | +| `name` | string | — | `name` applied to the inner control(s) | +| `id` | string | — | Standard HTML `id` on the host element | +| `placeholder` | string | — | Placeholder text (text controls; first option for dropdown) | +| `disabled` | boolean | false | Disables the control and dims the field | +| `required` | boolean | false | Participates in validation; sets `aria-required` | +| `loading` | boolean | false | Renders a spinner placeholder and disables interaction | +| `min` / `max` / `step` | string | — | Forwarded to number/range/date controls | +| `rows` | string | — | Forwarded to the `textarea` control | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `value` | `unknown` | Current value (string/number/boolean by type). Setting re-seeds the control | +| `defaultValue` | `unknown` | Seeds the control on first render when `value` is unset | +| `validate` | `FormInputValidator \| FormInputValidator[]` | Each `(value) => true \| string \| { valid: boolean; message?: string }` — `true` is valid, a string is an error message | +| `onErrorMessage` | `(result) => string` | Derives the display message from a validation result | +| `onChange` | `((value: unknown, hasError: boolean) => void) \| null` | Optional callback fired alongside `tc-change` | +| `options` | `{ value: string; label: string; disabled?: boolean }[]` | Options for `dropdown`/`radio` (also accepts slotted `
    ` + } + + private _renderRow(prop: SchemaProperty, idx: number, dups: Set, disabledAttr: string): string { + const propLabel = prop.key || `property ${idx + 1}` + const errId = `${this._idPrefix}-err-${idx}` + const message = this._keyErrorMessage(prop.key, dups) + const invalid = message != null + const invalidClass = invalid ? ' is-invalid' : '' + const ariaInvalid = invalid ? ' aria-invalid="true"' : '' + const describedby = invalid ? ` aria-describedby="${errId}"` : '' + + const keyInput = `` + + const typeOptions = PROPERTY_TYPES.map(t => + ``, + ).join('') + const typeSelect = `` + + let refSelect = '' + if (needsRef(prop.type)) { + const refs = this._refsFor(prop.type) + const refLabel = prop.type === 'array' + ? `Item type for ${propLabel}` + : prop.type === 'object' + ? `Object reference for ${propLabel}` + : `Reference for ${propLabel}` + const options = refs.length === 0 + ? `` + : refs.map(r => { + const ov = r.value + const ol = r.label ?? r.value + return `` + }).join('') + refSelect = `` + } + + const requiredId = `${this._idPrefix}-req-${idx}` + const requiredToggle = `` + + const actions = `
    ` + + `` + + `` + + `` + + `
    ` + + const errEl = `` + + return `
    ` + + `
    ` + + keyInput + + typeSelect + + refSelect + + requiredToggle + + actions + + `
    ` + + errEl + + `
    ` + } + + private _restoreFocus(focus: FocusTarget): void { + const row = this.querySelector(`.tc-json-schema-def-row[data-idx="${focus.idx}"]`) + if (!row) return + let el: HTMLElement | null = null + if (focus.field === 'key') el = row.querySelector('.tc-json-schema-def-key') + else if (focus.field === 'type') el = row.querySelector('.tc-json-schema-def-type') + else el = row.querySelector(`[data-action="${focus.field}"]`) + if (el && !(el as HTMLButtonElement).disabled) { + el.focus() + if (focus.select && el instanceof HTMLInputElement) el.select() + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: JSONSchemaDef + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 8b17ed93..698496d4 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -191,6 +191,7 @@ export * from './Hero' export * from './Image' export * from './ImageCrop' export * from './JSONEditor' +export * from './JSONSchemaDef' export * from './InfiniteScroll' export * from './InstallTabs' export * from './LiveFeed' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 5fe6d850..4a8e39aa 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -187,6 +187,7 @@ import { Hero } from './Hero' import { Image as TcImage } from './Image' import { ImageCrop } from './ImageCrop' import { JSONEditor } from './JSONEditor' +import { JSONSchemaDef } from './JSONSchemaDef' import { InfiniteScroll } from './InfiniteScroll' import { InstallTabs } from './InstallTabs' import { Leaderboard } from './Leaderboard' @@ -413,6 +414,7 @@ export function register(): void { customElements.define('tc-image', TcImage) customElements.define('tc-image-crop', ImageCrop) customElements.define('tc-json-editor', JSONEditor) + customElements.define('tc-json-schema-def', JSONSchemaDef) customElements.define('tc-infinite-scroll', InfiniteScroll) customElements.define('tc-install-tabs', InstallTabs) customElements.define('tc-leaderboard', Leaderboard) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index ebd01380..73bdf9eb 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -192,6 +192,7 @@ @forward 'image'; @forward 'image-crop'; @forward 'json-editor'; +@forward 'json-schema-def'; @forward 'infinite-scroll'; @forward 'live-feed'; @forward 'login'; diff --git a/web-components/style/components/_json-schema-def.scss b/web-components/style/components/_json-schema-def.scss new file mode 100644 index 00000000..ff46dadb --- /dev/null +++ b/web-components/style/components/_json-schema-def.scss @@ -0,0 +1,311 @@ +// tc-json-schema-def — visual editor for defining JSON schema properties with +// type selection, reference selectors, reordering, and duplicate-name +// validation. Sharp corners throughout; slate neutrals carry the design. Status +// colour is reserved for the duplicate-name error only. All cosmetic values flow +// through --bs-json-schema-def-* backed by --tc-* tokens. + +@use '../foundation/tokens' as *; + +tc-json-schema-def { + --bs-json-schema-def-bg: var(--tc-surface); + --bs-json-schema-def-border-color: var(--tc-border); + --bs-json-schema-def-row-border: var(--tc-slate-100, #f1f5f9); + --bs-json-schema-def-row-padding-y: 0.5rem; + --bs-json-schema-def-row-padding-x: 0.75rem; + --bs-json-schema-def-row-hover-bg: var(--tc-surface-hover); + --bs-json-schema-def-header-bg: var(--tc-surface-muted); + --bs-json-schema-def-label-color: var(--tc-text); + --bs-json-schema-def-name-label-color: var(--tc-text-muted); + --bs-json-schema-def-name-label-size: 0.6875rem; + --bs-json-schema-def-key-color: var(--tc-text); + --bs-json-schema-def-key-size: 13px; + --bs-json-schema-def-required-color: var(--tc-text-muted); + --bs-json-schema-def-icon-color: var(--tc-text-muted); + --bs-json-schema-def-icon-hover-bg: var(--tc-surface-muted); + --bs-json-schema-def-icon-hover-color: var(--tc-text); + --bs-json-schema-def-icon-size: 0.9rem; + --bs-json-schema-def-muted-color: var(--tc-text-muted); + --bs-json-schema-def-error-color: var(--tc-danger); + --bs-json-schema-def-mono: var(--bs-font-monospace, ui-monospace, monospace); +} + +.tc-json-schema-def { + background: var(--bs-json-schema-def-bg); + border: 1px solid var(--bs-json-schema-def-border-color); + border-radius: 0; +} + +// ── Disabled ───────────────────────────────────────────────────────────────── + +tc-json-schema-def[disabled] .tc-json-schema-def, +.tc-json-schema-def--disabled { + opacity: 0.6; + pointer-events: none; +} + +// ── Header (schema name) ───────────────────────────────────────────────────── + +.tc-json-schema-def-header { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: var(--bs-json-schema-def-row-padding-y) var(--bs-json-schema-def-row-padding-x); + background: var(--bs-json-schema-def-header-bg); + border-bottom: 1px solid var(--bs-json-schema-def-border-color); +} + +.tc-json-schema-def-name-label { + margin: 0; + font-size: var(--bs-json-schema-def-name-label-size); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bs-json-schema-def-name-label-color); +} + +.tc-json-schema-def-label { + width: 100%; + font-size: 0.9375rem; + font-weight: 500; + color: var(--bs-json-schema-def-label-color); +} + +// ── Property rows ──────────────────────────────────────────────────────────── + +.tc-json-schema-def-row { + border-bottom: 1px solid var(--bs-json-schema-def-row-border); + transition: background var(--tc-transition-fast, 0.15s ease); + + &:last-child { + border-bottom: 0; + } + + &:hover { + background: var(--bs-json-schema-def-row-hover-bg); + } +} + +.tc-json-schema-def-row-main { + display: flex; + align-items: center; + gap: 0.5rem; + padding: var(--bs-json-schema-def-row-padding-y) var(--bs-json-schema-def-row-padding-x); +} + +// Property-name keys are machine-facing — render mono. +.tc-json-schema-def-key { + flex: 1 1 30%; + min-width: 0; + font-family: var(--bs-json-schema-def-mono); + font-size: var(--bs-json-schema-def-key-size); + color: var(--bs-json-schema-def-key-color); +} + +.tc-json-schema-def-type { + flex: 0 0 auto; + width: auto; + min-width: 7rem; +} + +.tc-json-schema-def-ref { + flex: 1 1 auto; + min-width: 0; +} + +.tc-json-schema-def-required { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin: 0; + font-size: 0.75rem; + color: var(--bs-json-schema-def-required-color); + white-space: nowrap; + cursor: pointer; + + .form-check-input { + margin: 0; + float: none; + } +} + +// ── Row action buttons — ghost icon, hover-well ladder ─────────────────────── + +.tc-json-schema-def-row-actions { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 0.125rem; +} + +.tc-json-schema-def-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + color: var(--bs-json-schema-def-icon-color); + cursor: pointer; + transition: background var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease); + + svg { + width: var(--bs-json-schema-def-icon-size); + height: var(--bs-json-schema-def-icon-size); + pointer-events: none; + } + + &:hover { + background: var(--bs-json-schema-def-icon-hover-bg); + color: var(--bs-json-schema-def-icon-hover-color); + } + + &:active { + background: var(--tc-border); + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: -2px; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +// ── Duplicate / blank key validation — the only status colour ──────────────── + +.tc-json-schema-def-key-error { + padding: 0 var(--bs-json-schema-def-row-padding-x) var(--bs-json-schema-def-row-padding-y); + font-size: 0.75rem; + color: var(--bs-json-schema-def-error-color); +} + +// ── Add button ─────────────────────────────────────────────────────────────── + +.tc-json-schema-def-add { + display: inline-flex; + align-items: center; + gap: 0.375rem; + margin: 0.5rem; + padding: 0.35rem 0.6rem; + background: transparent; + border: 1px solid var(--bs-json-schema-def-border-color); + border-radius: 0; + color: var(--bs-json-schema-def-key-color); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: background var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease); + + svg { + width: var(--bs-json-schema-def-icon-size); + height: var(--bs-json-schema-def-icon-size); + pointer-events: none; + } + + &:hover { + background: var(--bs-json-schema-def-icon-hover-bg); + color: var(--bs-json-schema-def-icon-hover-color); + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +// ── Empty state ────────────────────────────────────────────────────────────── + +.tc-json-schema-def-empty { + padding: var(--bs-json-schema-def-row-padding-y) var(--bs-json-schema-def-row-padding-x); + font-family: var(--bs-json-schema-def-mono); + font-size: 0.75rem; + color: var(--bs-json-schema-def-muted-color); +} + +// ── Loading skeleton ───────────────────────────────────────────────────────── + +.tc-json-schema-def-skeleton-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: var(--bs-json-schema-def-row-padding-y) var(--bs-json-schema-def-row-padding-x); + border-bottom: 1px solid var(--bs-json-schema-def-row-border); + + &:last-child { + border-bottom: 0; + } +} + +.tc-json-schema-def-skeleton-key, +.tc-json-schema-def-skeleton-field, +.tc-json-schema-def-skeleton-actions { + height: 1.9rem; + border-radius: 0; + background: linear-gradient( + 90deg, + var(--tc-surface-muted) 25%, + var(--tc-surface-hover) 37%, + var(--tc-surface-muted) 63% + ); + background-size: 200% 100%; + animation: tc-json-schema-def-shimmer 1.4s ease infinite; +} + +.tc-json-schema-def-skeleton-key { + flex: 1 1 30%; +} + +.tc-json-schema-def-skeleton-field { + flex: 1 1 auto; +} + +.tc-json-schema-def-skeleton-actions { + flex: 0 0 5rem; +} + +@keyframes tc-json-schema-def-shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +// ── Touch targets — WCAG 2.5.5 ─────────────────────────────────────────────── + +@media (pointer: coarse) { + .tc-json-schema-def-icon-btn { + min-width: 44px; + min-height: 44px; + } +} + +// ── Reduced motion ─────────────────────────────────────────────────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-json-schema-def-row { + transition: none; + } + + .tc-json-schema-def-skeleton-key, + .tc-json-schema-def-skeleton-field, + .tc-json-schema-def-skeleton-actions { + animation: none; + background: var(--tc-surface-muted); + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 2d00558a..6976d2d7 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -252,6 +252,7 @@ tc-hero, tc-image, tc-image-crop, tc-json-editor, +tc-json-schema-def, tc-infinite-scroll, tc-install-tabs, tc-leaderboard, From 5a6a3142c66dbfcc86137eeb78b98634b968be7b Mon Sep 17 00:00:00 2001 From: kalevski Date: Mon, 15 Jun 2026 20:05:43 +0000 Subject: [PATCH 259/632] 257-lightbox: Build the tc-lightbox web component (Lightbox parity) --- examples/public/web-components/SKILL.md | 51 +++ examples/src/web-components/LightboxDemo.tsx | 89 ++++ examples/src/web-components/index.tsx | 2 + web-components/src/Lightbox.ts | 417 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_lightbox.scss | 261 +++++++++++ web-components/style/foundation/_reset.scss | 4 + 9 files changed, 828 insertions(+) create mode 100644 examples/src/web-components/LightboxDemo.tsx create mode 100644 web-components/src/Lightbox.ts create mode 100644 web-components/style/components/_lightbox.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index c1715a1b..5a245f13 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -195,6 +195,7 @@ After `register()` you can author markup directly: - [tc-command-palette](#tc-command-palette) - [tc-context-menu](#tc-context-menu) - [tc-drawer](#tc-drawer) + - [tc-lightbox](#tc-lightbox) - [tc-modal](#tc-modal) - [tc-offcanvas](#tc-offcanvas) - [tc-popover](#tc-popover) @@ -5023,6 +5024,56 @@ Slide-out panel with focus trap, keyboard handling, and optional pinned mode. Co --- +### tc-lightbox + +Modal image gallery with keyboard/swipe navigation, a thumbnail strip, captions, and focus management. Controlled component — fires `tc-close` when the user requests dismissal; the consumer sets `open` to `false` to actually close. Renders nothing when closed. + +**Tag:** `tc-lightbox` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `open` | boolean | false | Visible state — add/remove to show/hide the overlay | +| `initial-index` | number | `0` | Zero-based slide shown when first opened | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `images` | `LightboxImage[]` | Gallery data — must be set as a property (`el.images = [...]`), not an attribute. Setting it re-renders while open. | +| `onclose` | `(() => void) \| null` | Callback fired when close is requested (alongside the `tc-close` event) | + +`LightboxImage` shape: `{ src: string; alt?: string; caption?: string; thumb?: string }` — `thumb` is the optional thumbnail-strip source (falls back to `src`). + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-close` | `{}` | Fired when the user dismisses via the close button, Escape, or a backdrop click. The host does **not** self-close — set `open=false` in this handler. | +| `tc-change` | `{ index: number }` | Fired whenever the active slide changes (arrows, thumbnails, keyboard, or swipe) | + +**Keyboard:** `ArrowLeft`/`ArrowRight` navigate (wrap-around), `Home`/`End` jump to first/last, `Escape` closes, `Tab` cycles within the dialog (focus trapped). + +**Slots:** none + +```html + + + +``` + +--- + ### tc-popover Popover positioned by Popper.js. diff --git a/examples/src/web-components/LightboxDemo.tsx b/examples/src/web-components/LightboxDemo.tsx new file mode 100644 index 00000000..d8c37ea1 --- /dev/null +++ b/examples/src/web-components/LightboxDemo.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +type GalleryImage = { + src: string + alt?: string + caption?: string + thumb?: string +} + +const GALLERY: GalleryImage[] = [ + { src: 'https://picsum.photos/seed/a1/1200/800', alt: 'Mountains', caption: 'Majestic mountain range at sunrise', thumb: 'https://picsum.photos/seed/a1/200/150' }, + { src: 'https://picsum.photos/seed/b2/1200/800', alt: 'Forest', caption: 'Ancient forest in autumn', thumb: 'https://picsum.photos/seed/b2/200/150' }, + { src: 'https://picsum.photos/seed/c3/1200/800', alt: 'Ocean', caption: 'Waves crashing at the shore', thumb: 'https://picsum.photos/seed/c3/200/150' }, + { src: 'https://picsum.photos/seed/d4/1200/800', alt: 'Desert', caption: 'Sand dunes at golden hour', thumb: 'https://picsum.photos/seed/d4/200/150' }, + { src: 'https://picsum.photos/seed/e5/1200/800', alt: 'City', caption: 'City lights reflected in rain', thumb: 'https://picsum.photos/seed/e5/200/150' }, +] + +const LightboxDemo: React.FC = () => { + const lightboxRef = useRef(null) + const [open, setOpen] = useState(false) + const [initial, setInitial] = useState(0) + + // Feed the array via the JS `images` property (arrays can't be set as attributes). + useEffect(() => { + const el = lightboxRef.current as any + if (el) el.images = GALLERY + }, []) + + // Controlled — the host fires tc-close; the consumer flips `open` to false. + useEffect(() => { + const el = lightboxRef.current + if (!el) return + const onClose = () => setOpen(false) + el.addEventListener('tc-close', onClose) + return () => el.removeEventListener('tc-close', onClose) + }, []) + + const openAt = (i: number) => { + setInitial(i) + setOpen(true) + } + + return ( +
    +
    +
    +
    + Web Components} + title="Lightbox" + description="Full-screen image gallery with keyboard/swipe navigation, thumbnails, captions, and focus management. Controlled component — fires tc-close when dismissed; set open to false to close." + /> + +
    + +
    + {GALLERY.map((img, i) => ( + + ))} +
    +
    + + {/* @ts-ignore */} + { lightboxRef.current = el }} + initial-index={initial} + open={open || undefined} + /> +
    +
    +
    +
    +
    + ) +} + +export default LightboxDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 1f211eb0..4c2d1859 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -160,6 +160,7 @@ import SlicesCardDemo from './SlicesCardDemo' import DatePickerDemo from './DatePickerDemo' import DiffViewerDemo from './DiffViewerDemo' import DrawerDemo from './DrawerDemo' +import LightboxDemo from './LightboxDemo' import CommandPaletteDemo from './CommandPaletteDemo' import EarlySignupFormDemo from './EarlySignupFormDemo' import EcosystemMapDemo from './EcosystemMapDemo' @@ -390,6 +391,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'compatibility-matrix', category: 'Components', element: }, { key: 'context-menu', category: 'Overlays & Feedback', element: }, { key: 'drawer', category: 'Overlays & Feedback', element: }, + { key: 'lightbox', category: 'Overlays & Feedback', element: }, { key: 'command-palette', category: 'Overlays & Feedback', element: }, { key: 'cool-nav', category: 'Navigation', element: }, { key: 'countdown-timer', category: 'Components', element: }, diff --git a/web-components/src/Lightbox.ts b/web-components/src/Lightbox.ts new file mode 100644 index 00000000..b34c4cdb --- /dev/null +++ b/web-components/src/Lightbox.ts @@ -0,0 +1,417 @@ +// tc-lightbox — modal image gallery (overlay tier). Fully custom (no Bootstrap +// plugin). Port of @toolcase/react-components Lightbox. All cosmetics flow +// through --bs-lightbox-* vars backed by --tc-* tokens. + +import { closeIcon, chevronLeftIcon, chevronRightIcon } from './icons' + +const TAG_NAME = 'tc-lightbox' + +let _idCounter = 0 + +export interface LightboxImage { + src: string + alt?: string + caption?: string + thumb?: string +} + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function getFocusable(root: Element): HTMLElement[] { + return Array.from( + root.querySelectorAll( + 'a[href],area[href],button:not([disabled]),details>summary,[tabindex]:not([tabindex="-1"]),input:not([disabled]),select:not([disabled]),textarea:not([disabled])', + ), + ).filter(el => !el.closest('[hidden]') && el.tabIndex >= 0) +} + +const SWIPE_THRESHOLD = 40 + +export class Lightbox extends HTMLElement { + private _initialised = false + private _images: LightboxImage[] = [] + private _index = 0 + private _previousFocus: Element | null = null + private _idPrefix: string + + // Pointer-swipe state. + private _swipeStartX = 0 + private _dragging = false + + onclose: (() => void) | null = null + + constructor() { + super() + this._idPrefix = `tc-lightbox-${++_idCounter}` + } + + static get observedAttributes(): string[] { + return ['open', 'initial-index'] + } + + connectedCallback(): void { + this._initialised = true + // Detach before attach to prevent double-registration on reconnect. + this._detachHandlers() + this._attachHandlers() + if (this.open) this._applyOpenState(true) + else this.render() + } + + disconnectedCallback(): void { + this._detachHandlers() + this._restoreScroll() + } + + attributeChangedCallback(name: string): void { + if (!this.isConnected || !this._initialised) return + if (name === 'open') { + this._applyOpenState(this.open) + return + } + // initial-index only matters at the moment of opening; ignore otherwise. + } + + // ── Public API ───────────────────────────────────────────────────────────── + + get open(): boolean { + return this.hasAttribute('open') + } + set open(v: boolean) { + if (v) this.setAttribute('open', '') + else this.removeAttribute('open') + } + + get initialIndex(): number { + const n = parseInt(this.getAttribute('initial-index') ?? '0', 10) + return Number.isFinite(n) ? n : 0 + } + set initialIndex(v: number) { + this.setAttribute('initial-index', String(v)) + } + + get images(): LightboxImage[] { + return this._images + } + set images(v: LightboxImage[]) { + this._images = Array.isArray(v) ? v : [] + if (this._initialised && this.open) { + this._index = this._clampIndex(this._index) + this.render() + this._setOpenClass(true) + this._focusDialog() + } + } + + // ── Open / close lifecycle ─────────────────────────────────────────────────── + + private _applyOpenState(opening: boolean): void { + if (opening) { + this._previousFocus = document.activeElement + this._index = this._clampIndex(this.initialIndex) + this.render() + // Settle one frame before adding open class to trigger transition. + requestAnimationFrame(() => { + if (!this.open) return + this._setOpenClass(true) + this._lockScroll() + this._focusDialog() + }) + } else { + this._setOpenClass(false) + this._restoreScroll() + this._restoreFocus() + const delay = this._getTransitionDuration( + this.querySelector('.tc-lightbox__backdrop'), + ) + setTimeout(() => { + if (!this.open) this.innerHTML = '' + }, delay) + } + } + + private _getTransitionDuration(el: HTMLElement | null): number { + if (!el) return 0 + const raw = getComputedStyle(el).transitionDuration || '0s' + let max = 0 + for (const p of raw.split(',')) { + const sec = parseFloat(p.trim()) + if (!isNaN(sec)) max = Math.max(max, sec) + } + return max * 1000 + } + + private _setOpenClass(on: boolean): void { + if (on) this.classList.add('tc-lightbox--open') + else this.classList.remove('tc-lightbox--open') + } + + private _lockScroll(): void { + document.body.style.overflow = 'hidden' + } + + private _restoreScroll(): void { + document.body.style.overflow = '' + } + + private _restoreFocus(): void { + if (this._previousFocus instanceof HTMLElement) this._previousFocus.focus() + this._previousFocus = null + } + + private _focusDialog(): void { + const dialog = this.querySelector('.tc-lightbox__dialog') + dialog?.focus() + } + + // ── Navigation ─────────────────────────────────────────────────────────────── + + private _clampIndex(i: number): number { + const n = this._images.length + if (n === 0) return 0 + return ((i % n) + n) % n + } + + private _goTo(i: number): void { + const n = this._images.length + if (n === 0) return + const next = this._clampIndex(i) + if (next === this._index) return + this._index = next + this._patchActive() + this.dispatchEvent(new CustomEvent('tc-change', { + bubbles: true, + composed: true, + detail: { index: next }, + })) + } + + private _prev(): void { + this._goTo(this._index - 1) + } + + private _next(): void { + this._goTo(this._index + 1) + } + + // ── Event handlers ─────────────────────────────────────────────────────────── + + private _requestClose(): void { + this.dispatchEvent(new CustomEvent('tc-close', { + bubbles: true, + composed: true, + detail: {}, + })) + if (typeof this.onclose === 'function') this.onclose() + } + + private _onKeydown = (e: KeyboardEvent): void => { + if (!this.open) return + switch (e.key) { + case 'Escape': + e.preventDefault() + this._requestClose() + break + case 'ArrowLeft': + e.preventDefault() + this._prev() + break + case 'ArrowRight': + e.preventDefault() + this._next() + break + case 'Home': + e.preventDefault() + this._goTo(0) + break + case 'End': + e.preventDefault() + this._goTo(this._images.length - 1) + break + case 'Tab': { + const dialog = this.querySelector('.tc-lightbox__dialog') + if (!dialog) return + const focusable = getFocusable(dialog) + if (focusable.length === 0) { + e.preventDefault() + dialog.focus() + return + } + const first = focusable[0] + const last = focusable[focusable.length - 1] + const active = document.activeElement + if (e.shiftKey) { + if (active === first || active === dialog) { + e.preventDefault() + last.focus() + } + } else { + if (active === last) { + e.preventDefault() + first.focus() + } + } + break + } + } + } + + private _onClick = (e: MouseEvent): void => { + if (!this.open) return + const target = e.target as Element + if (target.closest('.tc-lightbox__close')) { + this._requestClose() + return + } + if (target.closest('.tc-lightbox__arrow--prev')) { + this._prev() + return + } + if (target.closest('.tc-lightbox__arrow--next')) { + this._next() + return + } + const thumb = target.closest('.tc-lightbox__thumbnail') + if (thumb) { + this._goTo(parseInt(thumb.dataset.idx ?? '0', 10)) + return + } + // Clicking the image itself does nothing; anywhere else (backdrop, dialog + // padding, stage gap) dismisses. + if (target.closest('.tc-lightbox__image')) return + this._requestClose() + } + + private _onPointerDown = (e: PointerEvent): void => { + if (!this.open) return + const target = e.target as Element + if (!target.closest('.tc-lightbox__stage')) return + if (target.closest('button')) return + this._dragging = true + this._swipeStartX = e.clientX + } + + private _onPointerUp = (e: PointerEvent): void => { + if (!this._dragging) return + this._dragging = false + const diff = e.clientX - this._swipeStartX + if (Math.abs(diff) > SWIPE_THRESHOLD) { + if (diff < 0) this._next() + else this._prev() + } + } + + private _attachHandlers(): void { + document.addEventListener('keydown', this._onKeydown) + this.addEventListener('click', this._onClick) + this.addEventListener('pointerdown', this._onPointerDown) + this.addEventListener('pointerup', this._onPointerUp) + } + + private _detachHandlers(): void { + document.removeEventListener('keydown', this._onKeydown) + this.removeEventListener('click', this._onClick) + this.removeEventListener('pointerdown', this._onPointerDown) + this.removeEventListener('pointerup', this._onPointerUp) + } + + // ── Rendering ──────────────────────────────────────────────────────────────── + + private _patchActive(): void { + const img = this._images[this._index] + if (!img) return + const count = this._images.length + + const imgEl = this.querySelector('.tc-lightbox__image') + if (imgEl) { + imgEl.src = img.src + imgEl.alt = img.alt ?? `Image ${this._index + 1} of ${count}` + } + + const caption = this.querySelector('.tc-lightbox__caption') + if (caption) { + if (img.caption) { + caption.textContent = img.caption + caption.hidden = false + } else { + caption.textContent = '' + caption.hidden = true + } + } + + const counter = this.querySelector('.tc-lightbox__counter') + if (counter) counter.textContent = `${this._index + 1} / ${count}` + + this.querySelectorAll('.tc-lightbox__thumbnail').forEach((btn, idx) => { + const active = idx === this._index + btn.classList.toggle('tc-lightbox__thumbnail--active', active) + if (active) btn.setAttribute('aria-current', 'true') + else btn.removeAttribute('aria-current') + }) + } + + private render(): void { + if (!this.open || this._images.length === 0) { + this.innerHTML = '' + return + } + + const count = this._images.length + const img = this._images[this._index] + const labelId = `${this._idPrefix}-label` + const multi = count > 1 + + const arrowsPrev = multi + ? `` + : '' + const arrowsNext = multi + ? `` + : '' + + const captionText = img.caption ? esc(img.caption) : '' + const captionHidden = img.caption ? '' : ' hidden' + + const thumbsHtml = multi + ? `
    ` + + this._images.map((image, idx) => { + const active = idx === this._index + const cls = `tc-lightbox__thumbnail${active ? ' tc-lightbox__thumbnail--active' : ''}` + const current = active ? ' aria-current="true"' : '' + const src = esc(image.thumb ?? image.src) + const label = esc(`View image ${idx + 1}${image.alt ? `: ${image.alt}` : ''}`) + return `` + }).join('') + + `
    ` + : '' + + this.innerHTML = + `` + + `` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Lightbox + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 698496d4..469a2ee0 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -170,6 +170,7 @@ export * from './DashboardLayout' export * from './DatePicker' export * from './DiffViewer' export * from './Drawer' +export * from './Lightbox' export * from './EarlySignupForm' export * from './EcosystemMap' export * from './EditableText' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 4a8e39aa..14d4a9f9 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -191,6 +191,7 @@ import { JSONSchemaDef } from './JSONSchemaDef' import { InfiniteScroll } from './InfiniteScroll' import { InstallTabs } from './InstallTabs' import { Leaderboard } from './Leaderboard' +import { Lightbox } from './Lightbox' import { LineChart } from './LineChart' import { PieChart } from './PieChart' import { LiveFeed } from './LiveFeed' @@ -418,6 +419,7 @@ export function register(): void { customElements.define('tc-infinite-scroll', InfiniteScroll) customElements.define('tc-install-tabs', InstallTabs) customElements.define('tc-leaderboard', Leaderboard) + customElements.define('tc-lightbox', Lightbox) customElements.define('tc-line-chart', LineChart) customElements.define('tc-pie-chart', PieChart) customElements.define('tc-live-feed', LiveFeed) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 73bdf9eb..e45216d3 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -170,6 +170,7 @@ @forward 'faq-list'; @forward 'install-tabs'; @forward 'drawer'; +@forward 'lightbox'; @forward 'file'; @forward 'early-signup-form'; @forward 'ecosystem-map'; diff --git a/web-components/style/components/_lightbox.scss b/web-components/style/components/_lightbox.scss new file mode 100644 index 00000000..80c8168a --- /dev/null +++ b/web-components/style/components/_lightbox.scss @@ -0,0 +1,261 @@ +// tc-lightbox — modal image gallery, overlay tier. Fully custom (no Bootstrap +// plugin). All cosmetics flow through --bs-lightbox-* vars backed by --tc-* tokens. +// Backdrop is a dark scrim; the stage sits on the --tc-ink overlay surface with +// white controls. Sharp corners throughout; the active thumbnail gets a genuine +// --tc-accent (cyan) highlight. + +tc-lightbox { + // Z-index band — backdrop below the dialog, both in the modal tier. + --bs-lightbox-z-backdrop: var(--tc-z-modal-backdrop); + --bs-lightbox-z-dialog: var(--tc-z-modal); + + // Backdrop scrim. + --bs-lightbox-backdrop-bg: #0f172a; + --bs-lightbox-backdrop-opacity: 0.82; + + // Overlay surface + chrome. + --bs-lightbox-stage-bg: var(--tc-ink); + --bs-lightbox-shadow: var(--tc-shadow-lg); + --bs-lightbox-control-color: #ffffff; + --bs-lightbox-control-wash: rgba(255, 255, 255, 0.14); + --bs-lightbox-caption-color: rgba(255, 255, 255, 0.7); + + // Thumbnails. + --bs-lightbox-thumb-size: 64px; + --bs-lightbox-thumb-border: rgba(255, 255, 255, 0.15); + --bs-lightbox-thumb-active-border: var(--tc-accent); + --bs-lightbox-thumb-opacity: 0.55; + + // Transition. + --bs-lightbox-transition: opacity var(--tc-transition-base); +} + +// ── Backdrop ────────────────────────────────────────────────────────────────── + +.tc-lightbox__backdrop { + position: fixed; + inset: 0; + z-index: var(--bs-lightbox-z-backdrop); + background: var(--bs-lightbox-backdrop-bg); + opacity: 0; + transition: var(--bs-lightbox-transition); + + tc-lightbox.tc-lightbox--open & { + opacity: var(--bs-lightbox-backdrop-opacity); + } +} + +// ── Dialog ──────────────────────────────────────────────────────────────────── + +.tc-lightbox__dialog { + position: fixed; + inset: 0; + z-index: var(--bs-lightbox-z-dialog); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: clamp(0.75rem, 3vw, 2.5rem); + outline: 0; + opacity: 0; + transition: var(--bs-lightbox-transition); + + tc-lightbox.tc-lightbox--open & { + opacity: 1; + } +} + +// ── Close button ────────────────────────────────────────────────────────────── + +.tc-lightbox__close { + position: absolute; + top: clamp(0.5rem, 2vw, 1rem); + right: clamp(0.5rem, 2vw, 1rem); + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + padding: 0; + border: 0; + background: none; + border-radius: 0; + color: var(--bs-lightbox-control-color); + cursor: pointer; + transition: background-color var(--tc-transition-fast); + + svg { + width: 1.5rem; + height: 1.5rem; + flex-shrink: 0; + } + + &:hover { + background: var(--bs-lightbox-control-wash); + } + + &:focus-visible { + outline: 2px solid var(--tc-accent); + outline-offset: 2px; + } + + @media (pointer: coarse) { + width: 2.75rem; + height: 2.75rem; + } +} + +// ── Counter ─────────────────────────────────────────────────────────────────── + +.tc-lightbox__counter { + flex-shrink: 0; + font-family: var(--tc-font-mono); + font-size: 0.8125rem; + letter-spacing: 0.04em; + color: var(--bs-lightbox-control-color); +} + +// ── Stage ───────────────────────────────────────────────────────────────────── + +.tc-lightbox__stage { + position: relative; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + flex: 1 1 auto; + min-height: 0; + max-width: 100%; +} + +.tc-lightbox__figure { + margin: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + min-width: 0; + min-height: 0; +} + +.tc-lightbox__image { + display: block; + max-width: min(88vw, 1200px); + max-height: 70vh; + width: auto; + height: auto; + object-fit: contain; + background: var(--bs-lightbox-stage-bg); + box-shadow: var(--bs-lightbox-shadow); + border-radius: 0; +} + +.tc-lightbox__caption { + margin: 0; + max-width: min(88vw, 1200px); + text-align: center; + font-size: 0.8125rem; + line-height: 1.5; + color: var(--bs-lightbox-caption-color); +} + +// ── Arrows ──────────────────────────────────────────────────────────────────── + +.tc-lightbox__arrow { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 2.75rem; + height: 2.75rem; + padding: 0; + border: 0; + background: none; + border-radius: 0; + color: var(--bs-lightbox-control-color); + cursor: pointer; + transition: background-color var(--tc-transition-fast); + + svg { + width: 1.75rem; + height: 1.75rem; + flex-shrink: 0; + } + + &:hover { + background: var(--bs-lightbox-control-wash); + } + + &:focus-visible { + outline: 2px solid var(--tc-accent); + outline-offset: 2px; + } + + @media (pointer: coarse) { + width: 3rem; + height: 3rem; + } +} + +// ── Thumbnails ──────────────────────────────────────────────────────────────── + +.tc-lightbox__thumbnails { + display: flex; + flex-wrap: nowrap; + flex-shrink: 0; + gap: 0.375rem; + max-width: 100%; + padding: 0.25rem; + overflow-x: auto; +} + +.tc-lightbox__thumbnail { + display: block; + flex-shrink: 0; + width: var(--bs-lightbox-thumb-size); + height: var(--bs-lightbox-thumb-size); + padding: 0; + border: 2px solid var(--bs-lightbox-thumb-border); + background: var(--bs-lightbox-stage-bg); + border-radius: 0; + cursor: pointer; + opacity: var(--bs-lightbox-thumb-opacity); + transition: opacity var(--tc-transition-fast), border-color var(--tc-transition-fast); + + img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + + &:hover { + opacity: 1; + } + + &:focus-visible { + outline: 2px solid var(--tc-accent); + outline-offset: 2px; + } + + &.tc-lightbox__thumbnail--active { + border-color: var(--bs-lightbox-thumb-active-border); + opacity: 1; + } + + // 44px coarse target — bump the floor without disturbing the desktop size. + @media (pointer: coarse) { + min-width: 44px; + min-height: 44px; + } +} + +// ── Reduced motion — no fade, instant show/hide ───────────────────────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-lightbox__backdrop, + .tc-lightbox__dialog { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 6976d2d7..29dcbd93 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -292,6 +292,10 @@ tc-drawer { display: block; } +tc-lightbox { + display: block; +} + tc-editable-text, tc-chip, tc-button, From b0a0758840f36acae3155b500f8e216267f3cbd5 Mon Sep 17 00:00:00 2001 From: kalevski Date: Mon, 15 Jun 2026 22:47:29 +0000 Subject: [PATCH 260/632] 258-ability-card: Build the tc-ability-card web component (AbilityCard game-components port) --- examples/public/web-components/SKILL.md | 106 +++++++++ .../src/web-components/AbilityCardDemo.tsx | 116 ++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/AbilityCard.ts | 176 ++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + .../style/components/_ability-card.scss | 216 ++++++++++++++++++ web-components/style/components/_index.scss | 1 + web-components/style/foundation/_reset.scss | 1 + 9 files changed, 621 insertions(+) create mode 100644 examples/src/web-components/AbilityCardDemo.tsx create mode 100644 web-components/src/AbilityCard.ts create mode 100644 web-components/style/components/_ability-card.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 5a245f13..fe01d42b 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -111,6 +111,7 @@ After `register()` you can author markup directly: - [tc-empty-state](#tc-empty-state) - [tc-entity-cell](#tc-entity-cell) - [tc-feature-card](#tc-feature-card) + - [tc-ability-card](#tc-ability-card) - [tc-good-first-issues](#tc-good-first-issues) - [tc-hero-stats-bar](#tc-hero-stats-bar) - [tc-leaderboard](#tc-leaderboard) @@ -8836,6 +8837,111 @@ None. `tc-feature-card` is purely presentational. --- +### tc-ability-card + +Ability portrait tile: an icon chip, a rarity micro-label, the ability name, an optional hotkey (keybind), a description, and a meta grid of cooldown / cost / range. Ported from the game-components `gc-ability-card` and restyled to the toolcase design system — flat slate surface, hairline borders, sharp corners, mono machine-text, and a single muted rarity accent (no gilded frames or glows). Purely attribute-driven and presentational. + +**Tag:** `tc-ability-card` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `ability-name` | string | — | The ability's display name (slate ink, weight 600). | +| `icon` | string | — | PascalCase Lucide icon name (e.g. `"Zap"`, `"Shield"`). Rendered as inline SVG inside the icon chip; unknown names render an empty (hidden) chip. | +| `description` | string | — | Body description text. Hidden when absent. | +| `cooldown` | string | — | Cooldown value (e.g. `"8s"`). Rendered as a meta row when present. | +| `cost` | string | — | Resource cost (e.g. `"40 mana"`). Rendered as a meta row when present. | +| `range` | string | — | Range value (e.g. `"600"`). Rendered as a meta row when present. | +| `keybind` | string | — | Hotkey label rendered as a mono `kbd` cap to the right of the name. Hidden when absent. | +| `rarity` | `'common' \| 'uncommon' \| 'rare' \| 'epic' \| 'legendary'` | `'common'` | Tier label rendered as a mono uppercase micro-label, tinted by a muted accent. Reflected onto the host as `data-rarity` for theming. Unknown values fall back to `'common'`. | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `abilityName` | `string` | Reflects the `ability-name` attribute. | +| `icon` | `string` | Reflects the `icon` attribute. | +| `description` | `string` | Reflects the `description` attribute. | +| `cooldown` | `string` | Reflects the `cooldown` attribute. | +| `cost` | `string` | Reflects the `cost` attribute. | +| `range` | `string` | Reflects the `range` attribute. | +| `keybind` | `string` | Reflects the `keybind` attribute. | +| `rarity` | `AbilityCardRarity` | Reflects the `rarity` attribute (validated; falls back to `'common'`). | + +**Events** + +None. `tc-ability-card` is purely presentational. + +**Named Slots** + +None. All content is attribute-driven. + +**Accessibility** + +- The icon chip carries `aria-hidden="true"` — it is decorative; the ability name is the readable label. +- The hotkey is rendered in a semantic `` element. +- `prefers-reduced-motion` retains the `box-shadow` hover transition but freezes the `translateY(-1px)` lift. + +**CSS Custom Properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-ability-card-bg` | `var(--tc-surface)` | Card background colour. | +| `--bs-ability-card-border-color` | `var(--tc-border)` | 1 px hairline border colour. | +| `--bs-ability-card-shadow` | `var(--tc-shadow-sm)` | Box shadow at rest. | +| `--bs-ability-card-shadow-hover` | `var(--tc-shadow-hover)` | Box shadow on hover. | +| `--bs-ability-card-padding-y` | `1rem` | Vertical padding. | +| `--bs-ability-card-padding-x` | `1rem` | Horizontal padding. | +| `--bs-ability-card-gap` | `0.75rem` | Gap between head, description, and meta regions. | +| `--bs-ability-card-head-gap` | `0.75rem` | Gap between icon, name block, and keybind. | +| `--bs-ability-card-icon-bg` | `var(--tc-surface-muted)` | Icon chip background. | +| `--bs-ability-card-icon-color` | `var(--tc-text)` | Icon glyph colour. | +| `--bs-ability-card-icon-size` | `1.375rem` | Icon SVG width/height inside the chip. | +| `--bs-ability-card-icon-tile-size` | `2.75rem` | Width and height of the icon chip square. | +| `--bs-ability-card-rarity-color` | `var(--tc-text-faint)` | Rarity micro-label colour (overridden per `data-rarity`). | +| `--bs-ability-card-name-color` | `var(--tc-text)` | Ability name colour. | +| `--bs-ability-card-name-weight` | `600` | Ability name font weight. | +| `--bs-ability-card-desc-color` | `var(--tc-text-muted)` | Description text colour. | +| `--bs-ability-card-keybind-bg` | `var(--tc-surface-muted)` | Hotkey cap background. | +| `--bs-ability-card-keybind-border` | `var(--tc-border-strong)` | Hotkey cap border colour. | +| `--bs-ability-card-keybind-color` | `var(--tc-text)` | Hotkey cap text colour. | +| `--bs-ability-card-meta-border` | `var(--tc-border)` | Top hairline above the meta grid. | +| `--bs-ability-card-meta-row-border` | `var(--tc-surface-muted)` | Hairline between meta rows. | +| `--bs-ability-card-meta-label-color` | `var(--tc-text-faint)` | Meta label (mono micro) colour. | +| `--bs-ability-card-meta-value-color` | `var(--tc-text)` | Meta value (mono) colour. | + +Per-rarity accent overrides (applied via `tc-ability-card[data-rarity='…']`): `uncommon` → `var(--tc-success)`, `rare` → `var(--tc-info)`, `epic` → `var(--tc-app-accent)`, `legendary` → `var(--tc-warning)`. + +```html + + + + + + + + +``` + +--- + ### tc-good-first-issues Bordered list-group of GitHub good-first-issue items. Each row links to the issue URL, shows the repo slug, a row of label chips (with optional color dot), and a meta line with comment count and relative update time. Purely presentational — no open/close logic. Set items via the `issues` JS property. diff --git a/examples/src/web-components/AbilityCardDemo.tsx b/examples/src/web-components/AbilityCardDemo.tsx new file mode 100644 index 00000000..82883101 --- /dev/null +++ b/examples/src/web-components/AbilityCardDemo.tsx @@ -0,0 +1,116 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const AbilityCardDemo: React.FC = () => { + return ( +
    +
    +
    +
    + Web Components} + title="AbilityCard" + description="An ability portrait tile: an icon chip, rarity micro-label, ability name, optional hotkey, description, and a meta grid of cooldown / cost / range. Ported from the game-components gc-ability-card and restyled to the toolcase design system — flat slate surface, hairline borders, sharp corners, mono machine-text." + /> + +
    + +
    +
    + {/* @ts-ignore */} + +
    +
    + {/* @ts-ignore */} + +
    +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default AbilityCardDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 4c2d1859..d72d0094 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -102,6 +102,7 @@ import DownloadStatsDemo from './DownloadStatsDemo' import EmptyStateDemo from './EmptyStateDemo' import EntityCellDemo from './EntityCellDemo' import FeatureCardDemo from './FeatureCardDemo' +import AbilityCardDemo from './AbilityCardDemo' import GoodFirstIssuesDemo from './GoodFirstIssuesDemo' import HeroStatsBarDemo from './HeroStatsBarDemo' import LeaderboardTrendDemo from './LeaderboardTrendDemo' @@ -340,6 +341,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'empty-state', category: 'Components', element: }, { key: 'entity-cell', category: 'Components', element: }, { key: 'feature-card', category: 'Components', element: }, + { key: 'ability-card', category: 'Content', element: }, { key: 'good-first-issues', category: 'Components', element: }, { key: 'hero-stats-bar', category: 'Components', element: }, { key: 'leaderboard', category: 'Components', element: }, diff --git a/web-components/src/AbilityCard.ts b/web-components/src/AbilityCard.ts new file mode 100644 index 00000000..a80a9310 --- /dev/null +++ b/web-components/src/AbilityCard.ts @@ -0,0 +1,176 @@ +import * as LucideIcons from 'lucide-static' +import { icon } from './icons' + +const TAG_NAME = 'tc-ability-card' + +export type AbilityCardRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary' +const RARITIES: AbilityCardRarity[] = ['common', 'uncommon', 'rare', 'epic', 'legendary'] + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function resolveIcon(name: string): string { + const svgStr = (LucideIcons as Record)[name] + if (!svgStr) return '' + return icon(svgStr) +} + +export class AbilityCard extends HTMLElement { + private _initialised = false + + static get observedAttributes(): string[] { + return [ + 'ability-name', + 'icon', + 'description', + 'cooldown', + 'cost', + 'range', + 'keybind', + 'rarity', + ] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + // --- Public API --- + + get abilityName(): string { + return this.getAttribute('ability-name') ?? '' + } + set abilityName(v: string) { + if (v) this.setAttribute('ability-name', v) + else this.removeAttribute('ability-name') + } + + get icon(): string { + return this.getAttribute('icon') ?? '' + } + set icon(v: string) { + if (v) this.setAttribute('icon', v) + else this.removeAttribute('icon') + } + + get description(): string { + return this.getAttribute('description') ?? '' + } + set description(v: string) { + if (v) this.setAttribute('description', v) + else this.removeAttribute('description') + } + + get cooldown(): string { + return this.getAttribute('cooldown') ?? '' + } + set cooldown(v: string) { + if (v) this.setAttribute('cooldown', v) + else this.removeAttribute('cooldown') + } + + get cost(): string { + return this.getAttribute('cost') ?? '' + } + set cost(v: string) { + if (v) this.setAttribute('cost', v) + else this.removeAttribute('cost') + } + + get range(): string { + return this.getAttribute('range') ?? '' + } + set range(v: string) { + if (v) this.setAttribute('range', v) + else this.removeAttribute('range') + } + + get keybind(): string { + return this.getAttribute('keybind') ?? '' + } + set keybind(v: string) { + if (v) this.setAttribute('keybind', v) + else this.removeAttribute('keybind') + } + + get rarity(): AbilityCardRarity { + const v = this.getAttribute('rarity') as AbilityCardRarity + return RARITIES.includes(v) ? v : 'common' + } + set rarity(v: AbilityCardRarity) { + this.setAttribute('rarity', v) + } + + private render(): void { + const rarity = this.rarity + // Reflect the rarity onto the host so themes / consumers can target a + // specific tier without a JS read (matches the gc-* source behaviour). + this.dataset.rarity = rarity + // Manage the component class via classList so author-supplied classes survive. + this.classList.add('tc-ability-card') + + const iconAttr = this.getAttribute('icon') + const iconSvg = iconAttr ? resolveIcon(iconAttr) : '' + const iconMarkup = `` + + const keybind = this.keybind + const keybindMarkup = keybind + ? `${esc(keybind)}` + : '' + + const description = this.description + const descMarkup = description + ? `
    ${esc(description)}
    ` + : '' + + const meta = [ + this.cooldown ? { label: 'Cooldown', value: this.cooldown } : null, + this.cost ? { label: 'Cost', value: this.cost } : null, + this.range ? { label: 'Range', value: this.range } : null, + ].filter(Boolean) as { label: string; value: string }[] + + const metaMarkup = meta.length + ? `
    ${meta + .map( + (m) => + `
    ` + + `${esc(m.label)}` + + `${esc(m.value)}` + + `
    `, + ) + .join('')}
    ` + : '' + + this.innerHTML = [ + '
    ', + iconMarkup, + '
    ', + `
    ${esc(rarity)}
    `, + `
    ${esc(this.abilityName)}
    `, + '
    ', + keybindMarkup, + '
    ', + descMarkup, + metaMarkup, + ].join('') + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: AbilityCard + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 469a2ee0..9cf57737 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -109,6 +109,7 @@ export * from './DownloadStats' export * from './EmptyState' export * from './EntityCell' export * from './FeatureCard' +export * from './AbilityCard' export * from './GoodFirstIssues' export * from './HeroStatsBar' export * from './Leaderboard' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 14d4a9f9..91ff47d9 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -108,6 +108,7 @@ import { DownloadStats } from './DownloadStats' import { EmptyState } from './EmptyState' import { EntityCell } from './EntityCell' import { FeatureCard } from './FeatureCard' +import { AbilityCard } from './AbilityCard' import { GoodFirstIssues } from './GoodFirstIssues' import { HeroStatsBar } from './HeroStatsBar' import { LeaderboardTrend } from './LeaderboardTrend' @@ -336,6 +337,7 @@ export function register(): void { customElements.define('tc-empty-state', EmptyState) customElements.define('tc-entity-cell', EntityCell) customElements.define('tc-feature-card', FeatureCard) + customElements.define('tc-ability-card', AbilityCard) customElements.define('tc-good-first-issues', GoodFirstIssues) customElements.define('tc-hero-stats-bar', HeroStatsBar) customElements.define('tc-leaderboard-trend', LeaderboardTrend) diff --git a/web-components/style/components/_ability-card.scss b/web-components/style/components/_ability-card.scss new file mode 100644 index 00000000..c45d0ffb --- /dev/null +++ b/web-components/style/components/_ability-card.scss @@ -0,0 +1,216 @@ +// tc-ability-card — ability portrait tile: an icon chip, rarity micro-label, +// ability name, optional hotkey (keybind), description, and a meta grid of +// cooldown / cost / range. Ported from the game-components gc-ability-card, but +// stripped of fantasy chrome — flat slate surface, hairline borders, sharp +// corners, mono machine-text. Rarity is conveyed by a single muted accent on +// the micro-label, not by gilded frames or glows. +// All cosmetics flow through --bs-ability-card-* custom properties backed by --tc-* tokens. + +@use '../foundation/tokens' as *; + +tc-ability-card { + --bs-ability-card-bg: var(--tc-surface); + --bs-ability-card-border-color: var(--tc-border); + --bs-ability-card-shadow: var(--tc-shadow-sm); + --bs-ability-card-shadow-hover: var(--tc-shadow-hover); + --bs-ability-card-padding-y: 1rem; + --bs-ability-card-padding-x: 1rem; + --bs-ability-card-gap: 0.75rem; + --bs-ability-card-head-gap: 0.75rem; + + --bs-ability-card-icon-bg: var(--tc-surface-muted); + --bs-ability-card-icon-color: var(--tc-text); + --bs-ability-card-icon-size: 1.375rem; + --bs-ability-card-icon-tile-size: 2.75rem; + + --bs-ability-card-rarity-color: var(--tc-text-faint); + --bs-ability-card-name-color: var(--tc-text); + --bs-ability-card-name-weight: 600; + --bs-ability-card-desc-color: var(--tc-text-muted); + + --bs-ability-card-keybind-bg: var(--tc-surface-muted); + --bs-ability-card-keybind-border: var(--tc-border-strong); + --bs-ability-card-keybind-color: var(--tc-text); + + --bs-ability-card-meta-border: var(--tc-border); + --bs-ability-card-meta-row-border: var(--tc-surface-muted); + --bs-ability-card-meta-label-color: var(--tc-text-faint); + --bs-ability-card-meta-value-color: var(--tc-text); +} + +// Per-rarity accent — sanctioned status tokens only, kept muted. Drives the +// rarity micro-label colour; themes can override --bs-ability-card-rarity-color. +tc-ability-card[data-rarity='uncommon'] { + --bs-ability-card-rarity-color: var(--tc-success); +} +tc-ability-card[data-rarity='rare'] { + --bs-ability-card-rarity-color: var(--tc-info); +} +tc-ability-card[data-rarity='epic'] { + --bs-ability-card-rarity-color: var(--tc-app-accent); +} +tc-ability-card[data-rarity='legendary'] { + --bs-ability-card-rarity-color: var(--tc-warning); +} + +.tc-ability-card { + display: flex; + flex-direction: column; + gap: var(--bs-ability-card-gap); + padding: var(--bs-ability-card-padding-y) var(--bs-ability-card-padding-x); + background: var(--bs-ability-card-bg); + border: 1px solid var(--bs-ability-card-border-color); + border-radius: 0; + box-shadow: var(--bs-ability-card-shadow); + transition: + box-shadow var(--tc-transition-fast), + transform var(--tc-transition-fast); + + &:hover { + box-shadow: var(--bs-ability-card-shadow-hover); + transform: translateY(-1px); + } +} + +// Head row: icon chip + name block + hotkey pushed to the trailing edge. +.tc-ability-card-head { + display: flex; + align-items: flex-start; + gap: var(--bs-ability-card-head-gap); +} + +// Icon chip — flat slate square, sharp corners. +.tc-ability-card-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--bs-ability-card-icon-tile-size); + height: var(--bs-ability-card-icon-tile-size); + flex-shrink: 0; + background: var(--bs-ability-card-icon-bg); + border-radius: 0; + color: var(--bs-ability-card-icon-color); + + &:empty { + display: none; + } + + svg { + width: var(--bs-ability-card-icon-size); + height: var(--bs-ability-card-icon-size); + } +} + +// Name block grows to fill, pushing the keybind to the right. +.tc-ability-card-headtext { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; + flex: 1 1 auto; +} + +// Rarity — JetBrains Mono micro-label, uppercase, tinted by the rarity accent. +.tc-ability-card-rarity { + font-family: var(--bs-font-monospace); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + line-height: 1.4; + color: var(--bs-ability-card-rarity-color); + + &:empty { + display: none; + } +} + +// Ability name — slate ink, weight 600. +.tc-ability-card-name { + font-size: 0.9375rem; + font-weight: var(--bs-ability-card-name-weight); + color: var(--bs-ability-card-name-color); + line-height: 1.35; + + &:empty { + display: none; + } +} + +// Hotkey — mono kbd cap, hairline frame, sharp corners. +.tc-ability-card-keybind { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + min-width: 1.75rem; + height: 1.75rem; + padding: 0 0.5rem; + font-family: var(--bs-font-monospace); + font-size: 0.8125rem; + font-weight: 600; + line-height: 1; + color: var(--bs-ability-card-keybind-color); + background: var(--bs-ability-card-keybind-bg); + border: 1px solid var(--bs-ability-card-keybind-border); + border-radius: 0; +} + +// Description — muted body text. +.tc-ability-card-description { + font-size: 0.875rem; + line-height: 1.55; + color: var(--bs-ability-card-desc-color); + + &:empty { + display: none; + } +} + +// Meta grid — cooldown / cost / range as label↔value rows separated by hairlines. +.tc-ability-card-meta { + display: flex; + flex-direction: column; + border-top: 1px solid var(--bs-ability-card-meta-border); +} + +.tc-ability-card-meta-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; + padding: 0.375rem 0; + border-bottom: 1px solid var(--bs-ability-card-meta-row-border); + + &:last-child { + border-bottom: 0; + } +} + +.tc-ability-card-meta-label { + font-family: var(--bs-font-monospace); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--bs-ability-card-meta-label-color); +} + +.tc-ability-card-meta-value { + font-family: var(--bs-font-monospace); + font-size: 0.8125rem; + font-weight: 500; + color: var(--bs-ability-card-meta-value-color); + text-align: right; +} + +// Honour prefers-reduced-motion: freeze the lift, keep the shadow transition. +@media (prefers-reduced-motion: reduce) { + .tc-ability-card { + transition: box-shadow var(--tc-transition-fast); + + &:hover { + transform: none; + } + } +} diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index e45216d3..6f8399ab 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -109,6 +109,7 @@ @forward 'empty-state'; @forward 'entity-cell'; @forward 'feature-card'; +@forward 'ability-card'; @forward 'good-first-issues'; @forward 'hero-stats-bar'; @forward 'leaderboard'; diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 29dcbd93..d671ea08 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -182,6 +182,7 @@ tc-download-stats, tc-empty-state, tc-entity-cell, tc-feature-card, +tc-ability-card, tc-good-first-issues, tc-hero-stats-bar, tc-linked-providers-card, From f4d2f580462f6ded978cdd4dfad476916086fe0c Mon Sep 17 00:00:00 2001 From: kalevski Date: Mon, 15 Jun 2026 22:56:10 +0000 Subject: [PATCH 261/632] 258-markdown-editor: Build the tc-markdown-editor web component (MarkdownEditor parity) --- examples/public/web-components/SKILL.md | 46 +++ .../src/web-components/MarkdownEditorDemo.tsx | 89 +++++ examples/src/web-components/index.tsx | 2 + web-components/src/MarkdownEditor.ts | Bin 0 -> 16799 bytes web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_markdown-editor.scss | 328 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 470 insertions(+) create mode 100644 examples/src/web-components/MarkdownEditorDemo.tsx create mode 100644 web-components/src/MarkdownEditor.ts create mode 100644 web-components/style/components/_markdown-editor.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index fe01d42b..b9742276 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -231,6 +231,7 @@ After `register()` you can author markup directly: - [tc-select](#tc-select) - [tc-switch](#tc-switch) - [tc-textarea](#tc-textarea) + - [tc-markdown-editor](#tc-markdown-editor) - [tc-early-signup-form](#tc-early-signup-form) - [tc-editable-text](#tc-editable-text) - [tc-extended-select](#tc-extended-select) @@ -6308,6 +6309,51 @@ Multi-line text input. --- +### tc-markdown-editor + +Split-pane markdown editor with Write/Preview tabs and a formatting toolbar. The toolbar (bold, italic, heading, link, list, quote, code) wraps or prefixes the current textarea selection; switching to Preview renders a small, safe internal markdown-to-HTML conversion (headings, bold/italic, inline code, code fences, links, lists, blockquotes, paragraphs) with all text HTML-escaped and link hrefs protocol-sanitised. Port of `@toolcase/react-components` `MarkdownEditor`. + +**Tag:** `tc-markdown-editor` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `value` | string | `''` | Markdown source. Also a JS property kept in sync; set via property for multi-line content. | +| `placeholder` | string | `Write some **Markdown**…` | Textarea placeholder | +| `height` | number \| string | `360` | Editor body height (bare number → px; string passthrough, e.g. `"24rem"`) | +| `toolbar` | boolean | shown | Formatting toolbar. **Shown by default**; set `toolbar="false"` to hide it. | +| `label` | string | — | Optional field label above the editor | +| `disabled` | boolean | false | Disables editing and the toolbar (opacity + `pointer-events: none`) | + +**Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `value` | string | Get/set the markdown source. Setting it updates the textarea without losing the active tab. | +| `onChange` | `(value: string) => void \| null` | Optional callback mirror of the `tc-change` event | + +**Events** + +| Event | `detail` | Description | +|-------|----------|-------------| +| `tc-change` | `{ value: string }` | Fired on every edit (typing, toolbar action, Tab indent) | + +**Slots:** none. + +**Notes:** the textarea has an accessible name (via `label` `
    ` + : '' + this.innerHTML = ` + ${speakerMarkup} +
    + + +
    + ${choicesMarkup} + ` + this._updateTyped() + + this.querySelectorAll('.tc-dialogue-box-choice').forEach(el => { + const handle = (event: Event): void => { + event.stopPropagation() + const id = el.dataset.id || '' + const c = this._choices.find(x => x.id === id) + if (!c || c.disabled) return + this._emit('tc-choice', { id }) + if (typeof this.onChoice === 'function') this.onChoice(id) + } + // Native ` + }).join('') + + this.setAttribute('role', 'group') + if (!this.hasAttribute('aria-label')) this.setAttribute('aria-label', 'Equipment') + + this.innerHTML = `
    ${figureMarkup}${slotsHTML}
    ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: EquipmentDoll + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index e7849efd..4d60a0d6 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -121,6 +121,7 @@ export * from './DashboardContent' export * from './DownloadStats' export * from './EmptyState' export * from './EntityCell' +export * from './EquipmentDoll' export * from './FeatureCard' export * from './AbilityCard' export * from './GoodFirstIssues' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 0ab90184..3673e4b1 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -120,6 +120,7 @@ import { DashboardContent } from './DashboardContent' import { DownloadStats } from './DownloadStats' import { EmptyState } from './EmptyState' import { EntityCell } from './EntityCell' +import { EquipmentDoll } from './EquipmentDoll' import { FeatureCard } from './FeatureCard' import { AbilityCard } from './AbilityCard' import { GoodFirstIssues } from './GoodFirstIssues' @@ -388,6 +389,7 @@ export function register(): void { customElements.define('tc-download-stats', DownloadStats) customElements.define('tc-empty-state', EmptyState) customElements.define('tc-entity-cell', EntityCell) + customElements.define('tc-equipment-doll', EquipmentDoll) customElements.define('tc-feature-card', FeatureCard) customElements.define('tc-ability-card', AbilityCard) customElements.define('tc-good-first-issues', GoodFirstIssues) diff --git a/web-components/style/components/_equipment-doll.scss b/web-components/style/components/_equipment-doll.scss new file mode 100644 index 00000000..2d00578d --- /dev/null +++ b/web-components/style/components/_equipment-doll.scss @@ -0,0 +1,196 @@ +// tc-equipment-doll — paper-doll equipment slots arranged around a neutral +// character figure. Re-skinned from the game-components gc-equipment-doll: no +// gilded frame, no glow, no fantasy fills. A faint slate figure backs sharp +// hairline slot tiles; selection is the standard ink-active state. +// All cosmetics flow through --bs-equipment-doll-* custom properties. + +@use '../foundation/tokens' as *; + +tc-equipment-doll { + --bs-equipment-doll-width: 240px; + --bs-equipment-doll-height: 360px; + --bs-equipment-doll-slot-size: 56px; + + --bs-equipment-doll-surface: var(--tc-surface); + --bs-equipment-doll-border-color: var(--tc-border); + --bs-equipment-doll-grid-color: var(--tc-border); + --bs-equipment-doll-grid-size: 28px; + + --bs-equipment-doll-figure-color: var(--tc-slate-300); + --bs-equipment-doll-figure-opacity: 0.45; + + --bs-equipment-doll-slot-bg: var(--tc-surface); + --bs-equipment-doll-slot-border: var(--tc-border-strong); + --bs-equipment-doll-slot-empty-border: var(--tc-border); + --bs-equipment-doll-slot-hover-bg: var(--tc-surface-muted); + --bs-equipment-doll-icon-color: var(--tc-text); + --bs-equipment-doll-empty-color: var(--tc-text-faint); + + --bs-equipment-doll-selected-bg: var(--tc-app-accent); + --bs-equipment-doll-selected-color: #fff; + + --bs-equipment-doll-label-color: var(--tc-text-muted); + --bs-equipment-doll-label-font-size: 0.6875rem; + + --bs-equipment-doll-qty-bg: var(--tc-app-accent); + --bs-equipment-doll-qty-color: #fff; +} + +.tc-equipment-doll { + // Centre the fixed-size canvas within whatever block width the host gets. + &__canvas { + position: relative; + width: var(--bs-equipment-doll-width); + height: var(--bs-equipment-doll-height); + max-width: 100%; + margin-inline: auto; + background: var(--bs-equipment-doll-surface); + border: 1px solid var(--bs-equipment-doll-border-color); + border-radius: 0; + + // Faint grid backdrop — hairlines, not boxes. Sits below the figure. + background-image: + linear-gradient(var(--bs-equipment-doll-grid-color) 1px, transparent 1px), + linear-gradient(90deg, var(--bs-equipment-doll-grid-color) 1px, transparent 1px); + background-size: var(--bs-equipment-doll-grid-size) var(--bs-equipment-doll-grid-size); + } + + // Neutral humanoid figure, centred and faint so the slots read first. + &__figure { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + height: 78%; + width: auto; + color: var(--bs-equipment-doll-figure-color); + fill: currentColor; + opacity: var(--bs-equipment-doll-figure-opacity); + pointer-events: none; + } + + // Custom text/glyph silhouette (when the `silhouette` attribute is set). + &__silhouette { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + align-items: center; + justify-content: center; + font-size: clamp(2rem, 30%, 6rem); + line-height: 1; + color: var(--bs-equipment-doll-figure-color); + opacity: var(--bs-equipment-doll-figure-opacity); + pointer-events: none; + } + + // Each slot is an absolutely-positioned button anchored on its x/y%, with the + // tile centred on the point and an optional mono label stacked below. + &__slot { + position: absolute; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + padding: 0; + margin: 0; + background: none; + border: 0; + border-radius: 0; + color: inherit; + font: inherit; + cursor: pointer; + transition: var(--tc-transition-fast); + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + } + + &:hover .tc-equipment-doll__tile { + background: var(--bs-equipment-doll-slot-hover-bg); + } + } + + // Sharp square tile — the slot face. + &__tile { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: var(--bs-equipment-doll-slot-size); + height: var(--bs-equipment-doll-slot-size); + background: var(--bs-equipment-doll-slot-bg); + border: 1px solid var(--bs-equipment-doll-slot-border); + border-radius: 0; + color: var(--bs-equipment-doll-icon-color); + transition: var(--tc-transition-fast); + } + + // Empty slot: dashed hairline, muted — reads as a vacant socket. + &__slot.is-empty &__tile { + background: transparent; + border-style: dashed; + border-color: var(--bs-equipment-doll-slot-empty-border); + color: var(--bs-equipment-doll-empty-color); + } + + // Active = solid ink, white content (the standard active state). + &__slot.is-selected &__tile { + background: var(--bs-equipment-doll-selected-bg); + border-color: var(--bs-equipment-doll-selected-bg); + color: var(--bs-equipment-doll-selected-color); + } + + &__icon { + max-width: 70%; + max-height: 70%; + font-size: calc(var(--bs-equipment-doll-slot-size) * 0.42); + line-height: 1; + object-fit: contain; + } + + // Quantity pill in the tile corner — a sanctioned mono micro-badge. + &__qty { + position: absolute; + right: -1px; + bottom: -1px; + padding: 0 0.25rem; + background: var(--bs-equipment-doll-qty-bg); + color: var(--bs-equipment-doll-qty-color); + font-family: var(--bs-font-monospace, monospace); + font-size: 0.625rem; + font-weight: 500; + line-height: 1.4; + } + + &__label { + font-family: var(--bs-font-monospace, monospace); + font-size: var(--bs-equipment-doll-label-font-size); + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--bs-equipment-doll-label-color); + white-space: nowrap; + line-height: 1; + } +} + +// Coarse pointers: guarantee a 44px touch target on the slot tiles. +@media (pointer: coarse) { + .tc-equipment-doll__tile { + min-width: 44px; + min-height: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + // Keep the colour/background state transitions (they convey selection/hover), + // there is no decorative transform to freeze here. + .tc-equipment-doll__slot, + .tc-equipment-doll__tile { + transition-duration: 0.001s; + } +} diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index ed07951d..0e836c38 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -203,6 +203,7 @@ @forward 'ecosystem-map'; @forward 'editable-text'; @forward 'entity-profile-card'; +@forward 'equipment-doll'; @forward 'extended-select'; @forward 'feature-matrix'; @forward 'file-dropzone'; diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index b694a10f..12c6047b 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -260,6 +260,7 @@ tc-diff-viewer, tc-early-signup-form, tc-ecosystem-map, tc-entity-profile-card, +tc-equipment-doll, tc-extended-select, tc-faq-list, tc-feature-matrix, From 6844409e0da1bc6e22efb6df2fa89f24e8dea0b5 Mon Sep 17 00:00:00 2001 From: kalevski Date: Tue, 16 Jun 2026 09:00:37 +0000 Subject: [PATCH 301/632] 294-eyebrow: Build the tc-eyebrow web component (Eyebrow game-components port) --- examples/public/web-components/SKILL.md | 29 ++++++++++ examples/src/web-components/EyebrowDemo.tsx | 56 +++++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Eyebrow.ts | 35 ++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_eyebrow.scss | 39 +++++++++++++ web-components/style/components/_index.scss | 1 + web-components/style/foundation/_reset.scss | 1 + 9 files changed, 166 insertions(+) create mode 100644 examples/src/web-components/EyebrowDemo.tsx create mode 100644 web-components/src/Eyebrow.ts create mode 100644 web-components/style/components/_eyebrow.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index c7d5afe2..f1627f92 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -73,6 +73,7 @@ After `register()` you can author markup directly: - [tc-collapse](#tc-collapse) - [tc-divider](#tc-divider) - [tc-dropdown](#tc-dropdown) + - [tc-eyebrow](#tc-eyebrow) - [tc-heading](#tc-heading) - [tc-icon](#tc-icon) - [tc-icon-button](#tc-icon-button) @@ -2320,6 +2321,34 @@ Item inside `tc-dropdown`. --- +### tc-eyebrow + +Small uppercase micro-label shown above a heading. Machine-facing JetBrains Mono, wide letter-spacing, slate-muted ink, sharp corners. Content is provided via the default slot — no attributes, properties, or events. + +**Slots** + +| Slot | Description | +|------|-------------| +| (default) | Label text or inline content (rendered uppercase) | + +**Theming (`--bs-eyebrow-*`)** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-eyebrow-font-family` | `'JetBrains Mono', var(--bs-font-monospace, monospace)` | Label font family | +| `--bs-eyebrow-font-size` | `0.6875rem` | Label font size | +| `--bs-eyebrow-font-weight` | `500` | Label font weight | +| `--bs-eyebrow-line-height` | `1.4` | Label line height | +| `--bs-eyebrow-letter-spacing` | `0.12em` | Letter spacing | +| `--bs-eyebrow-color` | `var(--tc-text-muted)` | Label text color | + +```html +What's new +Release 2.0 is here +``` + +--- + ### tc-heading Semantic heading element (h1–h6) with optional slate-ink gradient text treatment. Renders a real `` element for a correct document outline. diff --git a/examples/src/web-components/EyebrowDemo.tsx b/examples/src/web-components/EyebrowDemo.tsx new file mode 100644 index 00000000..0ee9bdf3 --- /dev/null +++ b/examples/src/web-components/EyebrowDemo.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const EyebrowDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="Eyebrow" + description="A small uppercase micro-label shown above a heading. Machine-facing JetBrains Mono, wide letter-spacing, slate-muted ink. Content is provided via the default slot." + /> + +
    + +
    + {/* @ts-ignore */} + What's new +

    Release 2.0 is here

    +
    +
    + + +
    + {/* @ts-ignore */} + Getting started + {/* @ts-ignore */} + Featured + {/* @ts-ignore */} + Step 01 +
    +
    + + +
    + {/* @ts-ignore */} + Accented eyebrow +

    Override the contract, not the markup

    +
    +
    +
    +
    +
    +
    +
    +) + +export default EyebrowDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 9695ad57..bf278fe8 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -114,6 +114,7 @@ import DashboardSidebarDemo from './DashboardSidebarDemo' import DashboardLayoutDemo from './DashboardLayoutDemo' import DownloadStatsDemo from './DownloadStatsDemo' import EmptyStateDemo from './EmptyStateDemo' +import EyebrowDemo from './EyebrowDemo' import EntityCellDemo from './EntityCellDemo' import EquipmentDollDemo from './EquipmentDollDemo' import FeatureCardDemo from './FeatureCardDemo' @@ -340,6 +341,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'form', category: 'Forms', element: }, { key: 'form-input', category: 'Forms', element: }, { key: 'divider', category: 'Components', element: }, + { key: 'eyebrow', category: 'Content', element: }, { key: 'heading', category: 'Content', element: }, { key: 'helper-text', category: 'Forms', element: }, { key: 'icon', category: 'Content', element: }, diff --git a/web-components/src/Eyebrow.ts b/web-components/src/Eyebrow.ts new file mode 100644 index 00000000..5f1d33c6 --- /dev/null +++ b/web-components/src/Eyebrow.ts @@ -0,0 +1,35 @@ +const TAG_NAME = 'tc-eyebrow' + +// Port of game-components `gc-eyebrow` — a small uppercase micro-label shown +// above a heading. The source is a pure default-slot component (no attributes, +// properties, or events), so this port keeps the same minimal surface and only +// swaps the fantasy chrome for the web-components design-system micro-label +// (JetBrains Mono, uppercase, slate-muted), styled via `_eyebrow.scss`. +export class Eyebrow extends HTMLElement { + + private _initialised = false + + constructor() { + super() + } + + connectedCallback(): void { + if (!this._initialised) { + const slotContent = Array.from(this.childNodes) + this.render() + const inner = this.querySelector('.tc-eyebrow-content') + if (inner) slotContent.forEach(n => inner.appendChild(n)) + this._initialised = true + } + } + + private render(): void { + this.innerHTML = `` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Eyebrow + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 4d60a0d6..b8bfe202 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -120,6 +120,7 @@ export * from './StatusCard' export * from './DashboardContent' export * from './DownloadStats' export * from './EmptyState' +export * from './Eyebrow' export * from './EntityCell' export * from './EquipmentDoll' export * from './FeatureCard' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 3673e4b1..f668b942 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -119,6 +119,7 @@ import { StatusCard } from './StatusCard' import { DashboardContent } from './DashboardContent' import { DownloadStats } from './DownloadStats' import { EmptyState } from './EmptyState' +import { Eyebrow } from './Eyebrow' import { EntityCell } from './EntityCell' import { EquipmentDoll } from './EquipmentDoll' import { FeatureCard } from './FeatureCard' @@ -388,6 +389,7 @@ export function register(): void { customElements.define('tc-dashboard-content', DashboardContent) customElements.define('tc-download-stats', DownloadStats) customElements.define('tc-empty-state', EmptyState) + customElements.define('tc-eyebrow', Eyebrow) customElements.define('tc-entity-cell', EntityCell) customElements.define('tc-equipment-doll', EquipmentDoll) customElements.define('tc-feature-card', FeatureCard) diff --git a/web-components/style/components/_eyebrow.scss b/web-components/style/components/_eyebrow.scss new file mode 100644 index 00000000..58eb9026 --- /dev/null +++ b/web-components/style/components/_eyebrow.scss @@ -0,0 +1,39 @@ +// tc-eyebrow — small uppercase micro-label shown above a heading. Port of the +// game-components `gc-eyebrow` (a bare slotted label) re-skinned to the +// web-components design system: machine-facing JetBrains Mono, uppercase, wide +// letter-spacing, slate-muted ink. Sharp by mandate; all cosmetics flow through +// the public `--bs-eyebrow-*` theming contract. + +.tc-eyebrow { + --bs-eyebrow-font-family: 'JetBrains Mono', var(--bs-font-monospace, monospace); + --bs-eyebrow-font-size: 0.6875rem; + --bs-eyebrow-font-weight: 500; + --bs-eyebrow-line-height: 1.4; + --bs-eyebrow-letter-spacing: 0.12em; + --bs-eyebrow-color: var(--tc-text-muted); + + display: block; + font-family: var(--bs-eyebrow-font-family); + font-size: var(--bs-eyebrow-font-size); + font-weight: var(--bs-eyebrow-font-weight); + line-height: var(--bs-eyebrow-line-height); + letter-spacing: var(--bs-eyebrow-letter-spacing); + text-transform: uppercase; + color: var(--bs-eyebrow-color); + + // Defensive cascade: a foreign Bootstrap sheet must not box or round it. + margin: 0; + border: 0; + border-radius: 0; + background: none; + + &:empty { + display: none; + } +} + +// The slot-content wrapper is purely structural — let inline content flow +// through it so truncation/wrapping rules apply to the text directly. +.tc-eyebrow-content { + display: contents; +} diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 0e836c38..4bcb0bc5 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -124,6 +124,7 @@ @forward 'dashboard-content'; @forward 'download-stats'; @forward 'empty-state'; +@forward 'eyebrow'; @forward 'entity-cell'; @forward 'feature-card'; @forward 'ability-card'; diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 12c6047b..24cb93d1 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -313,6 +313,7 @@ tc-user-panel, tc-vertical-item-list, tc-video-embed, tc-virtual-list, +tc-eyebrow, tc-roadmap { display: block; } From dd73a08917ec6845a091e81b482733ff96d653df Mon Sep 17 00:00:00 2001 From: kalevski Date: Tue, 16 Jun 2026 09:04:08 +0000 Subject: [PATCH 302/632] 295-fov-slider: Build the tc-fov-slider web component (FOVSlider game-components port) --- examples/public/web-components/SKILL.md | 49 ++++++ examples/src/web-components/FOVSliderDemo.tsx | 80 +++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/FOVSlider.ts | 116 +++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + .../style/components/_fov-slider.scss | 156 ++++++++++++++++++ web-components/style/components/_index.scss | 1 + web-components/style/foundation/_reset.scss | 1 + 9 files changed, 408 insertions(+) create mode 100644 examples/src/web-components/FOVSliderDemo.tsx create mode 100644 web-components/src/FOVSlider.ts create mode 100644 web-components/style/components/_fov-slider.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index f1627f92..af4c19e5 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -266,6 +266,7 @@ After `register()` you can author markup directly: - [tc-range](#tc-range) - [tc-range-slider](#tc-range-slider) - [tc-deadzone-slider](#tc-deadzone-slider) + - [tc-fov-slider](#tc-fov-slider) - [tc-rating](#tc-rating) - [tc-slider](#tc-slider) - [tc-select](#tc-select) @@ -7403,6 +7404,54 @@ A stick-deadzone setting row: a label/description text block paired with a 0–1 --- +### tc-fov-slider + +A field-of-view setting row: a label/description text block paired with a range control and a mono degree readout. Built on the shared `tc-setting-row` scaffold (a label/control row that the setting rows reuse). Port of game-components `gc-fov-slider` with the fantasy chrome dropped for the toolcase slate/ink look. + +**Tag:** `tc-fov-slider` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `row-label` | string | `Field of View` | Row label (set automatically when absent) | +| `description` | string | — | Optional secondary line beneath the label | +| `value` | number | `90` | Current field of view, in degrees (`1` step) | +| `min` | number | `60` | Minimum field of view | +| `max` | number | `120` | Maximum field of view | +| `disabled` | boolean | `false` | Disables the range input | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `value` | `number` | Get or set the field of view (degrees). Getter defaults to `90`. Setting patches the input + readout in place — no full re-render. | +| `min` | `number` | Get/set the minimum (`min` attribute). Defaults to `60`. | +| `max` | `number` | Get/set the maximum (`max` attribute). Defaults to `120`. | +| `rowLabel` | `string` | Get/set the `row-label` attribute. | +| `description` | `string` | Get/set the `description` attribute. | +| `disabled` | `boolean` | Get/set the `disabled` attribute. | +| `onChange` | `((value: number) => void) \| null` | Optional callback fired on every change. Mirrors the `tc-change` event. | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-change` | `{ value: number }` | Fired on every range-input change (the field of view in degrees). | + +**No slots.** + +```html + + +``` + +--- + ### tc-rating Interactive star rating with optional half-stars, keyboard navigation, custom icons, size variants, and a read-only display mode. The whole rating is a single `role="slider"` tab stop (`aria-valuemin=0` / `aria-valuemax=count` / `aria-valuenow=value`); the star glyphs are decorative. Filled stars use the warm amber accent; empty stars use the neutral border tone. No slots — all configuration is via attributes and the `value` JS property. diff --git a/examples/src/web-components/FOVSliderDemo.tsx b/examples/src/web-components/FOVSliderDemo.tsx new file mode 100644 index 00000000..b6aaef72 --- /dev/null +++ b/examples/src/web-components/FOVSliderDemo.tsx @@ -0,0 +1,80 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +function useFovValue(initial: number): [number, React.RefObject] { + const [value, setValue] = useState(initial) + const ref = useRef(null) + + useEffect(() => { + const el = ref.current + if (!el) return + const handler = (e: Event) => { + const detail = (e as CustomEvent<{ value: number }>).detail + if (detail) setValue(detail.value) + } + el.addEventListener('tc-change', handler) + return () => el.removeEventListener('tc-change', handler) + }, []) + + return [value, ref] +} + +const FOVSliderDemo: React.FC = () => { + const [v1, ref1] = useFovValue(90) + const [v2, ref2] = useFovValue(103) + + return ( +
    +
    +
    +
    + Web Components} + title="FOV Slider" + description="A field-of-view setting row: a label/description block paired with a range control and a mono degree readout. Built on the shared tc-setting-row scaffold." + /> + +
    + +
    + {/* @ts-ignore */} + +
    +
    Current value: {Math.round(v1)}°
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    Current value: {Math.round(v2)}°
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default FOVSliderDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index bf278fe8..07b7396f 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -224,6 +224,7 @@ import OTPInputDemo from './OTPInputDemo' import PhoneInputDemo from './PhoneInputDemo' import RangeSliderDemo from './RangeSliderDemo' import DeadzoneSliderDemo from './DeadzoneSliderDemo' +import FOVSliderDemo from './FOVSliderDemo' import AudioMixerDemo from './AudioMixerDemo' import RatingDemo from './RatingDemo' import SliderDemo from './SliderDemo' @@ -501,6 +502,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'phone-input', category: 'Forms', element: }, { key: 'range-slider', category: 'Forms', element: }, { key: 'deadzone-slider', category: 'Forms', element: }, + { key: 'fov-slider', category: 'Forms', element: }, { key: 'resizable-panel', category: 'Layout', element: }, { key: 'side-nav', category: 'Navigation', element: }, { key: 'tree-view', category: 'Navigation', element: }, diff --git a/web-components/src/FOVSlider.ts b/web-components/src/FOVSlider.ts new file mode 100644 index 00000000..fe81c5a8 --- /dev/null +++ b/web-components/src/FOVSlider.ts @@ -0,0 +1,116 @@ +import { SettingRowBase } from './SettingRowBase' + +const TAG_NAME = 'tc-fov-slider' + +// tc-fov-slider — a field-of-view preset row. A native range input paired with a +// mono degree readout, built on the shared setting-row scaffold. Port of +// game-components `gc-fov-slider` with the fantasy chrome dropped for the +// toolcase slate/ink look. + +export class FOVSlider extends SettingRowBase { + + // Optional callback mirror of the `tc-change` event (see styleguide §events). + onChange: ((value: number) => void) | null = null + + static get observedAttributes(): string[] { + return [...SettingRowBase.observedAttributes, 'value', 'min', 'max', 'disabled'] + } + + connectedCallback(): void { + if (!this.hasAttribute('row-label')) this.setAttribute('row-label', 'Field of View') + super.connectedCallback() + } + + attributeChangedCallback(name: string, old: string | null, next: string | null): void { + if (!this.isConnected || !this._initialised) return + // Patch the value in place — a full re-render would destroy the input + // (and the active drag) on every `input` event. + if (name === 'value') { + const input = this.querySelector('.tc-fov-slider__input') + const display = this.querySelector('.tc-fov-slider__value') + const v = this.value + if (input && input.value !== String(v)) input.value = String(v) + if (display) display.textContent = `${Math.round(v)}°` + return + } + super.attributeChangedCallback(name, old, next) + } + + get value(): number { + const raw = this.getAttribute('value') + if (raw == null) return 90 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) ? 90 : parsed + } + set value(v: number) { + this.setAttribute('value', String(v)) + } + + get min(): number { + const raw = this.getAttribute('min') + if (raw == null) return 60 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) ? 60 : parsed + } + set min(v: number) { + this.setAttribute('min', String(v)) + } + + get max(): number { + const raw = this.getAttribute('max') + if (raw == null) return 120 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) ? 120 : parsed + } + set max(v: number) { + this.setAttribute('max', String(v)) + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + protected renderControl(): string { + const value = this.value + const min = this.min + const max = this.max + const disabledAttr = this.disabled ? ' disabled' : '' + return ` +
    + + ${Math.round(value)}° +
    + ` + } + + protected bindControl(): void { + const input = this.querySelector('.tc-fov-slider__input') + const display = this.querySelector('.tc-fov-slider__value') + if (!input) return + input.addEventListener('input', () => { + const v = parseFloat(input.value) + if (display) display.textContent = `${Math.round(v)}°` + this.setAttribute('value', String(v)) + this.emit('tc-change', { value: v }) + if (typeof this.onChange === 'function') this.onChange(v) + }) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: FOVSlider + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index b8bfe202..96e19467 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -266,3 +266,4 @@ export * from './SettingRowBase' export * from './DeadzoneSlider' export * from './DebugOverlay' export * from './DialogueBox' +export * from './FOVSlider' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index f668b942..8e6b2252 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -263,6 +263,7 @@ import { CurrencyDisplay } from './CurrencyDisplay' import { DeadzoneSlider } from './DeadzoneSlider' import { DebugOverlay } from './DebugOverlay' import { DialogueBox } from './DialogueBox' +import { FOVSlider } from './FOVSlider' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -535,4 +536,5 @@ export function register(): void { customElements.define('tc-deadzone-slider', DeadzoneSlider) customElements.define('tc-debug-overlay', DebugOverlay) customElements.define('tc-dialogue-box', DialogueBox) + customElements.define('tc-fov-slider', FOVSlider) } diff --git a/web-components/style/components/_fov-slider.scss b/web-components/style/components/_fov-slider.scss new file mode 100644 index 00000000..09f66f5c --- /dev/null +++ b/web-components/style/components/_fov-slider.scss @@ -0,0 +1,156 @@ +// tc-fov-slider — field-of-view preset control on the shared setting-row +// scaffold (`_setting-row.scss`). The control is a native range input — flat +// hairline track, circular ink-ringed thumb (the one sanctioned curve) — paired +// with a mono degree readout. All cosmetics flow through `--bs-fov-slider-*`; +// the fantasy chrome of the gc-* original is dropped. + +tc-fov-slider { + --bs-fov-slider-track-width: 160px; + --bs-fov-slider-track-height: 6px; + --bs-fov-slider-track-color: var(--tc-border); + --bs-fov-slider-fill-color: var(--tc-app-accent); + --bs-fov-slider-thumb-size: 16px; + --bs-fov-slider-thumb-bg: var(--tc-surface); + --bs-fov-slider-thumb-border: var(--tc-app-accent); + --bs-fov-slider-thumb-active-bg: var(--tc-surface-muted); + --bs-fov-slider-thumb-shadow: var(--tc-shadow-sm); + --bs-fov-slider-focus-ring: 0 0 0 0.2rem rgba(30, 41, 59, 0.12); + --bs-fov-slider-disabled-color: var(--tc-text-faint); + --bs-fov-slider-value-color: var(--tc-text-muted); + --bs-fov-slider-value-size: 0.6875rem; + --bs-fov-slider-value-min-width: 3rem; + --bs-fov-slider-gap: 0.625rem; +} + +.tc-fov-slider__control { + display: flex; + align-items: center; + gap: var(--bs-fov-slider-gap); +} + +.tc-fov-slider__input { + width: var(--bs-fov-slider-track-width); + height: 1.25rem; + padding: 0; + appearance: none; + -webkit-appearance: none; + background-color: transparent; + cursor: pointer; + + // Coarse pointers get a 44px hit area without changing the visual track. + @media (pointer: coarse) { + min-height: 44px; + } + + &:focus { + outline: 0; + } + + &:focus-visible { + &::-webkit-slider-thumb { + box-shadow: var(--bs-fov-slider-focus-ring); + } + + &::-moz-range-thumb { + box-shadow: var(--bs-fov-slider-focus-ring); + } + } + + &::-webkit-slider-runnable-track { + width: 100%; + height: var(--bs-fov-slider-track-height); + cursor: pointer; + background-color: var(--bs-fov-slider-track-color); + border: 0; + border-radius: 0; + } + + &::-webkit-slider-thumb { + // Centre the thumb on the track regardless of the two sizes. + margin-top: calc( + (var(--bs-fov-slider-track-height) - var(--bs-fov-slider-thumb-size)) / 2 + ); + width: var(--bs-fov-slider-thumb-size); + height: var(--bs-fov-slider-thumb-size); + appearance: none; + -webkit-appearance: none; + cursor: pointer; + background-color: var(--bs-fov-slider-thumb-bg); + border: 1.5px solid var(--bs-fov-slider-thumb-border); + border-radius: 50%; + box-shadow: var(--bs-fov-slider-thumb-shadow); + transition: background-color var(--tc-transition-fast); + + &:active { + background-color: var(--bs-fov-slider-thumb-active-bg); + } + } + + &::-moz-range-track { + width: 100%; + height: var(--bs-fov-slider-track-height); + cursor: pointer; + background-color: var(--bs-fov-slider-track-color); + border: 0; + border-radius: 0; + } + + // Filled portion of the track (ink up to the thumb) — Firefox only. + &::-moz-range-progress { + height: var(--bs-fov-slider-track-height); + background-color: var(--bs-fov-slider-fill-color); + } + + &::-moz-range-thumb { + width: var(--bs-fov-slider-thumb-size); + height: var(--bs-fov-slider-thumb-size); + cursor: pointer; + background-color: var(--bs-fov-slider-thumb-bg); + border: 1.5px solid var(--bs-fov-slider-thumb-border); + border-radius: 50%; + box-shadow: var(--bs-fov-slider-thumb-shadow); + transition: background-color var(--tc-transition-fast); + + &:active { + background-color: var(--bs-fov-slider-thumb-active-bg); + } + } + + &:disabled { + pointer-events: none; + + &::-webkit-slider-thumb { + background-color: var(--bs-fov-slider-disabled-color); + border-color: var(--bs-fov-slider-disabled-color); + } + + &::-moz-range-thumb { + background-color: var(--bs-fov-slider-disabled-color); + border-color: var(--bs-fov-slider-disabled-color); + } + } +} + +.tc-fov-slider__value { + // Mono, machine-facing degree readout. + font-family: var(--tc-font-mono); + font-size: var(--bs-fov-slider-value-size); + letter-spacing: 0.06em; + color: var(--bs-fov-slider-value-color); + min-width: var(--bs-fov-slider-value-min-width); + text-align: right; +} + +// State-conveying colour transitions are cheap; the thumb's hover-fill is the +// only motion here, so freeze just that under reduced-motion. +@media (prefers-reduced-motion: reduce) { + .tc-fov-slider__input { + &::-webkit-slider-thumb { + transition: none; + } + + &::-moz-range-thumb { + transition: none; + } + } +} diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 4bcb0bc5..65f75c3a 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -58,6 +58,7 @@ @forward 'range-slider'; @forward 'setting-row'; @forward 'deadzone-slider'; +@forward 'fov-slider'; @forward 'rating'; @forward 'slider'; @forward 'resizable-panel'; diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 24cb93d1..8d4baca2 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -252,6 +252,7 @@ tc-credits-scroll, tc-cycle-wheel, tc-danger-zone-actions, tc-deadzone-slider, +tc-fov-slider, tc-metric-card, tc-slices-card, tc-dashboard-sidebar, From 04c4dd4f84b14822140bd676110ee696f7dc8394 Mon Sep 17 00:00:00 2001 From: kalevski Date: Tue, 16 Jun 2026 09:08:21 +0000 Subject: [PATCH 303/632] 296-fps-cap-select: Build the tc-fps-cap-select web component (FPSCapSelect game-components port) --- examples/public/web-components/SKILL.md | 47 ++++++++ .../src/web-components/FPSCapSelectDemo.tsx | 103 ++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/FPSCapSelect.ts | 114 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + .../style/components/_fps-cap-select.scss | 45 +++++++ web-components/style/components/_index.scss | 1 + web-components/style/foundation/_reset.scss | 1 + 9 files changed, 316 insertions(+) create mode 100644 examples/src/web-components/FPSCapSelectDemo.tsx create mode 100644 web-components/src/FPSCapSelect.ts create mode 100644 web-components/style/components/_fps-cap-select.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index af4c19e5..28674e1a 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -267,6 +267,7 @@ After `register()` you can author markup directly: - [tc-range-slider](#tc-range-slider) - [tc-deadzone-slider](#tc-deadzone-slider) - [tc-fov-slider](#tc-fov-slider) + - [tc-fps-cap-select](#tc-fps-cap-select) - [tc-rating](#tc-rating) - [tc-slider](#tc-slider) - [tc-select](#tc-select) @@ -7452,6 +7453,52 @@ A field-of-view setting row: a label/description text block paired with a range --- +### tc-fps-cap-select + +A preset FPS-cap picker: a label/description text block paired with a native ` of frame-rate +// presets paired with the shared setting-row scaffold. Port of game-components +// `gc-fps-cap-select` with the fantasy chrome dropped for the toolcase +// slate/ink look; the control reuses the design-system `.form-select` chrome. +export class FPSCapSelect extends SettingRowBase { + + private _options: FPSCapOption[] = DEFAULT_OPTIONS.slice() + + // Optional callback mirror of the `tc-change` event (see styleguide §events). + onChange: ((value: string) => void) | null = null + + static get observedAttributes(): string[] { + return [...SettingRowBase.observedAttributes, 'value', 'disabled'] + } + + connectedCallback(): void { + if (!this.hasAttribute('row-label')) this.setAttribute('row-label', 'FPS Cap') + super.connectedCallback() + } + + attributeChangedCallback(name: string, old: string | null, next: string | null): void { + if (!this.isConnected || !this._initialised) return + // Patch value/disabled in place — a full re-render would drop the + // select's focus and any open native dropdown. + if (name === 'value') { + const select = this.querySelector('.tc-fps-cap-select__select') + if (select && select.value !== this.value) select.value = this.value + return + } + if (name === 'disabled') { + const select = this.querySelector('.tc-fps-cap-select__select') + if (select) select.disabled = this.disabled + return + } + super.attributeChangedCallback(name, old, next) + } + + get value(): string { + return this.getAttribute('value') ?? '' + } + set value(v: string) { + this.setAttribute('value', v) + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + get options(): FPSCapOption[] { + return this._options.slice() + } + set options(values: FPSCapOption[]) { + this._options = Array.isArray(values) ? values.slice() : [] + if (this._initialised) this.renderRow() + } + + protected renderControl(): string { + const value = this.value + const disabledAttr = this.disabled ? ' disabled' : '' + const optsMarkup = this._options + .map(opt => { + const selected = opt.value === value ? ' selected' : '' + return `` + }) + .join('') + return ` + + ` + } + + protected bindControl(): void { + const select = this.querySelector('.tc-fps-cap-select__select') + if (!select) return + select.addEventListener('change', () => { + const v = select.value + this.setAttribute('value', v) + this.emit('tc-change', { value: v }) + if (typeof this.onChange === 'function') this.onChange(v) + }) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: FPSCapSelect + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 96e19467..c65e086e 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -267,3 +267,4 @@ export * from './DeadzoneSlider' export * from './DebugOverlay' export * from './DialogueBox' export * from './FOVSlider' +export * from './FPSCapSelect' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 8e6b2252..c707bc54 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -264,6 +264,7 @@ import { DeadzoneSlider } from './DeadzoneSlider' import { DebugOverlay } from './DebugOverlay' import { DialogueBox } from './DialogueBox' import { FOVSlider } from './FOVSlider' +import { FPSCapSelect } from './FPSCapSelect' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -537,4 +538,5 @@ export function register(): void { customElements.define('tc-debug-overlay', DebugOverlay) customElements.define('tc-dialogue-box', DialogueBox) customElements.define('tc-fov-slider', FOVSlider) + customElements.define('tc-fps-cap-select', FPSCapSelect) } diff --git a/web-components/style/components/_fps-cap-select.scss b/web-components/style/components/_fps-cap-select.scss new file mode 100644 index 00000000..e51c3d47 --- /dev/null +++ b/web-components/style/components/_fps-cap-select.scss @@ -0,0 +1,45 @@ +// tc-fps-cap-select — preset FPS-cap picker on the shared setting-row scaffold +// (`_setting-row.scss`). The control is the design-system `.form-select` (flat +// hairline box, inline chevron, sharp corners) constrained to a compact width +// and given a mono, machine-facing readout font for the frame-rate presets. All +// cosmetics flow through `--bs-fps-cap-select-*`; the fantasy chrome of the +// gc-* original is dropped. + +tc-fps-cap-select { + --bs-fps-cap-select-width: 9rem; + --bs-fps-cap-select-font: var(--tc-font-mono); + --bs-fps-cap-select-font-size: 0.75rem; + --bs-fps-cap-select-letter-spacing: 0.04em; + --bs-fps-cap-select-color: var(--tc-text); + --bs-fps-cap-select-bg: var(--tc-surface); + --bs-fps-cap-select-border-color: var(--tc-border-strong); + --bs-fps-cap-select-focus-border-color: rgba(30, 41, 59, 0.5); + --bs-fps-cap-select-focus-ring: 0 0 0 0.2rem rgba(30, 41, 59, 0.12); + --bs-fps-cap-select-disabled-color: var(--tc-text-muted); + --bs-fps-cap-select-disabled-bg: var(--tc-surface-muted); +} + +.tc-fps-cap-select__select { + // Re-skin the shared .form-select via the component's own contract so themes + // can re-paint the FPS picker without touching every select on the page. + --bs-form-select-border-color: var(--bs-fps-cap-select-border-color); + --bs-form-select-focus-border-color: var(--bs-fps-cap-select-focus-border-color); + --bs-form-select-focus-box-shadow: var(--bs-fps-cap-select-focus-ring); + + width: var(--bs-fps-cap-select-width); + font-family: var(--bs-fps-cap-select-font); + font-size: var(--bs-fps-cap-select-font-size); + letter-spacing: var(--bs-fps-cap-select-letter-spacing); + color: var(--bs-fps-cap-select-color); + background-color: var(--bs-fps-cap-select-bg); + + // Coarse pointers get a 44px hit area without resizing the visual control. + @media (pointer: coarse) { + min-height: 44px; + } + + &:disabled { + color: var(--bs-fps-cap-select-disabled-color); + background-color: var(--bs-fps-cap-select-disabled-bg); + } +} diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 65f75c3a..3cdd3364 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -59,6 +59,7 @@ @forward 'setting-row'; @forward 'deadzone-slider'; @forward 'fov-slider'; +@forward 'fps-cap-select'; @forward 'rating'; @forward 'slider'; @forward 'resizable-panel'; diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 8d4baca2..ceb735da 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -253,6 +253,7 @@ tc-cycle-wheel, tc-danger-zone-actions, tc-deadzone-slider, tc-fov-slider, +tc-fps-cap-select, tc-metric-card, tc-slices-card, tc-dashboard-sidebar, From c267ef229479d35ee69755625146ed7022d24508 Mon Sep 17 00:00:00 2001 From: kalevski Date: Tue, 16 Jun 2026 09:14:03 +0000 Subject: [PATCH 304/632] 297-friends-list: Build the tc-friends-list web component (FriendsList game-components port) --- examples/public/web-components/SKILL.md | 102 ++++++++ .../src/web-components/FriendsListDemo.tsx | 114 +++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/FriendsList.ts | 203 ++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + .../style/components/_friends-list.scss | 219 ++++++++++++++++++ web-components/style/components/_index.scss | 1 + web-components/style/foundation/_reset.scss | 1 + 9 files changed, 645 insertions(+) create mode 100644 examples/src/web-components/FriendsListDemo.tsx create mode 100644 web-components/src/FriendsList.ts create mode 100644 web-components/style/components/_friends-list.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 28674e1a..f3faf9a9 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -173,6 +173,7 @@ After `register()` you can author markup directly: - [tc-stat-card](#tc-stat-card) - [tc-state-machine](#tc-state-machine) - [tc-team-list](#tc-team-list) + - [tc-friends-list](#tc-friends-list) - [tc-tier-ladder](#tc-tier-ladder) - [tc-timeline](#tc-timeline) - [tc-usage-summary-panel](#tc-usage-summary-panel) @@ -4235,6 +4236,107 @@ document.querySelector('#tl2').members = [ --- +### tc-friends-list + +Friends roster with online/status pips, activity text, optional rank chips, and message/invite actions. Friends are set via the JS `friends` property. Rows are auto-sorted by status (`in-game` → `online` → `busy` → `away` → `offline`) then alphabetically by name. The header shows the list title and an online/total count. Restyled from the game-components `gc-friends-list` to the toolcase design system: slate neutrals, hairline borders, sharp corners (the status pip is the only sanctioned `border-radius`), status as the only color, and JetBrains Mono for the header micro-labels and rank chips. + +**Tag:** `tc-friends-list` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `list-title` | string | `"Friends"` | Header title. Mirrored by the `listTitle` JS property. | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `friends` | `Friend[]` | Array of friend objects. Set via `el.friends = [...]`. Getter returns a copy; setter triggers a re-render. | +| `listTitle` | `string` | Reflects the `list-title` attribute. | +| `onInvite` | `((detail: { id: string }) => void) \| null` | Optional callback fired alongside the `tc-invite` event. | +| `onMessage` | `((detail: { id: string }) => void) \| null` | Optional callback fired alongside the `tc-message` event. | + +`Friend` shape: + +```ts +type FriendStatus = 'online' | 'away' | 'busy' | 'offline' | 'in-game' + +interface Friend { + id: string + name: string + status?: FriendStatus // defaults to 'offline' + activity?: string // sub-line; falls back to "Offline" only for offline friends + rank?: string // optional mono rank chip +} +``` + +**Events** + +| Event | `detail` | Description | +|-------|----------|-------------| +| `tc-invite` | `{ id: string }` | Fired when a row's invite action is clicked. `bubbles`, `composed`. | +| `tc-message` | `{ id: string }` | Fired when a row's message action is clicked. `bubbles`, `composed`. | + +**Slots** + +None. All content is driven by the `friends` JS property. + +**Accessibility** + +- The inner `
    ` + ) + }).join('') + + this.innerHTML = `
    ${itemsHtml}
    ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: List + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 7f7c999d..42617ba6 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -233,6 +233,7 @@ export * from './InfiniteScroll' export * from './InstallTabs' export * from './InteractPrompt' export * from './KillFeed' +export * from './List' export * from './LiveFeed' export * from './Login' export * from './MarkdownEditor' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 50276eef..30f15a8c 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -231,6 +231,7 @@ import { Lightbox } from './Lightbox' import { LineChart } from './LineChart' import { PieChart } from './PieChart' import { KillFeed } from './KillFeed' +import { List } from './List' import { LiveFeed } from './LiveFeed' import { Login } from './Login' import { MarkdownEditor } from './MarkdownEditor' @@ -526,6 +527,7 @@ export function register(): void { customElements.define('tc-line-chart', LineChart) customElements.define('tc-pie-chart', PieChart) customElements.define('tc-kill-feed', KillFeed) + customElements.define('tc-list', List) customElements.define('tc-live-feed', LiveFeed) customElements.define('tc-login', Login) customElements.define('tc-markdown-editor', MarkdownEditor) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index bc1d8b37..8ee5db5e 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -242,6 +242,7 @@ @forward 'json-schema-def'; @forward 'infinite-scroll'; @forward 'kill-feed'; +@forward 'list'; @forward 'live-feed'; @forward 'login'; @forward 'markdown-editor'; diff --git a/web-components/style/components/_list.scss b/web-components/style/components/_list.scss new file mode 100644 index 00000000..3b63d1b2 --- /dev/null +++ b/web-components/style/components/_list.scss @@ -0,0 +1,161 @@ +// tc-list — generic vertical selectable list with icon / label / meta rows. +// Ported from gc-list (game-components); restyled to the web-components design system. +// Slate neutrals; sharp corners (border-radius: 0); 1px hairline row borders; no fantasy chrome. +// Active/selected state uses --tc-app-accent (ink). All cosmetics flow through --bs-list-* vars. + +tc-list { + --bs-list-bg: var(--tc-surface); + --bs-list-border-color: var(--tc-border); + --bs-list-row-border-color: var(--tc-border); + --bs-list-row-padding-y: 0.625rem; + --bs-list-row-padding-x: 0.875rem; + --bs-list-row-gap: 0.625rem; + --bs-list-min-height: 2.75rem; + + --bs-list-icon-size: 1.25rem; + --bs-list-icon-text-size: 1rem; + --bs-list-icon-color: var(--tc-text-muted); + + --bs-list-label-color: var(--tc-text); + --bs-list-label-font-size: 0.9375rem; + + --bs-list-meta-color: var(--tc-text-faint); + --bs-list-meta-font-size: 0.8125rem; + --bs-list-meta-font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + + --bs-list-selected-bg: var(--tc-surface-muted); + --bs-list-selected-label-color: var(--tc-app-accent); + --bs-list-selected-icon-color: var(--tc-app-accent); + --bs-list-selected-border-color: var(--tc-app-accent); + + --bs-list-disabled-opacity: 0.45; + + --bs-list-focus-outline: 2px solid var(--tc-app-accent); + + display: block; +} + +// ── Container ───────────────────────────────────────────────────────────────── + +.tc-list { + display: flex; + flex-direction: column; + background: var(--bs-list-bg); + border: 1px solid var(--bs-list-border-color); + border-radius: 0; + overflow: hidden; +} + +// ── Row ─────────────────────────────────────────────────────────────────────── + +.tc-list-item { + display: flex; + align-items: center; + gap: var(--bs-list-row-gap); + padding: var(--bs-list-row-padding-y) var(--bs-list-row-padding-x); + min-height: var(--bs-list-min-height); + border-bottom: 1px solid var(--bs-list-row-border-color); + cursor: pointer; + user-select: none; + outline: none; + transition: background 0.12s ease; + + &:last-child { + border-bottom: none; + } + + &:focus-visible { + outline: var(--bs-list-focus-outline); + outline-offset: -2px; + } + + &:hover:not(.tc-list-item--disabled):not(.tc-list-item--selected) { + background: var(--tc-surface-hover, rgba(255, 255, 255, 0.04)); + } +} + +// Coarse pointer: enlarge touch target +@media (pointer: coarse) { + .tc-list-item { + min-height: 44px; + } +} + +// ── Selected state ──────────────────────────────────────────────────────────── + +.tc-list-item--selected { + background: var(--bs-list-selected-bg); + border-left: 3px solid var(--bs-list-selected-border-color); + padding-left: calc(var(--bs-list-row-padding-x) - 3px); + + .tc-list-item-label { + color: var(--bs-list-selected-label-color); + font-weight: 500; + } + + .tc-list-item-icon { + color: var(--bs-list-selected-icon-color); + } +} + +// ── Disabled state ──────────────────────────────────────────────────────────── + +.tc-list-item--disabled { + opacity: var(--bs-list-disabled-opacity); + cursor: not-allowed; + pointer-events: none; +} + +// ── Icon ────────────────────────────────────────────────────────────────────── + +.tc-list-item-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--bs-list-icon-color); + + svg { + width: var(--bs-list-icon-size); + height: var(--bs-list-icon-size); + } + + &.tc-list-item-icon--text { + font-size: var(--bs-list-icon-text-size); + line-height: 1; + } +} + +// ── Body (label + meta) ─────────────────────────────────────────────────────── + +.tc-list-item-body { + display: flex; + flex-direction: column; + gap: 0.125rem; + flex: 1; + min-width: 0; +} + +.tc-list-item-label { + color: var(--bs-list-label-color); + font-size: var(--bs-list-label-font-size); + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tc-list-item-meta { + font-family: var(--bs-list-meta-font-family); + font-size: var(--bs-list-meta-font-size); + color: var(--bs-list-meta-color); + line-height: 1.3; +} + +// ── Reduced motion ──────────────────────────────────────────────────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-list-item { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index e454ad21..d6af2d2d 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -293,6 +293,7 @@ tc-leaderboard, tc-line-chart, tc-pie-chart, tc-kill-feed, +tc-list, tc-live-feed, tc-chat-window, tc-login, From b93c4d6d8dbd2a39e31a0ecefd4e82a33adff719 Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 17 Jun 2026 19:26:42 +0000 Subject: [PATCH 331/632] 325-list-row: Build the tc-list-row web component (ListRow game-components port) --- examples/public/web-components/SKILL.md | 96 +++++++++++++ examples/src/web-components/ListRowDemo.tsx | 86 +++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/ListRow.ts | 108 ++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_list-row.scss | 136 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 433 insertions(+) create mode 100644 examples/src/web-components/ListRowDemo.tsx create mode 100644 web-components/src/ListRow.ts create mode 100644 web-components/style/components/_list-row.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 02dda2e9..f9afa83e 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -223,6 +223,7 @@ After `register()` you can author markup directly: - [tc-install-tabs](#tc-install-tabs) - [tc-kill-feed](#tc-kill-feed) - [tc-list](#tc-list) + - [tc-list-row](#tc-list-row) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) - [tc-live-feed](#tc-live-feed) @@ -20782,3 +20783,98 @@ None. `tc-list` is purely data-driven via the `items` JS property. }) ``` + +--- + +### tc-list-row + +Single selectable list row (leading media, label, trailing value/action). Ported from `gc-list-row` (game-components) and restyled to the toolcase design system. The host is the interactive element: `role="option"`, `tabindex="0"`, `aria-selected`, `aria-disabled`. Activate via click, Enter, or Space — fires `tc-select`. Accent colour is overridable per-row via the `accent` attribute. Light DOM; all content is author-supplied children using the helper classes below. + +**Tag:** `tc-list-row` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `selected` | boolean | absent | Marks the row as selected. Adds `.tc-list-row--selected`, sets `aria-selected="true"`, and applies a 3px left accent border. | +| `disabled` | boolean | absent | Disables interaction. Adds `.tc-list-row--disabled`, sets `aria-disabled="true"` and `tabindex="-1"`. `tc-select` is suppressed. | +| `accent` | string | (token) | Any CSS colour value (e.g. `"var(--tc-success)"`, `"#a855f7"`). Overrides `--bs-list-row-accent` on the host so the selected-state border and label colour use this value instead of the default ink accent. | + +**JS Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `selected` | `boolean` | `false` | Reflects the `selected` attribute. | +| `disabled` | `boolean` | `false` | Reflects the `disabled` attribute. | +| `accent` | `string` | `''` | Reflects the `accent` attribute. | +| `onSelect` | `(() => void) \| null` | `null` | Optional callback fired alongside the `tc-select` CustomEvent. | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-select` | `{}` | Bubbles and is composed. Fired when a non-disabled row is activated by click, Enter, or Space. | + +**Slots** + +`tc-list-row` has no named slots. Author children directly inside the host. Use the helper classes below to achieve consistent layout: + +| Class | Element | Description | +|-------|---------|-------------| +| `.tc-list-row-media` | any inline | Leading icon / avatar. `flex-shrink: 0`; inherits accent colour when selected. | +| `.tc-list-row-label` | `` | Primary text. `flex: 1`; truncates with ellipsis. Inherits accent colour when selected. | +| `.tc-list-row-meta` | `` | Trailing value in JetBrains Mono. `flex-shrink: 0`. | +| `.tc-list-row-body` | `` | Optional stacking wrapper for label + sub-label (`flex-direction: column`). | + +**CSS custom properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-list-row-bg` | `var(--tc-surface)` | Row background colour. | +| `--bs-list-row-border-color` | `var(--tc-border)` | 1px hairline bottom border between rows. | +| `--bs-list-row-padding-y` | `0.625rem` | Vertical padding. | +| `--bs-list-row-padding-x` | `0.875rem` | Horizontal padding. | +| `--bs-list-row-gap` | `0.625rem` | Gap between child regions. | +| `--bs-list-row-min-height` | `2.75rem` | Minimum row height (44px under coarse pointer). | +| `--bs-list-row-accent` | `var(--tc-app-accent)` | Accent colour for the selected state. Overridden inline when `accent` is set. | +| `--bs-list-row-label-color` | `var(--tc-text)` | Label text colour. | +| `--bs-list-row-label-font-size` | `0.9375rem` | Label font size. | +| `--bs-list-row-meta-color` | `var(--tc-text-faint)` | Meta text colour. | +| `--bs-list-row-meta-font-size` | `0.8125rem` | Meta font size. | +| `--bs-list-row-meta-font-family` | `var(--tc-font-mono)` | Meta font family (JetBrains Mono). | +| `--bs-list-row-selected-bg` | `var(--tc-surface-muted)` | Background when selected. | +| `--bs-list-row-disabled-opacity` | `0.45` | Opacity applied to disabled rows. | +| `--bs-list-row-focus-outline` | `2px solid var(--bs-list-row-accent)` | Focus-visible outline. | + +**Example** + +```html +
    + + 📥 + Inbox + 12 + + + + 📤 + Sent + 4 + + + + 🗑️ + Trash + 0 + +
    + + +``` diff --git a/examples/src/web-components/ListRowDemo.tsx b/examples/src/web-components/ListRowDemo.tsx new file mode 100644 index 00000000..717a1770 --- /dev/null +++ b/examples/src/web-components/ListRowDemo.tsx @@ -0,0 +1,86 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const ListRowDemo: React.FC = () => { + const [lastSelected, setLastSelected] = useState('—') + const interactiveRef = useRef(null) + + useEffect(() => { + const container = interactiveRef.current + if (!container) return + const handler = (e: Event) => { + const row = e.target as HTMLElement + setLastSelected(row.querySelector('.tc-list-row-label')?.textContent ?? '?') + } + container.addEventListener('tc-select', handler) + return () => container.removeEventListener('tc-select', handler) + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="List Row" + description="A single selectable list row — leading media, label, trailing value/action — ported from gc-list-row. Selected/disabled state and an optional accent colour are controlled via attributes. Activating a non-disabled row (click, Enter, or Space) fires tc-select." + /> + +
    + + +
    + {/* @ts-ignore */} + 📥Inbox12 + {/* @ts-ignore */} + 📤Sent4 + {/* @ts-ignore */} + 📝Drafts2 + {/* @ts-ignore */} + 🗑️Trash (disabled)0 +
    +
    + + +
    + {/* @ts-ignore */} + Alpha releasev0.1.0 + {/* @ts-ignore */} + Beta releasev0.5.0 + {/* @ts-ignore */} + Stable releasev1.0.0 +
    +
    + + +
    + {/* @ts-ignore */} + Connectedonline + {/* @ts-ignore */} + Error detectedfailed + {/* @ts-ignore */} + Warning threshold78% +
    +
    + + +
    + {/* @ts-ignore */} + Dashboard + {/* @ts-ignore */} + Settings + {/* @ts-ignore */} + Account +
    +
    + +
    +
    +
    +
    +
    + ) +} + +export default ListRowDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 6f9fbc16..a5a7a4dd 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -231,6 +231,7 @@ import InteractPromptDemo from './InteractPromptDemo' import LeaderboardDemo from './LeaderboardDemo' import KillFeedDemo from './KillFeedDemo' import ListDemo from './ListDemo' +import ListRowDemo from './ListRowDemo' import LiveFeedDemo from './LiveFeedDemo' import LoginDemo from './LoginDemo' import MarkdownEditorDemo from './MarkdownEditorDemo' @@ -539,6 +540,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'interact-prompt', category: 'Overlays & Feedback', element: }, { key: 'kill-feed', category: 'Components', element: }, { key: 'list', category: 'Components', element: }, + { key: 'list-row', category: 'Components', element: }, { key: 'live-feed', category: 'Components', element: }, { key: 'login', category: 'Layout', element: }, { key: 'marquee', category: 'Content', element: }, diff --git a/web-components/src/ListRow.ts b/web-components/src/ListRow.ts new file mode 100644 index 00000000..8a08c2c4 --- /dev/null +++ b/web-components/src/ListRow.ts @@ -0,0 +1,108 @@ +const TAG_NAME = 'tc-list-row' + +export class ListRow extends HTMLElement { + private _initialised = false + + /** Optional callback fired alongside the `tc-select` CustomEvent. */ + onSelect: (() => void) | null = null + + static get observedAttributes(): string[] { + return ['selected', 'disabled', 'accent'] + } + + connectedCallback(): void { + if (!this._initialised) { + if (!this.hasAttribute('role')) this.setAttribute('role', 'option') + this._initialised = true + } + this.render() + this.addEventListener('click', this._onClick) + this.addEventListener('keydown', this._onKeydown) + } + + disconnectedCallback(): void { + this.removeEventListener('click', this._onClick) + this.removeEventListener('keydown', this._onKeydown) + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get selected(): boolean { + return this.hasAttribute('selected') + } + set selected(value: boolean) { + if (value) this.setAttribute('selected', '') + else this.removeAttribute('selected') + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(value: boolean) { + if (value) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + get accent(): string { + return this.getAttribute('accent') ?? '' + } + set accent(value: string) { + if (value) this.setAttribute('accent', value) + else this.removeAttribute('accent') + } + + private _onClick = (): void => { + if (this.disabled) return + this._select() + } + + private _onKeydown = (e: KeyboardEvent): void => { + if (e.key !== 'Enter' && e.key !== ' ') return + if (this.disabled) return + e.preventDefault() + this._select() + } + + private _select(): void { + this.dispatchEvent(new CustomEvent('tc-select', { + bubbles: true, + composed: true, + detail: {}, + })) + if (typeof this.onSelect === 'function') this.onSelect() + } + + private render(): void { + const selected = this.selected + const disabled = this.disabled + const accent = this.accent + + this.classList.toggle('tc-list-row--selected', selected) + this.classList.toggle('tc-list-row--disabled', disabled) + + this.setAttribute('aria-selected', selected ? 'true' : 'false') + + if (disabled) { + this.setAttribute('aria-disabled', 'true') + this.setAttribute('tabindex', '-1') + } else { + this.removeAttribute('aria-disabled') + this.setAttribute('tabindex', '0') + } + + if (accent) { + this.style.setProperty('--bs-list-row-accent', accent) + } else { + this.style.removeProperty('--bs-list-row-accent') + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: ListRow + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 42617ba6..b998c153 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -234,6 +234,7 @@ export * from './InstallTabs' export * from './InteractPrompt' export * from './KillFeed' export * from './List' +export * from './ListRow' export * from './LiveFeed' export * from './Login' export * from './MarkdownEditor' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 30f15a8c..82b17e4c 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -232,6 +232,7 @@ import { LineChart } from './LineChart' import { PieChart } from './PieChart' import { KillFeed } from './KillFeed' import { List } from './List' +import { ListRow } from './ListRow' import { LiveFeed } from './LiveFeed' import { Login } from './Login' import { MarkdownEditor } from './MarkdownEditor' @@ -528,6 +529,7 @@ export function register(): void { customElements.define('tc-pie-chart', PieChart) customElements.define('tc-kill-feed', KillFeed) customElements.define('tc-list', List) + customElements.define('tc-list-row', ListRow) customElements.define('tc-live-feed', LiveFeed) customElements.define('tc-login', Login) customElements.define('tc-markdown-editor', MarkdownEditor) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 8ee5db5e..2d24f0c4 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -243,6 +243,7 @@ @forward 'infinite-scroll'; @forward 'kill-feed'; @forward 'list'; +@forward 'list-row'; @forward 'live-feed'; @forward 'login'; @forward 'markdown-editor'; diff --git a/web-components/style/components/_list-row.scss b/web-components/style/components/_list-row.scss new file mode 100644 index 00000000..08933579 --- /dev/null +++ b/web-components/style/components/_list-row.scss @@ -0,0 +1,136 @@ +// tc-list-row — single selectable list row (leading media, label, trailing value/action). +// Ported from gc-list-row (game-components); restyled to the web-components design system. +// Slate neutrals; sharp corners (border-radius: 0); 1px hairline bottom border; no fantasy chrome. +// Selected/active state uses --bs-list-row-accent (defaults to --tc-app-accent). +// All cosmetics flow through --bs-list-row-* vars. + +tc-list-row { + // ── Theme contract ────────────────────────────────────────────────────────── + --bs-list-row-bg: var(--tc-surface); + --bs-list-row-border-color: var(--tc-border); + --bs-list-row-padding-y: 0.625rem; + --bs-list-row-padding-x: 0.875rem; + --bs-list-row-gap: 0.625rem; + --bs-list-row-min-height: 2.75rem; + + // Overridden inline via the `accent` attribute. + --bs-list-row-accent: var(--tc-app-accent); + + --bs-list-row-label-color: var(--tc-text); + --bs-list-row-label-font-size: 0.9375rem; + --bs-list-row-meta-color: var(--tc-text-faint); + --bs-list-row-meta-font-size: 0.8125rem; + --bs-list-row-meta-font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + + --bs-list-row-selected-bg: var(--tc-surface-muted); + --bs-list-row-disabled-opacity: 0.45; + --bs-list-row-focus-outline: 2px solid var(--bs-list-row-accent); + + // ── Layout ────────────────────────────────────────────────────────────────── + display: flex; + align-items: center; + gap: var(--bs-list-row-gap); + padding: var(--bs-list-row-padding-y) var(--bs-list-row-padding-x); + min-height: var(--bs-list-row-min-height); + background: var(--bs-list-row-bg); + border-bottom: 1px solid var(--bs-list-row-border-color); + cursor: pointer; + user-select: none; + outline: none; + transition: background 0.12s ease; + + &:last-child { + border-bottom: none; + } + + &:focus-visible { + outline: var(--bs-list-row-focus-outline); + outline-offset: -2px; + } + + &:hover:not(.tc-list-row--selected):not(.tc-list-row--disabled) { + background: var(--tc-surface-hover, rgba(255, 255, 255, 0.04)); + } +} + +// ── Coarse pointer: 44px touch target ───────────────────────────────────────── + +@media (pointer: coarse) { + tc-list-row { + min-height: 44px; + } +} + +// ── Selected state ──────────────────────────────────────────────────────────── + +tc-list-row.tc-list-row--selected { + background: var(--bs-list-row-selected-bg); + border-left: 3px solid var(--bs-list-row-accent); + padding-left: calc(var(--bs-list-row-padding-x) - 3px); + + .tc-list-row-label { + color: var(--bs-list-row-accent); + font-weight: 500; + } + + .tc-list-row-media { + color: var(--bs-list-row-accent); + } +} + +// ── Disabled state ──────────────────────────────────────────────────────────── + +tc-list-row.tc-list-row--disabled { + opacity: var(--bs-list-row-disabled-opacity); + cursor: not-allowed; + pointer-events: none; +} + +// ── Media region (leading icon / avatar) ────────────────────────────────────── + +.tc-list-row-media { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +// ── Body region (stacked label + sub-label) ─────────────────────────────────── + +.tc-list-row-body { + display: flex; + flex-direction: column; + gap: 0.125rem; + flex: 1; + min-width: 0; +} + +.tc-list-row-label { + flex: 1; + min-width: 0; + color: var(--bs-list-row-label-color); + font-size: var(--bs-list-row-label-font-size); + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// ── Meta / trailing value region ────────────────────────────────────────────── + +.tc-list-row-meta { + font-family: var(--bs-list-row-meta-font-family); + font-size: var(--bs-list-row-meta-font-size); + color: var(--bs-list-row-meta-color); + line-height: 1.3; + flex-shrink: 0; + white-space: nowrap; +} + +// ── Reduced motion ──────────────────────────────────────────────────────────── + +@media (prefers-reduced-motion: reduce) { + tc-list-row { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index d6af2d2d..85bd17f5 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -294,6 +294,7 @@ tc-line-chart, tc-pie-chart, tc-kill-feed, tc-list, +tc-list-row, tc-live-feed, tc-chat-window, tc-login, From 01bf252d699333f98da8d6d26d91ce8447290296 Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 17 Jun 2026 19:36:34 +0000 Subject: [PATCH 332/632] 326-loading-overlay: Build the tc-loading-overlay web component (LoadingOverlay game-components port) --- examples/public/web-components/SKILL.md | 89 +++++++ .../src/web-components/LoadingOverlayDemo.tsx | 126 +++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/LoadingOverlay.ts | 124 +++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_loading-overlay.scss | 252 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 6 + 9 files changed, 603 insertions(+) create mode 100644 examples/src/web-components/LoadingOverlayDemo.tsx create mode 100644 web-components/src/LoadingOverlay.ts create mode 100644 web-components/style/components/_loading-overlay.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index f9afa83e..31b55064 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -258,6 +258,7 @@ After `register()` you can author markup directly: - [tc-invite-toast](#tc-invite-toast) - [tc-letterbox-bars](#tc-letterbox-bars) - [tc-lightbox](#tc-lightbox) + - [tc-loading-overlay](#tc-loading-overlay) - [tc-modal](#tc-modal) - [tc-offcanvas](#tc-offcanvas) - [tc-popover](#tc-popover) @@ -6657,6 +6658,94 @@ Modal image gallery with keyboard/swipe navigation, a thumbnail strip, captions, --- +### tc-loading-overlay + +Full-surface loading overlay with a spinner ring, optional label, determinate or indeterminate progress bar, and an optional mono tip line. Ported from `gc-loading-overlay` and restyled to the toolcase design system: flat slate ink scrim, sharp panel, no game chrome. Visibility is driven by the `[open]` boolean attribute — the consumer sets it; the component never self-closes. No slots; purely attribute-driven. Renders `role="status"` + `aria-live="polite"` on the host and a `role="progressbar"` on the inner bar. + +**Tag:** `tc-loading-overlay` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `open` | boolean | absent | Show the overlay when present; hide it when absent. | +| `progress` | number \| absent | absent | Progress value in the `0–1` range. When absent the bar is indeterminate (animated slide). Set to `0.5` for 50 %. | +| `label` | string | `''` | Descriptive label displayed above the progress bar. When absent and `progress` is also absent no label row is rendered. | +| `tip` | string | `''` | Secondary mono status line below the bar (e.g. a file path or phase name). Hidden when absent. | + +**JS Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `open` | `boolean` | `false` | Reflects the `open` attribute. | +| `progress` | `number \| null` | `null` | Reflects the `progress` attribute. `null` = indeterminate. | +| `label` | `string` | `''` | Reflects the `label` attribute. | +| `tip` | `string` | `''` | Reflects the `tip` attribute. | + +**Events** + +None. `tc-loading-overlay` is a passive overlay — the consumer sets `open` to show and removes it to hide. + +**Slots:** None. Entirely attribute-driven. + +**CSS custom properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-loading-overlay-backdrop-bg` | `rgba(15,23,42,0.65)` | Semi-transparent ink wash behind the panel. | +| `--bs-loading-overlay-panel-bg` | `var(--tc-surface)` | Panel card background. | +| `--bs-loading-overlay-panel-border` | `1px solid var(--tc-border)` | Panel hairline border. | +| `--bs-loading-overlay-panel-shadow` | `var(--tc-shadow-lg)` | Panel elevation shadow (overlay tier). | +| `--bs-loading-overlay-panel-padding` | `2rem` | Panel internal padding. | +| `--bs-loading-overlay-panel-gap` | `1.25rem` | Gap between panel children. | +| `--bs-loading-overlay-panel-width` | `min(340px, calc(100vw - 2rem))` | Panel maximum width. | +| `--bs-loading-overlay-spinner-color` | `var(--tc-app-accent)` | Spinner arc colour. | +| `--bs-loading-overlay-spinner-size` | `36px` | Spinner ring diameter. | +| `--bs-loading-overlay-spinner-width` | `3px` | Spinner ring border width. | +| `--bs-loading-overlay-label-color` | `var(--tc-text)` | Label text colour. | +| `--bs-loading-overlay-label-size` | `0.925rem` | Label font size. | +| `--bs-loading-overlay-pct-color` | `var(--tc-text-muted)` | Percentage value colour. | +| `--bs-loading-overlay-pct-size` | `0.8125rem` | Percentage font size (JetBrains Mono). | +| `--bs-loading-overlay-bar-bg` | `var(--tc-border)` | Progress bar track colour. | +| `--bs-loading-overlay-bar-fill` | `var(--tc-app-accent)` | Progress bar fill colour. | +| `--bs-loading-overlay-bar-height` | `3px` | Progress bar track height. | +| `--bs-loading-overlay-tip-color` | `var(--tc-text-faint)` | Tip line text colour. | +| `--bs-loading-overlay-tip-size` | `0.8125rem` | Tip line font size (JetBrains Mono). | +| `--bs-loading-overlay-z` | `var(--tc-z-modal)` | Stacking layer (fixed `--tc-z-*` scale). | +| `--bs-loading-overlay-fade` | `var(--tc-transition-base)` | Fade-in animation duration. | + +```html + + + + + + + + + + +``` + +--- + ### tc-popover Popover positioned by Popper.js. diff --git a/examples/src/web-components/LoadingOverlayDemo.tsx b/examples/src/web-components/LoadingOverlayDemo.tsx new file mode 100644 index 00000000..c9e83a47 --- /dev/null +++ b/examples/src/web-components/LoadingOverlayDemo.tsx @@ -0,0 +1,126 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const TIPS = [ + 'Compiling shaders…', + 'Loading assets…', + 'Connecting to server…', + 'Initialising scene…', +] + +const LoadingOverlayDemo: React.FC = () => { + // ── Indeterminate (no progress) ─────────────────────────────────────────── + const [openBasic, setOpenBasic] = useState(false) + + // ── Determinate with label + progress ──────────────────────────────────── + const [openDet, setOpenDet] = useState(false) + const [progress, setProgress] = useState(0) + const detRef = useRef(null) + + useEffect(() => { + const el = detRef.current + if (!el) return + el.open = openDet + el.label = 'Loading assets' + el.tip = TIPS[Math.floor(progress * TIPS.length) % TIPS.length] + el.progress = progress + }, [openDet, progress]) + + // Simulate progress ticking forward while the overlay is open + useEffect(() => { + if (!openDet) { + setProgress(0) + return + } + const id = setInterval(() => { + setProgress(p => { + const next = parseFloat((p + 0.05).toFixed(2)) + if (next >= 1) { + setOpenDet(false) + return 0 + } + return next + }) + }, 200) + return () => clearInterval(id) + }, [openDet]) + + // ── Label only (no progress value, no tip) ──────────────────────────────── + const [openLabel, setOpenLabel] = useState(false) + + return ( +
    +
    +
    +
    + Web Components} + title="Loading Overlay" + description="Full-surface loading overlay with a spinner ring, optional label, determinate or indeterminate progress bar, and an optional mono tip line. Ported from gc-loading-overlay and restyled to the toolcase design system — flat slate ink scrim, sharp panel, no game chrome." + /> + +
    + + +

    + When progress is absent the bar animates + indeterminately. Toggle [open] to show or hide. +

    + + {/* @ts-ignore */} + +
    + + +

    + Pass a progress value between 0 and{' '} + 1 for a determinate bar with a percentage readout. + A tip attribute shows a mono status line below the bar. + This demo auto-advances the progress and closes when it reaches 100 %. +

    + + {/* @ts-ignore */} + +
    + + +

    + A label attribute with no progress{' '} + shows the indeterminate bar alongside a descriptive label. + No percentage is displayed since progress is unknown. +

    + + {/* @ts-ignore */} + +
    + +
    +
    +
    +
    +
    + ) +} + +export default LoadingOverlayDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index a5a7a4dd..5711ed36 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -285,6 +285,7 @@ import LegalScreenDemo from './LegalScreenDemo' import LetterboxBarsDemo from './LetterboxBarsDemo' import LevelHeaderDemo from './LevelHeaderDemo' import LevelSelectDemo from './LevelSelectDemo' +import LoadingOverlayDemo from './LoadingOverlayDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -590,4 +591,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'letterbox-bars', category: 'Overlays & Feedback', element: }, { key: 'level-header', category: 'Components', element: }, { key: 'level-select', category: 'Components', element: }, + { key: 'loading-overlay', category: 'Overlays & Feedback', element: }, ] diff --git a/web-components/src/LoadingOverlay.ts b/web-components/src/LoadingOverlay.ts new file mode 100644 index 00000000..e5ecf5f5 --- /dev/null +++ b/web-components/src/LoadingOverlay.ts @@ -0,0 +1,124 @@ +const TAG_NAME = 'tc-loading-overlay' + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +/** + * tc-loading-overlay — full-surface loading overlay with a spinner ring, + * optional label, determinate or indeterminate progress bar, and an optional + * tip line. Ported from `gc-loading-overlay` (game-components) but voiced for + * the web-components design system: flat slate ink scrim, no game chrome, + * Bootstrap-compatible progress conventions. + * + * Visibility is driven by the `[open]` boolean attribute via a CSS attribute + * selector (`tc-loading-overlay:not([open]) { display: none }`). The consumer + * sets `open` to show/hide; the component never self-closes. + * + * No slots — purely attribute-driven; `render()` writes innerHTML on every + * attribute change and skips the slot-capture cycle. + */ +export class LoadingOverlay extends HTMLElement { + + static get observedAttributes(): string[] { + return ['open', 'progress', 'label', 'tip'] + } + + connectedCallback(): void { + if (!this.hasAttribute('role')) this.setAttribute('role', 'status') + this.setAttribute('aria-live', 'polite') + this.render() + } + + attributeChangedCallback(): void { + if (this.isConnected) this.render() + } + + get open(): boolean { + return this.hasAttribute('open') + } + set open(v: boolean) { + if (v) this.setAttribute('open', '') + else this.removeAttribute('open') + } + + get progress(): number | null { + const raw = this.getAttribute('progress') + if (raw == null || raw === '') return null + const parsed = parseFloat(raw) + return Number.isFinite(parsed) ? parsed : null + } + set progress(v: number | null) { + if (v == null) this.removeAttribute('progress') + else this.setAttribute('progress', String(v)) + } + + get label(): string { + return this.getAttribute('label') ?? '' + } + set label(v: string) { + if (v) this.setAttribute('label', v) + else this.removeAttribute('label') + } + + get tip(): string { + return this.getAttribute('tip') ?? '' + } + set tip(v: string) { + if (v) this.setAttribute('tip', v) + else this.removeAttribute('tip') + } + + private render(): void { + const progress = this.progress + const label = this.label + const tip = this.tip + + const indeterminate = progress == null + const pct = indeterminate ? 0 : Math.max(0, Math.min(1, progress)) * 100 + const pctLabel = indeterminate ? '' : `${Math.round(pct)}%` + + // Label row: visible when there is a text label OR a determinate value + const showLabelRow = !!label || !indeterminate + const labelRow = showLabelRow + ? `
    + ${esc(label)} + ${esc(pctLabel)} +
    ` + : '' + + const barMod = indeterminate ? ' tc-loading-overlay-bar--indeterminate' : '' + const fillStyle = indeterminate ? '' : ` style="width:${pct.toFixed(2)}%"` + const ariaValueNow = indeterminate ? '' : ` aria-valuenow="${Math.round(pct)}"` + + const tipMarkup = tip + ? `
    ${esc(tip)}
    ` + : '' + + this.innerHTML = ` + +
    + + ${labelRow} +
    +
    +
    + ${tipMarkup} +
    + ` + } +} + +declare global { + interface HTMLElementTagNameMap { [TAG_NAME]: LoadingOverlay } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index b998c153..f7a8b8be 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -296,3 +296,4 @@ export * from './LegalScreen' export * from './LetterboxBars' export * from './LevelHeader' export * from './LevelSelect' +export * from './LoadingOverlay' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 82b17e4c..b2c3fed3 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -293,6 +293,7 @@ import { LegalScreen } from './LegalScreen' import { LetterboxBars } from './LetterboxBars' import { LevelHeader } from './LevelHeader' import { LevelSelect } from './LevelSelect' +import { LoadingOverlay } from './LoadingOverlay' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -595,4 +596,5 @@ export function register(): void { customElements.define('tc-letterbox-bars', LetterboxBars) customElements.define('tc-level-header', LevelHeader) customElements.define('tc-level-select', LevelSelect) + customElements.define('tc-loading-overlay', LoadingOverlay) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 2d24f0c4..8c5ac8d1 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -289,3 +289,4 @@ @forward 'letterbox-bars'; @forward 'level-header'; @forward 'level-select'; +@forward 'loading-overlay'; diff --git a/web-components/style/components/_loading-overlay.scss b/web-components/style/components/_loading-overlay.scss new file mode 100644 index 00000000..d1cd051d --- /dev/null +++ b/web-components/style/components/_loading-overlay.scss @@ -0,0 +1,252 @@ +// tc-loading-overlay — full-surface loading overlay. Ported from +// `gc-loading-overlay` but voiced for the web-components design system: flat +// slate ink scrim, sharp panel, standard spinner-border ring, no game chrome. +// Visibility is driven by the `[open]` attribute; the CSS hides the host when +// `[open]` is absent. Every cosmetic value flows through --bs-loading-overlay-* +// custom properties (the public theming contract). + +@use '../foundation/tokens' as *; + +// ── Component defaults ────────────────────────────────────────────────────────── + +tc-loading-overlay { + // Backdrop + --bs-loading-overlay-backdrop-bg: rgba(15, 23, 42, 0.65); // slate-900 @ 65% + + // Panel card + --bs-loading-overlay-panel-bg: var(--tc-surface); + --bs-loading-overlay-panel-border: 1px solid var(--tc-border); + --bs-loading-overlay-panel-shadow: var(--tc-shadow-lg); + --bs-loading-overlay-panel-padding: 2rem; + --bs-loading-overlay-panel-gap: 1.25rem; + --bs-loading-overlay-panel-width: min(340px, calc(100vw - 2rem)); + + // Spinner ring + --bs-loading-overlay-spinner-color: var(--tc-app-accent); + --bs-loading-overlay-spinner-size: 36px; + --bs-loading-overlay-spinner-width: 3px; + + // Label + percent + --bs-loading-overlay-label-color: var(--tc-text); + --bs-loading-overlay-label-size: 0.925rem; + --bs-loading-overlay-pct-color: var(--tc-text-muted); + --bs-loading-overlay-pct-size: 0.8125rem; + + // Progress bar track + fill + --bs-loading-overlay-bar-bg: var(--tc-border); + --bs-loading-overlay-bar-fill: var(--tc-app-accent); + --bs-loading-overlay-bar-height: 3px; + + // Tip + --bs-loading-overlay-tip-color: var(--tc-text-faint); + --bs-loading-overlay-tip-size: 0.8125rem; + + // Stacking: sits on the modal tier + --bs-loading-overlay-z: var(--tc-z-modal); + + // Fade-in duration + --bs-loading-overlay-fade: var(--tc-transition-base); +} + +// ── Hidden when [open] is absent ──────────────────────────────────────────────── +// The host defaults to display: block from _reset.scss. Suppress it when not open. + +tc-loading-overlay:not([open]) { + display: none; +} + +// ── Host when open: fixed full-surface flex container ─────────────────────────── +// Positions the host on the fixed overlay tier and centres the panel. + +tc-loading-overlay[open] { + position: fixed; + inset: 0; + z-index: var(--bs-loading-overlay-z); + display: flex; + align-items: center; + justify-content: center; + border-radius: 0; + animation: tc-loading-overlay-fade-in var(--bs-loading-overlay-fade) both; +} + +@keyframes tc-loading-overlay-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +// ── Backdrop: full-fill semi-transparent wash ─────────────────────────────────── +// Positioned absolutely inside the host; pointer-events disabled so the panel +// can receive focus-management clicks without the backdrop intercepting them. + +.tc-loading-overlay-backdrop { + position: absolute; + inset: 0; + background: var(--bs-loading-overlay-backdrop-bg); + border-radius: 0; + pointer-events: none; +} + +// ── Panel: centered content card ──────────────────────────────────────────────── +// Sits above the backdrop (z-index: 1); sharp corners per design mandate; +// overlay-tier shadow (the only place shadows are sanctioned). + +.tc-loading-overlay-panel { + position: relative; + z-index: 1; + width: var(--bs-loading-overlay-panel-width); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--bs-loading-overlay-panel-gap); + padding: var(--bs-loading-overlay-panel-padding); + background: var(--bs-loading-overlay-panel-bg); + border: var(--bs-loading-overlay-panel-border); + box-shadow: var(--bs-loading-overlay-panel-shadow); + border-radius: 0; // sharp by mandate +} + +// ── Spinner ring ───────────────────────────────────────────────────────────────── +// Composed from .spinner-border (defined in _spinner.scss). Override Bootstrap +// spinner defaults to apply component tokens: size, border-width, arc colour. +// The ring circle is a sanctioned use of border-radius: 50%. + +.tc-loading-overlay-spinner { + display: flex; + align-items: center; + justify-content: center; +} + +.tc-loading-overlay-spinner-ring { + // Drive size and border-width via the spinner vars so Bootstrap's own + // width/height/border declarations pick up the overrides. + --bs-spinner-width: var(--bs-loading-overlay-spinner-size); + --bs-spinner-height: var(--bs-loading-overlay-spinner-size); + --bs-spinner-border-width: var(--bs-loading-overlay-spinner-width); + + // Arc colour (used via currentcolor on border-top-color) + color: var(--bs-loading-overlay-spinner-color); + + // Refile the track and restore the arc; cascade order ensures these win + // over .spinner-border since _loading-overlay.scss is forwarded after + // _spinner.scss in _index.scss. + border-color: var(--tc-border-strong); + border-top-color: currentcolor; +} + +// ── Label row ──────────────────────────────────────────────────────────────────── +// Horizontal pair: descriptive label (left) + mono percentage (right). + +.tc-loading-overlay-label-row { + width: 100%; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; +} + +.tc-loading-overlay-label { + font-size: var(--bs-loading-overlay-label-size); + color: var(--bs-loading-overlay-label-color); + font-weight: 500; + flex: 1; + min-width: 0; + // Prevent long labels from overflowing the panel + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// Percentage is machine-facing text — JetBrains Mono, tabular figures. +.tc-loading-overlay-pct { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-loading-overlay-pct-size); + color: var(--bs-loading-overlay-pct-color); + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + +// ── Progress bar ───────────────────────────────────────────────────────────────── +// Thin hairline track (sharp); fill slides to width when determinate. +// Indeterminate variant animates the fill across the track. + +.tc-loading-overlay-bar { + width: 100%; + height: var(--bs-loading-overlay-bar-height); + background: var(--bs-loading-overlay-bar-bg); + border-radius: 0; // sharp by mandate + overflow: hidden; + position: relative; +} + +.tc-loading-overlay-bar-fill { + height: 100%; + background: var(--bs-loading-overlay-bar-fill); + border-radius: 0; + transition: width var(--tc-transition-base); +} + +// Indeterminate: slide a 40% fill segment across the full track width. +.tc-loading-overlay-bar--indeterminate .tc-loading-overlay-bar-fill { + width: 40%; + position: absolute; + top: 0; + bottom: 0; + animation: tc-loading-overlay-bar-slide 1.5s ease-in-out infinite; +} + +@keyframes tc-loading-overlay-bar-slide { + 0% { left: -50%; } + 100% { left: 110%; } +} + +// ── Tip line ───────────────────────────────────────────────────────────────────── +// Machine-facing secondary text (status messages, file paths, etc.) — mono, +// small, faint, centered. + +.tc-loading-overlay-tip { + width: 100%; + font-size: var(--bs-loading-overlay-tip-size); + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + color: var(--bs-loading-overlay-tip-color); + text-align: center; + letter-spacing: 0.025em; + // Prevent long tip strings from overflowing + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// ── Coarse pointer: loosen panel padding ───────────────────────────────────────── + +@media (pointer: coarse) { + .tc-loading-overlay-panel { + padding: 2.25rem 1.5rem; + } +} + +// ── Reduced motion ─────────────────────────────────────────────────────────────── +// Freeze decorative transforms/animations; preserve state-conveying color +// transitions. The spinner is state-conveying (user knows something is +// happening) so we slow it rather than removing it. + +@media (prefers-reduced-motion: reduce) { + tc-loading-overlay[open] { + animation: none; + } + + .tc-loading-overlay-spinner-ring { + // Slow the ring — it is informational, not decorative. + animation-duration: 1.5s; + } + + .tc-loading-overlay-bar-fill { + // Freeze the width transition on the determinate bar. + transition: none; + } + + // Freeze the indeterminate slide; show the fill statically positioned. + .tc-loading-overlay-bar--indeterminate .tc-loading-overlay-bar-fill { + animation: none; + left: 30%; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 85bd17f5..c02fb628 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -382,6 +382,12 @@ tc-letterbox-bars { display: block; } +// Fixed full-surface loading overlay; the SCSS partial hides it until [open] +// and positions it on the modal tier, but the host must not default to inline. +tc-loading-overlay { + display: block; +} + tc-editable-text, tc-chip, tc-button, From f3230b811541e74fbe2b0dfbea7ef12ce0487598 Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 17 Jun 2026 19:45:20 +0000 Subject: [PATCH 333/632] 327-loading-screen: Build the tc-loading-screen web component (LoadingScreen game-components port) --- examples/public/web-components/SKILL.md | 109 +++++++++ .../src/web-components/LoadingScreenDemo.tsx | 191 +++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/LoadingScreen.ts | 219 +++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_loading-screen.scss | 230 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 6 + 9 files changed, 761 insertions(+) create mode 100644 examples/src/web-components/LoadingScreenDemo.tsx create mode 100644 web-components/src/LoadingScreen.ts create mode 100644 web-components/style/components/_loading-screen.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 31b55064..89ed4f25 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -259,6 +259,7 @@ After `register()` you can author markup directly: - [tc-letterbox-bars](#tc-letterbox-bars) - [tc-lightbox](#tc-lightbox) - [tc-loading-overlay](#tc-loading-overlay) + - [tc-loading-screen](#tc-loading-screen) - [tc-modal](#tc-modal) - [tc-offcanvas](#tc-offcanvas) - [tc-popover](#tc-popover) @@ -6746,6 +6747,114 @@ None. `tc-loading-overlay` is a passive overlay — the consumer sets `open` to --- +### tc-loading-screen + +Full-viewport loading screen with an eyebrow label, optional title, progress bar (determinate or indeterminate), and a cycling tip section. Ported from `gc-loading-screen` and voiced for the toolcase design system — slate surface, sharp corners, JetBrains Mono for all machine-facing text. The component covers the viewport when present in the DOM; add or remove it (or toggle `[hidden]`) to show or hide the loading state. No `[open]` attribute is needed. + +**Tag:** `tc-loading-screen` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `eyebrow` | string | `'Loading'` | Mono micro-label above the title. | +| `title-text` | string | — | Human-readable heading shown below the eyebrow. | +| `label` | string | — | Descriptive text shown in the label row alongside the percentage. | +| `progress` | number (0–1) | — | Determinate progress value. Absent → indeterminate animation. | +| `tip-title` | string | `'Tip'` | Mono label above the cycling tip body. | +| `tip-interval` | number (ms) | `5000` | Milliseconds between automatic tip rotations. | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `tips` | `string[]` | Array of tip strings to display and cycle through. Set at least two strings to activate cycling. Setting this resets the tip index and restarts the interval. | +| `progress` | `number \| null` | Reflects the `progress` attribute. | +| `label` | `string` | Reflects the `label` attribute. | +| `eyebrow` | `string` | Reflects the `eyebrow` attribute. | +| `titleText` | `string` | Reflects the `title-text` attribute. | +| `tipTitle` | `string` | Reflects the `tip-title` attribute. | +| `tipInterval` | `number` | Reflects the `tip-interval` attribute. | + +**Events** + +None. `tc-loading-screen` is a passive display element — all state is driven in by the consumer via attributes and the `tips` JS property. + +**Slots** + +None. + +**CSS Custom Properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-loading-screen-bg` | `var(--tc-surface)` | Background colour of the full-screen surface. | +| `--bs-loading-screen-panel-max-width` | `480px` | Maximum width of the centred content column. | +| `--bs-loading-screen-panel-gap` | `1.5rem` | Gap between panel sections. | +| `--bs-loading-screen-eyebrow-color` | `var(--tc-text-muted)` | Eyebrow label colour (mono). | +| `--bs-loading-screen-eyebrow-size` | `10.5px` | Eyebrow label font size. | +| `--bs-loading-screen-title-color` | `var(--tc-text)` | Title text colour. | +| `--bs-loading-screen-title-size` | `1.1875rem` | Title font size. | +| `--bs-loading-screen-title-weight` | `600` | Title font weight. | +| `--bs-loading-screen-label-color` | `var(--tc-text)` | Label text colour. | +| `--bs-loading-screen-label-size` | `0.925rem` | Label font size. | +| `--bs-loading-screen-pct-color` | `var(--tc-text-muted)` | Percentage readout colour. | +| `--bs-loading-screen-pct-size` | `0.8125rem` | Percentage font size (JetBrains Mono). | +| `--bs-loading-screen-bar-bg` | `var(--tc-border)` | Progress bar track colour. | +| `--bs-loading-screen-bar-fill` | `var(--tc-app-accent)` | Progress bar fill colour. | +| `--bs-loading-screen-bar-height` | `3px` | Progress bar track height. | +| `--bs-loading-screen-tip-label-color` | `var(--tc-text-faint)` | Tip section label colour. | +| `--bs-loading-screen-tip-label-size` | `10.5px` | Tip label font size. | +| `--bs-loading-screen-tip-body-color` | `var(--tc-text-muted)` | Tip body text colour. | +| `--bs-loading-screen-tip-body-size` | `0.8125rem` | Tip body font size (JetBrains Mono). | +| `--bs-loading-screen-z` | `var(--tc-z-modal)` | Stacking layer (fixed `--tc-z-*` scale). | +| `--bs-loading-screen-fade` | `var(--tc-transition-base)` | Fade-in animation duration. | + +```html + + + + + + + + + + + + + + + +``` + +--- + ### tc-popover Popover positioned by Popper.js. diff --git a/examples/src/web-components/LoadingScreenDemo.tsx b/examples/src/web-components/LoadingScreenDemo.tsx new file mode 100644 index 00000000..65fa84a8 --- /dev/null +++ b/examples/src/web-components/LoadingScreenDemo.tsx @@ -0,0 +1,191 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const TIPS = [ + 'Compiling shaders…', + 'Loading assets…', + 'Connecting to server…', + 'Initialising scene…', + 'Resolving dependencies…', +] + +const LoadingScreenDemo: React.FC = () => { + // ── Indeterminate (no progress) ─────────────────────────────────────────── + const [showBasic, setShowBasic] = useState(false) + + // ── Determinate with cycling tips ───────────────────────────────────────── + const [showDet, setShowDet] = useState(false) + const [progress, setProgress] = useState(0) + const detRef = useRef(null) + + // Wire the tips JS property once after the element mounts + useEffect(() => { + const el = detRef.current + if (!el || !showDet) return + el.tips = TIPS + el.tipInterval = 1500 + }, [showDet]) + + // Simulate progress advancing and auto-close at 100 % + useEffect(() => { + if (!showDet) { + setProgress(0) + return + } + const id = setInterval(() => { + setProgress(p => { + const next = parseFloat((p + 0.04).toFixed(2)) + if (next >= 1) { + setShowDet(false) + return 0 + } + return next + }) + }, 150) + return () => clearInterval(id) + }, [showDet]) + + // ── Indeterminate with tips only ────────────────────────────────────────── + const [showTips, setShowTips] = useState(false) + const tipsRef = useRef(null) + + useEffect(() => { + const el = tipsRef.current + if (!el || !showTips) return + el.tips = TIPS + el.tipInterval = 2000 + }, [showTips]) + + return ( +
    +
    +
    +
    + Web Components} + title="Loading Screen" + description="Full-viewport loading screen with an eyebrow label, optional title, progress bar (determinate or indeterminate), and a cycling tip section. Ported from gc-loading-screen and voiced for the web-components design system — slate surface, sharp corners, no game chrome." + /> + +
    + + +

    + Without a progress attribute the bar animates + indeterminately. Click to add tc-loading-screen to + the DOM; the component covers the viewport until dismissed. +

    + + + {showBasic && ( + <> + {/* @ts-ignore */} + +
    + +
    + + )} +
    + + +

    + Pass a progress value between 0 and{' '} + 1 for a determinate bar with a percentage readout. + Set the tips JS property to an array of strings for + rotating tip text; tip-interval controls the rotation + speed in milliseconds. This demo auto-advances to 100 % and closes. +

    + + + {showDet && ( + /* @ts-ignore */ + + )} +
    + + +

    + Tips cycle automatically even without a determinate progress value. + Set the tips JS property to an array of strings and + optionally adjust tip-interval. +

    + + + {showTips && ( + <> + {/* @ts-ignore */} + +
    + +
    + + )} +
    + +
    +
    +
    +
    +
    + ) +} + +export default LoadingScreenDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 5711ed36..b52750c6 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -286,6 +286,7 @@ import LetterboxBarsDemo from './LetterboxBarsDemo' import LevelHeaderDemo from './LevelHeaderDemo' import LevelSelectDemo from './LevelSelectDemo' import LoadingOverlayDemo from './LoadingOverlayDemo' +import LoadingScreenDemo from './LoadingScreenDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -592,4 +593,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'level-header', category: 'Components', element: }, { key: 'level-select', category: 'Components', element: }, { key: 'loading-overlay', category: 'Overlays & Feedback', element: }, + { key: 'loading-screen', category: 'Overlays & Feedback', element: }, ] diff --git a/web-components/src/LoadingScreen.ts b/web-components/src/LoadingScreen.ts new file mode 100644 index 00000000..c015f83f --- /dev/null +++ b/web-components/src/LoadingScreen.ts @@ -0,0 +1,219 @@ +const TAG_NAME = 'tc-loading-screen' + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +/** + * tc-loading-screen — full-viewport loading screen with an eyebrow label, + * optional title, progress bar (determinate or indeterminate), and an optional + * cycling tip section. Ported from gc-loading-screen (game-components) but + * voiced for the web-components design system: slate surface, sharp panel, no + * game chrome. The component is always visible when present in the DOM; + * add/remove it (or toggle [hidden]) to show/hide the loading state. + * + * No slots — purely attribute- and JS-property-driven. + */ +export class LoadingScreen extends HTMLElement { + + static get observedAttributes(): string[] { + return ['progress', 'label', 'eyebrow', 'title-text', 'tip-title', 'tip-interval'] + } + + private _tips: string[] = [] + private _tipIndex = 0 + private _intervalId: ReturnType | null = null + private _visibilityHandler: (() => void) | null = null + + connectedCallback(): void { + if (!this.hasAttribute('role')) this.setAttribute('role', 'status') + this.setAttribute('aria-live', 'polite') + this.render() + this._attachHandlers() + } + + disconnectedCallback(): void { + this._detachHandlers() + } + + attributeChangedCallback(name: string): void { + if (!this.isConnected) return + if (name === 'tip-interval') { + this._detachHandlers() + this._attachHandlers() + return + } + this.render() + } + + get progress(): number | null { + const raw = this.getAttribute('progress') + if (raw == null || raw === '') return null + const parsed = parseFloat(raw) + return Number.isFinite(parsed) ? parsed : null + } + set progress(v: number | null) { + if (v == null) this.removeAttribute('progress') + else this.setAttribute('progress', String(v)) + } + + get label(): string { + return this.getAttribute('label') ?? '' + } + set label(v: string) { + if (v) this.setAttribute('label', v) + else this.removeAttribute('label') + } + + get eyebrow(): string { + return this.getAttribute('eyebrow') ?? 'Loading' + } + set eyebrow(v: string) { + if (v) this.setAttribute('eyebrow', v) + else this.removeAttribute('eyebrow') + } + + get titleText(): string { + return this.getAttribute('title-text') ?? '' + } + set titleText(v: string) { + if (v) this.setAttribute('title-text', v) + else this.removeAttribute('title-text') + } + + get tipTitle(): string { + return this.getAttribute('tip-title') ?? 'Tip' + } + set tipTitle(v: string) { + if (v) this.setAttribute('tip-title', v) + else this.removeAttribute('tip-title') + } + + get tipInterval(): number { + const raw = this.getAttribute('tip-interval') + if (raw == null) return 5000 + const parsed = parseFloat(raw) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 5000 + } + set tipInterval(v: number) { + this.setAttribute('tip-interval', String(v)) + } + + get tips(): string[] { + return this._tips.slice() + } + set tips(v: string[]) { + this._tips = Array.isArray(v) ? v.slice() : [] + this._tipIndex = 0 + if (this.isConnected) { + this.render() + this._detachHandlers() + this._attachHandlers() + } + } + + private _clearInterval(): void { + if (this._intervalId != null) { + clearInterval(this._intervalId) + this._intervalId = null + } + } + + private _startInterval(): void { + if (this._tips.length < 2) return + this._intervalId = setInterval(() => { + this._tipIndex = (this._tipIndex + 1) % this._tips.length + this._patchTip() + }, this.tipInterval) + } + + private _attachHandlers(): void { + this._detachHandlers() + this._visibilityHandler = () => { + if (document.hidden) { + this._clearInterval() + } else { + this._startInterval() + } + } + document.addEventListener('visibilitychange', this._visibilityHandler) + if (!document.hidden) { + this._startInterval() + } + } + + private _detachHandlers(): void { + if (this._visibilityHandler) { + document.removeEventListener('visibilitychange', this._visibilityHandler) + this._visibilityHandler = null + } + this._clearInterval() + } + + // Surgical tip-body update — avoids a full re-render on interval tick. + private _patchTip(): void { + const el = this.querySelector('.tc-loading-screen-tip-body') + if (!el) return + el.textContent = this._tips[this._tipIndex] ?? '' + } + + private render(): void { + const eyebrow = this.eyebrow + const title = this.titleText + const label = this.label + const progress = this.progress + const indeterminate = progress == null + const pct = indeterminate ? 0 : Math.max(0, Math.min(1, progress)) * 100 + const pctLabel = indeterminate ? '' : `${Math.round(pct)}%` + + const titleMarkup = title + ? `
    ${esc(title)}
    ` + : '' + + const showLabelRow = !!label || !indeterminate + const labelRow = showLabelRow + ? `
    + ${esc(label)} + ${esc(pctLabel)} +
    ` + : '' + + const barMod = indeterminate ? ' tc-loading-screen-bar--indeterminate' : '' + const fillStyle = indeterminate ? '' : ` style="width:${pct.toFixed(2)}%"` + const ariaValueNow = indeterminate ? '' : ` aria-valuenow="${Math.round(pct)}"` + + const tip = this._tips[this._tipIndex] ?? '' + const tipMarkup = this._tips.length > 0 + ? `
    +
    ${esc(this.tipTitle)}
    +
    ${esc(tip)}
    +
    ` + : '' + + this.innerHTML = ` +
    +
    +
    ${esc(eyebrow)}
    + ${titleMarkup} +
    + ${labelRow} +
    +
    +
    + ${tipMarkup} +
    + ` + } +} + +declare global { + interface HTMLElementTagNameMap { [TAG_NAME]: LoadingScreen } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index f7a8b8be..1a00e657 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -297,3 +297,4 @@ export * from './LetterboxBars' export * from './LevelHeader' export * from './LevelSelect' export * from './LoadingOverlay' +export * from './LoadingScreen' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index b2c3fed3..72d83c4d 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -294,6 +294,7 @@ import { LetterboxBars } from './LetterboxBars' import { LevelHeader } from './LevelHeader' import { LevelSelect } from './LevelSelect' import { LoadingOverlay } from './LoadingOverlay' +import { LoadingScreen } from './LoadingScreen' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -597,4 +598,5 @@ export function register(): void { customElements.define('tc-level-header', LevelHeader) customElements.define('tc-level-select', LevelSelect) customElements.define('tc-loading-overlay', LoadingOverlay) + customElements.define('tc-loading-screen', LoadingScreen) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 8c5ac8d1..e51ab1b6 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -290,3 +290,4 @@ @forward 'level-header'; @forward 'level-select'; @forward 'loading-overlay'; +@forward 'loading-screen'; diff --git a/web-components/style/components/_loading-screen.scss b/web-components/style/components/_loading-screen.scss new file mode 100644 index 00000000..91bf0072 --- /dev/null +++ b/web-components/style/components/_loading-screen.scss @@ -0,0 +1,230 @@ +// tc-loading-screen — full-viewport loading screen with eyebrow/title header, +// progress bar (determinate or indeterminate), and a cycling tip section. +// Ported from gc-loading-screen; game chrome replaced with the slate design +// system: sharp corners, hairline separators, no glows or textures. +// All cosmetics flow through --bs-loading-screen-* custom properties. + +@use '../foundation/tokens' as *; + +// ── Component defaults ─────────────────────────────────────────────────────── + +tc-loading-screen { + // Background of the full-screen surface + --bs-loading-screen-bg: var(--tc-surface); + + // Fade-in animation duration + --bs-loading-screen-fade: var(--tc-transition-base); + + // Z-index — sits at the modal tier to cover page content + --bs-loading-screen-z: var(--tc-z-modal); + + // Panel + --bs-loading-screen-panel-max-width: 480px; + --bs-loading-screen-panel-gap: 1.5rem; + --bs-loading-screen-panel-padding-x: 2rem; + --bs-loading-screen-panel-padding-y: 2.5rem; + + // Eyebrow (mono micro-label above the title) + --bs-loading-screen-eyebrow-color: var(--tc-text-muted); + --bs-loading-screen-eyebrow-size: 10.5px; + + // Title + --bs-loading-screen-title-color: var(--tc-text); + --bs-loading-screen-title-size: 1.1875rem; + --bs-loading-screen-title-weight: 600; + + // Label + percentage + --bs-loading-screen-label-color: var(--tc-text); + --bs-loading-screen-label-size: 0.925rem; + --bs-loading-screen-pct-color: var(--tc-text-muted); + --bs-loading-screen-pct-size: 0.8125rem; + + // Progress bar track + fill + --bs-loading-screen-bar-bg: var(--tc-border); + --bs-loading-screen-bar-fill: var(--tc-app-accent); + --bs-loading-screen-bar-height: 3px; + + // Tip section + --bs-loading-screen-tip-label-color: var(--tc-text-faint); + --bs-loading-screen-tip-label-size: 10.5px; + --bs-loading-screen-tip-body-color: var(--tc-text-muted); + --bs-loading-screen-tip-body-size: 0.8125rem; +} + +// ── Host: fixed full-viewport shell ───────────────────────────────────────── +// The host covers the entire viewport, centres the panel, and fades in. +// The display: block from _reset.scss is overridden here; no [open] attribute +// is needed — the consumer adds/removes the element or sets [hidden]. + +tc-loading-screen { + position: fixed; + inset: 0; + z-index: var(--bs-loading-screen-z); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--bs-loading-screen-bg); + border-radius: 0; + animation: tc-loading-screen-fade-in var(--bs-loading-screen-fade) both; +} + +@keyframes tc-loading-screen-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +// ── Panel: centred content column ─────────────────────────────────────────── +// No card chrome — the loading screen IS the page, not a floating card. + +.tc-loading-screen-panel { + width: 100%; + max-width: var(--bs-loading-screen-panel-max-width); + display: flex; + flex-direction: column; + gap: var(--bs-loading-screen-panel-gap); + padding: var(--bs-loading-screen-panel-padding-y) var(--bs-loading-screen-panel-padding-x); +} + +// ── Header: eyebrow + title ────────────────────────────────────────────────── + +.tc-loading-screen-header { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +// Eyebrow — mono micro-label, uppercase, per design system convention +.tc-loading-screen-eyebrow { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-loading-screen-eyebrow-size); + color: var(--bs-loading-screen-eyebrow-color); + text-transform: uppercase; + letter-spacing: 0.08em; + line-height: 1.4; +} + +// Title — human-readable heading (Inter) +.tc-loading-screen-title { + font-size: var(--bs-loading-screen-title-size); + font-weight: var(--bs-loading-screen-title-weight); + color: var(--bs-loading-screen-title-color); + line-height: 1.3; +} + +// ── Label row: description + mono percentage ────────────────────────────────── + +.tc-loading-screen-label-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; +} + +.tc-loading-screen-label { + font-size: var(--bs-loading-screen-label-size); + color: var(--bs-loading-screen-label-color); + font-weight: 500; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// Percentage is machine-facing — JetBrains Mono, tabular figures +.tc-loading-screen-pct { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-loading-screen-pct-size); + color: var(--bs-loading-screen-pct-color); + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + +// ── Progress bar ────────────────────────────────────────────────────────────── +// Thin hairline track (sharp per mandate); fill slides to width when +// determinate. Indeterminate variant slides a 40% fill across the track. + +.tc-loading-screen-bar { + width: 100%; + height: var(--bs-loading-screen-bar-height); + background: var(--bs-loading-screen-bar-bg); + border-radius: 0; // sharp by mandate + overflow: hidden; + position: relative; +} + +.tc-loading-screen-bar-fill { + height: 100%; + background: var(--bs-loading-screen-bar-fill); + border-radius: 0; + transition: width var(--tc-transition-base); +} + +.tc-loading-screen-bar--indeterminate .tc-loading-screen-bar-fill { + width: 40%; + position: absolute; + top: 0; + bottom: 0; + animation: tc-loading-screen-bar-slide 1.5s ease-in-out infinite; +} + +@keyframes tc-loading-screen-bar-slide { + 0% { left: -50%; } + 100% { left: 110%; } +} + +// ── Tip section ─────────────────────────────────────────────────────────────── +// Mono label + rotating tip body; separated from the bar by a hairline. + +.tc-loading-screen-tip { + padding-top: var(--bs-loading-screen-panel-gap); + border-top: 1px solid var(--tc-border); + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.tc-loading-screen-tip-label { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-loading-screen-tip-label-size); + color: var(--bs-loading-screen-tip-label-color); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.tc-loading-screen-tip-body { + font-size: var(--bs-loading-screen-tip-body-size); + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + color: var(--bs-loading-screen-tip-body-color); + letter-spacing: 0.02em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// ── Coarse pointer ──────────────────────────────────────────────────────────── + +@media (pointer: coarse) { + .tc-loading-screen-panel { + padding-left: 1.5rem; + padding-right: 1.5rem; + } +} + +// ── Reduced motion ──────────────────────────────────────────────────────────── + +@media (prefers-reduced-motion: reduce) { + tc-loading-screen { + animation: none; + } + + .tc-loading-screen-bar-fill { + transition: none; + } + + .tc-loading-screen-bar--indeterminate .tc-loading-screen-bar-fill { + animation: none; + left: 30%; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index c02fb628..ed7d24bf 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -388,6 +388,12 @@ tc-loading-overlay { display: block; } +// Full-viewport loading screen; the SCSS partial overrides display to flex and +// positions it fixed, but the host must not default to inline. +tc-loading-screen { + display: block; +} + tc-editable-text, tc-chip, tc-button, From 70d4f49de60944862cb39753cc5b5e1105c625df Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 17 Jun 2026 19:57:10 +0000 Subject: [PATCH 334/632] 328-lobby: Build the tc-lobby web component (Lobby game-components port) --- examples/public/web-components/SKILL.md | 113 ++++++++ examples/src/web-components/LobbyDemo.tsx | 130 +++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Lobby.ts | 208 ++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + web-components/style/components/_lobby.scss | 298 ++++++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 756 insertions(+) create mode 100644 examples/src/web-components/LobbyDemo.tsx create mode 100644 web-components/src/Lobby.ts create mode 100644 web-components/style/components/_lobby.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 89ed4f25..4e623589 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -224,6 +224,7 @@ After `register()` you can author markup directly: - [tc-kill-feed](#tc-kill-feed) - [tc-list](#tc-list) - [tc-list-row](#tc-list-row) + - [tc-lobby](#tc-lobby) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) - [tc-live-feed](#tc-live-feed) @@ -21076,3 +21077,115 @@ Single selectable list row (leading media, label, trailing value/action). Ported }) ``` + +--- + +### tc-lobby + +Multiplayer lobby panel showing player slots, ready state, and start controls. Port of `gc-lobby` (game-components), restyled to the toolcase design system: slate neutrals, hairline borders, sharp corners, JetBrains Mono for machine-facing text, ink accent for primary actions. Players are set via the `players` JS property. The Start Match button is shown only when a player with `host: true` is in the list and is enabled only when `can-start` is present. All cosmetics flow through `--bs-lobby-*` custom properties. + +**Tag:** `tc-lobby` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `capacity` | number | `8` | Maximum number of player slots to render. | +| `lobby-mode` | string | `''` | Mode label shown in the meta strip and header (e.g. `"Team Deathmatch"`). Omit to hide the Mode cell. | +| `map-name` | string | `''` | Map label shown in the meta strip (e.g. `"Dust II"`). Omit to hide the Map cell. | +| `is-ready` | boolean | absent | Marks the local player as ready. Toggles the Ready Up button to "Unready" and applies the active (ink-fill) state. | +| `can-start` | boolean | absent | Enables the Start Match button. Has no effect when no host player is present in `players`. | + +**JS Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `capacity` | `number` | `8` | Reflects the `capacity` attribute. | +| `lobbyMode` | `string` | `''` | Reflects the `lobby-mode` attribute. | +| `mapName` | `string` | `''` | Reflects the `map-name` attribute. | +| `isReady` | `boolean` | `false` | Reflects the `is-ready` attribute. | +| `canStart` | `boolean` | `false` | Reflects the `can-start` attribute. | +| `players` | `LobbyPlayer[]` | `[]` | Array of player objects. Setting this re-renders the slot grid. Accepts at most `capacity` items; extras are silently ignored. | +| `onLeave` | `(() => void) \| null` | `null` | Optional callback fired alongside `tc-leave`. | +| `onReady` | `(() => void) \| null` | `null` | Optional callback fired alongside `tc-ready`. | +| `onStart` | `(() => void) \| null` | `null` | Optional callback fired alongside `tc-start`. | + +**`LobbyPlayer` shape** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | `string` | yes | Unique player identifier. Stamped as `data-id` on the slot element. | +| `name` | `string` | yes | Display name rendered in the slot. | +| `ready` | `boolean` | no | When `true` the slot gains `.tc-lobby-slot--ready` and shows "Ready"; otherwise shows "Waiting". | +| `host` | `boolean` | no | When `true` a "Host" badge is shown in the slot and the Start Match button appears in the actions bar. | +| `rank` | `string` | no | Optional rank chip rendered in JetBrains Mono (e.g. `"Diamond"`). | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-leave` | `{}` | Fired when the Leave button is clicked. Bubbles and is composed. | +| `tc-ready` | `{}` | Fired when the Ready Up / Unready button is clicked. Bubbles and is composed. | +| `tc-start` | `{}` | Fired when the Start Match button is clicked (only visible for the host, enabled only when `can-start` is set). Bubbles and is composed. | + +**Slots** + +`tc-lobby` has no named slots. All content is driven by attributes and JS properties. + +**CSS custom properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-lobby-bg` | `var(--tc-surface)` | Panel background. | +| `--bs-lobby-border` | `var(--tc-border)` | Outer 1px hairline border. | +| `--bs-lobby-header-bg` | `var(--tc-surface-muted)` | Header section background. | +| `--bs-lobby-header-border` | `var(--tc-border)` | Header bottom border. | +| `--bs-lobby-eyebrow-color` | `var(--tc-text-muted)` | "Lobby" eyebrow label colour. | +| `--bs-lobby-title-color` | `var(--tc-text)` | Mode/Match title colour. | +| `--bs-lobby-meta-bg` | `var(--tc-surface)` | Meta strip background. | +| `--bs-lobby-meta-border` | `var(--tc-border)` | Meta strip cell dividers. | +| `--bs-lobby-meta-key-color` | `var(--tc-text-faint)` | Meta key label colour (Mode, Map, Players). | +| `--bs-lobby-meta-val-color` | `var(--tc-text)` | Meta value colour. | +| `--bs-lobby-slot-bg` | `var(--tc-surface)` | Default slot background. | +| `--bs-lobby-slot-border` | `var(--tc-border)` | Slot grid hairline borders. | +| `--bs-lobby-slot-empty-color` | `var(--tc-text-faint)` | "Open Slot" placeholder text colour. | +| `--bs-lobby-slot-filled-bg` | `var(--tc-surface)` | Background for filled (occupied) slots. | +| `--bs-lobby-slot-ready-bg` | `var(--tc-surface-muted)` | Background tint for ready slots. | +| `--bs-lobby-player-name-color` | `var(--tc-text)` | Player name text colour. | +| `--bs-lobby-rank-bg` | `var(--tc-surface-muted)` | Rank chip background. | +| `--bs-lobby-rank-color` | `var(--tc-text-muted)` | Rank chip text colour. | +| `--bs-lobby-host-badge-color` | `var(--tc-app-accent)` | Host badge text colour. | +| `--bs-lobby-ready-color` | `var(--tc-success)` | "Ready" badge colour. | +| `--bs-lobby-waiting-color` | `var(--tc-text-faint)` | "Waiting" badge colour. | +| `--bs-lobby-actions-bg` | `var(--tc-surface-muted)` | Actions bar background. | +| `--bs-lobby-btn-bg` | `var(--tc-surface)` | Default button background. | +| `--bs-lobby-btn-color` | `var(--tc-text)` | Default button text colour. | +| `--bs-lobby-btn-border` | `var(--tc-border)` | Default button border colour. | +| `--bs-lobby-btn-hover-bg` | `var(--tc-surface-hover, var(--tc-surface-muted))` | Button hover background. | +| `--bs-lobby-btn-active-bg` | `var(--tc-app-accent)` | Ready-active button fill (ink). | +| `--bs-lobby-btn-active-color` | `#fff` | Ready-active button text. | +| `--bs-lobby-btn-start-bg` | `var(--tc-app-accent)` | Start button fill. | +| `--bs-lobby-btn-start-color` | `#fff` | Start button text. | +| `--bs-lobby-btn-disabled-opacity` | `0.45` | Opacity for the disabled Start button. | +| `--bs-lobby-btn-min-height` | `2.25rem` | Button minimum height (44 px under coarse pointer). | + +**Example** + +```html + + + +``` diff --git a/examples/src/web-components/LobbyDemo.tsx b/examples/src/web-components/LobbyDemo.tsx new file mode 100644 index 00000000..1fe800a5 --- /dev/null +++ b/examples/src/web-components/LobbyDemo.tsx @@ -0,0 +1,130 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const LobbyDemo: React.FC = () => { + const basicRef = useRef(null) + const hostRef = useRef(null) + const eventsRef = useRef(null) + const rankedRef = useRef(null) + + const [log, setLog] = useState([]) + + useEffect(() => { + if (!basicRef.current) return + basicRef.current.players = [ + { id: '1', name: 'Aria', ready: true }, + { id: '2', name: 'Kestrel', ready: false }, + { id: '3', name: 'Vesper', ready: true }, + ] + }, []) + + useEffect(() => { + if (!hostRef.current) return + hostRef.current.players = [ + { id: '1', name: 'Aria', ready: true, host: true }, + { id: '2', name: 'Kestrel', ready: true }, + { id: '3', name: 'Vesper', ready: true }, + { id: '4', name: 'Onyx', ready: false }, + ] + }, []) + + useEffect(() => { + if (!rankedRef.current) return + rankedRef.current.players = [ + { id: '1', name: 'Aria', ready: true, host: true, rank: 'Diamond' }, + { id: '2', name: 'Kestrel', ready: true, rank: 'Platinum' }, + { id: '3', name: 'Vesper', ready: false, rank: 'Gold' }, + { id: '4', name: 'Onyx', ready: false, rank: 'Silver' }, + ] + }, []) + + useEffect(() => { + const el = eventsRef.current + if (!el) return + el.players = [ + { id: '1', name: 'Aria', ready: false, host: true }, + { id: '2', name: 'Kestrel', ready: false }, + ] + const onLeave = () => setLog(l => ['tc-leave fired', ...l].slice(0, 6)) + const onReady = () => setLog(l => ['tc-ready fired', ...l].slice(0, 6)) + const onStart = () => setLog(l => ['tc-start fired', ...l].slice(0, 6)) + el.addEventListener('tc-leave', onLeave) + el.addEventListener('tc-ready', onReady) + el.addEventListener('tc-start', onStart) + return () => { + el.removeEventListener('tc-leave', onLeave) + el.removeEventListener('tc-ready', onReady) + el.removeEventListener('tc-start', onStart) + } + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="Lobby" + description="Multiplayer lobby panel with player slots, ready state, and start controls. Players are set via the JS players property. The host player's slot controls the Start Match button; can-start enables it." + /> + +
    + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + +
    + Event log + {log.length === 0 ? ( + Click Leave, Ready Up, or Start Match… + ) : ( +
      + {log.map((line, i) => ( +
    • {line}
    • + ))} +
    + )} +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default LobbyDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index b52750c6..07cb3941 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -232,6 +232,7 @@ import LeaderboardDemo from './LeaderboardDemo' import KillFeedDemo from './KillFeedDemo' import ListDemo from './ListDemo' import ListRowDemo from './ListRowDemo' +import LobbyDemo from './LobbyDemo' import LiveFeedDemo from './LiveFeedDemo' import LoginDemo from './LoginDemo' import MarkdownEditorDemo from './MarkdownEditorDemo' @@ -543,6 +544,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'kill-feed', category: 'Components', element: }, { key: 'list', category: 'Components', element: }, { key: 'list-row', category: 'Components', element: }, + { key: 'lobby', category: 'Components', element: }, { key: 'live-feed', category: 'Components', element: }, { key: 'login', category: 'Layout', element: }, { key: 'marquee', category: 'Content', element: }, diff --git a/web-components/src/Lobby.ts b/web-components/src/Lobby.ts new file mode 100644 index 00000000..56c75bcd --- /dev/null +++ b/web-components/src/Lobby.ts @@ -0,0 +1,208 @@ +const TAG_NAME = 'tc-lobby' + +export interface LobbyPlayer { + id: string + name: string + ready?: boolean + host?: boolean + rank?: string +} + +export interface LobbyEventMap { + 'tc-leave': CustomEvent> + 'tc-ready': CustomEvent> + 'tc-start': CustomEvent> +} + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +export class Lobby extends HTMLElement { + + private _initialised = false + private _players: LobbyPlayer[] = [] + + onLeave: (() => void) | null = null + onReady: (() => void) | null = null + onStart: (() => void) | null = null + + static get observedAttributes(): string[] { + return ['capacity', 'lobby-mode', 'map-name', 'is-ready', 'can-start'] + } + + connectedCallback(): void { + if (!this._initialised) { + if (!this.hasAttribute('role')) this.setAttribute('role', 'group') + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get capacity(): number { + const raw = this.getAttribute('capacity') + if (raw == null) return 8 + const parsed = parseInt(raw, 10) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 8 + } + set capacity(v: number) { + this.setAttribute('capacity', String(v)) + } + + get lobbyMode(): string { + return this.getAttribute('lobby-mode') ?? '' + } + set lobbyMode(v: string) { + if (v) this.setAttribute('lobby-mode', v) + else this.removeAttribute('lobby-mode') + } + + get mapName(): string { + return this.getAttribute('map-name') ?? '' + } + set mapName(v: string) { + if (v) this.setAttribute('map-name', v) + else this.removeAttribute('map-name') + } + + get isReady(): boolean { + return this.hasAttribute('is-ready') + } + set isReady(v: boolean) { + if (v) this.setAttribute('is-ready', '') + else this.removeAttribute('is-ready') + } + + get canStart(): boolean { + return this.hasAttribute('can-start') + } + set canStart(v: boolean) { + if (v) this.setAttribute('can-start', '') + else this.removeAttribute('can-start') + } + + get players(): LobbyPlayer[] { + return this._players.slice() + } + set players(value: LobbyPlayer[]) { + this._players = Array.isArray(value) ? value.slice() : [] + if (this._initialised) this.render() + } + + private _emit(name: 'tc-leave' | 'tc-ready' | 'tc-start'): void { + this.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail: {} })) + if (name === 'tc-leave' && typeof this.onLeave === 'function') this.onLeave() + else if (name === 'tc-ready' && typeof this.onReady === 'function') this.onReady() + else if (name === 'tc-start' && typeof this.onStart === 'function') this.onStart() + } + + private _renderSlot(i: number, players: LobbyPlayer[]): string { + const p = players[i] + if (!p) { + return `
    + Open Slot +
    ` + } + + const cls = [ + 'tc-lobby-slot', + 'tc-lobby-slot--filled', + p.ready ? 'tc-lobby-slot--ready' : '', + ].filter(Boolean).join(' ') + + const rankHtml = p.rank + ? `${esc(p.rank)}` + : '' + const hostHtml = p.host + ? `Host` + : '' + const readyHtml = p.ready + ? `Ready` + : `Waiting` + + return `
    + ${esc(p.name)} + ${rankHtml}${hostHtml} + ${readyHtml} +
    ` + } + + private render(): void { + const capacity = this.capacity + const players = this._players.slice(0, capacity) + const isHost = players.some(p => p.host) + const filled = players.length + + const slots = Array.from({ length: capacity }, (_, i) => this._renderSlot(i, players)).join('') + + const metaCells: string[] = [] + if (this.lobbyMode) { + metaCells.push(`
    + Mode + ${esc(this.lobbyMode)} +
    `) + } + if (this.mapName) { + metaCells.push(`
    + Map + ${esc(this.mapName)} +
    `) + } + metaCells.push(`
    + Players + ${filled}/${capacity} +
    `) + + const readyBtnLabel = this.isReady ? 'Unready' : 'Ready Up' + const readyBtnCls = this.isReady + ? 'tc-lobby-btn tc-lobby-btn--ready tc-lobby-btn--active' + : 'tc-lobby-btn tc-lobby-btn--ready' + const startBtnHtml = isHost + ? `` + : '' + + this.innerHTML = ` +
    +
    + Lobby + ${esc(this.lobbyMode || 'Match')} +
    +
    ${metaCells.join('')}
    +
    ${slots}
    +
    + + + ${startBtnHtml} +
    +
    + ` + + // Delegate clicks on the fresh actions bar — old container + its + // listener are garbage-collected together so no leak occurs. + const actions = this.querySelector('.tc-lobby-actions') + actions?.addEventListener('click', (e: MouseEvent) => { + const btn = (e.target as HTMLElement).closest('[data-action]') + if (!btn || btn.disabled) return + const action = btn.dataset.action + if (action === 'leave') this._emit('tc-leave') + else if (action === 'ready') this._emit('tc-ready') + else if (action === 'start') this._emit('tc-start') + }) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Lobby + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 1a00e657..7d027352 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -298,3 +298,4 @@ export * from './LevelHeader' export * from './LevelSelect' export * from './LoadingOverlay' export * from './LoadingScreen' +export * from './Lobby' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 72d83c4d..e4fa35b9 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -295,6 +295,7 @@ import { LevelHeader } from './LevelHeader' import { LevelSelect } from './LevelSelect' import { LoadingOverlay } from './LoadingOverlay' import { LoadingScreen } from './LoadingScreen' +import { Lobby } from './Lobby' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -599,4 +600,5 @@ export function register(): void { customElements.define('tc-level-select', LevelSelect) customElements.define('tc-loading-overlay', LoadingOverlay) customElements.define('tc-loading-screen', LoadingScreen) + customElements.define('tc-lobby', Lobby) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index e51ab1b6..224e8622 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -244,6 +244,7 @@ @forward 'kill-feed'; @forward 'list'; @forward 'list-row'; +@forward 'lobby'; @forward 'live-feed'; @forward 'login'; @forward 'markdown-editor'; diff --git a/web-components/style/components/_lobby.scss b/web-components/style/components/_lobby.scss new file mode 100644 index 00000000..c7f25d06 --- /dev/null +++ b/web-components/style/components/_lobby.scss @@ -0,0 +1,298 @@ +// tc-lobby — multiplayer lobby panel with player slots, ready state, and +// start controls. Port of gc-lobby (game-components), restyled to the toolcase +// design system: slate neutrals, hairline borders, sharp corners (no radius +// anywhere), JetBrains Mono for all machine-facing text, ink accent for +// primary actions. All cosmetics flow through --bs-lobby-* custom properties. + +@use '../foundation/tokens' as *; + +tc-lobby { + --bs-lobby-bg: var(--tc-surface); + --bs-lobby-border: var(--tc-border); + --bs-lobby-header-bg: var(--tc-surface-muted); + --bs-lobby-header-border: var(--tc-border); + --bs-lobby-eyebrow-color: var(--tc-text-muted); + --bs-lobby-title-color: var(--tc-text); + --bs-lobby-meta-bg: var(--tc-surface); + --bs-lobby-meta-border: var(--tc-border); + --bs-lobby-meta-key-color: var(--tc-text-faint); + --bs-lobby-meta-val-color: var(--tc-text); + --bs-lobby-slot-bg: var(--tc-surface); + --bs-lobby-slot-border: var(--tc-border); + --bs-lobby-slot-empty-color: var(--tc-text-faint); + --bs-lobby-slot-filled-bg: var(--tc-surface); + --bs-lobby-slot-ready-bg: var(--tc-surface-muted); + --bs-lobby-player-name-color: var(--tc-text); + --bs-lobby-rank-bg: var(--tc-surface-muted); + --bs-lobby-rank-color: var(--tc-text-muted); + --bs-lobby-host-badge-color: var(--tc-app-accent); + --bs-lobby-ready-color: var(--tc-success); + --bs-lobby-waiting-color: var(--tc-text-faint); + --bs-lobby-actions-bg: var(--tc-surface-muted); + --bs-lobby-btn-color: var(--tc-text); + --bs-lobby-btn-bg: var(--tc-surface); + --bs-lobby-btn-border: var(--tc-border); + --bs-lobby-btn-hover-bg: var(--tc-surface-hover, var(--tc-surface-muted)); + --bs-lobby-btn-hover-color: var(--tc-text); + --bs-lobby-btn-active-bg: var(--tc-app-accent); + --bs-lobby-btn-active-color: #fff; + --bs-lobby-btn-start-bg: var(--tc-app-accent); + --bs-lobby-btn-start-color: #fff; + --bs-lobby-btn-disabled-opacity: 0.45; + --bs-lobby-btn-min-height: 2.25rem; +} + +.tc-lobby { + background: var(--bs-lobby-bg); + border: 1px solid var(--bs-lobby-border); + border-radius: 0; + overflow: hidden; +} + +// ── Header ────────────────────────────────────────────────────────────────── + +.tc-lobby-header { + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 0.625rem 0.875rem; + background: var(--bs-lobby-header-bg); + border-bottom: 1px solid var(--bs-lobby-header-border); +} + +.tc-lobby-eyebrow { + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--bs-lobby-eyebrow-color); +} + +.tc-lobby-title { + font-size: 1rem; + font-weight: 600; + line-height: 1.25; + color: var(--bs-lobby-title-color); +} + +// ── Meta strip ────────────────────────────────────────────────────────────── + +.tc-lobby-meta { + display: flex; + background: var(--bs-lobby-meta-bg); + border-bottom: 1px solid var(--bs-lobby-meta-border); +} + +.tc-lobby-meta-cell { + display: flex; + flex-direction: column; + gap: 0.0625rem; + padding: 0.375rem 0.875rem; + border-right: 1px solid var(--bs-lobby-meta-border); + + &:last-child { + border-right: none; + } +} + +.tc-lobby-meta-key { + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.5625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--bs-lobby-meta-key-color); +} + +.tc-lobby-meta-val { + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.8125rem; + font-weight: 500; + color: var(--bs-lobby-meta-val-color); +} + +// ── Player slots ──────────────────────────────────────────────────────────── + +.tc-lobby-slots { + display: grid; + grid-template-columns: repeat(2, 1fr); +} + +.tc-lobby-slot { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.875rem; + min-height: 2.75rem; + border-right: 1px solid var(--bs-lobby-slot-border); + border-bottom: 1px solid var(--bs-lobby-slot-border); + background: var(--bs-lobby-slot-bg); + + // Remove right border from the rightmost column + &:nth-child(2n) { + border-right: none; + } + + &.tc-lobby-slot--empty { + justify-content: center; + } + + &.tc-lobby-slot--filled { + background: var(--bs-lobby-slot-filled-bg); + } + + &.tc-lobby-slot--ready { + background: var(--bs-lobby-slot-ready-bg); + } +} + +.tc-lobby-empty-label { + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--bs-lobby-slot-empty-color); +} + +.tc-lobby-player-name { + flex: 1; + min-width: 0; + font-size: 0.875rem; + font-weight: 500; + color: var(--bs-lobby-player-name-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tc-lobby-player-meta { + display: flex; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; +} + +.tc-lobby-rank { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.375rem; + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.625rem; + font-weight: 500; + letter-spacing: 0.025em; + background: var(--bs-lobby-rank-bg); + color: var(--bs-lobby-rank-color); + border-radius: 0; + white-space: nowrap; +} + +.tc-lobby-host-badge { + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--bs-lobby-host-badge-color); +} + +.tc-lobby-ready-badge { + flex-shrink: 0; + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + + &.tc-lobby-ready-badge--ready { + color: var(--bs-lobby-ready-color); + } + + &.tc-lobby-ready-badge--waiting { + color: var(--bs-lobby-waiting-color); + } +} + +// ── Actions ───────────────────────────────────────────────────────────────── +// No border-top here — the last row of slots provides the visual separator +// via its border-bottom, avoiding a doubled hairline. + +.tc-lobby-actions { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 0.875rem; + background: var(--bs-lobby-actions-bg); +} + +.tc-lobby-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 0.875rem; + min-height: var(--bs-lobby-btn-min-height); + border: 1px solid var(--bs-lobby-btn-border); + border-radius: 0; + background: var(--bs-lobby-btn-bg); + color: var(--bs-lobby-btn-color); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: + background-color var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease), + opacity var(--tc-transition-fast, 0.15s ease); + + &:hover:not(:disabled) { + background: var(--bs-lobby-btn-hover-bg); + color: var(--bs-lobby-btn-hover-color); + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + } + + &:disabled { + opacity: var(--bs-lobby-btn-disabled-opacity); + cursor: not-allowed; + } + + // Active (is-ready = true) state — ink fill + &.tc-lobby-btn--active { + background: var(--bs-lobby-btn-active-bg); + color: var(--bs-lobby-btn-active-color); + border-color: var(--bs-lobby-btn-active-bg); + + &:hover:not(:disabled) { + opacity: 0.88; + } + } + + // Start button — pushed to the trailing edge; always uses ink fill + &.tc-lobby-btn--start { + margin-left: auto; + background: var(--bs-lobby-btn-start-bg); + color: var(--bs-lobby-btn-start-color); + border-color: var(--bs-lobby-btn-start-bg); + font-weight: 600; + + &:hover:not(:disabled) { + opacity: 0.88; + } + } +} + +// 44 px coarse touch targets +@media (pointer: coarse) { + .tc-lobby-btn { + --bs-lobby-btn-min-height: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + .tc-lobby-btn { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index ed7d24bf..865c125b 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -332,6 +332,7 @@ tc-guild-panel, tc-legal-screen, tc-level-header, tc-level-select, +tc-lobby, tc-roadmap { display: block; } From b5b5129d87c217798b129fd30695d507004ad7a4 Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 17 Jun 2026 20:04:51 +0000 Subject: [PATCH 335/632] 329-loot-list: Build the tc-loot-list web component (LootList game-components port) --- examples/public/web-components/SKILL.md | 102 ++++++++ examples/src/web-components/LootListDemo.tsx | 100 ++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/LootList.ts | 133 ++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_loot-list.scss | 238 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 580 insertions(+) create mode 100644 examples/src/web-components/LootListDemo.tsx create mode 100644 web-components/src/LootList.ts create mode 100644 web-components/style/components/_loot-list.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 4e623589..75da067b 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -225,6 +225,7 @@ After `register()` you can author markup directly: - [tc-list](#tc-list) - [tc-list-row](#tc-list-row) - [tc-lobby](#tc-lobby) + - [tc-loot-list](#tc-loot-list) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) - [tc-live-feed](#tc-live-feed) @@ -21188,4 +21189,105 @@ Multiplayer lobby panel showing player slots, ready state, and start controls. P lobby.addEventListener('tc-ready', () => lobby.toggleAttribute('is-ready')) lobby.addEventListener('tc-start', () => console.log('match starting')) + +--- + +### tc-loot-list + +A list of loot / drop entries with optional rarity tiers. Items are set via the JS `items` property. Rarity is communicated by a 3 px left-edge accent per row. The Take All button fires `tc-take-all`; individual Take buttons fire `tc-take` with the item id. No shadow root; light DOM; `display: block`. + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `list-title` | `string` | `"Loot"` | Heading shown in the panel header. | + +**JS Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `items` | `LootEntry[]` | `[]` | Array of loot entries to render. Setting this property re-renders the list. | +| `onTake` | `((id: string) => void) \| null` | `null` | Optional callback — called in addition to the `tc-take` event. | +| `onTakeAll` | `(() => void) \| null` | `null` | Optional callback — called in addition to the `tc-take-all` event. | + +**`LootEntry` shape** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `item` | `LootItem` | yes | The item being dropped. | +| `qty` | `number` | no | Override quantity displayed for this drop (falls back to `item.qty`, then 1). | + +**`LootItem` shape** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | `string` | yes | Unique item identifier. Stamped as `data-id` on the row element. | +| `name` | `string` | no | Display name (falls back to `id`). | +| `icon` | `string` | no | Single character or emoji used as the icon (defaults to `◆`). | +| `rarity` | `LootItemRarity` | no | One of `common \| uncommon \| rare \| epic \| legendary \| mythic`. Sets the row's left-stripe accent colour. | +| `qty` | `number` | no | Default quantity for this item. | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-take` | `{ id: string }` | Fired when the Take button for an individual row is clicked. Bubbles and is composed. | +| `tc-take-all` | `{}` | Fired when the Take All button is clicked (disabled when the list is empty). Bubbles and is composed. | + +**Slots** + +`tc-loot-list` has no named slots. All content is driven by the `items` JS property. + +**CSS custom properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-loot-list-bg` | `var(--tc-surface)` | Panel background. | +| `--bs-loot-list-border` | `var(--tc-border)` | Outer 1 px hairline border. | +| `--bs-loot-list-header-bg` | `var(--tc-surface-muted)` | Header section background. | +| `--bs-loot-list-header-border` | `var(--tc-border)` | Header bottom border. | +| `--bs-loot-list-title-color` | `var(--tc-text-muted)` | Eyebrow title colour. | +| `--bs-loot-list-title-font-size` | `0.6875rem` | Eyebrow title font size. | +| `--bs-loot-list-row-border` | `1px solid var(--tc-slate-100)` | Row separator hairline. | +| `--bs-loot-list-row-hover-bg` | `var(--tc-surface-hover)` | Row hover background. | +| `--bs-loot-list-icon-font-size` | `0.875rem` | Icon cell font size. | +| `--bs-loot-list-icon-color` | `var(--tc-text-faint)` | Icon cell colour. | +| `--bs-loot-list-name-font-size` | `0.8125rem` | Item name font size. | +| `--bs-loot-list-name-color` | `var(--tc-text)` | Item name colour. | +| `--bs-loot-list-qty-font-size` | `0.75rem` | Quantity suffix font size. | +| `--bs-loot-list-qty-color` | `var(--tc-text-muted)` | Quantity suffix colour. | +| `--bs-loot-list-rarity-common` | `var(--tc-text-faint)` | Left-stripe colour for `common`. | +| `--bs-loot-list-rarity-uncommon` | `var(--tc-success)` | Left-stripe colour for `uncommon`. | +| `--bs-loot-list-rarity-rare` | `var(--tc-info)` | Left-stripe colour for `rare`. | +| `--bs-loot-list-rarity-epic` | `#a855f7` | Left-stripe colour for `epic`. | +| `--bs-loot-list-rarity-legendary` | `var(--tc-warning)` | Left-stripe colour for `legendary`. | +| `--bs-loot-list-rarity-mythic` | `var(--tc-danger)` | Left-stripe colour for `mythic`. | +| `--bs-loot-list-btn-font-size` | `0.75rem` | Button font size. | +| `--bs-loot-list-btn-min-height` | `1.75rem` | Button minimum height (44 px under coarse pointer). | +| `--bs-loot-list-btn-bg` | `var(--tc-surface)` | Button background. | +| `--bs-loot-list-btn-color` | `var(--tc-text)` | Button text colour. | +| `--bs-loot-list-btn-border` | `var(--tc-border-strong)` | Button border. | +| `--bs-loot-list-btn-hover-bg` | `var(--tc-app-accent)` | Button hover background (ink). | +| `--bs-loot-list-btn-hover-color` | `#fff` | Button hover text colour. | +| `--bs-loot-list-btn-disabled-opacity` | `0.45` | Opacity for disabled Take All. | +| `--bs-loot-list-empty-color` | `var(--tc-text-faint)` | Empty-state text colour. | + +**Example** + +```html + + + +``` ``` diff --git a/examples/src/web-components/LootListDemo.tsx b/examples/src/web-components/LootListDemo.tsx new file mode 100644 index 00000000..d4bb4a1f --- /dev/null +++ b/examples/src/web-components/LootListDemo.tsx @@ -0,0 +1,100 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const LootListDemo: React.FC = () => { + const basicRef = useRef(null) + const rarityRef = useRef(null) + const eventsRef = useRef(null) + + const [log, setLog] = useState([]) + + useEffect(() => { + if (!basicRef.current) return + basicRef.current.items = [ + { item: { id: 'gold-coin', name: 'Gold Coin', icon: '◎', qty: 3 } }, + { item: { id: 'health-potion', name: 'Health Potion', icon: '⊕' } }, + { item: { id: 'leather-armor', name: 'Leather Armor', icon: '◈' }, qty: 1 }, + ] + }, []) + + useEffect(() => { + if (!rarityRef.current) return + rarityRef.current.items = [ + { item: { id: 'pebble', name: 'Pebble', icon: '○', rarity: 'common' } }, + { item: { id: 'iron-sword', name: 'Iron Sword', icon: '◆', rarity: 'uncommon' } }, + { item: { id: 'frost-staff', name: 'Frost Staff', icon: '✦', rarity: 'rare' } }, + { item: { id: 'shadow-blade', name: 'Shadow Blade', icon: '◇', rarity: 'epic' } }, + { item: { id: 'dawn-lance', name: 'Dawn Lance', icon: '★', rarity: 'legendary' } }, + { item: { id: 'void-shard', name: 'Void Shard', icon: '⬡', rarity: 'mythic' } }, + ] + }, []) + + useEffect(() => { + const el = eventsRef.current + if (!el) return + el.items = [ + { item: { id: 'scroll', name: 'Ancient Scroll', icon: '◉', rarity: 'rare' }, qty: 2 }, + { item: { id: 'gem', name: 'Sapphire Gem', icon: '◈', rarity: 'epic' } }, + ] + const onTake = (e: CustomEvent) => setLog(l => [`tc-take — id: "${e.detail.id}"`, ...l].slice(0, 8)) + const onTakeAll = () => setLog(l => ['tc-take-all fired', ...l].slice(0, 8)) + el.addEventListener('tc-take', onTake as EventListener) + el.addEventListener('tc-take-all', onTakeAll) + return () => { + el.removeEventListener('tc-take', onTake as EventListener) + el.removeEventListener('tc-take-all', onTakeAll) + } + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="LootList" + description="A list of loot / drop entries with rarity tiers. Items are set via the JS items property. Each item fires tc-take; the Take All button fires tc-take-all." + /> + +
    + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + +
    + Event log + {log.length === 0 ? ( + Click Take or Take All… + ) : ( +
      + {log.map((line, i) => ( +
    • {line}
    • + ))} +
    + )} +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default LootListDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 07cb3941..fea9e387 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -233,6 +233,7 @@ import KillFeedDemo from './KillFeedDemo' import ListDemo from './ListDemo' import ListRowDemo from './ListRowDemo' import LobbyDemo from './LobbyDemo' +import LootListDemo from './LootListDemo' import LiveFeedDemo from './LiveFeedDemo' import LoginDemo from './LoginDemo' import MarkdownEditorDemo from './MarkdownEditorDemo' @@ -545,6 +546,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'list', category: 'Components', element: }, { key: 'list-row', category: 'Components', element: }, { key: 'lobby', category: 'Components', element: }, + { key: 'loot-list', category: 'Components', element: }, { key: 'live-feed', category: 'Components', element: }, { key: 'login', category: 'Layout', element: }, { key: 'marquee', category: 'Content', element: }, diff --git a/web-components/src/LootList.ts b/web-components/src/LootList.ts new file mode 100644 index 00000000..dfca7a50 --- /dev/null +++ b/web-components/src/LootList.ts @@ -0,0 +1,133 @@ +const TAG_NAME = 'tc-loot-list' + +export type LootItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary' | 'mythic' +const RARITIES: LootItemRarity[] = ['common', 'uncommon', 'rare', 'epic', 'legendary', 'mythic'] + +export interface LootItem { + id: string + name?: string + icon?: string + rarity?: LootItemRarity + qty?: number +} + +export interface LootEntry { + item: LootItem + qty?: number +} + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +export class LootList extends HTMLElement { + private _initialised = false + private _items: LootEntry[] = [] + + onTake: ((id: string) => void) | null = null + onTakeAll: (() => void) | null = null + + static get observedAttributes(): string[] { + return ['list-title'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get listTitle(): string { + return this.getAttribute('list-title') ?? 'Loot' + } + set listTitle(v: string) { + if (v) this.setAttribute('list-title', v) + else this.removeAttribute('list-title') + } + + get items(): LootEntry[] { + return this._items.slice() + } + set items(value: LootEntry[]) { + this._items = Array.isArray(value) ? value.slice() : [] + if (this._initialised) this.render() + } + + private render(): void { + const rowsHtml = this._items.map(entry => { + const icon = entry.item.icon ?? '◆' + const name = entry.item.name ?? entry.item.id + const rarity = entry.item.rarity ?? '' + const qty = entry.qty ?? entry.item.qty ?? 1 + const qtyHtml = qty > 1 + ? `×${qty}` + : '' + const rarityCls = RARITIES.includes(rarity as LootItemRarity) + ? ` tc-loot-list-row--${rarity}` + : '' + return `
    + + ${esc(name)} + ${qtyHtml} + +
    ` + }).join('') + + const emptyHtml = this._items.length === 0 + ? `

    No loot available

    ` + : '' + + const allDisabled = this._items.length === 0 ? ' disabled' : '' + + this.innerHTML = `
    +
    + ${esc(this.listTitle)} + +
    +
    ${rowsHtml}${emptyHtml}
    +
    ` + + const container = this.querySelector('.tc-loot-list') + if (container) { + container.addEventListener('click', (e: Event) => { + const btn = (e.target as Element).closest('[data-action]') + if (!btn || btn.disabled) return + const action = btn.dataset.action + if (action === 'take') { + const row = btn.closest('.tc-loot-list-row') + if (!row) return + const id = row.dataset.id ?? '' + this.dispatchEvent(new CustomEvent('tc-take', { + bubbles: true, + composed: true, + detail: { id }, + })) + if (typeof this.onTake === 'function') this.onTake(id) + } else if (action === 'take-all') { + this.dispatchEvent(new CustomEvent('tc-take-all', { + bubbles: true, + composed: true, + detail: {}, + })) + if (typeof this.onTakeAll === 'function') this.onTakeAll() + } + }) + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: LootList + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 7d027352..a4718ec8 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -299,3 +299,4 @@ export * from './LevelSelect' export * from './LoadingOverlay' export * from './LoadingScreen' export * from './Lobby' +export * from './LootList' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index e4fa35b9..d7425e78 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -296,6 +296,7 @@ import { LevelSelect } from './LevelSelect' import { LoadingOverlay } from './LoadingOverlay' import { LoadingScreen } from './LoadingScreen' import { Lobby } from './Lobby' +import { LootList } from './LootList' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -601,4 +602,5 @@ export function register(): void { customElements.define('tc-loading-overlay', LoadingOverlay) customElements.define('tc-loading-screen', LoadingScreen) customElements.define('tc-lobby', Lobby) + customElements.define('tc-loot-list', LootList) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 224e8622..720ec9d6 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -292,3 +292,4 @@ @forward 'level-select'; @forward 'loading-overlay'; @forward 'loading-screen'; +@forward 'loot-list'; diff --git a/web-components/style/components/_loot-list.scss b/web-components/style/components/_loot-list.scss new file mode 100644 index 00000000..0fc3d899 --- /dev/null +++ b/web-components/style/components/_loot-list.scss @@ -0,0 +1,238 @@ +// tc-loot-list — loot / drop entry list with per-item rarity accents. +// Slate neutrals; JetBrains Mono for names and quantities; sharp corners; 1px hairlines. +// Rarity is communicated by a 3px left-edge accent per row, matching the Alert family motif. +// All cosmetics flow through --bs-loot-list-* custom properties. + +tc-loot-list { + // Panel + --bs-loot-list-bg: var(--tc-surface); + --bs-loot-list-border: 1px solid var(--tc-border); + + // Header + --bs-loot-list-header-bg: var(--tc-surface-muted); + --bs-loot-list-header-border: 1px solid var(--tc-border); + --bs-loot-list-title-color: var(--tc-text-muted); + --bs-loot-list-title-font-size: 0.6875rem; + + // Rows + --bs-loot-list-row-border: 1px solid var(--tc-slate-100, #f1f5f9); + --bs-loot-list-row-hover-bg: var(--tc-surface-hover, var(--tc-surface-muted)); + + // Item parts + --bs-loot-list-icon-font-size: 0.875rem; + --bs-loot-list-icon-color: var(--tc-text-faint); + --bs-loot-list-name-font-size: 0.8125rem; + --bs-loot-list-name-color: var(--tc-text); + --bs-loot-list-qty-font-size: 0.75rem; + --bs-loot-list-qty-color: var(--tc-text-muted); + + // Rarity left-stripe tokens — override to retheme + --bs-loot-list-rarity-common: var(--tc-text-faint, #94a3b8); + --bs-loot-list-rarity-uncommon: var(--tc-success, #22c55e); + --bs-loot-list-rarity-rare: var(--tc-info, #38bdf8); + --bs-loot-list-rarity-epic: #a855f7; + --bs-loot-list-rarity-legendary: var(--tc-warning, #f59e0b); + --bs-loot-list-rarity-mythic: var(--tc-danger, #ef4444); + + // Buttons + --bs-loot-list-btn-font-size: 0.75rem; + --bs-loot-list-btn-min-height: 1.75rem; + --bs-loot-list-btn-bg: var(--tc-surface); + --bs-loot-list-btn-color: var(--tc-text); + --bs-loot-list-btn-border: 1px solid var(--tc-border-strong); + --bs-loot-list-btn-hover-bg: var(--tc-app-accent); + --bs-loot-list-btn-hover-color: #fff; + --bs-loot-list-btn-disabled-opacity: 0.45; + + // Empty state + --bs-loot-list-empty-color: var(--tc-text-faint); +} + +// ── Panel shell ──────────────────────────────────────────────────────────────── + +.tc-loot-list { + display: flex; + flex-direction: column; + background: var(--bs-loot-list-bg); + border: var(--bs-loot-list-border); + border-radius: 0; + overflow: hidden; +} + +// ── Header ───────────────────────────────────────────────────────────────────── + +.tc-loot-list-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--bs-loot-list-header-bg); + border-bottom: var(--bs-loot-list-header-border); +} + +.tc-loot-list-title { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-loot-list-title-font-size); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bs-loot-list-title-color); + line-height: 1.4; +} + +// ── Rows container ───────────────────────────────────────────────────────────── + +.tc-loot-list-rows { + display: flex; + flex-direction: column; +} + +// ── Individual row ───────────────────────────────────────────────────────────── + +.tc-loot-list-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.75rem; + border-bottom: var(--bs-loot-list-row-border); + border-left: 3px solid transparent; + min-height: 36px; + background: transparent; + transition: background var(--tc-transition-fast, 0.15s ease); + + &:last-child { + border-bottom: none; + } + + &:hover { + background: var(--bs-loot-list-row-hover-bg); + } +} + +// Rarity left-stripe accents — information, not decoration +.tc-loot-list-row--common { border-left-color: var(--bs-loot-list-rarity-common); } +.tc-loot-list-row--uncommon { border-left-color: var(--bs-loot-list-rarity-uncommon); } +.tc-loot-list-row--rare { border-left-color: var(--bs-loot-list-rarity-rare); } +.tc-loot-list-row--epic { border-left-color: var(--bs-loot-list-rarity-epic); } +.tc-loot-list-row--legendary { border-left-color: var(--bs-loot-list-rarity-legendary); } +.tc-loot-list-row--mythic { border-left-color: var(--bs-loot-list-rarity-mythic); } + +// ── Row parts ────────────────────────────────────────────────────────────────── + +.tc-loot-list-icon { + font-size: var(--bs-loot-list-icon-font-size); + color: var(--bs-loot-list-icon-color); + flex-shrink: 0; + line-height: 1; + width: 1.25rem; + text-align: center; +} + +.tc-loot-list-name { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-loot-list-name-font-size); + font-weight: 500; + color: var(--bs-loot-list-name-color); + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.4; +} + +.tc-loot-list-qty { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-loot-list-qty-font-size); + color: var(--bs-loot-list-qty-color); + flex-shrink: 0; + line-height: 1.4; +} + +// ── Buttons ──────────────────────────────────────────────────────────────────── + +.tc-loot-list-btn { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0 0.625rem; + min-height: var(--bs-loot-list-btn-min-height); + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-loot-list-btn-font-size); + font-weight: 500; + letter-spacing: 0.025em; + color: var(--bs-loot-list-btn-color); + background: var(--bs-loot-list-btn-bg); + border: var(--bs-loot-list-btn-border); + border-radius: 0; + cursor: pointer; + transition: + background var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease), + transform var(--tc-transition-fast, 0.15s ease); + white-space: nowrap; + line-height: 1; + + &:hover:not(:disabled) { + background: var(--bs-loot-list-btn-hover-bg); + color: var(--bs-loot-list-btn-hover-color); + transform: translateY(-1px); + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + } + + &:disabled { + opacity: var(--bs-loot-list-btn-disabled-opacity); + pointer-events: none; + cursor: default; + } +} + +// Take All sits in the header at the right edge +.tc-loot-list-take-all { + margin-left: auto; +} + +// ── Empty state ──────────────────────────────────────────────────────────────── + +.tc-loot-list-empty { + padding: 1rem 0.75rem; + font-size: 0.8125rem; + color: var(--bs-loot-list-empty-color); + text-align: center; + margin: 0; +} + +// ── 44 px coarse touch targets ───────────────────────────────────────────────── + +@media (pointer: coarse) { + .tc-loot-list-row { + min-height: 44px; + } + + .tc-loot-list-btn { + min-height: 44px; + } +} + +// ── Reduced motion ───────────────────────────────────────────────────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-loot-list-row, + .tc-loot-list-btn { + // Freeze decorative transforms; keep color/background state transitions + transition: + background var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease); + } + + .tc-loot-list-btn:hover:not(:disabled) { + transform: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 865c125b..1e4354ca 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -333,6 +333,7 @@ tc-legal-screen, tc-level-header, tc-level-select, tc-lobby, +tc-loot-list, tc-roadmap { display: block; } From bbc9fa337605d3f321948e643042e1ef1867e6a1 Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 17 Jun 2026 20:17:09 +0000 Subject: [PATCH 336/632] 330-loot-popup: Build the tc-loot-popup web component (LootPopup game-components port) --- examples/public/web-components/SKILL.md | 101 +++++ examples/src/web-components/LootPopupDemo.tsx | 163 ++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/LootPopup.ts | 392 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_loot-popup.scss | 277 +++++++++++++ web-components/style/foundation/_reset.scss | 6 + 9 files changed, 945 insertions(+) create mode 100644 examples/src/web-components/LootPopupDemo.tsx create mode 100644 web-components/src/LootPopup.ts create mode 100644 web-components/style/components/_loot-popup.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 75da067b..37c265c7 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -226,6 +226,7 @@ After `register()` you can author markup directly: - [tc-list-row](#tc-list-row) - [tc-lobby](#tc-lobby) - [tc-loot-list](#tc-loot-list) + - [tc-loot-popup](#tc-loot-popup) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) - [tc-live-feed](#tc-live-feed) @@ -21291,3 +21292,103 @@ A list of loot / drop entries with optional rarity tiers. Items are set via the ``` ``` + +--- + +### tc-loot-popup + +Modal loot window with Take All / Discard and optional auto-fade timer. Port of `gc-loot-popup` (game-components), restyled to the slate design system (sharp corners, 1px hairline, overlay-tier shadow). Controlled component — fires `tc-close`; the consumer sets `open` to `false` to actually dismiss. Focus trap, scroll lock, and keyboard (`Escape`) handling included. No shadow root; light DOM; `display: block`. + +**Tag:** `tc-loot-popup` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `open` | boolean | false | Visible state — add/remove to show/hide the popup | +| `popup-title` | string | `"Loot"` | Panel heading text | +| `eyebrow` | string | `""` | Mono uppercase micro-label shown above the title; omit or set empty to hide | +| `discard-label` | string | `"Discard"` | Label for the Discard button | +| `auto-fade-ms` | number | `0` | When > 0, automatically fires `tc-close` after this many milliseconds; reset on each `tc-take` | + +**JS Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `open` | boolean | false | Reflects the `open` attribute | +| `popupTitle` | string | `"Loot"` | Reflects `popup-title` | +| `eyebrow` | string | `""` | Reflects `eyebrow` | +| `discardLabel` | string | `"Discard"` | Reflects `discard-label` | +| `autoFadeMs` | number | `0` | Reflects `auto-fade-ms`; set to 0 or non-finite to disable | +| `items` | `LootEntry[]` | `[]` | Loot entries forwarded to the inner `tc-loot-list`; setting re-renders and resets the auto-fade timer | +| `onTake` | `((id: string) => void) \| null` | `null` | Callback fired alongside `tc-take` | +| `onTakeAll` | `(() => void) \| null` | `null` | Callback fired alongside `tc-take-all` | +| `onDiscard` | `(() => void) \| null` | `null` | Callback fired alongside `tc-discard` | +| `onClose` | `(() => void) \| null` | `null` | Callback fired alongside `tc-close` | + +**`LootEntry` / `LootItem` shapes** — same as `tc-loot-list` (see above). + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-take` | `{ id: string }` | Fired when the Take button for an individual item is clicked. Host does **not** self-close. | +| `tc-take-all` | `{}` | Fired when the Take All button (panel actions area or list header) is clicked. Host does **not** self-close. | +| `tc-discard` | `{}` | Fired when the Discard button is clicked. Host does **not** self-close. | +| `tc-close` | `{}` | Fired on Escape key, backdrop click, or auto-fade expiry. Host does **not** self-close — set `open=false` in the handler. | + +**Slots:** None — content is supplied through attributes and the `items` JS property. + +**CSS custom properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-loot-popup-bg` | `var(--tc-surface)` | Panel background. | +| `--bs-loot-popup-border-color` | `var(--tc-border)` | Panel border colour. | +| `--bs-loot-popup-shadow` | `var(--tc-shadow-lg)` | Panel box-shadow (overlay tier). | +| `--bs-loot-popup-width` | `420px` | Default panel width. | +| `--bs-loot-popup-max-width` | `calc(100vw - 2rem)` | Maximum panel width (viewport responsive). | +| `--bs-loot-popup-max-height` | `calc(100vh - 4rem)` | Maximum panel height. | +| `--bs-loot-popup-padding` | `1.25rem` | Header and actions horizontal/vertical padding. | +| `--bs-loot-popup-gap` | `0.75rem` | Gap between action buttons. | +| `--bs-loot-popup-eyebrow-color` | `var(--tc-text-muted)` | Eyebrow micro-label colour. | +| `--bs-loot-popup-eyebrow-size` | `0.6875rem` | Eyebrow font size. | +| `--bs-loot-popup-title-color` | `var(--tc-text)` | Heading colour. | +| `--bs-loot-popup-title-size` | `1rem` | Heading font size. | +| `--bs-loot-popup-z-backdrop` | `var(--tc-z-modal-backdrop)` | Backdrop z-index. | +| `--bs-loot-popup-z-panel` | `var(--tc-z-modal)` | Panel z-index. | +| `--bs-loot-popup-backdrop-bg` | `#0f172a` | Backdrop scrim colour. | +| `--bs-loot-popup-backdrop-opacity` | `0.5` | Backdrop opacity (open state). | +| `--bs-loot-popup-btn-font-size` | `0.8125rem` | Action button font size. | +| `--bs-loot-popup-btn-min-height` | `2.25rem` | Action button minimum height (44 px under coarse pointer). | +| `--bs-loot-popup-btn-bg` | `var(--tc-surface)` | Discard button background. | +| `--bs-loot-popup-btn-color` | `var(--tc-text)` | Discard button text colour. | +| `--bs-loot-popup-btn-border` | `1px solid var(--tc-border-strong)` | Discard button border. | +| `--bs-loot-popup-btn-hover-bg` | `var(--tc-surface-muted)` | Discard button hover background. | +| `--bs-loot-popup-btn-primary-bg` | `var(--tc-app-accent)` | Take All button background (ink). | +| `--bs-loot-popup-btn-primary-color` | `#fff` | Take All button text colour. | +| `--bs-loot-popup-btn-primary-hover-bg` | `var(--tc-ink, #0f172a)` | Take All button hover background. | + +```html + + + + + +``` diff --git a/examples/src/web-components/LootPopupDemo.tsx b/examples/src/web-components/LootPopupDemo.tsx new file mode 100644 index 00000000..06610381 --- /dev/null +++ b/examples/src/web-components/LootPopupDemo.tsx @@ -0,0 +1,163 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const LootPopupDemo: React.FC = () => { + const basicRef = useRef(null) + const autoFadeRef = useRef(null) + const eventsRef = useRef(null) + + const [basicOpen, setBasicOpen] = useState(false) + const [autoFadeOpen, setAutoFadeOpen] = useState(false) + const [eventsOpen, setEventsOpen] = useState(false) + const [log, setLog] = useState([]) + + const appendLog = (msg: string) => setLog(l => [msg, ...l].slice(0, 10)) + + // Basic popup + useEffect(() => { + const el = basicRef.current + if (!el) return + el.items = [ + { item: { id: 'gold', name: 'Gold Coin', icon: '◎', rarity: 'common', qty: 12 } }, + { item: { id: 'potion', name: 'Health Potion', icon: '⊕', rarity: 'uncommon' } }, + { item: { id: 'staff', name: 'Frost Staff', icon: '✦', rarity: 'rare' }, qty: 2 }, + ] + const onClose = () => setBasicOpen(false) + el.addEventListener('tc-close', onClose) + return () => el.removeEventListener('tc-close', onClose) + }, []) + + useEffect(() => { + if (!basicRef.current) return + if (basicOpen) basicRef.current.setAttribute('open', '') + else basicRef.current.removeAttribute('open') + }, [basicOpen]) + + // Auto-fade popup + useEffect(() => { + const el = autoFadeRef.current + if (!el) return + el.items = [ + { item: { id: 'chest-key', name: 'Chest Key', icon: '⊗', rarity: 'epic' } }, + { item: { id: 'dawn-lance', name: 'Dawn Lance', icon: '★', rarity: 'legendary' } }, + ] + const onClose = () => setAutoFadeOpen(false) + el.addEventListener('tc-close', onClose) + return () => el.removeEventListener('tc-close', onClose) + }, []) + + useEffect(() => { + if (!autoFadeRef.current) return + if (autoFadeOpen) autoFadeRef.current.setAttribute('open', '') + else autoFadeRef.current.removeAttribute('open') + }, [autoFadeOpen]) + + // Events popup + useEffect(() => { + const el = eventsRef.current + if (!el) return + el.items = [ + { item: { id: 'scroll', name: 'Ancient Scroll', icon: '◉', rarity: 'rare' }, qty: 3 }, + { item: { id: 'gem', name: 'Sapphire Gem', icon: '◈', rarity: 'epic' } }, + { item: { id: 'void-shard', name: 'Void Shard', icon: '⬡', rarity: 'mythic' } }, + ] + const onTake = (e: CustomEvent) => appendLog(`tc-take — id: "${e.detail.id}"`) + const onTakeAll = () => appendLog('tc-take-all fired') + const onDiscard = () => { appendLog('tc-discard fired'); setEventsOpen(false) } + const onClose = () => { appendLog('tc-close fired'); setEventsOpen(false) } + el.addEventListener('tc-take', onTake as EventListener) + el.addEventListener('tc-take-all', onTakeAll) + el.addEventListener('tc-discard', onDiscard) + el.addEventListener('tc-close', onClose) + return () => { + el.removeEventListener('tc-take', onTake as EventListener) + el.removeEventListener('tc-take-all', onTakeAll) + el.removeEventListener('tc-discard', onDiscard) + el.removeEventListener('tc-close', onClose) + } + }, []) + + useEffect(() => { + if (!eventsRef.current) return + if (eventsOpen) eventsRef.current.setAttribute('open', '') + else eventsRef.current.removeAttribute('open') + }, [eventsOpen]) + + return ( +
    +
    +
    +
    + Web Components} + title="LootPopup" + description="Modal loot window with Take All / Discard and optional auto-fade. Items are set via the JS items property. Controlled — fire tc-close to dismiss." + /> + +
    + + + {/* @ts-ignore */} + + + + + + {/* @ts-ignore */} + + + + + + {/* @ts-ignore */} + +
    + Event log + {log.length === 0 ? ( + Open the popup and interact… + ) : ( +
      + {log.map((line, i) => ( +
    • {line}
    • + ))} +
    + )} +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default LootPopupDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index fea9e387..93cc7140 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -234,6 +234,7 @@ import ListDemo from './ListDemo' import ListRowDemo from './ListRowDemo' import LobbyDemo from './LobbyDemo' import LootListDemo from './LootListDemo' +import LootPopupDemo from './LootPopupDemo' import LiveFeedDemo from './LiveFeedDemo' import LoginDemo from './LoginDemo' import MarkdownEditorDemo from './MarkdownEditorDemo' @@ -547,6 +548,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'list-row', category: 'Components', element: }, { key: 'lobby', category: 'Components', element: }, { key: 'loot-list', category: 'Components', element: }, + { key: 'loot-popup', category: 'Components', element: }, { key: 'live-feed', category: 'Components', element: }, { key: 'login', category: 'Layout', element: }, { key: 'marquee', category: 'Content', element: }, diff --git a/web-components/src/LootPopup.ts b/web-components/src/LootPopup.ts new file mode 100644 index 00000000..670f8a85 --- /dev/null +++ b/web-components/src/LootPopup.ts @@ -0,0 +1,392 @@ +import { closeIcon } from './icons' +import type { LootEntry } from './LootList' + +const TAG_NAME = 'tc-loot-popup' + +let _idCounter = 0 + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function getFocusable(root: Element): HTMLElement[] { + return Array.from( + root.querySelectorAll( + 'a[href],area[href],button:not([disabled]),details>summary,[tabindex]:not([tabindex="-1"]),input:not([disabled]),select:not([disabled]),textarea:not([disabled])', + ), + ).filter(el => !el.closest('[hidden]') && el.tabIndex >= 0) +} + +export class LootPopup extends HTMLElement { + private _initialised = false + private _items: LootEntry[] = [] + private _previousFocus: Element | null = null + private _idPrefix: string + private _fadeTimer: ReturnType | null = null + + onTake: ((id: string) => void) | null = null + onTakeAll: (() => void) | null = null + onDiscard: (() => void) | null = null + onClose: (() => void) | null = null + + constructor() { + super() + this._idPrefix = `tc-loot-popup-${++_idCounter}` + } + + static get observedAttributes(): string[] { + return ['open', 'popup-title', 'eyebrow', 'discard-label', 'auto-fade-ms'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + this._detachHandlers() + this._attachHandlers() + if (this.open) { + this._applyOpenState(true) + this._scheduleAutoFade() + } + } + + disconnectedCallback(): void { + this._detachHandlers() + this._clearAutoFade() + this._restoreScroll() + } + + attributeChangedCallback(name: string): void { + if (!this.isConnected || !this._initialised) return + + if (name === 'open') { + this._applyOpenState(this.open) + this._scheduleAutoFade() + return + } + + if (name === 'auto-fade-ms') { + this._scheduleAutoFade() + return + } + + // Structural attribute changed — re-render, then re-apply visibility. + this.render() + if (this.open) this._applyOpenState(true) + } + + get open(): boolean { + return this.hasAttribute('open') + } + set open(v: boolean) { + if (v) this.setAttribute('open', '') + else this.removeAttribute('open') + } + + get popupTitle(): string { + return this.getAttribute('popup-title') ?? 'Loot' + } + set popupTitle(v: string) { + if (v) this.setAttribute('popup-title', v) + else this.removeAttribute('popup-title') + } + + get eyebrow(): string { + return this.getAttribute('eyebrow') ?? '' + } + set eyebrow(v: string) { + if (v) this.setAttribute('eyebrow', v) + else this.removeAttribute('eyebrow') + } + + get discardLabel(): string { + return this.getAttribute('discard-label') ?? 'Discard' + } + set discardLabel(v: string) { + if (v) this.setAttribute('discard-label', v) + else this.removeAttribute('discard-label') + } + + get autoFadeMs(): number { + const raw = this.getAttribute('auto-fade-ms') + if (raw === null) return 0 + const parsed = Number(raw) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0 + } + set autoFadeMs(v: number) { + if (Number.isFinite(v) && v > 0) this.setAttribute('auto-fade-ms', String(v)) + else this.removeAttribute('auto-fade-ms') + } + + get items(): LootEntry[] { + return this._items.slice() + } + set items(value: LootEntry[]) { + this._items = Array.isArray(value) ? value.slice() : [] + if (this._initialised) { + this._syncItems() + this._scheduleAutoFade() + } + } + + // ── Open / close lifecycle ───────────────────────────────────────────────── + + private _applyOpenState(opening: boolean): void { + const panel = this.querySelector('.tc-loot-popup__panel') + const backdrop = this.querySelector('.tc-loot-popup__backdrop') + + if (opening) { + this._previousFocus = document.activeElement + panel?.removeAttribute('hidden') + backdrop?.removeAttribute('hidden') + // Settle one frame before adding the open class to trigger the transition. + requestAnimationFrame(() => { + this.classList.add('tc-loot-popup--open') + panel?.setAttribute('aria-hidden', 'false') + this._lockScroll() + this._trapFocus(panel) + }) + } else { + this._clearAutoFade() + this.classList.remove('tc-loot-popup--open') + panel?.setAttribute('aria-hidden', 'true') + this._restoreScroll() + this._restoreFocus() + const delay = this._getTransitionDuration(panel) + setTimeout(() => { + if (!this.open) { + panel?.setAttribute('hidden', '') + backdrop?.setAttribute('hidden', '') + } + }, delay) + } + } + + private _requestClose(): void { + this.dispatchEvent(new CustomEvent('tc-close', { + bubbles: true, + composed: true, + detail: {}, + })) + if (typeof this.onClose === 'function') this.onClose() + } + + // ── Auto-fade ────────────────────────────────────────────────────────────── + + private _clearAutoFade(): void { + if (this._fadeTimer !== null) { + clearTimeout(this._fadeTimer) + this._fadeTimer = null + } + } + + private _scheduleAutoFade(): void { + this._clearAutoFade() + if (!this.open) return + const ms = this.autoFadeMs + if (ms <= 0) return + this._fadeTimer = setTimeout(() => { + this._fadeTimer = null + this._requestClose() + }, ms) + } + + // ── Focus management ─────────────────────────────────────────────────────── + + private _trapFocus(panel: HTMLElement | null): void { + if (!panel) return + const focusable = getFocusable(panel) + if (focusable.length > 0) { + focusable[0].focus() + } else { + panel.focus() + } + } + + private _restoreFocus(): void { + if (this._previousFocus instanceof HTMLElement) { + this._previousFocus.focus() + } + this._previousFocus = null + } + + private _lockScroll(): void { + document.body.style.overflow = 'hidden' + } + + private _restoreScroll(): void { + document.body.style.overflow = '' + } + + private _getTransitionDuration(el: HTMLElement | null): number { + if (!el) return 0 + const raw = getComputedStyle(el).transitionDuration || '0s' + let max = 0 + for (const p of raw.split(',')) { + const sec = parseFloat(p.trim()) + if (!isNaN(sec)) max = Math.max(max, sec) + } + return max * 1000 + } + + // ── Event handlers (arrow-property, stable references) ──────────────────── + + private _onKeydown = (e: KeyboardEvent): void => { + if (!this.open) return + if (e.key === 'Escape') { + e.preventDefault() + this._requestClose() + return + } + if (e.key === 'Tab') { + const panel = this.querySelector('.tc-loot-popup__panel') + if (!panel) return + const focusable = getFocusable(panel) + if (focusable.length === 0) return + const first = focusable[0] + const last = focusable[focusable.length - 1] + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault() + last.focus() + } + } else { + if (document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + } + } + + private _onBackdropClick = (e: MouseEvent): void => { + if ((e.target as Element)?.classList.contains('tc-loot-popup__backdrop')) { + this._requestClose() + } + } + + private _onCloseClick = (e: MouseEvent): void => { + if ((e.target as Element)?.closest('.tc-loot-popup__close')) { + this._requestClose() + } + } + + private _attachHandlers(): void { + document.addEventListener('keydown', this._onKeydown) + this.addEventListener('click', this._onBackdropClick) + this.addEventListener('click', this._onCloseClick) + } + + private _detachHandlers(): void { + document.removeEventListener('keydown', this._onKeydown) + this.removeEventListener('click', this._onBackdropClick) + this.removeEventListener('click', this._onCloseClick) + } + + // ── Sync items ───────────────────────────────────────────────────────────── + + private _syncItems(): void { + const list = this.querySelector('.tc-loot-popup__list') + if (list) list.items = this._items.slice() + } + + // ── Inner-element listeners (wired fresh each render) ────────────────────── + + private _wireInner(): void { + const list = this.querySelector('.tc-loot-popup__list') + if (list) { + list.addEventListener('tc-take', (e: Event) => { + e.stopPropagation() + const id = (e as CustomEvent<{ id: string }>).detail.id + this.dispatchEvent(new CustomEvent('tc-take', { + bubbles: true, + composed: true, + detail: { id }, + })) + if (typeof this.onTake === 'function') this.onTake(id) + this._scheduleAutoFade() + }) + list.addEventListener('tc-take-all', (e: Event) => { + e.stopPropagation() + this.dispatchEvent(new CustomEvent('tc-take-all', { + bubbles: true, + composed: true, + detail: {}, + })) + if (typeof this.onTakeAll === 'function') this.onTakeAll() + }) + } + + const discard = this.querySelector('.tc-loot-popup__discard') + discard?.addEventListener('click', () => { + this.dispatchEvent(new CustomEvent('tc-discard', { + bubbles: true, + composed: true, + detail: {}, + })) + if (typeof this.onDiscard === 'function') this.onDiscard() + }) + + const takeAll = this.querySelector('.tc-loot-popup__take-all') + takeAll?.addEventListener('click', () => { + this.dispatchEvent(new CustomEvent('tc-take-all', { + bubbles: true, + composed: true, + detail: {}, + })) + if (typeof this.onTakeAll === 'function') this.onTakeAll() + }) + } + + // ── Render ───────────────────────────────────────────────────────────────── + + private render(): void { + const isOpen = this.open + const labelId = `${this._idPrefix}-title` + const hiddenAttr = isOpen ? '' : ' hidden' + const eyebrowText = this.getAttribute('eyebrow') ?? '' + const titleText = this.getAttribute('popup-title') ?? 'Loot' + const discardText = this.getAttribute('discard-label') ?? 'Discard' + + const eyebrowHtml = eyebrowText + ? `${esc(eyebrowText)}` + : '' + + this.innerHTML = + `` + + `` + + if (isOpen) { + this.classList.add('tc-loot-popup--open') + } else { + this.classList.remove('tc-loot-popup--open') + } + + this._syncItems() + this._wireInner() + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: LootPopup + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index a4718ec8..f2ac4feb 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -300,3 +300,4 @@ export * from './LoadingOverlay' export * from './LoadingScreen' export * from './Lobby' export * from './LootList' +export * from './LootPopup' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index d7425e78..0677203e 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -297,6 +297,7 @@ import { LoadingOverlay } from './LoadingOverlay' import { LoadingScreen } from './LoadingScreen' import { Lobby } from './Lobby' import { LootList } from './LootList' +import { LootPopup } from './LootPopup' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -603,4 +604,5 @@ export function register(): void { customElements.define('tc-loading-screen', LoadingScreen) customElements.define('tc-lobby', Lobby) customElements.define('tc-loot-list', LootList) + customElements.define('tc-loot-popup', LootPopup) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 720ec9d6..99684390 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -293,3 +293,4 @@ @forward 'loading-overlay'; @forward 'loading-screen'; @forward 'loot-list'; +@forward 'loot-popup'; diff --git a/web-components/style/components/_loot-popup.scss b/web-components/style/components/_loot-popup.scss new file mode 100644 index 00000000..fdd77ff3 --- /dev/null +++ b/web-components/style/components/_loot-popup.scss @@ -0,0 +1,277 @@ +// tc-loot-popup — modal loot window with Take All / Discard and optional auto-fade. +// Overlay tier: full-screen backdrop + centred panel; sharp corners; 1px hairline; +// single hard shadow. Fantasy chrome (gilded frames, glows, diamond dividers) dropped. +// All cosmetics flow through --bs-loot-popup-* custom properties. + +tc-loot-popup { + // Surface + chrome + --bs-loot-popup-bg: var(--tc-surface); + --bs-loot-popup-border-color: var(--tc-border); + --bs-loot-popup-shadow: var(--tc-shadow-lg); + --bs-loot-popup-width: 420px; + --bs-loot-popup-max-width: calc(100vw - 2rem); + --bs-loot-popup-max-height: calc(100vh - 4rem); + --bs-loot-popup-padding: 1.25rem; + --bs-loot-popup-gap: 0.75rem; + + // Header + --bs-loot-popup-eyebrow-color: var(--tc-text-muted); + --bs-loot-popup-eyebrow-size: 0.6875rem; + --bs-loot-popup-title-color: var(--tc-text); + --bs-loot-popup-title-size: 1rem; + + // Z-index band — backdrop below panel, both in the overlay tier. + --bs-loot-popup-z-backdrop: var(--tc-z-modal-backdrop); + --bs-loot-popup-z-panel: var(--tc-z-modal); + + // Transition + --bs-loot-popup-transition: transform var(--tc-transition-base), opacity var(--tc-transition-base); + + // Backdrop scrim + --bs-loot-popup-backdrop-bg: #0f172a; + --bs-loot-popup-backdrop-opacity: 0.5; + + // Action buttons + --bs-loot-popup-btn-font-size: 0.8125rem; + --bs-loot-popup-btn-min-height: 2.25rem; + --bs-loot-popup-btn-bg: var(--tc-surface); + --bs-loot-popup-btn-color: var(--tc-text); + --bs-loot-popup-btn-border: 1px solid var(--tc-border-strong); + --bs-loot-popup-btn-hover-bg: var(--tc-surface-muted); + --bs-loot-popup-btn-hover-color: var(--tc-text); + --bs-loot-popup-btn-primary-bg: var(--tc-app-accent); + --bs-loot-popup-btn-primary-color: #fff; + --bs-loot-popup-btn-primary-hover-bg: var(--tc-ink, #0f172a); + --bs-loot-popup-btn-primary-hover-color: #fff; + + // Close button + --bs-loot-popup-close-color: var(--tc-text-muted); + --bs-loot-popup-close-hover-color: var(--tc-text); + --bs-loot-popup-close-hover-bg: var(--tc-surface-muted); +} + +// ── Backdrop ────────────────────────────────────────────────────────────────── + +.tc-loot-popup__backdrop { + position: fixed; + inset: 0; + z-index: var(--bs-loot-popup-z-backdrop); + background: var(--bs-loot-popup-backdrop-bg); + opacity: 0; + transition: opacity var(--tc-transition-base); + + tc-loot-popup.tc-loot-popup--open & { + opacity: var(--bs-loot-popup-backdrop-opacity); + } +} + +// ── Panel ───────────────────────────────────────────────────────────────────── + +.tc-loot-popup__panel { + position: fixed; + top: 50%; + left: 50%; + z-index: var(--bs-loot-popup-z-panel); + display: flex; + flex-direction: column; + width: var(--bs-loot-popup-width); + max-width: var(--bs-loot-popup-max-width); + max-height: var(--bs-loot-popup-max-height); + background: var(--bs-loot-popup-bg); + border: 1px solid var(--bs-loot-popup-border-color); + border-radius: 0; + box-shadow: var(--bs-loot-popup-shadow); + outline: 0; + overflow: hidden; + transition: var(--bs-loot-popup-transition); + + // Closed: shifted up, scaled down, hidden. + opacity: 0; + transform: translateY(-20px) translateX(-50%) scale(0.97); + + // Open: centered, full size. + tc-loot-popup.tc-loot-popup--open & { + opacity: 1; + transform: translateY(-50%) translateX(-50%); + } +} + +// ── Header ──────────────────────────────────────────────────────────────────── + +.tc-loot-popup__header { + display: flex; + flex-direction: column; + flex-shrink: 0; + padding: var(--bs-loot-popup-padding); + padding-bottom: 0; + gap: 0.25rem; + position: relative; +} + +.tc-loot-popup__eyebrow { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-loot-popup-eyebrow-size); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bs-loot-popup-eyebrow-color); + line-height: 1.4; +} + +.tc-loot-popup__title { + font-size: var(--bs-loot-popup-title-size); + font-weight: 700; + color: var(--bs-loot-popup-title-color); + margin: 0; + line-height: 1.3; + padding-right: 2.5rem; // avoid overlap with close button +} + +// ── Close button ────────────────────────────────────────────────────────────── + +.tc-loot-popup__close { + position: absolute; + top: var(--bs-loot-popup-padding); + right: var(--bs-loot-popup-padding); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 2rem; + height: 2rem; + padding: 0; + border: none; + background: transparent; + color: var(--bs-loot-popup-close-color); + cursor: pointer; + border-radius: 0; + transition: background-color var(--tc-transition-fast), color var(--tc-transition-fast); + + svg { + width: 1rem; + height: 1rem; + flex-shrink: 0; + } + + &:hover { + background: var(--bs-loot-popup-close-hover-bg); + color: var(--bs-loot-popup-close-hover-color); + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + } + + @media (pointer: coarse) { + width: 2.75rem; + height: 2.75rem; + } +} + +// ── Loot list ───────────────────────────────────────────────────────────────── + +.tc-loot-popup__list { + flex: 1 1 auto; + overflow-y: auto; + margin: var(--bs-loot-popup-padding); + margin-bottom: 0; +} + +// ── Actions ─────────────────────────────────────────────────────────────────── + +.tc-loot-popup__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--bs-loot-popup-gap); + flex-shrink: 0; + padding: var(--bs-loot-popup-padding); + border-top: 1px solid var(--bs-loot-popup-border-color); +} + +.tc-loot-popup__btn { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0 1rem; + min-height: var(--bs-loot-popup-btn-min-height); + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-loot-popup-btn-font-size); + font-weight: 500; + letter-spacing: 0.025em; + color: var(--bs-loot-popup-btn-color); + background: var(--bs-loot-popup-btn-bg); + border: var(--bs-loot-popup-btn-border); + border-radius: 0; + cursor: pointer; + transition: + background var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease), + transform var(--tc-transition-fast, 0.15s ease); + white-space: nowrap; + line-height: 1; + + &:hover:not(:disabled) { + background: var(--bs-loot-popup-btn-hover-bg); + color: var(--bs-loot-popup-btn-hover-color); + transform: translateY(-1px); + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + } + + &:disabled { + opacity: 0.45; + pointer-events: none; + cursor: default; + } + + @media (pointer: coarse) { + min-height: 44px; + } +} + +// Take All is the primary / ink action. +.tc-loot-popup__take-all { + color: var(--bs-loot-popup-btn-primary-color); + background: var(--bs-loot-popup-btn-primary-bg); + border-color: transparent; + + &:hover:not(:disabled) { + background: var(--bs-loot-popup-btn-primary-hover-bg); + color: var(--bs-loot-popup-btn-primary-hover-color); + } +} + +// ── Reduced motion — skip transform / opacity animations ────────────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-loot-popup__backdrop { + transition: none; + } + + .tc-loot-popup__panel { + transition: none; + opacity: 1; + transform: translateY(-50%) translateX(-50%); + } + + .tc-loot-popup__btn { + transition: + background var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease); + + &:hover:not(:disabled) { + transform: none; + } + } + + .tc-loot-popup__close { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 1e4354ca..3ea7f00d 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -361,6 +361,12 @@ tc-confirm-dialog { display: block; } +// Modal loot overlay; the SCSS partial positions the backdrop and centred panel, +// but the host must not default to inline. +tc-loot-popup { + display: block; +} + // Fixed full-surface scrim; the SCSS partial flips it to a centred flex // container, but it must not default to inline. tc-blur-overlay { From 754513748cd8c45595c326f2bd704dc8faabdf52 Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 17 Jun 2026 20:24:34 +0000 Subject: [PATCH 337/632] 331-lore-text: Build the tc-lore-text web component (LoreText game-components port) --- examples/public/web-components/SKILL.md | 64 +++++++++++++++++++ examples/src/web-components/LoreTextDemo.tsx | 51 +++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/LoreText.ts | 25 ++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_lore-text.scss | 49 ++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 196 insertions(+) create mode 100644 examples/src/web-components/LoreTextDemo.tsx create mode 100644 web-components/src/LoreText.ts create mode 100644 web-components/style/components/_lore-text.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 37c265c7..8e5b66e2 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -227,6 +227,7 @@ After `register()` you can author markup directly: - [tc-lobby](#tc-lobby) - [tc-loot-list](#tc-loot-list) - [tc-loot-popup](#tc-loot-popup) + - [tc-lore-text](#tc-lore-text) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) - [tc-live-feed](#tc-live-feed) @@ -21392,3 +21393,66 @@ Modal loot window with Take All / Discard and optional auto-fade timer. Port of popup.addEventListener('tc-close', () => popup.removeAttribute('open')) ``` + +--- + +### tc-lore-text + +Flavor / lore body-copy block. Slot-based — used for tooltips, loading screens, codex entries, or any italic narrative aside. Drops game-components fantasy chrome (gilded frames, glows, fantasy fills) in favour of the web-components design system: sharp corners, 1px hairline left border, Inter italic prose. + +**Tag:** `tc-lore-text` + +**Attributes** + +None. `tc-lore-text` is purely slot-driven. + +**JS Properties** + +None. `tc-lore-text` is purely slot-driven. + +**Events** + +None. `tc-lore-text` is purely presentational. + +**Slots** + +| Slot | Description | +|------|-------------| +| *(default)* | The lore / flavor text content. Plain text, `

    ` elements, or any inline content. | + +**CSS Custom Properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-lore-text-bg` | `transparent` | Block background. | +| `--bs-lore-text-border-color` | `var(--tc-border)` | Left accent border color. | +| `--bs-lore-text-border-width` | `2px` | Left accent border width. | +| `--bs-lore-text-padding-y` | `0.75rem` | Vertical inner padding. | +| `--bs-lore-text-padding-x` | `1rem` | Horizontal inner padding (applied to the right; the left is offset by the border). | +| `--bs-lore-text-color` | `var(--tc-text-muted)` | Text color. | +| `--bs-lore-text-font-size` | `0.925rem` | Font size. | +| `--bs-lore-text-font-weight` | `400` | Font weight. | +| `--bs-lore-text-font-style` | `italic` | Font style. | +| `--bs-lore-text-line-height` | `1.7` | Line height. | + +**Example** + +```html + + + Some swords remember every hand that wielded them. + + + + + The library at Velkhar burned for three nights and still its ashes + whispered names. Scholars came from distant kingdoms to listen, hoping + to catch a verse, a date, a forgotten lineage — anything the fire had + chosen to spare. + + + + + The door had no handle. It had never needed one. + +``` diff --git a/examples/src/web-components/LoreTextDemo.tsx b/examples/src/web-components/LoreTextDemo.tsx new file mode 100644 index 00000000..ebe05c28 --- /dev/null +++ b/examples/src/web-components/LoreTextDemo.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const LoreTextDemo: React.FC = () => { + return ( +

    +
    +
    +
    + Web Components} + title="LoreText" + description="Flavor / lore body-copy block. Slot-based — use it for tooltips, loading screens, codex entries, or any italic narrative aside." + /> + +
    + + + {/* @ts-ignore */} + Some swords remember every hand that wielded them. + + + + {/* @ts-ignore */} + The library at Velkhar burned for three nights and still its ashes whispered names. Scholars came from distant kingdoms to listen, hoping to catch a verse, a date, a forgotten lineage — anything the fire had chosen to spare. + + + +
    +
    +
    Aldric of the Vale
    + {/* @ts-ignore */} + He had walked three continents before he turned eighteen. None of the maps he carried matched the roads he found. +
    +
    +
    + + + {/* @ts-ignore */} + The door had no handle. It had never needed one — it only opened for those who already knew what lay beyond. + + +
    +
    +
    +
    +
    + ) +} + +export default LoreTextDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 93cc7140..881082bc 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -290,6 +290,7 @@ import LevelHeaderDemo from './LevelHeaderDemo' import LevelSelectDemo from './LevelSelectDemo' import LoadingOverlayDemo from './LoadingOverlayDemo' import LoadingScreenDemo from './LoadingScreenDemo' +import LoreTextDemo from './LoreTextDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -600,4 +601,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'level-select', category: 'Components', element: }, { key: 'loading-overlay', category: 'Overlays & Feedback', element: }, { key: 'loading-screen', category: 'Overlays & Feedback', element: }, + { key: 'lore-text', category: 'Content', element: }, ] diff --git a/web-components/src/LoreText.ts b/web-components/src/LoreText.ts new file mode 100644 index 00000000..bb1dfa17 --- /dev/null +++ b/web-components/src/LoreText.ts @@ -0,0 +1,25 @@ +const TAG_NAME = 'tc-lore-text' + +export class LoreText extends HTMLElement { + + private _initialised = false + + connectedCallback(): void { + if (!this._initialised) { + const slotContent = Array.from(this.childNodes) + this.render() + const inner = this.querySelector('.tc-lore-text__content') + if (inner) slotContent.forEach(n => inner.appendChild(n)) + this._initialised = true + } + } + + private render(): void { + this.classList.add('tc-lore-text') + this.innerHTML = `
    ` + } +} + +declare global { + interface HTMLElementTagNameMap { [TAG_NAME]: LoreText } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index f2ac4feb..b10dc940 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -301,3 +301,4 @@ export * from './LoadingScreen' export * from './Lobby' export * from './LootList' export * from './LootPopup' +export * from './LoreText' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 0677203e..df97fca7 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -298,6 +298,7 @@ import { LoadingScreen } from './LoadingScreen' import { Lobby } from './Lobby' import { LootList } from './LootList' import { LootPopup } from './LootPopup' +import { LoreText } from './LoreText' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -605,4 +606,5 @@ export function register(): void { customElements.define('tc-lobby', Lobby) customElements.define('tc-loot-list', LootList) customElements.define('tc-loot-popup', LootPopup) + customElements.define('tc-lore-text', LoreText) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 99684390..e4e9018e 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -294,3 +294,4 @@ @forward 'loading-screen'; @forward 'loot-list'; @forward 'loot-popup'; +@forward 'lore-text'; diff --git a/web-components/style/components/_lore-text.scss b/web-components/style/components/_lore-text.scss new file mode 100644 index 00000000..57ac8321 --- /dev/null +++ b/web-components/style/components/_lore-text.scss @@ -0,0 +1,49 @@ +// tc-lore-text — flavor / lore body-copy block. +// Port of gc-lore-text; drops the game-components fantasy chrome in favour of +// the web-components design system: sharp corners, 1px hairline borders, slate +// neutral ramp, italic Inter prose. +// All cosmetics flow through --bs-lore-text-* custom properties. + +@use '../foundation/tokens' as *; + +tc-lore-text { + --bs-lore-text-bg: transparent; + --bs-lore-text-border-color: var(--tc-border); + --bs-lore-text-border-width: 2px; + --bs-lore-text-padding-y: 0.75rem; + --bs-lore-text-padding-x: 1rem; + + --bs-lore-text-color: var(--tc-text-muted); + --bs-lore-text-font-size: 0.925rem; + --bs-lore-text-font-weight: 400; + --bs-lore-text-font-style: italic; + --bs-lore-text-line-height: 1.7; +} + +.tc-lore-text { + box-sizing: border-box; + padding: var(--bs-lore-text-padding-y) var(--bs-lore-text-padding-x); + padding-left: calc(var(--bs-lore-text-padding-x) + var(--bs-lore-text-border-width)); + background: var(--bs-lore-text-bg); + border-left: var(--bs-lore-text-border-width) solid var(--bs-lore-text-border-color); + border-radius: 0; +} + +.tc-lore-text__content { + margin: 0; + padding: 0; + color: var(--bs-lore-text-color); + font-size: var(--bs-lore-text-font-size); + font-weight: var(--bs-lore-text-font-weight); + font-style: var(--bs-lore-text-font-style); + line-height: var(--bs-lore-text-line-height); + + // Let multi-paragraph slot content retain spacing. + p { + margin: 0 0 0.5em; + + &:last-child { + margin-bottom: 0; + } + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 3ea7f00d..f4086d7c 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -334,6 +334,7 @@ tc-level-header, tc-level-select, tc-lobby, tc-loot-list, +tc-lore-text, tc-roadmap { display: block; } From 34bcece9e43995174afef754d57dceb5060f49c7 Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 17 Jun 2026 20:33:40 +0000 Subject: [PATCH 338/632] 332-main-menu: Build the tc-main-menu web component (MainMenu game-components port) --- examples/public/web-components/SKILL.md | 96 +++++++++ examples/src/web-components/MainMenuDemo.tsx | 145 +++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/MainMenu.ts | 202 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_main-menu.scss | 159 ++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 609 insertions(+) create mode 100644 examples/src/web-components/MainMenuDemo.tsx create mode 100644 web-components/src/MainMenu.ts create mode 100644 web-components/style/components/_main-menu.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 8e5b66e2..efcece28 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -228,6 +228,7 @@ After `register()` you can author markup directly: - [tc-loot-list](#tc-loot-list) - [tc-loot-popup](#tc-loot-popup) - [tc-lore-text](#tc-lore-text) + - [tc-main-menu](#tc-main-menu) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) - [tc-live-feed](#tc-live-feed) @@ -21456,3 +21457,98 @@ None. `tc-lore-text` is purely presentational. The door had no handle. It had never needed one. ``` + +--- + +### tc-main-menu + +Main-menu container of menu items. Port of `gc-main-menu` (game-components), restyled to the toolcase design system: slate neutrals, hairline borders, sharp corners, JetBrains Mono for machine-facing labels, ink accent for the selected item. Items are set via the JS `items` property. Arrow keys navigate between enabled items; Enter/Space fires `tc-select` for the highlighted item; hovering an item also highlights it. No shadow root; light DOM; `display: block`. + +**Tag:** `tc-main-menu` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `selected-id` | string | `''` | ID of the currently selected/highlighted item. Reflected by the `selectedId` JS property. | +| `menu-title` | string | `''` | Optional title shown in the header. When present, an eyebrow micro-label "Main Menu" is also rendered above it. Omit to suppress the header entirely. | +| `subtitle` | string | `''` | Optional subtitle rendered below the title in the header. Ignored when `menu-title` is absent. | + +**JS Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `selectedId` | `string` | `''` | Reflects the `selected-id` attribute. Setting this re-renders the item list to apply the `--selected` modifier. | +| `menuTitle` | `string` | `''` | Reflects the `menu-title` attribute. | +| `subtitle` | `string` | `''` | Reflects the `subtitle` attribute. | +| `items` | `MainMenuItem[]` | `[]` | Array of menu item objects. Setting this triggers a full re-render. | +| `onSelect` | `((id: string) => void) \| null` | `null` | Optional callback fired alongside `tc-select`. | + +**`MainMenuItem` shape** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | `string` | yes | Unique item identifier. Stamped as `data-id` on the row element. | +| `label` | `string` | yes | Display label rendered in the item row. | +| `disabled` | `boolean` | no | When `true` the item gains `--disabled`, `aria-disabled="true"`, and ignores hover, click, and keyboard selection. | +| `badge` | `string` | no | Optional short badge rendered trailing the label (e.g. `"3"`, `"NEW"`). | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-select` | `{ id: string }` | Fired when the user clicks an enabled item or presses Enter/Space while it is highlighted. Bubbles and is composed. | + +**Slots** + +`tc-main-menu` has no named slots. All content is driven by attributes and the `items` JS property. + +**CSS custom properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-main-menu-bg` | `var(--tc-surface)` | Panel background. | +| `--bs-main-menu-border` | `var(--tc-border)` | Outer 1px hairline border. | +| `--bs-main-menu-header-bg` | `var(--tc-surface-muted)` | Header section background. | +| `--bs-main-menu-header-border` | `var(--tc-border)` | Header bottom border. | +| `--bs-main-menu-eyebrow-color` | `var(--tc-text-muted)` | "Main Menu" eyebrow micro-label colour. | +| `--bs-main-menu-title-color` | `var(--tc-text)` | Title text colour. | +| `--bs-main-menu-subtitle-color` | `var(--tc-text-muted)` | Subtitle text colour. | +| `--bs-main-menu-item-color` | `var(--tc-text)` | Default item text colour. | +| `--bs-main-menu-item-border` | `var(--tc-border)` | Divider between items. | +| `--bs-main-menu-item-hover-bg` | `var(--tc-surface-hover, var(--tc-surface-muted))` | Hover / hover-highlight background (unselected enabled items). | +| `--bs-main-menu-item-selected-bg` | `var(--tc-app-accent)` | Selected item fill (ink). | +| `--bs-main-menu-item-selected-color` | `#fff` | Selected item text colour. | +| `--bs-main-menu-item-disabled-color` | `var(--tc-text-faint)` | Disabled item text colour. | +| `--bs-main-menu-item-disabled-opacity` | `0.5` | Opacity for disabled items. | +| `--bs-main-menu-badge-bg` | `var(--tc-surface-muted)` | Badge background (unselected). | +| `--bs-main-menu-badge-color` | `var(--tc-text-muted)` | Badge text colour (unselected). | +| `--bs-main-menu-badge-selected-bg` | `rgba(255,255,255,0.2)` | Badge background when item is selected. | +| `--bs-main-menu-badge-selected-color` | `#fff` | Badge text colour when item is selected. | +| `--bs-main-menu-item-min-height` | `2.75rem` | Item minimum height (44 px under coarse pointer). | + +**Example** + +```html + + + +``` diff --git a/examples/src/web-components/MainMenuDemo.tsx b/examples/src/web-components/MainMenuDemo.tsx new file mode 100644 index 00000000..45278e9e --- /dev/null +++ b/examples/src/web-components/MainMenuDemo.tsx @@ -0,0 +1,145 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const MainMenuDemo: React.FC = () => { + const basicRef = useRef(null) + const subtitleRef = useRef(null) + const eventsRef = useRef(null) + const disabledRef = useRef(null) + const badgeRef = useRef(null) + + const [log, setLog] = useState([]) + + useEffect(() => { + if (!basicRef.current) return + basicRef.current.items = [ + { id: 'play', label: 'Play' }, + { id: 'settings', label: 'Settings' }, + { id: 'credits', label: 'Credits' }, + { id: 'quit', label: 'Quit' }, + ] + }, []) + + useEffect(() => { + if (!subtitleRef.current) return + subtitleRef.current.items = [ + { id: 'continue', label: 'Continue' }, + { id: 'new-game', label: 'New Game' }, + { id: 'load', label: 'Load Game' }, + { id: 'options', label: 'Options' }, + ] + }, []) + + useEffect(() => { + if (!disabledRef.current) return + disabledRef.current.items = [ + { id: 'single', label: 'Single Player' }, + { id: 'multi', label: 'Multiplayer', disabled: true }, + { id: 'ranked', label: 'Ranked Match', disabled: true }, + { id: 'settings', label: 'Settings' }, + ] + disabledRef.current.selectedId = 'single' + }, []) + + useEffect(() => { + if (!badgeRef.current) return + badgeRef.current.items = [ + { id: 'inbox', label: 'Inbox', badge: '3' }, + { id: 'friends', label: 'Friends', badge: '12' }, + { id: 'shop', label: 'Shop', badge: 'NEW' }, + { id: 'achievements', label: 'Achievements' }, + ] + }, []) + + useEffect(() => { + const el = eventsRef.current + if (!el) return + el.items = [ + { id: 'play', label: 'Play' }, + { id: 'settings', label: 'Settings' }, + { id: 'quit', label: 'Quit' }, + ] + const onSelect = (e: CustomEvent) => { + setLog(l => [`tc-select: id="${e.detail.id}"`, ...l].slice(0, 6)) + } + el.addEventListener('tc-select', onSelect) + return () => { + el.removeEventListener('tc-select', onSelect) + } + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="Main Menu" + description="Main-menu container of menu items. Items are set via the JS items property. Arrow keys navigate between enabled items; Enter/Space fires tc-select for the highlighted item. Hover also highlights an item. All cosmetics flow through --bs-main-menu-* custom properties." + /> + +
    + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + +
    + Event log + {log.length === 0 ? ( + Click an item or press Enter on a highlighted item… + ) : ( +
      + {log.map((line, i) => ( +
    • {line}
    • + ))} +
    + )} +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default MainMenuDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 881082bc..fc7dc277 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -233,6 +233,7 @@ import KillFeedDemo from './KillFeedDemo' import ListDemo from './ListDemo' import ListRowDemo from './ListRowDemo' import LobbyDemo from './LobbyDemo' +import MainMenuDemo from './MainMenuDemo' import LootListDemo from './LootListDemo' import LootPopupDemo from './LootPopupDemo' import LiveFeedDemo from './LiveFeedDemo' @@ -548,6 +549,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'list', category: 'Components', element: }, { key: 'list-row', category: 'Components', element: }, { key: 'lobby', category: 'Components', element: }, + { key: 'main-menu', category: 'Components', element: }, { key: 'loot-list', category: 'Components', element: }, { key: 'loot-popup', category: 'Components', element: }, { key: 'live-feed', category: 'Components', element: }, diff --git a/web-components/src/MainMenu.ts b/web-components/src/MainMenu.ts new file mode 100644 index 00000000..c3c06a9f --- /dev/null +++ b/web-components/src/MainMenu.ts @@ -0,0 +1,202 @@ +const TAG_NAME = 'tc-main-menu' + +export interface MainMenuItem { + id: string + label: string + disabled?: boolean + badge?: string +} + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +export class MainMenu extends HTMLElement { + private _initialised = false + private _items: MainMenuItem[] = [] + + /** Optional callback fired alongside `tc-select`. */ + onSelect: ((id: string) => void) | null = null + + static get observedAttributes(): string[] { + return ['selected-id', 'menu-title', 'subtitle'] + } + + connectedCallback(): void { + if (!this._initialised) { + if (!this.hasAttribute('role')) this.setAttribute('role', 'menu') + if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0') + this.render() + this._initialised = true + } + this.addEventListener('keydown', this._onKeydown) + this.addEventListener('click', this._onClick) + this.addEventListener('mouseover', this._onMouseover) + } + + disconnectedCallback(): void { + this.removeEventListener('keydown', this._onKeydown) + this.removeEventListener('click', this._onClick) + this.removeEventListener('mouseover', this._onMouseover) + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get items(): MainMenuItem[] { + return this._items.slice() + } + set items(value: MainMenuItem[]) { + this._items = Array.isArray(value) ? value.slice() : [] + if (this._initialised) this.render() + } + + get selectedId(): string { + return this.getAttribute('selected-id') ?? '' + } + set selectedId(value: string) { + if (value) this.setAttribute('selected-id', value) + else this.removeAttribute('selected-id') + } + + get menuTitle(): string { + return this.getAttribute('menu-title') ?? '' + } + set menuTitle(value: string) { + if (value) this.setAttribute('menu-title', value) + else this.removeAttribute('menu-title') + } + + get subtitle(): string { + return this.getAttribute('subtitle') ?? '' + } + set subtitle(value: string) { + if (value) this.setAttribute('subtitle', value) + else this.removeAttribute('subtitle') + } + + private _select(id: string): void { + const item = this._items.find(i => i.id === id) + if (!item || item.disabled) return + this.selectedId = id + this.dispatchEvent(new CustomEvent('tc-select', { + bubbles: true, + composed: true, + detail: { id }, + })) + if (typeof this.onSelect === 'function') this.onSelect(id) + } + + private _enabledIndex(from: number, dir: 1 | -1): number { + const n = this._items.length + if (n === 0) return -1 + let i = from + for (let step = 0; step < n; step++) { + i = (i + dir + n) % n + if (!this._items[i].disabled) return i + } + return -1 + } + + private _onKeydown = (e: KeyboardEvent): void => { + const n = this._items.length + if (n === 0) return + const currentIdx = this._items.findIndex(i => i.id === this.selectedId) + + if (e.key === 'ArrowDown') { + e.preventDefault() + const next = this._enabledIndex(currentIdx >= 0 ? currentIdx : -1, 1) + if (next >= 0) this.selectedId = this._items[next].id + return + } + if (e.key === 'ArrowUp') { + e.preventDefault() + const next = this._enabledIndex(currentIdx >= 0 ? currentIdx : 0, -1) + if (next >= 0) this.selectedId = this._items[next].id + return + } + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + if (currentIdx >= 0 && !this._items[currentIdx].disabled) { + this._select(this._items[currentIdx].id) + } + } + } + + private _onClick = (e: Event): void => { + const target = e.target as Element | null + const row = target?.closest('[data-id]') + if (!row || !this.contains(row)) return + const id = row.dataset.id + if (id != null) this._select(id) + } + + // Hover-to-highlight: mirrors gc-main-menu's mouseenter-per-item behavior. + // Re-renders only when the highlighted item actually changes. + private _onMouseover = (e: Event): void => { + const target = e.target as Element | null + const row = target?.closest('[data-id]') + if (!row || !this.contains(row)) return + const id = row.dataset.id + if (!id || id === this.selectedId) return + const found = this._items.find(i => i.id === id) + if (!found || found.disabled) return + this.selectedId = id + } + + private render(): void { + const title = this.menuTitle + const subtitle = this.subtitle + const selected = this.selectedId + + const headerParts: string[] = [] + if (title) { + headerParts.push(`Main Menu`) + headerParts.push(`${esc(title)}`) + } + if (subtitle) { + headerParts.push(`
    ${esc(subtitle)}
    `) + } + const headerHtml = headerParts.length + ? `
    ${headerParts.join('')}
    ` + : '' + + const itemsHtml = this._items.map(item => { + const isSelected = item.id === selected + const cls = [ + 'tc-main-menu-item', + isSelected ? 'tc-main-menu-item--selected' : '', + item.disabled ? 'tc-main-menu-item--disabled' : '', + ].filter(Boolean).join(' ') + const badgeHtml = item.badge + ? `${esc(item.badge)}` + : '' + return ( + `` + + `${esc(item.label)}` + + badgeHtml + + `
    ` + ) + }).join('') + + this.innerHTML = `${headerHtml}
    ${itemsHtml}
    ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: MainMenu + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index b10dc940..c07b81f7 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -299,6 +299,7 @@ export * from './LevelSelect' export * from './LoadingOverlay' export * from './LoadingScreen' export * from './Lobby' +export * from './MainMenu' export * from './LootList' export * from './LootPopup' export * from './LoreText' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index df97fca7..e81211b0 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -296,6 +296,7 @@ import { LevelSelect } from './LevelSelect' import { LoadingOverlay } from './LoadingOverlay' import { LoadingScreen } from './LoadingScreen' import { Lobby } from './Lobby' +import { MainMenu } from './MainMenu' import { LootList } from './LootList' import { LootPopup } from './LootPopup' import { LoreText } from './LoreText' @@ -604,6 +605,7 @@ export function register(): void { customElements.define('tc-loading-overlay', LoadingOverlay) customElements.define('tc-loading-screen', LoadingScreen) customElements.define('tc-lobby', Lobby) + customElements.define('tc-main-menu', MainMenu) customElements.define('tc-loot-list', LootList) customElements.define('tc-loot-popup', LootPopup) customElements.define('tc-lore-text', LoreText) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index e4e9018e..1bd4118a 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -247,6 +247,7 @@ @forward 'lobby'; @forward 'live-feed'; @forward 'login'; +@forward 'main-menu'; @forward 'markdown-editor'; @forward 'marquee'; @forward 'multi-card-select'; diff --git a/web-components/style/components/_main-menu.scss b/web-components/style/components/_main-menu.scss new file mode 100644 index 00000000..c18e1fbd --- /dev/null +++ b/web-components/style/components/_main-menu.scss @@ -0,0 +1,159 @@ +// tc-main-menu — main-menu container of menu items. +// Port of gc-main-menu (game-components), restyled to the toolcase design system: +// slate neutrals, hairline borders, sharp corners (border-radius: 0 everywhere), +// JetBrains Mono for machine-facing text, ink accent for the selected item. +// All cosmetics flow through --bs-main-menu-* custom properties. + +@use '../foundation/tokens' as *; + +tc-main-menu { + --bs-main-menu-bg: var(--tc-surface); + --bs-main-menu-border: var(--tc-border); + --bs-main-menu-header-bg: var(--tc-surface-muted); + --bs-main-menu-header-border: var(--tc-border); + --bs-main-menu-eyebrow-color: var(--tc-text-muted); + --bs-main-menu-title-color: var(--tc-text); + --bs-main-menu-subtitle-color: var(--tc-text-muted); + --bs-main-menu-item-color: var(--tc-text); + --bs-main-menu-item-border: var(--tc-border); + --bs-main-menu-item-hover-bg: var(--tc-surface-hover, var(--tc-surface-muted)); + --bs-main-menu-item-selected-bg: var(--tc-app-accent); + --bs-main-menu-item-selected-color: #fff; + --bs-main-menu-item-disabled-color: var(--tc-text-faint); + --bs-main-menu-item-disabled-opacity: 0.5; + --bs-main-menu-badge-bg: var(--tc-surface-muted); + --bs-main-menu-badge-color: var(--tc-text-muted); + --bs-main-menu-badge-selected-bg: rgba(255, 255, 255, 0.2); + --bs-main-menu-badge-selected-color: #fff; + --bs-main-menu-item-min-height: 2.75rem; +} + +.tc-main-menu { + background: var(--bs-main-menu-bg); + border: 1px solid var(--bs-main-menu-border); + border-radius: 0; + overflow: hidden; +} + +// ── Header ────────────────────────────────────────────────────────────────── + +.tc-main-menu-header { + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 0.625rem 0.875rem; + background: var(--bs-main-menu-header-bg); + border-bottom: 1px solid var(--bs-main-menu-header-border); +} + +.tc-main-menu-eyebrow { + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--bs-main-menu-eyebrow-color); +} + +.tc-main-menu-title { + font-size: 1rem; + font-weight: 600; + line-height: 1.25; + color: var(--bs-main-menu-title-color); +} + +.tc-main-menu-subtitle { + font-size: 0.8125rem; + line-height: 1.4; + color: var(--bs-main-menu-subtitle-color); + margin-top: 0.125rem; +} + +// ── Item list ──────────────────────────────────────────────────────────────── + +.tc-main-menu-list { + display: flex; + flex-direction: column; +} + +.tc-main-menu-item { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0 0.875rem; + min-height: var(--bs-main-menu-item-min-height); + border-bottom: 1px solid var(--bs-main-menu-item-border); + color: var(--bs-main-menu-item-color); + cursor: pointer; + user-select: none; + transition: + background-color var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease); + + &:last-child { + border-bottom: none; + } + + &:hover:not(.tc-main-menu-item--disabled):not(.tc-main-menu-item--selected) { + background: var(--bs-main-menu-item-hover-bg); + } + + &.tc-main-menu-item--selected { + background: var(--bs-main-menu-item-selected-bg); + color: var(--bs-main-menu-item-selected-color); + } + + &.tc-main-menu-item--disabled { + color: var(--bs-main-menu-item-disabled-color); + opacity: var(--bs-main-menu-item-disabled-opacity); + cursor: not-allowed; + } +} + +tc-main-menu:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; +} + +.tc-main-menu-item-label { + flex: 1; + min-width: 0; + font-size: 0.9375rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tc-main-menu-badge { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.125rem 0.375rem; + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.6875rem; + font-weight: 600; + line-height: 1; + background: var(--bs-main-menu-badge-bg); + color: var(--bs-main-menu-badge-color); + border-radius: 0; + + .tc-main-menu-item--selected & { + background: var(--bs-main-menu-badge-selected-bg); + color: var(--bs-main-menu-badge-selected-color); + } +} + +// 44 px coarse touch targets +@media (pointer: coarse) { + tc-main-menu { + --bs-main-menu-item-min-height: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + .tc-main-menu-item { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index f4086d7c..ad638e20 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -333,6 +333,7 @@ tc-legal-screen, tc-level-header, tc-level-select, tc-lobby, +tc-main-menu, tc-loot-list, tc-lore-text, tc-roadmap { From cf40bff0d3d9982d0f7f5a5bd824a92084b95f4d Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 17 Jun 2026 20:40:25 +0000 Subject: [PATCH 339/632] 333-mana-bar: Build the tc-mana-bar web component (ManaBar game-components port) --- examples/public/web-components/SKILL.md | 73 +++++++++++ examples/src/web-components/ManaBarDemo.tsx | 51 ++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/ManaBar.ts | 121 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_mana-bar.scss | 117 +++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 369 insertions(+) create mode 100644 examples/src/web-components/ManaBarDemo.tsx create mode 100644 web-components/src/ManaBar.ts create mode 100644 web-components/style/components/_mana-bar.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index efcece28..1558bd52 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -108,6 +108,7 @@ After `register()` you can author markup directly: - [tc-buff-bar](#tc-buff-bar) - [tc-buff-icon](#tc-buff-icon) - [tc-health-bar](#tc-health-bar) + - [tc-mana-bar](#tc-mana-bar) - [tc-brightness-calibration](#tc-brightness-calibration) - [tc-cdn-map](#tc-cdn-map) - [tc-compass-bar](#tc-compass-bar) @@ -10186,6 +10187,78 @@ None. `tc-health-bar` is attribute-driven. --- +### tc-mana-bar + +Value/max resource bar for mana (MP) — a cyan-accent fill over a flat slate track. Structurally identical to `tc-health-bar`; styled with `--tc-accent` (cyan) so both bars are visually distinct at a glance in a game HUD. Supports an optional label row (human-readable label + mono `value / max` readout), a ghost band behind the fill for recent mana drain, inline mono text inside the track, and evenly-spaced segment dividers. Purely presentational, no events, no slots. Ported from the game-components `gc-mana-bar` (which extends `ResourceBarBase`); the fantasy chrome is dropped in favour of flat slate chrome; the track / fill / ghost / tick DOM is shared with the rest of the resource-bar family through the `internal/resourceBar` helper. + +**Tag:** `tc-mana-bar` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `value` | number | `0` | Current value. Clamped to `[0, max]` for the fill width and `aria-valuenow`. Non-numeric values fall back to `0`. | +| `max` | number | `100` | Maximum value. Values `<= 0` (or non-numeric) fall back to `100`. | +| `ghost` | number | — | Optional "ghost" / recent-drain value drawn as a muted band behind the fill. Only shown when it resolves to a wider band than the current fill. | +| `segments` | number | `1` | Number of equal slots; `segments - 1` evenly-spaced divider ticks are drawn across the track. Values `< 1` (or non-numeric) fall back to `1`. | +| `show-text` | boolean | `false` | When present (and no `label` is set), draws a centred mono `value / max` readout inside the track. | +| `label` | string | `""` | When set, renders a label row above the track with the label and a trailing mono `value / max` readout. Also used as the `aria-label` for the progressbar (defaults to `"Mana"`). | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `value` | `number` | Reflects the `value` attribute. | +| `max` | `number` | Reflects the `max` attribute. | +| `ghost` | `number \| null` | Reflects the `ghost` attribute; set to `null` to remove it. | +| `segments` | `number` | Reflects the `segments` attribute. | +| `showText` | `boolean` | Reflects the `show-text` boolean attribute. | +| `label` | `string` | Reflects the `label` attribute. | + +**Events** + +None. `tc-mana-bar` is a purely presentational element. + +**Slots** + +None. `tc-mana-bar` is attribute-driven. + +**CSS Custom Properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-mana-bar-gap` | `0.375rem` | Gap between the label row and the track. | +| `--bs-mana-bar-label-color` | `var(--tc-text)` | Label text color. | +| `--bs-mana-bar-label-font-size` | `0.8125rem` | Label font size. | +| `--bs-mana-bar-label-font-weight` | `500` | Label font weight (≤600). | +| `--bs-mana-bar-value-color` | `var(--tc-text-muted)` | `value / max` readout color. | +| `--bs-mana-bar-value-font-size` | `0.75rem` | `value / max` readout font size. | +| `--bs-mana-bar-track-bg` | `var(--tc-slate-200)` | Track background. | +| `--bs-mana-bar-track-height` | `0.625rem` | Track height. | +| `--bs-mana-bar-fill-bg` | `var(--tc-accent)` | Value-fill color (cyan accent; overridable). | +| `--bs-mana-bar-fill-transition` | `width var(--tc-transition-base)` | Fill-width transition (disabled under reduced motion). | +| `--bs-mana-bar-ghost-bg` | `var(--tc-slate-400)` | Ghost / recent-drain band color (shared resource-bar contract). | +| `--bs-mana-bar-tick-color` | `var(--tc-surface)` | Segment-divider color. | +| `--bs-mana-bar-inline-text-color` | `var(--tc-text-muted)` | Inline `value / max` text color. | + +**Example** + +```html + + + + + + + + + + + +``` + +--- + ### tc-brightness-calibration Gamma/brightness calibration view: three grayscale reference swatches (dark / mid / bright), each carrying a calibration instruction, plus a `0–1` brightness slider with a mono percentage readout. A `brightness()` filter derived from the slider value is applied to the whole swatch preview so the user can adjust until each band matches its instruction. Drops the game-components fantasy chrome in favour of a flat slate card with a hairline-separated swatch grid and the shared `.form-range` slider. diff --git a/examples/src/web-components/ManaBarDemo.tsx b/examples/src/web-components/ManaBarDemo.tsx new file mode 100644 index 00000000..0eba06eb --- /dev/null +++ b/examples/src/web-components/ManaBarDemo.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const ManaBarDemo: React.FC = () => { + return ( +
    +
    +
    +
    + Web Components} + title="ManaBar" + description="Value/max resource bar for mana (MP) — a cyan-accent fill over a flat slate track. Supports an optional label row with a mono value/max readout, a ghost band for recent loss, inline mono text, and evenly-spaced segment dividers." + /> + +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + +
    +
    +
    +
    +
    + ) +} + +export default ManaBarDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index fc7dc277..fad7d3bd 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -98,6 +98,7 @@ import BossBarDemo from './BossBarDemo' import BuffBarDemo from './BuffBarDemo' import BuffIconDemo from './BuffIconDemo' import HealthBarDemo from './HealthBarDemo' +import ManaBarDemo from './ManaBarDemo' import BrightnessCalibrationDemo from './BrightnessCalibrationDemo' import CalloutQuoteDemo from './CalloutQuoteDemo' import ChartContainerDemo from './ChartContainerDemo' @@ -413,6 +414,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'boss-bar', category: 'Content', element: }, { key: 'buff-bar', category: 'Content', element: }, { key: 'health-bar', category: 'Content', element: }, + { key: 'mana-bar', category: 'Content', element: }, { key: 'buff-icon', category: 'Content', element: }, { key: 'brightness-calibration', category: 'Components', element: }, { key: 'callout-quote', category: 'Content', element: }, diff --git a/web-components/src/ManaBar.ts b/web-components/src/ManaBar.ts new file mode 100644 index 00000000..2b84ceb0 --- /dev/null +++ b/web-components/src/ManaBar.ts @@ -0,0 +1,121 @@ +import { escapeHtml, renderResourceBarTrack } from './internal/resourceBar' + +const TAG_NAME = 'tc-mana-bar' + +export class ManaBar extends HTMLElement { + private _initialised = false + + static get observedAttributes(): string[] { + return ['value', 'max', 'ghost', 'segments', 'show-text', 'label'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get value(): number { + const raw = this.getAttribute('value') + if (raw == null) return 0 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) ? 0 : parsed + } + set value(v: number) { + this.setAttribute('value', String(v)) + } + + get max(): number { + const raw = this.getAttribute('max') + if (raw == null) return 100 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) || parsed <= 0 ? 100 : parsed + } + set max(v: number) { + this.setAttribute('max', String(v)) + } + + get ghost(): number | null { + const raw = this.getAttribute('ghost') + if (raw == null) return null + const parsed = parseFloat(raw) + return Number.isNaN(parsed) ? null : parsed + } + set ghost(v: number | null) { + if (v == null) this.removeAttribute('ghost') + else this.setAttribute('ghost', String(v)) + } + + get segments(): number { + const raw = this.getAttribute('segments') + if (raw == null) return 1 + const parsed = parseInt(raw, 10) + return Number.isNaN(parsed) || parsed < 1 ? 1 : parsed + } + set segments(v: number) { + this.setAttribute('segments', String(v)) + } + + get showText(): boolean { + return this.hasAttribute('show-text') + } + set showText(v: boolean) { + if (v) this.setAttribute('show-text', '') + else this.removeAttribute('show-text') + } + + get label(): string { + return this.getAttribute('label') ?? '' + } + set label(v: string) { + this.setAttribute('label', v) + } + + private render(): void { + const max = this.max + const value = Math.max(0, Math.min(max, this.value)) + const label = this.label + const showText = this.showText + const segments = this.segments + + this.classList.add('tc-mana-bar') + + const numericText = `${Math.round(value)} / ${Math.round(max)}` + + const ticks = + segments > 1 + ? Array.from({ length: segments - 1 }, (_, i) => (i + 1) / segments) + : [] + + const labelRow = label + ? `
    ` + + `${escapeHtml(label)}` + + `${numericText}` + + `
    ` + : '' + + const track = renderResourceBarTrack({ + prefix: 'tc-mana-bar', + value, + max, + ghost: this.ghost, + ticks, + label: label || 'Mana', + inlineText: showText && !label ? numericText : null, + }) + + this.innerHTML = labelRow + track + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: ManaBar + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index c07b81f7..cc1c4695 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -101,6 +101,7 @@ export * from './BossBar' export * from './BuffBar' export * from './BuffIcon' export * from './HealthBar' +export * from './ManaBar' export * from './Hotbar' export * from './InventoryGrid' export * from './ItemSlot' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index e81211b0..417d59d7 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -100,6 +100,7 @@ import { BossBar } from './BossBar' import { BuffBar } from './BuffBar' import { BuffIcon } from './BuffIcon' import { HealthBar } from './HealthBar' +import { ManaBar } from './ManaBar' import { Hotbar } from './Hotbar' import { InventoryGrid } from './InventoryGrid' import { ItemSlot } from './ItemSlot' @@ -407,6 +408,7 @@ export function register(): void { customElements.define('tc-buff-icon', BuffIcon) customElements.define('tc-buff-bar', BuffBar) customElements.define('tc-health-bar', HealthBar) + customElements.define('tc-mana-bar', ManaBar) customElements.define('tc-brightness-calibration', BrightnessCalibration) customElements.define('tc-callout-quote', CalloutQuote) customElements.define('tc-chart-container', ChartContainer) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 1bd4118a..d2762357 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -113,6 +113,7 @@ @forward 'buff-bar'; @forward 'buff-icon'; @forward 'health-bar'; +@forward 'mana-bar'; @forward 'brightness-calibration'; @forward 'callout-quote'; @forward 'chart-container'; diff --git a/web-components/style/components/_mana-bar.scss b/web-components/style/components/_mana-bar.scss new file mode 100644 index 00000000..27d638f5 --- /dev/null +++ b/web-components/style/components/_mana-bar.scss @@ -0,0 +1,117 @@ +// tc-mana-bar — value/max resource bar for mana (MP) in a game HUD. +// Structurally identical to tc-health-bar; styled with a cyan-accent fill +// so the two bars are visually distinct at a glance. All cosmetics flow through +// --bs-mana-bar-* custom properties backed by --tc-* tokens. Sharp corners; +// slate neutrals; JetBrains Mono for machine-facing readout; 1px hairline track. + +@use '../foundation/tokens' as *; + +tc-mana-bar { + --bs-mana-bar-gap: 0.375rem; + + --bs-mana-bar-label-color: var(--tc-text); + --bs-mana-bar-label-font-size: 0.8125rem; + --bs-mana-bar-label-font-weight: 500; + + --bs-mana-bar-value-color: var(--tc-text-muted); + --bs-mana-bar-value-font-size: 0.75rem; + + --bs-mana-bar-track-bg: var(--tc-slate-200); + --bs-mana-bar-track-height: 0.625rem; + // Cyan accent fills the mana bar — the one place cyan-as-information is appropriate + // in the HUD (mana is conventionally blue; --tc-accent is the nearest design-system token). + --bs-mana-bar-fill-bg: var(--tc-accent); + --bs-mana-bar-fill-transition: width var(--tc-transition-base); + --bs-mana-bar-ghost-bg: var(--tc-slate-400); + --bs-mana-bar-tick-color: var(--tc-surface); + --bs-mana-bar-inline-text-color: var(--tc-text-muted); +} + +.tc-mana-bar { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: var(--bs-mana-bar-gap); + width: 100%; +} + +// Label row — human-readable label (left) + mono value/max readout pushed right. +.tc-mana-bar__label-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; + min-width: 0; +} + +.tc-mana-bar__label { + min-width: 0; + font-size: var(--bs-mana-bar-label-font-size); + font-weight: var(--bs-mana-bar-label-font-weight); + line-height: 1.3; + color: var(--bs-mana-bar-label-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tc-mana-bar__label-value { + flex-shrink: 0; + font-family: 'JetBrains Mono', var(--bs-font-monospace, monospace); + font-size: var(--bs-mana-bar-value-font-size); + letter-spacing: 0.04em; + color: var(--bs-mana-bar-value-color); +} + +// Track — flat slate well; the fill, ghost band, and segment ticks all stack +// absolutely inside it (the shared resourceBar helper paints these elements). +.tc-mana-bar__track { + position: relative; + width: 100%; + height: var(--bs-mana-bar-track-height); + background: var(--bs-mana-bar-track-bg); + border-radius: 0; + overflow: hidden; +} + +.tc-mana-bar__ghost { + position: absolute; + inset: 0 auto 0 0; + height: 100%; + background: var(--bs-mana-bar-ghost-bg); +} + +.tc-mana-bar__fill { + position: absolute; + inset: 0 auto 0 0; + height: 100%; + background: var(--bs-mana-bar-fill-bg); + transition: var(--bs-mana-bar-fill-transition); +} + +// Segment dividers — thin vertical hairlines splitting the track into equal slots. +.tc-mana-bar__tick { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + transform: translateX(-1px); + background: var(--bs-mana-bar-tick-color); +} + +.tc-mana-bar__inline-text { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-family: 'JetBrains Mono', var(--bs-font-monospace, monospace); + font-size: 0.625rem; + color: var(--bs-mana-bar-inline-text-color); +} + +@media (prefers-reduced-motion: reduce) { + .tc-mana-bar__fill { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index ad638e20..a868cfb7 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -179,6 +179,7 @@ tc-bundle-bar, tc-boss-bar, tc-buff-bar, tc-health-bar, +tc-mana-bar, tc-brightness-calibration, tc-callout-quote, tc-character-create, From d3c20226377caeab25962220d96dfd8a81bf268a Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 17 Jun 2026 20:48:25 +0000 Subject: [PATCH 340/632] 334-matchmaking-screen: Build the tc-matchmaking-screen web component (MatchmakingScreen game-components port) --- examples/public/web-components/SKILL.md | 118 ++++++++ .../web-components/MatchmakingScreenDemo.tsx | 113 +++++++ examples/src/web-components/index.tsx | 2 + web-components/src/MatchmakingScreen.ts | 255 ++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_matchmaking-screen.scss | 277 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 6 + 9 files changed, 775 insertions(+) create mode 100644 examples/src/web-components/MatchmakingScreenDemo.tsx create mode 100644 web-components/src/MatchmakingScreen.ts create mode 100644 web-components/style/components/_matchmaking-screen.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 1558bd52..87eac45d 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -230,6 +230,7 @@ After `register()` you can author markup directly: - [tc-loot-popup](#tc-loot-popup) - [tc-lore-text](#tc-lore-text) - [tc-main-menu](#tc-main-menu) + - [tc-matchmaking-screen](#tc-matchmaking-screen) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) - [tc-live-feed](#tc-live-feed) @@ -21625,3 +21626,120 @@ Main-menu container of menu items. Port of `gc-main-menu` (game-components), res }) ``` + +--- + +### tc-matchmaking-screen + +Matchmaking / searching status panel with a state indicator ring, eyebrow + title header, optional meta strip (Mode, Region, Elapsed, ETA), and accept/cancel action buttons. Port of `gc-matchmaking-screen` (game-components), restyled to the toolcase design system: slate surface, sharp panel, 1px hairline borders, JetBrains Mono for machine-facing meta values, ink accent for the primary Accept action. Game chrome (gilded frames, glows, fantasy textures) is replaced with the slate neutral ramp. All cosmetics flow through `--bs-matchmaking-screen-*` custom properties. + +**Tag:** `tc-matchmaking-screen` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `state` | `'idle' \| 'searching' \| 'connecting' \| 'found' \| 'failed'` | `'idle'` | Current matchmaking state. Controls the ring icon, eyebrow, title, meta rows, and which action buttons appear. Invalid values fall back to `'idle'`. | +| `elapsed` | number | `0` | Seconds elapsed since matchmaking started. Shown as `M:SS` in the meta strip when `state` is `searching` or `connecting`. | +| `estimated` | number | `0` | Estimated total wait time in seconds. Shown as `M:SS` ETA when > 0 and `state` is `searching` or `connecting`. | +| `region` | string | `''` | Region label (e.g. `"EU-West"`). Shown in the meta strip when non-empty. Omit to hide the Region cell. | +| `mode` | string | `''` | Game mode label (e.g. `"3v3 Ranked"`). Shown in the meta strip when non-empty. Omit to hide the Mode cell. | +| `found-label` | string | `'Match Found'` | Title shown when `state="found"`. Override for localisation or custom copy. | + +**JS Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `state` | `MatchmakingScreenState` | `'idle'` | Reflects the `state` attribute. | +| `elapsed` | `number` | `0` | Reflects the `elapsed` attribute. | +| `estimated` | `number` | `0` | Reflects the `estimated` attribute. | +| `region` | `string` | `''` | Reflects the `region` attribute. | +| `mode` | `string` | `''` | Reflects the `mode` attribute. | +| `foundLabel` | `string` | `'Match Found'` | Reflects the `found-label` attribute. | +| `onAccept` | `(() => void) \| null` | `null` | Optional callback fired alongside `tc-accept`. | +| `onCancel` | `(() => void) \| null` | `null` | Optional callback fired alongside `tc-cancel`. | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-accept` | `{}` | Fired when the Accept button is clicked (only shown when `state="found"`). Bubbles and is composed. | +| `tc-cancel` | `{}` | Fired when the Cancel button (or Decline / Close) is clicked. Bubbles and is composed. | + +**Slots** + +`tc-matchmaking-screen` has no slots. All content is driven by attributes and JS properties. + +**State → UI mapping** + +| `state` | Ring | Eyebrow | Title | Meta | Buttons | +|---------|------|---------|-------|------|---------| +| `idle` | static dot | Matchmaking | Idle | none | none | +| `searching` | spinning arc (accent) | Matchmaking | Searching for Match | Mode, Region, Elapsed, ETA | Cancel | +| `connecting` | spinning arc (accent) | Joining | Connecting | Mode, Region, Elapsed, ETA | Cancel | +| `found` | check circle (success green) | Ready | `found-label` value | Mode, Region | Accept + Decline | +| `failed` | × circle (danger red) | Error | Match Failed | Mode, Region | Close | + +**CSS custom properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-matchmaking-screen-bg` | `var(--tc-surface)` | Panel background. | +| `--bs-matchmaking-screen-border` | `var(--tc-border)` | Outer 1px hairline border. | +| `--bs-matchmaking-screen-max-width` | `380px` | Maximum panel width. | +| `--bs-matchmaking-screen-padding-x` | `1.5rem` | Horizontal padding. | +| `--bs-matchmaking-screen-padding-y` | `2rem` | Vertical padding. | +| `--bs-matchmaking-screen-gap` | `1.25rem` | Gap between panel sections. | +| `--bs-matchmaking-screen-ring-size` | `4rem` | Diameter of the state indicator ring (sanctioned circle). | +| `--bs-matchmaking-screen-ring-border` | `var(--tc-border)` | Idle ring border colour. | +| `--bs-matchmaking-screen-ring-color` | `var(--tc-text-muted)` | Idle ring icon colour. | +| `--bs-matchmaking-screen-ring-spin-color` | `var(--tc-app-accent)` | Ring border + icon colour when searching or connecting. | +| `--bs-matchmaking-screen-ring-found-border` | `var(--tc-success)` | Ring border colour when `state="found"`. | +| `--bs-matchmaking-screen-ring-found-color` | `var(--tc-success)` | Check icon colour when `state="found"`. | +| `--bs-matchmaking-screen-ring-failed-border` | `var(--tc-danger)` | Ring border colour when `state="failed"`. | +| `--bs-matchmaking-screen-ring-failed-color` | `var(--tc-danger)` | × icon colour when `state="failed"`. | +| `--bs-matchmaking-screen-eyebrow-color` | `var(--tc-text-muted)` | Eyebrow label colour. | +| `--bs-matchmaking-screen-eyebrow-size` | `10.5px` | Eyebrow font size. | +| `--bs-matchmaking-screen-title-color` | `var(--tc-text)` | Title colour. | +| `--bs-matchmaking-screen-title-size` | `1.0625rem` | Title font size. | +| `--bs-matchmaking-screen-title-weight` | `600` | Title font weight. | +| `--bs-matchmaking-screen-meta-bg` | `var(--tc-surface-muted)` | Meta strip background. | +| `--bs-matchmaking-screen-meta-border` | `var(--tc-border)` | Meta strip cell dividers. | +| `--bs-matchmaking-screen-meta-key-color` | `var(--tc-text-faint)` | Meta key label colour. | +| `--bs-matchmaking-screen-meta-val-color` | `var(--tc-text)` | Meta value colour. | +| `--bs-matchmaking-screen-btn-bg` | `var(--tc-surface)` | Default button background. | +| `--bs-matchmaking-screen-btn-color` | `var(--tc-text)` | Default button text colour. | +| `--bs-matchmaking-screen-btn-border` | `var(--tc-border)` | Default button border colour. | +| `--bs-matchmaking-screen-btn-hover-bg` | `var(--tc-surface-hover, var(--tc-surface-muted))` | Button hover background. | +| `--bs-matchmaking-screen-btn-accept-bg` | `var(--tc-app-accent)` | Accept button fill (ink). | +| `--bs-matchmaking-screen-btn-accept-color` | `#fff` | Accept button text colour. | +| `--bs-matchmaking-screen-btn-min-height` | `2.25rem` | Button minimum height (44 px under coarse pointer). | + +**Example** + +```html + + + +``` diff --git a/examples/src/web-components/MatchmakingScreenDemo.tsx b/examples/src/web-components/MatchmakingScreenDemo.tsx new file mode 100644 index 00000000..4334e15d --- /dev/null +++ b/examples/src/web-components/MatchmakingScreenDemo.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +type State = 'searching' | 'connecting' | 'found' | 'failed' | 'idle' + +const MatchmakingScreenDemo: React.FC = () => { + const [state, setState] = useState('idle') + const [elapsed, setElapsed] = useState(0) + const [log, setLog] = useState([]) + + const addLog = (msg: string): void => setLog(l => [msg, ...l].slice(0, 6)) + + // Tick elapsed while actively searching / connecting + useEffect(() => { + if (state !== 'searching' && state !== 'connecting') return + const id = window.setInterval(() => setElapsed(s => s + 1), 1000) + return () => window.clearInterval(id) + }, [state]) + + const eventRef = useRef(null) + useEffect(() => { + const el = eventRef.current + if (!el) return + const onAccept = () => addLog('tc-accept fired') + const onCancel = () => { addLog('tc-cancel fired'); setState('idle'); setElapsed(0) } + el.addEventListener('tc-accept', onAccept) + el.addEventListener('tc-cancel', onCancel) + return () => { + el.removeEventListener('tc-accept', onAccept) + el.removeEventListener('tc-cancel', onCancel) + } + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="Matchmaking Screen" + description="Status and cancel panel for multiplayer matchmaking. Driven by the state attribute (idle, searching, connecting, found, failed) with mode, region, elapsed, and estimated meta." + /> + +
    + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {(['idle', 'searching', 'connecting', 'found', 'failed'] as State[]).map(s => ( + + ))} +
    + {/* @ts-ignore */} + +
    + Event log + {log.length === 0 ? ( + Set state to found or searching/connecting, then click a button… + ) : ( +
      + {log.map((line, i) => ( +
    • {line}
    • + ))} +
    + )} +
    +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    + ) +} + +export default MatchmakingScreenDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index fad7d3bd..4d2facdd 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -234,6 +234,7 @@ import KillFeedDemo from './KillFeedDemo' import ListDemo from './ListDemo' import ListRowDemo from './ListRowDemo' import LobbyDemo from './LobbyDemo' +import MatchmakingScreenDemo from './MatchmakingScreenDemo' import MainMenuDemo from './MainMenuDemo' import LootListDemo from './LootListDemo' import LootPopupDemo from './LootPopupDemo' @@ -551,6 +552,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'list', category: 'Components', element: }, { key: 'list-row', category: 'Components', element: }, { key: 'lobby', category: 'Components', element: }, + { key: 'matchmaking-screen', category: 'Components', element: }, { key: 'main-menu', category: 'Components', element: }, { key: 'loot-list', category: 'Components', element: }, { key: 'loot-popup', category: 'Components', element: }, diff --git a/web-components/src/MatchmakingScreen.ts b/web-components/src/MatchmakingScreen.ts new file mode 100644 index 00000000..71791ed5 --- /dev/null +++ b/web-components/src/MatchmakingScreen.ts @@ -0,0 +1,255 @@ +const TAG_NAME = 'tc-matchmaking-screen' + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +export type MatchmakingScreenState = 'searching' | 'connecting' | 'found' | 'failed' | 'idle' + +const STATES: MatchmakingScreenState[] = ['searching', 'connecting', 'found', 'failed', 'idle'] + +export interface MatchmakingScreenEventMap { + 'tc-accept': CustomEvent> + 'tc-cancel': CustomEvent> +} + +export class MatchmakingScreen extends HTMLElement { + + private _initialised = false + + onAccept: (() => void) | null = null + onCancel: (() => void) | null = null + + static get observedAttributes(): string[] { + return ['state', 'elapsed', 'estimated', 'region', 'mode', 'found-label'] + } + + connectedCallback(): void { + if (!this._initialised) { + if (!this.hasAttribute('role')) this.setAttribute('role', 'region') + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get state(): MatchmakingScreenState { + const raw = this.getAttribute('state') as MatchmakingScreenState | null + return (raw && STATES.includes(raw)) ? raw : 'idle' + } + set state(v: MatchmakingScreenState) { + if (v) this.setAttribute('state', v) + else this.removeAttribute('state') + } + + get elapsed(): number { + const raw = this.getAttribute('elapsed') + if (raw == null) return 0 + const parsed = parseFloat(raw) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0 + } + set elapsed(v: number) { + this.setAttribute('elapsed', String(v)) + } + + get estimated(): number { + const raw = this.getAttribute('estimated') + if (raw == null) return 0 + const parsed = parseFloat(raw) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0 + } + set estimated(v: number) { + this.setAttribute('estimated', String(v)) + } + + get region(): string { + return this.getAttribute('region') ?? '' + } + set region(v: string) { + if (v) this.setAttribute('region', v) + else this.removeAttribute('region') + } + + get mode(): string { + return this.getAttribute('mode') ?? '' + } + set mode(v: string) { + if (v) this.setAttribute('mode', v) + else this.removeAttribute('mode') + } + + get foundLabel(): string { + return this.getAttribute('found-label') ?? 'Match Found' + } + set foundLabel(v: string) { + if (v) this.setAttribute('found-label', v) + else this.removeAttribute('found-label') + } + + private _emit(name: 'tc-accept' | 'tc-cancel'): void { + this.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail: {} })) + if (name === 'tc-accept' && typeof this.onAccept === 'function') this.onAccept() + else if (name === 'tc-cancel' && typeof this.onCancel === 'function') this.onCancel() + } + + private _formatDuration(seconds: number): string { + const total = Math.max(0, Math.floor(seconds)) + const m = Math.floor(total / 60) + const s = total % 60 + return `${m}:${String(s).padStart(2, '0')}` + } + + private _titleFor(state: MatchmakingScreenState): string { + switch (state) { + case 'searching': return 'Searching for Match' + case 'connecting': return 'Connecting' + case 'found': return this.foundLabel + case 'failed': return 'Match Failed' + case 'idle': + default: return 'Idle' + } + } + + private _eyebrowFor(state: MatchmakingScreenState): string { + switch (state) { + case 'searching': return 'Matchmaking' + case 'connecting': return 'Joining' + case 'found': return 'Ready' + case 'failed': return 'Error' + case 'idle': + default: return 'Matchmaking' + } + } + + // Returns the SVG icon string for the state indicator ring + private _iconFor(state: MatchmakingScreenState): string { + switch (state) { + case 'searching': + case 'connecting': + // Lucide Loader-2 (spinning arcs) + return `` + case 'found': + // Lucide Check + return `` + case 'failed': + // Lucide X + return `` + default: + // Lucide Minus (idle) + return `` + } + } + + private render(): void { + const state = this.state + const elapsed = this.elapsed + const estimated = this.estimated + const region = this.region + const mode = this.mode + + // Meta row — Mode, Region, elapsed, ETA (only for active searching/connecting) + const metaItems: string[] = [] + if (mode) metaItems.push( + `
    + Mode + ${esc(mode)} +
    ` + ) + if (region) metaItems.push( + `
    + Region + ${esc(region)} +
    ` + ) + if (state === 'searching' || state === 'connecting') { + metaItems.push( + `
    + Elapsed + ${esc(this._formatDuration(elapsed))} +
    ` + ) + if (estimated > 0) metaItems.push( + `
    + ETA + ${esc(this._formatDuration(estimated))} +
    ` + ) + } + const metaMarkup = metaItems.length + ? `
    ${metaItems.join('')}
    ` + : '' + + // State indicator ring — spinning for active, check for found, x for failed + const isActive = state === 'searching' || state === 'connecting' + const ringMod = isActive ? ' tc-matchmaking-screen-ring--spin' + : state === 'found' ? ' tc-matchmaking-screen-ring--found' + : state === 'failed' ? ' tc-matchmaking-screen-ring--failed' + : '' + + const ringMarkup = `` + + // Action buttons + let actionsMarkup = '' + if (state === 'found') { + actionsMarkup = ` + + ` + } else if (state === 'searching' || state === 'connecting' || state === 'failed') { + const cancelLabel = state === 'failed' ? 'Close' : 'Cancel' + actionsMarkup = `` + } + + this.innerHTML = ` +
    + ${ringMarkup} +
    +
    ${esc(this._eyebrowFor(state))}
    +
    ${esc(this._titleFor(state))}
    +
    + ${metaMarkup} + ${actionsMarkup ? `
    ${actionsMarkup}
    ` : ''} +
    + ` + + // Delegate clicks on the fresh actions bar + const actions = this.querySelector('.tc-matchmaking-screen-actions') + actions?.addEventListener('click', (e: MouseEvent) => { + const btn = (e.target as HTMLElement).closest('[data-action]') + if (!btn || btn.disabled) return + const action = btn.dataset.action + if (action === 'accept') this._emit('tc-accept') + else if (action === 'cancel') this._emit('tc-cancel') + }) + } +} + +declare global { + interface HTMLElementTagNameMap { [TAG_NAME]: MatchmakingScreen } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index cc1c4695..67cab7fa 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -300,6 +300,7 @@ export * from './LevelSelect' export * from './LoadingOverlay' export * from './LoadingScreen' export * from './Lobby' +export * from './MatchmakingScreen' export * from './MainMenu' export * from './LootList' export * from './LootPopup' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 417d59d7..400f90f1 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -297,6 +297,7 @@ import { LevelSelect } from './LevelSelect' import { LoadingOverlay } from './LoadingOverlay' import { LoadingScreen } from './LoadingScreen' import { Lobby } from './Lobby' +import { MatchmakingScreen } from './MatchmakingScreen' import { MainMenu } from './MainMenu' import { LootList } from './LootList' import { LootPopup } from './LootPopup' @@ -607,6 +608,7 @@ export function register(): void { customElements.define('tc-loading-overlay', LoadingOverlay) customElements.define('tc-loading-screen', LoadingScreen) customElements.define('tc-lobby', Lobby) + customElements.define('tc-matchmaking-screen', MatchmakingScreen) customElements.define('tc-main-menu', MainMenu) customElements.define('tc-loot-list', LootList) customElements.define('tc-loot-popup', LootPopup) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index d2762357..610edd2b 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -297,3 +297,4 @@ @forward 'loot-list'; @forward 'loot-popup'; @forward 'lore-text'; +@forward 'matchmaking-screen'; diff --git a/web-components/style/components/_matchmaking-screen.scss b/web-components/style/components/_matchmaking-screen.scss new file mode 100644 index 00000000..79125358 --- /dev/null +++ b/web-components/style/components/_matchmaking-screen.scss @@ -0,0 +1,277 @@ +// tc-matchmaking-screen — searching / connecting / found / failed / idle +// state panel with a status ring, eyebrow + title header, meta strip, and +// accept/cancel actions. Ported from gc-matchmaking-screen (game-components) +// but restyled to the toolcase design system: slate surface, sharp panel, +// hairline borders, JetBrains Mono for machine-facing text, ink accent for +// primary actions. No game chrome (no glows, no gilded frames, no textures). +// All cosmetics flow through --bs-matchmaking-screen-* custom properties. + +@use '../foundation/tokens' as *; + +// ── Component defaults ─────────────────────────────────────────────────────── + +tc-matchmaking-screen { + --bs-matchmaking-screen-bg: var(--tc-surface); + --bs-matchmaking-screen-border: var(--tc-border); + --bs-matchmaking-screen-max-width: 380px; + --bs-matchmaking-screen-padding-x: 1.5rem; + --bs-matchmaking-screen-padding-y: 2rem; + --bs-matchmaking-screen-gap: 1.25rem; + + // Ring + --bs-matchmaking-screen-ring-size: 4rem; + --bs-matchmaking-screen-ring-border: var(--tc-border); + --bs-matchmaking-screen-ring-color: var(--tc-text-muted); + --bs-matchmaking-screen-ring-spin-color: var(--tc-app-accent); + --bs-matchmaking-screen-ring-found-bg: transparent; + --bs-matchmaking-screen-ring-found-border: var(--tc-success); + --bs-matchmaking-screen-ring-found-color: var(--tc-success); + --bs-matchmaking-screen-ring-failed-border: var(--tc-danger); + --bs-matchmaking-screen-ring-failed-color: var(--tc-danger); + + // Eyebrow (mono micro-label) + --bs-matchmaking-screen-eyebrow-color: var(--tc-text-muted); + --bs-matchmaking-screen-eyebrow-size: 10.5px; + + // Title + --bs-matchmaking-screen-title-color: var(--tc-text); + --bs-matchmaking-screen-title-size: 1.0625rem; + --bs-matchmaking-screen-title-weight: 600; + + // Meta strip (key/value pairs: Mode, Region, Elapsed, ETA) + --bs-matchmaking-screen-meta-bg: var(--tc-surface-muted); + --bs-matchmaking-screen-meta-border: var(--tc-border); + --bs-matchmaking-screen-meta-key-color: var(--tc-text-faint); + --bs-matchmaking-screen-meta-val-color: var(--tc-text); + + // Buttons + --bs-matchmaking-screen-btn-color: var(--tc-text); + --bs-matchmaking-screen-btn-bg: var(--tc-surface); + --bs-matchmaking-screen-btn-border: var(--tc-border); + --bs-matchmaking-screen-btn-hover-bg: var(--tc-surface-hover, var(--tc-surface-muted)); + --bs-matchmaking-screen-btn-accept-bg: var(--tc-app-accent); + --bs-matchmaking-screen-btn-accept-color: #fff; + --bs-matchmaking-screen-btn-min-height: 2.25rem; +} + +// ── Host ───────────────────────────────────────────────────────────────────── + +tc-matchmaking-screen { + display: block; +} + +// ── Root panel ─────────────────────────────────────────────────────────────── + +.tc-matchmaking-screen { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: var(--bs-matchmaking-screen-gap); + padding: var(--bs-matchmaking-screen-padding-y) var(--bs-matchmaking-screen-padding-x); + background: var(--bs-matchmaking-screen-bg); + border: 1px solid var(--bs-matchmaking-screen-border); + border-radius: 0; + width: 100%; + max-width: var(--bs-matchmaking-screen-max-width); + box-sizing: border-box; +} + +// ── State indicator ring ───────────────────────────────────────────────────── +// A sanctioned circle (per design system — spinner rings are the exception). +// Sharp panels elsewhere; this ring is the focal visual element of the screen. + +.tc-matchmaking-screen-ring { + display: flex; + align-items: center; + justify-content: center; + width: var(--bs-matchmaking-screen-ring-size); + height: var(--bs-matchmaking-screen-ring-size); + border-radius: 50%; // sanctioned circle + border: 1px solid var(--bs-matchmaking-screen-ring-border); + color: var(--bs-matchmaking-screen-ring-color); + flex-shrink: 0; +} + +// Searching / connecting — accent colour, spinning icon +.tc-matchmaking-screen-ring--spin { + border-color: var(--bs-matchmaking-screen-ring-spin-color); + color: var(--bs-matchmaking-screen-ring-spin-color); +} + +// Found — success green ring + check +.tc-matchmaking-screen-ring--found { + border-color: var(--bs-matchmaking-screen-ring-found-border); + color: var(--bs-matchmaking-screen-ring-found-color); + background: var(--bs-matchmaking-screen-ring-found-bg); +} + +// Failed — danger red ring + x +.tc-matchmaking-screen-ring--failed { + border-color: var(--bs-matchmaking-screen-ring-failed-border); + color: var(--bs-matchmaking-screen-ring-failed-color); +} + +// Icon inside the ring — sized down to fit comfortably +.tc-matchmaking-screen-icon { + width: 1.5rem; + height: 1.5rem; +} + +// Spin animation — continuous rotation on the loader arc +@keyframes tc-matchmaking-screen-spin { + to { transform: rotate(360deg); } +} + +.tc-matchmaking-screen-ring--spin .tc-matchmaking-screen-icon { + animation: tc-matchmaking-screen-spin 1s linear infinite; +} + +// ── Header: eyebrow + title ───────────────────────────────────────────────── + +.tc-matchmaking-screen-header { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +// Eyebrow — mono micro-label, uppercase, muted; identifies the screen context +.tc-matchmaking-screen-eyebrow { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-matchmaking-screen-eyebrow-size); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--bs-matchmaking-screen-eyebrow-color); + line-height: 1.4; +} + +// Title — state-driven heading +.tc-matchmaking-screen-title { + font-size: var(--bs-matchmaking-screen-title-size); + font-weight: var(--bs-matchmaking-screen-title-weight); + color: var(--bs-matchmaking-screen-title-color); + line-height: 1.3; +} + +// ── Meta strip ─────────────────────────────────────────────────────────────── +// Stacked key/value pairs (Mode, Region, Elapsed, ETA) in a bordered box +// below the header. JetBrains Mono for all machine-facing values. + +.tc-matchmaking-screen-meta { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0; + background: var(--bs-matchmaking-screen-meta-bg); + border: 1px solid var(--bs-matchmaking-screen-meta-border); + border-radius: 0; + width: 100%; + overflow: hidden; +} + +.tc-matchmaking-screen-meta-item { + display: flex; + flex-direction: column; + gap: 0.0625rem; + padding: 0.375rem 0.75rem; + border-right: 1px solid var(--bs-matchmaking-screen-meta-border); + + &:last-child { + border-right: none; + } +} + +.tc-matchmaking-screen-meta-key { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: 0.5625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--bs-matchmaking-screen-meta-key-color); +} + +.tc-matchmaking-screen-meta-val { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: 0.8125rem; + font-weight: 500; + color: var(--bs-matchmaking-screen-meta-val-color); + font-variant-numeric: tabular-nums; +} + +// ── Actions ────────────────────────────────────────────────────────────────── + +.tc-matchmaking-screen-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: 100%; +} + +.tc-matchmaking-screen-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 1rem; + min-height: var(--bs-matchmaking-screen-btn-min-height); + border: 1px solid var(--bs-matchmaking-screen-btn-border); + border-radius: 0; + background: var(--bs-matchmaking-screen-btn-bg); + color: var(--bs-matchmaking-screen-btn-color); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: + background-color var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease), + opacity var(--tc-transition-fast, 0.15s ease); + + &:hover:not(:disabled) { + background: var(--bs-matchmaking-screen-btn-hover-bg); + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + // Accept (or primary action) — ink fill + &.tc-matchmaking-screen-btn--accept { + background: var(--bs-matchmaking-screen-btn-accept-bg); + color: var(--bs-matchmaking-screen-btn-accept-color); + border-color: var(--bs-matchmaking-screen-btn-accept-bg); + font-weight: 600; + + &:hover:not(:disabled) { + opacity: 0.88; + background: var(--bs-matchmaking-screen-btn-accept-bg); + } + } +} + +// ── Coarse pointer — 44 px minimum touch targets ───────────────────────────── + +@media (pointer: coarse) { + .tc-matchmaking-screen-btn { + --bs-matchmaking-screen-btn-min-height: 44px; + } +} + +// ── Reduced motion ─────────────────────────────────────────────────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-matchmaking-screen-ring--spin .tc-matchmaking-screen-icon { + animation: none; + } + + .tc-matchmaking-screen-btn { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index a868cfb7..0a585efe 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -405,6 +405,12 @@ tc-loading-screen { display: block; } +// Matchmaking status panel; the SCSS partial owns the inner flex layout, +// but the host must not default to inline. +tc-matchmaking-screen { + display: block; +} + tc-editable-text, tc-chip, tc-button, From cb37d9b1f1dde98c42666780a4010ebe94efae5e Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 17 Jun 2026 20:57:03 +0000 Subject: [PATCH 341/632] 335-menu-item: Build the tc-menu-item web component (MenuItem game-components port) --- examples/public/web-components/SKILL.md | 91 ++++++++++ examples/src/web-components/MenuItemDemo.tsx | 119 +++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/MenuItem.ts | 165 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_menu-item.scss | 162 +++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 544 insertions(+) create mode 100644 examples/src/web-components/MenuItemDemo.tsx create mode 100644 web-components/src/MenuItem.ts create mode 100644 web-components/style/components/_menu-item.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 87eac45d..769e3c30 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -230,6 +230,7 @@ After `register()` you can author markup directly: - [tc-loot-popup](#tc-loot-popup) - [tc-lore-text](#tc-lore-text) - [tc-main-menu](#tc-main-menu) + - [tc-menu-item](#tc-menu-item) - [tc-matchmaking-screen](#tc-matchmaking-screen) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) @@ -21629,6 +21630,96 @@ Main-menu container of menu items. Port of `gc-main-menu` (game-components), res --- +### tc-menu-item + +Single interactive menu row — icon, label, optional hotkey badge, selected/disabled states. Port of `gc-menu-item` (game-components), restyled to the toolcase design system: slate neutrals, sharp corners, 1px hairline border, ink accent for the selected state, JetBrains Mono for the hotkey badge. Fires `tc-select` on click or Enter/Space. No shadow root; light DOM; `display: block` (overridden to `display: flex` in the component partial). + +**Tag:** `tc-menu-item` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `label` | string | `''` | Display label for the menu row. XSS-escaped before rendering. | +| `icon` | string | `''` | Lucide icon name in kebab-case (e.g. `"play"`, `"settings"`, `"log-out"`). Rendered as inline SVG. Absent or unrecognised names suppress the icon region. | +| `hotkey` | string | `''` | Keyboard shortcut text shown trailing the label in a monospace badge (e.g. `"Ctrl+S"`, `"Esc"`). Absent means no badge. | +| `selected` | boolean | absent | Marks the row as the active item. Adds `.tc-menu-item--selected`, applies the ink accent fill, and sets `aria-current="true"`. | +| `disabled` | boolean | absent | Disables interaction. Adds `.tc-menu-item--disabled`, sets `aria-disabled="true"`, removes `tabindex`. `tc-select` is suppressed. | + +**JS Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `label` | `string` | `''` | Reflects the `label` attribute. | +| `icon` | `string` | `''` | Reflects the `icon` attribute. | +| `hotkey` | `string` | `''` | Reflects the `hotkey` attribute. | +| `selected` | `boolean` | `false` | Reflects the `selected` boolean attribute. | +| `disabled` | `boolean` | `false` | Reflects the `disabled` boolean attribute. | +| `onSelect` | `((label: string) => void) \| null` | `null` | Optional callback fired alongside `tc-select`. | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-select` | `{ label: string }` | Fired when the user clicks an enabled item or presses Enter/Space while it is focused. Bubbles and is composed. Suppressed when `disabled`. | + +**Slots** + +`tc-menu-item` has no slots. All content (icon, label, hotkey) is driven by attributes. + +**CSS custom properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-menu-item-bg` | `transparent` | Row background at rest. | +| `--bs-menu-item-color` | `var(--tc-text)` | Text colour at rest. | +| `--bs-menu-item-padding-y` | `0.5rem` | Vertical padding. | +| `--bs-menu-item-padding-x` | `0.875rem` | Horizontal padding. | +| `--bs-menu-item-gap` | `0.5rem` | Gap between icon, label, and hotkey. | +| `--bs-menu-item-min-height` | `2.75rem` | Minimum row height (44 px under coarse pointer). | +| `--bs-menu-item-border-color` | `var(--tc-border)` | Bottom hairline border colour (last child has none). | +| `--bs-menu-item-icon-size` | `1rem` | Width and height of the icon region. | +| `--bs-menu-item-icon-color` | `var(--tc-text-muted)` | Icon colour at rest. | +| `--bs-menu-item-hover-bg` | `var(--tc-surface-muted)` | Background on hover (unselected, enabled). | +| `--bs-menu-item-hover-color` | `var(--tc-text)` | Text colour on hover. | +| `--bs-menu-item-selected-bg` | `var(--tc-app-accent)` | Background when `selected`. | +| `--bs-menu-item-selected-color` | `#fff` | Text colour when `selected`. | +| `--bs-menu-item-selected-icon-color` | `rgba(255,255,255,0.8)` | Icon colour when `selected`. | +| `--bs-menu-item-disabled-opacity` | `0.45` | Opacity when `disabled`. | +| `--bs-menu-item-hotkey-font-family` | `var(--tc-font-mono, 'JetBrains Mono', monospace)` | Hotkey badge font family. | +| `--bs-menu-item-hotkey-font-size` | `0.6875rem` | Hotkey badge font size. | +| `--bs-menu-item-hotkey-color` | `var(--tc-text-muted)` | Hotkey badge text colour at rest. | +| `--bs-menu-item-hotkey-bg` | `var(--tc-surface-muted)` | Hotkey badge background at rest. | +| `--bs-menu-item-hotkey-border` | `1px solid var(--tc-border)` | Hotkey badge border at rest. | +| `--bs-menu-item-hotkey-selected-color` | `rgba(255,255,255,0.85)` | Hotkey badge text when item is `selected`. | +| `--bs-menu-item-hotkey-selected-bg` | `rgba(255,255,255,0.12)` | Hotkey badge background when item is `selected`. | +| `--bs-menu-item-hotkey-selected-border` | `1px solid rgba(255,255,255,0.2)` | Hotkey badge border when item is `selected`. | + +**Example** + +```html + +
    + + + + +
    + + +``` + +--- + ### tc-matchmaking-screen Matchmaking / searching status panel with a state indicator ring, eyebrow + title header, optional meta strip (Mode, Region, Elapsed, ETA), and accept/cancel action buttons. Port of `gc-matchmaking-screen` (game-components), restyled to the toolcase design system: slate surface, sharp panel, 1px hairline borders, JetBrains Mono for machine-facing meta values, ink accent for the primary Accept action. Game chrome (gilded frames, glows, fantasy textures) is replaced with the slate neutral ramp. All cosmetics flow through `--bs-matchmaking-screen-*` custom properties. diff --git a/examples/src/web-components/MenuItemDemo.tsx b/examples/src/web-components/MenuItemDemo.tsx new file mode 100644 index 00000000..8b6264c9 --- /dev/null +++ b/examples/src/web-components/MenuItemDemo.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const MenuItemDemo: React.FC = () => { + const eventsRef = useRef(null) + const [log, setLog] = useState([]) + + useEffect(() => { + const container = eventsRef.current + if (!container) return + const handler = (e: CustomEvent) => { + setLog(l => [`tc-select: label="${e.detail.label}"`, ...l].slice(0, 6)) + } + container.addEventListener('tc-select', handler) + return () => container.removeEventListener('tc-select', handler) + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="Menu Item" + description="A single interactive menu row — icon, label, optional hotkey badge, selected, and disabled states. Port of gc-menu-item (game-components), restyled to the toolcase design system. Fires tc-select on click or Enter/Space. All cosmetics flow through --bs-menu-item-* custom properties." + /> + +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + Event log + {log.length === 0 ? ( + Click an item or press Enter/Space while focused… + ) : ( +
      + {log.map((line, i) => ( +
    • {line}
    • + ))} +
    + )} +
    +
    + +
    +
    +
    +
    +
    + ) +} + +export default MenuItemDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 4d2facdd..84b5c3ac 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -236,6 +236,7 @@ import ListRowDemo from './ListRowDemo' import LobbyDemo from './LobbyDemo' import MatchmakingScreenDemo from './MatchmakingScreenDemo' import MainMenuDemo from './MainMenuDemo' +import MenuItemDemo from './MenuItemDemo' import LootListDemo from './LootListDemo' import LootPopupDemo from './LootPopupDemo' import LiveFeedDemo from './LiveFeedDemo' @@ -554,6 +555,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'lobby', category: 'Components', element: }, { key: 'matchmaking-screen', category: 'Components', element: }, { key: 'main-menu', category: 'Components', element: }, + { key: 'menu-item', category: 'Components', element: }, { key: 'loot-list', category: 'Components', element: }, { key: 'loot-popup', category: 'Components', element: }, { key: 'live-feed', category: 'Components', element: }, diff --git a/web-components/src/MenuItem.ts b/web-components/src/MenuItem.ts new file mode 100644 index 00000000..c59136b0 --- /dev/null +++ b/web-components/src/MenuItem.ts @@ -0,0 +1,165 @@ +import * as LucideIcons from 'lucide-static' +import { icon } from './icons' + +const TAG_NAME = 'tc-menu-item' + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function lucideByName(name: string): string { + const pascal = name + .split('-') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') + const svgStr = (LucideIcons as Record)[pascal] + if (!svgStr) return '' + return icon(svgStr) +} + +export class MenuItem extends HTMLElement { + private _initialised = false + + /** Optional callback fired alongside `tc-select`. */ + onSelect: ((label: string) => void) | null = null + + static get observedAttributes(): string[] { + return ['label', 'hotkey', 'icon', 'selected', 'disabled'] + } + + connectedCallback(): void { + if (!this._initialised) { + if (!this.hasAttribute('role')) this.setAttribute('role', 'menuitem') + this._initialised = true + } + this.render() + this.addEventListener('click', this._onClick) + this.addEventListener('keydown', this._onKeydown) + } + + disconnectedCallback(): void { + this.removeEventListener('click', this._onClick) + this.removeEventListener('keydown', this._onKeydown) + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get label(): string { + return this.getAttribute('label') ?? '' + } + set label(value: string) { + if (value) this.setAttribute('label', value) + else this.removeAttribute('label') + } + + get hotkey(): string { + return this.getAttribute('hotkey') ?? '' + } + set hotkey(value: string) { + if (value) this.setAttribute('hotkey', value) + else this.removeAttribute('hotkey') + } + + // NOTE: `icon` does not collide with any HTMLElement native property (unlike + // `title` or `role`), so the getter/setter is safe here. + get icon(): string { + return this.getAttribute('icon') ?? '' + } + set icon(value: string) { + if (value) this.setAttribute('icon', value) + else this.removeAttribute('icon') + } + + get selected(): boolean { + return this.hasAttribute('selected') + } + set selected(value: boolean) { + if (value) this.setAttribute('selected', '') + else this.removeAttribute('selected') + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(value: boolean) { + if (value) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + private _onClick = (): void => { + if (this.disabled) return + this._emitSelect() + } + + private _onKeydown = (e: KeyboardEvent): void => { + if (e.key !== 'Enter' && e.key !== ' ') return + if (this.disabled) return + e.preventDefault() + this._emitSelect() + } + + private _emitSelect(): void { + const label = this.label + this.dispatchEvent(new CustomEvent('tc-select', { + bubbles: true, + composed: true, + detail: { label }, + })) + if (typeof this.onSelect === 'function') this.onSelect(label) + } + + private render(): void { + const label = this.label + const hotkey = this.hotkey + const iconName = this.icon + const selected = this.selected + const disabled = this.disabled + + // Host modifier classes — use classList to preserve any author-added classes + this.classList.toggle('tc-menu-item--selected', selected) + this.classList.toggle('tc-menu-item--disabled', disabled) + + // ARIA state + if (selected) this.setAttribute('aria-current', 'true') + else this.removeAttribute('aria-current') + + if (disabled) { + this.setAttribute('aria-disabled', 'true') + this.removeAttribute('tabindex') + } else { + this.removeAttribute('aria-disabled') + this.setAttribute('tabindex', '0') + } + + // Build inner HTML + const parts: string[] = [] + + if (iconName) { + const iconHtml = lucideByName(iconName) + if (iconHtml) { + parts.push(``) + } + } + + parts.push(`${esc(label)}`) + + if (hotkey) { + parts.push(`${esc(hotkey)}`) + } + + this.innerHTML = parts.join('') + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: MenuItem + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 67cab7fa..9795ff04 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -302,6 +302,7 @@ export * from './LoadingScreen' export * from './Lobby' export * from './MatchmakingScreen' export * from './MainMenu' +export * from './MenuItem' export * from './LootList' export * from './LootPopup' export * from './LoreText' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 400f90f1..28a09541 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -299,6 +299,7 @@ import { LoadingScreen } from './LoadingScreen' import { Lobby } from './Lobby' import { MatchmakingScreen } from './MatchmakingScreen' import { MainMenu } from './MainMenu' +import { MenuItem } from './MenuItem' import { LootList } from './LootList' import { LootPopup } from './LootPopup' import { LoreText } from './LoreText' @@ -610,6 +611,7 @@ export function register(): void { customElements.define('tc-lobby', Lobby) customElements.define('tc-matchmaking-screen', MatchmakingScreen) customElements.define('tc-main-menu', MainMenu) + customElements.define('tc-menu-item', MenuItem) customElements.define('tc-loot-list', LootList) customElements.define('tc-loot-popup', LootPopup) customElements.define('tc-lore-text', LoreText) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 610edd2b..1fff0c9b 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -249,6 +249,7 @@ @forward 'live-feed'; @forward 'login'; @forward 'main-menu'; +@forward 'menu-item'; @forward 'markdown-editor'; @forward 'marquee'; @forward 'multi-card-select'; diff --git a/web-components/style/components/_menu-item.scss b/web-components/style/components/_menu-item.scss new file mode 100644 index 00000000..5f6027bb --- /dev/null +++ b/web-components/style/components/_menu-item.scss @@ -0,0 +1,162 @@ +// tc-menu-item — single interactive menu row (icon, label, optional hotkey badge). +// Port of gc-menu-item (game-components), restyled to the toolcase design system: +// slate neutrals, sharp corners, 1px hairline border, ink accent for selected state, +// JetBrains Mono for the hotkey badge. Game chrome (gilded frames, glows) dropped. +// All cosmetics flow through --bs-menu-item-* custom properties. + +@use '../foundation/tokens' as *; + +tc-menu-item { + // ── Theme contract ────────────────────────────────────────────────────────── + --bs-menu-item-bg: transparent; + --bs-menu-item-color: var(--tc-text); + --bs-menu-item-padding-y: 0.5rem; + --bs-menu-item-padding-x: 0.875rem; + --bs-menu-item-gap: 0.5rem; + --bs-menu-item-min-height: 2.75rem; + --bs-menu-item-border-color: var(--tc-border); + + --bs-menu-item-icon-size: 1rem; + --bs-menu-item-icon-color: var(--tc-text-muted); + + --bs-menu-item-hover-bg: var(--tc-surface-muted); + --bs-menu-item-hover-color: var(--tc-text); + + --bs-menu-item-selected-bg: var(--tc-app-accent); + --bs-menu-item-selected-color: #fff; + --bs-menu-item-selected-icon-color: rgba(255, 255, 255, 0.8); + + --bs-menu-item-disabled-opacity: 0.45; + + --bs-menu-item-hotkey-font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + --bs-menu-item-hotkey-font-size: 0.6875rem; + --bs-menu-item-hotkey-color: var(--tc-text-muted); + --bs-menu-item-hotkey-bg: var(--tc-surface-muted); + --bs-menu-item-hotkey-border: 1px solid var(--tc-border); + --bs-menu-item-hotkey-selected-color: rgba(255, 255, 255, 0.85); + --bs-menu-item-hotkey-selected-bg: rgba(255, 255, 255, 0.12); + --bs-menu-item-hotkey-selected-border: 1px solid rgba(255, 255, 255, 0.2); + + // ── Layout ────────────────────────────────────────────────────────────────── + display: flex; + align-items: center; + gap: var(--bs-menu-item-gap); + padding: var(--bs-menu-item-padding-y) var(--bs-menu-item-padding-x); + min-height: var(--bs-menu-item-min-height); + background: var(--bs-menu-item-bg); + color: var(--bs-menu-item-color); + border: 0; + border-bottom: 1px solid var(--bs-menu-item-border-color); + border-radius: 0; + cursor: pointer; + user-select: none; + outline: none; + transition: + background-color var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease); + + &:last-child { + border-bottom: none; + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: -2px; + } + + &:hover:not(.tc-menu-item--selected):not(.tc-menu-item--disabled) { + background: var(--bs-menu-item-hover-bg); + color: var(--bs-menu-item-hover-color); + } +} + +// ── Selected state ───────────────────────────────────────────────────────────── + +tc-menu-item.tc-menu-item--selected { + background: var(--bs-menu-item-selected-bg); + color: var(--bs-menu-item-selected-color); + + .tc-menu-item-icon { + color: var(--bs-menu-item-selected-icon-color); + } +} + +// ── Disabled state ───────────────────────────────────────────────────────────── + +tc-menu-item.tc-menu-item--disabled { + opacity: var(--bs-menu-item-disabled-opacity); + cursor: not-allowed; + pointer-events: none; +} + +// ── Icon region ──────────────────────────────────────────────────────────────── + +.tc-menu-item-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: var(--bs-menu-item-icon-size); + height: var(--bs-menu-item-icon-size); + color: var(--bs-menu-item-icon-color); + + svg { + width: 100%; + height: 100%; + } +} + +// ── Label ───────────────────────────────────────────────────────────────────── + +.tc-menu-item-label { + flex: 1; + min-width: 0; + font-size: 0.9375rem; + font-weight: 500; + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// ── Hotkey badge ────────────────────────────────────────────────────────────── + +.tc-menu-item-hotkey { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.125rem 0.375rem; + font-family: var(--bs-menu-item-hotkey-font-family); + font-size: var(--bs-menu-item-hotkey-font-size); + font-weight: 600; + font-style: normal; + line-height: 1; + color: var(--bs-menu-item-hotkey-color); + background: var(--bs-menu-item-hotkey-bg); + border: var(--bs-menu-item-hotkey-border); + border-radius: 0; + white-space: nowrap; + + tc-menu-item.tc-menu-item--selected & { + color: var(--bs-menu-item-hotkey-selected-color); + background: var(--bs-menu-item-hotkey-selected-bg); + border: var(--bs-menu-item-hotkey-selected-border); + } +} + +// ── Coarse pointer: 44 px touch target ──────────────────────────────────────── + +@media (pointer: coarse) { + tc-menu-item { + --bs-menu-item-min-height: 44px; + } +} + +// ── Reduced motion — freeze decorative transitions ──────────────────────────── + +@media (prefers-reduced-motion: reduce) { + tc-menu-item { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 0a585efe..733619fa 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -335,6 +335,7 @@ tc-level-header, tc-level-select, tc-lobby, tc-main-menu, +tc-menu-item, tc-loot-list, tc-lore-text, tc-roadmap { From 5680fb87a30fe05c2d32602c6e35622bb15fcc34 Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 17 Jun 2026 21:08:59 +0000 Subject: [PATCH 342/632] 336-metal-button: Build the tc-metal-button web component (MetalButton game-components port) --- examples/public/web-components/SKILL.md | 75 ++++++++ .../src/web-components/MetalButtonDemo.tsx | 110 ++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/MetalButton.ts | 76 +++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_metal-button.scss | 160 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 428 insertions(+) create mode 100644 examples/src/web-components/MetalButtonDemo.tsx create mode 100644 web-components/src/MetalButton.ts create mode 100644 web-components/style/components/_metal-button.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 769e3c30..44cb417f 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -231,6 +231,7 @@ After `register()` you can author markup directly: - [tc-lore-text](#tc-lore-text) - [tc-main-menu](#tc-main-menu) - [tc-menu-item](#tc-menu-item) + - [tc-metal-button](#tc-metal-button) - [tc-matchmaking-screen](#tc-matchmaking-screen) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) @@ -21834,3 +21835,77 @@ Matchmaking / searching status panel with a state indicator ring, eyebrow + titl setTimeout(() => { mm.state = 'found' }, 7000) ``` + +--- + +### tc-metal-button + +Primary call-to-action button ported from `gc-metal-button` (game-components), restyled to the toolcase design system. Game-specific chrome (metal textures, gilded frames, glows) is dropped; the button renders with slate neutrals, sharp corners (`border-radius: 0`), a 1px hairline border, and the ink primary gradient for the `primary` variant. Slotted children become the button label. Disabled state is enforced natively on the inner `` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: MetalButton + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 9795ff04..7b5bb628 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -306,3 +306,4 @@ export * from './MenuItem' export * from './LootList' export * from './LootPopup' export * from './LoreText' +export * from './MetalButton' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 28a09541..d17789c6 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -303,6 +303,7 @@ import { MenuItem } from './MenuItem' import { LootList } from './LootList' import { LootPopup } from './LootPopup' import { LoreText } from './LoreText' +import { MetalButton } from './MetalButton' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -615,4 +616,5 @@ export function register(): void { customElements.define('tc-loot-list', LootList) customElements.define('tc-loot-popup', LootPopup) customElements.define('tc-lore-text', LoreText) + customElements.define('tc-metal-button', MetalButton) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 1fff0c9b..f481c69f 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -250,6 +250,7 @@ @forward 'login'; @forward 'main-menu'; @forward 'menu-item'; +@forward 'metal-button'; @forward 'markdown-editor'; @forward 'marquee'; @forward 'multi-card-select'; diff --git a/web-components/style/components/_metal-button.scss b/web-components/style/components/_metal-button.scss new file mode 100644 index 00000000..8a2e6a65 --- /dev/null +++ b/web-components/style/components/_metal-button.scss @@ -0,0 +1,160 @@ +// tc-metal-button — primary call-to-action button. +// Port of gc-metal-button (game-components), restyled to the toolcase design system: +// slate neutrals, sharp corners (border-radius: 0 everywhere), 1px hairline border, +// ink accent for the primary variant. Game-specific chrome (metal textures, glows, +// gilded frames) is dropped entirely. +// All cosmetics flow through --bs-metal-button-* custom properties. + +@use '../foundation/tokens' as *; + +// ── Host defaults (variant=default) ───────────────────────────────────────── + +tc-metal-button { + --bs-metal-button-color: var(--tc-text); + --bs-metal-button-bg: transparent; + --bs-metal-button-border-color: var(--tc-border-strong); + --bs-metal-button-hover-bg: var(--tc-surface-muted); + --bs-metal-button-hover-color: var(--tc-text); + --bs-metal-button-hover-border-color: var(--tc-border-strong); + --bs-metal-button-active-bg: var(--tc-surface-muted); + --bs-metal-button-active-color: var(--tc-text); + --bs-metal-button-active-border-color: var(--tc-border-strong); + --bs-metal-button-focus-ring-color: rgba(30, 41, 59, 0.25); + --bs-metal-button-disabled-opacity: 0.65; + --bs-metal-button-padding-x: 1rem; + --bs-metal-button-padding-y: 0.5rem; + --bs-metal-button-font-size: 0.925rem; + --bs-metal-button-font-weight: 500; + --bs-metal-button-line-height: 1.6; + --bs-metal-button-border-width: 1px; + --bs-metal-button-border-radius: 0; + --bs-metal-button-min-height: 0; +} + +// ── Variant token overrides ────────────────────────────────────────────────── + +tc-metal-button[variant='primary'] { + --bs-metal-button-color: #{$white}; + --bs-metal-button-bg: linear-gradient(135deg, var(--tc-app-accent), #2b3a51); + --bs-metal-button-border-color: var(--tc-app-accent); + --bs-metal-button-hover-bg: linear-gradient(135deg, var(--tc-app-accent), #2b3a51); + --bs-metal-button-hover-color: #{$white}; + --bs-metal-button-hover-border-color: var(--tc-app-accent); + --bs-metal-button-active-bg: linear-gradient(135deg, var(--tc-app-accent), #2b3a51); + --bs-metal-button-active-color: #{$white}; + --bs-metal-button-active-border-color: var(--tc-app-accent); + --bs-metal-button-focus-ring-color: rgba(30, 41, 59, 0.35); +} + +tc-metal-button[variant='danger'] { + --bs-metal-button-color: #{$white}; + --bs-metal-button-bg: rgba(var(--bs-danger-rgb), 1); + --bs-metal-button-border-color: rgba(var(--bs-danger-rgb), 1); + --bs-metal-button-hover-bg: rgba(var(--bs-danger-rgb), 1); + --bs-metal-button-hover-color: #{$white}; + --bs-metal-button-hover-border-color: rgba(var(--bs-danger-rgb), 1); + --bs-metal-button-active-bg: rgba(var(--bs-danger-rgb), 1); + --bs-metal-button-active-color: #{$white}; + --bs-metal-button-active-border-color: rgba(var(--bs-danger-rgb), 1); + --bs-metal-button-focus-ring-color: rgba(var(--bs-danger-rgb), 0.25); +} + +tc-metal-button[variant='ghost'] { + --bs-metal-button-color: var(--tc-text-muted); + --bs-metal-button-bg: transparent; + --bs-metal-button-border-color: transparent; + --bs-metal-button-hover-bg: var(--tc-surface-muted); + --bs-metal-button-hover-color: var(--tc-text); + --bs-metal-button-hover-border-color: transparent; + --bs-metal-button-active-bg: var(--tc-surface-muted); + --bs-metal-button-active-color: var(--tc-text); + --bs-metal-button-active-border-color: transparent; +} + +// ── Inner button ───────────────────────────────────────────────────────────── + +.tc-metal-button__btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: 100%; + padding: var(--bs-metal-button-padding-y) var(--bs-metal-button-padding-x); + min-height: var(--bs-metal-button-min-height); + font-family: var(--tc-font-sans); + font-size: var(--bs-metal-button-font-size); + font-weight: var(--bs-metal-button-font-weight); + line-height: var(--bs-metal-button-line-height); + letter-spacing: 0.025em; + color: var(--bs-metal-button-color); + background: var(--bs-metal-button-bg); + border: var(--bs-metal-button-border-width) solid var(--bs-metal-button-border-color); + border-radius: var(--bs-metal-button-border-radius); + cursor: pointer; + user-select: none; + text-align: center; + white-space: nowrap; + vertical-align: middle; + transition: + transform var(--tc-transition-fast, 0.15s ease), + box-shadow var(--tc-transition-fast, 0.15s ease), + background var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease); + + &:hover { + color: var(--bs-metal-button-hover-color); + background: var(--bs-metal-button-hover-bg); + border-color: var(--bs-metal-button-hover-border-color); + transform: translateY(-1px); + } + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 var(--bs-focus-ring-width, 0.25rem) var(--bs-metal-button-focus-ring-color); + } + + &:active { + color: var(--bs-metal-button-active-color); + background: var(--bs-metal-button-active-bg); + border-color: var(--bs-metal-button-active-border-color); + transform: translateY(0); + } + + &:disabled { + opacity: var(--bs-metal-button-disabled-opacity); + pointer-events: none; + transform: none; + } +} + +// ── Size modifiers ──────────────────────────────────────────────────────────── + +.tc-metal-button__btn--sm { + --bs-metal-button-padding-x: 0.75rem; + --bs-metal-button-padding-y: 0.375rem; + --bs-metal-button-font-size: 0.8125rem; +} + +.tc-metal-button__btn--lg { + --bs-metal-button-padding-x: 1.5rem; + --bs-metal-button-padding-y: 0.75rem; + --bs-metal-button-font-size: 1.04rem; +} + +// Primary hover glow (matches tc-button primary treatment) +tc-metal-button[variant='primary'] .tc-metal-button__btn:hover { + box-shadow: 0 4px 15px rgba(30, 41, 59, 0.3); +} + +// WCAG 2.5.5 — 44 px minimum tappable area on touch devices. +@media (pointer: coarse) { + tc-metal-button { + --bs-metal-button-min-height: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + .tc-metal-button__btn { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 733619fa..5f30a459 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -416,6 +416,7 @@ tc-editable-text, tc-chip, tc-button, tc-cool-button, +tc-metal-button, tc-badge, tc-brand, tc-avatar, From 68a466e42051987a1791f02af43f9cba31448a51 Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 17 Jun 2026 21:19:27 +0000 Subject: [PATCH 343/632] 337-minimap: Build the tc-minimap web component (Minimap game-components port) --- examples/public/web-components/SKILL.md | 77 ++++++++++ examples/src/web-components/MinimapDemo.tsx | 90 ++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Minimap.ts | 135 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + web-components/style/components/_minimap.scss | 90 ++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 399 insertions(+) create mode 100644 examples/src/web-components/MinimapDemo.tsx create mode 100644 web-components/src/Minimap.ts create mode 100644 web-components/style/components/_minimap.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 44cb417f..75de9151 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -232,6 +232,7 @@ After `register()` you can author markup directly: - [tc-main-menu](#tc-main-menu) - [tc-menu-item](#tc-menu-item) - [tc-metal-button](#tc-metal-button) + - [tc-minimap](#tc-minimap) - [tc-matchmaking-screen](#tc-matchmaking-screen) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) @@ -21909,3 +21910,79 @@ Primary call-to-action button ported from `gc-metal-button` (game-components), r Custom Colour ``` + +--- + +### tc-minimap + +Positioned-marker map surface with a fixed player dot at the centre. World coordinates (`world-x`, `world-y`, `world-width`, `world-height`) define the visible bounds; entity markers are projected onto the surface proportionally. The `rotation` attribute spins the map around the fixed player dot. Entity markers are circles (sanctioned shape); all other chrome uses sharp corners and the slate neutral ramp. + +**Tag:** `tc-minimap` + +#### Attributes + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `world-x` | `number` | `0` | Left edge of the visible world region (world-space units). | +| `world-y` | `number` | `0` | Top edge of the visible world region (world-space units). | +| `world-width` | `number` | `100` | Width of the visible world region. Must be at least 1. | +| `world-height` | `number` | `100` | Height of the visible world region. Must be at least 1. | +| `background-image` | `string` | — | URL for the map background image. Applied as `url(…)` via `--bs-minimap-bg-image`. | +| `size` | `number` | — | Side length of the square minimap in pixels. When absent, `--bs-minimap-size` (default `200px`) is used. | +| `rotation` | `number` | `0` | Degrees to rotate the map surface (counter-clockwise) around the fixed player dot. | + +#### JS Properties + +| Property | Type | Default | Description | +|---|---|---|---| +| `markers` | `MinimapMarker[]` | `[]` | Array of entity marker descriptors. Setting this property triggers a re-render. Markers outside the world bounds are silently omitted. | + +**MinimapMarker shape** + +```ts +interface MinimapMarker { + id: string // Unique identifier; used for aria-label. + x: number // World-space X position. + y: number // World-space Y position. + color?: string // CSS colour for this marker (overrides --bs-minimap-marker-color). + size?: number // Marker diameter in pixels (overrides --bs-minimap-marker-size). +} +``` + +#### Events + +None. `tc-minimap` is a purely presentational element. + +#### Slots + +None. `tc-minimap` is attribute- and property-driven. + +#### CSS Custom Properties + +| Property | Default | Description | +|---|---|---| +| `--bs-minimap-size` | `200px` | Side length of the square minimap (overridden by the `size` attribute when set). | +| `--bs-minimap-surface` | `var(--tc-surface)` | Host background colour. | +| `--bs-minimap-border-color` | `var(--tc-border)` | 1px hairline border around the minimap. | +| `--bs-minimap-bg-image` | `none` | Background image URL (set automatically from the `background-image` attribute). | +| `--bs-minimap-bg-color` | `var(--tc-slate-900)` | Fallback background fill behind the grid and image. | +| `--bs-minimap-grid-color` | `var(--tc-border)` | Colour of the tiled grid hairlines. | +| `--bs-minimap-grid-size` | `20px` | Cell size of the tiled grid. | +| `--bs-minimap-marker-color` | `var(--tc-accent)` | Default entity marker colour (overridable per-marker via the `color` field). | +| `--bs-minimap-marker-size` | `8px` | Default entity marker diameter (overridable per-marker via the `size` field). | +| `--bs-minimap-player-color` | `var(--tc-app-accent)` | Fixed player dot colour (always rendered at the centre). | +| `--bs-minimap-player-size` | `10px` | Fixed player dot diameter. | + +#### Example + +```html + + +``` diff --git a/examples/src/web-components/MinimapDemo.tsx b/examples/src/web-components/MinimapDemo.tsx new file mode 100644 index 00000000..770e7b04 --- /dev/null +++ b/examples/src/web-components/MinimapDemo.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useRef } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const MinimapDemo: React.FC = () => { + const basicRef = useRef(null) + const rotatedRef = useRef(null) + const largeRef = useRef(null) + const emptyRef = useRef(null) + + useEffect(() => { + if (basicRef.current) { + basicRef.current.markers = [ + { id: 'player-a', x: 30, y: 25, color: 'var(--tc-success)', size: 10 }, + { id: 'enemy-1', x: 60, y: 40, color: 'var(--tc-danger)', size: 8 }, + { id: 'enemy-2', x: 75, y: 70 }, + { id: 'objective', x: 50, y: 50, color: 'var(--tc-warning)', size: 12 }, + ] + } + }, []) + + useEffect(() => { + if (rotatedRef.current) { + rotatedRef.current.markers = [ + { id: 'ally-1', x: 20, y: 20, color: 'var(--tc-success)', size: 8 }, + { id: 'enemy-3', x: 80, y: 80, color: 'var(--tc-danger)', size: 8 }, + { id: 'enemy-4', x: 65, y: 30, color: 'var(--tc-danger)', size: 8 }, + ] + } + }, []) + + useEffect(() => { + if (largeRef.current) { + largeRef.current.worldWidth = 200 + largeRef.current.worldHeight = 200 + largeRef.current.markers = [ + { id: 'squad-1', x: 10, y: 10, color: 'var(--tc-success)', size: 10 }, + { id: 'squad-2', x: 50, y: 80, color: 'var(--tc-success)', size: 10 }, + { id: 'squad-3', x: 120, y: 30, color: 'var(--tc-success)', size: 10 }, + { id: 'boss', x: 150, y: 160, color: 'var(--tc-danger)', size: 14 }, + { id: 'chest', x: 90, y: 90, color: 'var(--tc-warning)', size: 10 }, + { id: 'portal', x: 180, y: 40, color: 'var(--tc-accent)', size: 12 }, + ] + } + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="Minimap" + description="Positioned-marker map surface with a fixed player dot at centre. World coordinates are projected onto the surface; the rotation attribute spins the map around the player. Entity markers are circles; all other chrome is sharp. Set markers via the JS markers property." + /> + +
    + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    + ) +} + +export default MinimapDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 22976091..74fb8cc2 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -296,6 +296,7 @@ import LevelSelectDemo from './LevelSelectDemo' import LoadingOverlayDemo from './LoadingOverlayDemo' import LoadingScreenDemo from './LoadingScreenDemo' import LoreTextDemo from './LoreTextDemo' +import MinimapDemo from './MinimapDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -612,4 +613,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'loading-overlay', category: 'Overlays & Feedback', element: }, { key: 'loading-screen', category: 'Overlays & Feedback', element: }, { key: 'lore-text', category: 'Content', element: }, + { key: 'minimap', category: 'Components', element: }, ] diff --git a/web-components/src/Minimap.ts b/web-components/src/Minimap.ts new file mode 100644 index 00000000..05c5d793 --- /dev/null +++ b/web-components/src/Minimap.ts @@ -0,0 +1,135 @@ +const TAG_NAME = 'tc-minimap' + +export interface MinimapMarker { + id: string + x: number + y: number + color?: string + size?: number +} + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +export class Minimap extends HTMLElement { + private _initialised = false + private _markers: MinimapMarker[] = [] + + static get observedAttributes(): string[] { + return ['world-x', 'world-y', 'world-width', 'world-height', 'background-image', 'size', 'rotation'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + private numberAttr(name: string, fallback: number): number { + const raw = this.getAttribute(name) + if (raw === null) return fallback + const parsed = parseFloat(raw) + return Number.isFinite(parsed) ? parsed : fallback + } + + get worldX(): number { return this.numberAttr('world-x', 0) } + set worldX(v: number) { this.setAttribute('world-x', String(v)) } + + get worldY(): number { return this.numberAttr('world-y', 0) } + set worldY(v: number) { this.setAttribute('world-y', String(v)) } + + get worldWidth(): number { return this.numberAttr('world-width', 100) } + set worldWidth(v: number) { this.setAttribute('world-width', String(v)) } + + get worldHeight(): number { return this.numberAttr('world-height', 100) } + set worldHeight(v: number) { this.setAttribute('world-height', String(v)) } + + get backgroundImage(): string { + return this.getAttribute('background-image') ?? '' + } + set backgroundImage(v: string) { + if (v) this.setAttribute('background-image', v) + else this.removeAttribute('background-image') + } + + get size(): number | null { + const raw = this.getAttribute('size') + if (raw === null) return null + const parsed = parseFloat(raw) + return Number.isFinite(parsed) ? parsed : null + } + set size(v: number | null) { + if (v === null) this.removeAttribute('size') + else this.setAttribute('size', String(v)) + } + + get rotation(): number { return this.numberAttr('rotation', 0) } + set rotation(v: number) { this.setAttribute('rotation', String(v)) } + + get markers(): MinimapMarker[] { + return this._markers.slice() + } + set markers(value: MinimapMarker[]) { + this._markers = Array.isArray(value) ? value.slice() : [] + if (this._initialised) this.render() + } + + private render(): void { + const size = this.size + if (size !== null) this.style.setProperty('--bs-minimap-size', `${size}px`) + else this.style.removeProperty('--bs-minimap-size') + + const bg = this.backgroundImage + if (bg) this.style.setProperty('--bs-minimap-bg-image', `url(${esc(bg)})`) + else this.style.removeProperty('--bs-minimap-bg-image') + + const wx = this.worldX + const wy = this.worldY + const ww = Math.max(1, this.worldWidth) + const wh = Math.max(1, this.worldHeight) + const rotation = this.rotation + + const markersMarkup = this._markers.map((m) => { + const px = ((m.x - wx) / ww) * 100 + const py = ((m.y - wy) / wh) * 100 + if (px < 0 || px > 100 || py < 0 || py > 100) return '' + const colorProp = m.color ? `--bs-minimap-marker-color:${esc(m.color)};` : '' + const sizeProp = m.size ? `--bs-minimap-marker-size:${m.size}px;` : '' + return `` + }).join('') + + const markerCount = this._markers.length + const ariaLabel = markerCount === 0 + ? 'Minimap — no markers' + : `Minimap — ${markerCount} marker${markerCount !== 1 ? 's' : ''}` + + this.classList.add('tc-minimap') + this.setAttribute('role', 'img') + this.setAttribute('aria-label', ariaLabel) + + this.innerHTML = ` +
    + + ${markersMarkup} +
    + + ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Minimap + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 7b5bb628..ce520e8c 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -307,3 +307,4 @@ export * from './LootList' export * from './LootPopup' export * from './LoreText' export * from './MetalButton' +export * from './Minimap' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index d17789c6..ac3e79b0 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -304,6 +304,7 @@ import { LootList } from './LootList' import { LootPopup } from './LootPopup' import { LoreText } from './LoreText' import { MetalButton } from './MetalButton' +import { Minimap } from './Minimap' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -617,4 +618,5 @@ export function register(): void { customElements.define('tc-loot-popup', LootPopup) customElements.define('tc-lore-text', LoreText) customElements.define('tc-metal-button', MetalButton) + customElements.define('tc-minimap', Minimap) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index f481c69f..8199aa67 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -300,3 +300,4 @@ @forward 'loot-popup'; @forward 'lore-text'; @forward 'matchmaking-screen'; +@forward 'minimap'; diff --git a/web-components/style/components/_minimap.scss b/web-components/style/components/_minimap.scss new file mode 100644 index 00000000..1c92734e --- /dev/null +++ b/web-components/style/components/_minimap.scss @@ -0,0 +1,90 @@ +// tc-minimap — positioned-marker map surface with a fixed player dot at centre. +// Entity markers are circles (border-radius: 50%); everything else is sharp (border-radius: 0). +// Player marker uses --tc-app-accent (ink); entity markers default to --tc-accent (cyan). +// All cosmetics flow through --bs-minimap-* custom properties backed by --tc-* tokens. + +@use '../foundation/tokens' as *; + +tc-minimap { + --bs-minimap-size: 200px; + --bs-minimap-surface: var(--tc-surface); + --bs-minimap-border-color: var(--tc-border); + --bs-minimap-bg-image: none; + --bs-minimap-bg-color: var(--tc-slate-900); + --bs-minimap-grid-color: var(--tc-border); + --bs-minimap-grid-size: 20px; + --bs-minimap-marker-color: var(--tc-accent); + --bs-minimap-marker-size: 8px; + --bs-minimap-player-color: var(--tc-app-accent); + --bs-minimap-player-size: 10px; + + position: relative; + width: var(--bs-minimap-size); + height: var(--bs-minimap-size); + border: 1px solid var(--bs-minimap-border-color); + border-radius: 0; + background: var(--bs-minimap-surface); + overflow: hidden; + flex-shrink: 0; +} + +.tc-minimap { + // Rotating surface that holds the background and markers; the player dot stays fixed. + &__surface { + position: absolute; + inset: 0; + transform-origin: center center; + } + + // Tiled grid background; optional image overlay via --bs-minimap-bg-image. + &__bg { + position: absolute; + inset: 0; + background-color: var(--bs-minimap-bg-color); + background-image: + var(--bs-minimap-bg-image), + linear-gradient(var(--bs-minimap-grid-color) 1px, transparent 1px), + linear-gradient(90deg, var(--bs-minimap-grid-color) 1px, transparent 1px); + background-size: + 100% 100%, + var(--bs-minimap-grid-size) var(--bs-minimap-grid-size), + var(--bs-minimap-grid-size) var(--bs-minimap-grid-size); + opacity: 0.85; + pointer-events: none; + } + + // Entity marker — circles are sanctioned; uses per-marker --bs-minimap-marker-color override. + &__marker { + position: absolute; + transform: translate(-50%, -50%); + width: var(--bs-minimap-marker-size); + height: var(--bs-minimap-marker-size); + border-radius: 50%; + background: var(--bs-minimap-marker-color); + pointer-events: none; + } + + // Player marker — fixed at the centre of the host (not inside the rotating surface). + // The 2px white ring separates it visually from the rotating background. + &__player { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: var(--bs-minimap-player-size); + height: var(--bs-minimap-player-size); + border-radius: 50%; + background: var(--bs-minimap-player-color); + outline: 2px solid var(--bs-minimap-surface); + outline-offset: 0; + z-index: 1; + pointer-events: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .tc-minimap__marker, + .tc-minimap__player { + animation: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 5f30a459..c5499f1b 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -338,6 +338,7 @@ tc-main-menu, tc-menu-item, tc-loot-list, tc-lore-text, +tc-minimap, tc-roadmap { display: block; } From c53c9fdbe257e3f66d808ac5e72d935784f9375c Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 17 Jun 2026 23:27:11 +0000 Subject: [PATCH 344/632] 338-mouse-sensitivity: Build the tc-mouse-sensitivity web component (MouseSensitivity game-components port) --- examples/public/web-components/SKILL.md | 53 ++++++ .../web-components/MouseSensitivityDemo.tsx | 96 ++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/MouseSensitivity.ts | 149 +++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_mouse-sensitivity.scss | 178 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 483 insertions(+) create mode 100644 examples/src/web-components/MouseSensitivityDemo.tsx create mode 100644 web-components/src/MouseSensitivity.ts create mode 100644 web-components/style/components/_mouse-sensitivity.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 75de9151..19fe7418 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -304,6 +304,7 @@ After `register()` you can author markup directly: - [tc-range-slider](#tc-range-slider) - [tc-deadzone-slider](#tc-deadzone-slider) - [tc-fov-slider](#tc-fov-slider) + - [tc-mouse-sensitivity](#tc-mouse-sensitivity) - [tc-fps-cap-select](#tc-fps-cap-select) - [tc-fullscreen-toggle](#tc-fullscreen-toggle) - [tc-graphics-preset-picker](#tc-graphics-preset-picker) @@ -8212,6 +8213,58 @@ A field-of-view setting row: a label/description text block paired with a range --- +### tc-mouse-sensitivity + +A mouse-sensitivity setting row: a label/description text block paired with one or two native range sliders (main + optional ADS, each spanning 0.1–5 with 0.05 steps) and mono decimal readouts. Built on the shared `tc-setting-row` scaffold (a label/control row that the setting rows reuse). Port of game-components `gc-mouse-sensitivity` with the fantasy chrome dropped for the toolcase slate/ink look. + +**Tag:** `tc-mouse-sensitivity` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `row-label` | string | `Mouse sensitivity` | Row label (set automatically when absent) | +| `description` | string | — | Optional secondary line beneath the label | +| `value` | number | `1` | Main sensitivity (0.1–5, 0.05 step) | +| `ads` | number | — | ADS sensitivity (0.1–5, 0.05 step). When absent, the ADS slider is hidden. | +| `disabled` | boolean | `false` | Disables both range inputs | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `value` | `number` | Get or set the main sensitivity. Getter defaults to `1`. Setting patches the input + readout in place — no full re-render. | +| `ads` | `number \| null` | Get or set the ADS sensitivity. Setting to `null` removes the ADS row; setting to a number shows it. Structural change triggers a re-render; numeric-only change patches in place. | +| `rowLabel` | `string` | Get/set the `row-label` attribute. | +| `description` | `string` | Get/set the `description` attribute. | +| `disabled` | `boolean` | Get/set the `disabled` attribute. | +| `onChange` | `((key: 'main' \| 'ads', value: number) => void) \| null` | Optional callback fired on every change. Mirrors the `tc-change` event. | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-change` | `{ key: 'main' \| 'ads', value: number }` | Fired on every range-input change. `key` identifies which slider changed. | + +**No slots.** + +```html + + +``` + +--- + ### tc-fps-cap-select A preset FPS-cap picker: a label/description text block paired with a native ` + ${ads.toFixed(2)} + + ` : '' + return ` +
    +
    + Main + + ${main.toFixed(2)} +
    + ${adsRow} +
    + ` + } + + protected bindControl(): void { + const inputs = this.querySelectorAll('.tc-mouse-sensitivity__input') + inputs.forEach(input => { + input.addEventListener('input', () => { + const key = (input.dataset.key ?? 'main') as 'main' | 'ads' + const v = parseFloat(input.value) + const display = this.querySelector( + `.tc-mouse-sensitivity__value[data-key="${key}"]` + ) + if (display) display.textContent = v.toFixed(2) + if (key === 'main') this.setAttribute('value', String(v)) + else this.setAttribute('ads', String(v)) + this.emit('tc-change', { key, value: v }) + if (typeof this.onChange === 'function') this.onChange(key, v) + }) + }) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: MouseSensitivity + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index ce520e8c..dc0918a5 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -283,6 +283,7 @@ export * from './DeadzoneSlider' export * from './DebugOverlay' export * from './DialogueBox' export * from './FOVSlider' +export * from './MouseSensitivity' export * from './FPSCapSelect' export * from './FullscreenToggle' export * from './GameOverScreen' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index ac3e79b0..cfaa5376 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -280,6 +280,7 @@ import { DeadzoneSlider } from './DeadzoneSlider' import { DebugOverlay } from './DebugOverlay' import { DialogueBox } from './DialogueBox' import { FOVSlider } from './FOVSlider' +import { MouseSensitivity } from './MouseSensitivity' import { FPSCapSelect } from './FPSCapSelect' import { FullscreenToggle } from './FullscreenToggle' import { GameOverScreen } from './GameOverScreen' @@ -591,6 +592,7 @@ export function register(): void { customElements.define('tc-debug-overlay', DebugOverlay) customElements.define('tc-dialogue-box', DialogueBox) customElements.define('tc-fov-slider', FOVSlider) + customElements.define('tc-mouse-sensitivity', MouseSensitivity) customElements.define('tc-fps-cap-select', FPSCapSelect) customElements.define('tc-fullscreen-toggle', FullscreenToggle) customElements.define('tc-game-over-screen', GameOverScreen) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 8199aa67..cb34b2e7 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -59,6 +59,7 @@ @forward 'setting-row'; @forward 'deadzone-slider'; @forward 'fov-slider'; +@forward 'mouse-sensitivity'; @forward 'fps-cap-select'; @forward 'fullscreen-toggle'; @forward 'graphics-preset-picker'; diff --git a/web-components/style/components/_mouse-sensitivity.scss b/web-components/style/components/_mouse-sensitivity.scss new file mode 100644 index 00000000..6559256f --- /dev/null +++ b/web-components/style/components/_mouse-sensitivity.scss @@ -0,0 +1,178 @@ +// tc-mouse-sensitivity — mouse-sensitivity preset control on the shared setting- +// row scaffold (`_setting-row.scss`). The control is one or two native range +// inputs (main + optional ADS) — flat hairline tracks, circular ink-ringed +// thumbs (the one sanctioned curve) — each paired with a mono decimal readout. +// All cosmetics flow through `--bs-mouse-sensitivity-*`; the fantasy chrome of +// the gc-* original is dropped. + +tc-mouse-sensitivity { + --bs-mouse-sensitivity-track-width: 140px; + --bs-mouse-sensitivity-track-height: 6px; + --bs-mouse-sensitivity-track-color: var(--tc-border); + --bs-mouse-sensitivity-fill-color: var(--tc-app-accent); + --bs-mouse-sensitivity-thumb-size: 16px; + --bs-mouse-sensitivity-thumb-bg: var(--tc-surface); + --bs-mouse-sensitivity-thumb-border: var(--tc-app-accent); + --bs-mouse-sensitivity-thumb-active-bg: var(--tc-surface-muted); + --bs-mouse-sensitivity-thumb-shadow: var(--tc-shadow-sm); + --bs-mouse-sensitivity-focus-ring: 0 0 0 0.2rem rgba(30, 41, 59, 0.12); + --bs-mouse-sensitivity-disabled-color: var(--tc-text-faint); + --bs-mouse-sensitivity-key-color: var(--tc-text-muted); + --bs-mouse-sensitivity-key-size: 0.6875rem; + --bs-mouse-sensitivity-key-min-width: 2.5rem; + --bs-mouse-sensitivity-value-color: var(--tc-text-muted); + --bs-mouse-sensitivity-value-size: 0.6875rem; + --bs-mouse-sensitivity-value-min-width: 2.5rem; + --bs-mouse-sensitivity-row-gap: 0.375rem; + --bs-mouse-sensitivity-col-gap: 0.625rem; +} + +.tc-mouse-sensitivity__control { + display: flex; + flex-direction: column; + gap: var(--bs-mouse-sensitivity-row-gap); +} + +.tc-mouse-sensitivity__row { + display: flex; + align-items: center; + gap: var(--bs-mouse-sensitivity-col-gap); +} + +// Mono uppercase micro-label identifying which slider ("Main" / "ADS"). +.tc-mouse-sensitivity__key { + font-family: var(--tc-font-mono); + font-size: var(--bs-mouse-sensitivity-key-size); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bs-mouse-sensitivity-key-color); + min-width: var(--bs-mouse-sensitivity-key-min-width); +} + +.tc-mouse-sensitivity__input { + width: var(--bs-mouse-sensitivity-track-width); + height: 1.25rem; + padding: 0; + appearance: none; + -webkit-appearance: none; + background-color: transparent; + cursor: pointer; + + // Coarse pointers get a 44px hit area without changing the visual track. + @media (pointer: coarse) { + min-height: 44px; + } + + &:focus { + outline: 0; + } + + &:focus-visible { + &::-webkit-slider-thumb { + box-shadow: var(--bs-mouse-sensitivity-focus-ring); + } + + &::-moz-range-thumb { + box-shadow: var(--bs-mouse-sensitivity-focus-ring); + } + } + + &::-webkit-slider-runnable-track { + width: 100%; + height: var(--bs-mouse-sensitivity-track-height); + cursor: pointer; + background-color: var(--bs-mouse-sensitivity-track-color); + border: 0; + border-radius: 0; + } + + &::-webkit-slider-thumb { + // Centre the thumb on the track regardless of the two sizes. + margin-top: calc( + (var(--bs-mouse-sensitivity-track-height) - var(--bs-mouse-sensitivity-thumb-size)) / 2 + ); + width: var(--bs-mouse-sensitivity-thumb-size); + height: var(--bs-mouse-sensitivity-thumb-size); + appearance: none; + -webkit-appearance: none; + cursor: pointer; + background-color: var(--bs-mouse-sensitivity-thumb-bg); + border: 1.5px solid var(--bs-mouse-sensitivity-thumb-border); + border-radius: 50%; + box-shadow: var(--bs-mouse-sensitivity-thumb-shadow); + transition: background-color var(--tc-transition-fast); + + &:active { + background-color: var(--bs-mouse-sensitivity-thumb-active-bg); + } + } + + &::-moz-range-track { + width: 100%; + height: var(--bs-mouse-sensitivity-track-height); + cursor: pointer; + background-color: var(--bs-mouse-sensitivity-track-color); + border: 0; + border-radius: 0; + } + + // Filled portion of the track (ink up to the thumb) — Firefox only. + &::-moz-range-progress { + height: var(--bs-mouse-sensitivity-track-height); + background-color: var(--bs-mouse-sensitivity-fill-color); + } + + &::-moz-range-thumb { + width: var(--bs-mouse-sensitivity-thumb-size); + height: var(--bs-mouse-sensitivity-thumb-size); + cursor: pointer; + background-color: var(--bs-mouse-sensitivity-thumb-bg); + border: 1.5px solid var(--bs-mouse-sensitivity-thumb-border); + border-radius: 50%; + box-shadow: var(--bs-mouse-sensitivity-thumb-shadow); + transition: background-color var(--tc-transition-fast); + + &:active { + background-color: var(--bs-mouse-sensitivity-thumb-active-bg); + } + } + + &:disabled { + pointer-events: none; + + &::-webkit-slider-thumb { + background-color: var(--bs-mouse-sensitivity-disabled-color); + border-color: var(--bs-mouse-sensitivity-disabled-color); + } + + &::-moz-range-thumb { + background-color: var(--bs-mouse-sensitivity-disabled-color); + border-color: var(--bs-mouse-sensitivity-disabled-color); + } + } +} + +.tc-mouse-sensitivity__value { + // Mono, machine-facing decimal readout. + font-family: var(--tc-font-mono); + font-size: var(--bs-mouse-sensitivity-value-size); + letter-spacing: 0.06em; + color: var(--bs-mouse-sensitivity-value-color); + min-width: var(--bs-mouse-sensitivity-value-min-width); + text-align: right; +} + +// State-conveying colour transitions are cheap; the thumb's hover-fill is the +// only motion here, so freeze just that under reduced-motion. +@media (prefers-reduced-motion: reduce) { + .tc-mouse-sensitivity__input { + &::-webkit-slider-thumb { + transition: none; + } + + &::-moz-range-thumb { + transition: none; + } + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index c5499f1b..53243cb9 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -257,6 +257,7 @@ tc-cycle-wheel, tc-danger-zone-actions, tc-deadzone-slider, tc-fov-slider, +tc-mouse-sensitivity, tc-fps-cap-select, tc-fullscreen-toggle, tc-graphics-preset-picker, From 04faee50deee850f2a3acb6089afe24c73713f25 Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 17 Jun 2026 23:33:43 +0000 Subject: [PATCH 345/632] 339-mute-list: Build the tc-mute-list web component (MuteList game-components port) --- examples/public/web-components/SKILL.md | 81 ++++++++ examples/src/web-components/MuteListDemo.tsx | 76 +++++++ examples/src/web-components/index.tsx | 2 + web-components/src/MuteList.ts | 93 +++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_mute-list.scss | 194 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 451 insertions(+) create mode 100644 examples/src/web-components/MuteListDemo.tsx create mode 100644 web-components/src/MuteList.ts create mode 100644 web-components/style/components/_mute-list.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 19fe7418..bcba11e8 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -233,6 +233,7 @@ After `register()` you can author markup directly: - [tc-menu-item](#tc-menu-item) - [tc-metal-button](#tc-metal-button) - [tc-minimap](#tc-minimap) + - [tc-mute-list](#tc-mute-list) - [tc-matchmaking-screen](#tc-matchmaking-screen) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) @@ -22039,3 +22040,83 @@ None. `tc-minimap` is attribute- and property-driven. ]; ``` + +### tc-mute-list + +A list of muted players with optional per-entry reason, timestamp, and a per-row Unmute button. Players are set via the JS `players` property. Clicking Unmute fires `tc-unmute` with the player id. No shadow root; light DOM; `display: block`. + +**Tag:** `tc-mute-list` + +#### Attributes + +None. All content is driven by the `players` JS property. + +#### JS Properties + +| Property | Type | Default | Description | +|---|---|---|---| +| `players` | `MutedPlayer[]` | `[]` | Array of muted-player descriptors to render. Setting this property re-renders the list. | +| `onUnmute` | `((id: string) => void) \| null` | `null` | Optional callback — called in addition to the `tc-unmute` event when the Unmute button is clicked. | + +**`MutedPlayer` shape** + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | `string` | yes | Unique player identifier. Stamped as `data-id` on the row element and forwarded in the `tc-unmute` event detail. | +| `name` | `string` | yes | Display name shown in JetBrains Mono. | +| `mutedAt` | `string` | no | Human-readable timestamp string (e.g. `"2d ago"`). Displayed to the right of the name. | +| `reason` | `string` | no | Optional mute reason displayed as a muted sub-label below the name. | + +#### Events + +| Event | Detail | Description | +|---|---|---| +| `tc-unmute` | `{ id: string }` | Fired when the Unmute button for a row is clicked. `id` is the `MutedPlayer.id` of that row. Bubbles and is composed. | + +#### Slots + +None. `tc-mute-list` is property-driven; all content is generated. + +#### CSS Custom Properties + +| Property | Default | Description | +|---|---|---| +| `--bs-mute-list-bg` | `var(--tc-surface)` | Panel background. | +| `--bs-mute-list-border` | `1px solid var(--tc-border)` | Outer 1 px hairline border. | +| `--bs-mute-list-row-border` | `1px solid var(--tc-border)` | Row separator hairline. | +| `--bs-mute-list-row-hover-bg` | `var(--tc-surface-hover)` | Row hover background. | +| `--bs-mute-list-name-font-size` | `0.8125rem` | Player name font size. | +| `--bs-mute-list-name-color` | `var(--tc-text)` | Player name colour. | +| `--bs-mute-list-reason-font-size` | `0.75rem` | Reason sub-label font size. | +| `--bs-mute-list-reason-color` | `var(--tc-text-muted)` | Reason sub-label colour. | +| `--bs-mute-list-time-font-size` | `0.6875rem` | Timestamp font size. | +| `--bs-mute-list-time-color` | `var(--tc-text-faint)` | Timestamp colour. | +| `--bs-mute-list-btn-font-size` | `0.75rem` | Unmute button font size. | +| `--bs-mute-list-btn-min-height` | `1.75rem` | Unmute button minimum height (44 px under coarse pointer). | +| `--bs-mute-list-btn-bg` | `var(--tc-surface)` | Unmute button background. | +| `--bs-mute-list-btn-color` | `var(--tc-text)` | Unmute button text colour. | +| `--bs-mute-list-btn-border` | `1px solid var(--tc-border-strong)` | Unmute button border. | +| `--bs-mute-list-btn-hover-bg` | `var(--tc-app-accent)` | Unmute button hover background (ink). | +| `--bs-mute-list-btn-hover-color` | `#fff` | Unmute button hover text colour. | +| `--bs-mute-list-empty-color` | `var(--tc-text-faint)` | Empty-state text colour. | + +#### Example + +```html + + + +``` diff --git a/examples/src/web-components/MuteListDemo.tsx b/examples/src/web-components/MuteListDemo.tsx new file mode 100644 index 00000000..afbb5640 --- /dev/null +++ b/examples/src/web-components/MuteListDemo.tsx @@ -0,0 +1,76 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const PLAYERS = [ + { id: '1', name: 'ToxicWizard92', mutedAt: '2d ago', reason: 'Spam' }, + { id: '2', name: 'ChatBot_AFK', mutedAt: '1w ago' }, + { id: '3', name: 'Griefer404', mutedAt: '1mo ago', reason: 'Harassment' }, +] + +const MuteListDemo: React.FC = () => { + const populatedRef = useRef(null) + const eventsRef = useRef(null) + const [log, setLog] = useState([]) + + useEffect(() => { + if (!populatedRef.current) return + populatedRef.current.players = PLAYERS + }, []) + + useEffect(() => { + const el = eventsRef.current + if (!el) return + el.players = PLAYERS.slice(0, 2) + const handler = (e: CustomEvent) => + setLog(l => [`tc-unmute — id: "${e.detail.id}"`, ...l].slice(0, 8)) + el.addEventListener('tc-unmute', handler as EventListener) + return () => el.removeEventListener('tc-unmute', handler as EventListener) + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="MuteList" + description="A list of muted players with optional reason, timestamp, and a per-row Unmute button. Players are set via the JS players property. Clicking Unmute fires tc-unmute with the player id." + /> + +
    + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + +
    + Event log + {log.length === 0 ? ( + Click Unmute… + ) : ( +
      + {log.map((line, i) => ( +
    • {line}
    • + ))} +
    + )} +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default MuteListDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 1c5b0e60..9f594ba3 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -298,6 +298,7 @@ import LoadingOverlayDemo from './LoadingOverlayDemo' import LoadingScreenDemo from './LoadingScreenDemo' import LoreTextDemo from './LoreTextDemo' import MinimapDemo from './MinimapDemo' +import MuteListDemo from './MuteListDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -616,4 +617,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'loading-screen', category: 'Overlays & Feedback', element: }, { key: 'lore-text', category: 'Content', element: }, { key: 'minimap', category: 'Components', element: }, + { key: 'mute-list', category: 'Components', element: }, ] diff --git a/web-components/src/MuteList.ts b/web-components/src/MuteList.ts new file mode 100644 index 00000000..438477b6 --- /dev/null +++ b/web-components/src/MuteList.ts @@ -0,0 +1,93 @@ +const TAG_NAME = 'tc-mute-list' + +export interface MutedPlayer { + id: string + name: string + mutedAt?: string + reason?: string +} + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +export class MuteList extends HTMLElement { + private _initialised = false + private _players: MutedPlayer[] = [] + + onUnmute: ((id: string) => void) | null = null + + static get observedAttributes(): string[] { + return [] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + get players(): MutedPlayer[] { + return this._players.slice() + } + set players(value: MutedPlayer[]) { + this._players = Array.isArray(value) ? value.slice() : [] + if (this._initialised) this.render() + } + + private render(): void { + if (this._players.length === 0) { + this.innerHTML = `

    No muted players.

    ` + return + } + + const rowsHtml = this._players.map(p => { + const reasonHtml = p.reason + ? `${esc(p.reason)}` + : '' + const mutedAtHtml = p.mutedAt + ? `${esc(p.mutedAt)}` + : '' + return `
    +
    + ${esc(p.name)} + ${reasonHtml} +
    +
    + ${mutedAtHtml} + +
    +
    ` + }).join('') + + this.innerHTML = `
    ${rowsHtml}
    ` + + const container = this.querySelector('.tc-mute-list') + if (container) { + container.addEventListener('click', (e: Event) => { + const btn = (e.target as Element).closest('[data-action="unmute"]') + if (!btn || btn.disabled) return + const row = btn.closest('.tc-mute-list-row') + if (!row) return + const id = row.dataset.id ?? '' + this.dispatchEvent(new CustomEvent('tc-unmute', { + bubbles: true, + composed: true, + detail: { id }, + })) + if (typeof this.onUnmute === 'function') this.onUnmute(id) + }) + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: MuteList + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index dc0918a5..691d3e65 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -309,3 +309,4 @@ export * from './LootPopup' export * from './LoreText' export * from './MetalButton' export * from './Minimap' +export * from './MuteList' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index cfaa5376..788e09bf 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -306,6 +306,7 @@ import { LootPopup } from './LootPopup' import { LoreText } from './LoreText' import { MetalButton } from './MetalButton' import { Minimap } from './Minimap' +import { MuteList } from './MuteList' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -621,4 +622,5 @@ export function register(): void { customElements.define('tc-lore-text', LoreText) customElements.define('tc-metal-button', MetalButton) customElements.define('tc-minimap', Minimap) + customElements.define('tc-mute-list', MuteList) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index cb34b2e7..a7fcc4af 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -302,3 +302,4 @@ @forward 'lore-text'; @forward 'matchmaking-screen'; @forward 'minimap'; +@forward 'mute-list'; diff --git a/web-components/style/components/_mute-list.scss b/web-components/style/components/_mute-list.scss new file mode 100644 index 00000000..de158288 --- /dev/null +++ b/web-components/style/components/_mute-list.scss @@ -0,0 +1,194 @@ +// tc-mute-list — list of muted players with a per-row Unmute action. +// Slate neutrals; JetBrains Mono for names, reasons, and timestamps. +// Sharp corners; 1px hairlines. All cosmetics flow through --bs-mute-list-* custom properties. + +tc-mute-list { + // Panel + --bs-mute-list-bg: var(--tc-surface); + --bs-mute-list-border: 1px solid var(--tc-border); + + // Rows + --bs-mute-list-row-border: 1px solid var(--tc-border); + --bs-mute-list-row-hover-bg: var(--tc-surface-hover, var(--tc-surface-muted)); + + // Player name + --bs-mute-list-name-font-size: 0.8125rem; + --bs-mute-list-name-color: var(--tc-text); + + // Reason (sub-label) + --bs-mute-list-reason-font-size: 0.75rem; + --bs-mute-list-reason-color: var(--tc-text-muted); + + // Timestamp + --bs-mute-list-time-font-size: 0.6875rem; + --bs-mute-list-time-color: var(--tc-text-faint); + + // Unmute button + --bs-mute-list-btn-font-size: 0.75rem; + --bs-mute-list-btn-min-height: 1.75rem; + --bs-mute-list-btn-bg: var(--tc-surface); + --bs-mute-list-btn-color: var(--tc-text); + --bs-mute-list-btn-border: 1px solid var(--tc-border-strong); + --bs-mute-list-btn-hover-bg: var(--tc-app-accent); + --bs-mute-list-btn-hover-color: #fff; + + // Empty state + --bs-mute-list-empty-color: var(--tc-text-faint); +} + +// ── Panel shell ──────────────────────────────────────────────────────────────── + +.tc-mute-list { + display: flex; + flex-direction: column; + background: var(--bs-mute-list-bg); + border: var(--bs-mute-list-border); + border-radius: 0; + overflow: hidden; +} + +// ── Individual row ───────────────────────────────────────────────────────────── + +.tc-mute-list-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-bottom: var(--bs-mute-list-row-border); + min-height: 44px; + background: transparent; + transition: background var(--tc-transition-fast, 0.15s ease); + + &:last-child { + border-bottom: none; + } + + &:hover { + background: var(--bs-mute-list-row-hover-bg); + } +} + +// ── Text group (name + reason) ───────────────────────────────────────────────── + +.tc-mute-list-text { + display: flex; + flex-direction: column; + gap: 0.125rem; + flex: 1 1 auto; + min-width: 0; +} + +.tc-mute-list-name { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-mute-list-name-font-size); + font-weight: 500; + color: var(--bs-mute-list-name-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.4; +} + +.tc-mute-list-reason { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-mute-list-reason-font-size); + color: var(--bs-mute-list-reason-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.4; +} + +// ── Meta strip (timestamp + button) ─────────────────────────────────────────── + +.tc-mute-list-meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.tc-mute-list-time { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-mute-list-time-font-size); + color: var(--bs-mute-list-time-color); + line-height: 1.4; + white-space: nowrap; +} + +// ── Unmute button ────────────────────────────────────────────────────────────── + +.tc-mute-list-unmute { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0 0.625rem; + min-height: var(--bs-mute-list-btn-min-height); + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-mute-list-btn-font-size); + font-weight: 500; + letter-spacing: 0.025em; + color: var(--bs-mute-list-btn-color); + background: var(--bs-mute-list-btn-bg); + border: var(--bs-mute-list-btn-border); + border-radius: 0; + cursor: pointer; + transition: + background var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease), + transform var(--tc-transition-fast, 0.15s ease); + white-space: nowrap; + line-height: 1; + + &:hover:not(:disabled) { + background: var(--bs-mute-list-btn-hover-bg); + color: var(--bs-mute-list-btn-hover-color); + transform: translateY(-1px); + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + } + + &:disabled { + opacity: 0.45; + pointer-events: none; + cursor: default; + } +} + +// ── Empty state ──────────────────────────────────────────────────────────────── + +.tc-mute-list-empty { + padding: 1rem 0.75rem; + font-size: 0.8125rem; + color: var(--bs-mute-list-empty-color); + text-align: center; + margin: 0; +} + +// ── 44 px coarse touch targets ───────────────────────────────────────────────── + +@media (pointer: coarse) { + .tc-mute-list-unmute { + min-height: 44px; + } +} + +// ── Reduced motion ───────────────────────────────────────────────────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-mute-list-row, + .tc-mute-list-unmute { + transition: + background var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease); + } + + .tc-mute-list-unmute:hover:not(:disabled) { + transform: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 53243cb9..12cf3465 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -340,6 +340,7 @@ tc-menu-item, tc-loot-list, tc-lore-text, tc-minimap, +tc-mute-list, tc-roadmap { display: block; } From 2a878c43947970966347cfc4565494ff769d3ff3 Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 17 Jun 2026 23:39:57 +0000 Subject: [PATCH 346/632] 340-nav-button: Build the tc-nav-button web component (NavButton game-components port) --- examples/public/web-components/SKILL.md | 70 ++++++++++ examples/src/web-components/NavButtonDemo.tsx | 124 ++++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/NavButton.ts | 93 +++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_nav-button.scss | 97 ++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 391 insertions(+) create mode 100644 examples/src/web-components/NavButtonDemo.tsx create mode 100644 web-components/src/NavButton.ts create mode 100644 web-components/style/components/_nav-button.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index bcba11e8..3a008d54 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -249,6 +249,7 @@ After `register()` you can author markup directly: - [tc-breadcrumb](#tc-breadcrumb) - [tc-cool-nav](#tc-cool-nav) - [tc-nav](#tc-nav) + - [tc-nav-button](#tc-nav-button) - [tc-navbar](#tc-navbar) - [tc-pagination](#tc-pagination) - [tc-scrollspy](#tc-scrollspy) @@ -5204,6 +5205,75 @@ Navigation strip. --- +### tc-nav-button + +Back / close navigation button ported from `gc-nav-button` (game-components), restyled to the toolcase design system. Renders as a compact square icon button (chevron-left for `back`, × for `close`) with slate neutrals, sharp corners (`border-radius: 0`), and no game-specific chrome. The `size` attribute overrides the button dimensions in px. No shadow root; light DOM; `display: inline-block`. + +**Tag:** `tc-nav-button` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `kind` | `'back' \| 'close'` | `'back'` | Icon rendered. `back` shows a chevron-left; `close` shows an ×. | +| `label` | `string` | — | Overrides the default `aria-label` ("Back" for `back`, "Close" for `close`). | +| `size` | `number` | — | Square button dimension in px. Overrides `--bs-nav-button-size`. Omit to use the CSS default (2 rem). | +| `disabled` | boolean | absent | Disables the button — sets `disabled` on the inner `` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: NavButton + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 691d3e65..5cd9c67f 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -308,5 +308,6 @@ export * from './LootList' export * from './LootPopup' export * from './LoreText' export * from './MetalButton' +export * from './NavButton' export * from './Minimap' export * from './MuteList' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 788e09bf..93f43efe 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -305,6 +305,7 @@ import { LootList } from './LootList' import { LootPopup } from './LootPopup' import { LoreText } from './LoreText' import { MetalButton } from './MetalButton' +import { NavButton } from './NavButton' import { Minimap } from './Minimap' import { MuteList } from './MuteList' @@ -621,6 +622,7 @@ export function register(): void { customElements.define('tc-loot-popup', LootPopup) customElements.define('tc-lore-text', LoreText) customElements.define('tc-metal-button', MetalButton) + customElements.define('tc-nav-button', NavButton) customElements.define('tc-minimap', Minimap) customElements.define('tc-mute-list', MuteList) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index a7fcc4af..834aa13c 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -252,6 +252,7 @@ @forward 'main-menu'; @forward 'menu-item'; @forward 'metal-button'; +@forward 'nav-button'; @forward 'markdown-editor'; @forward 'marquee'; @forward 'multi-card-select'; diff --git a/web-components/style/components/_nav-button.scss b/web-components/style/components/_nav-button.scss new file mode 100644 index 00000000..504766d5 --- /dev/null +++ b/web-components/style/components/_nav-button.scss @@ -0,0 +1,97 @@ +// tc-nav-button — back / close navigation button. +// Port of gc-nav-button (game-components), restyled to the toolcase design system: +// slate neutrals, sharp corners (border-radius: 0), no game-specific chrome. +// Defaults to a 2 rem square ghost icon button; the `size` attribute overrides +// --bs-nav-button-size inline so the CSS default is always the fallback. +// All cosmetics flow through --bs-nav-button-* custom properties. + +@use '../foundation/tokens' as *; + +// ── Host defaults ───────────────────────────────────────────────────────────── + +tc-nav-button { + --bs-nav-button-size: 2rem; + --bs-nav-button-color: var(--tc-text-muted); + --bs-nav-button-bg: transparent; + --bs-nav-button-border-color: transparent; + --bs-nav-button-hover-color: var(--tc-text); + --bs-nav-button-hover-bg: var(--tc-surface-muted); + --bs-nav-button-hover-border-color: transparent; + --bs-nav-button-active-color: var(--tc-text); + --bs-nav-button-active-bg: var(--tc-surface-muted); + --bs-nav-button-active-border-color: transparent; + --bs-nav-button-focus-ring-color: rgba(30, 41, 59, 0.25); + --bs-nav-button-disabled-opacity: 0.65; + --bs-nav-button-border-radius: 0; + --bs-nav-button-icon-size: 1rem; +} + +// ── Inner button ───────────────────────────────────────────────────────────── + +.tc-nav-button__btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--bs-nav-button-size); + height: var(--bs-nav-button-size); + padding: 0; + color: var(--bs-nav-button-color); + background: var(--bs-nav-button-bg); + border: 1px solid var(--bs-nav-button-border-color); + border-radius: var(--bs-nav-button-border-radius); + cursor: pointer; + user-select: none; + transition: + color var(--tc-transition-fast, 0.15s ease), + background var(--tc-transition-fast, 0.15s ease), + border-color var(--tc-transition-fast, 0.15s ease), + transform var(--tc-transition-fast, 0.15s ease), + box-shadow var(--tc-transition-fast, 0.15s ease); + + svg { + width: var(--bs-nav-button-icon-size); + height: var(--bs-nav-button-icon-size); + flex-shrink: 0; + } + + &:hover { + color: var(--bs-nav-button-hover-color); + background: var(--bs-nav-button-hover-bg); + border-color: var(--bs-nav-button-hover-border-color); + transform: translateY(-1px); + } + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 0.25rem var(--bs-nav-button-focus-ring-color); + } + + &:active { + color: var(--bs-nav-button-active-color); + background: var(--bs-nav-button-active-bg); + border-color: var(--bs-nav-button-active-border-color); + transform: translateY(0); + } + + &:disabled { + opacity: var(--bs-nav-button-disabled-opacity); + pointer-events: none; + transform: none; + } +} + +// ── Touch target — 44 px minimum per WCAG 2.5.5 ────────────────────────────── + +@media (pointer: coarse) { + tc-nav-button { + --bs-nav-button-size: 44px; + } +} + +// ── Reduced motion ──────────────────────────────────────────────────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-nav-button__btn { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 12cf3465..487856a5 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -420,6 +420,7 @@ tc-chip, tc-button, tc-cool-button, tc-metal-button, +tc-nav-button, tc-badge, tc-brand, tc-avatar, From 475967d3d124669a15a176ba2b0a20d76eeaf03a Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 17 Jun 2026 23:48:20 +0000 Subject: [PATCH 347/632] 341-network-status-icon: Build the tc-network-status-icon web component (NetworkStatusIcon game-components port) --- examples/public/web-components/SKILL.md | 83 +++++++++++ .../web-components/NetworkStatusIconDemo.tsx | 110 +++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/NetworkStatusIcon.ts | 132 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../components/_network-status-icon.scss | 96 +++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 428 insertions(+) create mode 100644 examples/src/web-components/NetworkStatusIconDemo.tsx create mode 100644 web-components/src/NetworkStatusIcon.ts create mode 100644 web-components/style/components/_network-status-icon.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 3a008d54..d8d52d0e 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -235,6 +235,7 @@ After `register()` you can author markup directly: - [tc-minimap](#tc-minimap) - [tc-mute-list](#tc-mute-list) - [tc-matchmaking-screen](#tc-matchmaking-screen) + - [tc-network-status-icon](#tc-network-status-icon) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) - [tc-live-feed](#tc-live-feed) @@ -22190,3 +22191,85 @@ None. `tc-mute-list` is property-driven; all content is generated. }); ``` + +### tc-network-status-icon + +4-bar signal-strength indicator for connectivity / network quality. Bar count and tier are computed from ping latency and packet loss; the optional label shows the ping value or offline state. No shadow root; light DOM; `display: inline-flex`. + +**Tag:** `tc-network-status-icon` + +#### Attributes + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `ping` | `number \| null` | `null` | Round-trip latency in milliseconds. `null` or absent = unknown. | +| `loss` | `number` | `0` | Packet-loss percentage (0–100). High loss downgrades the tier even with a low ping. | +| `connected` | `boolean` | `false` | Boolean presence attribute. When absent the component renders in the **offline** (0-bar) tier. | +| `size` | `number` | `16` | Icon height in pixels. Bar widths scale proportionally. | +| `show-label` | `boolean` | `false` | When present, renders a JetBrains Mono label: ping value (`"N ms"`), `"ONLINE"` if connected with no ping, or `"OFFLINE"`. | + +#### JS Properties + +| Property | Type | Default | Description | +|---|---|---|---| +| `ping` | `number \| null` | `null` | Reflects the `ping` attribute. Set to `null` to remove. | +| `loss` | `number` | `0` | Reflects the `loss` attribute. | +| `connected` | `boolean` | `false` | Reflects the `connected` boolean attribute. | +| `size` | `number` | `16` | Reflects the `size` attribute. | +| `showLabel` | `boolean` | `false` | Reflects the `show-label` attribute. | + +#### Tier mapping + +| Active bars | `data-tier` | Condition | +|---|---|---| +| 4 | `good` | ping < 60 ms and loss < 1 % | +| 3 | `ok` | ping < 120 ms and loss < 3 % | +| 2 | `warning` | ping < 200 ms and loss < 5 % | +| 1 | `bad` | ping ≥ 200 ms or loss ≥ 5 % | +| 0 | `offline` | `connected` attribute absent | + +#### Events + +None. `tc-network-status-icon` is a purely presentational indicator with no user interaction. + +#### Slots + +None. All content is generated from attributes. + +#### CSS Custom Properties + +| Property | Default | Description | +|---|---|---| +| `--bs-network-status-icon-size` | `16px` | Icon height (set by the `size` attribute; override to drive size from CSS). | +| `--bs-network-status-icon-gap` | `0.375rem` | Gap between bar group and label. | +| `--bs-network-status-icon-bar-gap` | `2px` | Gap between individual bars. | +| `--bs-network-status-icon-bar-width` | `calc(size × 0.22)` | Width of each bar. | +| `--bs-network-status-icon-inactive-color` | `var(--tc-border-strong)` | Colour of inactive (empty) bars. | +| `--bs-network-status-icon-label-font-size` | `calc(size × 0.75)` | Label font size. | +| `--bs-network-status-icon-label-color` | `var(--tc-text-muted)` | Label text colour. | +| `--bs-network-status-icon-color-offline` | `var(--tc-text-faint)` | Active bar colour for the `offline` tier. | +| `--bs-network-status-icon-color-bad` | `var(--tc-danger)` | Active bar colour for the `bad` tier. | +| `--bs-network-status-icon-color-warning` | `var(--tc-warning)` | Active bar colour for the `warning` tier. | +| `--bs-network-status-icon-color-ok` | `var(--tc-accent)` | Active bar colour for the `ok` tier. | +| `--bs-network-status-icon-color-good` | `var(--tc-success)` | Active bar colour for the `good` tier. | + +#### Example + +```html + + + + + + + + + + + + +``` diff --git a/examples/src/web-components/NetworkStatusIconDemo.tsx b/examples/src/web-components/NetworkStatusIconDemo.tsx new file mode 100644 index 00000000..2456a5af --- /dev/null +++ b/examples/src/web-components/NetworkStatusIconDemo.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useRef } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const NetworkStatusIconDemo: React.FC = () => { + const interactiveRef = useRef(null) + + useEffect(() => { + const el = interactiveRef.current + if (!el) return + + let direction = 1 + let ping = 20 + const id = setInterval(() => { + ping += direction * 15 + if (ping >= 250) { ping = 250; direction = -1 } + if (ping <= 20) { ping = 20; direction = 1 } + el.ping = ping + }, 600) + + return () => clearInterval(id) + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="NetworkStatusIcon" + description="4-bar signal-strength indicator. Quality is computed from ping latency and packet loss. Tiers: good (4 bars), ok (3), warning (2), bad (1), offline (0)." + /> + +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + + Ping animates 20–250 ms every 600 ms + +
    +
    + +
    +
    +
    +
    +
    + ) +} + +export default NetworkStatusIconDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index fff7de3e..aaa867c6 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -300,6 +300,7 @@ import LoadingScreenDemo from './LoadingScreenDemo' import LoreTextDemo from './LoreTextDemo' import MinimapDemo from './MinimapDemo' import MuteListDemo from './MuteListDemo' +import NetworkStatusIconDemo from './NetworkStatusIconDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -620,4 +621,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'lore-text', category: 'Content', element: }, { key: 'minimap', category: 'Components', element: }, { key: 'mute-list', category: 'Components', element: }, + { key: 'network-status-icon', category: 'Components', element: }, ] diff --git a/web-components/src/NetworkStatusIcon.ts b/web-components/src/NetworkStatusIcon.ts new file mode 100644 index 00000000..f503a9d1 --- /dev/null +++ b/web-components/src/NetworkStatusIcon.ts @@ -0,0 +1,132 @@ +const TAG_NAME = 'tc-network-status-icon' + +export type NetworkStatusTier = 'offline' | 'bad' | 'warning' | 'ok' | 'good' + +const TIER_LABELS: Record = { + offline: 'Offline', + bad: 'Bad signal', + warning: 'Poor signal', + ok: 'Fair signal', + good: 'Good signal', +} + +export class NetworkStatusIcon extends HTMLElement { + private _initialised = false + + static get observedAttributes(): string[] { + return ['ping', 'loss', 'connected', 'size', 'show-label'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get ping(): number | null { + const raw = this.getAttribute('ping') + if (raw == null || raw === '') return null + const parsed = parseFloat(raw) + return Number.isNaN(parsed) ? null : parsed + } + set ping(value: number | null) { + if (value == null) this.removeAttribute('ping') + else this.setAttribute('ping', String(value)) + } + + get loss(): number { + const raw = this.getAttribute('loss') + if (raw == null) return 0 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) || parsed < 0 ? 0 : parsed + } + set loss(value: number) { + this.setAttribute('loss', String(value)) + } + + get connected(): boolean { + return this.hasAttribute('connected') + } + set connected(value: boolean) { + if (value) this.setAttribute('connected', '') + else this.removeAttribute('connected') + } + + get size(): number { + const raw = this.getAttribute('size') + if (raw == null) return 16 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) || parsed <= 0 ? 16 : parsed + } + set size(value: number) { + this.setAttribute('size', String(value)) + } + + get showLabel(): boolean { + return this.hasAttribute('show-label') + } + set showLabel(value: boolean) { + if (value) this.setAttribute('show-label', '') + else this.removeAttribute('show-label') + } + + private _computeBars(): number { + if (!this.connected) return 0 + const ping = this.ping + const loss = this.loss + const p = ping == null ? 999 : ping + if (p >= 200 || loss >= 5) return 1 + if (p >= 120 || loss >= 3) return 2 + if (p >= 60 || loss >= 1) return 3 + return 4 + } + + private _computeTier(bars: number): NetworkStatusTier { + if (bars === 0) return 'offline' + if (bars === 1) return 'bad' + if (bars === 2) return 'warning' + if (bars === 3) return 'ok' + return 'good' + } + + private _computeLabelText(tier: NetworkStatusTier): string { + if (tier === 'offline') return 'OFFLINE' + const ping = this.ping + if (ping == null) return 'ONLINE' + return `${Math.round(ping)} ms` + } + + private render(): void { + const size = this.size + this.style.setProperty('--bs-network-status-icon-size', `${size}px`) + + const bars = this._computeBars() + const tier = this._computeTier(bars) + + this.dataset.tier = tier + + const barsHtml = [1, 2, 3, 4].map(i => { + const active = i <= bars ? ' is-active' : '' + return `` + }).join('') + + const ariaLabel = TIER_LABELS[tier] + const labelHtml = this.showLabel + ? `` + : '' + + this.innerHTML = `${barsHtml}${labelHtml}` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: NetworkStatusIcon + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 5cd9c67f..f8c77d80 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -309,5 +309,6 @@ export * from './LootPopup' export * from './LoreText' export * from './MetalButton' export * from './NavButton' +export * from './NetworkStatusIcon' export * from './Minimap' export * from './MuteList' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 93f43efe..48ab55c6 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -306,6 +306,7 @@ import { LootPopup } from './LootPopup' import { LoreText } from './LoreText' import { MetalButton } from './MetalButton' import { NavButton } from './NavButton' +import { NetworkStatusIcon } from './NetworkStatusIcon' import { Minimap } from './Minimap' import { MuteList } from './MuteList' @@ -623,6 +624,7 @@ export function register(): void { customElements.define('tc-lore-text', LoreText) customElements.define('tc-metal-button', MetalButton) customElements.define('tc-nav-button', NavButton) + customElements.define('tc-network-status-icon', NetworkStatusIcon) customElements.define('tc-minimap', Minimap) customElements.define('tc-mute-list', MuteList) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 834aa13c..2632999d 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -253,6 +253,7 @@ @forward 'menu-item'; @forward 'metal-button'; @forward 'nav-button'; +@forward 'network-status-icon'; @forward 'markdown-editor'; @forward 'marquee'; @forward 'multi-card-select'; diff --git a/web-components/style/components/_network-status-icon.scss b/web-components/style/components/_network-status-icon.scss new file mode 100644 index 00000000..3ccec114 --- /dev/null +++ b/web-components/style/components/_network-status-icon.scss @@ -0,0 +1,96 @@ +// tc-network-status-icon — 4-bar signal-strength indicator. +// Bars grow progressively in height from left (shortest) to right (tallest). +// Active bars are coloured by tier; inactive bars use a muted hairline fill. +// JetBrains Mono for the optional ping/label text; sharp corners everywhere. +// All cosmetics flow through --bs-network-status-icon-* custom properties. + +tc-network-status-icon { + --bs-network-status-icon-size: 16px; + --bs-network-status-icon-gap: 0.375rem; + --bs-network-status-icon-bar-gap: 2px; + --bs-network-status-icon-bar-width: calc(var(--bs-network-status-icon-size) * 0.22); + --bs-network-status-icon-bar-radius: 0; + --bs-network-status-icon-inactive-color: var(--tc-border-strong, #475569); + --bs-network-status-icon-label-font-size: calc(var(--bs-network-status-icon-size) * 0.75); + --bs-network-status-icon-label-color: var(--tc-text-muted); + // Tier accent colours + --bs-network-status-icon-color-offline: var(--tc-text-faint, #94a3b8); + --bs-network-status-icon-color-bad: var(--tc-danger, #ef4444); + --bs-network-status-icon-color-warning: var(--tc-warning, #f59e0b); + --bs-network-status-icon-color-ok: var(--tc-accent, #22d3ee); + --bs-network-status-icon-color-good: var(--tc-success, #22c55e); +} + +.tc-network-status-icon { + display: inline-flex; + align-items: center; + gap: var(--bs-network-status-icon-gap); + line-height: 1; +} + +// ── Bar group ────────────────────────────────────────────────────────────────── + +.tc-network-status-icon-bars { + display: inline-flex; + align-items: flex-end; + gap: var(--bs-network-status-icon-bar-gap); + height: var(--bs-network-status-icon-size); +} + +// ── Individual bars ──────────────────────────────────────────────────────────── +// Each bar grows in height proportional to its step (1=25%, 2=50%, 3=75%, 4=100%). + +.tc-network-status-icon-bar { + display: inline-block; + width: var(--bs-network-status-icon-bar-width); + border-radius: var(--bs-network-status-icon-bar-radius); + background-color: var(--bs-network-status-icon-inactive-color); + flex-shrink: 0; + + &[data-step="1"] { height: 25%; } + &[data-step="2"] { height: 50%; } + &[data-step="3"] { height: 75%; } + &[data-step="4"] { height: 100%; } +} + +// Active bars adopt the tier colour from the host data-tier attribute. + +tc-network-status-icon[data-tier="offline"] .tc-network-status-icon-bar.is-active { + background-color: var(--bs-network-status-icon-color-offline); +} + +tc-network-status-icon[data-tier="bad"] .tc-network-status-icon-bar.is-active { + background-color: var(--bs-network-status-icon-color-bad); +} + +tc-network-status-icon[data-tier="warning"] .tc-network-status-icon-bar.is-active { + background-color: var(--bs-network-status-icon-color-warning); +} + +tc-network-status-icon[data-tier="ok"] .tc-network-status-icon-bar.is-active { + background-color: var(--bs-network-status-icon-color-ok); +} + +tc-network-status-icon[data-tier="good"] .tc-network-status-icon-bar.is-active { + background-color: var(--bs-network-status-icon-color-good); +} + +// ── Label ────────────────────────────────────────────────────────────────────── + +.tc-network-status-icon-label { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-network-status-icon-label-font-size); + font-weight: 500; + color: var(--bs-network-status-icon-label-color); + letter-spacing: 0.03em; + line-height: 1; + white-space: nowrap; +} + +// ── Reduced motion ───────────────────────────────────────────────────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-network-status-icon-bar { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 487856a5..c595532a 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -479,6 +479,7 @@ tc-icon-button, tc-kbd, tc-key, tc-key-binder, +tc-network-status-icon, tc-rating, tc-social-links, tc-status-dot, From 7fc8885ca81a00d1c178f1557e4191498d548d6d Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 17 Jun 2026 23:55:48 +0000 Subject: [PATCH 348/632] 342-objective-marker: Build the tc-objective-marker web component (ObjectiveMarker game-components port) --- examples/public/web-components/SKILL.md | 72 +++++++++ .../web-components/ObjectiveMarkerDemo.tsx | 73 +++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/ObjectiveMarker.ts | 143 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_objective-marker.scss | 92 +++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 387 insertions(+) create mode 100644 examples/src/web-components/ObjectiveMarkerDemo.tsx create mode 100644 web-components/src/ObjectiveMarker.ts create mode 100644 web-components/style/components/_objective-marker.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index d8d52d0e..47446312 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -236,6 +236,7 @@ After `register()` you can author markup directly: - [tc-mute-list](#tc-mute-list) - [tc-matchmaking-screen](#tc-matchmaking-screen) - [tc-network-status-icon](#tc-network-status-icon) + - [tc-objective-marker](#tc-objective-marker) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) - [tc-live-feed](#tc-live-feed) @@ -22273,3 +22274,74 @@ None. All content is generated from attributes. setInterval(() => { net.ping = Math.round(20 + Math.random() * 200) }, 1000); ``` + +### tc-objective-marker + +Absolutely-positioned world-space marker with a map-pin glyph, optional label, and formatted distance readout (metres / kilometres). Drop it inside a `position: relative` container and set `x`/`y` to world coordinates. The element is `position: absolute` and transforms to pin the glyph tip at the target point. No shadow root; light DOM; `display: inline-flex`. + +**Tag:** `tc-objective-marker` + +#### Attributes + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `x` | `number \| null` | `null` | Horizontal position in pixels (`style.left`). Omit to leave unset. | +| `y` | `number \| null` | `null` | Vertical position in pixels (`style.top`). Omit to leave unset. | +| `label` | `string` | `''` | Objective label displayed below the glyph. Omit for no text chip. | +| `distance` | `number \| null` | `null` | Distance in metres. Values ≥ 1000 are formatted as `N.Nkm`. Omit for no distance readout. | +| `color` | `string` | `''` | CSS colour for the glyph and border. Writes `--bs-objective-marker-color` inline. | +| `size` | `number \| null` | `null` | Glyph icon size in pixels. Writes `--bs-objective-marker-size` inline. | +| `pulse` | `boolean` | `false` | Boolean presence attribute. Enables a scale/fade pulse animation on the glyph. | + +#### JS Properties + +| Property | Type | Description | +|---|---|---| +| `x` | `number \| null` | Reflects the `x` attribute. | +| `y` | `number \| null` | Reflects the `y` attribute. | +| `label` | `string` | Reflects the `label` attribute. | +| `distance` | `number \| null` | Reflects the `distance` attribute. | +| `color` | `string` | Reflects the `color` attribute. | +| `size` | `number \| null` | Reflects the `size` attribute. | +| `pulse` | `boolean` | Reflects the `pulse` boolean attribute. | + +#### Events + +None. `tc-objective-marker` is a purely presentational element. + +#### Slots + +None. `tc-objective-marker` is attribute-driven; all content is generated internally. + +#### CSS Custom Properties + +| Property | Default | Description | +|---|---|---| +| `--bs-objective-marker-size` | `18px` | Map-pin glyph size in pixels (overridden by the `size` attribute when set). | +| `--bs-objective-marker-color` | `var(--tc-app-accent)` | Glyph and chip-border accent colour (overridden by the `color` attribute when set). | +| `--bs-objective-marker-bg` | `var(--tc-surface)` | Background of the label/distance chip. | +| `--bs-objective-marker-border-color` | `var(--tc-border)` | 1px hairline border around the label/distance chip. | +| `--bs-objective-marker-text-color` | `var(--tc-text)` | Label text colour. | +| `--bs-objective-marker-distance-color` | `var(--tc-text-muted)` | Distance readout colour. | +| `--bs-objective-marker-label-size` | `11px` | Label font size. | +| `--bs-objective-marker-distance-size` | `10px` | Distance readout font size. | + +#### Example + +```html +
    + + +
    + + + + +``` diff --git a/examples/src/web-components/ObjectiveMarkerDemo.tsx b/examples/src/web-components/ObjectiveMarkerDemo.tsx new file mode 100644 index 00000000..2b8a0407 --- /dev/null +++ b/examples/src/web-components/ObjectiveMarkerDemo.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const ObjectiveMarkerDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="ObjectiveMarker" + description="Absolutely-positioned world-space marker with a map-pin glyph, optional label, and formatted distance readout. Drop inside a position:relative container and set x/y for world coordinates. Pulse animation is gated by the [pulse] attribute." + /> + +
    + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    +) + +export default ObjectiveMarkerDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index aaa867c6..7459635d 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -301,6 +301,7 @@ import LoreTextDemo from './LoreTextDemo' import MinimapDemo from './MinimapDemo' import MuteListDemo from './MuteListDemo' import NetworkStatusIconDemo from './NetworkStatusIconDemo' +import ObjectiveMarkerDemo from './ObjectiveMarkerDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -622,4 +623,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'minimap', category: 'Components', element: }, { key: 'mute-list', category: 'Components', element: }, { key: 'network-status-icon', category: 'Components', element: }, + { key: 'objective-marker', category: 'Components', element: }, ] diff --git a/web-components/src/ObjectiveMarker.ts b/web-components/src/ObjectiveMarker.ts new file mode 100644 index 00000000..8752fb99 --- /dev/null +++ b/web-components/src/ObjectiveMarker.ts @@ -0,0 +1,143 @@ +import * as LucideIcons from 'lucide-static' +import { icon } from './icons' + +const TAG_NAME = 'tc-objective-marker' + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function lucideByName(name: string): string { + const pascal = name + .split('-') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') + const svgStr = (LucideIcons as Record)[pascal] + if (!svgStr) return '' + return icon(svgStr) +} + +// Pre-computed at module level — always rendered, never conditional. +const mapPinIconHtml = lucideByName('map-pin') + +function formatDistance(d: number): string { + if (d >= 1000) return `${(d / 1000).toFixed(1)}km` + return `${Math.round(d)}m` +} + +export class ObjectiveMarker extends HTMLElement { + private _initialised = false + + static get observedAttributes(): string[] { + return ['x', 'y', 'label', 'distance', 'color', 'size', 'pulse'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + private numberAttr(name: string): number | null { + const raw = this.getAttribute(name) + if (raw == null) return null + const parsed = parseFloat(raw) + return Number.isFinite(parsed) ? parsed : null + } + + get x(): number | null { return this.numberAttr('x') } + set x(v: number | null) { + if (v == null) this.removeAttribute('x') + else this.setAttribute('x', String(v)) + } + + get y(): number | null { return this.numberAttr('y') } + set y(v: number | null) { + if (v == null) this.removeAttribute('y') + else this.setAttribute('y', String(v)) + } + + get label(): string { return this.getAttribute('label') ?? '' } + set label(v: string) { + if (v) this.setAttribute('label', v) + else this.removeAttribute('label') + } + + get distance(): number | null { return this.numberAttr('distance') } + set distance(v: number | null) { + if (v == null) this.removeAttribute('distance') + else this.setAttribute('distance', String(v)) + } + + get color(): string { return this.getAttribute('color') ?? '' } + set color(v: string) { + if (v) this.setAttribute('color', v) + else this.removeAttribute('color') + } + + get size(): number | null { return this.numberAttr('size') } + set size(v: number | null) { + if (v == null) this.removeAttribute('size') + else this.setAttribute('size', String(v)) + } + + get pulse(): boolean { return this.hasAttribute('pulse') } + set pulse(v: boolean) { + if (v) this.setAttribute('pulse', '') + else this.removeAttribute('pulse') + } + + private render(): void { + const x = this.x + const y = this.y + const size = this.size + const color = this.color + + if (x != null) this.style.left = `${x}px` + else this.style.removeProperty('left') + if (y != null) this.style.top = `${y}px` + else this.style.removeProperty('top') + if (size != null) this.style.setProperty('--bs-objective-marker-size', `${size}px`) + else this.style.removeProperty('--bs-objective-marker-size') + if (color) this.style.setProperty('--bs-objective-marker-color', color) + else this.style.removeProperty('--bs-objective-marker-color') + + this.classList.add('tc-objective-marker') + + const label = this.label + const distance = this.distance + + const distanceMarkup = distance != null + ? `${esc(formatDistance(distance))}` + : '' + const textMarkup = (label || distance != null) + ? `
    + ${label ? `${esc(label)}` : ''} + ${distanceMarkup} +
    ` + : '' + + this.innerHTML = ` + + ${textMarkup} + ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: ObjectiveMarker + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index f8c77d80..a784e659 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -312,3 +312,4 @@ export * from './NavButton' export * from './NetworkStatusIcon' export * from './Minimap' export * from './MuteList' +export * from './ObjectiveMarker' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 48ab55c6..bf84ffdd 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -309,6 +309,7 @@ import { NavButton } from './NavButton' import { NetworkStatusIcon } from './NetworkStatusIcon' import { Minimap } from './Minimap' import { MuteList } from './MuteList' +import { ObjectiveMarker } from './ObjectiveMarker' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -627,4 +628,5 @@ export function register(): void { customElements.define('tc-network-status-icon', NetworkStatusIcon) customElements.define('tc-minimap', Minimap) customElements.define('tc-mute-list', MuteList) + customElements.define('tc-objective-marker', ObjectiveMarker) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 2632999d..d245a361 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -305,3 +305,4 @@ @forward 'matchmaking-screen'; @forward 'minimap'; @forward 'mute-list'; +@forward 'objective-marker'; diff --git a/web-components/style/components/_objective-marker.scss b/web-components/style/components/_objective-marker.scss new file mode 100644 index 00000000..54f2d214 --- /dev/null +++ b/web-components/style/components/_objective-marker.scss @@ -0,0 +1,92 @@ +// tc-objective-marker — absolutely-positioned world-space marker with a +// map-pin glyph, optional label, and formatted distance readout. +// Sharp corners everywhere; circular / pill shapes are not used here. +// Color flows through --bs-objective-marker-color (default: --tc-app-accent). +// All cosmetics use --bs-objective-marker-* backed by --tc-* tokens. + +@use '../foundation/tokens' as *; + +tc-objective-marker { + --bs-objective-marker-size: 18px; + --bs-objective-marker-color: var(--tc-app-accent); + --bs-objective-marker-bg: var(--tc-surface); + --bs-objective-marker-border-color: var(--tc-border); + --bs-objective-marker-text-color: var(--tc-text); + --bs-objective-marker-distance-color: var(--tc-text-muted); + --bs-objective-marker-label-size: 11px; + --bs-objective-marker-distance-size: 10px; + + position: absolute; + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 3px; + pointer-events: none; + transform: translate(-50%, -100%); + color: var(--bs-objective-marker-color); +} + +.tc-objective-marker { + // Map-pin icon; size driven by --bs-objective-marker-size. + &__glyph { + display: flex; + align-items: center; + justify-content: center; + color: var(--bs-objective-marker-color); + + svg { + width: var(--bs-objective-marker-size); + height: var(--bs-objective-marker-size); + stroke: currentColor; + } + } + + // Label + distance chip — 1px hairline, slate surface, no border-radius. + &__text { + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + background: var(--bs-objective-marker-bg); + border: 1px solid var(--bs-objective-marker-border-color); + padding: 2px 8px; + border-radius: 0; + white-space: nowrap; + } + + // Uppercase micro-label in JetBrains Mono (machine-facing structure text). + &__label { + font-family: 'JetBrains Mono', monospace; + font-size: var(--bs-objective-marker-label-size); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bs-objective-marker-text-color); + line-height: 1.3; + } + + // Distance readout in JetBrains Mono — muted, tabular. + &__distance { + font-family: 'JetBrains Mono', monospace; + font-size: var(--bs-objective-marker-distance-size); + letter-spacing: 0.06em; + color: var(--bs-objective-marker-distance-color); + line-height: 1.3; + } +} + +// Pulse animation on the glyph when [pulse] is present. +tc-objective-marker[pulse] .tc-objective-marker__glyph { + animation: tc-objective-marker-pulse 1.4s ease-in-out infinite; +} + +@keyframes tc-objective-marker-pulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.2); opacity: 0.65; } +} + +@media (prefers-reduced-motion: reduce) { + tc-objective-marker[pulse] .tc-objective-marker__glyph { + animation: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index c595532a..7b417a9c 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -480,6 +480,7 @@ tc-kbd, tc-key, tc-key-binder, tc-network-status-icon, +tc-objective-marker, tc-rating, tc-social-links, tc-status-dot, From 47450a8aa64f17091f6a57413d0183c1fe5f8509 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 00:04:10 +0000 Subject: [PATCH 349/632] 343-page-indicator: Build the tc-page-indicator web component (PageIndicator game-components port) --- examples/public/web-components/SKILL.md | 78 +++++++++ .../src/web-components/PageIndicatorDemo.tsx | 74 ++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/PageIndicator.ts | 159 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_page-indicator.scss | 64 +++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 382 insertions(+) create mode 100644 examples/src/web-components/PageIndicatorDemo.tsx create mode 100644 web-components/src/PageIndicator.ts create mode 100644 web-components/style/components/_page-indicator.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 47446312..e7a93dc7 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -237,6 +237,7 @@ After `register()` you can author markup directly: - [tc-matchmaking-screen](#tc-matchmaking-screen) - [tc-network-status-icon](#tc-network-status-icon) - [tc-objective-marker](#tc-objective-marker) + - [tc-page-indicator](#tc-page-indicator) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) - [tc-live-feed](#tc-live-feed) @@ -22345,3 +22346,80 @@ None. `tc-objective-marker` is attribute-driven; all content is generated intern marker.pulse = true; ``` + +--- + +### tc-page-indicator + +Dot page-navigation widget. Renders one circular button per page; clicking or pressing Enter/Space on a dot selects that page and fires `tc-select`. + +**Tag:** `tc-page-indicator` + +**Category:** Navigation + +#### Attributes + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `count` | `number` | `0` | Total number of pages (dots rendered). | +| `index` | `number` | `0` | Zero-based index of the currently active dot. Updated automatically on selection. | +| `size` | `number` | — | Dot diameter in pixels. When absent, `--bs-page-indicator-size` (default `8px`) is used. | +| `gap` | `string` | — | CSS length for the gap between dots (e.g. `"10px"`). When absent, `--bs-page-indicator-gap` (default `6px`) is used. | +| `color` | `string` | — | CSS color for inactive dots. Writes `--bs-page-indicator-color` as an inline style. | +| `active-color` | `string` | — | CSS color for the active dot. Writes `--bs-page-indicator-active-color` as an inline style. | + +#### JS Properties + +| Property | Type | Default | Description | +|---|---|---|---| +| `count` | `number` | `0` | Reflects the `count` attribute. | +| `index` | `number` | `0` | Reflects the `index` attribute. Setting this programmatically re-renders without firing `tc-select`. | +| `size` | `number \| null` | `null` | Reflects the `size` attribute. | +| `gap` | `string` | `""` | Reflects the `gap` attribute. | +| `color` | `string` | `""` | Reflects the `color` attribute. | +| `activeColor` | `string` | `""` | Reflects the `active-color` attribute. | +| `onSelect` | `((index: number) => void) \| null` | `null` | Optional callback fired alongside `tc-select`. | + +#### Events + +| Event | Detail | Description | +|---|---|---| +| `tc-select` | `{ index: number }` | Fired when the user selects a dot by click or keyboard. Not fired when `index` is set programmatically. | + +#### Slots + +None. `tc-page-indicator` is entirely attribute-driven; all content is generated internally. + +#### CSS Custom Properties + +| Property | Default | Description | +|---|---|---| +| `--bs-page-indicator-size` | `8px` | Diameter of each dot. Overridden by the `size` attribute when set. | +| `--bs-page-indicator-gap` | `6px` | Gap between dots. Overridden by the `gap` attribute when set. | +| `--bs-page-indicator-color` | `var(--tc-border-strong)` | Inactive dot color (border and fill on hover). Overridden by the `color` attribute. | +| `--bs-page-indicator-active-color` | `var(--tc-app-accent)` | Active dot fill color. Overridden by the `active-color` attribute. | +| `--bs-page-indicator-dot-border-width` | `1px` | Border width of each dot. | + +#### Usage + +```html + + + + + + + + + + + + +``` diff --git a/examples/src/web-components/PageIndicatorDemo.tsx b/examples/src/web-components/PageIndicatorDemo.tsx new file mode 100644 index 00000000..f1313d02 --- /dev/null +++ b/examples/src/web-components/PageIndicatorDemo.tsx @@ -0,0 +1,74 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const PageIndicatorDemo: React.FC = () => { + const interactiveRef = useRef(null) + const [activeIndex, setActiveIndex] = useState(0) + + useEffect(() => { + const el = interactiveRef.current + if (!el) return + const onSelect = (e: Event) => { + const ce = e as CustomEvent<{ index: number }> + setActiveIndex(ce.detail.index) + } + el.addEventListener('tc-select', onSelect) + return () => el.removeEventListener('tc-select', onSelect) + }, []) + + useEffect(() => { + if (interactiveRef.current) { + interactiveRef.current.setAttribute('index', String(activeIndex)) + } + }, [activeIndex]) + + return ( +
    +
    +
    +
    + Web Components} + title="Page Indicator" + description="Dot page-navigation widget. Fires tc-select with { index } when a dot is clicked." + /> + +
    + + {/* @ts-ignore */} + + + + +
    + {/* @ts-ignore */} + + + page {activeIndex + 1} / 7 + +
    +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    + ) +} + +export default PageIndicatorDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 7459635d..38bcb698 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -302,6 +302,7 @@ import MinimapDemo from './MinimapDemo' import MuteListDemo from './MuteListDemo' import NetworkStatusIconDemo from './NetworkStatusIconDemo' import ObjectiveMarkerDemo from './ObjectiveMarkerDemo' +import PageIndicatorDemo from './PageIndicatorDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -624,4 +625,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'mute-list', category: 'Components', element: }, { key: 'network-status-icon', category: 'Components', element: }, { key: 'objective-marker', category: 'Components', element: }, + { key: 'page-indicator', category: 'Navigation', element: }, ] diff --git a/web-components/src/PageIndicator.ts b/web-components/src/PageIndicator.ts new file mode 100644 index 00000000..87a85eec --- /dev/null +++ b/web-components/src/PageIndicator.ts @@ -0,0 +1,159 @@ +const TAG_NAME = 'tc-page-indicator' + +export class PageIndicator extends HTMLElement { + + private _initialised = false + private _clickHandler = (e: Event) => this._onClick(e) + private _keydownHandler = (e: KeyboardEvent) => this._onKeydown(e) + + onSelect: ((index: number) => void) | null = null + + static get observedAttributes(): string[] { + return ['count', 'index', 'size', 'gap', 'color', 'active-color'] + } + + connectedCallback(): void { + this.addEventListener('click', this._clickHandler) + this.addEventListener('keydown', this._keydownHandler) + if (!this._initialised) { + this.setAttribute('role', 'group') + if (!this.hasAttribute('aria-label')) { + this.setAttribute('aria-label', 'Page navigation') + } + this.render() + this._initialised = true + } + } + + disconnectedCallback(): void { + this.removeEventListener('click', this._clickHandler) + this.removeEventListener('keydown', this._keydownHandler) + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get count(): number { + const raw = this.getAttribute('count') + if (raw == null) return 0 + const parsed = parseInt(raw, 10) + return Number.isNaN(parsed) ? 0 : Math.max(0, parsed) + } + set count(value: number) { + this.setAttribute('count', String(value)) + } + + get index(): number { + const raw = this.getAttribute('index') + if (raw == null) return 0 + const parsed = parseInt(raw, 10) + return Number.isNaN(parsed) ? 0 : Math.max(0, parsed) + } + set index(value: number) { + this.setAttribute('index', String(value)) + } + + get size(): number | null { + const value = this.getAttribute('size') + if (value == null) return null + const parsed = parseFloat(value) + return Number.isNaN(parsed) ? null : parsed + } + set size(value: number | null) { + if (value == null) this.removeAttribute('size') + else this.setAttribute('size', String(value)) + } + + get gap(): string { + return this.getAttribute('gap') ?? '' + } + set gap(value: string) { + if (value) this.setAttribute('gap', value) + else this.removeAttribute('gap') + } + + get color(): string { + return this.getAttribute('color') ?? '' + } + set color(value: string) { + if (value) this.setAttribute('color', value) + else this.removeAttribute('color') + } + + get activeColor(): string { + return this.getAttribute('active-color') ?? '' + } + set activeColor(value: string) { + if (value) this.setAttribute('active-color', value) + else this.removeAttribute('active-color') + } + + private _onClick(e: Event): void { + const target = (e.target as HTMLElement | null)?.closest('[data-pi]') + if (!target) return + const idx = parseInt(target.dataset.pi ?? '', 10) + if (Number.isNaN(idx)) return + this._select(idx) + } + + private _onKeydown(e: KeyboardEvent): void { + if (e.key !== 'Enter' && e.key !== ' ') return + const target = (e.target as HTMLElement | null)?.closest('[data-pi]') + if (!target) return + const idx = parseInt(target.dataset.pi ?? '', 10) + if (Number.isNaN(idx)) return + e.preventDefault() + this._select(idx) + } + + private _select(index: number): void { + if (index === this.index) return + this.index = index + this.dispatchEvent(new CustomEvent('tc-select', { + bubbles: true, + composed: true, + detail: { index }, + })) + if (typeof this.onSelect === 'function') this.onSelect(index) + } + + private render(): void { + const size = this.size + if (size != null) this.style.setProperty('--bs-page-indicator-size', `${size}px`) + else this.style.removeProperty('--bs-page-indicator-size') + + const gap = this.getAttribute('gap') + if (gap) this.style.setProperty('--bs-page-indicator-gap', gap) + else this.style.removeProperty('--bs-page-indicator-gap') + + const color = this.getAttribute('color') + if (color) this.style.setProperty('--bs-page-indicator-color', color) + else this.style.removeProperty('--bs-page-indicator-color') + + const activeColor = this.getAttribute('active-color') + if (activeColor) this.style.setProperty('--bs-page-indicator-active-color', activeColor) + else this.style.removeProperty('--bs-page-indicator-active-color') + + const count = this.count + const active = this.index + const dots: string[] = [] + for (let i = 0; i < count; i++) { + const isActive = i === active + const cls = isActive + ? 'tc-page-indicator-dot tc-page-indicator-dot--active' + : 'tc-page-indicator-dot' + dots.push( + `` + ) + } + this.innerHTML = dots.join('') + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: PageIndicator + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index a784e659..8fb416c8 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -313,3 +313,4 @@ export * from './NetworkStatusIcon' export * from './Minimap' export * from './MuteList' export * from './ObjectiveMarker' +export * from './PageIndicator' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index bf84ffdd..b87ad0a9 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -310,6 +310,7 @@ import { NetworkStatusIcon } from './NetworkStatusIcon' import { Minimap } from './Minimap' import { MuteList } from './MuteList' import { ObjectiveMarker } from './ObjectiveMarker' +import { PageIndicator } from './PageIndicator' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -629,4 +630,5 @@ export function register(): void { customElements.define('tc-minimap', Minimap) customElements.define('tc-mute-list', MuteList) customElements.define('tc-objective-marker', ObjectiveMarker) + customElements.define('tc-page-indicator', PageIndicator) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index d245a361..d524d95e 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -306,3 +306,4 @@ @forward 'minimap'; @forward 'mute-list'; @forward 'objective-marker'; +@forward 'page-indicator'; diff --git a/web-components/style/components/_page-indicator.scss b/web-components/style/components/_page-indicator.scss new file mode 100644 index 00000000..00c859de --- /dev/null +++ b/web-components/style/components/_page-indicator.scss @@ -0,0 +1,64 @@ +// tc-page-indicator — dot page-navigation widget. +// Dots are sanctioned circles (styleguide §1 rule 3 lists "carousel dots" +// as an explicit exception to the no-radius mandate). +// All cosmetics flow through --bs-page-indicator-* custom properties. + +tc-page-indicator { + --bs-page-indicator-size: 8px; + --bs-page-indicator-gap: 6px; + --bs-page-indicator-color: var(--tc-border-strong); + --bs-page-indicator-active-color: var(--tc-app-accent); + --bs-page-indicator-dot-border-width: 1px; + + display: inline-flex; + align-items: center; + gap: var(--bs-page-indicator-gap); +} + +.tc-page-indicator-dot { + position: relative; + display: block; + width: var(--bs-page-indicator-size); + height: var(--bs-page-indicator-size); + flex-shrink: 0; + padding: 0; + border-radius: 50%; + border: var(--bs-page-indicator-dot-border-width) solid var(--bs-page-indicator-color); + background: transparent; + cursor: pointer; + transition: background-color var(--tc-transition-fast), border-color var(--tc-transition-fast); + + // Expand the hit area to 44 px without growing the visual dot. + &::after { + content: ''; + position: absolute; + inset: -18px; + min-width: 44px; + min-height: 44px; + } + + &:hover { + background: color-mix(in srgb, var(--bs-page-indicator-color) 20%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + } +} + +.tc-page-indicator-dot--active { + background: var(--bs-page-indicator-active-color); + border-color: var(--bs-page-indicator-active-color); + + &:hover { + background: var(--bs-page-indicator-active-color); + border-color: var(--bs-page-indicator-active-color); + } +} + +@media (prefers-reduced-motion: reduce) { + .tc-page-indicator-dot { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 7b417a9c..0e5f757c 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -481,6 +481,7 @@ tc-key, tc-key-binder, tc-network-status-icon, tc-objective-marker, +tc-page-indicator, tc-rating, tc-social-links, tc-status-dot, From 59853e7f430e52b8f5d0020ad06dcbe6e3848eba Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 00:16:11 +0000 Subject: [PATCH 350/632] 344-panel: Build the tc-panel web component (Panel game-components port) --- examples/public/web-components/SKILL.md | 136 +++++++++++++++- examples/src/web-components/PanelDemo.tsx | 106 ++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Panel.ts | 169 ++++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 3 + web-components/style/components/_index.scss | 1 + web-components/style/components/_panel.scss | 88 ++++++++++ web-components/style/foundation/_reset.scss | 2 + 9 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 examples/src/web-components/PanelDemo.tsx create mode 100644 web-components/src/Panel.ts create mode 100644 web-components/style/components/_panel.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index e7a93dc7..3a8a1ce4 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -1,6 +1,6 @@ --- name: web-components -description: Use when building UI with @toolcase/web-components — framework-free HTML5 Web Components (`tc-*` custom elements) with from-scratch toolcase styling and a Bootstrap-compatible class API. Covers layout (BasicLayout, DashboardLayout, DashboardContent, DashboardSidebar, Login, Container, Row, Col, Spacer), content (ActionHeader, ActionItems, ActionRowList, Alert, AnnouncementBar, ApiReferenceTable, AssetRow, AssetRowList, Avatar, Badge, BadgeRow, Banner, Brand, Build, BriefCard, BundleBar, CdnMap, CalloutQuote, Changelog, ChartContainer, Sparkline, TrendIndicator, Leaderboard, LeaderboardTrend, CodeLabelCell, CodeSnippet, CodeWithOutput, CommunityLinks, ConfigPreview, ContributorWall, CookbookGrid, CoolButton, ActivityCard, BasicCard, Button, ButtonGroup, Card, Carousel, CloseButton, Collapse, Divider, Dropdown, DownloadStats, EcosystemMap, EmptyState, GameShowcaseCard, GithubStarsCard, GoodFirstIssues, Group, Hero, HeroStatsBar, Heading, Image, InfiniteScroll, Kbd, ListCard, ListGroup, LogoCloud, MaintainerCard, Marquee, MetricTile, MetricGrid, MigrationGuide, PageFooter, PhaseGrid, Pipeline, PinnedFeatureShowcase, PluginGrid, PricingCard, File, UserPanel, QueuedFile, Placeholder, Progress, PulseIndicator, ScoringRules, SectionCard, SectionFlag, Skeleton, Spinner, SprintChain, Stamp, MetricCard, StatCard, StateMachine, StatusCard, StatusDot, Stepper, Tag, TeamList, TierLadder, Timeline, UsageSummaryPanel, WelcomeGuide, CommandReference, Comparator, CompatibilityMatrix, CountdownTimer, FAQList, FeatureMatrix, Text, VisuallyHidden), navigation (Breadcrumb, CoolNav, Nav, Navbar, Pagination, Scrollspy, SocialLinks, Stepper), overlays & feedback (ContextMenu, DebugOverlay, Modal, Offcanvas, Popover, Toast, Tooltip), and forms (CardOptions, Check, CheckboxGroup, Chip, ChipGroup, ColorPicker, IconPicker, DatePicker, EarlySignupForm, EditableText, FloatingLabel, Form, HelperText, Input, InputGroup, InputGroupText, Label, MultiCardSelect, NewsletterSignup, Option, Radio, RadioGroup, Range, RangeSlider, DeadzoneSlider, Select, Switch, Textarea). Consumable from any stack — React, Vue, Svelte, or plain HTML. +description: Use when building UI with @toolcase/web-components — framework-free HTML5 Web Components (`tc-*` custom elements) with from-scratch toolcase styling and a Bootstrap-compatible class API. Covers layout (BasicLayout, DashboardLayout, DashboardContent, DashboardSidebar, Login, Container, Row, Col, Spacer), content (ActionHeader, ActionItems, ActionRowList, Alert, AnnouncementBar, ApiReferenceTable, AssetRow, AssetRowList, Avatar, Badge, BadgeRow, Banner, Brand, Build, BriefCard, BundleBar, CdnMap, CalloutQuote, Changelog, ChartContainer, Sparkline, TrendIndicator, Leaderboard, LeaderboardTrend, CodeLabelCell, CodeSnippet, CodeWithOutput, CommunityLinks, ConfigPreview, ContributorWall, CookbookGrid, CoolButton, ActivityCard, BasicCard, Button, ButtonGroup, Card, Carousel, CloseButton, Collapse, Divider, Dropdown, DownloadStats, EcosystemMap, EmptyState, GameShowcaseCard, GithubStarsCard, GoodFirstIssues, Group, Hero, HeroStatsBar, Heading, Image, InfiniteScroll, Kbd, ListCard, ListGroup, LogoCloud, MaintainerCard, Marquee, MetricTile, MetricGrid, MigrationGuide, PageFooter, Panel, PhaseGrid, Pipeline, PinnedFeatureShowcase, PluginGrid, PricingCard, File, UserPanel, QueuedFile, Placeholder, Progress, PulseIndicator, ScoringRules, SectionCard, SectionFlag, Skeleton, Spinner, SprintChain, Stamp, MetricCard, StatCard, StateMachine, StatusCard, StatusDot, Stepper, Tag, TeamList, TierLadder, Timeline, UsageSummaryPanel, WelcomeGuide, CommandReference, Comparator, CompatibilityMatrix, CountdownTimer, FAQList, FeatureMatrix, Text, VisuallyHidden), navigation (Breadcrumb, CoolNav, Nav, Navbar, Pagination, Scrollspy, SocialLinks, Stepper), overlays & feedback (ContextMenu, DebugOverlay, Modal, Offcanvas, Popover, Toast, Tooltip), and forms (CardOptions, Check, CheckboxGroup, Chip, ChipGroup, ColorPicker, IconPicker, DatePicker, EarlySignupForm, EditableText, FloatingLabel, Form, HelperText, Input, InputGroup, InputGroupText, Label, MultiCardSelect, NewsletterSignup, Option, Radio, RadioGroup, Range, RangeSlider, DeadzoneSlider, Select, Switch, Textarea). Consumable from any stack — React, Vue, Svelte, or plain HTML. --- # web-components — API Reference @@ -238,6 +238,8 @@ After `register()` you can author markup directly: - [tc-network-status-icon](#tc-network-status-icon) - [tc-objective-marker](#tc-objective-marker) - [tc-page-indicator](#tc-page-indicator) + - [tc-panel](#tc-panel) + - [tc-panel-header](#tc-panel-header) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) - [tc-live-feed](#tc-live-feed) @@ -22423,3 +22425,135 @@ None. `tc-page-indicator` is entirely attribute-driven; all content is generated pager.onSelect = index => console.log('Selected page', index); ``` + +--- + +### tc-panel + +A themed surface panel — a lightweight container with an optional 1px hairline border and optional header. Compose `tc-panel-header` as a first child to add a heading row. + +**Tag:** `tc-panel` + +#### Attributes + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `bordered` | `boolean` | `false` | Adds a 1px hairline border around the panel. | + +#### JS Properties + +| Property | Type | Default | Description | +|---|---|---|---| +| `bordered` | `boolean` | `false` | Reflects the `bordered` attribute. | + +#### Events + +None. `tc-panel` is a presentational container with no interactive events. + +#### Slots + +`tc-panel` distributes its children automatically: + +| Slot | Description | +|---|---| +| `tc-panel-header` children | Placed above the body in a dedicated header slot. | +| Everything else | Rendered inside `.tc-panel-body` with `1rem` padding. | + +#### CSS Custom Properties + +| Property | Default | Description | +|---|---|---| +| `--bs-panel-bg` | `var(--tc-surface)` | Panel background color. | +| `--bs-panel-border-color` | `var(--tc-border)` | Border color when `bordered` is set. | +| `--bs-panel-body-padding` | `1rem` | Padding around the body content area. | + +#### Usage + +```html + + +

    Body content here.

    +
    + + + +

    Content with a 1px hairline border.

    +
    + + + + +

    Body content below the header.

    +
    + + + + + + +
      +
    • Deploy completed
    • +
    • PR merged
    • +
    +
    +``` + +--- + +### tc-panel-header + +The header sub-element for `tc-panel`. Renders a heading row with an optional Lucide icon on the left and an optional action slot on the right. Gains a bottom hairline divider automatically when inside a `tc-panel--bordered` panel. + +**Tag:** `tc-panel-header` + +#### Attributes + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `heading` | `string` | `''` | Header text. | +| `icon` | `string` | — | Lucide icon name (PascalCase, e.g. `Settings`, `Activity`). | + +#### JS Properties + +| Property | Type | Default | Description | +|---|---|---|---| +| `heading` | `string` | `''` | Reflects the `heading` attribute. | +| `icon` | `string \| null` | `null` | Reflects the `icon` attribute. | + +#### Events + +None. `tc-panel-header` is a presentational element with no interactive events. + +#### Slots + +| Slot | Description | +|---|---| +| `action` | Trailing action area (e.g. buttons, icon-buttons). Rendered inside `.tc-panel-header-action` on the right. | + +#### CSS Custom Properties + +| Property | Default | Description | +|---|---|---| +| `--bs-panel-header-bg` | `var(--tc-surface)` | Header background color. | +| `--bs-panel-header-border-color` | `var(--tc-border)` | Color of the bottom hairline separator (shown in bordered panels). | +| `--bs-panel-header-color` | `var(--tc-text)` | Header text color. | +| `--bs-panel-header-padding-y` | `0.5rem` | Vertical padding. | +| `--bs-panel-header-padding-x` | `0.75rem` | Horizontal padding. | +| `--bs-panel-header-font-size` | `0.875rem` | Heading font size. | +| `--bs-panel-header-icon-size` | `1rem` | Icon width and height. | +| `--bs-panel-header-icon-color` | `var(--tc-text-muted)` | Icon color. | + +#### Usage + +```html + + + + + + + + + + +``` diff --git a/examples/src/web-components/PanelDemo.tsx b/examples/src/web-components/PanelDemo.tsx new file mode 100644 index 00000000..2631ecc5 --- /dev/null +++ b/examples/src/web-components/PanelDemo.tsx @@ -0,0 +1,106 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const PanelDemo: React.FC = () => { + return ( +
    +
    +
    +
    + Web Components} + title="Panel" + description="A themed surface panel with an optional header. Compose tc-panel-header as a child to add a heading row with an optional icon and action slot." + /> + +
    + + {/* @ts-ignore */} + +

    Panel body content — no border by default.

    +
    +
    + + + {/* @ts-ignore */} + +

    Panel body with a 1px hairline border.

    +
    +
    + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +

    Body content below the header.

    +
    +
    + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +

    Configure your preferences below.

    +

    Changes apply immediately.

    +
    +
    +
    + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + +
      +
    • Deploy completed
    • +
    • PR #42 merged
    • +
    • Build passed
    • +
    +
    +
    + + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +

    Panels without a border can still use a header for structure.

    +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +

    First panel content.

    +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +

    Second panel content.

    +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +

    Third panel content.

    +
    +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default PanelDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 38bcb698..3926770e 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -303,6 +303,7 @@ import MuteListDemo from './MuteListDemo' import NetworkStatusIconDemo from './NetworkStatusIconDemo' import ObjectiveMarkerDemo from './ObjectiveMarkerDemo' import PageIndicatorDemo from './PageIndicatorDemo' +import PanelDemo from './PanelDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -626,4 +627,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'network-status-icon', category: 'Components', element: }, { key: 'objective-marker', category: 'Components', element: }, { key: 'page-indicator', category: 'Navigation', element: }, + { key: 'panel', category: 'Components', element: }, ] diff --git a/web-components/src/Panel.ts b/web-components/src/Panel.ts new file mode 100644 index 00000000..dec94998 --- /dev/null +++ b/web-components/src/Panel.ts @@ -0,0 +1,169 @@ +import * as LucideIcons from 'lucide-static' +import { icon } from './icons' + +const PANEL_TAG = 'tc-panel' +const HEADER_TAG = 'tc-panel-header' + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +// ── tc-panel ────────────────────────────────────────────────────────────────── + +export class Panel extends HTMLElement { + private _initialised = false + private _headerNodes: Node[] = [] + + static get observedAttributes(): string[] { + return ['bordered'] + } + + connectedCallback(): void { + if (!this._initialised) { + // Separate tc-panel-header children from body children before render + this._headerNodes = Array.from(this.childNodes).filter( + n => n instanceof Element && n.tagName.toLowerCase() === HEADER_TAG + ) + const bodyNodes = Array.from(this.childNodes).filter( + n => !(n instanceof Element && n.tagName.toLowerCase() === HEADER_TAG) + ) + + this.render() + + const headerSlot = this.querySelector('.tc-panel-header-slot') + if (headerSlot) this._headerNodes.forEach(n => headerSlot.appendChild(n)) + const body = this.querySelector('.tc-panel-body') + if (body) bodyNodes.forEach(n => body.appendChild(n)) + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + + const headerSlot = this.querySelector('.tc-panel-header-slot') + if (headerSlot) { + const inSlot = Array.from(headerSlot.childNodes).filter( + n => n instanceof Element && n.tagName.toLowerCase() === HEADER_TAG + ) + if (inSlot.length > 0) this._headerNodes = inSlot + } + + const body = this.querySelector('.tc-panel-body') + const bodyNodes = body ? Array.from(body.childNodes) : [] + + this.render() + + const newHeaderSlot = this.querySelector('.tc-panel-header-slot') + if (newHeaderSlot) this._headerNodes.forEach(n => newHeaderSlot.appendChild(n)) + const newBody = this.querySelector('.tc-panel-body') + if (newBody) bodyNodes.forEach(n => newBody.appendChild(n)) + } + + get bordered(): boolean { + return this.hasAttribute('bordered') + } + set bordered(value: boolean) { + if (value) this.setAttribute('bordered', '') + else this.removeAttribute('bordered') + } + + private render(): void { + const bordered = this.bordered + const hasHeader = this._headerNodes.length > 0 + const cls = ['tc-panel', bordered ? 'tc-panel--bordered' : ''].filter(Boolean).join(' ') + + this.innerHTML = `
    ` + + (hasHeader ? `
    ` : '') + + `
    ` + + `
    ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [PANEL_TAG]: Panel + } +} + +// ── tc-panel-header ─────────────────────────────────────────────────────────── + +export class PanelHeader extends HTMLElement { + private _initialised = false + private _actionSlotNodes: Node[] = [] + + static get observedAttributes(): string[] { + return ['heading', 'icon'] + } + + connectedCallback(): void { + if (!this._initialised) { + this._actionSlotNodes = Array.from(this.querySelectorAll('[slot="action"]')) + this.render() + const actionEl = this.querySelector('.tc-panel-header-action') + if (actionEl) this._actionSlotNodes.forEach(n => actionEl.appendChild(n)) + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + + const actionEl = this.querySelector('.tc-panel-header-action') + if (actionEl) { + const inAction = Array.from(actionEl.querySelectorAll('[slot="action"]')) + if (inAction.length > 0) this._actionSlotNodes = inAction + } + + this.render() + + const newActionEl = this.querySelector('.tc-panel-header-action') + if (newActionEl) this._actionSlotNodes.forEach(n => newActionEl.appendChild(n)) + } + + // NOTE: 'heading' is used instead of 'title' to avoid colliding with + // the native HTMLElement.title reflected attribute (ARIAMixin gotcha). + get heading(): string { + return this.getAttribute('heading') ?? '' + } + set heading(v: string) { + if (v) this.setAttribute('heading', v) + else this.removeAttribute('heading') + } + + get icon(): string | null { + return this.getAttribute('icon') + } + set icon(v: string | null) { + if (v != null) this.setAttribute('icon', v) + else this.removeAttribute('icon') + } + + private render(): void { + const heading = this.getAttribute('heading') ?? '' + const iconName = this.getAttribute('icon') + const hasAction = this._actionSlotNodes.length > 0 + + const svgStr = iconName ? (LucideIcons as Record)[iconName] : null + const iconHtml = svgStr + ? `` + : '' + const actionHtml = hasAction ? `
    ` : '' + + this.innerHTML = `
    ` + + iconHtml + + `${esc(heading)}` + + actionHtml + + `
    ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [HEADER_TAG]: PanelHeader + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 8fb416c8..1d595231 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -314,3 +314,4 @@ export * from './Minimap' export * from './MuteList' export * from './ObjectiveMarker' export * from './PageIndicator' +export * from './Panel' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index b87ad0a9..4bad3f06 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -311,6 +311,7 @@ import { Minimap } from './Minimap' import { MuteList } from './MuteList' import { ObjectiveMarker } from './ObjectiveMarker' import { PageIndicator } from './PageIndicator' +import { Panel, PanelHeader } from './Panel' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -631,4 +632,6 @@ export function register(): void { customElements.define('tc-mute-list', MuteList) customElements.define('tc-objective-marker', ObjectiveMarker) customElements.define('tc-page-indicator', PageIndicator) + customElements.define('tc-panel', Panel) + customElements.define('tc-panel-header', PanelHeader) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index d524d95e..af23808e 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -307,3 +307,4 @@ @forward 'mute-list'; @forward 'objective-marker'; @forward 'page-indicator'; +@forward 'panel'; diff --git a/web-components/style/components/_panel.scss b/web-components/style/components/_panel.scss new file mode 100644 index 00000000..52a6f8ea --- /dev/null +++ b/web-components/style/components/_panel.scss @@ -0,0 +1,88 @@ +// tc-panel & tc-panel-header — themed surface panel with an optional header. +// Sharp corners throughout; all cosmetics via --bs-panel-* / --tc-* tokens. + +@use '../foundation/tokens' as *; + +// ── Custom-property defaults ─────────────────────────────────────────────────── + +tc-panel { + --bs-panel-bg: var(--tc-surface); + --bs-panel-border-color: var(--tc-border); + --bs-panel-body-padding: 1rem; +} + +tc-panel-header { + --bs-panel-header-bg: var(--tc-surface); + --bs-panel-header-border-color: var(--tc-border); + --bs-panel-header-color: var(--tc-text); + --bs-panel-header-padding-y: 0.5rem; + --bs-panel-header-padding-x: 0.75rem; + --bs-panel-header-font-size: 0.875rem; + --bs-panel-header-icon-size: 1rem; + --bs-panel-header-icon-color: var(--tc-text-muted); +} + +// ── Panel container ──────────────────────────────────────────────────────────── + +.tc-panel { + background: var(--bs-panel-bg); + border-radius: 0; +} + +.tc-panel--bordered { + border: 1px solid var(--bs-panel-border-color); +} + +.tc-panel-header-slot { + display: block; +} + +.tc-panel-body { + padding: var(--bs-panel-body-padding); +} + +// ── Panel header element ─────────────────────────────────────────────────────── + +.tc-panel-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: var(--bs-panel-header-padding-y) var(--bs-panel-header-padding-x); + background: var(--bs-panel-header-bg); + color: var(--bs-panel-header-color); + font-size: var(--bs-panel-header-font-size); + font-weight: 500; +} + +// Bottom hairline separator between header and body when the panel is bordered +.tc-panel--bordered .tc-panel-header { + border-bottom: 1px solid var(--bs-panel-header-border-color); +} + +.tc-panel-header-icon { + display: inline-flex; + align-items: center; + flex-shrink: 0; + color: var(--bs-panel-header-icon-color); + + svg { + width: var(--bs-panel-header-icon-size); + height: var(--bs-panel-header-icon-size); + display: block; + } +} + +.tc-panel-header-heading { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.tc-panel-header-action { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 0.25rem; +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 0e5f757c..6fd441a0 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -336,6 +336,8 @@ tc-level-header, tc-level-select, tc-lobby, tc-main-menu, +tc-panel, +tc-panel-header, tc-menu-item, tc-loot-list, tc-lore-text, From 74d13c130e979648ef92e18f3390193490d37e50 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 00:26:23 +0000 Subject: [PATCH 351/632] 346-particle-emitter: Build the tc-particle-emitter web component (ParticleEmitter game-components port) --- examples/public/web-components/SKILL.md | 94 +++++++ .../web-components/ParticleEmitterDemo.tsx | 107 ++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/ParticleEmitter.ts | 231 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_particle-emitter.scss | 21 ++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 460 insertions(+) create mode 100644 examples/src/web-components/ParticleEmitterDemo.tsx create mode 100644 web-components/src/ParticleEmitter.ts create mode 100644 web-components/style/components/_particle-emitter.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 3a8a1ce4..4d3ad694 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -240,6 +240,7 @@ After `register()` you can author markup directly: - [tc-page-indicator](#tc-page-indicator) - [tc-panel](#tc-panel) - [tc-panel-header](#tc-panel-header) + - [tc-particle-emitter](#tc-particle-emitter) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) - [tc-live-feed](#tc-live-feed) @@ -22557,3 +22558,96 @@ None. `tc-panel-header` is a presentational element with no interactive events. ``` + +--- + +### tc-particle-emitter + +Canvas-based DOM particle-burst emitter. Each time the `burst` attribute changes to a new value (or the imperative `burst()` method is called), a wave of square particles radiates from the centre of the canvas and fades out under configurable physics. Fires a `tc-burst` CustomEvent on every burst. + +**Tag:** `tc-particle-emitter` + +#### Attributes + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `burst` | `string` | — | Toggle attribute — set to any new value to trigger a burst. Duplicate values are ignored. | +| `count` | `number` | `24` | Number of particles per burst. | +| `particle-size` | `number` | `4` | Base particle side length in pixels (randomised ±30 % per particle). | +| `speed` | `number` | `200` | Base particle launch speed in pixels per second (randomised ±30 % per particle). | +| `lifetime` | `number` | `700` | Base particle lifetime in milliseconds (randomised ±20 % per particle). | +| `gravity` | `number` | `600` | Downward acceleration in pixels per second². | +| `width` | `number` | `240` | Canvas width in pixels. Changing this attribute recreates the canvas. | +| `height` | `number` | `160` | Canvas height in pixels. Changing this attribute recreates the canvas. | + +#### JS Properties + +| Property | Type | Default | Description | +|---|---|---|---| +| `colors` | `string[]` | `['#0f172a','#475569','#22d3ee','#cbd5e1']` | Array of CSS colour strings used randomly per particle. Set to override the slate/cyan palette defaults. | +| `count` | `number` | `24` | Mirrors the `count` attribute. | +| `particleSize` | `number` | `4` | Mirrors the `particle-size` attribute. | +| `speed` | `number` | `200` | Mirrors the `speed` attribute. | +| `lifetime` | `number` | `700` | Mirrors the `lifetime` attribute. | +| `gravity` | `number` | `600` | Mirrors the `gravity` attribute. | +| `width` | `number` | `240` | Mirrors the `width` attribute. | +| `height` | `number` | `160` | Mirrors the `height` attribute. | +| `onBurst` | `((count: number) => void) \| null` | `null` | Optional callback fired alongside `tc-burst`. | + +#### Methods + +| Method | Description | +|---|---| +| `burst(value?: string)` | Imperatively trigger a burst. Internally sets the `burst` attribute to `value` (defaults to `Date.now()`). | + +#### Events + +| Event | Detail | Description | +|---|---|---| +| `tc-burst` | `{ count: number }` | Fired when a burst begins. `count` is the number of particles spawned. Bubbles. | + +#### Slots + +None. `tc-particle-emitter` is canvas-based; all visual output is drawn programmatically. + +#### CSS Custom Properties + +| Property | Default | Description | +|---|---|---| +| `--bs-particle-emitter-bg` | `transparent` | Canvas background fill. | +| `--bs-particle-emitter-border-color` | `var(--tc-border)` | 1px hairline border colour around the canvas. | +| `--bs-particle-emitter-border-width` | `1px` | Border width. Set to `0` to remove the border. | +| `--bs-particle-emitter-color-1` | `#0f172a` | Documentation anchor for the first default particle colour (slate-900). | +| `--bs-particle-emitter-color-2` | `#475569` | Documentation anchor for the second default particle colour (slate-600). | +| `--bs-particle-emitter-color-3` | `#22d3ee` | Documentation anchor for the third default particle colour (cyan accent). | +| `--bs-particle-emitter-color-4` | `#cbd5e1` | Documentation anchor for the fourth default particle colour (slate-300). | + +> **Note:** Particle colours are drawn onto a `` via the 2D API and cannot read CSS custom properties at draw time. Use the `colors` JS property to set colours programmatically. + +> **Reduced motion:** When `prefers-reduced-motion: reduce` is active the RAF animation loop is skipped; the `tc-burst` event still fires but no visual animation plays. + +#### Usage + +```html + + + + + + + + + +``` + +```js +// Custom palette +const pe = document.querySelector('tc-particle-emitter') +pe.colors = ['#22d3ee', '#06b6d4', '#0e7490', '#cffafe'] +pe.burst() + +// Listen for the burst event +pe.addEventListener('tc-burst', e => { + console.log(`Burst: ${e.detail.count} particles`) +}) +``` diff --git a/examples/src/web-components/ParticleEmitterDemo.tsx b/examples/src/web-components/ParticleEmitterDemo.tsx new file mode 100644 index 00000000..35b3b273 --- /dev/null +++ b/examples/src/web-components/ParticleEmitterDemo.tsx @@ -0,0 +1,107 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const ParticleEmitterDemo: React.FC = () => { + const defaultRef = useRef(null) + const cyanRef = useRef(null) + const fastRef = useRef(null) + const heavyRef = useRef(null) + const [burstCount, setBurstCount] = useState(0) + + useEffect(() => { + if (!defaultRef.current) return + const el = defaultRef.current + const handler = () => setBurstCount(n => n + 1) + el.addEventListener('tc-burst', handler) + return () => el.removeEventListener('tc-burst', handler) + }, []) + + useEffect(() => { + if (cyanRef.current) { + cyanRef.current.colors = ['#22d3ee', '#06b6d4', '#0e7490', '#cffafe'] + } + }, []) + + const handleBurst = useCallback((ref: React.MutableRefObject) => { + if (ref.current) ref.current.burst() + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="ParticleEmitter" + description="Canvas particle-burst emitter. Call burst() or toggle the burst attribute to spawn a wave of particles from the centre. Particle physics (count, speed, gravity, lifetime, size) are fully configurable via attributes; colours are set via the colors JS property." + /> + +
    + +
    + {/* @ts-ignore */} + +
    + + {burstCount > 0 && ( + + tc-burst fired ×{burstCount} + + )} +
    +
    +
    + + +
    + {/* @ts-ignore */} + + +
    +
    + + +
    + {/* @ts-ignore */} + + +
    +
    + + +
    + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default ParticleEmitterDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 3926770e..c22ef2aa 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -304,6 +304,7 @@ import NetworkStatusIconDemo from './NetworkStatusIconDemo' import ObjectiveMarkerDemo from './ObjectiveMarkerDemo' import PageIndicatorDemo from './PageIndicatorDemo' import PanelDemo from './PanelDemo' +import ParticleEmitterDemo from './ParticleEmitterDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -628,4 +629,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'objective-marker', category: 'Components', element: }, { key: 'page-indicator', category: 'Navigation', element: }, { key: 'panel', category: 'Components', element: }, + { key: 'particle-emitter', category: 'Components', element: }, ] diff --git a/web-components/src/ParticleEmitter.ts b/web-components/src/ParticleEmitter.ts new file mode 100644 index 00000000..87d9417e --- /dev/null +++ b/web-components/src/ParticleEmitter.ts @@ -0,0 +1,231 @@ +const TAG_NAME = 'tc-particle-emitter' + +interface Particle { + x: number + y: number + vx: number + vy: number + life: number + maxLife: number + color: string + size: number +} + +// TC palette defaults: slate-900, slate-600, cyan accent, slate-300 +const DEFAULT_COLORS = ['#0f172a', '#475569', '#22d3ee', '#cbd5e1'] + +export class ParticleEmitter extends HTMLElement { + private _canvas: HTMLCanvasElement | null = null + private _ctx: CanvasRenderingContext2D | null = null + private _particles: Particle[] = [] + private _rafHandle: number | null = null + private _lastBurst: string | null = null + private _colors: string[] = DEFAULT_COLORS.slice() + + onBurst: ((count: number) => void) | null = null + + static get observedAttributes(): string[] { + return ['burst', 'count', 'particle-size', 'speed', 'lifetime', 'gravity', 'width', 'height'] + } + + connectedCallback(): void { + this.render() + this._lastBurst = this.getAttribute('burst') + } + + disconnectedCallback(): void { + this.stop() + } + + attributeChangedCallback(name: string, _old: string | null, next: string | null): void { + if (!this.isConnected) return + if (name === 'burst' && next !== this._lastBurst) { + this._lastBurst = next + this.spawnBurst() + return + } + if (name === 'width' || name === 'height') this.render() + } + + get count(): number { + const raw = this.getAttribute('count') + if (raw == null) return 24 + const parsed = parseInt(raw, 10) + return Number.isNaN(parsed) || parsed <= 0 ? 24 : parsed + } + set count(v: number) { + this.setAttribute('count', String(v)) + } + + get colors(): string[] { + return this._colors.slice() + } + set colors(values: string[]) { + if (Array.isArray(values) && values.length > 0) this._colors = values.slice() + } + + get particleSize(): number { + const raw = this.getAttribute('particle-size') + if (raw == null) return 4 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) || parsed <= 0 ? 4 : parsed + } + set particleSize(v: number) { + this.setAttribute('particle-size', String(v)) + } + + get speed(): number { + const raw = this.getAttribute('speed') + if (raw == null) return 200 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) ? 200 : parsed + } + set speed(v: number) { + this.setAttribute('speed', String(v)) + } + + get lifetime(): number { + const raw = this.getAttribute('lifetime') + if (raw == null) return 700 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) || parsed <= 0 ? 700 : parsed + } + set lifetime(v: number) { + this.setAttribute('lifetime', String(v)) + } + + get gravity(): number { + const raw = this.getAttribute('gravity') + if (raw == null) return 600 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) ? 600 : parsed + } + set gravity(v: number) { + this.setAttribute('gravity', String(v)) + } + + get width(): number { + const raw = this.getAttribute('width') + if (raw == null) return 240 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) || parsed <= 0 ? 240 : parsed + } + set width(v: number) { + this.setAttribute('width', String(v)) + } + + get height(): number { + const raw = this.getAttribute('height') + if (raw == null) return 160 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) || parsed <= 0 ? 160 : parsed + } + set height(v: number) { + this.setAttribute('height', String(v)) + } + + burst(value?: string): void { + const v = value ?? String(Date.now()) + this.setAttribute('burst', v) + } + + private render(): void { + const w = this.width + const h = this.height + this.innerHTML = `` + this._canvas = this.querySelector('canvas') + this._ctx = this._canvas?.getContext('2d') ?? null + } + + private spawnBurst(): void { + if (!this._canvas) this.render() + const w = this.width + const h = this.height + const cx = w / 2 + const cy = h / 2 + const count = this.count + const speed = this.speed + const lifetime = this.lifetime + const colors = this._colors + for (let i = 0; i < count; i++) { + const angle = (i / count) * Math.PI * 2 + Math.random() * 0.3 + const v = speed * (0.7 + Math.random() * 0.6) + this._particles.push({ + x: cx, + y: cy, + vx: Math.cos(angle) * v, + vy: Math.sin(angle) * v, + life: 0, + maxLife: lifetime * (0.8 + Math.random() * 0.4), + color: colors[Math.floor(Math.random() * colors.length)], + size: this.particleSize * (0.7 + Math.random() * 0.6), + }) + } + this.dispatchEvent(new CustomEvent('tc-burst', { + bubbles: true, + composed: true, + detail: { count }, + })) + if (typeof this.onBurst === 'function') this.onBurst(count) + // Skip the animation loop when the user has requested reduced motion + if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + this.startLoop() + } + } + + private startLoop(): void { + if (this._rafHandle != null) return + let lastT = performance.now() + const loop = (now: number) => { + const dt = Math.min(0.05, (now - lastT) / 1000) + lastT = now + this.step(dt) + if (this._particles.length === 0) { + this.stop() + return + } + this._rafHandle = requestAnimationFrame(loop) + } + this._rafHandle = requestAnimationFrame(loop) + } + + private stop(): void { + if (this._rafHandle != null) { + cancelAnimationFrame(this._rafHandle) + this._rafHandle = null + } + if (this._ctx && this._canvas) { + this._ctx.clearRect(0, 0, this.width, this.height) + } + this._particles = [] + } + + private step(dt: number): void { + if (!this._ctx || !this._canvas) return + const ctx = this._ctx + const gravity = this.gravity + ctx.clearRect(0, 0, this.width, this.height) + const next: Particle[] = [] + for (const p of this._particles) { + p.life += dt * 1000 + p.vy += gravity * dt + p.x += p.vx * dt + p.y += p.vy * dt + const t = p.life / p.maxLife + if (t >= 1) continue + const alpha = 1 - t + ctx.fillStyle = p.color + ctx.globalAlpha = alpha + ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size) + next.push(p) + } + ctx.globalAlpha = 1 + this._particles = next + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: ParticleEmitter + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 1d595231..623666a7 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -315,3 +315,4 @@ export * from './MuteList' export * from './ObjectiveMarker' export * from './PageIndicator' export * from './Panel' +export * from './ParticleEmitter' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 4bad3f06..61211004 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -311,6 +311,7 @@ import { Minimap } from './Minimap' import { MuteList } from './MuteList' import { ObjectiveMarker } from './ObjectiveMarker' import { PageIndicator } from './PageIndicator' +import { ParticleEmitter } from './ParticleEmitter' import { Panel, PanelHeader } from './Panel' export function register(): void { @@ -632,6 +633,7 @@ export function register(): void { customElements.define('tc-mute-list', MuteList) customElements.define('tc-objective-marker', ObjectiveMarker) customElements.define('tc-page-indicator', PageIndicator) + customElements.define('tc-particle-emitter', ParticleEmitter) customElements.define('tc-panel', Panel) customElements.define('tc-panel-header', PanelHeader) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index af23808e..87a0c29d 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -308,3 +308,4 @@ @forward 'objective-marker'; @forward 'page-indicator'; @forward 'panel'; +@forward 'particle-emitter'; diff --git a/web-components/style/components/_particle-emitter.scss b/web-components/style/components/_particle-emitter.scss new file mode 100644 index 00000000..74b7fc1b --- /dev/null +++ b/web-components/style/components/_particle-emitter.scss @@ -0,0 +1,21 @@ +@use '../foundation/tokens' as *; + +tc-particle-emitter { + --bs-particle-emitter-bg: transparent; + --bs-particle-emitter-border-color: var(--tc-border); + --bs-particle-emitter-border-width: 1px; + // Expose particle color slots so themes can override the JS-property defaults + // without imperative code. These are documentation anchors; actual color values + // are set via the `colors` JS property. + --bs-particle-emitter-color-1: #0f172a; // slate-900 + --bs-particle-emitter-color-2: #475569; // slate-600 + --bs-particle-emitter-color-3: #22d3ee; // cyan accent + --bs-particle-emitter-color-4: #cbd5e1; // slate-300 +} + +.tc-particle-emitter__canvas { + display: block; + background: var(--bs-particle-emitter-bg); + border: var(--bs-particle-emitter-border-width) solid var(--bs-particle-emitter-border-color); + border-radius: 0; +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 6fd441a0..ef081af3 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -343,6 +343,7 @@ tc-loot-list, tc-lore-text, tc-minimap, tc-mute-list, +tc-particle-emitter, tc-roadmap { display: block; } From 04d57d0398a5e2d5d18ea29f33c4e7b94f244da9 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 00:32:17 +0000 Subject: [PATCH 352/632] 347-party-panel: Build the tc-party-panel web component (PartyPanel game-components port) --- examples/public/web-components/SKILL.md | 95 ++++++ .../src/web-components/PartyPanelDemo.tsx | 113 +++++++ examples/src/web-components/index.tsx | 2 + web-components/src/PartyPanel.ts | 147 ++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_party-panel.scss | 277 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 639 insertions(+) create mode 100644 examples/src/web-components/PartyPanelDemo.tsx create mode 100644 web-components/src/PartyPanel.ts create mode 100644 web-components/style/components/_party-panel.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 4d3ad694..13861221 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -227,6 +227,7 @@ After `register()` you can author markup directly: - [tc-list-row](#tc-list-row) - [tc-lobby](#tc-lobby) - [tc-loot-list](#tc-loot-list) + - [tc-party-panel](#tc-party-panel) - [tc-loot-popup](#tc-loot-popup) - [tc-lore-text](#tc-lore-text) - [tc-main-menu](#tc-main-menu) @@ -21604,6 +21605,100 @@ Modal loot window with Take All / Discard and optional auto-fade timer. Port of --- +### tc-party-panel + +Party member panel with portraits, health, and status. Port of `gc-party-panel` (game-components), restyled to the toolcase design system: slate neutrals, hairline borders, sharp corners, JetBrains Mono for machine-facing text, ink accent for host badge. Members are set via the `members` JS property. Empty slots render as Invite buttons that fire `tc-invite`; the Leave Party button fires `tc-leave`. All cosmetics flow through `--bs-party-panel-*` custom properties. + +**Tag:** `tc-party-panel` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `capacity` | number | `4` | Maximum number of member slots to render. | + +**JS Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `capacity` | `number` | `4` | Reflects the `capacity` attribute. | +| `members` | `PartyMember[]` | `[]` | Array of party member objects. Setting this re-renders the slot grid. Accepts at most `capacity` items; extras are silently ignored. | +| `onLeave` | `(() => void) \| null` | `null` | Optional callback fired alongside `tc-leave`. | +| `onInvite` | `(() => void) \| null` | `null` | Optional callback fired alongside `tc-invite`. | + +**`PartyMember` shape** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | `string` | yes | Unique member identifier. Stamped as `data-id` on the slot element. | +| `name` | `string` | yes | Display name rendered in the slot. | +| `ready` | `boolean` | no | When `true` the slot gains `.tc-party-panel-slot--ready` and shows "Ready"; otherwise shows "Waiting". | +| `host` | `boolean` | no | When `true` a "Host" badge is shown in the slot. | +| `role` | `string` | no | Optional role chip rendered in JetBrains Mono (e.g. `"Tank"`, `"Healer"`, `"DPS"`). | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-leave` | `{}` | Fired when the Leave Party button is clicked. Bubbles and is composed. | +| `tc-invite` | `{}` | Fired when an empty (Invite) slot button is clicked. Bubbles and is composed. | + +**Slots** + +`tc-party-panel` has no named slots. All content is driven by attributes and JS properties. + +**CSS custom properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-party-panel-bg` | `var(--tc-surface)` | Panel background. | +| `--bs-party-panel-border` | `var(--tc-border)` | Outer 1px hairline border. | +| `--bs-party-panel-header-bg` | `var(--tc-surface-muted)` | Header section background. | +| `--bs-party-panel-header-border` | `var(--tc-border)` | Header bottom border. | +| `--bs-party-panel-eyebrow-color` | `var(--tc-text-muted)` | "Party" eyebrow label colour. | +| `--bs-party-panel-count-color` | `var(--tc-text-faint)` | Member count colour (e.g. "2/4"). | +| `--bs-party-panel-slot-bg` | `var(--tc-surface)` | Default slot background. | +| `--bs-party-panel-slot-border` | `var(--tc-border)` | Slot row hairline borders. | +| `--bs-party-panel-slot-empty-bg` | `var(--tc-surface)` | Empty (Invite) slot background. | +| `--bs-party-panel-slot-empty-color` | `var(--tc-text-faint)` | Invite glyph and label colour. | +| `--bs-party-panel-slot-empty-hover-bg` | `var(--tc-surface-hover, var(--tc-surface-muted))` | Invite slot hover background. | +| `--bs-party-panel-slot-filled-bg` | `var(--tc-surface)` | Background for filled (occupied) slots. | +| `--bs-party-panel-slot-ready-bg` | `var(--tc-surface-muted)` | Background tint for ready slots. | +| `--bs-party-panel-member-name-color` | `var(--tc-text)` | Member name text colour. | +| `--bs-party-panel-role-bg` | `var(--tc-surface-muted)` | Role chip background. | +| `--bs-party-panel-role-color` | `var(--tc-text-muted)` | Role chip text colour. | +| `--bs-party-panel-host-badge-color` | `var(--tc-app-accent)` | Host badge text colour. | +| `--bs-party-panel-ready-color` | `var(--tc-success)` | "Ready" badge colour. | +| `--bs-party-panel-waiting-color` | `var(--tc-text-faint)` | "Waiting" badge colour. | +| `--bs-party-panel-actions-bg` | `var(--tc-surface-muted)` | Actions bar background. | +| `--bs-party-panel-btn-bg` | `var(--tc-surface)` | Default button background. | +| `--bs-party-panel-btn-color` | `var(--tc-text)` | Default button text colour. | +| `--bs-party-panel-btn-border` | `var(--tc-border)` | Default button border colour. | +| `--bs-party-panel-btn-hover-bg` | `var(--tc-surface-hover, var(--tc-surface-muted))` | Button hover background. | +| `--bs-party-panel-btn-min-height` | `2.25rem` | Button minimum height (44 px under coarse pointer). | +| `--bs-party-panel-btn-disabled-opacity` | `0.45` | Opacity for disabled buttons. | + +**Example** + +```html + + + +``` + +--- + ### tc-lore-text Flavor / lore body-copy block. Slot-based — used for tooltips, loading screens, codex entries, or any italic narrative aside. Drops game-components fantasy chrome (gilded frames, glows, fantasy fills) in favour of the web-components design system: sharp corners, 1px hairline left border, Inter italic prose. diff --git a/examples/src/web-components/PartyPanelDemo.tsx b/examples/src/web-components/PartyPanelDemo.tsx new file mode 100644 index 00000000..98e3a653 --- /dev/null +++ b/examples/src/web-components/PartyPanelDemo.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const PartyPanelDemo: React.FC = () => { + const basicRef = useRef(null) + const rolesRef = useRef(null) + const fullRef = useRef(null) + const eventsRef = useRef(null) + + const [log, setLog] = useState([]) + + useEffect(() => { + if (!basicRef.current) return + basicRef.current.members = [ + { id: '1', name: 'Aldric', host: true, ready: true }, + { id: '2', name: 'Brina', ready: true }, + { id: '3', name: 'Caelum', ready: false }, + ] + }, []) + + useEffect(() => { + if (!rolesRef.current) return + rolesRef.current.members = [ + { id: '1', name: 'Aldric', host: true, ready: true, role: 'Tank' }, + { id: '2', name: 'Brina', ready: true, role: 'Healer' }, + { id: '3', name: 'Caelum', ready: false, role: 'DPS' }, + ] + }, []) + + useEffect(() => { + if (!fullRef.current) return + fullRef.current.members = [ + { id: '1', name: 'Aldric', host: true, ready: true, role: 'Tank' }, + { id: '2', name: 'Brina', ready: true, role: 'Healer' }, + { id: '3', name: 'Caelum', ready: true, role: 'DPS' }, + { id: '4', name: 'Devra', ready: true, role: 'Support' }, + ] + }, []) + + useEffect(() => { + const el = eventsRef.current + if (!el) return + el.members = [ + { id: '1', name: 'Aldric', host: true, ready: true }, + { id: '2', name: 'Brina', ready: false }, + ] + const onLeave = () => setLog(l => ['tc-leave fired', ...l].slice(0, 6)) + const onInvite = () => setLog(l => ['tc-invite fired', ...l].slice(0, 6)) + el.addEventListener('tc-leave', onLeave) + el.addEventListener('tc-invite', onInvite) + return () => { + el.removeEventListener('tc-leave', onLeave) + el.removeEventListener('tc-invite', onInvite) + } + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="PartyPanel" + description="Party member panel with portraits, health, and status. Members are set via the JS members property. Empty slots render as Invite buttons that fire tc-invite; the Leave Party button fires tc-leave." + /> + +
    + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + +
    + Event log + {log.length === 0 ? ( + Click Leave Party or an Invite slot… + ) : ( +
      + {log.map((line, i) => ( +
    • {line}
    • + ))} +
    + )} +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default PartyPanelDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index c22ef2aa..8973ecb9 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -234,6 +234,7 @@ import KillFeedDemo from './KillFeedDemo' import ListDemo from './ListDemo' import ListRowDemo from './ListRowDemo' import LobbyDemo from './LobbyDemo' +import PartyPanelDemo from './PartyPanelDemo' import MatchmakingScreenDemo from './MatchmakingScreenDemo' import MainMenuDemo from './MainMenuDemo' import MenuItemDemo from './MenuItemDemo' @@ -563,6 +564,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'list', category: 'Components', element: }, { key: 'list-row', category: 'Components', element: }, { key: 'lobby', category: 'Components', element: }, + { key: 'party-panel', category: 'Components', element: }, { key: 'matchmaking-screen', category: 'Components', element: }, { key: 'main-menu', category: 'Components', element: }, { key: 'menu-item', category: 'Components', element: }, diff --git a/web-components/src/PartyPanel.ts b/web-components/src/PartyPanel.ts new file mode 100644 index 00000000..02bbb1a2 --- /dev/null +++ b/web-components/src/PartyPanel.ts @@ -0,0 +1,147 @@ +const TAG_NAME = 'tc-party-panel' + +export interface PartyMember { + id: string + name: string + ready?: boolean + host?: boolean + role?: string +} + +export interface PartyPanelEventMap { + 'tc-leave': CustomEvent> + 'tc-invite': CustomEvent> +} + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +export class PartyPanel extends HTMLElement { + + private _initialised = false + private _members: PartyMember[] = [] + + onLeave: (() => void) | null = null + onInvite: (() => void) | null = null + + static get observedAttributes(): string[] { + return ['capacity'] + } + + connectedCallback(): void { + if (!this._initialised) { + if (!this.hasAttribute('role')) this.setAttribute('role', 'group') + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get capacity(): number { + const raw = this.getAttribute('capacity') + if (raw == null) return 4 + const parsed = parseInt(raw, 10) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 4 + } + set capacity(v: number) { + this.setAttribute('capacity', String(v)) + } + + get members(): PartyMember[] { + return this._members.slice() + } + set members(value: PartyMember[]) { + this._members = Array.isArray(value) ? value.slice() : [] + if (this._initialised) this.render() + } + + private _emit(name: 'tc-leave' | 'tc-invite'): void { + this.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail: {} })) + if (name === 'tc-leave' && typeof this.onLeave === 'function') this.onLeave() + else if (name === 'tc-invite' && typeof this.onInvite === 'function') this.onInvite() + } + + private _renderSlot(i: number, members: PartyMember[]): string { + const m = members[i] + if (!m) { + return `` + } + + const cls = [ + 'tc-party-panel-slot', + 'tc-party-panel-slot--filled', + m.ready ? 'tc-party-panel-slot--ready' : '', + ].filter(Boolean).join(' ') + + const hostHtml = m.host + ? `Host` + : '' + const roleHtml = m.role + ? `${esc(m.role)}` + : '' + const readyHtml = m.ready + ? `Ready` + : `Waiting` + + return `
    + ${esc(m.name)} + ${roleHtml}${hostHtml} + ${readyHtml} +
    ` + } + + private render(): void { + const capacity = this.capacity + const members = this._members.slice(0, capacity) + const filled = members.length + + const slots = Array.from({ length: capacity }, (_, i) => this._renderSlot(i, members)).join('') + + this.innerHTML = ` +
    +
    + Party + ${filled}/${capacity} +
    +
    ${slots}
    +
    + +
    +
    + ` + + const actions = this.querySelector('.tc-party-panel-actions') + const slots_el = this.querySelector('.tc-party-panel-slots') + + slots_el?.addEventListener('click', (e: MouseEvent) => { + const btn = (e.target as HTMLElement).closest('[data-action="invite"]') + if (!btn || btn.disabled) return + this._emit('tc-invite') + }) + + actions?.addEventListener('click', (e: MouseEvent) => { + const btn = (e.target as HTMLElement).closest('[data-action]') + if (!btn || btn.disabled) return + if (btn.dataset.action === 'leave') this._emit('tc-leave') + }) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: PartyPanel + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 623666a7..5f215740 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -301,6 +301,7 @@ export * from './LevelSelect' export * from './LoadingOverlay' export * from './LoadingScreen' export * from './Lobby' +export * from './PartyPanel' export * from './MatchmakingScreen' export * from './MainMenu' export * from './MenuItem' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 61211004..73a472c6 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -298,6 +298,7 @@ import { LevelSelect } from './LevelSelect' import { LoadingOverlay } from './LoadingOverlay' import { LoadingScreen } from './LoadingScreen' import { Lobby } from './Lobby' +import { PartyPanel } from './PartyPanel' import { MatchmakingScreen } from './MatchmakingScreen' import { MainMenu } from './MainMenu' import { MenuItem } from './MenuItem' @@ -620,6 +621,7 @@ export function register(): void { customElements.define('tc-loading-overlay', LoadingOverlay) customElements.define('tc-loading-screen', LoadingScreen) customElements.define('tc-lobby', Lobby) + customElements.define('tc-party-panel', PartyPanel) customElements.define('tc-matchmaking-screen', MatchmakingScreen) customElements.define('tc-main-menu', MainMenu) customElements.define('tc-menu-item', MenuItem) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 87a0c29d..2dfb4525 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -247,6 +247,7 @@ @forward 'list'; @forward 'list-row'; @forward 'lobby'; +@forward 'party-panel'; @forward 'live-feed'; @forward 'login'; @forward 'main-menu'; diff --git a/web-components/style/components/_party-panel.scss b/web-components/style/components/_party-panel.scss new file mode 100644 index 00000000..a76d18d8 --- /dev/null +++ b/web-components/style/components/_party-panel.scss @@ -0,0 +1,277 @@ +// tc-party-panel — party member panel with portraits, health, status. +// Port of gc-party-panel (game-components), restyled to the toolcase design +// system: slate neutrals, hairline borders, sharp corners (border-radius: 0), +// JetBrains Mono for machine-facing text, ink accent (--tc-app-accent) for +// primary actions. All cosmetics flow through --bs-party-panel-* custom +// properties. + +@use '../foundation/tokens' as *; + +tc-party-panel { + --bs-party-panel-bg: var(--tc-surface); + --bs-party-panel-border: var(--tc-border); + --bs-party-panel-header-bg: var(--tc-surface-muted); + --bs-party-panel-header-border: var(--tc-border); + --bs-party-panel-eyebrow-color: var(--tc-text-muted); + --bs-party-panel-count-color: var(--tc-text-faint); + --bs-party-panel-slot-bg: var(--tc-surface); + --bs-party-panel-slot-border: var(--tc-border); + --bs-party-panel-slot-empty-bg: var(--tc-surface); + --bs-party-panel-slot-empty-color: var(--tc-text-faint); + --bs-party-panel-slot-empty-hover-bg: var(--tc-surface-hover, var(--tc-surface-muted)); + --bs-party-panel-slot-filled-bg: var(--tc-surface); + --bs-party-panel-slot-ready-bg: var(--tc-surface-muted); + --bs-party-panel-member-name-color: var(--tc-text); + --bs-party-panel-role-bg: var(--tc-surface-muted); + --bs-party-panel-role-color: var(--tc-text-muted); + --bs-party-panel-host-badge-color: var(--tc-app-accent); + --bs-party-panel-ready-color: var(--tc-success); + --bs-party-panel-waiting-color: var(--tc-text-faint); + --bs-party-panel-actions-bg: var(--tc-surface-muted); + --bs-party-panel-btn-bg: var(--tc-surface); + --bs-party-panel-btn-color: var(--tc-text); + --bs-party-panel-btn-border: var(--tc-border); + --bs-party-panel-btn-hover-bg: var(--tc-surface-hover, var(--tc-surface-muted)); + --bs-party-panel-btn-hover-color: var(--tc-text); + --bs-party-panel-btn-min-height: 2.25rem; + --bs-party-panel-btn-leave-bg: var(--tc-surface); + --bs-party-panel-btn-leave-color: var(--tc-text); + --bs-party-panel-btn-disabled-opacity: 0.45; +} + +.tc-party-panel { + background: var(--bs-party-panel-bg); + border: 1px solid var(--bs-party-panel-border); + border-radius: 0; + overflow: hidden; +} + +// ── Header ─────────────────────────────────────────────────────────────────── + +.tc-party-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.875rem; + background: var(--bs-party-panel-header-bg); + border-bottom: 1px solid var(--bs-party-panel-header-border); +} + +.tc-party-panel-eyebrow { + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--bs-party-panel-eyebrow-color); +} + +.tc-party-panel-count { + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.625rem; + font-weight: 500; + color: var(--bs-party-panel-count-color); + letter-spacing: 0.025em; +} + +// ── Slot grid ──────────────────────────────────────────────────────────────── + +.tc-party-panel-slots { + display: flex; + flex-direction: column; +} + +.tc-party-panel-slot { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.875rem; + min-height: 2.75rem; + border-bottom: 1px solid var(--bs-party-panel-slot-border); + background: var(--bs-party-panel-slot-bg); + text-align: left; + + &:last-child { + border-bottom: none; + } + + &.tc-party-panel-slot--empty { + cursor: pointer; + border: none; + border-bottom: 1px solid var(--bs-party-panel-slot-border); + background: var(--bs-party-panel-slot-empty-bg); + width: 100%; + color: var(--bs-party-panel-slot-empty-color); + transition: background-color var(--tc-transition-fast, 0.15s ease); + + &:last-child { + border-bottom: none; + } + + &:hover { + background: var(--bs-party-panel-slot-empty-hover-bg); + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: -2px; + } + } + + &.tc-party-panel-slot--filled { + background: var(--bs-party-panel-slot-filled-bg); + } + + &.tc-party-panel-slot--ready { + background: var(--bs-party-panel-slot-ready-bg); + } +} + +.tc-party-panel-invite-glyph { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + font-size: 1rem; + font-weight: 400; + line-height: 1; + color: var(--bs-party-panel-slot-empty-color); + flex-shrink: 0; +} + +.tc-party-panel-invite-label { + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.6875rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--bs-party-panel-slot-empty-color); +} + +.tc-party-panel-member-name { + flex: 1; + min-width: 0; + font-size: 0.875rem; + font-weight: 500; + color: var(--bs-party-panel-member-name-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tc-party-panel-member-meta { + display: flex; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; +} + +.tc-party-panel-role { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.375rem; + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.625rem; + font-weight: 500; + letter-spacing: 0.025em; + background: var(--bs-party-panel-role-bg); + color: var(--bs-party-panel-role-color); + border-radius: 0; + white-space: nowrap; +} + +.tc-party-panel-host-badge { + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--bs-party-panel-host-badge-color); +} + +.tc-party-panel-ready-badge { + flex-shrink: 0; + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + + &.tc-party-panel-ready-badge--ready { + color: var(--bs-party-panel-ready-color); + } + + &.tc-party-panel-ready-badge--waiting { + color: var(--bs-party-panel-waiting-color); + } +} + +// ── Actions ────────────────────────────────────────────────────────────────── + +.tc-party-panel-actions { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.875rem; + background: var(--bs-party-panel-actions-bg); + border-top: 1px solid var(--bs-party-panel-slot-border); +} + +.tc-party-panel-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 0.875rem; + min-height: var(--bs-party-panel-btn-min-height); + border: 1px solid var(--bs-party-panel-btn-border); + border-radius: 0; + background: var(--bs-party-panel-btn-bg); + color: var(--bs-party-panel-btn-color); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: + background-color var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease), + opacity var(--tc-transition-fast, 0.15s ease); + + &:hover:not(:disabled) { + background: var(--bs-party-panel-btn-hover-bg); + color: var(--bs-party-panel-btn-hover-color); + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + } + + &:disabled { + opacity: var(--bs-party-panel-btn-disabled-opacity); + cursor: not-allowed; + } + + &.tc-party-panel-btn--leave { + background: var(--bs-party-panel-btn-leave-bg); + color: var(--bs-party-panel-btn-leave-color); + } +} + +// 44 px coarse touch targets +@media (pointer: coarse) { + .tc-party-panel-btn { + --bs-party-panel-btn-min-height: 44px; + } + + .tc-party-panel-slot--empty { + min-height: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + .tc-party-panel-btn, + .tc-party-panel-slot--empty { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index ef081af3..6306a800 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -335,6 +335,7 @@ tc-legal-screen, tc-level-header, tc-level-select, tc-lobby, +tc-party-panel, tc-main-menu, tc-panel, tc-panel-header, From 586ca4d11e307930d294e85e75dfe228e1e19caf Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 00:40:12 +0000 Subject: [PATCH 353/632] 348-pause-menu: Build the tc-pause-menu web component (PauseMenu game-components port) --- examples/public/web-components/SKILL.md | 95 +++++ examples/src/web-components/PauseMenuDemo.tsx | 133 +++++++ examples/src/web-components/index.tsx | 2 + web-components/src/PauseMenu.ts | 342 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_pause-menu.scss | 285 +++++++++++++++ web-components/style/foundation/_reset.scss | 6 + 9 files changed, 867 insertions(+) create mode 100644 examples/src/web-components/PauseMenuDemo.tsx create mode 100644 web-components/src/PauseMenu.ts create mode 100644 web-components/style/components/_pause-menu.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 13861221..06477f49 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -242,6 +242,7 @@ After `register()` you can author markup directly: - [tc-panel](#tc-panel) - [tc-panel-header](#tc-panel-header) - [tc-particle-emitter](#tc-particle-emitter) + - [tc-pause-menu](#tc-pause-menu) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) - [tc-live-feed](#tc-live-feed) @@ -22746,3 +22747,97 @@ pe.addEventListener('tc-burst', e => { console.log(`Burst: ${e.detail.count} particles`) }) ``` + +--- + +### tc-pause-menu + +In-game pause overlay with a full-screen backdrop, an optional eyebrow + title header, a keyboard-navigable menu-item list, and a primary Resume button. Port of `gc-pause-menu` (game-components), restyled to the toolcase design system: slate neutrals, sharp corners, 1px hairline, overlay-tier shadow. Controlled component — fires `tc-close` / `tc-resume` / `tc-select`; the consumer sets `open` to `false` to actually dismiss. Focus trap, scroll lock, and keyboard (`Escape`, `ArrowDown`/`Up`, `Enter`/`Space`) handling included. Items are set via the JS `items` property. No shadow root; light DOM; `display: block`. + +**Tag:** `tc-pause-menu` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `open` | boolean | `false` | Shows the overlay when present; removing it hides it. | +| `menu-title` | string | `"Game Paused"` | Heading shown inside the panel. | + +**JS Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `open` | `boolean` | `false` | Reflects the `open` attribute. | +| `menuTitle` | `string` | `""` | Reflects the `menu-title` attribute. | +| `items` | `PauseMenuItem[]` | `[]` | Array of menu items. Setting this after connect surgically updates the items list without re-rendering the panel. | +| `onResume` | `(() => void) \| null` | `null` | Optional callback fired alongside `tc-resume`. | +| `onClose` | `(() => void) \| null` | `null` | Optional callback fired alongside `tc-close`. | +| `onSelect` | `((id: string) => void) \| null` | `null` | Optional callback fired alongside `tc-select`. | + +**PauseMenuItem shape** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | `string` | yes | Unique item identifier, returned in `tc-select` detail. | +| `label` | `string` | yes | Display text for the menu row. | +| `disabled` | `boolean` | no | Prevents selection and applies the disabled style. | +| `badge` | `string` | no | Optional mono badge rendered on the trailing edge. | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-resume` | `{}` | Fired when the Resume button is clicked. Does **not** self-close. | +| `tc-close` | `{}` | Fired on `Escape` key, backdrop click, or — if the consumer wires it up — close actions. Does **not** mutate `open`. | +| `tc-select` | `{ id: string }` | Fired when a non-disabled menu item is activated (click, `Enter`, or `Space`). | + +**CSS custom properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-pause-menu-bg` | `var(--tc-surface)` | Panel background. | +| `--bs-pause-menu-border-color` | `var(--tc-border)` | Panel and item border colour. | +| `--bs-pause-menu-shadow` | `var(--tc-shadow-lg)` | Panel box-shadow (overlay tier). | +| `--bs-pause-menu-width` | `360px` | Default panel width. | +| `--bs-pause-menu-max-width` | `calc(100vw - 2rem)` | Maximum panel width (viewport responsive). | +| `--bs-pause-menu-max-height` | `calc(100vh - 4rem)` | Maximum panel height. | +| `--bs-pause-menu-padding` | `1.25rem` | Header and footer padding. | +| `--bs-pause-menu-backdrop-bg` | `#0f172a` | Backdrop scrim colour. | +| `--bs-pause-menu-backdrop-opacity` | `0.55` | Backdrop opacity (open state). | +| `--bs-pause-menu-z-backdrop` | `var(--tc-z-modal-backdrop)` | Backdrop z-index. | +| `--bs-pause-menu-z-panel` | `var(--tc-z-modal)` | Panel z-index. | +| `--bs-pause-menu-eyebrow-color` | `var(--tc-text-muted)` | Eyebrow micro-label colour. | +| `--bs-pause-menu-title-color` | `var(--tc-text)` | Heading colour. | +| `--bs-pause-menu-item-min-height` | `2.75rem` | Item row min-height (44 px under coarse pointer). | +| `--bs-pause-menu-item-color` | `var(--tc-text)` | Item text colour. | +| `--bs-pause-menu-item-hover-bg` | `var(--tc-surface-muted)` | Item hover/focus background. | +| `--bs-pause-menu-item-disabled-color` | `var(--tc-text-faint)` | Disabled item text colour. | +| `--bs-pause-menu-badge-bg` | `var(--tc-surface-muted)` | Badge background. | +| `--bs-pause-menu-badge-color` | `var(--tc-text-muted)` | Badge text colour. | +| `--bs-pause-menu-resume-bg` | `var(--tc-app-accent)` | Resume button background. | +| `--bs-pause-menu-resume-color` | `#fff` | Resume button text colour. | +| `--bs-pause-menu-resume-hover-bg` | `var(--tc-ink, #0f172a)` | Resume button hover background. | + +```html + + + + + +``` diff --git a/examples/src/web-components/PauseMenuDemo.tsx b/examples/src/web-components/PauseMenuDemo.tsx new file mode 100644 index 00000000..4d7a137c --- /dev/null +++ b/examples/src/web-components/PauseMenuDemo.tsx @@ -0,0 +1,133 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const BASIC_ITEMS = [ + { id: 'resume', label: 'Resume' }, + { id: 'settings', label: 'Settings' }, + { id: 'load', label: 'Load Game' }, + { id: 'quit', label: 'Quit to Desktop' }, +] + +const BADGE_ITEMS = [ + { id: 'resume', label: 'Resume' }, + { id: 'settings', label: 'Settings', badge: 'New' }, + { id: 'achievements', label: 'Achievements', badge: '3' }, + { id: 'disabled', label: 'Leaderboards', disabled: true }, + { id: 'quit', label: 'Quit' }, +] + +const PauseMenuDemo: React.FC = () => { + const basicRef = useRef(null) + const eventsRef = useRef(null) + + const [basicOpen, setBasicOpen] = useState(false) + const [eventsOpen, setEventsOpen] = useState(false) + const [log, setLog] = useState([]) + + const appendLog = (msg: string) => setLog(l => [msg, ...l].slice(0, 10)) + + // Basic demo + useEffect(() => { + const el = basicRef.current + if (!el) return + el.items = BASIC_ITEMS + const onClose = () => setBasicOpen(false) + const onResume = () => setBasicOpen(false) + el.addEventListener('tc-close', onClose) + el.addEventListener('tc-resume', onResume) + return () => { + el.removeEventListener('tc-close', onClose) + el.removeEventListener('tc-resume', onResume) + } + }, []) + + useEffect(() => { + if (!basicRef.current) return + if (basicOpen) basicRef.current.setAttribute('open', '') + else basicRef.current.removeAttribute('open') + }, [basicOpen]) + + // Events demo + useEffect(() => { + const el = eventsRef.current + if (!el) return + el.items = BADGE_ITEMS + const onClose = () => { appendLog('tc-close fired'); setEventsOpen(false) } + const onResume = () => { appendLog('tc-resume fired'); setEventsOpen(false) } + const onSelect = (e: CustomEvent) => appendLog(`tc-select — id: "${e.detail.id}"`) + el.addEventListener('tc-close', onClose) + el.addEventListener('tc-resume', onResume) + el.addEventListener('tc-select', onSelect as EventListener) + return () => { + el.removeEventListener('tc-close', onClose) + el.removeEventListener('tc-resume', onResume) + el.removeEventListener('tc-select', onSelect as EventListener) + } + }, []) + + useEffect(() => { + if (!eventsRef.current) return + if (eventsOpen) eventsRef.current.setAttribute('open', '') + else eventsRef.current.removeAttribute('open') + }, [eventsOpen]) + + return ( +
    +
    +
    +
    + Web Components} + title="PauseMenu" + description="In-game pause overlay with a backdrop, menu items, and a Resume button. Controlled component — fire tc-close / tc-resume to dismiss. Esc and backdrop click emit tc-close. Items are set via the JS items property." + /> + +
    + + + {/* @ts-ignore */} + + + + + + {/* @ts-ignore */} + +
    + Event log + {log.length === 0 ? ( + Open the menu and interact… + ) : ( +
      + {log.map((line, i) => ( +
    • {line}
    • + ))} +
    + )} +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default PauseMenuDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 8973ecb9..da85a6e3 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -242,6 +242,7 @@ import MetalButtonDemo from './MetalButtonDemo' import NavButtonDemo from './NavButtonDemo' import LootListDemo from './LootListDemo' import LootPopupDemo from './LootPopupDemo' +import PauseMenuDemo from './PauseMenuDemo' import LiveFeedDemo from './LiveFeedDemo' import LoginDemo from './LoginDemo' import MarkdownEditorDemo from './MarkdownEditorDemo' @@ -572,6 +573,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'nav-button', category: 'Navigation', element: }, { key: 'loot-list', category: 'Components', element: }, { key: 'loot-popup', category: 'Components', element: }, + { key: 'pause-menu', category: 'Overlays & Feedback', element: }, { key: 'live-feed', category: 'Components', element: }, { key: 'login', category: 'Layout', element: }, { key: 'marquee', category: 'Content', element: }, diff --git a/web-components/src/PauseMenu.ts b/web-components/src/PauseMenu.ts new file mode 100644 index 00000000..7b05c87b --- /dev/null +++ b/web-components/src/PauseMenu.ts @@ -0,0 +1,342 @@ +const TAG_NAME = 'tc-pause-menu' + +let _idCounter = 0 + +export interface PauseMenuItem { + id: string + label: string + disabled?: boolean + badge?: string +} + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function getFocusable(root: Element): HTMLElement[] { + return Array.from( + root.querySelectorAll( + 'a[href],area[href],button:not([disabled]),details>summary,[tabindex]:not([tabindex="-1"]),input:not([disabled]),select:not([disabled]),textarea:not([disabled])', + ), + ).filter(el => !el.closest('[hidden]') && el.tabIndex >= 0) +} + +export class PauseMenu extends HTMLElement { + private _initialised = false + private _items: PauseMenuItem[] = [] + private _previousFocus: Element | null = null + private _idPrefix: string + + onResume: (() => void) | null = null + onClose: (() => void) | null = null + onSelect: ((id: string) => void) | null = null + + constructor() { + super() + this._idPrefix = `tc-pause-menu-${++_idCounter}` + } + + static get observedAttributes(): string[] { + return ['open', 'menu-title'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + this._detachHandlers() + this._attachHandlers() + if (this.open) this._applyOpenState(true) + } + + disconnectedCallback(): void { + this._detachHandlers() + this._restoreScroll() + } + + attributeChangedCallback(name: string): void { + if (!this.isConnected || !this._initialised) return + + if (name === 'open') { + this._applyOpenState(this.open) + return + } + + // Structural attribute — re-render, then re-apply visibility if open. + this.render() + if (this.open) this._applyOpenState(true) + } + + get open(): boolean { + return this.hasAttribute('open') + } + set open(v: boolean) { + if (v) this.setAttribute('open', '') + else this.removeAttribute('open') + } + + get menuTitle(): string { + return this.getAttribute('menu-title') ?? '' + } + set menuTitle(v: string) { + if (v) this.setAttribute('menu-title', v) + else this.removeAttribute('menu-title') + } + + get items(): PauseMenuItem[] { + return this._items.slice() + } + set items(value: PauseMenuItem[]) { + this._items = Array.isArray(value) ? value.slice() : [] + if (this._initialised) this._syncItems() + } + + // ── Private helpers ───────────────────────────────────────────────────────── + + private _buildItemsHtml(): string { + return this._items.map(item => { + const cls = [ + 'tc-pause-menu__item', + item.disabled ? 'tc-pause-menu__item--disabled' : '', + ].filter(Boolean).join(' ') + const badgeHtml = item.badge + ? `${esc(item.badge)}` + : '' + return ( + `` + ) + }).join('') + } + + private _syncItems(): void { + const list = this.querySelector('.tc-pause-menu__items') + if (list) list.innerHTML = this._buildItemsHtml() + } + + private _fireSelect(id: string): void { + this.dispatchEvent(new CustomEvent('tc-select', { + bubbles: true, + composed: true, + detail: { id }, + })) + if (typeof this.onSelect === 'function') this.onSelect(id) + } + + // ── Open / close lifecycle ────────────────────────────────────────────────── + + private _applyOpenState(opening: boolean): void { + const panel = this.querySelector('.tc-pause-menu__panel') + const backdrop = this.querySelector('.tc-pause-menu__backdrop') + + if (opening) { + this._previousFocus = document.activeElement + panel?.removeAttribute('hidden') + backdrop?.removeAttribute('hidden') + // Settle one frame before adding the open class to trigger the CSS transition. + requestAnimationFrame(() => { + this.classList.add('tc-pause-menu--open') + panel?.setAttribute('aria-hidden', 'false') + this._lockScroll() + this._trapFocus(panel) + }) + } else { + this.classList.remove('tc-pause-menu--open') + panel?.setAttribute('aria-hidden', 'true') + this._restoreScroll() + this._restoreFocus() + const delay = this._getTransitionDuration(panel) + setTimeout(() => { + if (!this.open) { + panel?.setAttribute('hidden', '') + backdrop?.setAttribute('hidden', '') + } + }, delay) + } + } + + private _requestClose(): void { + this.dispatchEvent(new CustomEvent('tc-close', { + bubbles: true, + composed: true, + detail: {}, + })) + if (typeof this.onClose === 'function') this.onClose() + } + + private _trapFocus(panel: HTMLElement | null): void { + if (!panel) return + const focusable = getFocusable(panel) + if (focusable.length > 0) focusable[0].focus() + else panel.focus() + } + + private _restoreFocus(): void { + if (this._previousFocus instanceof HTMLElement) this._previousFocus.focus() + this._previousFocus = null + } + + private _lockScroll(): void { + document.body.style.overflow = 'hidden' + } + + private _restoreScroll(): void { + document.body.style.overflow = '' + } + + private _getTransitionDuration(el: HTMLElement | null): number { + if (!el) return 0 + const raw = getComputedStyle(el).transitionDuration || '0s' + let max = 0 + for (const p of raw.split(',')) { + const sec = parseFloat(p.trim()) + if (!isNaN(sec)) max = Math.max(max, sec) + } + return max * 1000 + } + + // ── Event handlers ────────────────────────────────────────────────────────── + + private _onKeydown = (e: KeyboardEvent): void => { + if (!this.open) return + + if (e.key === 'Escape') { + e.preventDefault() + this._requestClose() + return + } + + if (e.key === 'Tab') { + const panel = this.querySelector('.tc-pause-menu__panel') + if (!panel) return + const focusable = getFocusable(panel) + if (focusable.length === 0) return + const first = focusable[0] + const last = focusable[focusable.length - 1] + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault() + last.focus() + } + } else { + if (document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + return + } + + // Arrow navigation within menu items. + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + const items = Array.from( + this.querySelectorAll('.tc-pause-menu__item:not(.tc-pause-menu__item--disabled)'), + ) + if (items.length === 0) return + e.preventDefault() + const current = items.indexOf(document.activeElement as HTMLElement) + const next = e.key === 'ArrowDown' + ? (current + 1) % items.length + : (current - 1 + items.length) % items.length + items[next].focus() + return + } + + // Enter/Space activate the currently focused menu item. + if (e.key === 'Enter' || e.key === ' ') { + const item = (document.activeElement as Element | null) + ?.closest('.tc-pause-menu__item') + if (item && this.contains(item) && !item.classList.contains('tc-pause-menu__item--disabled')) { + e.preventDefault() + const id = item.dataset.id + if (id) this._fireSelect(id) + } + } + } + + private _onBackdropClick = (e: MouseEvent): void => { + if ((e.target as Element)?.classList.contains('tc-pause-menu__backdrop')) { + this._requestClose() + } + } + + private _onResumeClick = (e: MouseEvent): void => { + if ((e.target as Element)?.closest('.tc-pause-menu__resume')) { + this.dispatchEvent(new CustomEvent('tc-resume', { + bubbles: true, + composed: true, + detail: {}, + })) + if (typeof this.onResume === 'function') this.onResume() + } + } + + private _onItemClick = (e: MouseEvent): void => { + const item = (e.target as Element)?.closest('.tc-pause-menu__item') + if (!item || !this.contains(item)) return + if (item.classList.contains('tc-pause-menu__item--disabled')) return + const id = item.dataset.id + if (id) this._fireSelect(id) + } + + private _attachHandlers(): void { + document.addEventListener('keydown', this._onKeydown) + this.addEventListener('click', this._onBackdropClick) + this.addEventListener('click', this._onResumeClick) + this.addEventListener('click', this._onItemClick) + } + + private _detachHandlers(): void { + document.removeEventListener('keydown', this._onKeydown) + this.removeEventListener('click', this._onBackdropClick) + this.removeEventListener('click', this._onResumeClick) + this.removeEventListener('click', this._onItemClick) + } + + // ── Render ────────────────────────────────────────────────────────────────── + + private render(): void { + const isOpen = this.open + const labelId = `${this._idPrefix}-title` + const hiddenAttr = isOpen ? '' : ' hidden' + const titleText = this.getAttribute('menu-title') ?? 'Game Paused' + + this.innerHTML = + `` + + `` + + if (isOpen) this.classList.add('tc-pause-menu--open') + else this.classList.remove('tc-pause-menu--open') + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: PauseMenu + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 5f215740..384f5f72 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -317,3 +317,4 @@ export * from './ObjectiveMarker' export * from './PageIndicator' export * from './Panel' export * from './ParticleEmitter' +export * from './PauseMenu' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 73a472c6..9f259d84 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -314,6 +314,7 @@ import { ObjectiveMarker } from './ObjectiveMarker' import { PageIndicator } from './PageIndicator' import { ParticleEmitter } from './ParticleEmitter' import { Panel, PanelHeader } from './Panel' +import { PauseMenu } from './PauseMenu' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -638,4 +639,5 @@ export function register(): void { customElements.define('tc-particle-emitter', ParticleEmitter) customElements.define('tc-panel', Panel) customElements.define('tc-panel-header', PanelHeader) + customElements.define('tc-pause-menu', PauseMenu) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 2dfb4525..75be99a3 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -303,6 +303,7 @@ @forward 'loot-list'; @forward 'loot-popup'; @forward 'lore-text'; +@forward 'pause-menu'; @forward 'matchmaking-screen'; @forward 'minimap'; @forward 'mute-list'; diff --git a/web-components/style/components/_pause-menu.scss b/web-components/style/components/_pause-menu.scss new file mode 100644 index 00000000..8c8eddb2 --- /dev/null +++ b/web-components/style/components/_pause-menu.scss @@ -0,0 +1,285 @@ +// tc-pause-menu — in-game pause overlay menu. +// Port of gc-pause-menu (game-components), restyled to the toolcase design system: +// slate neutrals, sharp corners (border-radius: 0), 1px hairline, overlay-tier shadow. +// Controlled component — fires tc-close / tc-resume / tc-select; never self-closes. +// All cosmetics flow through --bs-pause-menu-* custom properties. + +tc-pause-menu { + // Surface + chrome + --bs-pause-menu-bg: var(--tc-surface); + --bs-pause-menu-border-color: var(--tc-border); + --bs-pause-menu-shadow: var(--tc-shadow-lg); + --bs-pause-menu-width: 360px; + --bs-pause-menu-max-width: calc(100vw - 2rem); + --bs-pause-menu-max-height: calc(100vh - 4rem); + --bs-pause-menu-padding: 1.25rem; + + // Backdrop scrim + --bs-pause-menu-backdrop-bg: #0f172a; + --bs-pause-menu-backdrop-opacity: 0.55; + + // Z-index band — overlay tier + --bs-pause-menu-z-backdrop: var(--tc-z-modal-backdrop); + --bs-pause-menu-z-panel: var(--tc-z-modal); + + // Transition + --bs-pause-menu-transition: transform var(--tc-transition-base), opacity var(--tc-transition-base); + + // Header + --bs-pause-menu-eyebrow-color: var(--tc-text-muted); + --bs-pause-menu-eyebrow-size: 0.6875rem; + --bs-pause-menu-title-color: var(--tc-text); + --bs-pause-menu-title-size: 1.125rem; + + // Menu items + --bs-pause-menu-item-min-height: 2.75rem; + --bs-pause-menu-item-color: var(--tc-text); + --bs-pause-menu-item-border: var(--tc-border); + --bs-pause-menu-item-hover-bg: var(--tc-surface-muted); + --bs-pause-menu-item-disabled-color: var(--tc-text-faint); + --bs-pause-menu-item-disabled-opacity: 0.5; + --bs-pause-menu-badge-bg: var(--tc-surface-muted); + --bs-pause-menu-badge-color: var(--tc-text-muted); + + // Resume button (primary ink action) + --bs-pause-menu-resume-font-size: 0.8125rem; + --bs-pause-menu-resume-min-height: 2.25rem; + --bs-pause-menu-resume-bg: var(--tc-app-accent); + --bs-pause-menu-resume-color: #fff; + --bs-pause-menu-resume-hover-bg: var(--tc-ink, #0f172a); + --bs-pause-menu-resume-hover-color: #fff; +} + +// ── Backdrop ─────────────────────────────────────────────────────────────────── + +.tc-pause-menu__backdrop { + position: fixed; + inset: 0; + z-index: var(--bs-pause-menu-z-backdrop); + background: var(--bs-pause-menu-backdrop-bg); + opacity: 0; + transition: opacity var(--tc-transition-base); + + tc-pause-menu.tc-pause-menu--open & { + opacity: var(--bs-pause-menu-backdrop-opacity); + } +} + +// ── Panel ────────────────────────────────────────────────────────────────────── + +.tc-pause-menu__panel { + position: fixed; + top: 50%; + left: 50%; + z-index: var(--bs-pause-menu-z-panel); + display: flex; + flex-direction: column; + width: var(--bs-pause-menu-width); + max-width: var(--bs-pause-menu-max-width); + max-height: var(--bs-pause-menu-max-height); + background: var(--bs-pause-menu-bg); + border: 1px solid var(--bs-pause-menu-border-color); + border-radius: 0; + box-shadow: var(--bs-pause-menu-shadow); + outline: 0; + overflow: hidden; + transition: var(--bs-pause-menu-transition); + + // Closed: shifted up, scaled down slightly, hidden. + opacity: 0; + transform: translateY(-20px) translateX(-50%) scale(0.97); + + // Open: centred, full size. + tc-pause-menu.tc-pause-menu--open & { + opacity: 1; + transform: translateY(-50%) translateX(-50%); + } +} + +// ── Header ───────────────────────────────────────────────────────────────────── + +.tc-pause-menu__header { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: var(--bs-pause-menu-padding); + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--bs-pause-menu-border-color); + background: var(--tc-surface-muted); + flex-shrink: 0; +} + +.tc-pause-menu__eyebrow { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-pause-menu-eyebrow-size); + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--bs-pause-menu-eyebrow-color); + line-height: 1.4; +} + +.tc-pause-menu__title { + font-size: var(--bs-pause-menu-title-size); + font-weight: 600; + color: var(--bs-pause-menu-title-color); + margin: 0; + line-height: 1.25; +} + +// ── Menu items ───────────────────────────────────────────────────────────────── + +.tc-pause-menu__items { + display: flex; + flex-direction: column; + flex: 1 1 auto; + overflow-y: auto; +} + +.tc-pause-menu__item { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0 var(--bs-pause-menu-padding); + min-height: var(--bs-pause-menu-item-min-height); + color: var(--bs-pause-menu-item-color); + border-bottom: 1px solid var(--bs-pause-menu-item-border); + cursor: pointer; + user-select: none; + transition: + background-color var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease); + + &:last-child { + border-bottom: none; + } + + &:hover:not(.tc-pause-menu__item--disabled), + &:focus-visible:not(.tc-pause-menu__item--disabled) { + background: var(--bs-pause-menu-item-hover-bg); + outline: none; + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: -2px; + } + + &.tc-pause-menu__item--disabled { + color: var(--bs-pause-menu-item-disabled-color); + opacity: var(--bs-pause-menu-item-disabled-opacity); + cursor: not-allowed; + pointer-events: none; + } +} + +.tc-pause-menu__item-label { + flex: 1; + min-width: 0; + font-size: 0.9375rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tc-pause-menu__item-badge { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.125rem 0.375rem; + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: 0.6875rem; + font-weight: 600; + line-height: 1; + background: var(--bs-pause-menu-badge-bg); + color: var(--bs-pause-menu-badge-color); + border-radius: 0; +} + +// ── Footer (Resume button) ───────────────────────────────────────────────────── + +.tc-pause-menu__footer { + display: flex; + align-items: center; + justify-content: flex-end; + flex-shrink: 0; + padding: var(--bs-pause-menu-padding); + border-top: 1px solid var(--bs-pause-menu-border-color); +} + +.tc-pause-menu__resume { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 1.25rem; + min-height: var(--bs-pause-menu-resume-min-height); + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-pause-menu-resume-font-size); + font-weight: 500; + letter-spacing: 0.025em; + color: var(--bs-pause-menu-resume-color); + background: var(--bs-pause-menu-resume-bg); + border: none; + border-radius: 0; + cursor: pointer; + white-space: nowrap; + line-height: 1; + transition: + background var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease), + transform var(--tc-transition-fast, 0.15s ease); + + &:hover { + background: var(--bs-pause-menu-resume-hover-bg); + color: var(--bs-pause-menu-resume-hover-color); + transform: translateY(-1px); + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + } + + @media (pointer: coarse) { + min-height: 44px; + } +} + +// ── 44 px coarse touch targets ───────────────────────────────────────────────── + +@media (pointer: coarse) { + tc-pause-menu { + --bs-pause-menu-item-min-height: 44px; + } +} + +// ── Reduced motion — skip transform / opacity animations ────────────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-pause-menu__backdrop { + transition: none; + } + + .tc-pause-menu__panel { + transition: none; + opacity: 1; + transform: translateY(-50%) translateX(-50%); + } + + .tc-pause-menu__resume { + transition: + background var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease); + + &:hover { + transform: none; + } + } + + .tc-pause-menu__item { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 6306a800..e20987b2 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -378,6 +378,12 @@ tc-loot-popup { display: block; } +// In-game pause overlay; the SCSS partial positions the backdrop and centred +// panel, but the host must not default to inline. +tc-pause-menu { + display: block; +} + // Fixed full-surface scrim; the SCSS partial flips it to a centred flex // container, but it must not default to inline. tc-blur-overlay { From 6394d5c973c6d3b9baa204e8953dd633bca20716 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 00:47:04 +0000 Subject: [PATCH 354/632] 349-pause-screen: Build the tc-pause-screen web component (PauseScreen game-components port) --- examples/public/web-components/SKILL.md | 97 +++++ .../src/web-components/PauseScreenDemo.tsx | 137 +++++++ examples/src/web-components/index.tsx | 2 + web-components/src/PauseScreen.ts | 341 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_pause-screen.scss | 222 ++++++++++++ web-components/style/foundation/_reset.scss | 6 + 9 files changed, 809 insertions(+) create mode 100644 examples/src/web-components/PauseScreenDemo.tsx create mode 100644 web-components/src/PauseScreen.ts create mode 100644 web-components/style/components/_pause-screen.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 06477f49..78bfba50 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -243,6 +243,7 @@ After `register()` you can author markup directly: - [tc-panel-header](#tc-panel-header) - [tc-particle-emitter](#tc-particle-emitter) - [tc-pause-menu](#tc-pause-menu) + - [tc-pause-screen](#tc-pause-screen) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) - [tc-live-feed](#tc-live-feed) @@ -22841,3 +22842,99 @@ In-game pause overlay with a full-screen backdrop, an optional eyebrow + title h }) ``` + +--- + +### tc-pause-screen + +Full-screen pause overlay with a backdrop, a mono eyebrow label, a custom title heading, and a keyboard-navigable item list. Port of `gc-pause-screen` (game-components), restyled to the toolcase design system: slate neutrals, sharp corners (border-radius: 0), 1px hairline borders, overlay-tier shadow. Controlled component — fires `tc-close` / `tc-resume` / `tc-restart` / `tc-quit` / `tc-select`; the consumer sets `open` to `false` to actually dismiss. Focus trap, scroll lock, and keyboard (`Escape`, `ArrowDown`/`Up`, `Enter`/`Space`, `Tab`) handling included. Default items are Resume, Restart, and Quit; set `items` via the JS property to customise. No shadow root; light DOM; `display: block`. + +**Tag:** `tc-pause-screen` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `open` | boolean | `false` | Shows the overlay when present; removing it hides it. | +| `screen-title` | string | `"Paused"` | Title heading shown inside the panel. | + +**JS Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `open` | `boolean` | `false` | Reflects the `open` attribute. | +| `screenTitle` | `string` | `"Paused"` | Reflects the `screen-title` attribute. | +| `items` | `PauseScreenItem[]` | `[Resume, Restart, Quit]` | Array of menu items. Falls back to the default three items when set to an empty array. Setting this after connect surgically updates the list without re-rendering the panel. | +| `onResume` | `(() => void) \| null` | `null` | Optional callback fired alongside `tc-resume`. | +| `onRestart` | `(() => void) \| null` | `null` | Optional callback fired alongside `tc-restart`. | +| `onQuit` | `(() => void) \| null` | `null` | Optional callback fired alongside `tc-quit`. | +| `onClose` | `(() => void) \| null` | `null` | Optional callback fired alongside `tc-close`. | +| `onSelect` | `((id: string) => void) \| null` | `null` | Optional callback fired alongside `tc-select`. | + +**PauseScreenItem shape** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | `string` | yes | Unique item identifier, returned in `tc-select` detail. Items with `id: 'resume'`, `'restart'`, or `'quit'` also fire their dedicated events. | +| `label` | `string` | yes | Display text for the item row. | +| `disabled` | `boolean` | no | Prevents selection and applies the disabled style. | +| `badge` | `string` | no | Optional mono badge rendered on the trailing edge. | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-resume` | `{}` | Fired when an item with `id: 'resume'` is activated. Does **not** self-close. | +| `tc-restart` | `{}` | Fired when an item with `id: 'restart'` is activated. | +| `tc-quit` | `{}` | Fired when an item with `id: 'quit'` is activated. | +| `tc-select` | `{ id: string }` | Fired for every non-disabled item activation (click, `Enter`, or `Space`). | +| `tc-close` | `{}` | Fired on `Escape` key or backdrop click. Does **not** mutate `open`. | + +**CSS custom properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-pause-screen-bg` | `var(--tc-surface)` | Panel background. | +| `--bs-pause-screen-border-color` | `var(--tc-border)` | Panel and item divider border colour. | +| `--bs-pause-screen-shadow` | `var(--tc-shadow-lg)` | Panel box-shadow (overlay tier). | +| `--bs-pause-screen-width` | `420px` | Default panel width. | +| `--bs-pause-screen-max-width` | `calc(100vw - 2rem)` | Maximum panel width (viewport responsive). | +| `--bs-pause-screen-max-height` | `calc(100vh - 4rem)` | Maximum panel height. | +| `--bs-pause-screen-padding` | `1.5rem` | Header and item padding. | +| `--bs-pause-screen-backdrop-bg` | `#0f172a` | Backdrop scrim colour. | +| `--bs-pause-screen-backdrop-opacity` | `0.65` | Backdrop opacity (open state). | +| `--bs-pause-screen-z-backdrop` | `var(--tc-z-modal-backdrop)` | Backdrop z-index. | +| `--bs-pause-screen-z-panel` | `var(--tc-z-modal)` | Panel z-index. | +| `--bs-pause-screen-eyebrow-color` | `var(--tc-cyan, #22d3ee)` | Eyebrow micro-label colour (cyan accent). | +| `--bs-pause-screen-title-color` | `var(--tc-text)` | Title heading colour. | +| `--bs-pause-screen-item-min-height` | `3rem` | Item row min-height (44 px under coarse pointer). | +| `--bs-pause-screen-item-color` | `var(--tc-text)` | Item text colour. | +| `--bs-pause-screen-item-hover-bg` | `var(--tc-surface-muted)` | Item hover/focus background. | +| `--bs-pause-screen-item-active-color` | `var(--tc-app-accent)` | Item text colour on hover/focus. | +| `--bs-pause-screen-item-disabled-color` | `var(--tc-text-faint)` | Disabled item text colour. | +| `--bs-pause-screen-badge-bg` | `var(--tc-surface-muted)` | Badge background. | +| `--bs-pause-screen-badge-color` | `var(--tc-text-muted)` | Badge text colour. | + +```html + + + + + +``` diff --git a/examples/src/web-components/PauseScreenDemo.tsx b/examples/src/web-components/PauseScreenDemo.tsx new file mode 100644 index 00000000..a471e914 --- /dev/null +++ b/examples/src/web-components/PauseScreenDemo.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const DEFAULT_ITEMS = [ + { id: 'resume', label: 'Resume' }, + { id: 'restart', label: 'Restart' }, + { id: 'quit', label: 'Quit' }, +] + +const CUSTOM_ITEMS = [ + { id: 'resume', label: 'Resume' }, + { id: 'settings', label: 'Settings', badge: 'New' }, + { id: 'achievements', label: 'Achievements', badge: '3' }, + { id: 'leaderboard', label: 'Leaderboards', disabled: true }, + { id: 'quit', label: 'Quit to Menu' }, +] + +const PauseScreenDemo: React.FC = () => { + const defaultRef = useRef(null) + const customRef = useRef(null) + + const [defaultOpen, setDefaultOpen] = useState(false) + const [customOpen, setCustomOpen] = useState(false) + const [log, setLog] = useState([]) + + const appendLog = (msg: string) => setLog(l => [msg, ...l].slice(0, 10)) + + // Default items demo + useEffect(() => { + const el = defaultRef.current + if (!el) return + const onClose = () => setDefaultOpen(false) + const onResume = () => { appendLog('tc-resume fired'); setDefaultOpen(false) } + const onRestart = () => appendLog('tc-restart fired') + const onQuit = () => appendLog('tc-quit fired') + const onSelect = (e: CustomEvent) => appendLog(`tc-select — id: "${e.detail.id}"`) + el.addEventListener('tc-close', onClose) + el.addEventListener('tc-resume', onResume) + el.addEventListener('tc-restart', onRestart) + el.addEventListener('tc-quit', onQuit) + el.addEventListener('tc-select', onSelect as EventListener) + return () => { + el.removeEventListener('tc-close', onClose) + el.removeEventListener('tc-resume', onResume) + el.removeEventListener('tc-restart', onRestart) + el.removeEventListener('tc-quit', onQuit) + el.removeEventListener('tc-select', onSelect as EventListener) + } + }, []) + + useEffect(() => { + if (!defaultRef.current) return + if (defaultOpen) defaultRef.current.setAttribute('open', '') + else defaultRef.current.removeAttribute('open') + }, [defaultOpen]) + + // Custom items demo + useEffect(() => { + const el = customRef.current + if (!el) return + el.items = CUSTOM_ITEMS + const onClose = () => setCustomOpen(false) + const onSelect = (e: CustomEvent) => appendLog(`tc-select — id: "${e.detail.id}"`) + el.addEventListener('tc-close', onClose) + el.addEventListener('tc-select', onSelect as EventListener) + return () => { + el.removeEventListener('tc-close', onClose) + el.removeEventListener('tc-select', onSelect as EventListener) + } + }, []) + + useEffect(() => { + if (!customRef.current) return + if (customOpen) customRef.current.setAttribute('open', '') + else customRef.current.removeAttribute('open') + }, [customOpen]) + + return ( +
    +
    +
    +
    + Web Components} + title="PauseScreen" + description="Full-screen pause overlay with a backdrop, eyebrow header, and a keyboard-navigable item list. Controlled component — fire tc-close / tc-resume to dismiss. Escape and backdrop click emit tc-close. The default items list [Resume, Restart, Quit] fires dedicated tc-resume, tc-restart, and tc-quit events in addition to tc-select." + /> + +
    + + + {/* @ts-ignore */} + +
    + Event log + {log.length === 0 ? ( + Open the screen and interact… + ) : ( +
      + {log.map((line, i) => ( +
    • {line}
    • + ))} +
    + )} +
    +
    + + + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    + ) +} + +export default PauseScreenDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index da85a6e3..a0a59906 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -243,6 +243,7 @@ import NavButtonDemo from './NavButtonDemo' import LootListDemo from './LootListDemo' import LootPopupDemo from './LootPopupDemo' import PauseMenuDemo from './PauseMenuDemo' +import PauseScreenDemo from './PauseScreenDemo' import LiveFeedDemo from './LiveFeedDemo' import LoginDemo from './LoginDemo' import MarkdownEditorDemo from './MarkdownEditorDemo' @@ -574,6 +575,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'loot-list', category: 'Components', element: }, { key: 'loot-popup', category: 'Components', element: }, { key: 'pause-menu', category: 'Overlays & Feedback', element: }, + { key: 'pause-screen', category: 'Overlays & Feedback', element: }, { key: 'live-feed', category: 'Components', element: }, { key: 'login', category: 'Layout', element: }, { key: 'marquee', category: 'Content', element: }, diff --git a/web-components/src/PauseScreen.ts b/web-components/src/PauseScreen.ts new file mode 100644 index 00000000..6d09cfc6 --- /dev/null +++ b/web-components/src/PauseScreen.ts @@ -0,0 +1,341 @@ +const TAG_NAME = 'tc-pause-screen' + +let _idCounter = 0 + +export interface PauseScreenItem { + id: string + label: string + disabled?: boolean + badge?: string +} + +const DEFAULT_ITEMS: PauseScreenItem[] = [ + { id: 'resume', label: 'Resume' }, + { id: 'restart', label: 'Restart' }, + { id: 'quit', label: 'Quit' }, +] + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function getFocusable(root: Element): HTMLElement[] { + return Array.from( + root.querySelectorAll( + 'a[href],area[href],button:not([disabled]),details>summary,[tabindex]:not([tabindex="-1"]),input:not([disabled]),select:not([disabled]),textarea:not([disabled])', + ), + ).filter(el => !el.closest('[hidden]') && el.tabIndex >= 0) +} + +export class PauseScreen extends HTMLElement { + private _initialised = false + private _items: PauseScreenItem[] = DEFAULT_ITEMS.slice() + private _previousFocus: Element | null = null + private _idPrefix: string + + onResume: (() => void) | null = null + onRestart: (() => void) | null = null + onQuit: (() => void) | null = null + onClose: (() => void) | null = null + onSelect: ((id: string) => void) | null = null + + constructor() { + super() + this._idPrefix = `tc-pause-screen-${++_idCounter}` + } + + static get observedAttributes(): string[] { + return ['open', 'screen-title'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + this._detachHandlers() + this._attachHandlers() + if (this.open) this._applyOpenState(true) + } + + disconnectedCallback(): void { + this._detachHandlers() + this._restoreScroll() + } + + attributeChangedCallback(name: string): void { + if (!this.isConnected || !this._initialised) return + + if (name === 'open') { + this._applyOpenState(this.open) + return + } + + this.render() + if (this.open) this._applyOpenState(true) + } + + get open(): boolean { + return this.hasAttribute('open') + } + set open(v: boolean) { + if (v) this.setAttribute('open', '') + else this.removeAttribute('open') + } + + get screenTitle(): string { + return this.getAttribute('screen-title') ?? 'Paused' + } + set screenTitle(v: string) { + if (v) this.setAttribute('screen-title', v) + else this.removeAttribute('screen-title') + } + + get items(): PauseScreenItem[] { + return this._items.slice() + } + set items(value: PauseScreenItem[]) { + this._items = Array.isArray(value) && value.length ? value.slice() : DEFAULT_ITEMS.slice() + if (this._initialised) this._syncItems() + } + + // ── Private helpers ───────────────────────────────────────────────────────── + + private _buildItemsHtml(): string { + return this._items.map(item => { + const cls = [ + 'tc-pause-screen__item', + item.disabled ? 'tc-pause-screen__item--disabled' : '', + ].filter(Boolean).join(' ') + const badgeHtml = item.badge + ? `${esc(item.badge)}` + : '' + return ( + `` + ) + }).join('') + } + + private _syncItems(): void { + const list = this.querySelector('.tc-pause-screen__items') + if (list) list.innerHTML = this._buildItemsHtml() + } + + private _fireSelect(id: string): void { + this.dispatchEvent(new CustomEvent('tc-select', { + bubbles: true, + composed: true, + detail: { id }, + })) + if (typeof this.onSelect === 'function') this.onSelect(id) + + if (id === 'resume') { + this.dispatchEvent(new CustomEvent('tc-resume', { bubbles: true, composed: true, detail: {} })) + if (typeof this.onResume === 'function') this.onResume() + } else if (id === 'restart') { + this.dispatchEvent(new CustomEvent('tc-restart', { bubbles: true, composed: true, detail: {} })) + if (typeof this.onRestart === 'function') this.onRestart() + } else if (id === 'quit') { + this.dispatchEvent(new CustomEvent('tc-quit', { bubbles: true, composed: true, detail: {} })) + if (typeof this.onQuit === 'function') this.onQuit() + } + } + + // ── Open / close lifecycle ────────────────────────────────────────────────── + + private _applyOpenState(opening: boolean): void { + const panel = this.querySelector('.tc-pause-screen__panel') + const backdrop = this.querySelector('.tc-pause-screen__backdrop') + + if (opening) { + this._previousFocus = document.activeElement + panel?.removeAttribute('hidden') + backdrop?.removeAttribute('hidden') + requestAnimationFrame(() => { + this.classList.add('tc-pause-screen--open') + panel?.setAttribute('aria-hidden', 'false') + this._lockScroll() + this._trapFocus(panel) + }) + } else { + this.classList.remove('tc-pause-screen--open') + panel?.setAttribute('aria-hidden', 'true') + this._restoreScroll() + this._restoreFocus() + const delay = this._getTransitionDuration(panel) + setTimeout(() => { + if (!this.open) { + panel?.setAttribute('hidden', '') + backdrop?.setAttribute('hidden', '') + } + }, delay) + } + } + + private _requestClose(): void { + this.dispatchEvent(new CustomEvent('tc-close', { + bubbles: true, + composed: true, + detail: {}, + })) + if (typeof this.onClose === 'function') this.onClose() + } + + private _trapFocus(panel: HTMLElement | null): void { + if (!panel) return + const focusable = getFocusable(panel) + if (focusable.length > 0) focusable[0].focus() + else panel.focus() + } + + private _restoreFocus(): void { + if (this._previousFocus instanceof HTMLElement) this._previousFocus.focus() + this._previousFocus = null + } + + private _lockScroll(): void { + document.body.style.overflow = 'hidden' + } + + private _restoreScroll(): void { + document.body.style.overflow = '' + } + + private _getTransitionDuration(el: HTMLElement | null): number { + if (!el) return 0 + const raw = getComputedStyle(el).transitionDuration || '0s' + let max = 0 + for (const p of raw.split(',')) { + const sec = parseFloat(p.trim()) + if (!isNaN(sec)) max = Math.max(max, sec) + } + return max * 1000 + } + + // ── Event handlers ────────────────────────────────────────────────────────── + + private _onKeydown = (e: KeyboardEvent): void => { + if (!this.open) return + + if (e.key === 'Escape') { + e.preventDefault() + this._requestClose() + return + } + + if (e.key === 'Tab') { + const panel = this.querySelector('.tc-pause-screen__panel') + if (!panel) return + const focusable = getFocusable(panel) + if (focusable.length === 0) return + const first = focusable[0] + const last = focusable[focusable.length - 1] + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault() + last.focus() + } + } else { + if (document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + return + } + + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + const items = Array.from( + this.querySelectorAll('.tc-pause-screen__item:not(.tc-pause-screen__item--disabled)'), + ) + if (items.length === 0) return + e.preventDefault() + const current = items.indexOf(document.activeElement as HTMLElement) + const next = e.key === 'ArrowDown' + ? (current + 1) % items.length + : (current - 1 + items.length) % items.length + items[next].focus() + return + } + + if (e.key === 'Enter' || e.key === ' ') { + const item = (document.activeElement as Element | null) + ?.closest('.tc-pause-screen__item') + if (item && this.contains(item) && !item.classList.contains('tc-pause-screen__item--disabled')) { + e.preventDefault() + const id = item.dataset.id + if (id) this._fireSelect(id) + } + } + } + + private _onBackdropClick = (e: MouseEvent): void => { + if ((e.target as Element)?.classList.contains('tc-pause-screen__backdrop')) { + this._requestClose() + } + } + + private _onItemClick = (e: MouseEvent): void => { + const item = (e.target as Element)?.closest('.tc-pause-screen__item') + if (!item || !this.contains(item)) return + if (item.classList.contains('tc-pause-screen__item--disabled')) return + const id = item.dataset.id + if (id) this._fireSelect(id) + } + + private _attachHandlers(): void { + document.addEventListener('keydown', this._onKeydown) + this.addEventListener('click', this._onBackdropClick) + this.addEventListener('click', this._onItemClick) + } + + private _detachHandlers(): void { + document.removeEventListener('keydown', this._onKeydown) + this.removeEventListener('click', this._onBackdropClick) + this.removeEventListener('click', this._onItemClick) + } + + // ── Render ────────────────────────────────────────────────────────────────── + + private render(): void { + const isOpen = this.open + const labelId = `${this._idPrefix}-title` + const hiddenAttr = isOpen ? '' : ' hidden' + const titleText = this.getAttribute('screen-title') ?? 'Paused' + + this.innerHTML = + `` + + `` + + if (isOpen) this.classList.add('tc-pause-screen--open') + else this.classList.remove('tc-pause-screen--open') + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: PauseScreen + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 384f5f72..e656e3cf 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -318,3 +318,4 @@ export * from './PageIndicator' export * from './Panel' export * from './ParticleEmitter' export * from './PauseMenu' +export * from './PauseScreen' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 9f259d84..9e9c6550 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -315,6 +315,7 @@ import { PageIndicator } from './PageIndicator' import { ParticleEmitter } from './ParticleEmitter' import { Panel, PanelHeader } from './Panel' import { PauseMenu } from './PauseMenu' +import { PauseScreen } from './PauseScreen' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -640,4 +641,5 @@ export function register(): void { customElements.define('tc-panel', Panel) customElements.define('tc-panel-header', PanelHeader) customElements.define('tc-pause-menu', PauseMenu) + customElements.define('tc-pause-screen', PauseScreen) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 75be99a3..6466388e 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -304,6 +304,7 @@ @forward 'loot-popup'; @forward 'lore-text'; @forward 'pause-menu'; +@forward 'pause-screen'; @forward 'matchmaking-screen'; @forward 'minimap'; @forward 'mute-list'; diff --git a/web-components/style/components/_pause-screen.scss b/web-components/style/components/_pause-screen.scss new file mode 100644 index 00000000..a6af11ec --- /dev/null +++ b/web-components/style/components/_pause-screen.scss @@ -0,0 +1,222 @@ +// tc-pause-screen — full-screen pause overlay with backdrop, eyebrow/title header, +// and a keyboard-navigable items list. +// Port of gc-pause-screen (game-components); game chrome (gilded frames, glows, +// fantasy fills, diamond dividers) replaced with the slate design system: +// sharp corners, 1px hairline borders, overlay-tier shadow. +// Controlled component — fires tc-close / tc-resume / tc-restart / tc-quit / tc-select; +// never self-closes. +// All cosmetics flow through --bs-pause-screen-* custom properties. + +tc-pause-screen { + // Surface + chrome + --bs-pause-screen-bg: var(--tc-surface); + --bs-pause-screen-border-color: var(--tc-border); + --bs-pause-screen-shadow: var(--tc-shadow-lg); + --bs-pause-screen-width: 420px; + --bs-pause-screen-max-width: calc(100vw - 2rem); + --bs-pause-screen-max-height: calc(100vh - 4rem); + --bs-pause-screen-padding: 1.5rem; + + // Backdrop scrim + --bs-pause-screen-backdrop-bg: #0f172a; + --bs-pause-screen-backdrop-opacity: 0.65; + + // Z-index band — overlay tier + --bs-pause-screen-z-backdrop: var(--tc-z-modal-backdrop); + --bs-pause-screen-z-panel: var(--tc-z-modal); + + // Transition + --bs-pause-screen-transition: transform var(--tc-transition-base), opacity var(--tc-transition-base); + + // Header + --bs-pause-screen-eyebrow-color: var(--tc-cyan, #22d3ee); + --bs-pause-screen-eyebrow-size: 0.6875rem; + --bs-pause-screen-title-color: var(--tc-text); + --bs-pause-screen-title-size: 1.25rem; + + // Items + --bs-pause-screen-item-min-height: 3rem; + --bs-pause-screen-item-color: var(--tc-text); + --bs-pause-screen-item-border: var(--tc-border); + --bs-pause-screen-item-hover-bg: var(--tc-surface-muted); + --bs-pause-screen-item-active-color: var(--tc-app-accent); + --bs-pause-screen-item-disabled-color: var(--tc-text-faint); + --bs-pause-screen-item-disabled-opacity: 0.45; + --bs-pause-screen-badge-bg: var(--tc-surface-muted); + --bs-pause-screen-badge-color: var(--tc-text-muted); +} + +// ── Backdrop ─────────────────────────────────────────────────────────────────── + +.tc-pause-screen__backdrop { + position: fixed; + inset: 0; + z-index: var(--bs-pause-screen-z-backdrop); + background: var(--bs-pause-screen-backdrop-bg); + opacity: 0; + transition: opacity var(--tc-transition-base); + + tc-pause-screen.tc-pause-screen--open & { + opacity: var(--bs-pause-screen-backdrop-opacity); + } +} + +// ── Panel ────────────────────────────────────────────────────────────────────── + +.tc-pause-screen__panel { + position: fixed; + top: 50%; + left: 50%; + z-index: var(--bs-pause-screen-z-panel); + display: flex; + flex-direction: column; + width: var(--bs-pause-screen-width); + max-width: var(--bs-pause-screen-max-width); + max-height: var(--bs-pause-screen-max-height); + background: var(--bs-pause-screen-bg); + border: 1px solid var(--bs-pause-screen-border-color); + border-radius: 0; + box-shadow: var(--bs-pause-screen-shadow); + outline: 0; + overflow: hidden; + transition: var(--bs-pause-screen-transition); + + // Closed: shifted up, faded out. + opacity: 0; + transform: translateY(-20px) translateX(-50%) scale(0.97); + + // Open: vertically centred. + tc-pause-screen.tc-pause-screen--open & { + opacity: 1; + transform: translateY(-50%) translateX(-50%); + } +} + +// ── Header ───────────────────────────────────────────────────────────────────── + +.tc-pause-screen__header { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: var(--bs-pause-screen-padding); + padding-bottom: 1rem; + border-bottom: 1px solid var(--bs-pause-screen-border-color); + background: var(--tc-surface-muted); + flex-shrink: 0; +} + +.tc-pause-screen__eyebrow { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-pause-screen-eyebrow-size); + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--bs-pause-screen-eyebrow-color); + line-height: 1.4; +} + +.tc-pause-screen__title { + font-size: var(--bs-pause-screen-title-size); + font-weight: 600; + color: var(--bs-pause-screen-title-color); + margin: 0; + line-height: 1.25; +} + +// ── Items list ───────────────────────────────────────────────────────────────── + +.tc-pause-screen__items { + display: flex; + flex-direction: column; + flex: 1 1 auto; + overflow-y: auto; +} + +.tc-pause-screen__item { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0 var(--bs-pause-screen-padding); + min-height: var(--bs-pause-screen-item-min-height); + color: var(--bs-pause-screen-item-color); + border-bottom: 1px solid var(--bs-pause-screen-item-border); + cursor: pointer; + user-select: none; + transition: + background-color var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease); + + &:last-child { + border-bottom: none; + } + + &:hover:not(.tc-pause-screen__item--disabled), + &:focus-visible:not(.tc-pause-screen__item--disabled) { + background: var(--bs-pause-screen-item-hover-bg); + color: var(--bs-pause-screen-item-active-color); + outline: none; + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: -2px; + } + + &.tc-pause-screen__item--disabled { + color: var(--bs-pause-screen-item-disabled-color); + opacity: var(--bs-pause-screen-item-disabled-opacity); + cursor: not-allowed; + pointer-events: none; + } +} + +.tc-pause-screen__item-label { + flex: 1; + min-width: 0; + font-size: 0.9375rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tc-pause-screen__item-badge { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.125rem 0.375rem; + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: 0.6875rem; + font-weight: 600; + line-height: 1; + background: var(--bs-pause-screen-badge-bg); + color: var(--bs-pause-screen-badge-color); + border-radius: 0; +} + +// ── 44 px coarse touch targets ───────────────────────────────────────────────── + +@media (pointer: coarse) { + tc-pause-screen { + --bs-pause-screen-item-min-height: 44px; + } +} + +// ── Reduced motion — skip transform / opacity animations ────────────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-pause-screen__backdrop { + transition: none; + } + + .tc-pause-screen__panel { + transition: none; + opacity: 1; + transform: translateY(-50%) translateX(-50%); + } + + .tc-pause-screen__item { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index e20987b2..b16e816a 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -384,6 +384,12 @@ tc-pause-menu { display: block; } +// Full-screen pause overlay; the SCSS partial positions the backdrop and +// centred panel, but the host must not default to inline. +tc-pause-screen { + display: block; +} + // Fixed full-surface scrim; the SCSS partial flips it to a centred flex // container, but it must not default to inline. tc-blur-overlay { From 24ae8c6244e3bd54f0686216950bf260ccd91822 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 00:56:09 +0000 Subject: [PATCH 355/632] 350-perk-picker: Build the tc-perk-picker web component (PerkPicker game-components port) --- examples/public/web-components/SKILL.md | 78 +++++++++ .../src/web-components/PerkPickerDemo.tsx | 107 ++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/PerkPicker.ts | 159 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_perk-picker.scss | 156 +++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 507 insertions(+) create mode 100644 examples/src/web-components/PerkPickerDemo.tsx create mode 100644 web-components/src/PerkPicker.ts create mode 100644 web-components/style/components/_perk-picker.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 78bfba50..c694edd0 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -243,6 +243,7 @@ After `register()` you can author markup directly: - [tc-panel-header](#tc-panel-header) - [tc-particle-emitter](#tc-particle-emitter) - [tc-pause-menu](#tc-pause-menu) + - [tc-perk-picker](#tc-perk-picker) - [tc-pause-screen](#tc-pause-screen) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) @@ -22938,3 +22939,80 @@ Full-screen pause overlay with a backdrop, a mono eyebrow label, a custom title screen.addEventListener('tc-select', e => console.log('selected:', e.detail.id)) ``` + +--- + +### tc-perk-picker + +Grid of selectable perk cards with selected and locked states. Perks are supplied via the `perks` JS property; each card shows an optional icon tile, a name, and an optional description. Fires `tc-select` on click, Enter, or Space. The `columns` attribute controls the CSS grid column count. Locked perks are inert (reduced opacity, not focusable). Ported from the game-components `gc-perk-picker`, restyled to the toolcase design system (flat slate cards, hairline borders, sharp corners, ink fill for the selected card, lucide lock icon for locked entries). + +**Tag:** `tc-perk-picker` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `columns` | number | `3` | Number of grid columns; parsed as a positive integer | + +The host element automatically gains `role="listbox"`; each card is `role="option"`. + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `perks` | `Perk[]` | Perk entries to render; setting re-renders the grid | +| `columns` | number | Mirror of the `columns` attribute | +| `onSelect` | `((id: string) => void) \| null` | Optional callback fired alongside `tc-select` | + +Each `Perk`: + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Unique key; echoed in the event `detail` | +| `name` | string | Perk display name | +| `description` | string? | Short descriptive text shown below the name | +| `icon` | string? | A lucide icon name (kebab-case, e.g. `"shield"`) rendered as inline SVG; non-lucide strings appear as mono text glyphs | +| `selected` | boolean? | Pre-selects the card (ink fill); consumer is responsible for updating on `tc-select` | +| `locked` | boolean? | Renders a lock icon, reduces opacity, and makes the card inert | + +**Events** + +| Event | `detail` | Fired when | +|-------|----------|------------| +| `tc-select` | `{ id: string }` | A non-locked card is clicked, or Enter / Space is pressed on a focused card | + +**Slots:** none — all content is driven by the `perks` JS property. + +**Custom properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-perk-picker-gap` | `0.75rem` | Gap between cards | +| `--bs-perk-picker-columns` | `3` | Grid column count (set inline by the component) | +| `--bs-perk-picker-card-padding` | `0.875rem 0.75rem` | Inner padding of each card | +| `--bs-perk-picker-card-bg` | `var(--tc-surface)` | Card background | +| `--bs-perk-picker-card-border` | `var(--tc-border)` | Card hairline border | +| `--bs-perk-picker-card-hover-bg` | `var(--tc-surface-muted)` | Hover background | +| `--bs-perk-picker-selected-bg` | `var(--tc-app-accent)` | Selected card fill | +| `--bs-perk-picker-selected-color` | `#fff` | Selected card text colour | +| `--bs-perk-picker-icon-size` | `2.25rem` | Icon tile width and height | +| `--bs-perk-picker-icon-bg` | `var(--tc-surface-muted)` | Icon tile background | +| `--bs-perk-picker-disabled-opacity` | `0.45` | Opacity of locked cards | + +```html + + +``` diff --git a/examples/src/web-components/PerkPickerDemo.tsx b/examples/src/web-components/PerkPickerDemo.tsx new file mode 100644 index 00000000..1b33a13f --- /dev/null +++ b/examples/src/web-components/PerkPickerDemo.tsx @@ -0,0 +1,107 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const PERKS_BASIC = [ + { id: 'iron-will', name: 'Iron Will', description: 'Reduces incoming damage by 15%.', icon: 'shield', selected: true }, + { id: 'quickstep', name: 'Quickstep', description: 'Roll cooldown reduced by 20%.', icon: 'zap' }, + { id: 'bloodlust', name: 'Bloodlust', description: 'Crits restore 5 HP.', icon: 'heart-pulse' }, + { id: 'arcane', name: 'Arcane Surge', description: 'Mana regen +30%.', icon: 'sparkles' }, + { id: 'endure', name: 'Endure', description: 'Stamina drain reduced.', icon: 'timer' }, + { id: 'mastery', name: 'Mastery', description: 'Locked until level 30.', icon: 'star', locked: true }, +] + +const PERKS_EVENTS = [ + { id: 'swift', name: 'Swift', description: '+10% move speed.', icon: 'wind' }, + { id: 'fortify', name: 'Fortify', description: '+15% max HP.', icon: 'shield' }, + { id: 'focus', name: 'Focus', description: 'Reduces skill cooldown.', icon: 'crosshair' }, + { id: 'rally', name: 'Rally', description: 'Revive allies faster.', icon: 'users' }, +] + +const PerkPickerDemo: React.FC = () => { + const basicRef = useRef(null) + const twoColRef = useRef(null) + const eventsRef = useRef(null) + + const [selectedId, setSelectedId] = useState('swift') + const [log, setLog] = useState([]) + + useEffect(() => { + if (!basicRef.current) return + basicRef.current.perks = PERKS_BASIC + }, []) + + useEffect(() => { + if (!twoColRef.current) return + twoColRef.current.perks = PERKS_BASIC.slice(0, 4) + }, []) + + useEffect(() => { + const el = eventsRef.current + if (!el) return + el.perks = PERKS_EVENTS.map(p => ({ ...p, selected: p.id === selectedId })) + }, [selectedId]) + + useEffect(() => { + const el = eventsRef.current + if (!el) return + const handler = (e: any) => { + setSelectedId(e.detail.id) + setLog(l => [`tc-select — id: "${e.detail.id}"`, ...l].slice(0, 8)) + } + el.addEventListener('tc-select', handler) + return () => el.removeEventListener('tc-select', handler) + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="PerkPicker" + description="Grid of selectable perk cards with selected and locked states. Perks are supplied via the JS perks property. Fires tc-select on click or Enter/Space. The columns attribute sets the grid column count." + /> + +
    + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + Event log + {log.length === 0 ? ( + Click a perk card… + ) : ( +
      + {log.map((line, i) => ( +
    • {line}
    • + ))} +
    + )} +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default PerkPickerDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index a0a59906..1e1c1a94 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -244,6 +244,7 @@ import LootListDemo from './LootListDemo' import LootPopupDemo from './LootPopupDemo' import PauseMenuDemo from './PauseMenuDemo' import PauseScreenDemo from './PauseScreenDemo' +import PerkPickerDemo from './PerkPickerDemo' import LiveFeedDemo from './LiveFeedDemo' import LoginDemo from './LoginDemo' import MarkdownEditorDemo from './MarkdownEditorDemo' @@ -636,4 +637,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'page-indicator', category: 'Navigation', element: }, { key: 'panel', category: 'Components', element: }, { key: 'particle-emitter', category: 'Components', element: }, + { key: 'perk-picker', category: 'Components', element: }, ] diff --git a/web-components/src/PerkPicker.ts b/web-components/src/PerkPicker.ts new file mode 100644 index 00000000..b4281c1e --- /dev/null +++ b/web-components/src/PerkPicker.ts @@ -0,0 +1,159 @@ +import * as LucideIcons from 'lucide-static' +import { icon } from './icons' + +const TAG_NAME = 'tc-perk-picker' + +export interface Perk { + id: string + name: string + description?: string + icon?: string + selected?: boolean + locked?: boolean +} + +export interface PerkPickerEventMap { + 'tc-select': CustomEvent<{ id: string }> +} + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function lucideByName(name: string): string { + const pascal = name + .split('-') + .map(p => p.charAt(0).toUpperCase() + p.slice(1)) + .join('') + const svg = (LucideIcons as Record)[pascal] + if (!svg) return '' + return icon(svg) +} + +// Locked cards always show a lucide lock glyph — never emoji per design rules. +const lockIconHtml = lucideByName('lock') + +export class PerkPicker extends HTMLElement { + private _initialised = false + private _perks: Perk[] = [] + + onSelect: ((id: string) => void) | null = null + + static get observedAttributes(): string[] { + return ['columns'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.setAttribute('role', 'listbox') + this.render() + this.addEventListener('click', this._onClick) + this.addEventListener('keydown', this._onKeydown) + this._initialised = true + } + } + + disconnectedCallback(): void { + this.removeEventListener('click', this._onClick) + this.removeEventListener('keydown', this._onKeydown) + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get columns(): number { + const raw = this.getAttribute('columns') + if (raw == null) return 3 + const parsed = parseInt(raw, 10) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 3 + } + set columns(v: number) { + this.setAttribute('columns', String(v)) + } + + get perks(): Perk[] { + return this._perks.slice() + } + set perks(value: Perk[]) { + this._perks = Array.isArray(value) ? value.slice() : [] + if (this._initialised) this.render() + } + + private _perkFor(target: EventTarget | null): { el: HTMLElement; id: string } | null { + if (!(target instanceof HTMLElement)) return null + const el = target.closest('.tc-perk-picker-card') + if (!el || !this.contains(el)) return null + if (el.getAttribute('aria-disabled') === 'true') return null + const id = el.dataset.id + if (!id) return null + return { el, id } + } + + private _select(id: string): void { + this.dispatchEvent(new CustomEvent('tc-select', { bubbles: true, composed: true, detail: { id } })) + if (typeof this.onSelect === 'function') this.onSelect(id) + } + + private _onClick = (e: MouseEvent): void => { + const hit = this._perkFor(e.target) + if (!hit) return + this._select(hit.id) + } + + private _onKeydown = (e: KeyboardEvent): void => { + if (e.key !== 'Enter' && e.key !== ' ') return + const hit = this._perkFor(e.target) + if (!hit) return + e.preventDefault() + this._select(hit.id) + } + + private _iconMarkup(perk: Perk): string { + if (perk.locked) { + return `` + } + if (perk.icon) { + const lucideHtml = lucideByName(perk.icon) + if (lucideHtml) { + return `` + } + // Not a lucide name — render as a text glyph (e.g. short abbreviation or initials). + return `` + } + return '' + } + + private render(): void { + this.classList.add('tc-perk-picker') + this.style.setProperty('--bs-perk-picker-columns', String(this.columns)) + + const cardsMarkup = this._perks.map(p => { + const classes = ['tc-perk-picker-card'] + if (p.selected) classes.push('is-selected') + if (p.locked) classes.push('is-locked') + const descMarkup = p.description + ? `${esc(p.description)}` + : '' + return `
    + ${this._iconMarkup(p)} + ${esc(p.name)} + ${descMarkup} +
    ` + }).join('') + + this.innerHTML = `
    ${cardsMarkup}
    ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: PerkPicker + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index e656e3cf..8be71d92 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -319,3 +319,4 @@ export * from './Panel' export * from './ParticleEmitter' export * from './PauseMenu' export * from './PauseScreen' +export * from './PerkPicker' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 9e9c6550..410e445e 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -316,6 +316,7 @@ import { ParticleEmitter } from './ParticleEmitter' import { Panel, PanelHeader } from './Panel' import { PauseMenu } from './PauseMenu' import { PauseScreen } from './PauseScreen' +import { PerkPicker } from './PerkPicker' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -642,4 +643,5 @@ export function register(): void { customElements.define('tc-panel-header', PanelHeader) customElements.define('tc-pause-menu', PauseMenu) customElements.define('tc-pause-screen', PauseScreen) + customElements.define('tc-perk-picker', PerkPicker) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 6466388e..46032b00 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -312,3 +312,4 @@ @forward 'page-indicator'; @forward 'panel'; @forward 'particle-emitter'; +@forward 'perk-picker'; diff --git a/web-components/style/components/_perk-picker.scss b/web-components/style/components/_perk-picker.scss new file mode 100644 index 00000000..a9d2497a --- /dev/null +++ b/web-components/style/components/_perk-picker.scss @@ -0,0 +1,156 @@ +// tc-perk-picker — grid of selectable perk cards with selected and locked states. +// Ported from gc-perk-picker (game-components) but stripped of the fantasy chrome +// (gilded frames, glow fills, emoji icons): flat slate cards, hairline borders, +// sharp corners, ink fill for selected cards, and a lucide lock glyph for locked +// entries. All cosmetics flow through --bs-perk-picker-* custom properties. + +@use '../foundation/tokens' as *; + +tc-perk-picker { + --bs-perk-picker-gap: 0.75rem; + // columns is set inline by render() via style.setProperty; default shown here. + --bs-perk-picker-columns: 3; + + --bs-perk-picker-card-padding: 0.875rem 0.75rem; + --bs-perk-picker-card-bg: var(--tc-surface); + --bs-perk-picker-card-border: var(--tc-border); + --bs-perk-picker-card-hover-bg: var(--tc-surface-muted); + --bs-perk-picker-card-hover-border: var(--tc-border-strong); + + --bs-perk-picker-selected-bg: var(--tc-app-accent); + --bs-perk-picker-selected-border: var(--tc-app-accent); + --bs-perk-picker-selected-color: #fff; + --bs-perk-picker-selected-desc-color: rgba(255, 255, 255, 0.72); + + --bs-perk-picker-icon-size: 2.25rem; + --bs-perk-picker-icon-bg: var(--tc-surface-muted); + --bs-perk-picker-icon-color: var(--tc-text); + --bs-perk-picker-icon-svg-size: 1.125rem; + --bs-perk-picker-icon-selected-bg: rgba(255, 255, 255, 0.14); + --bs-perk-picker-icon-selected-color: #fff; + + --bs-perk-picker-name-size: 0.875rem; + --bs-perk-picker-name-color: var(--tc-text); + --bs-perk-picker-description-size: 0.75rem; + --bs-perk-picker-description-color: var(--tc-text-muted); + + --bs-perk-picker-disabled-opacity: 0.45; +} + +.tc-perk-picker-grid { + display: grid; + grid-template-columns: repeat(var(--bs-perk-picker-columns), 1fr); + gap: var(--bs-perk-picker-gap); +} + +.tc-perk-picker-card { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + min-height: 44px; + padding: var(--bs-perk-picker-card-padding); + background: var(--bs-perk-picker-card-bg); + border: 1px solid var(--bs-perk-picker-card-border); + border-radius: 0; + cursor: pointer; + user-select: none; + text-align: center; + -webkit-tap-highlight-color: transparent; + transition: + background-color var(--tc-transition-fast, 0.15s ease), + border-color var(--tc-transition-fast, 0.15s ease); + + @media (pointer: coarse) { + min-height: 56px; + } + + &:hover:not(.is-selected):not(.is-locked) { + background: var(--bs-perk-picker-card-hover-bg); + border-color: var(--bs-perk-picker-card-hover-border); + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + z-index: 1; + } + + // Selected: ink fill + white text. + &.is-selected { + background: var(--bs-perk-picker-selected-bg); + border-color: var(--bs-perk-picker-selected-border); + color: var(--bs-perk-picker-selected-color); + } + + // Locked: disabled appearance per the standard state ladder. + &.is-locked { + opacity: var(--bs-perk-picker-disabled-opacity); + cursor: default; + pointer-events: none; + } +} + +// Square icon tile — slate well at rest; semi-transparent white when selected. +.tc-perk-picker-card-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: var(--bs-perk-picker-icon-size); + height: var(--bs-perk-picker-icon-size); + background: var(--bs-perk-picker-icon-bg); + color: var(--bs-perk-picker-icon-color); + border-radius: 0; + + svg { + width: var(--bs-perk-picker-icon-svg-size); + height: var(--bs-perk-picker-icon-svg-size); + } + + // Text glyph variant (non-lucide icon string). + &--text { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: 1rem; + font-weight: 600; + line-height: 1; + } + + .is-selected & { + background: var(--bs-perk-picker-icon-selected-bg); + color: var(--bs-perk-picker-icon-selected-color); + } +} + +.tc-perk-picker-card-name { + font-size: var(--bs-perk-picker-name-size); + font-weight: 600; + line-height: 1.3; + color: var(--bs-perk-picker-name-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; + + .is-selected & { + color: var(--bs-perk-picker-selected-color); + } +} + +.tc-perk-picker-card-description { + font-size: var(--bs-perk-picker-description-size); + line-height: 1.45; + color: var(--bs-perk-picker-description-color); + text-align: center; + + .is-selected & { + color: var(--bs-perk-picker-selected-desc-color); + } +} + +@media (prefers-reduced-motion: reduce) { + .tc-perk-picker-card { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index b16e816a..c553ab92 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -345,6 +345,7 @@ tc-lore-text, tc-minimap, tc-mute-list, tc-particle-emitter, +tc-perk-picker, tc-roadmap { display: block; } From deb53fc976572742af20f6834006e49fcb5281bf Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 01:01:11 +0000 Subject: [PATCH 356/632] 351-ping-display: Build the tc-ping-display web component (PingDisplay game-components port) --- examples/public/web-components/SKILL.md | 70 ++++++++++++++++ .../src/web-components/PingDisplayDemo.tsx | 83 +++++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/PingDisplay.ts | 69 +++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_ping-display.scss | 53 ++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 282 insertions(+) create mode 100644 examples/src/web-components/PingDisplayDemo.tsx create mode 100644 web-components/src/PingDisplay.ts create mode 100644 web-components/style/components/_ping-display.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index c694edd0..a3fb9001 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -237,6 +237,7 @@ After `register()` you can author markup directly: - [tc-mute-list](#tc-mute-list) - [tc-matchmaking-screen](#tc-matchmaking-screen) - [tc-network-status-icon](#tc-network-status-icon) + - [tc-ping-display](#tc-ping-display) - [tc-objective-marker](#tc-objective-marker) - [tc-page-indicator](#tc-page-indicator) - [tc-panel](#tc-panel) @@ -22377,6 +22378,75 @@ None. All content is generated from attributes. ``` +### tc-ping-display + +Compact network-latency readout: a status pip square plus a JetBrains Mono millisecond value, colour-coded by tier. Port of `gc-ping-display` from `@toolcase/game-components`, restyled to the toolcase design system (slate neutrals, sharp corners, `--bs-ping-display-*` custom properties). No shadow root; light DOM; `display: inline-flex`. + +**Tag:** `tc-ping-display` + +#### Attributes + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `ping` | `number \| null` | `null` | Round-trip latency in milliseconds. Absent or empty renders an em-dash in the unknown tier colour. | + +#### JS Properties + +| Property | Type | Default | Description | +|---|---|---|---| +| `ping` | `number \| null` | `null` | Reflects the `ping` attribute. Set to `null` to remove. | + +#### Tier mapping + +| `data-tier` | Condition | Colour token | +|---|---|---| +| `success` | ping < 60 ms | `--tc-success` | +| `warning` | 60 ms ≤ ping < 200 ms | `--tc-warning` | +| `danger` | ping ≥ 200 ms | `--tc-danger` | +| `unknown` | ping absent / `null` | `--tc-text-faint` | + +#### Events + +None. `tc-ping-display` is a purely presentational readout with no user interaction. + +#### Slots + +None. All content is generated from the `ping` attribute. + +#### CSS Custom Properties + +| Property | Default | Description | +|---|---|---| +| `--bs-ping-display-gap` | `0.375rem` | Gap between the pip and the value label. | +| `--bs-ping-display-pip-size` | `6px` | Width and height of the status pip square. | +| `--bs-ping-display-font-size` | `0.6875rem` | Font size of the value label. | +| `--bs-ping-display-letter-spacing` | `0.05em` | Letter-spacing of the value label. | +| `--bs-ping-display-font-weight` | `500` | Font weight of the value label. | +| `--bs-ping-display-color-unknown` | `var(--tc-text-faint)` | Pip and text colour for the `unknown` tier. | +| `--bs-ping-display-color-success` | `var(--tc-success)` | Pip and text colour for the `success` tier. | +| `--bs-ping-display-color-warning` | `var(--tc-warning)` | Pip and text colour for the `warning` tier. | +| `--bs-ping-display-color-danger` | `var(--tc-danger)` | Pip and text colour for the `danger` tier. | + +#### Example + +```html + + + + + + + + + + + + +``` + ### tc-objective-marker Absolutely-positioned world-space marker with a map-pin glyph, optional label, and formatted distance readout (metres / kilometres). Drop it inside a `position: relative` container and set `x`/`y` to world coordinates. The element is `position: absolute` and transforms to pin the glyph tip at the target point. No shadow root; light DOM; `display: inline-flex`. diff --git a/examples/src/web-components/PingDisplayDemo.tsx b/examples/src/web-components/PingDisplayDemo.tsx new file mode 100644 index 00000000..e401ea8b --- /dev/null +++ b/examples/src/web-components/PingDisplayDemo.tsx @@ -0,0 +1,83 @@ +import React, { useEffect, useRef } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const PingDisplayDemo: React.FC = () => { + const liveRef = useRef(null) + + useEffect(() => { + const el = liveRef.current + if (!el) return + + let direction = 1 + let ping = 20 + const id = setInterval(() => { + ping += direction * 25 + if (ping >= 350) { ping = 350; direction = -1 } + if (ping <= 20) { ping = 20; direction = 1 } + el.ping = ping + }, 700) + + return () => clearInterval(id) + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="PingDisplay" + description="Compact latency readout. Tiers: success (<60 ms), warning (<200 ms), danger (≥200 ms), unknown (no ping attribute). Renders a status pip and a JetBrains Mono millisecond value." + /> + +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + + No ping attribute — renders an em-dash in the unknown tier colour + +
    +
    + + +
    + {/* @ts-ignore */} + + + Animates 20–350 ms every 700 ms via el.ping = value + +
    +
    + +
    +
    +
    +
    +
    + ) +} + +export default PingDisplayDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 1e1c1a94..018f9e13 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -305,6 +305,7 @@ import LoreTextDemo from './LoreTextDemo' import MinimapDemo from './MinimapDemo' import MuteListDemo from './MuteListDemo' import NetworkStatusIconDemo from './NetworkStatusIconDemo' +import PingDisplayDemo from './PingDisplayDemo' import ObjectiveMarkerDemo from './ObjectiveMarkerDemo' import PageIndicatorDemo from './PageIndicatorDemo' import PanelDemo from './PanelDemo' @@ -633,6 +634,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'minimap', category: 'Components', element: }, { key: 'mute-list', category: 'Components', element: }, { key: 'network-status-icon', category: 'Components', element: }, + { key: 'ping-display', category: 'Components', element: }, { key: 'objective-marker', category: 'Components', element: }, { key: 'page-indicator', category: 'Navigation', element: }, { key: 'panel', category: 'Components', element: }, diff --git a/web-components/src/PingDisplay.ts b/web-components/src/PingDisplay.ts new file mode 100644 index 00000000..6a243657 --- /dev/null +++ b/web-components/src/PingDisplay.ts @@ -0,0 +1,69 @@ +const TAG_NAME = 'tc-ping-display' + +export type PingTier = 'unknown' | 'success' | 'warning' | 'danger' + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +export class PingDisplay extends HTMLElement { + private _initialised = false + + static get observedAttributes(): string[] { + return ['ping'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get ping(): number | null { + const raw = this.getAttribute('ping') + if (raw == null || raw === '') return null + const parsed = parseFloat(raw) + return Number.isNaN(parsed) ? null : parsed + } + set ping(value: number | null) { + if (value == null) this.removeAttribute('ping') + else this.setAttribute('ping', String(value)) + } + + private _classify(value: number | null): PingTier { + if (value == null) return 'unknown' + if (value < 60) return 'success' + if (value < 200) return 'warning' + return 'danger' + } + + private render(): void { + const value = this.ping + const tier = this._classify(value) + + this.dataset.tier = tier + + const text = value == null ? '—' : `${Math.round(value)} ms` + + this.innerHTML = ` + + ${esc(text)} + ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: PingDisplay + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 8be71d92..9be8d300 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -311,6 +311,7 @@ export * from './LoreText' export * from './MetalButton' export * from './NavButton' export * from './NetworkStatusIcon' +export * from './PingDisplay' export * from './Minimap' export * from './MuteList' export * from './ObjectiveMarker' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 410e445e..711aa8b7 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -308,6 +308,7 @@ import { LoreText } from './LoreText' import { MetalButton } from './MetalButton' import { NavButton } from './NavButton' import { NetworkStatusIcon } from './NetworkStatusIcon' +import { PingDisplay } from './PingDisplay' import { Minimap } from './Minimap' import { MuteList } from './MuteList' import { ObjectiveMarker } from './ObjectiveMarker' @@ -634,6 +635,7 @@ export function register(): void { customElements.define('tc-metal-button', MetalButton) customElements.define('tc-nav-button', NavButton) customElements.define('tc-network-status-icon', NetworkStatusIcon) + customElements.define('tc-ping-display', PingDisplay) customElements.define('tc-minimap', Minimap) customElements.define('tc-mute-list', MuteList) customElements.define('tc-objective-marker', ObjectiveMarker) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 46032b00..7cf43aff 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -313,3 +313,4 @@ @forward 'panel'; @forward 'particle-emitter'; @forward 'perk-picker'; +@forward 'ping-display'; diff --git a/web-components/style/components/_ping-display.scss b/web-components/style/components/_ping-display.scss new file mode 100644 index 00000000..3fae38a2 --- /dev/null +++ b/web-components/style/components/_ping-display.scss @@ -0,0 +1,53 @@ +// tc-ping-display — compact latency readout. +// A status pip (filled square) paired with a JetBrains Mono millisecond value. +// Four tiers: success (<60 ms), warning (<200 ms), danger (≥200 ms), unknown (no ping). +// All cosmetics flow through --bs-ping-display-* custom properties. + +tc-ping-display { + --bs-ping-display-gap: 0.375rem; + --bs-ping-display-pip-size: 6px; + --bs-ping-display-font-size: 0.6875rem; + --bs-ping-display-letter-spacing: 0.05em; + --bs-ping-display-font-weight: 500; + // Tier colours + --bs-ping-display-color-unknown: var(--tc-text-faint, #94a3b8); + --bs-ping-display-color-success: var(--tc-success, #22c55e); + --bs-ping-display-color-warning: var(--tc-warning, #f59e0b); + --bs-ping-display-color-danger: var(--tc-danger, #ef4444); + // Active tier colour resolved by data-tier attribute rules below + --bs-ping-display-color: var(--bs-ping-display-color-unknown); + + display: inline-flex; + align-items: center; + gap: var(--bs-ping-display-gap); + line-height: 1; +} + +// ── Tier colour mapping ──────────────────────────────────────────────────────── + +tc-ping-display[data-tier="success"] { --bs-ping-display-color: var(--bs-ping-display-color-success); } +tc-ping-display[data-tier="warning"] { --bs-ping-display-color: var(--bs-ping-display-color-warning); } +tc-ping-display[data-tier="danger"] { --bs-ping-display-color: var(--bs-ping-display-color-danger); } +tc-ping-display[data-tier="unknown"] { --bs-ping-display-color: var(--bs-ping-display-color-unknown); } + +// ── Pip ─────────────────────────────────────────────────────────────────────── + +.tc-ping-display-pip { + display: inline-block; + flex-shrink: 0; + width: var(--bs-ping-display-pip-size); + height: var(--bs-ping-display-pip-size); + background-color: var(--bs-ping-display-color); + border-radius: 0; +} + +// ── Value label ─────────────────────────────────────────────────────────────── + +.tc-ping-display-value { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-ping-display-font-size); + font-weight: var(--bs-ping-display-font-weight); + letter-spacing: var(--bs-ping-display-letter-spacing); + color: var(--bs-ping-display-color); + white-space: nowrap; +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index c553ab92..b46c365b 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -497,6 +497,7 @@ tc-kbd, tc-key, tc-key-binder, tc-network-status-icon, +tc-ping-display, tc-objective-marker, tc-page-indicator, tc-rating, From 2eb238284a766629048a7690bf2dfad5f5a02f02 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 01:07:50 +0000 Subject: [PATCH 357/632] 352-platform-icon: Build the tc-platform-icon web component (PlatformIcon game-components port) --- examples/public/web-components/SKILL.md | 86 ++++++++++++ .../src/web-components/PlatformIconDemo.tsx | 79 +++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/PlatformIcon.ts | 124 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_platform-icon.scss | 57 ++++++++ web-components/style/foundation/_reset.scss | 3 +- 9 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 examples/src/web-components/PlatformIconDemo.tsx create mode 100644 web-components/src/PlatformIcon.ts create mode 100644 web-components/style/components/_platform-icon.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index a3fb9001..fd0d3e89 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -238,6 +238,7 @@ After `register()` you can author markup directly: - [tc-matchmaking-screen](#tc-matchmaking-screen) - [tc-network-status-icon](#tc-network-status-icon) - [tc-ping-display](#tc-ping-display) + - [tc-platform-icon](#tc-platform-icon) - [tc-objective-marker](#tc-objective-marker) - [tc-page-indicator](#tc-page-indicator) - [tc-panel](#tc-panel) @@ -22447,6 +22448,91 @@ None. All content is generated from the `ping` attribute. ``` +### tc-platform-icon + +Inline platform badge combining a Lucide glyph and an optional JetBrains Mono label. Covers PC, PlayStation, Xbox, Nintendo, Steam, Mobile, and Web. No shadow root; light DOM; `display: inline-flex`. Purely presentational — no events, no slots. + +**Tag:** `tc-platform-icon` + +#### Attributes + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `platform` | `'pc' \| 'playstation' \| 'xbox' \| 'nintendo' \| 'steam' \| 'mobile' \| 'web'` | `'pc'` | Which platform to display. Unknown values fall back to `'pc'`. | +| `size` | `number \| null` | `null` | Glyph size in pixels. When absent the glyph inherits its size from `--bs-platform-icon-size` (default `1rem`). | +| `label` | `boolean` | `false` | Boolean presence attribute. When present, renders the platform name beside the glyph. | + +#### JS Properties + +| Property | Type | Default | Description | +|---|---|---|---| +| `platform` | `Platform` | `'pc'` | Reflects the `platform` attribute. | +| `size` | `number \| null` | `null` | Reflects the `size` attribute. Set to `null` to remove. | +| `label` | `boolean` | `false` | Reflects the `label` boolean attribute. | + +#### Platform map + +| `platform` value | Lucide icon | Displayed label | +|---|---|---| +| `pc` | `Monitor` | PC | +| `playstation` | `Gamepad2` | PlayStation | +| `xbox` | `Gamepad` | Xbox | +| `nintendo` | `Disc` | Nintendo | +| `steam` | `Cog` | Steam | +| `mobile` | `Smartphone` | Mobile | +| `web` | `Globe` | Web | + +#### Events + +None. `tc-platform-icon` is a purely presentational component with no user interaction. + +#### Slots + +None. All content is generated from attributes. + +#### CSS Custom Properties + +| Property | Default | Description | +|---|---|---| +| `--bs-platform-icon-size` | `1rem` | Glyph width and height. Overridden by the `size` attribute inline style when set. | +| `--bs-platform-icon-gap` | `0.375rem` | Gap between glyph and label. | +| `--bs-platform-icon-glyph-color` | `var(--tc-text-muted)` | Glyph stroke colour. | +| `--bs-platform-icon-label-color` | `var(--tc-text-muted)` | Label text colour. | +| `--bs-platform-icon-label-font-size` | `0.8125rem` | Label font size. | +| `--bs-platform-icon-label-font-family` | `var(--tc-font-mono)` | Label font family (JetBrains Mono). | + +#### Example + +```html + + + + + + + + + + + + + + + + + + + + + +``` + +--- + ### tc-objective-marker Absolutely-positioned world-space marker with a map-pin glyph, optional label, and formatted distance readout (metres / kilometres). Drop it inside a `position: relative` container and set `x`/`y` to world coordinates. The element is `position: absolute` and transforms to pin the glyph tip at the target point. No shadow root; light DOM; `display: inline-flex`. diff --git a/examples/src/web-components/PlatformIconDemo.tsx b/examples/src/web-components/PlatformIconDemo.tsx new file mode 100644 index 00000000..50faa980 --- /dev/null +++ b/examples/src/web-components/PlatformIconDemo.tsx @@ -0,0 +1,79 @@ +import React, { useEffect, useRef } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const ALL_PLATFORMS = ['pc', 'playstation', 'xbox', 'nintendo', 'steam', 'mobile', 'web'] as const + +const PlatformIconDemo: React.FC = () => { + const sizeRef = useRef(null) + + useEffect(() => { + const el = sizeRef.current + if (!el) return + el.platform = 'steam' + el.size = 28 + el.label = true + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="PlatformIcon" + description="Inline platform badge combining a Lucide glyph and an optional label. Attributes: platform, size, label." + /> + +
    + + +
    + {ALL_PLATFORMS.map(p => ( + /* @ts-ignore */ + + ))} +
    +
    + + +
    + {ALL_PLATFORMS.map(p => ( + /* @ts-ignore */ + + ))} +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + + {/* @ts-ignore */} + +

    + Set via el.platform, el.size, el.label in useEffect. +

    +
    + +
    +
    +
    +
    +
    + ) +} + +export default PlatformIconDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 018f9e13..4ed6e021 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -306,6 +306,7 @@ import MinimapDemo from './MinimapDemo' import MuteListDemo from './MuteListDemo' import NetworkStatusIconDemo from './NetworkStatusIconDemo' import PingDisplayDemo from './PingDisplayDemo' +import PlatformIconDemo from './PlatformIconDemo' import ObjectiveMarkerDemo from './ObjectiveMarkerDemo' import PageIndicatorDemo from './PageIndicatorDemo' import PanelDemo from './PanelDemo' @@ -634,6 +635,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'minimap', category: 'Components', element: }, { key: 'mute-list', category: 'Components', element: }, { key: 'network-status-icon', category: 'Components', element: }, + { key: 'platform-icon', category: 'Components', element: }, { key: 'ping-display', category: 'Components', element: }, { key: 'objective-marker', category: 'Components', element: }, { key: 'page-indicator', category: 'Navigation', element: }, diff --git a/web-components/src/PlatformIcon.ts b/web-components/src/PlatformIcon.ts new file mode 100644 index 00000000..7a879caa --- /dev/null +++ b/web-components/src/PlatformIcon.ts @@ -0,0 +1,124 @@ +import * as LucideIcons from 'lucide-static' +import { icon } from './icons' + +const TAG_NAME = 'tc-platform-icon' + +export type Platform = 'pc' | 'playstation' | 'xbox' | 'nintendo' | 'steam' | 'mobile' | 'web' + +const PLATFORMS: Platform[] = ['pc', 'playstation', 'xbox', 'nintendo', 'steam', 'mobile', 'web'] + +const PLATFORM_ICON: Record = { + pc: 'monitor', + playstation: 'gamepad-2', + xbox: 'gamepad', + nintendo: 'disc', + steam: 'cog', + mobile: 'smartphone', + web: 'globe', +} + +const PLATFORM_LABEL: Record = { + pc: 'PC', + playstation: 'PlayStation', + xbox: 'Xbox', + nintendo: 'Nintendo', + steam: 'Steam', + mobile: 'Mobile', + web: 'Web', +} + +function lucideByName(name: string): string { + const pascal = name + .split('-') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') + const svgStr = (LucideIcons as Record)[pascal] + if (!svgStr) return '' + return icon(svgStr) +} + +function esc(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +export class PlatformIcon extends HTMLElement { + private _initialised = false + + static get observedAttributes(): string[] { + return ['platform', 'size', 'label'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get platform(): Platform { + const v = this.getAttribute('platform') as Platform + return PLATFORMS.includes(v) ? v : 'pc' + } + set platform(v: Platform) { + this.setAttribute('platform', v) + } + + get size(): number | null { + const raw = this.getAttribute('size') + if (raw == null) return null + const parsed = parseFloat(raw) + return Number.isNaN(parsed) || parsed <= 0 ? null : parsed + } + set size(v: number | null) { + if (v == null) this.removeAttribute('size') + else this.setAttribute('size', String(v)) + } + + get label(): boolean { + return this.hasAttribute('label') + } + set label(v: boolean) { + if (v) this.setAttribute('label', '') + else this.removeAttribute('label') + } + + private render(): void { + const size = this.size + if (size != null) { + this.style.setProperty('--bs-platform-icon-size', `${size}px`) + } else { + this.style.removeProperty('--bs-platform-icon-size') + } + + const platform = this.platform + const iconName = PLATFORM_ICON[platform] + const iconHtml = lucideByName(iconName) + const labelText = PLATFORM_LABEL[platform] + + this.setAttribute('role', 'img') + this.setAttribute('aria-label', labelText) + + const labelHtml = this.label + ? `${esc(labelText)}` + : '' + + this.innerHTML = + `` + + labelHtml + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: PlatformIcon + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 9be8d300..277aab21 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -311,6 +311,7 @@ export * from './LoreText' export * from './MetalButton' export * from './NavButton' export * from './NetworkStatusIcon' +export * from './PlatformIcon' export * from './PingDisplay' export * from './Minimap' export * from './MuteList' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 711aa8b7..d19feec3 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -308,6 +308,7 @@ import { LoreText } from './LoreText' import { MetalButton } from './MetalButton' import { NavButton } from './NavButton' import { NetworkStatusIcon } from './NetworkStatusIcon' +import { PlatformIcon } from './PlatformIcon' import { PingDisplay } from './PingDisplay' import { Minimap } from './Minimap' import { MuteList } from './MuteList' @@ -635,6 +636,7 @@ export function register(): void { customElements.define('tc-metal-button', MetalButton) customElements.define('tc-nav-button', NavButton) customElements.define('tc-network-status-icon', NetworkStatusIcon) + customElements.define('tc-platform-icon', PlatformIcon) customElements.define('tc-ping-display', PingDisplay) customElements.define('tc-minimap', Minimap) customElements.define('tc-mute-list', MuteList) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 7cf43aff..e8756c35 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -255,6 +255,7 @@ @forward 'metal-button'; @forward 'nav-button'; @forward 'network-status-icon'; +@forward 'platform-icon'; @forward 'markdown-editor'; @forward 'marquee'; @forward 'multi-card-select'; diff --git a/web-components/style/components/_platform-icon.scss b/web-components/style/components/_platform-icon.scss new file mode 100644 index 00000000..46b913f2 --- /dev/null +++ b/web-components/style/components/_platform-icon.scss @@ -0,0 +1,57 @@ +// tc-platform-icon — platform badge: a Lucide glyph with an optional label. +// Purely presentational; no shadow root; light DOM; display: inline-flex. +// All cosmetics flow through --bs-platform-icon-* custom properties. + +tc-platform-icon { + --bs-platform-icon-size: 1rem; + --bs-platform-icon-gap: 0.375rem; + --bs-platform-icon-glyph-color: var(--tc-text-muted, #94a3b8); + --bs-platform-icon-label-color: var(--tc-text-muted, #94a3b8); + --bs-platform-icon-label-font-size: 0.8125rem; + --bs-platform-icon-label-font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + + display: inline-flex; + align-items: center; + gap: var(--bs-platform-icon-gap); + line-height: 1; +} + +// ── Glyph ────────────────────────────────────────────────────────────────────── + +.tc-platform-icon__glyph { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--bs-platform-icon-glyph-color); + + svg { + width: var(--bs-platform-icon-size); + height: var(--bs-platform-icon-size); + stroke: currentColor; + fill: none; + stroke-width: 2; + stroke-linecap: square; + stroke-linejoin: miter; + } +} + +// ── Label ────────────────────────────────────────────────────────────────────── + +.tc-platform-icon__label { + font-family: var(--bs-platform-icon-label-font-family); + font-size: var(--bs-platform-icon-label-font-size); + font-weight: 500; + letter-spacing: 0.03em; + color: var(--bs-platform-icon-label-color); + white-space: nowrap; + line-height: 1; +} + +// ── Reduced motion ───────────────────────────────────────────────────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-platform-icon__glyph svg { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index b46c365b..196a95ff 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -507,7 +507,8 @@ tc-damage-number, tc-hit-marker, tc-buff-icon, tc-gamepad-button-prompt, -tc-interact-prompt { +tc-interact-prompt, +tc-platform-icon { display: inline-flex; } From 32f44bedd22820b22be72ffe28c051c0d8a7c324 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 01:15:26 +0000 Subject: [PATCH 358/632] 353-player-card: Build the tc-player-card web component (PlayerCard game-components port) --- examples/public/web-components/SKILL.md | 119 +++++++ .../src/web-components/PlayerCardDemo.tsx | 142 +++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/PlayerCard.ts | 210 +++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_player-card.scss | 290 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 768 insertions(+) create mode 100644 examples/src/web-components/PlayerCardDemo.tsx create mode 100644 web-components/src/PlayerCard.ts create mode 100644 web-components/style/components/_player-card.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index fd0d3e89..86527194 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -235,6 +235,7 @@ After `register()` you can author markup directly: - [tc-metal-button](#tc-metal-button) - [tc-minimap](#tc-minimap) - [tc-mute-list](#tc-mute-list) + - [tc-player-card](#tc-player-card) - [tc-matchmaking-screen](#tc-matchmaking-screen) - [tc-network-status-icon](#tc-network-status-icon) - [tc-ping-display](#tc-ping-display) @@ -22297,6 +22298,124 @@ None. `tc-mute-list` is property-driven; all content is generated. ``` +### tc-player-card + +Player summary card showing a player name, optional title, presence status pip, rank badge, level, a stats grid, and action buttons. Port of `gc-player-card` (game-components), restyled to the toolcase design system: slate neutrals, sharp corners (`border-radius: 0`), hairline borders, JetBrains Mono for all machine-facing text, and a sanctioned circle for the status pip. All game-specific chrome (gilded frames, glows, metal textures) is dropped. No shadow root; light DOM; `display: block`. + +**Tag:** `tc-player-card` + +#### Attributes + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `player-name` | `string` | `''` | Player display name shown in the header. | +| `card-title` | `string` | — | Optional sub-label below the name (e.g. a guild title or in-game rank title). | +| `rank` | `string` | — | Optional rank badge text (e.g. `"Diamond III"`). Shown as a mono uppercase tag in the meta row. | +| `level` | `number` | — | Optional player level displayed as `Lv N` next to the rank badge. | +| `online-status` | `'online' \| 'away' \| 'busy' \| 'in-game' \| 'offline'` | `'offline'` | Presence status. Controls the pip color and the status label text. | + +#### JS Properties + +| Property | Type | Default | Description | +|---|---|---|---| +| `playerName` | `string` | `''` | Reflects the `player-name` attribute. | +| `cardTitle` | `string` | `''` | Reflects the `card-title` attribute. | +| `rank` | `string` | `''` | Reflects the `rank` attribute. | +| `level` | `number \| null` | `null` | Reflects the `level` attribute. Set to `null` to remove. | +| `onlineStatus` | `PresenceStatus` | `'offline'` | Reflects the `online-status` attribute. | +| `stats` | `PlayerCardStat[]` | `[]` | Array of stat objects. Setting this property triggers a re-render. | +| `actions` | `PlayerCardAction[]` | `[]` | Array of action button descriptors. Setting this property triggers a re-render. | +| `onAction` | `((id: string) => void) \| null` | `null` | Optional callback — called in addition to the `tc-action` event when an action button is clicked. | + +**`PlayerCardStat` shape** + +| Field | Type | Required | Description | +|---|---|---|---| +| `label` | `string` | yes | Short stat label (e.g. `"KDR"`, `"Wins"`). Displayed as a mono uppercase micro-label. | +| `value` | `string \| number` | yes | Stat value. Numbers are formatted with `toLocaleString()`. | + +**`PlayerCardAction` shape** + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | `string` | yes | Unique action identifier forwarded in the `tc-action` event detail. | +| `label` | `string` | yes | Button label text. | +| `danger` | `boolean` | no | When `true`, renders the button in the danger variant (red text / red fill on hover). | + +#### Events + +| Event | Detail | Description | +|---|---|---| +| `tc-action` | `{ id: string }` | Fired when an action button is clicked. `id` is the `PlayerCardAction.id` of the clicked button. Bubbles and is composed. | + +#### Slots + +None. `tc-player-card` is attribute- and property-driven; all content is generated. + +#### CSS Custom Properties + +| Property | Default | Description | +|---|---|---| +| `--bs-player-card-bg` | `var(--tc-surface)` | Card background. | +| `--bs-player-card-border` | `var(--tc-border)` | 1 px hairline border around the card. | +| `--bs-player-card-header-bg` | `var(--tc-surface-muted)` | Header region background. | +| `--bs-player-card-header-border` | `var(--tc-border)` | Bottom hairline on the header. | +| `--bs-player-card-name-color` | `var(--tc-text)` | Player name text colour. | +| `--bs-player-card-title-color` | `var(--tc-text-muted)` | Card title sub-label colour. | +| `--bs-player-card-pip-online` | `var(--tc-success, #22c55e)` | Pip colour for `online` status. | +| `--bs-player-card-pip-away` | `var(--tc-warning, #f59e0b)` | Pip colour for `away` status. | +| `--bs-player-card-pip-busy` | `var(--tc-danger, #ef4444)` | Pip colour for `busy` status. | +| `--bs-player-card-pip-in-game` | `var(--tc-app-accent)` | Pip colour for `in-game` status. | +| `--bs-player-card-pip-offline` | `var(--tc-text-faint)` | Pip colour for `offline` status. | +| `--bs-player-card-status-color` | `var(--tc-text-muted)` | Status label text colour. | +| `--bs-player-card-rank-bg` | `var(--tc-surface-muted)` | Rank badge background. | +| `--bs-player-card-rank-color` | `var(--tc-text-muted)` | Rank badge text colour. | +| `--bs-player-card-level-color` | `var(--tc-text-muted)` | Level label colour. | +| `--bs-player-card-stat-label-color` | `var(--tc-text-faint)` | Stat micro-label colour. | +| `--bs-player-card-stat-value-color` | `var(--tc-text)` | Stat value colour. | +| `--bs-player-card-btn-color` | `var(--tc-text)` | Action button text colour. | +| `--bs-player-card-btn-bg` | `var(--tc-surface)` | Action button background. | +| `--bs-player-card-btn-border` | `var(--tc-border)` | Action button border colour. | +| `--bs-player-card-btn-hover-bg` | `var(--tc-surface-hover)` | Action button hover background. | +| `--bs-player-card-btn-danger-color` | `var(--tc-danger, #ef4444)` | Danger-variant button text colour at rest. | +| `--bs-player-card-btn-danger-hover-bg` | `var(--tc-danger, #ef4444)` | Danger-variant button background on hover. | +| `--bs-player-card-btn-min-height` | `2rem` | Action button minimum height (44 px under coarse pointer). | + +#### Example + +```html + + + + +``` + +--- + ### tc-network-status-icon 4-bar signal-strength indicator for connectivity / network quality. Bar count and tier are computed from ping latency and packet loss; the optional label shows the ping value or offline state. No shadow root; light DOM; `display: inline-flex`. diff --git a/examples/src/web-components/PlayerCardDemo.tsx b/examples/src/web-components/PlayerCardDemo.tsx new file mode 100644 index 00000000..868e48e8 --- /dev/null +++ b/examples/src/web-components/PlayerCardDemo.tsx @@ -0,0 +1,142 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const PlayerCardDemo: React.FC = () => { + const statsRef = useRef(null) + const actionsRef = useRef(null) + const eventsRef = useRef(null) + + const [log, setLog] = useState([]) + + useEffect(() => { + if (!statsRef.current) return + statsRef.current.stats = [ + { label: 'KDR', value: 1.84 }, + { label: 'Wins', value: 312 }, + { label: 'Hours', value: 1470 }, + { label: 'Rank Pts', value: 4820 }, + ] + }, []) + + useEffect(() => { + if (!actionsRef.current) return + actionsRef.current.stats = [ + { label: 'KDR', value: 2.1 }, + { label: 'Wins', value: 88 }, + ] + actionsRef.current.actions = [ + { id: 'add-friend', label: 'Add Friend' }, + { id: 'invite', label: 'Invite to Party' }, + { id: 'block', label: 'Block', danger: true }, + ] + }, []) + + useEffect(() => { + const el = eventsRef.current + if (!el) return + el.stats = [{ label: 'Wins', value: 42 }] + el.actions = [ + { id: 'challenge', label: 'Challenge' }, + { id: 'report', label: 'Report', danger: true }, + ] + const onAction = (e: CustomEvent<{ id: string }>) => { + setLog(l => [`tc-action fired: id="${e.detail.id}"`, ...l].slice(0, 6)) + } + el.addEventListener('tc-action', onAction) + return () => el.removeEventListener('tc-action', onAction) + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="PlayerCard" + description="Player summary card with name, optional title, rank, level, online-status pip, a stats grid, and action buttons. Stats and actions are set via JS properties; all other data flows through attributes." + /> + +
    + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + +
    + Event log + {log.length === 0 ? ( + Click an action button… + ) : ( +
      + {log.map((line, i) => ( +
    • {line}
    • + ))} +
    + )} +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default PlayerCardDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 4ed6e021..64b709bf 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -304,6 +304,7 @@ import LoadingScreenDemo from './LoadingScreenDemo' import LoreTextDemo from './LoreTextDemo' import MinimapDemo from './MinimapDemo' import MuteListDemo from './MuteListDemo' +import PlayerCardDemo from './PlayerCardDemo' import NetworkStatusIconDemo from './NetworkStatusIconDemo' import PingDisplayDemo from './PingDisplayDemo' import PlatformIconDemo from './PlatformIconDemo' @@ -634,6 +635,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'lore-text', category: 'Content', element: }, { key: 'minimap', category: 'Components', element: }, { key: 'mute-list', category: 'Components', element: }, + { key: 'player-card', category: 'Components', element: }, { key: 'network-status-icon', category: 'Components', element: }, { key: 'platform-icon', category: 'Components', element: }, { key: 'ping-display', category: 'Components', element: }, diff --git a/web-components/src/PlayerCard.ts b/web-components/src/PlayerCard.ts new file mode 100644 index 00000000..41232a99 --- /dev/null +++ b/web-components/src/PlayerCard.ts @@ -0,0 +1,210 @@ +const TAG_NAME = 'tc-player-card' + +export type PresenceStatus = 'online' | 'away' | 'busy' | 'offline' | 'in-game' +const PRESENCE_STATUSES: PresenceStatus[] = ['online', 'away', 'busy', 'offline', 'in-game'] + +export interface PlayerCardStat { + label: string + value: string | number +} + +export interface PlayerCardAction { + id: string + label: string + danger?: boolean +} + +export interface PlayerCardEventMap { + 'tc-action': CustomEvent<{ id: string }> +} + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function statusLabel(status: PresenceStatus): string { + switch (status) { + case 'online': return 'Online' + case 'away': return 'Away' + case 'busy': return 'Busy' + case 'in-game': return 'In Game' + case 'offline': + default: return 'Offline' + } +} + +export class PlayerCard extends HTMLElement { + + private _initialised = false + private _stats: PlayerCardStat[] = [] + private _actions: PlayerCardAction[] = [] + + onAction: ((id: string) => void) | null = null + + static get observedAttributes(): string[] { + return ['player-name', 'card-title', 'rank', 'level', 'online-status'] + } + + connectedCallback(): void { + if (!this._initialised) { + if (!this.hasAttribute('role')) this.setAttribute('role', 'group') + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get playerName(): string { + return this.getAttribute('player-name') ?? '' + } + set playerName(v: string) { + if (v) this.setAttribute('player-name', v) + else this.removeAttribute('player-name') + } + + get cardTitle(): string { + return this.getAttribute('card-title') ?? '' + } + set cardTitle(v: string) { + if (v) this.setAttribute('card-title', v) + else this.removeAttribute('card-title') + } + + get rank(): string { + return this.getAttribute('rank') ?? '' + } + set rank(v: string) { + if (v) this.setAttribute('rank', v) + else this.removeAttribute('rank') + } + + get level(): number | null { + const raw = this.getAttribute('level') + if (raw == null) return null + const parsed = parseInt(raw, 10) + return Number.isFinite(parsed) ? parsed : null + } + set level(v: number | null) { + if (v == null) this.removeAttribute('level') + else this.setAttribute('level', String(v)) + } + + get onlineStatus(): PresenceStatus { + const raw = this.getAttribute('online-status') as PresenceStatus + return PRESENCE_STATUSES.includes(raw) ? raw : 'offline' + } + set onlineStatus(v: PresenceStatus) { + if (v) this.setAttribute('online-status', v) + else this.removeAttribute('online-status') + } + + get stats(): PlayerCardStat[] { + return this._stats.slice() + } + set stats(v: PlayerCardStat[]) { + this._stats = Array.isArray(v) ? v.slice() : [] + if (this._initialised) this.render() + } + + get actions(): PlayerCardAction[] { + return this._actions.slice() + } + set actions(v: PlayerCardAction[]) { + this._actions = Array.isArray(v) ? v.slice() : [] + if (this._initialised) this.render() + } + + private _emit(id: string): void { + this.dispatchEvent(new CustomEvent('tc-action', { + bubbles: true, + composed: true, + detail: { id }, + })) + if (typeof this.onAction === 'function') this.onAction(id) + } + + private render(): void { + const status = this.onlineStatus + const level = this.level + const rank = this.rank + const cardTitle = this.cardTitle + + // ── Meta row (rank + level) ────────────────────────────────────────── + const metaParts: string[] = [] + if (rank) { + metaParts.push(`${esc(rank)}`) + } + if (level != null) { + metaParts.push(`Lv ${level}`) + } + const metaHtml = metaParts.length + ? `
    ${metaParts.join('')}
    ` + : '' + + // ── Stats ──────────────────────────────────────────────────────────── + const statsHtml = this._stats.length + ? `
    ${this._stats.map(s => { + const val = typeof s.value === 'number' ? s.value.toLocaleString() : s.value + return `
    + ${esc(s.label)} + ${esc(String(val))} +
    ` + }).join('')}
    ` + : '' + + // ── Actions ────────────────────────────────────────────────────────── + const actionsHtml = this._actions.length + ? `
    ${this._actions.map((a, i) => { + const cls = [ + 'tc-player-card-action-btn', + a.danger ? 'tc-player-card-action-btn--danger' : '', + ].filter(Boolean).join(' ') + return `` + }).join('')}
    ` + : '' + + this.innerHTML = ` +
    +
    +
    + ${esc(this.playerName)} + ${cardTitle ? `${esc(cardTitle)}` : ''} +
    +
    + + ${statusLabel(status)} +
    +
    + ${metaHtml} + ${statsHtml} + ${actionsHtml} +
    + ` + + // Delegate clicks on the fresh actions container — old container + its + // listener are garbage-collected together so no leak occurs. + const actionsEl = this.querySelector('.tc-player-card-actions') + actionsEl?.addEventListener('click', (e: MouseEvent) => { + const btn = (e.target as HTMLElement).closest('[data-id]') + if (!btn || btn.disabled) return + const id = btn.dataset.id ?? '' + if (!id) return + this._emit(id) + }) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: PlayerCard + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 277aab21..1e638ecc 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -315,6 +315,7 @@ export * from './PlatformIcon' export * from './PingDisplay' export * from './Minimap' export * from './MuteList' +export * from './PlayerCard' export * from './ObjectiveMarker' export * from './PageIndicator' export * from './Panel' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index d19feec3..a819ef44 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -312,6 +312,7 @@ import { PlatformIcon } from './PlatformIcon' import { PingDisplay } from './PingDisplay' import { Minimap } from './Minimap' import { MuteList } from './MuteList' +import { PlayerCard } from './PlayerCard' import { ObjectiveMarker } from './ObjectiveMarker' import { PageIndicator } from './PageIndicator' import { ParticleEmitter } from './ParticleEmitter' @@ -640,6 +641,7 @@ export function register(): void { customElements.define('tc-ping-display', PingDisplay) customElements.define('tc-minimap', Minimap) customElements.define('tc-mute-list', MuteList) + customElements.define('tc-player-card', PlayerCard) customElements.define('tc-objective-marker', ObjectiveMarker) customElements.define('tc-page-indicator', PageIndicator) customElements.define('tc-particle-emitter', ParticleEmitter) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index e8756c35..a986fc23 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -315,3 +315,4 @@ @forward 'particle-emitter'; @forward 'perk-picker'; @forward 'ping-display'; +@forward 'player-card'; diff --git a/web-components/style/components/_player-card.scss b/web-components/style/components/_player-card.scss new file mode 100644 index 00000000..d4bfc510 --- /dev/null +++ b/web-components/style/components/_player-card.scss @@ -0,0 +1,290 @@ +// tc-player-card — player summary card (name, title, rank, level, online +// status, stats grid, action buttons). Port of gc-player-card (game-components), +// restyled to the toolcase design system: slate neutrals, sharp corners, +// JetBrains Mono for machine-facing text, cyan/ink accent. +// All cosmetics flow through --bs-player-card-* custom properties. + +@use '../foundation/tokens' as *; + +tc-player-card { + // ── Surface ────────────────────────────────────────────────────────────── + --bs-player-card-bg: var(--tc-surface); + --bs-player-card-border: var(--tc-border); + + // ── Header ─────────────────────────────────────────────────────────────── + --bs-player-card-header-bg: var(--tc-surface-muted); + --bs-player-card-header-border: var(--tc-border); + + // ── Identity text ──────────────────────────────────────────────────────── + --bs-player-card-name-color: var(--tc-text); + --bs-player-card-title-color: var(--tc-text-muted); + + // ── Presence pip + label ───────────────────────────────────────────────── + --bs-player-card-pip-online: var(--tc-success, #22c55e); + --bs-player-card-pip-away: var(--tc-warning, #f59e0b); + --bs-player-card-pip-busy: var(--tc-danger, #ef4444); + --bs-player-card-pip-in-game: var(--tc-app-accent); + --bs-player-card-pip-offline: var(--tc-text-faint); + --bs-player-card-status-color: var(--tc-text-muted); + + // ── Meta (rank + level) ────────────────────────────────────────────────── + --bs-player-card-meta-bg: var(--tc-surface); + --bs-player-card-meta-border: var(--tc-border); + --bs-player-card-rank-bg: var(--tc-surface-muted); + --bs-player-card-rank-color: var(--tc-text-muted); + --bs-player-card-level-color: var(--tc-text-muted); + + // ── Stats grid ─────────────────────────────────────────────────────────── + --bs-player-card-stats-bg: var(--tc-surface); + --bs-player-card-stats-border: var(--tc-border); + --bs-player-card-stat-label-color: var(--tc-text-faint); + --bs-player-card-stat-value-color: var(--tc-text); + + // ── Action buttons ─────────────────────────────────────────────────────── + --bs-player-card-actions-bg: var(--tc-surface-muted); + --bs-player-card-actions-border: var(--tc-border); + --bs-player-card-btn-color: var(--tc-text); + --bs-player-card-btn-bg: var(--tc-surface); + --bs-player-card-btn-border: var(--tc-border); + --bs-player-card-btn-hover-bg: var(--tc-surface-hover, var(--tc-surface-muted)); + --bs-player-card-btn-hover-color:var(--tc-text); + --bs-player-card-btn-danger-color: var(--tc-danger, #ef4444); + --bs-player-card-btn-danger-hover-bg: var(--tc-danger, #ef4444); + --bs-player-card-btn-danger-hover-color: #fff; + --bs-player-card-btn-min-height: 2rem; + --bs-player-card-btn-disabled-opacity: 0.45; +} + +// ── Card shell ─────────────────────────────────────────────────────────────── + +.tc-player-card { + background: var(--bs-player-card-bg); + border: 1px solid var(--bs-player-card-border); + border-radius: 0; + overflow: hidden; +} + +// ── Header (name + presence) ───────────────────────────────────────────────── + +.tc-player-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + padding: 0.625rem 0.875rem; + background: var(--bs-player-card-header-bg); + border-bottom: 1px solid var(--bs-player-card-header-border); +} + +.tc-player-card-identity { + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; +} + +.tc-player-card-name { + font-size: 0.9375rem; + font-weight: 600; + line-height: 1.25; + color: var(--bs-player-card-name-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tc-player-card-card-title { + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.6875rem; + font-weight: 400; + color: var(--bs-player-card-title-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// ── Presence indicator ─────────────────────────────────────────────────────── + +.tc-player-card-presence { + display: inline-flex; + align-items: center; + gap: 0.375rem; + flex-shrink: 0; + padding-top: 0.1875rem; // optically align with the name baseline +} + +// Sanctioned circle — pip is explicitly circular per the design system rule +// (status dots are one of the permitted uses of border-radius). +.tc-player-card-pip { + display: inline-block; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + flex-shrink: 0; + background: var(--bs-player-card-pip-offline); + + .tc-player-card-presence[data-status="online"] & { background: var(--bs-player-card-pip-online); } + .tc-player-card-presence[data-status="away"] & { background: var(--bs-player-card-pip-away); } + .tc-player-card-presence[data-status="busy"] & { background: var(--bs-player-card-pip-busy); } + .tc-player-card-presence[data-status="in-game"] & { background: var(--bs-player-card-pip-in-game); } + .tc-player-card-presence[data-status="offline"] & { background: var(--bs-player-card-pip-offline); } +} + +.tc-player-card-status-label { + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.6875rem; + font-weight: 500; + color: var(--bs-player-card-status-color); + white-space: nowrap; +} + +// ── Meta row (rank + level) ────────────────────────────────────────────────── + +.tc-player-card-meta { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.875rem; + background: var(--bs-player-card-meta-bg); + border-bottom: 1px solid var(--bs-player-card-meta-border); +} + +.tc-player-card-rank { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.4375rem; + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.625rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + background: var(--bs-player-card-rank-bg); + color: var(--bs-player-card-rank-color); + border-radius: 0; + border: 1px solid var(--bs-player-card-meta-border); + white-space: nowrap; +} + +.tc-player-card-level { + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.6875rem; + font-weight: 500; + color: var(--bs-player-card-level-color); + white-space: nowrap; +} + +// ── Stats grid ─────────────────────────────────────────────────────────────── + +.tc-player-card-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(5rem, 1fr)); + background: var(--bs-player-card-stats-bg); + border-bottom: 1px solid var(--bs-player-card-stats-border); +} + +.tc-player-card-stat { + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 0.5rem 0.875rem; + border-right: 1px solid var(--bs-player-card-stats-border); + + &:last-child { + border-right: none; + } +} + +.tc-player-card-stat-label { + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.5625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--bs-player-card-stat-label-color); + white-space: nowrap; +} + +.tc-player-card-stat-value { + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.9375rem; + font-weight: 600; + color: var(--bs-player-card-stat-value-color); + line-height: 1.2; +} + +// ── Action buttons ─────────────────────────────────────────────────────────── + +.tc-player-card-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.625rem 0.875rem; + background: var(--bs-player-card-actions-bg); + border-top: 1px solid var(--bs-player-card-actions-border); +} + +.tc-player-card-action-btn { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 0.75rem; + min-height: var(--bs-player-card-btn-min-height); + border: 1px solid var(--bs-player-card-btn-border); + border-radius: 0; + background: var(--bs-player-card-btn-bg); + color: var(--bs-player-card-btn-color); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + line-height: 1; + transition: + background-color var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease), + opacity var(--tc-transition-fast, 0.15s ease); + + &:hover:not(:disabled) { + background: var(--bs-player-card-btn-hover-bg); + color: var(--bs-player-card-btn-hover-color); + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + } + + &:disabled { + opacity: var(--bs-player-card-btn-disabled-opacity); + cursor: not-allowed; + } + + // Danger variant — colored text at rest, red fill on hover + &.tc-player-card-action-btn--danger { + color: var(--bs-player-card-btn-danger-color); + border-color: var(--bs-player-card-btn-danger-color); + + &:hover:not(:disabled) { + background: var(--bs-player-card-btn-danger-hover-bg); + color: var(--bs-player-card-btn-danger-hover-color); + border-color: var(--bs-player-card-btn-danger-hover-bg); + } + } +} + +// ── 44 px coarse touch targets ─────────────────────────────────────────────── + +@media (pointer: coarse) { + .tc-player-card-action-btn { + --bs-player-card-btn-min-height: 44px; + } +} + +// ── Reduced motion ─────────────────────────────────────────────────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-player-card-action-btn { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 196a95ff..747f0040 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -344,6 +344,7 @@ tc-loot-list, tc-lore-text, tc-minimap, tc-mute-list, +tc-player-card, tc-particle-emitter, tc-perk-picker, tc-roadmap { From ee5923d2c9c9147ba2b4fc24032d0f850f5b0968 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 01:26:55 +0000 Subject: [PATCH 359/632] 354-player-frame: Build the tc-player-frame web component (PlayerFrame game-components port) --- examples/public/web-components/SKILL.md | 108 ++++++++++ .../src/web-components/PlayerFrameDemo.tsx | 171 +++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/PlayerFrame.ts | 204 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_player-frame.scss | 180 ++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 670 insertions(+) create mode 100644 examples/src/web-components/PlayerFrameDemo.tsx create mode 100644 web-components/src/PlayerFrame.ts create mode 100644 web-components/style/components/_player-frame.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 86527194..436af8c8 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -236,6 +236,7 @@ After `register()` you can author markup directly: - [tc-minimap](#tc-minimap) - [tc-mute-list](#tc-mute-list) - [tc-player-card](#tc-player-card) + - [tc-player-frame](#tc-player-frame) - [tc-matchmaking-screen](#tc-matchmaking-screen) - [tc-network-status-icon](#tc-network-status-icon) - [tc-ping-display](#tc-ping-display) @@ -22416,6 +22417,113 @@ None. `tc-player-card` is attribute- and property-driven; all content is generat --- +### tc-player-frame + +Player nameplate / HUD frame combining a portrait tile (glyph + optional level badge), a player name, an optional class label, and up to three stacked resource bars (HP always shown; MP and Stamina shown via boolean attributes). Port of `gc-player-frame` (game-components), restyled to the toolcase design system: slate neutrals, sharp corners (`border-radius: 0`), 1px hairline borders, JetBrains Mono for machine-facing text. No shadow root; light DOM; `display: block`. + +**Tag:** `tc-player-frame` + +#### Attributes + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `name` | `string` | `''` | Player display name shown in the header. | +| `class-name` | `string` | — | Optional archetype / class label shown below the name in monospace. | +| `glyph` | `string` | `'—'` | Portrait character — typically an emoji or unicode glyph. | +| `level` | `number` | — | Optional player level. Shown as a badge strip at the bottom of the portrait tile. | +| `hp` | `number` | `0` | Current HP value. | +| `hp-max` | `number` | `100` | Maximum HP. Must be > 0; falls back to 100. | +| `mp` | `number` | `0` | Current MP value. Only visible when `show-mp` is set. | +| `mp-max` | `number` | `100` | Maximum MP. Must be > 0; falls back to 100. | +| `stamina` | `number` | `0` | Current stamina value. Only visible when `show-stamina` is set. | +| `stamina-max` | `number` | `100` | Maximum stamina. Must be > 0; falls back to 100. | +| `show-mp` | `boolean` | absent | When present, renders the MP bar below the HP bar. | +| `show-stamina` | `boolean` | absent | When present, renders the stamina bar below the MP bar (or HP bar if MP is hidden). | + +#### JS Properties + +| Property | Type | Default | Description | +|---|---|---|---| +| `name` | `string` | `''` | Reflects the `name` attribute. | +| `classLabel` | `string` | `''` | Reflects the `class-name` attribute. Renamed to avoid shadowing `HTMLElement.className`. | +| `glyph` | `string` | `''` | Reflects the `glyph` attribute. | +| `level` | `number \| null` | `null` | Reflects the `level` attribute. Set to `null` to remove the badge. | +| `hp` | `number` | `0` | Reflects the `hp` attribute. | +| `hpMax` | `number` | `100` | Reflects the `hp-max` attribute. | +| `mp` | `number` | `0` | Reflects the `mp` attribute. | +| `mpMax` | `number` | `100` | Reflects the `mp-max` attribute. | +| `stamina` | `number` | `0` | Reflects the `stamina` attribute. | +| `staminaMax` | `number` | `100` | Reflects the `stamina-max` attribute. | +| `showMp` | `boolean` | `false` | Reflects the `show-mp` attribute. | +| `showStamina` | `boolean` | `false` | Reflects the `show-stamina` attribute. | + +#### Events + +None. `tc-player-frame` is a purely presentational component. + +#### Slots + +None. All content is generated from attributes. + +#### CSS Custom Properties + +| Property | Default | Description | +|---|---|---| +| `--bs-player-frame-bg` | `var(--tc-surface)` | Card background. | +| `--bs-player-frame-border` | `var(--tc-border)` | 1 px hairline border around the card. | +| `--bs-player-frame-portrait-size` | `3rem` | Width (and minimum height) of the portrait tile. | +| `--bs-player-frame-portrait-bg` | `var(--tc-surface-muted)` | Portrait tile background. | +| `--bs-player-frame-portrait-border` | `var(--tc-border)` | Right border separating portrait from body. | +| `--bs-player-frame-glyph-size` | `1.375rem` | Font size of the portrait glyph. | +| `--bs-player-frame-glyph-color` | `var(--tc-text)` | Portrait glyph colour. | +| `--bs-player-frame-level-bg` | `var(--tc-surface)` | Level badge background. | +| `--bs-player-frame-level-border` | `var(--tc-border)` | Top border of the level badge strip. | +| `--bs-player-frame-level-color` | `var(--tc-text-muted)` | Level badge text colour. | +| `--bs-player-frame-name-color` | `var(--tc-text)` | Player name text colour. | +| `--bs-player-frame-class-color` | `var(--tc-text-muted)` | Class label text colour. | +| `--bs-player-frame-hp-track-bg` | `var(--tc-surface-muted)` | HP bar track background. | +| `--bs-player-frame-hp-fill` | `var(--tc-success, #16a34a)` | HP bar fill colour. | +| `--bs-player-frame-mp-track-bg` | `var(--tc-surface-muted)` | MP bar track background. | +| `--bs-player-frame-mp-fill` | `var(--tc-info, #0284c7)` | MP bar fill colour. | +| `--bs-player-frame-stamina-track-bg` | `var(--tc-surface-muted)` | Stamina bar track background. | +| `--bs-player-frame-stamina-fill` | `var(--tc-warning, #d97706)` | Stamina bar fill colour. | +| `--bs-player-frame-bar-height` | `0.3125rem` | Height of each resource bar (5 px). | + +#### Example + +```html + + + + +``` + +--- + ### tc-network-status-icon 4-bar signal-strength indicator for connectivity / network quality. Bar count and tier are computed from ping latency and packet loss; the optional label shows the ping value or offline state. No shadow root; light DOM; `display: inline-flex`. diff --git a/examples/src/web-components/PlayerFrameDemo.tsx b/examples/src/web-components/PlayerFrameDemo.tsx new file mode 100644 index 00000000..f56eab6c --- /dev/null +++ b/examples/src/web-components/PlayerFrameDemo.tsx @@ -0,0 +1,171 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const PlayerFrameDemo: React.FC = () => { + const liveRef = useRef(null) + const [hp, setHp] = useState(75) + const [mp, setMp] = useState(60) + + // Wire live-update demo + useEffect(() => { + const el = liveRef.current + if (!el) return + el.setAttribute('hp', String(hp)) + }, [hp]) + + useEffect(() => { + const el = liveRef.current + if (!el) return + el.setAttribute('mp', String(mp)) + }, [mp]) + + return ( +
    +
    +
    +
    + Web Components} + title="PlayerFrame" + description="Player nameplate / HUD frame combining a portrait tile, player name, optional class label, and resource bars (HP, MP, Stamina). Port of gc-player-frame, restyled to the toolcase design system." + /> + +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + +
    + {/* @ts-ignore */} + +
    +
    + + setHp(Number(e.target.value))} + /> + + setMp(Number(e.target.value))} + /> +
    +
    + +
    +
    +
    +
    +
    + ) +} + +export default PlayerFrameDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 64b709bf..50da692a 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -305,6 +305,7 @@ import LoreTextDemo from './LoreTextDemo' import MinimapDemo from './MinimapDemo' import MuteListDemo from './MuteListDemo' import PlayerCardDemo from './PlayerCardDemo' +import PlayerFrameDemo from './PlayerFrameDemo' import NetworkStatusIconDemo from './NetworkStatusIconDemo' import PingDisplayDemo from './PingDisplayDemo' import PlatformIconDemo from './PlatformIconDemo' @@ -636,6 +637,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'minimap', category: 'Components', element: }, { key: 'mute-list', category: 'Components', element: }, { key: 'player-card', category: 'Components', element: }, + { key: 'player-frame', category: 'Components', element: }, { key: 'network-status-icon', category: 'Components', element: }, { key: 'platform-icon', category: 'Components', element: }, { key: 'ping-display', category: 'Components', element: }, diff --git a/web-components/src/PlayerFrame.ts b/web-components/src/PlayerFrame.ts new file mode 100644 index 00000000..70c7dca6 --- /dev/null +++ b/web-components/src/PlayerFrame.ts @@ -0,0 +1,204 @@ +import { escapeHtml } from './internal/resourceBar' + +const TAG_NAME = 'tc-player-frame' + +function renderBar(kind: 'hp' | 'mp' | 'stamina', value: number, max: number, label: string): string { + const safeMax = max > 0 ? max : 100 + const safeValue = Math.max(0, Math.min(safeMax, value)) + const pct = (safeValue / safeMax) * 100 + + return ( + `
    ` + + `
    ` + + `
    ` + ) +} + +export class PlayerFrame extends HTMLElement { + private _initialised = false + + static get observedAttributes(): string[] { + return ['name', 'class-name', 'glyph', 'level', 'hp', 'hp-max', 'mp', 'mp-max', 'stamina', 'stamina-max', 'show-mp', 'show-stamina'] + } + + connectedCallback(): void { + if (!this._initialised) { + if (!this.hasAttribute('role')) this.setAttribute('role', 'group') + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + // HTMLElement does not natively reflect a `.name` property on generic elements; + // this getter is safe (unlike `title` or `role` which are ARIAMixin/HTMLElement builtins). + get name(): string { + return this.getAttribute('name') ?? '' + } + set name(v: string) { + if (v) this.setAttribute('name', v) + else this.removeAttribute('name') + } + + // `classLabel` maps to the `class-name` attribute; renamed to avoid shadowing + // the native `HTMLElement.className` property which reflects the `class` attribute. + get classLabel(): string { + return this.getAttribute('class-name') ?? '' + } + set classLabel(v: string) { + if (v) this.setAttribute('class-name', v) + else this.removeAttribute('class-name') + } + + get glyph(): string { + return this.getAttribute('glyph') ?? '' + } + set glyph(v: string) { + if (v) this.setAttribute('glyph', v) + else this.removeAttribute('glyph') + } + + get level(): number | null { + const raw = this.getAttribute('level') + if (raw == null) return null + const parsed = parseInt(raw, 10) + return Number.isFinite(parsed) ? parsed : null + } + set level(v: number | null) { + if (v == null) this.removeAttribute('level') + else this.setAttribute('level', String(v)) + } + + get hp(): number { + const raw = this.getAttribute('hp') + if (raw == null) return 0 + const parsed = parseFloat(raw) + return Number.isFinite(parsed) ? parsed : 0 + } + set hp(v: number) { + this.setAttribute('hp', String(v)) + } + + get hpMax(): number { + const raw = this.getAttribute('hp-max') + if (raw == null) return 100 + const parsed = parseFloat(raw) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 100 + } + set hpMax(v: number) { + this.setAttribute('hp-max', String(v)) + } + + get mp(): number { + const raw = this.getAttribute('mp') + if (raw == null) return 0 + const parsed = parseFloat(raw) + return Number.isFinite(parsed) ? parsed : 0 + } + set mp(v: number) { + this.setAttribute('mp', String(v)) + } + + get mpMax(): number { + const raw = this.getAttribute('mp-max') + if (raw == null) return 100 + const parsed = parseFloat(raw) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 100 + } + set mpMax(v: number) { + this.setAttribute('mp-max', String(v)) + } + + get stamina(): number { + const raw = this.getAttribute('stamina') + if (raw == null) return 0 + const parsed = parseFloat(raw) + return Number.isFinite(parsed) ? parsed : 0 + } + set stamina(v: number) { + this.setAttribute('stamina', String(v)) + } + + get staminaMax(): number { + const raw = this.getAttribute('stamina-max') + if (raw == null) return 100 + const parsed = parseFloat(raw) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 100 + } + set staminaMax(v: number) { + this.setAttribute('stamina-max', String(v)) + } + + get showMp(): boolean { + return this.hasAttribute('show-mp') + } + set showMp(v: boolean) { + if (v) this.setAttribute('show-mp', '') + else this.removeAttribute('show-mp') + } + + get showStamina(): boolean { + return this.hasAttribute('show-stamina') + } + set showStamina(v: boolean) { + if (v) this.setAttribute('show-stamina', '') + else this.removeAttribute('show-stamina') + } + + private render(): void { + const level = this.level + const glyph = this.glyph + const classLabel = this.getAttribute('class-name') ?? '' + + // Portrait + const glyphSpan = `` + const levelBadge = level != null + ? `` + : '' + + // Header row (name + class label) + const classSpan = classLabel + ? `${escapeHtml(classLabel)}` + : '' + + // Bars + const hpBar = renderBar('hp', this.hp, this.hpMax, 'Health') + const mpBar = this.showMp ? renderBar('mp', this.mp, this.mpMax, 'Mana') : '' + const staminaBar = this.showStamina ? renderBar('stamina', this.stamina, this.staminaMax, 'Stamina') : '' + + this.innerHTML = ` +
    + +
    +
    + ${escapeHtml(this.name)} + ${classSpan} +
    +
    + ${hpBar} + ${mpBar} + ${staminaBar} +
    +
    +
    + ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: PlayerFrame + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 1e638ecc..2d7d2e85 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -316,6 +316,7 @@ export * from './PingDisplay' export * from './Minimap' export * from './MuteList' export * from './PlayerCard' +export * from './PlayerFrame' export * from './ObjectiveMarker' export * from './PageIndicator' export * from './Panel' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index a819ef44..f61df3b7 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -313,6 +313,7 @@ import { PingDisplay } from './PingDisplay' import { Minimap } from './Minimap' import { MuteList } from './MuteList' import { PlayerCard } from './PlayerCard' +import { PlayerFrame } from './PlayerFrame' import { ObjectiveMarker } from './ObjectiveMarker' import { PageIndicator } from './PageIndicator' import { ParticleEmitter } from './ParticleEmitter' @@ -642,6 +643,7 @@ export function register(): void { customElements.define('tc-minimap', Minimap) customElements.define('tc-mute-list', MuteList) customElements.define('tc-player-card', PlayerCard) + customElements.define('tc-player-frame', PlayerFrame) customElements.define('tc-objective-marker', ObjectiveMarker) customElements.define('tc-page-indicator', PageIndicator) customElements.define('tc-particle-emitter', ParticleEmitter) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index a986fc23..6ae661e6 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -316,3 +316,4 @@ @forward 'perk-picker'; @forward 'ping-display'; @forward 'player-card'; +@forward 'player-frame'; diff --git a/web-components/style/components/_player-frame.scss b/web-components/style/components/_player-frame.scss new file mode 100644 index 00000000..f28c7797 --- /dev/null +++ b/web-components/style/components/_player-frame.scss @@ -0,0 +1,180 @@ +// tc-player-frame — player nameplate / HUD frame showing a portrait tile, +// player name, optional class label, and resource bars (HP, MP, stamina). +// Port of gc-player-frame (game-components), restyled to the toolcase design +// system: slate neutrals, sharp corners, JetBrains Mono for machine-facing +// text. No gilded frames, glows, or fantasy chrome. +// All cosmetics flow through --bs-player-frame-* custom properties. + +@use '../foundation/tokens' as *; + +tc-player-frame { + // ── Surface ─────────────────────────────────────────────────────────────── + --bs-player-frame-bg: var(--tc-surface); + --bs-player-frame-border: var(--tc-border); + + // ── Portrait tile ───────────────────────────────────────────────────────── + --bs-player-frame-portrait-size: 3rem; + --bs-player-frame-portrait-bg: var(--tc-surface-muted); + --bs-player-frame-portrait-border: var(--tc-border); + --bs-player-frame-glyph-size: 1.375rem; + --bs-player-frame-glyph-color: var(--tc-text); + + // ── Level badge ─────────────────────────────────────────────────────────── + --bs-player-frame-level-bg: var(--tc-surface); + --bs-player-frame-level-border: var(--tc-border); + --bs-player-frame-level-color: var(--tc-text-muted); + + // ── Name + class label ──────────────────────────────────────────────────── + --bs-player-frame-name-color: var(--tc-text); + --bs-player-frame-class-color: var(--tc-text-muted); + + // ── HP bar ──────────────────────────────────────────────────────────────── + --bs-player-frame-hp-track-bg: var(--tc-surface-muted); + --bs-player-frame-hp-fill: var(--tc-success, #16a34a); + + // ── MP bar ──────────────────────────────────────────────────────────────── + --bs-player-frame-mp-track-bg: var(--tc-surface-muted); + --bs-player-frame-mp-fill: var(--tc-info, #0284c7); + + // ── Stamina bar ─────────────────────────────────────────────────────────── + --bs-player-frame-stamina-track-bg: var(--tc-surface-muted); + --bs-player-frame-stamina-fill: var(--tc-warning, #d97706); + + // ── Bar dimensions ──────────────────────────────────────────────────────── + --bs-player-frame-bar-height: 0.3125rem; // 5 px +} + +// ── Root shell ─────────────────────────────────────────────────────────────── + +.tc-player-frame { + display: flex; + flex-direction: row; + align-items: stretch; + background: var(--bs-player-frame-bg); + border: 1px solid var(--bs-player-frame-border); + border-radius: 0; + overflow: hidden; +} + +// ── Portrait tile ───────────────────────────────────────────────────────────── + +.tc-player-frame__portrait { + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: var(--bs-player-frame-portrait-size); + // Height stretches to fill the card via align-items: stretch on the parent flex row + min-height: var(--bs-player-frame-portrait-size); + background: var(--bs-player-frame-portrait-bg); + border-right: 1px solid var(--bs-player-frame-portrait-border); +} + +.tc-player-frame__glyph { + display: block; + font-size: var(--bs-player-frame-glyph-size); + line-height: 1; + color: var(--bs-player-frame-glyph-color); + user-select: none; + font-style: normal; +} + +// Level badge — absolute-positioned strip along the bottom of the portrait tile +.tc-player-frame__level { + position: absolute; + bottom: 0; + inset-inline-start: 0; + inset-inline-end: 0; + display: block; + text-align: center; + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.5625rem; + font-weight: 600; + letter-spacing: 0.04em; + line-height: 1.25rem; + color: var(--bs-player-frame-level-color); + background: var(--bs-player-frame-level-bg); + border-top: 1px solid var(--bs-player-frame-level-border); +} + +// ── Body (name + bars) ──────────────────────────────────────────────────────── + +.tc-player-frame__body { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.375rem; + flex: 1 1 0; + min-width: 0; + padding: 0.5rem 0.625rem; +} + +// ── Header row (name + class) ───────────────────────────────────────────────── + +.tc-player-frame__header { + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; +} + +.tc-player-frame__name { + font-size: 0.875rem; + font-weight: 600; + line-height: 1.2; + color: var(--bs-player-frame-name-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tc-player-frame__class { + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.625rem; + font-weight: 400; + color: var(--bs-player-frame-class-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// ── Bars ────────────────────────────────────────────────────────────────────── + +.tc-player-frame__bars { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.tc-player-frame__bar { + position: relative; + height: var(--bs-player-frame-bar-height); + border-radius: 0; + overflow: hidden; + background: var(--bs-player-frame-hp-track-bg); + + &[data-kind="mp"] { background: var(--bs-player-frame-mp-track-bg); } + &[data-kind="stamina"] { background: var(--bs-player-frame-stamina-track-bg); } +} + +.tc-player-frame__bar-fill { + position: absolute; + inset-block: 0; + inset-inline-start: 0; + height: 100%; + border-radius: 0; + background: var(--bs-player-frame-hp-fill); + transition: width var(--tc-transition-fast, 0.15s ease); + + .tc-player-frame__bar[data-kind="mp"] & { background: var(--bs-player-frame-mp-fill); } + .tc-player-frame__bar[data-kind="stamina"] & { background: var(--bs-player-frame-stamina-fill); } +} + +// ── Reduced motion ──────────────────────────────────────────────────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-player-frame__bar-fill { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 747f0040..ccf63b17 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -345,6 +345,7 @@ tc-lore-text, tc-minimap, tc-mute-list, tc-player-card, +tc-player-frame, tc-particle-emitter, tc-perk-picker, tc-roadmap { From c7aad94cc39ec76fed937388a07ff2efbe41085e Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 01:35:06 +0000 Subject: [PATCH 360/632] 355-portrait: Build the tc-portrait web component (Portrait game-components port) --- examples/public/web-components/SKILL.md | 81 +++++++++++ examples/src/web-components/PortraitDemo.tsx | 129 ++++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Portrait.ts | 121 ++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_portrait.scss | 85 ++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 423 insertions(+) create mode 100644 examples/src/web-components/PortraitDemo.tsx create mode 100644 web-components/src/Portrait.ts create mode 100644 web-components/style/components/_portrait.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 436af8c8..a51eb8bb 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -248,6 +248,7 @@ After `register()` you can author markup directly: - [tc-particle-emitter](#tc-particle-emitter) - [tc-pause-menu](#tc-pause-menu) - [tc-perk-picker](#tc-perk-picker) + - [tc-portrait](#tc-portrait) - [tc-pause-screen](#tc-pause-screen) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) @@ -23399,3 +23400,83 @@ el.addEventListener('tc-select', e => { }) ``` + +--- + +### tc-portrait + +Standalone character portrait frame. Displays a glyph (emoji, initials, unicode symbol, or image URL) with an optional level badge strip at the bottom and an optional colored ring outline accent. Port of `gc-portrait` (game-components), restyled to the toolcase design system (flat slate surface, 1px hairline border, sharp corners, JetBrains Mono level badge). No shadow root; light DOM; `display: block`. + +**Tag:** `tc-portrait` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `glyph` | string | `''` | Content displayed in the portrait body — an emoji, initials, unicode glyph, or an image URL (auto-detected: starts with `http(s)://` or `/`, or ends with a common image extension); rendered as `` for URLs, plain text otherwise | +| `size` | number | — | Override the portrait's width and height in pixels; sets `--bs-portrait-size` inline | +| `ring` | string | — | A CSS color value applied as a colored outline accent around the portrait border; sets `--bs-portrait-ring-color` inline | +| `level` | number | — | When provided, renders a JetBrains Mono level badge strip along the bottom edge of the portrait | +| `circle` | boolean | `false` | Applies `border-radius: 50%` to produce a circular portrait (sanctioned circle variant) | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `glyph` | string | Mirrors the `glyph` attribute | +| `size` | number \| null | Mirrors the `size` attribute; `null` removes it | +| `ring` | string | Mirrors the `ring` attribute | +| `level` | number \| null | Mirrors the `level` attribute; `null` removes the badge | +| `circle` | boolean | Mirrors the `circle` boolean attribute | + +**Events:** none — `tc-portrait` is purely presentational. + +**Slots:** none — all content is attribute-driven. + +**Custom properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-portrait-size` | `3.5rem` | Width and height of the portrait square | +| `--bs-portrait-glyph-size` | `1.5rem` | Font size of the glyph text | +| `--bs-portrait-bg` | `var(--tc-surface-muted)` | Portrait background fill | +| `--bs-portrait-border-color` | `var(--tc-border)` | 1px hairline border color | +| `--bs-portrait-glyph-color` | `var(--tc-text)` | Glyph text color | +| `--bs-portrait-ring-color` | `transparent` | Colored outline accent color (transparent = no ring) | +| `--bs-portrait-ring-width` | `2px` | Width of the ring outline | +| `--bs-portrait-ring-offset` | `2px` | Offset of the ring outline from the border | +| `--bs-portrait-level-bg` | `var(--tc-surface)` | Level badge background | +| `--bs-portrait-level-color` | `var(--tc-text-muted)` | Level badge text color | +| `--bs-portrait-level-border` | `1px solid var(--tc-border)` | Top border of the level badge strip | + +```html + + + + + + + + + + + + + + + + + + + + +``` diff --git a/examples/src/web-components/PortraitDemo.tsx b/examples/src/web-components/PortraitDemo.tsx new file mode 100644 index 00000000..40098728 --- /dev/null +++ b/examples/src/web-components/PortraitDemo.tsx @@ -0,0 +1,129 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const PortraitDemo: React.FC = () => { + return ( +
    +
    +
    +
    + Web Components} + title="Portrait" + description="Standalone character portrait frame. Displays a glyph (emoji, initials, or image URL) with an optional level badge and a colored ring accent. Port of gc-portrait, restyled to the toolcase design system." + /> + +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + + {/* @ts-ignore */} + + + +
    +
    +
    +
    +
    + ) +} + +export default PortraitDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 50da692a..4e53caab 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -245,6 +245,7 @@ import LootPopupDemo from './LootPopupDemo' import PauseMenuDemo from './PauseMenuDemo' import PauseScreenDemo from './PauseScreenDemo' import PerkPickerDemo from './PerkPickerDemo' +import PortraitDemo from './PortraitDemo' import LiveFeedDemo from './LiveFeedDemo' import LoginDemo from './LoginDemo' import MarkdownEditorDemo from './MarkdownEditorDemo' @@ -646,4 +647,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'panel', category: 'Components', element: }, { key: 'particle-emitter', category: 'Components', element: }, { key: 'perk-picker', category: 'Components', element: }, + { key: 'portrait', category: 'Components', element: }, ] diff --git a/web-components/src/Portrait.ts b/web-components/src/Portrait.ts new file mode 100644 index 00000000..0fb7d032 --- /dev/null +++ b/web-components/src/Portrait.ts @@ -0,0 +1,121 @@ +const TAG_NAME = 'tc-portrait' + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function looksLikeUrl(s: string): boolean { + return /^https?:\/\//.test(s) || /^\//.test(s) || /\.(png|jpe?g|gif|webp|svg|avif)$/i.test(s) +} + +export class Portrait extends HTMLElement { + private _initialised = false + + static get observedAttributes(): string[] { + return ['glyph', 'size', 'ring', 'level', 'circle'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get glyph(): string { + return this.getAttribute('glyph') ?? '' + } + set glyph(v: string) { + if (v) this.setAttribute('glyph', v) + else this.removeAttribute('glyph') + } + + get size(): number | null { + const raw = this.getAttribute('size') + if (raw === null) return null + const n = parseFloat(raw) + return Number.isNaN(n) ? null : n + } + set size(v: number | null) { + if (v === null) this.removeAttribute('size') + else this.setAttribute('size', String(v)) + } + + get ring(): string { + return this.getAttribute('ring') ?? '' + } + set ring(v: string) { + if (v) this.setAttribute('ring', v) + else this.removeAttribute('ring') + } + + get level(): number | null { + const raw = this.getAttribute('level') + if (raw === null) return null + const n = parseFloat(raw) + return Number.isNaN(n) ? null : n + } + set level(v: number | null) { + if (v === null) this.removeAttribute('level') + else this.setAttribute('level', String(v)) + } + + get circle(): boolean { + return this.hasAttribute('circle') + } + set circle(v: boolean) { + if (v) this.setAttribute('circle', '') + else this.removeAttribute('circle') + } + + private render(): void { + const size = this.size + const ring = this.ring + const level = this.level + const glyph = this.glyph + const isCircle = this.circle + + if (size !== null) { + this.style.setProperty('--bs-portrait-size', `${size}px`) + } else { + this.style.removeProperty('--bs-portrait-size') + } + + if (ring) { + this.style.setProperty('--bs-portrait-ring-color', ring) + } else { + this.style.removeProperty('--bs-portrait-ring-color') + } + + // No slot — host IS the visual; className = is safe here. + this.className = `tc-portrait${isCircle ? ' tc-portrait--circle' : ''}` + this.setAttribute('role', 'img') + this.setAttribute('aria-label', glyph || 'Portrait') + + let bodyContent: string + if (glyph && looksLikeUrl(glyph)) { + bodyContent = `` + } else { + bodyContent = `` + } + + const levelHtml = level !== null + ? `${esc(String(Math.floor(level)))}` + : '' + + this.innerHTML = bodyContent + levelHtml + } +} + +declare global { + interface HTMLElementTagNameMap { [TAG_NAME]: Portrait } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 2d7d2e85..2da5c304 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -324,3 +324,4 @@ export * from './ParticleEmitter' export * from './PauseMenu' export * from './PauseScreen' export * from './PerkPicker' +export * from './Portrait' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index f61df3b7..268abedb 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -321,6 +321,7 @@ import { Panel, PanelHeader } from './Panel' import { PauseMenu } from './PauseMenu' import { PauseScreen } from './PauseScreen' import { PerkPicker } from './PerkPicker' +import { Portrait } from './Portrait' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -652,4 +653,5 @@ export function register(): void { customElements.define('tc-pause-menu', PauseMenu) customElements.define('tc-pause-screen', PauseScreen) customElements.define('tc-perk-picker', PerkPicker) + customElements.define('tc-portrait', Portrait) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 6ae661e6..769fba48 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -314,6 +314,7 @@ @forward 'panel'; @forward 'particle-emitter'; @forward 'perk-picker'; +@forward 'portrait'; @forward 'ping-display'; @forward 'player-card'; @forward 'player-frame'; diff --git a/web-components/style/components/_portrait.scss b/web-components/style/components/_portrait.scss new file mode 100644 index 00000000..31fdeb05 --- /dev/null +++ b/web-components/style/components/_portrait.scss @@ -0,0 +1,85 @@ +// tc-portrait — standalone character portrait frame. +// Port of gc-portrait (game-components), restyled to the toolcase design +// system: slate neutrals, sharp corners, hairline borders, no gilded chrome. +// All cosmetics flow through --bs-portrait-* custom properties. + +@use '../foundation/tokens' as *; + +tc-portrait { + // ── Size ────────────────────────────────────────────────────────────────── + --bs-portrait-size: 3.5rem; + --bs-portrait-glyph-size: 1.5rem; + + // ── Surface ─────────────────────────────────────────────────────────────── + --bs-portrait-bg: var(--tc-surface-muted); + --bs-portrait-border-color: var(--tc-border); + --bs-portrait-glyph-color: var(--tc-text); + + // ── Ring (colored accent outline) ───────────────────────────────────────── + --bs-portrait-ring-color: transparent; + --bs-portrait-ring-width: 2px; + --bs-portrait-ring-offset: 2px; + + // ── Level badge ─────────────────────────────────────────────────────────── + --bs-portrait-level-bg: var(--tc-surface); + --bs-portrait-level-color: var(--tc-text-muted); + --bs-portrait-level-border: 1px solid var(--tc-border); +} + +.tc-portrait { + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: var(--bs-portrait-size); + height: var(--bs-portrait-size); + background: var(--bs-portrait-bg); + border: 1px solid var(--bs-portrait-border-color); + border-radius: 0; + overflow: hidden; + outline: var(--bs-portrait-ring-width) solid var(--bs-portrait-ring-color); + outline-offset: var(--bs-portrait-ring-offset); + + // ── Circle variant — sanctioned per styleguide ──────────────────────────── + &.tc-portrait--circle { + border-radius: 50%; + } +} + +.tc-portrait__glyph { + display: block; + font-size: var(--bs-portrait-glyph-size); + line-height: 1; + color: var(--bs-portrait-glyph-color); + user-select: none; + text-align: center; +} + +.tc-portrait__img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +// Level badge — absolute strip along the bottom edge of the portrait +.tc-portrait__level { + position: absolute; + bottom: 0; + inset-inline-start: 0; + inset-inline-end: 0; + display: block; + text-align: center; + font-family: var(--bs-font-monospace, 'JetBrains Mono', monospace); + font-size: 0.5625rem; + font-weight: 600; + letter-spacing: 0.04em; + line-height: 1.125rem; + color: var(--bs-portrait-level-color); + background: var(--bs-portrait-level-bg); + border-top: var(--bs-portrait-level-border); +} + diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index ccf63b17..1717af35 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -348,6 +348,7 @@ tc-player-card, tc-player-frame, tc-particle-emitter, tc-perk-picker, +tc-portrait, tc-roadmap { display: block; } From c4a237abf167c1a2097756c23fab34e1526b566f Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 01:43:16 +0000 Subject: [PATCH 361/632] 356-press-any-key: Build the tc-press-any-key web component (PressAnyKey game-components port) --- examples/public/web-components/SKILL.md | 74 +++++++++ .../src/web-components/PressAnyKeyDemo.tsx | 141 ++++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/PressAnyKey.ts | 88 +++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_press-any-key.scss | 82 ++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 392 insertions(+) create mode 100644 examples/src/web-components/PressAnyKeyDemo.tsx create mode 100644 web-components/src/PressAnyKey.ts create mode 100644 web-components/style/components/_press-any-key.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index a51eb8bb..9b320f31 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -249,6 +249,7 @@ After `register()` you can author markup directly: - [tc-pause-menu](#tc-pause-menu) - [tc-perk-picker](#tc-perk-picker) - [tc-portrait](#tc-portrait) + - [tc-press-any-key](#tc-press-any-key) - [tc-pause-screen](#tc-pause-screen) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) @@ -23480,3 +23481,76 @@ Standalone character portrait frame. Displays a glyph (emoji, initials, unicode " > ``` + +--- + +### tc-press-any-key + +"Press any key to continue" prompt. Renders a pulsing mono text label that fires `tc-continue` on any non-modifier keydown (document-level) or mousedown on the element. Port of `gc-press-any-key` (game-components), restyled to the toolcase design system (JetBrains Mono, slate muted text, sharp corners, opacity-pulse animation). No shadow root; light DOM; `display: block`. + +**Tag:** `tc-press-any-key` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `text` | string | `'Press Any Key'` | Text label displayed in the prompt | +| `disabled` | boolean | `false` | When present, suppresses event dispatch, freezes the pulse animation, and reduces opacity to 40% | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `text` | string | Mirrors the `text` attribute | +| `disabled` | boolean | Mirrors the `disabled` boolean attribute | +| `onContinue` | `((e: CustomEvent) => void) \| null` | Optional callback invoked synchronously alongside the `tc-continue` event | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-continue` | `void` | Dispatched when any non-modifier key is pressed (document-level) or the element receives a `mousedown`. Ignored when `disabled`. | + +**Slots:** none — content is fully driven by the `text` attribute. + +**Custom properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-press-any-key-color` | `var(--tc-text-muted)` | Text color at rest | +| `--bs-press-any-key-color-hover` | `var(--tc-text)` | Text color on hover | +| `--bs-press-any-key-font-size` | `0.8125rem` | Font size (13 px — mono micro-label tier) | +| `--bs-press-any-key-font-weight` | `500` | Font weight | +| `--bs-press-any-key-letter-spacing` | `0.08em` | Letter-spacing for the uppercase mono label | +| `--bs-press-any-key-padding-y` | `0.375rem` | Vertical padding on the inner text span | +| `--bs-press-any-key-padding-x` | `0.75rem` | Horizontal padding on the inner text span | +| `--bs-press-any-key-pulse-duration` | `1.8s` | Duration of the opacity-pulse animation cycle | +| `--bs-press-any-key-cursor` | `pointer` | Cursor style | + +```html + + + + + + + + + + + + + + + +``` diff --git a/examples/src/web-components/PressAnyKeyDemo.tsx b/examples/src/web-components/PressAnyKeyDemo.tsx new file mode 100644 index 00000000..bc252c31 --- /dev/null +++ b/examples/src/web-components/PressAnyKeyDemo.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const PressAnyKeyDemo: React.FC = () => { + const defaultRef = useRef(null) + const customRef = useRef(null) + + const [log, setLog] = useState([]) + + const appendLog = (msg: string) => setLog(l => [msg, ...l].slice(0, 8)) + + useEffect(() => { + const el = defaultRef.current + if (!el) return + const handler = (e: CustomEvent) => appendLog(`tc-continue — "${e.type}" @ ${new Date().toLocaleTimeString()}`) + el.addEventListener('tc-continue', handler as EventListener) + return () => el.removeEventListener('tc-continue', handler as EventListener) + }, []) + + useEffect(() => { + const el = customRef.current + if (!el) return + const handler = (e: CustomEvent) => appendLog(`tc-continue (custom text) @ ${new Date().toLocaleTimeString()}`) + el.addEventListener('tc-continue', handler as EventListener) + return () => el.removeEventListener('tc-continue', handler as EventListener) + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="PressAnyKey" + description="Pulsing mono prompt that emits tc-continue on any non-modifier keydown (document-level) or mousedown. Use on splash screens, title screens, or any transition gate." + /> + +
    + + +
    + {/* @ts-ignore */} + +
    +
    + Event log + {log.length === 0 ? ( + Click the element or press any key… + ) : ( +
      + {log.map((line, i) => ( +
    • {line}
    • + ))} +
    + )} +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +

    + Disabled: event does not fire; animation is frozen; opacity reduced to 40%. +

    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + +
    +
    +
    +
    +
    + ) +} + +export default PressAnyKeyDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 4e53caab..d92c348b 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -246,6 +246,7 @@ import PauseMenuDemo from './PauseMenuDemo' import PauseScreenDemo from './PauseScreenDemo' import PerkPickerDemo from './PerkPickerDemo' import PortraitDemo from './PortraitDemo' +import PressAnyKeyDemo from './PressAnyKeyDemo' import LiveFeedDemo from './LiveFeedDemo' import LoginDemo from './LoginDemo' import MarkdownEditorDemo from './MarkdownEditorDemo' @@ -648,4 +649,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'particle-emitter', category: 'Components', element: }, { key: 'perk-picker', category: 'Components', element: }, { key: 'portrait', category: 'Components', element: }, + { key: 'press-any-key', category: 'Components', element: }, ] diff --git a/web-components/src/PressAnyKey.ts b/web-components/src/PressAnyKey.ts new file mode 100644 index 00000000..d663d816 --- /dev/null +++ b/web-components/src/PressAnyKey.ts @@ -0,0 +1,88 @@ +const TAG_NAME = 'tc-press-any-key' + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +// Keys that should NOT trigger tc-continue; they are modifier-only presses. +const IGNORED_KEYS = new Set(['Tab', 'Shift', 'Control', 'Alt', 'Meta']) + +export class PressAnyKey extends HTMLElement { + + private _initialised = false + + /** Optional callback invoked synchronously alongside the tc-continue event. */ + onContinue: ((e: CustomEvent) => void) | null = null + + static get observedAttributes(): string[] { + return ['text', 'disabled'] + } + + private _keyHandler = (e: KeyboardEvent): void => { + if (!this.isConnected || this.disabled) return + if (IGNORED_KEYS.has(e.key)) return + this._fire() + } + + private _mouseHandler = (): void => { + if (this.disabled) return + this._fire() + } + + connectedCallback(): void { + if (!this._initialised) { + if (!this.hasAttribute('role')) this.setAttribute('role', 'button') + if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0') + this.render() + this._initialised = true + } + // Listeners re-attached every connect so they survive disconnect/reconnect. + document.addEventListener('keydown', this._keyHandler) + this.addEventListener('mousedown', this._mouseHandler) + } + + disconnectedCallback(): void { + document.removeEventListener('keydown', this._keyHandler) + this.removeEventListener('mousedown', this._mouseHandler) + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get text(): string { + return this.getAttribute('text') ?? 'Press Any Key' + } + set text(v: string) { + if (v) this.setAttribute('text', v) + else this.removeAttribute('text') + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + private _fire(): void { + const ev = new CustomEvent('tc-continue', { bubbles: true, composed: true }) + this.dispatchEvent(ev) + if (typeof this.onContinue === 'function') this.onContinue(ev) + } + + private render(): void { + this.classList.add('tc-press-any-key') + this.innerHTML = `${esc(this.text)}` + } +} + +declare global { + interface HTMLElementTagNameMap { [TAG_NAME]: PressAnyKey } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 2da5c304..b97db9e3 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -325,3 +325,4 @@ export * from './PauseMenu' export * from './PauseScreen' export * from './PerkPicker' export * from './Portrait' +export * from './PressAnyKey' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 268abedb..442665c5 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -322,6 +322,7 @@ import { PauseMenu } from './PauseMenu' import { PauseScreen } from './PauseScreen' import { PerkPicker } from './PerkPicker' import { Portrait } from './Portrait' +import { PressAnyKey } from './PressAnyKey' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -654,4 +655,5 @@ export function register(): void { customElements.define('tc-pause-screen', PauseScreen) customElements.define('tc-perk-picker', PerkPicker) customElements.define('tc-portrait', Portrait) + customElements.define('tc-press-any-key', PressAnyKey) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 769fba48..b66f3e54 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -315,6 +315,7 @@ @forward 'particle-emitter'; @forward 'perk-picker'; @forward 'portrait'; +@forward 'press-any-key'; @forward 'ping-display'; @forward 'player-card'; @forward 'player-frame'; diff --git a/web-components/style/components/_press-any-key.scss b/web-components/style/components/_press-any-key.scss new file mode 100644 index 00000000..3e3df7f6 --- /dev/null +++ b/web-components/style/components/_press-any-key.scss @@ -0,0 +1,82 @@ +// tc-press-any-key — "press any key to continue" prompt. +// Port of gc-press-any-key; game chrome dropped in favour of the slate design +// system: mono uppercase text with a pulsing opacity animation to draw attention. +// The host element carries role="button" and tabindex="0" so it participates in +// keyboard navigation — pressing any non-modifier key on the document fires the +// tc-continue event. +// All cosmetics flow through --bs-press-any-key-* custom properties. + +@use '../foundation/tokens' as *; + +tc-press-any-key { + --bs-press-any-key-color: var(--tc-text-muted); + --bs-press-any-key-color-hover: var(--tc-text); + --bs-press-any-key-font-size: 0.8125rem; // 13px — mono micro-label tier + --bs-press-any-key-font-weight: 500; + --bs-press-any-key-letter-spacing: 0.08em; + --bs-press-any-key-padding-y: 0.375rem; + --bs-press-any-key-padding-x: 0.75rem; + --bs-press-any-key-pulse-duration: 1.8s; + --bs-press-any-key-cursor: pointer; +} + +// ── Host ──────────────────────────────────────────────────────────────────── + +.tc-press-any-key { + display: block; + text-align: center; + cursor: var(--bs-press-any-key-cursor); + user-select: none; + -webkit-user-select: none; +} + +// ── Text span ──────────────────────────────────────────────────────────────── + +.tc-press-any-key__text { + display: inline-block; + padding: var(--bs-press-any-key-padding-y) var(--bs-press-any-key-padding-x); + font-family: 'JetBrains Mono', monospace; + font-size: var(--bs-press-any-key-font-size); + font-weight: var(--bs-press-any-key-font-weight); + letter-spacing: var(--bs-press-any-key-letter-spacing); + text-transform: uppercase; + color: var(--bs-press-any-key-color); + border-radius: 0; + animation: tc-press-any-key-pulse var(--bs-press-any-key-pulse-duration) ease-in-out infinite; +} + +@keyframes tc-press-any-key-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +// ── States ─────────────────────────────────────────────────────────────────── + +tc-press-any-key:hover:not([disabled]) .tc-press-any-key__text { + color: var(--bs-press-any-key-color-hover); +} + +// The global :focus-visible rule in _reset.scss handles the outline; no override +// needed unless a component-specific focus appearance is required. + +tc-press-any-key[disabled] { + opacity: 0.4; + pointer-events: none; + cursor: default; +} + +// Freeze the pulse when the element is disabled so it reads as inactive. +tc-press-any-key[disabled] .tc-press-any-key__text { + animation: none; +} + +// ── Touch target ───────────────────────────────────────────────────────────── + +@media (pointer: coarse) { + .tc-press-any-key__text { + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 1717af35..9356e9b8 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -349,6 +349,7 @@ tc-player-frame, tc-particle-emitter, tc-perk-picker, tc-portrait, +tc-press-any-key, tc-roadmap { display: block; } From a9b8f5fb770773f726e203273a0246aced790340 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 01:52:06 +0000 Subject: [PATCH 362/632] 357-quest-tracker: Build the tc-quest-tracker web component (QuestTracker game-components port) --- examples/public/web-components/SKILL.md | 106 ++++++++ .../src/web-components/QuestTrackerDemo.tsx | 108 ++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/QuestTracker.ts | 131 ++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_quest-tracker.scss | 236 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 588 insertions(+) create mode 100644 examples/src/web-components/QuestTrackerDemo.tsx create mode 100644 web-components/src/QuestTracker.ts create mode 100644 web-components/style/components/_quest-tracker.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 9b320f31..14c98335 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -250,6 +250,7 @@ After `register()` you can author markup directly: - [tc-perk-picker](#tc-perk-picker) - [tc-portrait](#tc-portrait) - [tc-press-any-key](#tc-press-any-key) + - [tc-quest-tracker](#tc-quest-tracker) - [tc-pause-screen](#tc-pause-screen) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) @@ -23554,3 +23555,108 @@ Standalone character portrait frame. Displays a glyph (emoji, initials, unicode " > ``` + +--- + +### tc-quest-tracker + +On-screen quest-objectives tracker. Renders a header title, a list of named quests, and per-quest objective rows with a checkbox indicator, label, optional badge, progress count, and a 3 px progress bar. Objectives support `completed` (struck-through, muted) and `optional` states. All content is driven by the `quests` JS property. Sharp corners; slate neutrals; JetBrains Mono for the header and progress counts. + +**Tag:** `tc-quest-tracker` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `tracker-title` | string | `'Active Quests'` | Text displayed in the component header. | + +**JS Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `trackerTitle` | `string` | `'Active Quests'` | Reflected JS accessor for the `tracker-title` attribute. | +| `quests` | `QuestEntry[]` | `[]` | Array of quest entries. Setting this property re-renders the tracker. | + +**QuestEntry shape** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | `string` | yes | Unique quest identifier (stamped as `data-id` on the quest element). | +| `name` | `string` | yes | Quest display name shown as the quest heading. | +| `objectives` | `QuestObjective[]` | yes | Ordered list of objectives for this quest. | + +**QuestObjective shape** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | `string` | yes | Unique objective identifier (stamped as `data-id` on the objective element). | +| `label` | `string` | yes | Objective display text. | +| `progress` | `number` | no | Current progress value. Renders a progress bar and count when both `progress` and `target` are set. | +| `target` | `number` | no | Target value for the progress bar. Must be `> 0` for the bar to render. | +| `completed` | `boolean` | no | When `true`, strikes through the label and mutes the text. | +| `optional` | `boolean` | no | When `true`, appends an `(optional)` mono label after the objective text. | + +**Events** + +None. `tc-quest-tracker` is purely data-driven via the `quests` JS property. + +**Slots** + +None. + +**CSS custom properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-quest-tracker-bg` | `var(--tc-surface)` | Panel background colour. | +| `--bs-quest-tracker-border` | `1px solid var(--tc-border)` | Outer border of the panel. | +| `--bs-quest-tracker-header-bg` | `var(--tc-surface-muted)` | Header background colour. | +| `--bs-quest-tracker-header-border` | `1px solid var(--tc-border)` | Border below the header. | +| `--bs-quest-tracker-title-color` | `var(--tc-text-muted)` | Header title text colour. | +| `--bs-quest-tracker-title-font-size` | `0.6875rem` | Header title font size. | +| `--bs-quest-tracker-quest-border` | `1px solid var(--tc-slate-100)` | Hairline separator between quests. | +| `--bs-quest-tracker-quest-name-color` | `var(--tc-text)` | Quest name colour. | +| `--bs-quest-tracker-quest-name-font-size` | `0.8125rem` | Quest name font size. | +| `--bs-quest-tracker-objective-label-color` | `var(--tc-text)` | Objective label colour at rest. | +| `--bs-quest-tracker-objective-label-font-size` | `0.8125rem` | Objective label font size. | +| `--bs-quest-tracker-objective-completed-color` | `var(--tc-text-faint)` | Objective label colour when completed. | +| `--bs-quest-tracker-objective-optional-color` | `var(--tc-text-faint)` | Optional badge text colour. | +| `--bs-quest-tracker-objective-optional-font-size` | `0.6875rem` | Optional badge font size. | +| `--bs-quest-tracker-check-size` | `0.625rem` | Width and height of the check indicator box. | +| `--bs-quest-tracker-check-color` | `var(--tc-border-strong)` | Border colour of the unchecked indicator. | +| `--bs-quest-tracker-check-done-color` | `var(--tc-app-accent)` | Fill and border colour of the completed indicator. | +| `--bs-quest-tracker-count-color` | `var(--tc-text-muted)` | Progress count text colour. | +| `--bs-quest-tracker-count-font-size` | `0.75rem` | Progress count font size. | +| `--bs-quest-tracker-bar-bg` | `var(--tc-border)` | Progress bar track colour. | +| `--bs-quest-tracker-bar-fill` | `var(--tc-app-accent)` | Progress bar fill colour. | +| `--bs-quest-tracker-bar-height` | `3px` | Progress bar height. | +| `--bs-quest-tracker-empty-color` | `var(--tc-text-faint)` | Empty state text colour. | + +**Example** + +```html + + + +``` diff --git a/examples/src/web-components/QuestTrackerDemo.tsx b/examples/src/web-components/QuestTrackerDemo.tsx new file mode 100644 index 00000000..a5cad3f9 --- /dev/null +++ b/examples/src/web-components/QuestTrackerDemo.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useRef } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +interface QuestObjective { + id: string + label: string + progress?: number + target?: number + completed?: boolean + optional?: boolean +} + +interface QuestEntry { + id: string + name: string + objectives: QuestObjective[] +} + +const BASIC_QUESTS: QuestEntry[] = [ + { + id: 'q1', + name: 'The Lost Heirloom', + objectives: [ + { id: 'o1', label: 'Speak to Lady Elara', completed: true }, + { id: 'o2', label: 'Find the heirloom', progress: 1, target: 3 }, + { id: 'o3', label: 'Defeat the guardian', optional: true }, + ], + }, + { + id: 'q2', + name: 'Wolves at the Gate', + objectives: [ + { id: 'o1', label: 'Slay wolves', progress: 4, target: 6 }, + ], + }, +] + +const ALL_COMPLETED: QuestEntry[] = [ + { + id: 'q3', + name: 'Supply Run', + objectives: [ + { id: 'o1', label: 'Gather rations', completed: true }, + { id: 'o2', label: 'Return to camp', completed: true }, + ], + }, +] + +const EMPTY_QUESTS: QuestEntry[] = [] + +const QuestTrackerDemo: React.FC = () => { + const basicRef = useRef(null) + const completedRef = useRef(null) + const emptyRef = useRef(null) + + useEffect(() => { + if (basicRef.current) basicRef.current.quests = BASIC_QUESTS + }, []) + + useEffect(() => { + if (completedRef.current) completedRef.current.quests = ALL_COMPLETED + }, []) + + useEffect(() => { + if (emptyRef.current) emptyRef.current.quests = EMPTY_QUESTS + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="QuestTracker" + description="On-screen quest-objectives tracker with per-objective progress bars, completed/optional states, and a configurable header title." + /> + +
    + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default QuestTrackerDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index d92c348b..eba3f8c7 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -247,6 +247,7 @@ import PauseScreenDemo from './PauseScreenDemo' import PerkPickerDemo from './PerkPickerDemo' import PortraitDemo from './PortraitDemo' import PressAnyKeyDemo from './PressAnyKeyDemo' +import QuestTrackerDemo from './QuestTrackerDemo' import LiveFeedDemo from './LiveFeedDemo' import LoginDemo from './LoginDemo' import MarkdownEditorDemo from './MarkdownEditorDemo' @@ -650,4 +651,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'perk-picker', category: 'Components', element: }, { key: 'portrait', category: 'Components', element: }, { key: 'press-any-key', category: 'Components', element: }, + { key: 'quest-tracker', category: 'Components', element: }, ] diff --git a/web-components/src/QuestTracker.ts b/web-components/src/QuestTracker.ts new file mode 100644 index 00000000..e94271e4 --- /dev/null +++ b/web-components/src/QuestTracker.ts @@ -0,0 +1,131 @@ +const TAG_NAME = 'tc-quest-tracker' + +export interface QuestObjective { + id: string + label: string + progress?: number + target?: number + completed?: boolean + optional?: boolean +} + +export interface QuestEntry { + id: string + name: string + objectives: QuestObjective[] +} + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +export class QuestTracker extends HTMLElement { + private _initialised = false + private _quests: QuestEntry[] = [] + + static get observedAttributes(): string[] { + return ['tracker-title'] + } + + connectedCallback(): void { + if (!this._initialised) { + if (!this.hasAttribute('role')) this.setAttribute('role', 'group') + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get trackerTitle(): string { + return this.getAttribute('tracker-title') ?? 'Active Quests' + } + set trackerTitle(v: string) { + if (v) this.setAttribute('tracker-title', v) + else this.removeAttribute('tracker-title') + } + + get quests(): QuestEntry[] { + return this._quests.slice() + } + set quests(value: QuestEntry[]) { + this._quests = Array.isArray(value) ? value.slice() : [] + if (this._initialised) this.render() + } + + private render(): void { + const questsHtml = this._quests.map(q => { + const objectivesHtml = q.objectives.map(o => { + const hasProgress = + typeof o.progress === 'number' && + typeof o.target === 'number' && + o.target > 0 + const pct = hasProgress + ? Math.max(0, Math.min(100, ((o.progress as number) / (o.target as number)) * 100)) + : 0 + + const stateCls = [ + o.completed ? ' tc-quest-tracker-objective--completed' : '', + o.optional ? ' tc-quest-tracker-objective--optional' : '', + ].join('') + + const checkHtml = o.completed + ? `` + : `` + + const progressCountHtml = hasProgress + ? `${o.progress}/${o.target}` + : '' + + const progressBarHtml = hasProgress + ? `
    +
    +
    ` + : '' + + const optionalHtml = o.optional + ? `(optional)` + : '' + + return `
    +
    + ${checkHtml} + ${esc(o.label)} + ${optionalHtml} + ${progressCountHtml} +
    + ${progressBarHtml} +
    ` + }).join('') + + return `
    +
    ${esc(q.name)}
    +
    ${objectivesHtml}
    +
    ` + }).join('') + + const emptyHtml = this._quests.length === 0 + ? `

    No active quests

    ` + : '' + + this.innerHTML = `
    +
    + ${esc(this.trackerTitle)} +
    +
    ${questsHtml}${emptyHtml}
    +
    ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: QuestTracker + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index b97db9e3..7c7e6d75 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -326,3 +326,4 @@ export * from './PauseScreen' export * from './PerkPicker' export * from './Portrait' export * from './PressAnyKey' +export * from './QuestTracker' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 442665c5..f72f204a 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -323,6 +323,7 @@ import { PauseScreen } from './PauseScreen' import { PerkPicker } from './PerkPicker' import { Portrait } from './Portrait' import { PressAnyKey } from './PressAnyKey' +import { QuestTracker } from './QuestTracker' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -656,4 +657,5 @@ export function register(): void { customElements.define('tc-perk-picker', PerkPicker) customElements.define('tc-portrait', Portrait) customElements.define('tc-press-any-key', PressAnyKey) + customElements.define('tc-quest-tracker', QuestTracker) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index b66f3e54..6a3157e9 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -319,3 +319,4 @@ @forward 'ping-display'; @forward 'player-card'; @forward 'player-frame'; +@forward 'quest-tracker'; diff --git a/web-components/style/components/_quest-tracker.scss b/web-components/style/components/_quest-tracker.scss new file mode 100644 index 00000000..443c86af --- /dev/null +++ b/web-components/style/components/_quest-tracker.scss @@ -0,0 +1,236 @@ +// tc-quest-tracker — on-screen quest / objective tracker. +// Slate neutrals; JetBrains Mono for the header and progress counts; sharp corners; 1px hairlines. +// Completed objectives are struck through and muted; optional ones carry a faint secondary label. +// All cosmetics flow through --bs-quest-tracker-* custom properties. + +tc-quest-tracker { + // Panel + --bs-quest-tracker-bg: var(--tc-surface); + --bs-quest-tracker-border: 1px solid var(--tc-border); + + // Header + --bs-quest-tracker-header-bg: var(--tc-surface-muted); + --bs-quest-tracker-header-border: 1px solid var(--tc-border); + --bs-quest-tracker-title-color: var(--tc-text-muted); + --bs-quest-tracker-title-font-size: 0.6875rem; + + // Quest name + --bs-quest-tracker-quest-border: 1px solid var(--tc-slate-100, #f1f5f9); + --bs-quest-tracker-quest-name-color: var(--tc-text); + --bs-quest-tracker-quest-name-font-size: 0.8125rem; + + // Objectives + --bs-quest-tracker-objective-label-color: var(--tc-text); + --bs-quest-tracker-objective-label-font-size: 0.8125rem; + --bs-quest-tracker-objective-completed-color: var(--tc-text-faint); + --bs-quest-tracker-objective-optional-color: var(--tc-text-faint); + --bs-quest-tracker-objective-optional-font-size: 0.6875rem; + + // Check indicator + --bs-quest-tracker-check-size: 0.625rem; + --bs-quest-tracker-check-color: var(--tc-border-strong); + --bs-quest-tracker-check-done-color: var(--tc-app-accent); + + // Progress count + --bs-quest-tracker-count-color: var(--tc-text-muted); + --bs-quest-tracker-count-font-size: 0.75rem; + + // Progress bar + --bs-quest-tracker-bar-bg: var(--tc-border); + --bs-quest-tracker-bar-fill: var(--tc-app-accent); + --bs-quest-tracker-bar-height: 3px; + + // Empty state + --bs-quest-tracker-empty-color: var(--tc-text-faint); +} + +// ── Panel shell ──────────────────────────────────────────────────────────────── + +.tc-quest-tracker { + display: flex; + flex-direction: column; + background: var(--bs-quest-tracker-bg); + border: var(--bs-quest-tracker-border); + border-radius: 0; + overflow: hidden; +} + +// ── Header ───────────────────────────────────────────────────────────────────── + +.tc-quest-tracker-header { + display: flex; + align-items: center; + padding: 0.5rem 0.75rem; + background: var(--bs-quest-tracker-header-bg); + border-bottom: var(--bs-quest-tracker-header-border); +} + +.tc-quest-tracker-title { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-quest-tracker-title-font-size); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bs-quest-tracker-title-color); + line-height: 1.4; +} + +// ── Quest list ───────────────────────────────────────────────────────────────── + +.tc-quest-tracker-quests { + display: flex; + flex-direction: column; +} + +.tc-quest-tracker-quest { + padding: 0.5rem 0.75rem; + border-bottom: var(--bs-quest-tracker-quest-border); + + &:last-child { + border-bottom: none; + } +} + +.tc-quest-tracker-quest-name { + font-size: var(--bs-quest-tracker-quest-name-font-size); + font-weight: 600; + color: var(--bs-quest-tracker-quest-name-color); + line-height: 1.4; + margin-bottom: 0.375rem; +} + +// ── Objectives ───────────────────────────────────────────────────────────────── + +.tc-quest-tracker-objectives { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.tc-quest-tracker-objective { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.tc-quest-tracker-objective-row { + display: flex; + align-items: center; + gap: 0.375rem; + min-height: 24px; +} + +// ── Check indicator ──────────────────────────────────────────────────────────── + +.tc-quest-tracker-objective-check { + flex-shrink: 0; + width: var(--bs-quest-tracker-check-size); + height: var(--bs-quest-tracker-check-size); + border: 1px solid var(--bs-quest-tracker-check-color); + border-radius: 0; + background: transparent; + transition: background var(--tc-transition-fast, 0.15s ease), border-color var(--tc-transition-fast, 0.15s ease); + + &--done { + border-color: var(--bs-quest-tracker-check-done-color); + background: var(--bs-quest-tracker-check-done-color); + position: relative; + + // Checkmark via clip-path + &::after { + content: ''; + display: block; + position: absolute; + inset: 0; + // Small tick: white SVG injected via mask so it recolors correctly + background: #fff; + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Cpolyline points='1.5,5.5 4,8 8.5,2' fill='none' stroke='%23fff' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + mask-size: cover; + } + } +} + +// ── Objective label ──────────────────────────────────────────────────────────── + +.tc-quest-tracker-objective-label { + font-size: var(--bs-quest-tracker-objective-label-font-size); + color: var(--bs-quest-tracker-objective-label-color); + line-height: 1.4; + flex: 1 1 auto; + min-width: 0; +} + +.tc-quest-tracker-objective--completed .tc-quest-tracker-objective-label { + color: var(--bs-quest-tracker-objective-completed-color); + text-decoration: line-through; + text-decoration-color: var(--bs-quest-tracker-objective-completed-color); +} + +// ── Optional badge ───────────────────────────────────────────────────────────── + +.tc-quest-tracker-objective-optional { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-quest-tracker-objective-optional-font-size); + color: var(--bs-quest-tracker-objective-optional-color); + flex-shrink: 0; + line-height: 1.4; +} + +// ── Progress count ───────────────────────────────────────────────────────────── + +.tc-quest-tracker-objective-count { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-quest-tracker-count-font-size); + color: var(--bs-quest-tracker-count-color); + flex-shrink: 0; + line-height: 1.4; + margin-left: auto; +} + +// ── Progress bar ─────────────────────────────────────────────────────────────── + +.tc-quest-tracker-objective-bar { + height: var(--bs-quest-tracker-bar-height); + background: var(--bs-quest-tracker-bar-bg); + border-radius: 0; + overflow: hidden; + // indented to align with the label (check width + gap) + margin-left: calc(var(--bs-quest-tracker-check-size) + 0.375rem); +} + +.tc-quest-tracker-objective-bar-fill { + height: 100%; + background: var(--bs-quest-tracker-bar-fill); + border-radius: 0; + transition: width var(--tc-transition-base, 0.2s cubic-bezier(0.4, 0, 0.2, 1)); +} + +// ── Empty state ──────────────────────────────────────────────────────────────── + +.tc-quest-tracker-empty { + padding: 1rem 0.75rem; + font-size: 0.8125rem; + color: var(--bs-quest-tracker-empty-color); + text-align: center; + margin: 0; +} + +// ── 44 px coarse touch targets ───────────────────────────────────────────────── + +@media (pointer: coarse) { + .tc-quest-tracker-objective-row { + min-height: 44px; + } +} + +// ── Reduced motion ───────────────────────────────────────────────────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-quest-tracker-objective-bar-fill { + transition: none; + } + + .tc-quest-tracker-objective-check { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 9356e9b8..557dc732 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -350,6 +350,7 @@ tc-particle-emitter, tc-perk-picker, tc-portrait, tc-press-any-key, +tc-quest-tracker, tc-roadmap { display: block; } From b973a2fc1884531e86e88901d2660e65d11e7e66 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 02:07:44 +0000 Subject: [PATCH 363/632] 358-radial-wheel: Build the tc-radial-wheel web component (RadialWheel game-components port) --- examples/public/web-components/SKILL.md | 112 ++++++++++ .../src/web-components/RadialWheelDemo.tsx | 158 +++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/RadialWheel.ts | 210 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_radial-wheel.scss | 209 +++++++++++++++++ web-components/style/foundation/_reset.scss | 7 + 9 files changed, 702 insertions(+) create mode 100644 examples/src/web-components/RadialWheelDemo.tsx create mode 100644 web-components/src/RadialWheel.ts create mode 100644 web-components/style/components/_radial-wheel.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 14c98335..db925102 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -251,6 +251,7 @@ After `register()` you can author markup directly: - [tc-portrait](#tc-portrait) - [tc-press-any-key](#tc-press-any-key) - [tc-quest-tracker](#tc-quest-tracker) + - [tc-radial-wheel](#tc-radial-wheel) - [tc-pause-screen](#tc-pause-screen) - [tc-level-header](#tc-level-header) - [tc-level-select](#tc-level-select) @@ -23660,3 +23661,114 @@ None. ] ``` + +--- + +### tc-radial-wheel + +**Tag:** `tc-radial-wheel` + +**Description:** Modal radial (pie) item / ability selector. A fixed-position overlay displaying a circular disc with options arranged radially. Hovering an option shows its label in the center of the disc. Clicking the backdrop or pressing Escape closes the wheel. Port of `gc-radial-wheel` from `@toolcase/game-components`, restyled to the toolcase design system. + +--- + +#### Attributes + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `open` | boolean | `false` | Present → wheel is visible. Absent → wheel is hidden. | +| `radius` | number | `120` | Distance in px from the disc center to each option button center. | +| `option-size` | number | `56` | Diameter in px of each circular option button. | +| `center-label` | string | `''` | Text shown in the disc center when no option is hovered. | + +--- + +#### JS Properties + +| Property | Type | Default | Description | +|---|---|---|---| +| `options` | `RadialOption[]` | `[]` | Array of option objects. Setting this re-renders the wheel. | +| `onSelect` | `((id: string) => void) \| null` | `null` | Optional callback fired alongside `tc-select`. | +| `onClose` | `(() => void) \| null` | `null` | Optional callback fired alongside `tc-close`. | + +**`RadialOption` shape:** + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | `string` | yes | Unique identifier for the option. | +| `icon` | `string` | no | Icon character/emoji displayed in the button. Defaults to `●`. | +| `label` | `string` | no | Human-readable label shown in the disc center on hover. | +| `color` | `string` | no | CSS color applied as `--bs-radial-wheel-option-color` on the button. | +| `disabled` | `boolean` | no | Prevents selection and dims the button. | + +--- + +#### Events + +| Event | Detail | Description | +|---|---|---| +| `tc-select` | `{ id: string }` | Fired when a non-disabled option is clicked. The wheel does **not** auto-close on select. | +| `tc-close` | `{}` | Fired when the backdrop is clicked or Escape is pressed. The wheel self-closes (removes `[open]`). | + +--- + +#### Custom properties + +| Property | Default | Description | +|---|---|---| +| `--bs-radial-wheel-backdrop-bg` | `#0f172a` | Backdrop scrim color. | +| `--bs-radial-wheel-backdrop-opacity` | `0.55` | Backdrop scrim opacity. | +| `--bs-radial-wheel-z-backdrop` | `var(--tc-z-modal-backdrop)` | Z-index of the backdrop layer. | +| `--bs-radial-wheel-z-disc` | `var(--tc-z-modal)` | Z-index of the disc + options layer. | +| `--bs-radial-wheel-disc-bg` | `var(--tc-surface)` | Circular disc background. | +| `--bs-radial-wheel-disc-border` | `var(--tc-border-strong)` | Disc hairline border color. | +| `--bs-radial-wheel-disc-shadow` | `var(--tc-shadow-lg)` | Disc drop shadow. | +| `--bs-radial-wheel-option-bg` | `var(--tc-surface)` | Option button background at rest. | +| `--bs-radial-wheel-option-border` | `var(--tc-border-strong)` | Option button border. | +| `--bs-radial-wheel-option-color` | `var(--tc-text)` | Option icon/text color at rest (overridden per-option via `options[].color`). | +| `--bs-radial-wheel-option-hover-bg` | `var(--tc-surface-muted)` | Option background on hover. | +| `--bs-radial-wheel-option-hover-color` | `var(--tc-app-accent)` | Option icon color on hover. | +| `--bs-radial-wheel-option-shadow` | `var(--tc-shadow-sm)` | Option button shadow at rest. | +| `--bs-radial-wheel-option-hover-shadow` | `var(--tc-shadow-md)` | Option button shadow on hover. | +| `--bs-radial-wheel-center-color` | `var(--tc-text-muted)` | Center label text color. | +| `--bs-radial-wheel-center-size` | `0.75rem` | Center label font size. | + +--- + +#### Slots + +None. All content is driven by the `options` JS property. + +--- + +#### Example + +```html + + + +``` diff --git a/examples/src/web-components/RadialWheelDemo.tsx b/examples/src/web-components/RadialWheelDemo.tsx new file mode 100644 index 00000000..8093aa98 --- /dev/null +++ b/examples/src/web-components/RadialWheelDemo.tsx @@ -0,0 +1,158 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const DEFAULT_OPTIONS = [ + { id: 'sword', icon: '⚔', label: 'Sword', color: 'var(--tc-app-accent)' }, + { id: 'bow', icon: '🏹', label: 'Bow', color: '#22c55e' }, + { id: 'staff', icon: '✦', label: 'Staff', color: '#a855f7' }, + { id: 'shield', icon: '🛡', label: 'Shield', color: '#0ea5e9' }, + { id: 'potion', icon: '⚕', label: 'Potion', color: '#ef4444' }, + { id: 'horn', icon: '☩', label: 'Horn (disabled)', disabled: true }, +] + +const RadialWheelDemo: React.FC = () => { + const wheelRef = useRef(null) + const [open, setOpen] = useState(false) + const [last, setLast] = useState(null) + + // Set JS-property options on mount. + useEffect(() => { + if (!wheelRef.current) return + wheelRef.current.options = DEFAULT_OPTIONS + }, []) + + // Mirror React state → [open] attribute. + useEffect(() => { + if (!wheelRef.current) return + if (open) wheelRef.current.setAttribute('open', '') + else wheelRef.current.removeAttribute('open') + }, [open]) + + // Listen for tc-select and tc-close. + useEffect(() => { + const el = wheelRef.current + if (!el) return + const onSelect = (e: CustomEvent) => { + setLast(`tc-select — id: "${e.detail.id}"`) + setOpen(false) + } + const onClose = () => setOpen(false) + el.addEventListener('tc-select', onSelect) + el.addEventListener('tc-close', onClose) + return () => { + el.removeEventListener('tc-select', onSelect) + el.removeEventListener('tc-close', onClose) + } + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="Radial Wheel" + description="Modal radial (pie) item / ability selector. Options are set via the JS options property. Hover shows a label in the centre; Escape and backdrop click close the wheel. All cosmetics flow through --bs-radial-wheel-* custom properties." + /> + +
    + +

    + Click the button to open the wheel. Hover over a segment to see its label in the centre. + Click an option to select it; click the backdrop or press Escape to dismiss. +

    + + {last && ( + + Last event: {last} + + )} + + {/* The wheel renders as a fixed overlay so it can live anywhere in the tree. */} + {/* @ts-ignore */} + +
    + + + + + + +
    {`tc-radial-wheel {
    +  --bs-radial-wheel-backdrop-opacity: 0.7;
    +  --bs-radial-wheel-disc-bg: var(--tc-ink);
    +  --bs-radial-wheel-option-bg: var(--tc-surface-muted);
    +  --bs-radial-wheel-option-hover-color: var(--tc-accent);
    +}`}
    +
    +
    +
    +
    +
    +
    + ) +} + +/** Secondary demo — smaller wheel, no center label, fewer options. */ +const SmallWheelExample: React.FC = () => { + const ref = useRef(null) + const [open, setOpen] = useState(false) + const [last, setLast] = useState(null) + + useEffect(() => { + if (!ref.current) return + ref.current.options = [ + { id: 'fire', icon: '🔥', label: 'Fire' }, + { id: 'ice', icon: '❄', label: 'Ice' }, + { id: 'storm', icon: '⚡', label: 'Storm' }, + { id: 'earth', icon: '🪨', label: 'Earth' }, + ] + }, []) + + useEffect(() => { + if (!ref.current) return + if (open) ref.current.setAttribute('open', '') + else ref.current.removeAttribute('open') + }, [open]) + + useEffect(() => { + const el = ref.current + if (!el) return + const onSelect = (e: CustomEvent) => { + setLast(e.detail.id) + setOpen(false) + } + const onClose = () => setOpen(false) + el.addEventListener('tc-select', onSelect) + el.addEventListener('tc-close', onClose) + return () => { + el.removeEventListener('tc-select', onSelect) + el.removeEventListener('tc-close', onClose) + } + }, []) + + return ( + <> + + {last && Selected: {last}} + {/* @ts-ignore */} + + + ) +} + +export default RadialWheelDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index eba3f8c7..1e9ab518 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -196,6 +196,7 @@ import DamageNumberDemo from './DamageNumberDemo' import HitMarkerDemo from './HitMarkerDemo' import CreditsScrollDemo from './CreditsScrollDemo' import CycleWheelDemo from './CycleWheelDemo' +import RadialWheelDemo from './RadialWheelDemo' import DangerZoneActionsDemo from './DangerZoneActionsDemo' import MetricCardDemo from './MetricCardDemo' import SlicesCardDemo from './SlicesCardDemo' @@ -620,6 +621,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'toggle-card', category: 'Forms', element: }, { key: 'video-embed', category: 'Components', element: }, { key: 'cycle-wheel', category: 'Components', element: }, + { key: 'radial-wheel', category: 'Overlays & Feedback', element: }, { key: 'circular-progress', category: 'Components', element: }, { key: 'cooldown-badge', category: 'Components', element: }, { key: 'compass-bar', category: 'Content', element: }, diff --git a/web-components/src/RadialWheel.ts b/web-components/src/RadialWheel.ts new file mode 100644 index 00000000..c1f27c36 --- /dev/null +++ b/web-components/src/RadialWheel.ts @@ -0,0 +1,210 @@ +const TAG_NAME = 'tc-radial-wheel' + +export interface RadialOption { + id: string + icon?: string + label?: string + color?: string + disabled?: boolean +} + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +export class RadialWheel extends HTMLElement { + private _initialised = false + private _options: RadialOption[] = [] + private _hoverId: string | null = null + + /** Optional callback fired alongside `tc-select`. */ + onSelect: ((id: string) => void) | null = null + + /** Optional callback fired alongside `tc-close`. */ + onClose: (() => void) | null = null + + static get observedAttributes(): string[] { + return ['open', 'radius', 'option-size', 'center-label'] + } + + connectedCallback(): void { + if (!this._initialised) { + if (!this.hasAttribute('role')) this.setAttribute('role', 'menu') + this.render() + this._initialised = true + } + // Document-level handlers live for the connected lifetime so Escape + // and backdrop-click work whenever [open] is set from outside. + document.addEventListener('keydown', this._onKeydown) + this.addEventListener('click', this._onClick) + } + + disconnectedCallback(): void { + document.removeEventListener('keydown', this._onKeydown) + this.removeEventListener('click', this._onClick) + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get open(): boolean { + return this.hasAttribute('open') + } + set open(v: boolean) { + if (v) this.setAttribute('open', '') + else this.removeAttribute('open') + } + + get radius(): number { + const raw = this.getAttribute('radius') + if (raw == null) return 120 + const parsed = parseFloat(raw) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 120 + } + set radius(v: number) { + this.setAttribute('radius', String(v)) + } + + get optionSize(): number { + const raw = this.getAttribute('option-size') + if (raw == null) return 56 + const parsed = parseFloat(raw) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 56 + } + set optionSize(v: number) { + this.setAttribute('option-size', String(v)) + } + + get centerLabel(): string { + return this.getAttribute('center-label') ?? '' + } + set centerLabel(v: string) { + if (v) this.setAttribute('center-label', v) + else this.removeAttribute('center-label') + } + + get options(): RadialOption[] { + return this._options.slice() + } + set options(value: RadialOption[]) { + this._options = Array.isArray(value) ? value.slice() : [] + if (this._initialised) this.render() + } + + private _close(): void { + this.open = false + this.dispatchEvent(new CustomEvent('tc-close', { bubbles: true, composed: true, detail: {} })) + if (typeof this.onClose === 'function') this.onClose() + } + + private _onKeydown = (e: KeyboardEvent): void => { + if (!this.open) return + if (e.key === 'Escape') { + e.preventDefault() + this._close() + } + } + + // Backdrop click closes; option buttons stop propagation so they don't trigger this. + private _onClick = (e: Event): void => { + const target = e.target as Element | null + if (target?.closest('.tc-radial-wheel-option')) return + if (target?.classList.contains('tc-radial-wheel-backdrop')) { + this._close() + } + } + + private render(): void { + const radius = this.radius + const size = this.optionSize + const centerLabel = this.centerLabel + const hoverOption = this._options.find(o => o.id === this._hoverId) ?? null + + // Expose layout geometry as inline custom properties so the SCSS can + // drive the disc diameter without knowing the attribute values. + this.style.setProperty('--tc-rw-radius', `${radius}px`) + this.style.setProperty('--tc-rw-size', `${size}px`) + + const n = this._options.length + const optionsHtml = this._options.map((opt, i) => { + const angle = (i / Math.max(1, n)) * Math.PI * 2 - Math.PI / 2 + const x = Math.cos(angle) * radius + const y = Math.sin(angle) * radius + const isHover = opt.id === this._hoverId + const cls = [ + 'tc-radial-wheel-option', + opt.disabled ? 'tc-radial-wheel-option--disabled' : '', + isHover ? 'tc-radial-wheel-option--hover' : '', + ].filter(Boolean).join(' ') + // Per-option color fed through a custom property so the SCSS can + // expose it as --bs-radial-wheel-option-color on each button. + const colorStyle = opt.color ? `--bs-radial-wheel-option-color:${esc(opt.color)};` : '' + const icon = opt.icon ?? '●' + return ( + `` + ) + }).join('') + + const centerText = hoverOption?.label || centerLabel + const centerHtml = `
    ${centerText ? esc(centerText) : ''}
    ` + + this.innerHTML = ( + `` + + `
    ` + + centerHtml + + optionsHtml + + `
    ` + ) + + // Attach hover listeners on each option button after innerHTML write. + // Option click listeners also live here since buttons are replaced every render. + this.querySelectorAll('.tc-radial-wheel-option').forEach(btn => { + const id = btn.dataset.id ?? '' + const opt = this._options.find(o => o.id === id) + if (!opt) return + + btn.addEventListener('mouseenter', () => { + if (this._hoverId === id) return + this._hoverId = id + this.render() + }) + btn.addEventListener('mouseleave', () => { + if (this._hoverId !== id) return + this._hoverId = null + this.render() + }) + + if (!opt.disabled) { + btn.addEventListener('click', e => { + e.stopPropagation() + this.dispatchEvent(new CustomEvent('tc-select', { + bubbles: true, + composed: true, + detail: { id }, + })) + if (typeof this.onSelect === 'function') this.onSelect(id) + }) + } + }) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: RadialWheel + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 7c7e6d75..ad51abd6 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -199,6 +199,7 @@ export * from './DamageNumber' export * from './HitMarker' export * from './CreditsScroll' export * from './CycleWheel' +export * from './RadialWheel' export * from './DangerZoneActions' export * from './MetricCard' export * from './SlicesCard' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index f72f204a..b3b74ed5 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -194,6 +194,7 @@ import { DamageNumber } from './DamageNumber' import { HitMarker } from './HitMarker' import { CreditsScroll } from './CreditsScroll' import { CycleWheel } from './CycleWheel' +import { RadialWheel } from './RadialWheel' import { DangerZoneActions } from './DangerZoneActions' import { MetricCard } from './MetricCard' import { SlicesCard } from './SlicesCard' @@ -522,6 +523,7 @@ export function register(): void { customElements.define('tc-hit-marker', HitMarker) customElements.define('tc-credits-scroll', CreditsScroll) customElements.define('tc-cycle-wheel', CycleWheel) + customElements.define('tc-radial-wheel', RadialWheel) customElements.define('tc-danger-zone-actions', DangerZoneActions) customElements.define('tc-metric-card', MetricCard) customElements.define('tc-slices-card', SlicesCard) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 6a3157e9..ecbbceb9 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -203,6 +203,7 @@ @forward 'hit-marker'; @forward 'credits-scroll'; @forward 'cycle-wheel'; +@forward 'radial-wheel'; @forward 'danger-zone-actions'; @forward 'metric-card'; @forward 'slices-card'; diff --git a/web-components/style/components/_radial-wheel.scss b/web-components/style/components/_radial-wheel.scss new file mode 100644 index 00000000..fb1e0be3 --- /dev/null +++ b/web-components/style/components/_radial-wheel.scss @@ -0,0 +1,209 @@ +// tc-radial-wheel — radial (pie) item / ability selector. +// Port of gc-radial-wheel (game-components), restyled to the toolcase design +// system: slate neutrals, 1px hairline borders, sharp corners where applicable. +// SANCTIONED CIRCLES: the disc and each option button are genuinely circular +// shapes — an explicit exception to the sharp-corner rule. +// All cosmetics flow through --bs-radial-wheel-* custom properties. + +@use '../foundation/tokens' as *; + +// ── Custom-property defaults ──────────────────────────────────────────────── + +tc-radial-wheel { + // Backdrop scrim + --bs-radial-wheel-backdrop-bg: #0f172a; + --bs-radial-wheel-backdrop-opacity: 0.55; + + // Z-index band — overlay tier + --bs-radial-wheel-z-backdrop: var(--tc-z-modal-backdrop, 1050); + --bs-radial-wheel-z-disc: var(--tc-z-modal, 1055); + + // Disc (circular hub surface) + --bs-radial-wheel-disc-bg: var(--tc-surface); + --bs-radial-wheel-disc-border: var(--tc-border-strong); + --bs-radial-wheel-disc-shadow: var(--tc-shadow-lg); + + // Option buttons + --bs-radial-wheel-option-size: 56px; // overridden by JS inline var --tc-rw-size + --bs-radial-wheel-option-bg: var(--tc-surface); + --bs-radial-wheel-option-border: var(--tc-border-strong); + --bs-radial-wheel-option-color: var(--tc-text); + --bs-radial-wheel-option-hover-bg: var(--tc-surface-muted); + --bs-radial-wheel-option-hover-color: var(--tc-app-accent); + --bs-radial-wheel-option-shadow: var(--tc-shadow-sm); + --bs-radial-wheel-option-hover-shadow: var(--tc-shadow-md); + + // Center label + --bs-radial-wheel-center-color: var(--tc-text-muted); + --bs-radial-wheel-center-size: 0.75rem; + + // Transition + --bs-radial-wheel-transition: opacity var(--tc-transition-base, 0.2s cubic-bezier(0.4, 0, 0.2, 1)); +} + +// ── Host (hidden by default; shown when [open]) ───────────────────────────── + +// _reset.scss registers display:block; the component partial overrides that to +// display:none for the closed state (the element has no layout impact when +// absent). The open state switches to position:fixed so it never displaces +// document flow. +tc-radial-wheel:not([open]) { + display: none; +} + +tc-radial-wheel[open] { + position: fixed; + inset: 0; + z-index: var(--bs-radial-wheel-z-disc); + display: flex; + align-items: center; + justify-content: center; +} + +// ── Backdrop ──────────────────────────────────────────────────────────────── + +.tc-radial-wheel-backdrop { + position: fixed; + inset: 0; + z-index: -1; + background: var(--bs-radial-wheel-backdrop-bg); + opacity: var(--bs-radial-wheel-backdrop-opacity); +} + +// ── Disc (circular centred hub) ───────────────────────────────────────────── +// SANCTIONED CIRCLE: the disc is a circular surface by design. + +.tc-radial-wheel-disc { + // Diameter = 2*radius + optionSize; the JS sets --tc-rw-radius and --tc-rw-size. + // Fall back to the default radius (120) and option-size (56) for the CSS calc. + --_radius: var(--tc-rw-radius, 120px); + --_size: var(--tc-rw-size, 56px); + + position: relative; + width: calc(var(--_radius) * 2 + var(--_size)); + height: calc(var(--_radius) * 2 + var(--_size)); + border-radius: 50%; // SANCTIONED CIRCLE + background: var(--bs-radial-wheel-disc-bg); + border: 1px solid var(--bs-radial-wheel-disc-border); + box-shadow: var(--bs-radial-wheel-disc-shadow); +} + +// ── Center label ───────────────────────────────────────────────────────────── + +.tc-radial-wheel-center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-width: 7rem; + text-align: center; + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-radial-wheel-center-size); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bs-radial-wheel-center-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; + user-select: none; +} + +.tc-radial-wheel-center--empty { + // Keep the space reserved so the disc stays balanced when no label is set. + min-height: 1em; +} + +// ── Option buttons ───────────────────────────────────────────────────────── +// SANCTIONED CIRCLE: each option is a circular icon button. + +.tc-radial-wheel-option { + --_opt-size: var(--tc-rw-size, 56px); + + position: absolute; + transform: translate(-50%, -50%); + width: var(--_opt-size); + height: var(--_opt-size); + min-width: var(--_opt-size); + min-height: var(--_opt-size); + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--bs-radial-wheel-option-bg); + border: 1px solid var(--bs-radial-wheel-option-border); + border-radius: 50%; // SANCTIONED CIRCLE + box-shadow: var(--bs-radial-wheel-option-shadow); + color: var(--bs-radial-wheel-option-color); + cursor: pointer; + padding: 0; + outline: 0; + transition: + background-color var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease), + box-shadow var(--tc-transition-fast, 0.15s ease), + transform var(--tc-transition-fast, 0.15s ease); + + // Per-option color overrides the shared default when JS sets it inline. + &:not([disabled]) { + color: var(--bs-radial-wheel-option-color); + } + + &:hover:not([disabled]), + &.tc-radial-wheel-option--hover:not([disabled]) { + background: var(--bs-radial-wheel-option-hover-bg); + color: var(--bs-radial-wheel-option-hover-color); + box-shadow: var(--bs-radial-wheel-option-hover-shadow); + // Subtle lift — matches the standard state ladder 1px lift. + transform: translate(-50%, calc(-50% - 1px)) scale(1.08); + border-color: var(--tc-border-strong); + z-index: 1; + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + } + + &[disabled], + &.tc-radial-wheel-option--disabled { + opacity: 0.38; + cursor: not-allowed; + pointer-events: none; + } + + @media (pointer: coarse) { + // Enforce 44 px minimum coarse touch target. + --_opt-size: max(var(--tc-rw-size, 56px), 44px); + width: var(--_opt-size); + height: var(--_opt-size); + min-width: var(--_opt-size); + min-height: var(--_opt-size); + } +} + +.tc-radial-wheel-option-icon { + display: block; + font-size: calc(var(--tc-rw-size, 56px) * 0.4); + line-height: 1; + user-select: none; + // Use the per-option color override when provided by JS. + color: var(--bs-radial-wheel-option-color); +} + +// ── Reduced motion ─────────────────────────────────────────────────────────── +// Freeze the transform lift; keep the state transitions (bg, color, border). + +@media (prefers-reduced-motion: reduce) { + .tc-radial-wheel-option { + // Remove transform animation; keep bg/color/border-color transitions. + transition: + background-color var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease); + + &:hover:not([disabled]), + &.tc-radial-wheel-option--hover:not([disabled]) { + transform: translate(-50%, -50%); // no lift, no scale + } + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 557dc732..f5b8b7c5 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -396,6 +396,13 @@ tc-pause-screen { display: block; } +// Radial pie/ability selector overlay; the SCSS partial switches the host to +// position:fixed when [open] and hides it otherwise, but it must not default +// to inline. +tc-radial-wheel { + display: block; +} + // Fixed full-surface scrim; the SCSS partial flips it to a centred flex // container, but it must not default to inline. tc-blur-overlay { From d3b8854484f8869851fe784ac5f8d5ad28f6605f Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 02:15:00 +0000 Subject: [PATCH 364/632] 359-rarity-chip: Build the tc-rarity-chip web component (RarityChip game-components port) --- examples/public/web-components/SKILL.md | 62 ++++++++++++++ .../src/web-components/RarityChipDemo.tsx | 59 +++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/RarityChip.ts | 62 ++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_rarity-chip.scss | 82 +++++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 272 insertions(+) create mode 100644 examples/src/web-components/RarityChipDemo.tsx create mode 100644 web-components/src/RarityChip.ts create mode 100644 web-components/style/components/_rarity-chip.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index db925102..76656a87 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -92,6 +92,7 @@ After `register()` you can author markup directly: - [tc-interact-prompt](#tc-interact-prompt) - [tc-currency-chip](#tc-currency-chip) - [tc-currency-display](#tc-currency-display) + - [tc-rarity-chip](#tc-rarity-chip) - [tc-crosshair](#tc-crosshair) - [tc-section-flag](#tc-section-flag) - [tc-skeleton](#tc-skeleton) @@ -3079,6 +3080,67 @@ None. `tc-currency-display` is a purely presentational element. --- +### tc-rarity-chip + +Mono uppercase rarity label chip for item tiers: Common, Uncommon, Rare, Epic, Legendary, and Mythic. Port of game-components `gc-rarity-chip`, restyled to the design system — sharp corners, 1px hairline border, JetBrains Mono uppercase label, and per-rarity tinted text/border using the sanctioned token ramp. Fantasy chrome (gilded frames, glows, metal textures) is not reproduced. Purely presentational — no events, no slots. + +**Tag:** `tc-rarity-chip` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `rarity` | `'common' \| 'uncommon' \| 'rare' \| 'epic' \| 'legendary' \| 'mythic'` | `'common'` | Item rarity tier. Invalid or absent values fall back to `'common'` | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `rarity` | `ItemRarity` | Reflects the `rarity` attribute. Setter validates against the enum list; invalid values set `'common'` | + +**Slots** + +None. `tc-rarity-chip` renders entirely from its attributes. + +**Events** + +None. `tc-rarity-chip` is a purely presentational element. + +**CSS custom properties (theming)** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-rarity-chip-padding-x` | `0.5rem` | Horizontal padding | +| `--bs-rarity-chip-padding-y` | `0.1875rem` | Vertical padding | +| `--bs-rarity-chip-font-size` | `0.6875rem` | Label font size | +| `--bs-rarity-chip-font-weight` | `600` | Label font weight | +| `--bs-rarity-chip-letter-spacing` | `0.08em` | Uppercase letter spacing | +| `--bs-rarity-chip-bg` | `var(--tc-surface-muted)` | Chip background fill (overridden per rarity) | +| `--bs-rarity-chip-color` | `var(--tc-text-muted)` | Label text/border color (overridden per rarity) | +| `--bs-rarity-chip-border-color` | `var(--tc-border)` | Hairline border color (overridden per rarity) | +| `--bs-rarity-chip-border-width` | `1px` | Border width | + +```html + + + + + + + + + + + + + + +``` + +--- + ### tc-crosshair Configurable aiming reticle, ported from the `gc-crosshair` game component into the toolcase idiom. Six variants — `cross`, `dot`, `circle`, `tShape`, `classic`, `rune` — with knobs for overall `size`, arm `thickness`, center `gap`, `color`, and a `spread` offset added to the gap (for a recoil/bloom effect). The fantasy chrome is dropped: arms and the center pip are sharp ink shapes, the only curve is the sanctioned ring, and the `rune` variant is reworked into a sharp rotated-square marker. The host is decorative (`aria-hidden="true"`) and pointer-transparent so it can sit as an overlay marker. Purely presentational — no events. diff --git a/examples/src/web-components/RarityChipDemo.tsx b/examples/src/web-components/RarityChipDemo.tsx new file mode 100644 index 00000000..eb3d7388 --- /dev/null +++ b/examples/src/web-components/RarityChipDemo.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const RarityChipDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="RarityChip" + description="Mono uppercase rarity label chip: Common through Mythic. Port of game-components gc-rarity-chip, restyled to the design system with sharp corners, a 1px hairline border, and rarity-tinted text/border. Purely presentational — no events, no slots." + /> + +
    + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + Defaults to common when rarity is absent or invalid. +
    +
    + + +

    + The Sword of Eternity is a{' '} + {/* @ts-ignore */} + + {' '}weapon, while the Iron Dagger is only{' '} + {/* @ts-ignore */} + + {' '}quality. +

    +
    +
    +
    +
    +
    +
    +) + +export default RarityChipDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 1e9ab518..567132fe 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -317,6 +317,7 @@ import ObjectiveMarkerDemo from './ObjectiveMarkerDemo' import PageIndicatorDemo from './PageIndicatorDemo' import PanelDemo from './PanelDemo' import ParticleEmitterDemo from './ParticleEmitterDemo' +import RarityChipDemo from './RarityChipDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -654,4 +655,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'portrait', category: 'Components', element: }, { key: 'press-any-key', category: 'Components', element: }, { key: 'quest-tracker', category: 'Components', element: }, + { key: 'rarity-chip', category: 'Content', element: }, ] diff --git a/web-components/src/RarityChip.ts b/web-components/src/RarityChip.ts new file mode 100644 index 00000000..eba4afe7 --- /dev/null +++ b/web-components/src/RarityChip.ts @@ -0,0 +1,62 @@ +const TAG_NAME = 'tc-rarity-chip' + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +export type ItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary' | 'mythic' + +const RARITIES: ItemRarity[] = ['common', 'uncommon', 'rare', 'epic', 'legendary', 'mythic'] + +const RARITY_LABEL: Record = { + common: 'Common', + uncommon: 'Uncommon', + rare: 'Rare', + epic: 'Epic', + legendary: 'Legendary', + mythic: 'Mythic', +} + +// Port of game-components `gc-rarity-chip`: an item-rarity label chip. +// Purely attribute-driven, no slots, no events. The fantasy chrome (gilded +// frames, glows, metal textures) is dropped — this renders as a mono uppercase +// chip with a rarity-tinted border and text per the design system. +export class RarityChip extends HTMLElement { + private _initialised = false + + static get observedAttributes(): string[] { + return ['rarity'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get rarity(): ItemRarity { + const v = this.getAttribute('rarity') as ItemRarity + return RARITIES.includes(v) ? v : 'common' + } + set rarity(value: ItemRarity) { + this.setAttribute('rarity', RARITIES.includes(value) ? value : 'common') + } + + private render(): void { + const rarity = this.rarity + const label = esc(RARITY_LABEL[rarity]) + this.innerHTML = `${label}` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: RarityChip + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index ad51abd6..6204b779 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -328,3 +328,4 @@ export * from './PerkPicker' export * from './Portrait' export * from './PressAnyKey' export * from './QuestTracker' +export * from './RarityChip' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index b3b74ed5..f16d06fc 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -325,6 +325,7 @@ import { PerkPicker } from './PerkPicker' import { Portrait } from './Portrait' import { PressAnyKey } from './PressAnyKey' import { QuestTracker } from './QuestTracker' +import { RarityChip } from './RarityChip' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -660,4 +661,5 @@ export function register(): void { customElements.define('tc-portrait', Portrait) customElements.define('tc-press-any-key', PressAnyKey) customElements.define('tc-quest-tracker', QuestTracker) + customElements.define('tc-rarity-chip', RarityChip) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index ecbbceb9..b3c59510 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -321,3 +321,4 @@ @forward 'player-card'; @forward 'player-frame'; @forward 'quest-tracker'; +@forward 'rarity-chip'; diff --git a/web-components/style/components/_rarity-chip.scss b/web-components/style/components/_rarity-chip.scss new file mode 100644 index 00000000..68c9d3f2 --- /dev/null +++ b/web-components/style/components/_rarity-chip.scss @@ -0,0 +1,82 @@ +// tc-rarity-chip — item-rarity label chip: Common … Mythic. +// Port of game-components `gc-rarity-chip`, restyled to the design system: +// sharp corners, mono uppercase label, 1px hairline border, rarity-tinted text +// and border. All cosmetics flow through --bs-rarity-chip-* custom properties. +// Fantasy chrome (gilded frames, glows, metal textures) is not reproduced. + +@use 'sass:map'; +@use '../foundation/tokens' as *; + +.tc-rarity-chip { + --bs-rarity-chip-padding-x: 0.5rem; + --bs-rarity-chip-padding-y: 0.1875rem; + --bs-rarity-chip-font-size: 0.6875rem; + --bs-rarity-chip-font-weight: 600; + --bs-rarity-chip-letter-spacing: 0.08em; + --bs-rarity-chip-bg: var(--tc-surface-muted); + --bs-rarity-chip-color: var(--tc-text-muted); + --bs-rarity-chip-border-color: var(--tc-border); + --bs-rarity-chip-border-width: 1px; + + display: inline-flex; + align-items: center; + padding: var(--bs-rarity-chip-padding-y) var(--bs-rarity-chip-padding-x); + font-family: var(--tc-font-mono); + font-size: var(--bs-rarity-chip-font-size); + font-weight: var(--bs-rarity-chip-font-weight); + letter-spacing: var(--bs-rarity-chip-letter-spacing); + text-transform: uppercase; + line-height: 1; + white-space: nowrap; + color: var(--bs-rarity-chip-color); + background-color: var(--bs-rarity-chip-bg); + border: var(--bs-rarity-chip-border-width) solid var(--bs-rarity-chip-border-color); + border-radius: 0; + user-select: none; + vertical-align: middle; +} + +// Per-rarity color variants following the sanctioned token ramp. +// `common` keeps the muted neutral defaults; higher tiers override. +.tc-rarity-chip--uncommon { + --bs-rarity-chip-color: var(--tc-success); + --bs-rarity-chip-border-color: var(--tc-success); + --bs-rarity-chip-bg: #{map.get($theme-colors-soft, 'success')}; +} + +.tc-rarity-chip--rare { + --bs-rarity-chip-color: var(--tc-info); + --bs-rarity-chip-border-color: var(--tc-info); + --bs-rarity-chip-bg: #{map.get($theme-colors-soft, 'info')}; +} + +.tc-rarity-chip--epic { + --bs-rarity-chip-color: var(--tc-app-accent); + --bs-rarity-chip-border-color: var(--tc-app-accent); + --bs-rarity-chip-bg: #{map.get($theme-colors-soft, 'primary')}; +} + +.tc-rarity-chip--legendary { + --bs-rarity-chip-color: var(--tc-warning); + --bs-rarity-chip-border-color: var(--tc-warning); + --bs-rarity-chip-bg: #{map.get($theme-colors-soft, 'warning')}; +} + +.tc-rarity-chip--mythic { + --bs-rarity-chip-color: var(--tc-danger); + --bs-rarity-chip-border-color: var(--tc-danger); + --bs-rarity-chip-bg: #{map.get($theme-colors-soft, 'danger')}; +} + +// 44 px minimum touch target under coarse pointers. +@media (pointer: coarse) { + .tc-rarity-chip { + min-height: 2.75rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .tc-rarity-chip { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index f5b8b7c5..df4614b1 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -446,6 +446,7 @@ tc-matchmaking-screen { tc-editable-text, tc-chip, +tc-rarity-chip, tc-button, tc-cool-button, tc-metal-button, From 2f3443cd50f842a0d60f263bf0f863a7f4e8ccd3 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 02:26:53 +0000 Subject: [PATCH 365/632] 360-report-player-dialog: Build the tc-report-player-dialog web component (ReportPlayerDialog game-components port) --- examples/public/web-components/SKILL.md | 108 +++++ .../web-components/ReportPlayerDialogDemo.tsx | 163 +++++++ examples/src/web-components/index.tsx | 2 + web-components/src/ReportPlayerDialog.ts | 328 +++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../components/_report-player-dialog.scss | 450 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 6 + 9 files changed, 1061 insertions(+) create mode 100644 examples/src/web-components/ReportPlayerDialogDemo.tsx create mode 100644 web-components/src/ReportPlayerDialog.ts create mode 100644 web-components/style/components/_report-player-dialog.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 76656a87..3fb15e1a 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -294,6 +294,7 @@ After `register()` you can author markup directly: - [tc-modal](#tc-modal) - [tc-offcanvas](#tc-offcanvas) - [tc-popover](#tc-popover) + - [tc-report-player-dialog](#tc-report-player-dialog) - [tc-toast](#tc-toast) - [tc-tooltip](#tc-tooltip) - [Forms](#forms) @@ -23834,3 +23835,110 @@ None. All content is driven by the `options` JS property. }) ``` + +--- + +### tc-report-player-dialog + +Player-report moderation modal with a reason radio group, an optional comment textarea, and Cancel / Submit Report actions. Port of `gc-report-player-dialog` (game-components), restyled to the slate design system (sharp corners, 1px hairline, overlay-tier shadow, danger-red submit). Controlled component — fires `tc-cancel` or `tc-submit`; the consumer sets `open` to `false` to dismiss. Focus trap, scroll lock, and keyboard (`Escape`) handling included. No shadow root; light DOM; `display: block`. + +**Tag:** `tc-report-player-dialog` + +--- + +#### Attributes + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `open` | boolean | `false` | Shows the dialog when present; remove to hide. Handled by `_applyOpenState` — CSS transition plays on change. | +| `player-name` | string | `''` | Display name of the player being reported. Rendered as the dialog title. | + +--- + +#### JS Properties + +| Property | Type | Default | Description | +|---|---|---|---| +| `open` | `boolean` | `false` | Mirrors the `open` attribute. | +| `playerName` | `string` | `''` | Mirrors the `player-name` attribute. | +| `reasons` | `string[]` | Default reason list | Array of report-reason strings rendered as the radio group. Resetting this property clears any selected reason. | +| `onCancel` | `(() => void) \| null` | `null` | Optional callback fired alongside `tc-cancel`. | +| `onSubmit` | `((reason: string, comment: string) => void) \| null` | `null` | Optional callback fired alongside `tc-submit`. | + +--- + +#### Events + +| Event | Detail shape | Fired when | +|---|---|---| +| `tc-cancel` | `{}` | User clicks Cancel, the × close button, the backdrop, or presses Escape. | +| `tc-submit` | `{ reason: string, comment: string }` | User clicks Submit Report with a reason selected. `comment` is an empty string when the textarea is left blank. | + +--- + +#### Custom Properties + +| Property | Default | Description | +|---|---|---| +| `--bs-report-player-dialog-bg` | `var(--tc-surface)` | Panel background. | +| `--bs-report-player-dialog-border-color` | `var(--tc-border)` | Panel border colour. | +| `--bs-report-player-dialog-shadow` | `var(--tc-shadow-lg)` | Panel box-shadow (overlay tier). | +| `--bs-report-player-dialog-width` | `460px` | Default panel width. | +| `--bs-report-player-dialog-max-width` | `calc(100vw - 2rem)` | Maximum panel width (viewport responsive). | +| `--bs-report-player-dialog-max-height` | `calc(100vh - 4rem)` | Maximum panel height. | +| `--bs-report-player-dialog-padding` | `1.25rem` | Header, body, and actions padding. | +| `--bs-report-player-dialog-gap` | `0.75rem` | Gap between action buttons and body items. | +| `--bs-report-player-dialog-eyebrow-color` | `var(--tc-text-muted)` | "Report Player" eyebrow micro-label colour. | +| `--bs-report-player-dialog-eyebrow-size` | `0.6875rem` | Eyebrow font size. | +| `--bs-report-player-dialog-title-color` | `var(--tc-text)` | Player name heading colour. | +| `--bs-report-player-dialog-title-size` | `1rem` | Player name heading font size. | +| `--bs-report-player-dialog-z-backdrop` | `var(--tc-z-modal-backdrop)` | Backdrop z-index. | +| `--bs-report-player-dialog-z-panel` | `var(--tc-z-modal)` | Panel z-index. | +| `--bs-report-player-dialog-backdrop-bg` | `#0f172a` | Backdrop scrim colour. | +| `--bs-report-player-dialog-backdrop-opacity` | `0.5` | Backdrop opacity when open. | +| `--bs-report-player-dialog-message-color` | `var(--tc-text-muted)` | Instruction message text colour. | +| `--bs-report-player-dialog-reason-color` | `var(--tc-text)` | Reason label text colour. | +| `--bs-report-player-dialog-reason-hover-bg` | `var(--tc-surface-muted)` | Reason row hover background. | +| `--bs-report-player-dialog-reason-checked-bg` | `var(--tc-surface-hover)` | Reason row background when selected. | +| `--bs-report-player-dialog-reason-checked-color` | `var(--tc-app-accent)` | Reason label colour when selected. | +| `--bs-report-player-dialog-radio-size` | `1rem` | Custom radio indicator diameter. | +| `--bs-report-player-dialog-radio-border` | `1px solid var(--tc-border-strong)` | Radio indicator border. | +| `--bs-report-player-dialog-radio-checked-bg` | `var(--tc-app-accent)` | Radio fill colour when checked. | +| `--bs-report-player-dialog-btn-submit-bg` | `var(--tc-danger, #dc2626)` | Submit button background (danger red). | +| `--bs-report-player-dialog-btn-submit-color` | `#fff` | Submit button text colour. | + +--- + +#### Slots + +None. All content is driven by attributes and the `reasons` JS property. + +--- + +#### Example + +```html + + + + + +``` + diff --git a/examples/src/web-components/ReportPlayerDialogDemo.tsx b/examples/src/web-components/ReportPlayerDialogDemo.tsx new file mode 100644 index 00000000..2ca06710 --- /dev/null +++ b/examples/src/web-components/ReportPlayerDialogDemo.tsx @@ -0,0 +1,163 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const CUSTOM_REASONS = [ + 'Exploiting bugs', + 'Stream sniping', + 'Account sharing', + 'Abusive voice chat', +] + +const ReportPlayerDialogDemo: React.FC = () => { + const basicRef = useRef(null) + const customRef = useRef(null) + const eventsRef = useRef(null) + + const [basicOpen, setBasicOpen] = useState(false) + const [customOpen, setCustomOpen] = useState(false) + const [eventsOpen, setEventsOpen] = useState(false) + const [log, setLog] = useState([]) + + const appendLog = (msg: string) => setLog(l => [msg, ...l].slice(0, 10)) + + // Basic dialog + useEffect(() => { + const el = basicRef.current + if (!el) return + const onCancel = () => setBasicOpen(false) + const onSubmit = (e: CustomEvent) => { + setBasicOpen(false) + appendLog(`Submitted — reason: "${e.detail.reason}", comment: "${e.detail.comment}"`) + } + el.addEventListener('tc-cancel', onCancel) + el.addEventListener('tc-submit', onSubmit as EventListener) + return () => { + el.removeEventListener('tc-cancel', onCancel) + el.removeEventListener('tc-submit', onSubmit as EventListener) + } + }, []) + + useEffect(() => { + if (!basicRef.current) return + if (basicOpen) basicRef.current.setAttribute('open', '') + else basicRef.current.removeAttribute('open') + }, [basicOpen]) + + // Custom reasons dialog + useEffect(() => { + const el = customRef.current + if (!el) return + el.reasons = CUSTOM_REASONS + const onCancel = () => setCustomOpen(false) + const onSubmit = () => setCustomOpen(false) + el.addEventListener('tc-cancel', onCancel) + el.addEventListener('tc-submit', onSubmit) + return () => { + el.removeEventListener('tc-cancel', onCancel) + el.removeEventListener('tc-submit', onSubmit) + } + }, []) + + useEffect(() => { + if (!customRef.current) return + if (customOpen) customRef.current.setAttribute('open', '') + else customRef.current.removeAttribute('open') + }, [customOpen]) + + // Events dialog + useEffect(() => { + const el = eventsRef.current + if (!el) return + const onCancel = () => { appendLog('tc-cancel fired'); setEventsOpen(false) } + const onSubmit = (e: CustomEvent) => { + appendLog(`tc-submit — reason: "${e.detail.reason}", comment: "${e.detail.comment || '(none)'}"`) + setEventsOpen(false) + } + el.addEventListener('tc-cancel', onCancel) + el.addEventListener('tc-submit', onSubmit as EventListener) + return () => { + el.removeEventListener('tc-cancel', onCancel) + el.removeEventListener('tc-submit', onSubmit as EventListener) + } + }, []) + + useEffect(() => { + if (!eventsRef.current) return + if (eventsOpen) eventsRef.current.setAttribute('open', '') + else eventsRef.current.removeAttribute('open') + }, [eventsOpen]) + + return ( +
    +
    +
    +
    + Web Components} + title="ReportPlayerDialog" + description="Player-report moderation modal with a reason radio group, optional comment textarea, and Cancel / Submit Report actions. Controlled — fires tc-cancel or tc-submit; the consumer sets open to false to dismiss." + /> + +
    + + + {/* @ts-ignore */} + + + + + + {/* @ts-ignore */} + + + + + + {/* @ts-ignore */} + +
    + Event log + {log.length === 0 ? ( + Open the dialog, pick a reason, and interact… + ) : ( +
      + {log.map((line, i) => ( +
    • {line}
    • + ))} +
    + )} +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default ReportPlayerDialogDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 567132fe..9ae9c99d 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -204,6 +204,7 @@ import DatePickerDemo from './DatePickerDemo' import DiffViewerDemo from './DiffViewerDemo' import DrawerDemo from './DrawerDemo' import ConfirmDialogDemo from './ConfirmDialogDemo' +import ReportPlayerDialogDemo from './ReportPlayerDialogDemo' import InviteToastDemo from './InviteToastDemo' import LightboxDemo from './LightboxDemo' import CommandPaletteDemo from './CommandPaletteDemo' @@ -537,6 +538,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'blur-overlay', category: 'Overlays & Feedback', element: }, { key: 'drawer', category: 'Overlays & Feedback', element: }, { key: 'confirm-dialog', category: 'Overlays & Feedback', element: }, + { key: 'report-player-dialog', category: 'Overlays & Feedback', element: }, { key: 'invite-toast', category: 'Overlays & Feedback', element: }, { key: 'lightbox', category: 'Overlays & Feedback', element: }, { key: 'command-palette', category: 'Overlays & Feedback', element: }, diff --git a/web-components/src/ReportPlayerDialog.ts b/web-components/src/ReportPlayerDialog.ts new file mode 100644 index 00000000..f8ed3280 --- /dev/null +++ b/web-components/src/ReportPlayerDialog.ts @@ -0,0 +1,328 @@ +import { closeIcon } from './icons' + +const TAG_NAME = 'tc-report-player-dialog' + +let _idCounter = 0 + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function getFocusable(root: Element): HTMLElement[] { + return Array.from( + root.querySelectorAll( + 'a[href],area[href],button:not([disabled]),details>summary,[tabindex]:not([tabindex="-1"]),input:not([disabled]),select:not([disabled]),textarea:not([disabled])', + ), + ).filter(el => !el.closest('[hidden]') && el.tabIndex >= 0) +} + +const DEFAULT_REASONS = [ + 'Cheating / hacks', + 'Toxic behavior', + 'Inappropriate name', + 'Griefing / sabotage', + 'Other', +] + +export class ReportPlayerDialog extends HTMLElement { + private _initialised = false + private _reasons: string[] = DEFAULT_REASONS.slice() + private _selectedReason = '' + private _comment = '' + private _previousFocus: Element | null = null + private _idPrefix: string + + onCancel: (() => void) | null = null + onSubmit: ((reason: string, comment: string) => void) | null = null + + constructor() { + super() + this._idPrefix = `tc-report-player-dialog-${++_idCounter}` + } + + static get observedAttributes(): string[] { + return ['open', 'player-name'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + // Detach before attach to prevent double-registration on reconnect. + this._detachHandlers() + this._attachHandlers() + if (this.open) this._applyOpenState(true) + } + + disconnectedCallback(): void { + this._detachHandlers() + this._restoreScroll() + } + + attributeChangedCallback(name: string): void { + if (!this.isConnected || !this._initialised) return + + if (name === 'open') { + this._applyOpenState(this.open) + return + } + + // Structural attribute changed (player-name) — re-render. + this.render() + if (this.open) this._applyOpenState(true) + } + + get open(): boolean { + return this.hasAttribute('open') + } + set open(v: boolean) { + if (v) this.setAttribute('open', '') + else this.removeAttribute('open') + } + + get playerName(): string { + return this.getAttribute('player-name') ?? '' + } + set playerName(v: string) { + if (v) this.setAttribute('player-name', v) + else this.removeAttribute('player-name') + } + + get reasons(): string[] { + return this._reasons.slice() + } + set reasons(value: string[]) { + this._reasons = Array.isArray(value) && value.length > 0 ? value.slice() : DEFAULT_REASONS.slice() + this._selectedReason = '' + if (this._initialised) { + this.render() + if (this.open) this._applyOpenState(true) + } + } + + // ── Open / close lifecycle ───────────────────────────────────────────────── + + private _applyOpenState(opening: boolean): void { + const panel = this.querySelector('.tc-report-player-dialog__panel') + const backdrop = this.querySelector('.tc-report-player-dialog__backdrop') + + if (opening) { + this._previousFocus = document.activeElement + panel?.removeAttribute('hidden') + backdrop?.removeAttribute('hidden') + // Settle one frame before adding the open class to trigger the transition. + requestAnimationFrame(() => { + this.classList.add('tc-report-player-dialog--open') + panel?.setAttribute('aria-hidden', 'false') + this._lockScroll() + this._trapFocus(panel) + }) + } else { + this.classList.remove('tc-report-player-dialog--open') + panel?.setAttribute('aria-hidden', 'true') + this._restoreScroll() + this._restoreFocus() + const delay = this._getTransitionDuration(panel) + setTimeout(() => { + if (!this.open) { + panel?.setAttribute('hidden', '') + backdrop?.setAttribute('hidden', '') + } + }, delay) + } + } + + private _emitCancel(): void { + this.dispatchEvent(new CustomEvent('tc-cancel', { bubbles: true, composed: true, detail: {} })) + if (typeof this.onCancel === 'function') this.onCancel() + } + + private _emitSubmit(): void { + if (!this._selectedReason) return + this.dispatchEvent(new CustomEvent('tc-submit', { + bubbles: true, + composed: true, + detail: { reason: this._selectedReason, comment: this._comment }, + })) + if (typeof this.onSubmit === 'function') this.onSubmit(this._selectedReason, this._comment) + } + + // ── Focus management ─────────────────────────────────────────────────────── + + private _trapFocus(panel: HTMLElement | null): void { + if (!panel) return + const focusable = getFocusable(panel) + if (focusable.length > 0) focusable[0].focus() + else panel.focus() + } + + private _restoreFocus(): void { + if (this._previousFocus instanceof HTMLElement) this._previousFocus.focus() + this._previousFocus = null + } + + private _lockScroll(): void { + document.body.style.overflow = 'hidden' + } + + private _restoreScroll(): void { + document.body.style.overflow = '' + } + + private _getTransitionDuration(el: HTMLElement | null): number { + if (!el) return 0 + const raw = getComputedStyle(el).transitionDuration || '0s' + let max = 0 + for (const p of raw.split(',')) { + const sec = parseFloat(p.trim()) + if (!isNaN(sec)) max = Math.max(max, sec) + } + return max * 1000 + } + + // ── Event handlers (arrow-property, stable references) ──────────────────── + + private _onKeydown = (e: KeyboardEvent): void => { + if (!this.open) return + if (e.key === 'Escape') { + e.preventDefault() + this._emitCancel() + return + } + if (e.key === 'Tab') { + const panel = this.querySelector('.tc-report-player-dialog__panel') + if (!panel) return + const focusable = getFocusable(panel) + if (focusable.length === 0) return + const first = focusable[0] + const last = focusable[focusable.length - 1] + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault() + last.focus() + } + } else { + if (document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + } + } + + private _onClick = (e: MouseEvent): void => { + const target = e.target as Element | null + if (!target) return + if (target.classList.contains('tc-report-player-dialog__backdrop')) { + this._emitCancel() + } else if (target.closest('.tc-report-player-dialog__close')) { + this._emitCancel() + } else if (target.closest('.tc-report-player-dialog__cancel')) { + this._emitCancel() + } else { + const submitBtn = target.closest('.tc-report-player-dialog__submit') + if (submitBtn && !submitBtn.disabled) this._emitSubmit() + } + } + + private _onReasonChange = (e: Event): void => { + const input = e.target as HTMLInputElement + if (!input || input.type !== 'radio') return + this._patchReasonSelection(input.value) + // Focus naturally stays on the clicked radio — no explicit refocus needed. + } + + // Surgically update checked state without a full re-render, so the overlay + // transition does not replay every time the user picks a reason. + private _patchReasonSelection(chosenValue: string): void { + this._selectedReason = chosenValue + this.querySelectorAll('.tc-report-player-dialog__reason').forEach(label => { + const radio = label.querySelector('input[type="radio"]') + if (!radio) return + const isChosen = radio.value === chosenValue + radio.checked = isChosen + label.classList.toggle('tc-report-player-dialog__reason--checked', isChosen) + }) + const submitBtn = this.querySelector('.tc-report-player-dialog__submit') + if (submitBtn) submitBtn.disabled = !chosenValue + } + + private _onCommentInput = (e: Event): void => { + const textarea = e.target as HTMLTextAreaElement + if (!textarea || textarea.tagName !== 'TEXTAREA') return + this._comment = textarea.value + } + + private _attachHandlers(): void { + document.addEventListener('keydown', this._onKeydown) + this.addEventListener('click', this._onClick) + this.addEventListener('change', this._onReasonChange) + this.addEventListener('input', this._onCommentInput) + } + + private _detachHandlers(): void { + document.removeEventListener('keydown', this._onKeydown) + this.removeEventListener('click', this._onClick) + this.removeEventListener('change', this._onReasonChange) + this.removeEventListener('input', this._onCommentInput) + } + + // ── Render ───────────────────────────────────────────────────────────────── + + private render(): void { + const isOpen = this.open + const hiddenAttr = isOpen ? '' : ' hidden' + const labelId = `${this._idPrefix}-title` + const playerNameText = esc(this.getAttribute('player-name') ?? 'Unknown') + + const reasonsHtml = this._reasons.map((reason, idx) => { + const checked = reason === this._selectedReason + const inputId = `${this._idPrefix}-reason-${idx}` + return ( + `` + ) + }).join('') + + this.innerHTML = + `` + + `` + + if (isOpen) { + this.classList.add('tc-report-player-dialog--open') + } else { + this.classList.remove('tc-report-player-dialog--open') + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: ReportPlayerDialog + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 6204b779..a07ae9d3 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -329,3 +329,4 @@ export * from './Portrait' export * from './PressAnyKey' export * from './QuestTracker' export * from './RarityChip' +export * from './ReportPlayerDialog' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index f16d06fc..eaa0bc14 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -326,6 +326,7 @@ import { Portrait } from './Portrait' import { PressAnyKey } from './PressAnyKey' import { QuestTracker } from './QuestTracker' import { RarityChip } from './RarityChip' +import { ReportPlayerDialog } from './ReportPlayerDialog' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -662,4 +663,5 @@ export function register(): void { customElements.define('tc-press-any-key', PressAnyKey) customElements.define('tc-quest-tracker', QuestTracker) customElements.define('tc-rarity-chip', RarityChip) + customElements.define('tc-report-player-dialog', ReportPlayerDialog) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index b3c59510..4a79aeda 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -65,6 +65,7 @@ @forward 'graphics-preset-picker'; @forward 'invert-axis-toggle'; @forward 'rating'; +@forward 'report-player-dialog'; @forward 'slider'; @forward 'resizable-panel'; @forward 'roadmap'; diff --git a/web-components/style/components/_report-player-dialog.scss b/web-components/style/components/_report-player-dialog.scss new file mode 100644 index 00000000..5cd5ec68 --- /dev/null +++ b/web-components/style/components/_report-player-dialog.scss @@ -0,0 +1,450 @@ +// tc-report-player-dialog — player-report moderation modal. +// Overlay tier: full-screen backdrop + centred panel; sharp corners; 1px hairline; +// single hard shadow. Game chrome (gilded frames, glows, fantasy fills) dropped. +// All cosmetics flow through --bs-report-player-dialog-* custom properties. + +tc-report-player-dialog { + // Surface + --bs-report-player-dialog-bg: var(--tc-surface); + --bs-report-player-dialog-border-color: var(--tc-border); + --bs-report-player-dialog-shadow: var(--tc-shadow-lg); + --bs-report-player-dialog-width: 460px; + --bs-report-player-dialog-max-width: calc(100vw - 2rem); + --bs-report-player-dialog-max-height: calc(100vh - 4rem); + --bs-report-player-dialog-padding: 1.25rem; + --bs-report-player-dialog-gap: 0.75rem; + + // Header + --bs-report-player-dialog-eyebrow-color: var(--tc-text-muted); + --bs-report-player-dialog-eyebrow-size: 0.6875rem; + --bs-report-player-dialog-title-color: var(--tc-text); + --bs-report-player-dialog-title-size: 1rem; + + // Z-index band — backdrop below panel, both in the overlay tier. + --bs-report-player-dialog-z-backdrop: var(--tc-z-modal-backdrop); + --bs-report-player-dialog-z-panel: var(--tc-z-modal); + + // Transition + --bs-report-player-dialog-transition: transform var(--tc-transition-base), opacity var(--tc-transition-base); + + // Backdrop scrim + --bs-report-player-dialog-backdrop-bg: #0f172a; + --bs-report-player-dialog-backdrop-opacity: 0.5; + + // Message + --bs-report-player-dialog-message-color: var(--tc-text-muted); + --bs-report-player-dialog-message-size: 0.8125rem; + + // Reason rows + --bs-report-player-dialog-reason-color: var(--tc-text); + --bs-report-player-dialog-reason-size: 0.8125rem; + --bs-report-player-dialog-reason-hover-bg: var(--tc-surface-muted); + --bs-report-player-dialog-reason-checked-bg: var(--tc-surface-hover); + --bs-report-player-dialog-reason-checked-color: var(--tc-app-accent); + + // Radio indicator — sanctioned circle per design spec + --bs-report-player-dialog-radio-size: 1rem; + --bs-report-player-dialog-radio-border: 1px solid var(--tc-border-strong); + --bs-report-player-dialog-radio-checked-bg: var(--tc-app-accent); + --bs-report-player-dialog-radio-checked-border: var(--tc-app-accent); + + // Textarea + --bs-report-player-dialog-textarea-bg: var(--tc-surface-muted); + --bs-report-player-dialog-textarea-border: 1px solid var(--tc-border-strong); + --bs-report-player-dialog-textarea-color: var(--tc-text); + --bs-report-player-dialog-textarea-size: 0.8125rem; + --bs-report-player-dialog-textarea-min-height: 5rem; + + // Action buttons + --bs-report-player-dialog-btn-font-size: 0.8125rem; + --bs-report-player-dialog-btn-min-height: 2.25rem; + --bs-report-player-dialog-btn-bg: var(--tc-surface); + --bs-report-player-dialog-btn-color: var(--tc-text); + --bs-report-player-dialog-btn-border: 1px solid var(--tc-border-strong); + --bs-report-player-dialog-btn-hover-bg: var(--tc-surface-muted); + --bs-report-player-dialog-btn-hover-color: var(--tc-text); + --bs-report-player-dialog-btn-submit-bg: var(--tc-danger, #dc2626); + --bs-report-player-dialog-btn-submit-color: #fff; + --bs-report-player-dialog-btn-submit-hover-bg: #b91c1c; + --bs-report-player-dialog-btn-submit-hover-color: #fff; + + // Close button + --bs-report-player-dialog-close-color: var(--tc-text-muted); + --bs-report-player-dialog-close-hover-color: var(--tc-text); + --bs-report-player-dialog-close-hover-bg: var(--tc-surface-muted); +} + +// ── Backdrop ────────────────────────────────────────────────────────────────── + +.tc-report-player-dialog__backdrop { + position: fixed; + inset: 0; + z-index: var(--bs-report-player-dialog-z-backdrop); + background: var(--bs-report-player-dialog-backdrop-bg); + opacity: 0; + transition: opacity var(--tc-transition-base); + + tc-report-player-dialog.tc-report-player-dialog--open & { + opacity: var(--bs-report-player-dialog-backdrop-opacity); + } +} + +// ── Panel ───────────────────────────────────────────────────────────────────── + +.tc-report-player-dialog__panel { + position: fixed; + top: 50%; + left: 50%; + z-index: var(--bs-report-player-dialog-z-panel); + display: flex; + flex-direction: column; + width: var(--bs-report-player-dialog-width); + max-width: var(--bs-report-player-dialog-max-width); + max-height: var(--bs-report-player-dialog-max-height); + background: var(--bs-report-player-dialog-bg); + border: 1px solid var(--bs-report-player-dialog-border-color); + border-radius: 0; + box-shadow: var(--bs-report-player-dialog-shadow); + outline: 0; + overflow: hidden; + transition: var(--bs-report-player-dialog-transition); + + // Closed: shifted up, scaled down, hidden. + opacity: 0; + transform: translateY(-20px) translateX(-50%) scale(0.97); + + // Open: centered, full size. + tc-report-player-dialog.tc-report-player-dialog--open & { + opacity: 1; + transform: translateY(-50%) translateX(-50%); + } +} + +// ── Header ──────────────────────────────────────────────────────────────────── + +.tc-report-player-dialog__header { + display: flex; + flex-direction: column; + flex-shrink: 0; + padding: var(--bs-report-player-dialog-padding); + padding-bottom: 0; + gap: 0.25rem; + border-bottom: 1px solid var(--bs-report-player-dialog-border-color); + position: relative; +} + +.tc-report-player-dialog__eyebrow { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-report-player-dialog-eyebrow-size); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bs-report-player-dialog-eyebrow-color); + line-height: 1.4; +} + +.tc-report-player-dialog__title { + font-size: var(--bs-report-player-dialog-title-size); + font-weight: 700; + color: var(--bs-report-player-dialog-title-color); + margin: 0 0 var(--bs-report-player-dialog-padding); + line-height: 1.3; + padding-right: 2.5rem; // avoid overlap with close button +} + +// ── Close button ────────────────────────────────────────────────────────────── + +.tc-report-player-dialog__close { + position: absolute; + top: var(--bs-report-player-dialog-padding); + right: var(--bs-report-player-dialog-padding); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 2rem; + height: 2rem; + padding: 0; + border: none; + background: transparent; + color: var(--bs-report-player-dialog-close-color); + cursor: pointer; + border-radius: 0; + transition: background-color var(--tc-transition-fast), color var(--tc-transition-fast); + + svg { + width: 1rem; + height: 1rem; + flex-shrink: 0; + } + + &:hover { + background: var(--bs-report-player-dialog-close-hover-bg); + color: var(--bs-report-player-dialog-close-hover-color); + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + } + + @media (pointer: coarse) { + width: 2.75rem; + height: 2.75rem; + } +} + +// ── Body ────────────────────────────────────────────────────────────────────── + +.tc-report-player-dialog__body { + flex: 1 1 auto; + overflow-y: auto; + display: flex; + flex-direction: column; + padding: var(--bs-report-player-dialog-padding); + gap: var(--bs-report-player-dialog-gap); +} + +.tc-report-player-dialog__message { + margin: 0; + font-size: var(--bs-report-player-dialog-message-size); + color: var(--bs-report-player-dialog-message-color); + line-height: 1.5; +} + +// ── Reason radio group ──────────────────────────────────────────────────────── + +.tc-report-player-dialog__reasons { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.tc-report-player-dialog__reason { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.5rem 0.625rem; + cursor: pointer; + border: 1px solid transparent; + border-radius: 0; + color: var(--bs-report-player-dialog-reason-color); + font-size: var(--bs-report-player-dialog-reason-size); + line-height: 1.4; + transition: + background var(--tc-transition-fast), + border-color var(--tc-transition-fast); + user-select: none; + + // Visually hide the native radio input but keep it accessible. + input[type="radio"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; + } + + &:hover { + background: var(--bs-report-player-dialog-reason-hover-bg); + } + + &--checked { + background: var(--bs-report-player-dialog-reason-checked-bg); + border-color: var(--bs-report-player-dialog-border-color); + + .tc-report-player-dialog__reason-label { + color: var(--bs-report-player-dialog-reason-checked-color); + font-weight: 500; + } + + .tc-report-player-dialog__radio { + background: var(--bs-report-player-dialog-radio-checked-bg); + border-color: var(--bs-report-player-dialog-radio-checked-border); + + &::after { + opacity: 1; + } + } + } + + // Focus ring on the custom radio when the hidden input is focused. + input:focus-visible + .tc-report-player-dialog__radio { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + } + + @media (pointer: coarse) { + min-height: 44px; + } +} + +// Custom radio indicator — sanctioned circle shape per design spec. +.tc-report-player-dialog__radio { + flex-shrink: 0; + width: var(--bs-report-player-dialog-radio-size); + height: var(--bs-report-player-dialog-radio-size); + border: var(--bs-report-player-dialog-radio-border); + border-radius: 50%; + background: var(--bs-report-player-dialog-bg); + position: relative; + transition: + background var(--tc-transition-fast), + border-color var(--tc-transition-fast); + + // Inner dot — visible when checked. + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 0.375rem; + height: 0.375rem; + border-radius: 50%; + background: #fff; + opacity: 0; + transition: opacity var(--tc-transition-fast); + } +} + +// ── Comment textarea ────────────────────────────────────────────────────────── + +.tc-report-player-dialog__comment { + appearance: none; + display: block; + width: 100%; + min-height: var(--bs-report-player-dialog-textarea-min-height); + padding: 0.5rem 0.625rem; + font-family: inherit; + font-size: var(--bs-report-player-dialog-textarea-size); + color: var(--bs-report-player-dialog-textarea-color); + background: var(--bs-report-player-dialog-textarea-bg); + border: var(--bs-report-player-dialog-textarea-border); + border-radius: 0; + resize: vertical; + outline: 0; + line-height: 1.5; + box-sizing: border-box; + transition: border-color var(--tc-transition-fast), box-shadow var(--tc-transition-fast); + + &::placeholder { + color: var(--tc-text-faint, var(--tc-text-muted)); + opacity: 1; + } + + &:focus { + border-color: rgba(30, 41, 59, 0.5); + box-shadow: 0 0 0 0.2rem rgba(30, 41, 59, 0.12); + } +} + +// ── Actions ─────────────────────────────────────────────────────────────────── + +.tc-report-player-dialog__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--bs-report-player-dialog-gap); + flex-shrink: 0; + padding: var(--bs-report-player-dialog-padding); + border-top: 1px solid var(--bs-report-player-dialog-border-color); +} + +.tc-report-player-dialog__btn { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0 1rem; + min-height: var(--bs-report-player-dialog-btn-min-height); + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-report-player-dialog-btn-font-size); + font-weight: 500; + letter-spacing: 0.025em; + color: var(--bs-report-player-dialog-btn-color); + background: var(--bs-report-player-dialog-btn-bg); + border: var(--bs-report-player-dialog-btn-border); + border-radius: 0; + cursor: pointer; + white-space: nowrap; + line-height: 1; + transition: + background var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease), + transform var(--tc-transition-fast, 0.15s ease); + + &:hover:not(:disabled) { + background: var(--bs-report-player-dialog-btn-hover-bg); + color: var(--bs-report-player-dialog-btn-hover-color); + transform: translateY(-1px); + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + } + + &:disabled { + opacity: 0.45; + pointer-events: none; + cursor: default; + } + + @media (pointer: coarse) { + min-height: 44px; + } +} + +// Submit Report is a danger action — uses red accent to signal intent. +.tc-report-player-dialog__submit { + color: var(--bs-report-player-dialog-btn-submit-color); + background: var(--bs-report-player-dialog-btn-submit-bg); + border-color: transparent; + + &:hover:not(:disabled) { + background: var(--bs-report-player-dialog-btn-submit-hover-bg); + color: var(--bs-report-player-dialog-btn-submit-hover-color); + } +} + +// ── Reduced motion — skip transform / opacity transitions ───────────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-report-player-dialog__backdrop { + transition: none; + } + + .tc-report-player-dialog__panel { + transition: none; + opacity: 1; + transform: translateY(-50%) translateX(-50%); + } + + .tc-report-player-dialog__btn { + transition: + background var(--tc-transition-fast, 0.15s ease), + color var(--tc-transition-fast, 0.15s ease); + + &:hover:not(:disabled) { + transform: none; + } + } + + .tc-report-player-dialog__close { + transition: none; + } + + .tc-report-player-dialog__reason { + transition: none; + } + + .tc-report-player-dialog__radio { + transition: none; + } + + .tc-report-player-dialog__comment { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index df4614b1..db5c5b3e 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -384,6 +384,12 @@ tc-loot-popup { display: block; } +// Player-report modal overlay; the SCSS partial positions the backdrop and +// centred panel, but the host must not default to inline. +tc-report-player-dialog { + display: block; +} + // In-game pause overlay; the SCSS partial positions the backdrop and centred // panel, but the host must not default to inline. tc-pause-menu { From e1f5393f72df58949185eacd00b660bb6ef65091 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 04:33:28 +0000 Subject: [PATCH 366/632] 361-reset-to-defaults: Build the tc-reset-to-defaults web component (ResetToDefaults game-components port) --- examples/public/web-components/SKILL.md | 46 ++++++ .../web-components/ResetToDefaultsDemo.tsx | 72 ++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/ResetToDefaults.ts | 98 +++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_reset-to-defaults.scss | 134 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 357 insertions(+) create mode 100644 examples/src/web-components/ResetToDefaultsDemo.tsx create mode 100644 web-components/src/ResetToDefaults.ts create mode 100644 web-components/style/components/_reset-to-defaults.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 3fb15e1a..0865f9fc 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -327,6 +327,7 @@ After `register()` you can author markup directly: - [tc-deadzone-slider](#tc-deadzone-slider) - [tc-fov-slider](#tc-fov-slider) - [tc-mouse-sensitivity](#tc-mouse-sensitivity) + - [tc-reset-to-defaults](#tc-reset-to-defaults) - [tc-fps-cap-select](#tc-fps-cap-select) - [tc-fullscreen-toggle](#tc-fullscreen-toggle) - [tc-graphics-preset-picker](#tc-graphics-preset-picker) @@ -8417,6 +8418,51 @@ A mouse-sensitivity setting row: a label/description text block paired with one --- +### tc-reset-to-defaults + +A two-step reset action row: a label/description text block paired with a Reset button that enters a confirmation state (Confirm reset + Cancel). Confirming fires `tc-reset` and returns to idle; cancelling discards without firing. Built on the shared `tc-setting-row` scaffold. Port of game-components `gc-reset-to-defaults` with the fantasy chrome dropped for the toolcase slate/ink look. + +**Tag:** `tc-reset-to-defaults` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `row-label` | string | `Reset to defaults` | Row label (set automatically when absent) | +| `description` | string | — | Optional secondary line beneath the label | +| `disabled` | boolean | `false` | Disables the Reset button | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `rowLabel` | `string` | Get/set the `row-label` attribute. | +| `description` | `string` | Get/set the `description` attribute. | +| `disabled` | `boolean` | Get/set the `disabled` attribute. | +| `onReset` | `(() => void) \| null` | Optional callback fired on confirmed reset. Mirrors the `tc-reset` event. | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-reset` | `undefined` | Fired when the user confirms the reset (clicks Confirm reset). Not fired on cancel. | + +**No slots.** + +```html + + +``` + +--- + ### tc-fps-cap-select A preset FPS-cap picker: a label/description text block paired with a native ` on the right. Built on +// the shared SettingRowBase scaffold; the control reuses the design-system +// .form-select chrome. Port of game-components `gc-select-row` with the +// fantasy chrome dropped for the toolcase slate/ink look. + +export class SelectRow extends SettingRowBase { + + private _options: SelectOption[] = [] + + // Optional callback mirror of the `tc-change` event (see styleguide §events). + onChange: ((value: string) => void) | null = null + + static get observedAttributes(): string[] { + return [...SettingRowBase.observedAttributes, 'value', 'disabled'] + } + + attributeChangedCallback(name: string, old: string | null, next: string | null): void { + if (!this.isConnected || !this._initialised) return + // Patch value/disabled in place — a full re-render would drop the + // select's focus and any open native dropdown. + if (name === 'value') { + const select = this.querySelector('.tc-select-row__select') + if (select && select.value !== this.value) select.value = this.value + return + } + if (name === 'disabled') { + const select = this.querySelector('.tc-select-row__select') + if (select) select.disabled = this.disabled + return + } + super.attributeChangedCallback(name, old, next) + } + + get value(): string { + return this.getAttribute('value') ?? '' + } + set value(v: string) { + this.setAttribute('value', v) + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + get options(): SelectOption[] { + return this._options.slice() + } + set options(values: SelectOption[]) { + this._options = Array.isArray(values) ? values.slice() : [] + if (this._initialised) this.renderRow() + } + + protected renderControl(): string { + const value = this.value + const disabledAttr = this.disabled ? ' disabled' : '' + const optsMarkup = this._options + .map(opt => { + const selected = opt.value === value ? ' selected' : '' + return `` + }) + .join('') + return ` + + ` + } + + protected bindControl(): void { + const select = this.querySelector('.tc-select-row__select') + if (!select) return + select.addEventListener('change', () => { + const v = select.value + this.setAttribute('value', v) + this.emit('tc-change', { value: v }) + if (typeof this.onChange === 'function') this.onChange(v) + }) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: SelectRow + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 5d2c3678..09a8521c 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -286,6 +286,7 @@ export * from './DialogueBox' export * from './FOVSlider' export * from './MouseSensitivity' export * from './FPSCapSelect' +export * from './SelectRow' export * from './FullscreenToggle' export * from './GameOverScreen' export * from './ResultScreen' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 3bbd605a..38ec5d7f 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -283,6 +283,7 @@ import { DialogueBox } from './DialogueBox' import { FOVSlider } from './FOVSlider' import { MouseSensitivity } from './MouseSensitivity' import { FPSCapSelect } from './FPSCapSelect' +import { SelectRow } from './SelectRow' import { FullscreenToggle } from './FullscreenToggle' import { GameOverScreen } from './GameOverScreen' import { ResultScreen } from './ResultScreen' @@ -624,6 +625,7 @@ export function register(): void { customElements.define('tc-fov-slider', FOVSlider) customElements.define('tc-mouse-sensitivity', MouseSensitivity) customElements.define('tc-fps-cap-select', FPSCapSelect) + customElements.define('tc-select-row', SelectRow) customElements.define('tc-fullscreen-toggle', FullscreenToggle) customElements.define('tc-game-over-screen', GameOverScreen) customElements.define('tc-result-screen', ResultScreen) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 71fab00e..c36a2c57 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -61,6 +61,7 @@ @forward 'fov-slider'; @forward 'mouse-sensitivity'; @forward 'fps-cap-select'; +@forward 'select-row'; @forward 'fullscreen-toggle'; @forward 'graphics-preset-picker'; @forward 'invert-axis-toggle'; diff --git a/web-components/style/components/_select-row.scss b/web-components/style/components/_select-row.scss new file mode 100644 index 00000000..7768efd1 --- /dev/null +++ b/web-components/style/components/_select-row.scss @@ -0,0 +1,44 @@ +// tc-select-row — generic labeled dropdown setting row on the shared setting-row +// scaffold (`_setting-row.scss`). The control is a native { + const v = Number(e.target.value) + setLiveValue(v) + if (liveRef.current) { + liveRef.current.rpm = Math.round(v * 40) + liveRef.current.gear = v < 40 ? '1' : v < 80 ? '2' : v < 120 ? '3' : v < 160 ? '4' : v < 190 ? '5' : '6' + } + }} + /> + {liveValue} + + + + + + + + + + ) +} + +export default SpeedometerDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index ef829113..e88a2d4c 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -329,6 +329,7 @@ import SafeAreaDemo from './SafeAreaDemo' import SaveSlotListDemo from './SaveSlotListDemo' import SettingsCategoryListDemo from './SettingsCategoryListDemo' import ScoreDisplayDemo from './ScoreDisplayDemo' +import SpeedometerDemo from './SpeedometerDemo' import ScreenFlashDemo from './ScreenFlashDemo' import ShakeContainerDemo from './ShakeContainerDemo' import ScrollTextDemo from './ScrollTextDemo' @@ -684,6 +685,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'save-slot-list', category: 'Components', element: }, { key: 'settings-category-list', category: 'Components', element: }, { key: 'score-display', category: 'Content', element: }, + { key: 'speedometer', category: 'Content', element: }, { key: 'scroll-text', category: 'Content', element: }, { key: 'shop-panel', category: 'Components', element: }, ] diff --git a/web-components/src/Speedometer.ts b/web-components/src/Speedometer.ts new file mode 100644 index 00000000..d7bdcd71 --- /dev/null +++ b/web-components/src/Speedometer.ts @@ -0,0 +1,159 @@ +const TAG_NAME = 'tc-speedometer' + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +export class Speedometer extends HTMLElement { + private _initialised = false + + static get observedAttributes(): string[] { + return ['value', 'max', 'rpm', 'unit', 'gear', 'size'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get value(): number { + const raw = this.getAttribute('value') + if (raw == null) return 0 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) ? 0 : parsed + } + set value(v: number) { this.setAttribute('value', String(v)) } + + get max(): number { + const raw = this.getAttribute('max') + if (raw == null) return 220 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) || parsed <= 0 ? 220 : parsed + } + set max(v: number) { this.setAttribute('max', String(v)) } + + get rpm(): number | null { + const raw = this.getAttribute('rpm') + if (raw == null) return null + const parsed = parseFloat(raw) + return Number.isNaN(parsed) ? null : parsed + } + set rpm(v: number | null) { + if (v == null) this.removeAttribute('rpm') + else this.setAttribute('rpm', String(v)) + } + + get unit(): string { + return this.getAttribute('unit') ?? 'KM/H' + } + set unit(v: string) { this.setAttribute('unit', v) } + + get gear(): string { + return this.getAttribute('gear') ?? '' + } + set gear(v: string) { + if (v) this.setAttribute('gear', v) + else this.removeAttribute('gear') + } + + get size(): number { + const raw = this.getAttribute('size') + if (raw == null) return 160 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) || parsed <= 0 ? 160 : parsed + } + set size(v: number) { this.setAttribute('size', String(v)) } + + private render(): void { + const value = this.value + const max = this.max + const pct = Math.max(0, Math.min(1, value / max)) + const danger = pct >= 0.85 + + this.classList.add('tc-speedometer') + this.dataset.danger = String(danger) + + const size = this.size + this.style.setProperty('--bs-speedometer-size', `${size}px`) + // Scale value font size proportionally to the gauge diameter. + this.style.setProperty('--bs-speedometer-value-font-size', `${Math.max(Math.round(size * 0.2), 14)}px`) + this.style.setProperty('--bs-speedometer-gear-font-size', `${Math.max(Math.round(size * 0.125), 11)}px`) + + const rpm = this.rpm + const unit = this.unit + const gear = this.gear + + // SVG geometry: semicircle arc opening upward. + // viewBox="0 0 100 70", arc centre at (50,60), radius=45. + // The arc sweeps counterclockwise (sweep=0) from (5,60) through (50,15) to (95,60) + // so it opens upward and stays inside the viewBox. + // SANCTIONED CIRCLES: the arc track and fill are genuinely circular shapes. + const r = 45 + const cx = 50 + const cy = 60 + const sw = 7 // stroke-width in viewBox units + + const trackPath = `M ${cx - r} ${cy} A ${r} ${r} 0 0 0 ${cx + r} ${cy}` + + // Fill arc sweeps from the left end counterclockwise to the speed endpoint. + // Angle parametrization: fillX = cx - r*cos(PI*pct), fillY = cy - r*sin(PI*pct) + // gives positions on the upper semicircle as pct goes from 0 (left) to 1 (right). + let fillPathHtml = '' + if (pct > 0.001) { + const fillX = (cx - r * Math.cos(Math.PI * pct)).toFixed(2) + const fillY = (cy - r * Math.sin(Math.PI * pct)).toFixed(2) + fillPathHtml = `` + } + + // 9 tick marks at equal intervals along the arc. + const ticks = Array.from({ length: 9 }, (_, i) => { + const t = i / 8 + const c = Math.cos(Math.PI * t) + const s = Math.sin(Math.PI * t) + const x1 = (cx - r * c).toFixed(2) + const y1 = (cy - r * s).toFixed(2) + const x2 = (cx - (r - 5) * c).toFixed(2) + const y2 = (cy - (r - 5) * s).toFixed(2) + return `` + }).join('') + + const gearHtml = gear + ? `
    ${esc(gear)}
    ` + : '' + + const rpmHtml = rpm != null + ? `
    ${Math.round(rpm)} RPM
    ` + : '' + + const ariaLabel = `${Math.round(value)} ${esc(unit)}${rpm != null ? `, ${Math.round(rpm)} RPM` : ''}${gear ? `, gear ${esc(gear)}` : ''}` + + this.innerHTML = ` +` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Speedometer + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 6313ccf3..a951a814 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -340,6 +340,7 @@ export * from './SettingsCategoryList' export * from './ScreenFlash' export * from './ShakeContainer' export * from './ScoreDisplay' +export * from './Speedometer' export * from './ScrollText' export * from './ShopPanel' export * from './SkillBar' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 510db892..87c250b0 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -337,6 +337,7 @@ import { SafeArea } from './SafeArea' import { SaveSlotList } from './SaveSlotList' import { SettingsCategoryList } from './SettingsCategoryList' import { ScoreDisplay } from './ScoreDisplay' +import { Speedometer } from './Speedometer' import { ScrollText } from './ScrollText' import { ShopPanel } from './ShopPanel' import { SkillBar } from './SkillBar' @@ -688,6 +689,7 @@ export function register(): void { customElements.define('tc-save-slot-list', SaveSlotList) customElements.define('tc-settings-category-list', SettingsCategoryList) customElements.define('tc-score-display', ScoreDisplay) + customElements.define('tc-speedometer', Speedometer) customElements.define('tc-scroll-text', ScrollText) customElements.define('tc-shop-panel', ShopPanel) customElements.define('tc-skill-bar', SkillBar) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 2b52f68c..fb228b35 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -333,6 +333,7 @@ @forward 'shake-container'; @forward 'screen-flash'; @forward 'score-display'; +@forward 'speedometer'; @forward 'scroll-text'; @forward 'shop-panel'; @forward 'skill-bar'; diff --git a/web-components/style/components/_speedometer.scss b/web-components/style/components/_speedometer.scss new file mode 100644 index 00000000..88923827 --- /dev/null +++ b/web-components/style/components/_speedometer.scss @@ -0,0 +1,149 @@ +// tc-speedometer — vehicle speedometer gauge with a semicircular SVG arc and a +// centre readout (speed value, unit, optional gear and RPM). +// SANCTIONED CIRCLES: the arc track and fill are genuinely circular — an explicit +// exception to the sharp-corner rule. +// All cosmetics flow through --bs-speedometer-* custom properties backed by --tc-* tokens. + +@use '../foundation/tokens' as *; + +tc-speedometer { + --bs-speedometer-size: 160px; + --bs-speedometer-bg: var(--tc-surface); + --bs-speedometer-border-color: var(--tc-border); + + --bs-speedometer-track-color: var(--tc-border-strong); + --bs-speedometer-fill-color: var(--tc-app-accent); + --bs-speedometer-danger-fill-color: var(--tc-danger); + --bs-speedometer-tick-color: var(--tc-text-faint); + + --bs-speedometer-value-color: var(--tc-text); + --bs-speedometer-unit-color: var(--tc-text-muted); + --bs-speedometer-gear-color: var(--tc-app-accent); + --bs-speedometer-rpm-color: var(--tc-text-faint); + + // Font sizes are written inline by render() from the size attribute so each + // instance scales proportionally. These are fallbacks for un-upgraded HTML. + --bs-speedometer-value-font-size: 2rem; + --bs-speedometer-gear-font-size: 1.25rem; + --bs-speedometer-unit-font-size: 0.6875rem; // 11px + --bs-speedometer-rpm-font-size: 0.625rem; // 10px +} + +.tc-speedometer { + position: relative; + width: var(--bs-speedometer-size); + background: var(--bs-speedometer-bg); + border: 1px solid var(--bs-speedometer-border-color); + border-radius: 0; + box-sizing: border-box; +} + +// SVG fills the full block width; natural height derives from the 100×70 viewBox +// aspect ratio (70% of the element width). +.tc-speedometer__arc { + display: block; + width: 100%; +} + +.tc-speedometer__track { + stroke: var(--bs-speedometer-track-color); + fill: none; +} + +.tc-speedometer__fill { + stroke: var(--bs-speedometer-fill-color); + fill: none; + transition: stroke var(--tc-transition-fast); +} + +// Danger state (pct ≥ 0.85) — fill and value colour shift to danger red. +tc-speedometer[data-danger='true'] { + .tc-speedometer__fill { + stroke: var(--bs-speedometer-danger-fill-color); + } + + .tc-speedometer__value { + color: var(--bs-speedometer-danger-fill-color); + } +} + +.tc-speedometer__tick { + stroke: var(--bs-speedometer-tick-color); + stroke-width: 1; + fill: none; +} + +// Readout sits in the bottom-centre of the gauge face, inside the arc opening. +// The arc endpoints are at y≈85 % of the SVG height; bottom: 14 % positions the +// readout just inside that opening. +.tc-speedometer__readout { + position: absolute; + bottom: 14%; + left: 50%; + transform: translateX(-50%); + text-align: center; + white-space: nowrap; + pointer-events: none; + font-family: 'JetBrains Mono', var(--bs-font-monospace, monospace); + line-height: 1.1; +} + +// Gear indicator — ink accent, displayed above the speed value. +.tc-speedometer__gear { + display: block; + font-size: var(--bs-speedometer-gear-font-size); + font-weight: 700; + color: var(--bs-speedometer-gear-color); + letter-spacing: 0.04em; + margin-bottom: 0.1em; +} + +// Main speed value — large mono number. +.tc-speedometer__value { + display: block; + font-size: var(--bs-speedometer-value-font-size); + font-weight: 700; + color: var(--bs-speedometer-value-color); + letter-spacing: -0.02em; + line-height: 1; + transition: color var(--tc-transition-fast); +} + +// Unit label — small uppercase mono below the value. +.tc-speedometer__unit { + display: block; + font-size: var(--bs-speedometer-unit-font-size); + font-weight: 500; + color: var(--bs-speedometer-unit-color); + letter-spacing: 0.1em; + text-transform: uppercase; + margin-top: 0.1em; +} + +// RPM line — faint secondary readout below the unit. +.tc-speedometer__rpm { + display: block; + font-size: var(--bs-speedometer-rpm-font-size); + font-weight: 400; + color: var(--bs-speedometer-rpm-color); + letter-spacing: 0.06em; + margin-top: 0.3em; +} + +// Touch target: the host should be at least 44×44px under coarse pointers. +// The default size (160px) satisfies this; small custom sizes need the guard. +@media (pointer: coarse) { + tc-speedometer { + min-width: 44px; + min-height: 44px; + } +} + +// prefers-reduced-motion — remove the fill and value colour transitions while +// keeping the state-conveying danger colour visible (no animation needed to read it). +@media (prefers-reduced-motion: reduce) { + .tc-speedometer__fill, + .tc-speedometer__value { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index e76b8460..58833914 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -358,6 +358,7 @@ tc-roadmap, tc-save-slot-list, tc-settings-category-list, tc-score-display, +tc-speedometer, tc-scroll-text, tc-shop-panel, tc-skill-bar, From cba524fdf0536accbdcc6f42b6de302b23336654 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 06:52:03 +0000 Subject: [PATCH 381/632] 376-stack: Build the tc-stack web component (Stack game-components port) --- examples/public/web-components/SKILL.md | 69 ++++++++++++++- examples/src/web-components/StackDemo.tsx | 94 +++++++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Stack.ts | 90 ++++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + web-components/style/components/_stack.scss | 24 ++++++ web-components/style/foundation/_reset.scss | 7 ++ 9 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 examples/src/web-components/StackDemo.tsx create mode 100644 web-components/src/Stack.ts create mode 100644 web-components/style/components/_stack.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 71bebd40..c30b3ea0 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -1,6 +1,6 @@ --- name: web-components -description: Use when building UI with @toolcase/web-components — framework-free HTML5 Web Components (`tc-*` custom elements) with from-scratch toolcase styling and a Bootstrap-compatible class API. Covers layout (BasicLayout, DashboardLayout, DashboardContent, DashboardSidebar, Login, Container, Row, Col, Spacer), content (ActionHeader, ActionItems, ActionRowList, Alert, AnnouncementBar, ApiReferenceTable, AssetRow, AssetRowList, Avatar, Badge, BadgeRow, Banner, Brand, Build, BriefCard, BundleBar, CdnMap, CalloutQuote, Changelog, ChartContainer, Sparkline, TrendIndicator, Leaderboard, LeaderboardTrend, CodeLabelCell, CodeSnippet, CodeWithOutput, CommunityLinks, ConfigPreview, ContributorWall, CookbookGrid, CoolButton, ActivityCard, BasicCard, Button, ButtonGroup, Card, Carousel, CloseButton, Collapse, Divider, Dropdown, DownloadStats, EcosystemMap, EmptyState, GameShowcaseCard, GithubStarsCard, GoodFirstIssues, Group, Hero, HeroStatsBar, Heading, Image, InfiniteScroll, Kbd, ListCard, ListGroup, LogoCloud, MaintainerCard, Marquee, MetricTile, MetricGrid, MigrationGuide, PageFooter, Panel, PhaseGrid, Pipeline, PinnedFeatureShowcase, PluginGrid, PricingCard, File, UserPanel, QueuedFile, Placeholder, Progress, PulseIndicator, ScoringRules, ScoreDisplay, Speedometer, SectionCard, SectionFlag, Skeleton, Spinner, SprintChain, Stamp, MetricCard, StatCard, StateMachine, StatusCard, StatusDot, Stepper, Tag, TeamList, TierLadder, Timeline, UsageSummaryPanel, WelcomeGuide, CommandReference, Comparator, CompatibilityMatrix, CountdownTimer, FAQList, FeatureMatrix, Text, VisuallyHidden), navigation (Breadcrumb, CoolNav, Nav, Navbar, Pagination, Scrollspy, SocialLinks, Stepper), overlays & feedback (ContextMenu, DebugOverlay, Modal, Offcanvas, Popover, Toast, Tooltip), and forms (CardOptions, Check, CheckboxGroup, Chip, ChipGroup, ColorPicker, IconPicker, DatePicker, EarlySignupForm, EditableText, FloatingLabel, Form, HelperText, Input, InputGroup, InputGroupText, Label, MultiCardSelect, NewsletterSignup, Option, Radio, RadioGroup, Range, RangeSlider, DeadzoneSlider, Select, Switch, Textarea). Consumable from any stack — React, Vue, Svelte, or plain HTML. +description: Use when building UI with @toolcase/web-components — framework-free HTML5 Web Components (`tc-*` custom elements) with from-scratch toolcase styling and a Bootstrap-compatible class API. Covers layout (BasicLayout, DashboardLayout, DashboardContent, DashboardSidebar, Login, Container, Row, Col, Spacer, Stack), content (ActionHeader, ActionItems, ActionRowList, Alert, AnnouncementBar, ApiReferenceTable, AssetRow, AssetRowList, Avatar, Badge, BadgeRow, Banner, Brand, Build, BriefCard, BundleBar, CdnMap, CalloutQuote, Changelog, ChartContainer, Sparkline, TrendIndicator, Leaderboard, LeaderboardTrend, CodeLabelCell, CodeSnippet, CodeWithOutput, CommunityLinks, ConfigPreview, ContributorWall, CookbookGrid, CoolButton, ActivityCard, BasicCard, Button, ButtonGroup, Card, Carousel, CloseButton, Collapse, Divider, Dropdown, DownloadStats, EcosystemMap, EmptyState, GameShowcaseCard, GithubStarsCard, GoodFirstIssues, Group, Hero, HeroStatsBar, Heading, Image, InfiniteScroll, Kbd, ListCard, ListGroup, LogoCloud, MaintainerCard, Marquee, MetricTile, MetricGrid, MigrationGuide, PageFooter, Panel, PhaseGrid, Pipeline, PinnedFeatureShowcase, PluginGrid, PricingCard, File, UserPanel, QueuedFile, Placeholder, Progress, PulseIndicator, ScoringRules, ScoreDisplay, Speedometer, SectionCard, SectionFlag, Skeleton, Spinner, SprintChain, Stamp, MetricCard, StatCard, StateMachine, StatusCard, StatusDot, Stepper, Tag, TeamList, TierLadder, Timeline, UsageSummaryPanel, WelcomeGuide, CommandReference, Comparator, CompatibilityMatrix, CountdownTimer, FAQList, FeatureMatrix, Text, VisuallyHidden), navigation (Breadcrumb, CoolNav, Nav, Navbar, Pagination, Scrollspy, SocialLinks, Stepper), overlays & feedback (ContextMenu, DebugOverlay, Modal, Offcanvas, Popover, Toast, Tooltip), and forms (CardOptions, Check, CheckboxGroup, Chip, ChipGroup, ColorPicker, IconPicker, DatePicker, EarlySignupForm, EditableText, FloatingLabel, Form, HelperText, Input, InputGroup, InputGroupText, Label, MultiCardSelect, NewsletterSignup, Option, Radio, RadioGroup, Range, RangeSlider, DeadzoneSlider, Select, Switch, Textarea). Consumable from any stack — React, Vue, Svelte, or plain HTML. --- # web-components — API Reference @@ -37,6 +37,7 @@ After `register()` you can author markup directly: - [tc-col](#tc-col) - [tc-safe-area](#tc-safe-area) - [tc-spacer](#tc-spacer) + - [tc-stack](#tc-stack) - [tc-grid](#tc-grid) - [tc-anchor](#tc-anchor) - [tc-aspect-ratio-box](#tc-aspect-ratio-box) @@ -795,6 +796,72 @@ None. --- +### tc-stack + +Flexbox row/column layout primitive. Children are composed along a single axis with configurable gap, alignment, justification, and optional wrapping. The element has no visible chrome; it is purely structural. + +**Tag:** `tc-stack` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `direction` | `vertical\|horizontal` | `vertical` | Flex axis. `vertical` → `flex-direction: column`; `horizontal` → `flex-direction: row`. | +| `gap` | string | `var(--bs-stack-gap, 0px)` | CSS gap between children (e.g. `8px`, `1rem`). When absent the `--bs-stack-gap` custom property applies. | +| `align` | string | `var(--bs-stack-align, stretch)` | `align-items` value (e.g. `center`, `flex-start`). When absent the `--bs-stack-align` custom property applies. | +| `justify` | string | `var(--bs-stack-justify, flex-start)` | `justify-content` value (e.g. `space-between`, `center`). When absent the `--bs-stack-justify` custom property applies. | +| `wrap` | boolean | `false` | When present enables `flex-wrap: wrap`. | +| `inline` | boolean | `false` | When present switches the host to `display: inline-flex`. | + +**CSS custom properties (theming contract)** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-stack-gap` | `0px` | Default gap when the `gap` attribute is absent. | +| `--bs-stack-align` | `stretch` | Default `align-items` when the `align` attribute is absent. | +| `--bs-stack-justify` | `flex-start` | Default `justify-content` when the `justify` attribute is absent. | + +**Events** + +None. `tc-stack` is a purely structural element. + +**Slots** + +Default slot — all child elements are laid out in the flex container. + +```html + + +
    Item A
    +
    Item B
    +
    Item C
    +
    + + + + Left + Right + + + + +
    Alpha
    +
    Beta
    +
    Gamma
    +
    + + +

    + Before + + AB + + after. +

    +``` + +--- + ### tc-grid CSS-grid layout primitive. Set a column and/or row count, a gap, and a uniform cell size; children are laid out directly as grid items. Purely structural — no visible chrome, no shadows, no border-radius. All cosmetics flow through `--bs-grid-*` custom properties. diff --git a/examples/src/web-components/StackDemo.tsx b/examples/src/web-components/StackDemo.tsx new file mode 100644 index 00000000..deff3a86 --- /dev/null +++ b/examples/src/web-components/StackDemo.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const box = (label: string) => ( +
    + {label} +
    +) + +const StackDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="Stack" + description="Flexbox row/column layout primitive. Composes children along a single axis with configurable gap, alignment, and wrapping." + /> + +
    + + {/* @ts-ignore */} + + {box('Item A')} + {box('Item B')} + {box('Item C')} + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {box('Left')} + {box('Middle')} + {box('Right')} + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {box('Alpha')} + {box('Beta')} + {box('Gamma')} + {box('Delta')} + {box('Epsilon')} + {box('Zeta')} + {box('Eta')} + {box('Theta')} + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + {box('Start')} + {box('Center')} + {box('End')} + {/* @ts-ignore */} + + + + +

    + Inline text before + {/* @ts-ignore */} + + {box('A')} + {box('B')} + {/* @ts-ignore */} + + and after. +

    +
    +
    +
    +
    +
    +
    +) + +export default StackDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index e88a2d4c..a8beeec1 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -136,6 +136,7 @@ import ItemTooltipDemo from './ItemTooltipDemo' import AbilityCardDemo from './AbilityCardDemo' import SkillBarDemo from './SkillBarDemo' import SkillTreeDemo from './SkillTreeDemo' +import StackDemo from './StackDemo' import AmmoCounterDemo from './AmmoCounterDemo' import ComboCounterDemo from './ComboCounterDemo' import GoodFirstIssuesDemo from './GoodFirstIssuesDemo' @@ -432,6 +433,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'link', category: 'Content', element: }, { key: 'safe-area', category: 'Layout', element: }, { key: 'spacer', category: 'Layout', element: }, + { key: 'stack', category: 'Layout', element: }, { key: 'anchor', category: 'Layout', element: }, { key: 'aspect-ratio-box', category: 'Layout', element: }, { key: 'grid', category: 'Layout', element: }, diff --git a/web-components/src/Stack.ts b/web-components/src/Stack.ts new file mode 100644 index 00000000..cbbc7509 --- /dev/null +++ b/web-components/src/Stack.ts @@ -0,0 +1,90 @@ +const TAG_NAME = 'tc-stack' + +export type StackDirection = 'vertical' | 'horizontal' +const DIRECTIONS: StackDirection[] = ['vertical', 'horizontal'] + +export class Stack extends HTMLElement { + + private _initialised = false + + static get observedAttributes(): string[] { + return ['direction', 'gap', 'align', 'justify', 'wrap', 'inline'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get direction(): StackDirection { + const v = this.getAttribute('direction') + return DIRECTIONS.includes(v as StackDirection) ? (v as StackDirection) : 'vertical' + } + set direction(v: StackDirection) { + if (DIRECTIONS.includes(v)) this.setAttribute('direction', v) + else this.removeAttribute('direction') + } + + get gap(): string { + return this.getAttribute('gap') ?? '' + } + set gap(v: string) { + if (v) this.setAttribute('gap', v) + else this.removeAttribute('gap') + } + + get align(): string { + return this.getAttribute('align') ?? '' + } + set align(v: string) { + if (v) this.setAttribute('align', v) + else this.removeAttribute('align') + } + + get justify(): string { + return this.getAttribute('justify') ?? '' + } + set justify(v: string) { + if (v) this.setAttribute('justify', v) + else this.removeAttribute('justify') + } + + get wrap(): boolean { + return this.hasAttribute('wrap') + } + set wrap(v: boolean) { + if (v) this.setAttribute('wrap', '') + else this.removeAttribute('wrap') + } + + get inline(): boolean { + return this.hasAttribute('inline') + } + set inline(v: boolean) { + if (v) this.setAttribute('inline', '') + else this.removeAttribute('inline') + } + + // Purely structural — no innerHTML, no slots. Mutates inline styles directly; + // CSS provides defaults via --bs-stack-* vars when attributes are absent. + private render(): void { + this.style.flexDirection = this.direction === 'horizontal' ? 'row' : 'column' + this.style.gap = this.getAttribute('gap') || '' + this.style.alignItems = this.getAttribute('align') || '' + this.style.justifyContent = this.getAttribute('justify') || '' + this.style.flexWrap = this.wrap ? 'wrap' : 'nowrap' + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Stack + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index a951a814..88ee86c9 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -345,3 +345,4 @@ export * from './ScrollText' export * from './ShopPanel' export * from './SkillBar' export * from './SkillTree' +export * from './Stack' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 87c250b0..c39f9356 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -342,6 +342,7 @@ import { ScrollText } from './ScrollText' import { ShopPanel } from './ShopPanel' import { SkillBar } from './SkillBar' import { SkillTree } from './SkillTree' +import { Stack } from './Stack' export function register(): void { if (customElements.get('tc-button') !== undefined) { @@ -694,4 +695,5 @@ export function register(): void { customElements.define('tc-shop-panel', ShopPanel) customElements.define('tc-skill-bar', SkillBar) customElements.define('tc-skill-tree', SkillTree) + customElements.define('tc-stack', Stack) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index fb228b35..5239dcbc 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -93,6 +93,7 @@ @forward 'label'; @forward 'link'; @forward 'spacer'; +@forward 'stack'; @forward 'text'; @forward 'visually-hidden'; @forward 'pulse-indicator'; diff --git a/web-components/style/components/_stack.scss b/web-components/style/components/_stack.scss new file mode 100644 index 00000000..625903df --- /dev/null +++ b/web-components/style/components/_stack.scss @@ -0,0 +1,24 @@ +// tc-stack — purely structural flexbox layout primitive. +// No visible chrome; inline styles drive flex properties from attributes. +// This partial registers display and provides --bs-stack-* defaults that +// take effect when the corresponding attribute is absent (inline style is cleared). + +tc-stack { + // Public theming contract — override to change defaults for all instances. + --bs-stack-gap: 0px; + --bs-stack-align: stretch; + --bs-stack-justify: flex-start; + + display: flex; + flex-direction: column; + // Applied when the JS attribute is absent (inline style cleared by render()). + gap: var(--bs-stack-gap); + align-items: var(--bs-stack-align); + justify-content: var(--bs-stack-justify); + flex-wrap: nowrap; +} + +// inline attribute switches to inline-flex so the stack sits inside a text/flex row. +tc-stack[inline] { + display: inline-flex; +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 58833914..1b458224 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -568,6 +568,13 @@ tc-platform-icon { display: inline-flex; } +// tc-stack is a flexbox layout primitive; display varies by the [inline] attribute +// (handled in _stack.scss). Register the flex default here so layout is correct +// before JS runs. +tc-stack { + display: flex; +} + @media (prefers-reduced-motion: reduce) { *, *::before, From ee487410c57be1c0573519c08670a20176a39dc1 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 06:58:59 +0000 Subject: [PATCH 382/632] 377-stamina-bar: Build the tc-stamina-bar web component (StaminaBar game-components port) --- examples/public/web-components/SKILL.md | 73 +++++++++++ .../src/web-components/StaminaBarDemo.tsx | 51 ++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/StaminaBar.ts | 121 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_stamina-bar.scss | 117 +++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 369 insertions(+) create mode 100644 examples/src/web-components/StaminaBarDemo.tsx create mode 100644 web-components/src/StaminaBar.ts create mode 100644 web-components/style/components/_stamina-bar.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index c30b3ea0..6068b6cd 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -120,6 +120,7 @@ After `register()` you can author markup directly: - [tc-buff-icon](#tc-buff-icon) - [tc-health-bar](#tc-health-bar) - [tc-mana-bar](#tc-mana-bar) + - [tc-stamina-bar](#tc-stamina-bar) - [tc-brightness-calibration](#tc-brightness-calibration) - [tc-cdn-map](#tc-cdn-map) - [tc-compass-bar](#tc-compass-bar) @@ -10699,6 +10700,78 @@ None. `tc-mana-bar` is attribute-driven. --- +### tc-stamina-bar + +Value/max resource bar for stamina (SP) — a green success fill over a flat slate track. Structurally identical to `tc-health-bar` and `tc-mana-bar`; styled with `--tc-success` (green) so all three resource bars are visually distinct at a glance in a game HUD. Supports an optional label row (human-readable label + mono `value / max` readout), a ghost band behind the fill for recent stamina drain, inline mono text inside the track, and evenly-spaced segment dividers. Purely presentational, no events, no slots. Ported from the game-components `gc-stamina-bar` (which extends `ResourceBarBase`); the fantasy chrome is dropped in favour of flat slate chrome; the track / fill / ghost / tick DOM is shared with the rest of the resource-bar family through the `internal/resourceBar` helper. + +**Tag:** `tc-stamina-bar` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `value` | number | `0` | Current value. Clamped to `[0, max]` for the fill width and `aria-valuenow`. Non-numeric values fall back to `0`. | +| `max` | number | `100` | Maximum value. Values `<= 0` (or non-numeric) fall back to `100`. | +| `ghost` | number | — | Optional "ghost" / recent-drain value drawn as a muted band behind the fill. Only shown when it resolves to a wider band than the current fill. | +| `segments` | number | `1` | Number of equal slots; `segments - 1` evenly-spaced divider ticks are drawn across the track. Values `< 1` (or non-numeric) fall back to `1`. | +| `show-text` | boolean | `false` | When present (and no `label` is set), draws a centred mono `value / max` readout inside the track. | +| `label` | string | `""` | When set, renders a label row above the track with the label and a trailing mono `value / max` readout. Also used as the `aria-label` for the progressbar (defaults to `"Stamina"`). | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `value` | `number` | Reflects the `value` attribute. | +| `max` | `number` | Reflects the `max` attribute. | +| `ghost` | `number \| null` | Reflects the `ghost` attribute; set to `null` to remove it. | +| `segments` | `number` | Reflects the `segments` attribute. | +| `showText` | `boolean` | Reflects the `show-text` boolean attribute. | +| `label` | `string` | Reflects the `label` attribute. | + +**Events** + +None. `tc-stamina-bar` is a purely presentational element. + +**Slots** + +None. `tc-stamina-bar` is attribute-driven. + +**CSS Custom Properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-stamina-bar-gap` | `0.375rem` | Gap between the label row and the track. | +| `--bs-stamina-bar-label-color` | `var(--tc-text)` | Label text color. | +| `--bs-stamina-bar-label-font-size` | `0.8125rem` | Label font size. | +| `--bs-stamina-bar-label-font-weight` | `500` | Label font weight (≤600). | +| `--bs-stamina-bar-value-color` | `var(--tc-text-muted)` | `value / max` readout color. | +| `--bs-stamina-bar-value-font-size` | `0.75rem` | `value / max` readout font size. | +| `--bs-stamina-bar-track-bg` | `var(--tc-slate-200)` | Track background. | +| `--bs-stamina-bar-track-height` | `0.625rem` | Track height. | +| `--bs-stamina-bar-fill-bg` | `var(--tc-success)` | Value-fill color (green success; overridable). | +| `--bs-stamina-bar-fill-transition` | `width var(--tc-transition-base)` | Fill-width transition (disabled under reduced motion). | +| `--bs-stamina-bar-ghost-bg` | `var(--tc-slate-400)` | Ghost / recent-drain band color (shared resource-bar contract). | +| `--bs-stamina-bar-tick-color` | `var(--tc-surface)` | Segment-divider color. | +| `--bs-stamina-bar-inline-text-color` | `var(--tc-text-muted)` | Inline `value / max` text color. | + +**Example** + +```html + + + + + + + + + + + +``` + +--- + ### tc-brightness-calibration Gamma/brightness calibration view: three grayscale reference swatches (dark / mid / bright), each carrying a calibration instruction, plus a `0–1` brightness slider with a mono percentage readout. A `brightness()` filter derived from the slider value is applied to the whole swatch preview so the user can adjust until each band matches its instruction. Drops the game-components fantasy chrome in favour of a flat slate card with a hairline-separated swatch grid and the shared `.form-range` slider. diff --git a/examples/src/web-components/StaminaBarDemo.tsx b/examples/src/web-components/StaminaBarDemo.tsx new file mode 100644 index 00000000..d7e39a04 --- /dev/null +++ b/examples/src/web-components/StaminaBarDemo.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const StaminaBarDemo: React.FC = () => { + return ( +
    +
    +
    +
    + Web Components} + title="StaminaBar" + description="Value/max resource bar for stamina (SP) — a green success fill over a flat slate track. Supports an optional label row with a mono value/max readout, a ghost band for recent drain, inline mono text inside the track, and evenly-spaced segment dividers." + /> + +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + +
    +
    +
    +
    +
    + ) +} + +export default StaminaBarDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index a8beeec1..54792304 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -100,6 +100,7 @@ import BuffBarDemo from './BuffBarDemo' import BuffIconDemo from './BuffIconDemo' import HealthBarDemo from './HealthBarDemo' import ManaBarDemo from './ManaBarDemo' +import StaminaBarDemo from './StaminaBarDemo' import BrightnessCalibrationDemo from './BrightnessCalibrationDemo' import CalloutQuoteDemo from './CalloutQuoteDemo' import ChartContainerDemo from './ChartContainerDemo' @@ -459,6 +460,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'buff-bar', category: 'Content', element: }, { key: 'health-bar', category: 'Content', element: }, { key: 'mana-bar', category: 'Content', element: }, + { key: 'stamina-bar', category: 'Content', element: }, { key: 'buff-icon', category: 'Content', element: }, { key: 'brightness-calibration', category: 'Components', element: }, { key: 'callout-quote', category: 'Content', element: }, diff --git a/web-components/src/StaminaBar.ts b/web-components/src/StaminaBar.ts new file mode 100644 index 00000000..ac8897e6 --- /dev/null +++ b/web-components/src/StaminaBar.ts @@ -0,0 +1,121 @@ +import { escapeHtml, renderResourceBarTrack } from './internal/resourceBar' + +const TAG_NAME = 'tc-stamina-bar' + +export class StaminaBar extends HTMLElement { + private _initialised = false + + static get observedAttributes(): string[] { + return ['value', 'max', 'ghost', 'segments', 'show-text', 'label'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get value(): number { + const raw = this.getAttribute('value') + if (raw == null) return 0 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) ? 0 : parsed + } + set value(v: number) { + this.setAttribute('value', String(v)) + } + + get max(): number { + const raw = this.getAttribute('max') + if (raw == null) return 100 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) || parsed <= 0 ? 100 : parsed + } + set max(v: number) { + this.setAttribute('max', String(v)) + } + + get ghost(): number | null { + const raw = this.getAttribute('ghost') + if (raw == null) return null + const parsed = parseFloat(raw) + return Number.isNaN(parsed) ? null : parsed + } + set ghost(v: number | null) { + if (v == null) this.removeAttribute('ghost') + else this.setAttribute('ghost', String(v)) + } + + get segments(): number { + const raw = this.getAttribute('segments') + if (raw == null) return 1 + const parsed = parseInt(raw, 10) + return Number.isNaN(parsed) || parsed < 1 ? 1 : parsed + } + set segments(v: number) { + this.setAttribute('segments', String(v)) + } + + get showText(): boolean { + return this.hasAttribute('show-text') + } + set showText(v: boolean) { + if (v) this.setAttribute('show-text', '') + else this.removeAttribute('show-text') + } + + get label(): string { + return this.getAttribute('label') ?? '' + } + set label(v: string) { + this.setAttribute('label', v) + } + + private render(): void { + const max = this.max + const value = Math.max(0, Math.min(max, this.value)) + const label = this.label + const showText = this.showText + const segments = this.segments + + this.classList.add('tc-stamina-bar') + + const numericText = `${Math.round(value)} / ${Math.round(max)}` + + const ticks = + segments > 1 + ? Array.from({ length: segments - 1 }, (_, i) => (i + 1) / segments) + : [] + + const labelRow = label + ? `
    ` + + `${escapeHtml(label)}` + + `${numericText}` + + `
    ` + : '' + + const track = renderResourceBarTrack({ + prefix: 'tc-stamina-bar', + value, + max, + ghost: this.ghost, + ticks, + label: label || 'Stamina', + inlineText: showText && !label ? numericText : null, + }) + + this.innerHTML = labelRow + track + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: StaminaBar + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 88ee86c9..23407541 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -102,6 +102,7 @@ export * from './BuffBar' export * from './BuffIcon' export * from './HealthBar' export * from './ManaBar' +export * from './StaminaBar' export * from './Hotbar' export * from './InventoryGrid' export * from './ItemSlot' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index c39f9356..618a2c0e 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -101,6 +101,7 @@ import { BuffBar } from './BuffBar' import { BuffIcon } from './BuffIcon' import { HealthBar } from './HealthBar' import { ManaBar } from './ManaBar' +import { StaminaBar } from './StaminaBar' import { Hotbar } from './Hotbar' import { InventoryGrid } from './InventoryGrid' import { ItemSlot } from './ItemSlot' @@ -451,6 +452,7 @@ export function register(): void { customElements.define('tc-buff-bar', BuffBar) customElements.define('tc-health-bar', HealthBar) customElements.define('tc-mana-bar', ManaBar) + customElements.define('tc-stamina-bar', StaminaBar) customElements.define('tc-brightness-calibration', BrightnessCalibration) customElements.define('tc-callout-quote', CalloutQuote) customElements.define('tc-chart-container', ChartContainer) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 5239dcbc..88379f1c 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -118,6 +118,7 @@ @forward 'buff-icon'; @forward 'health-bar'; @forward 'mana-bar'; +@forward 'stamina-bar'; @forward 'brightness-calibration'; @forward 'callout-quote'; @forward 'chart-container'; diff --git a/web-components/style/components/_stamina-bar.scss b/web-components/style/components/_stamina-bar.scss new file mode 100644 index 00000000..c1fb122f --- /dev/null +++ b/web-components/style/components/_stamina-bar.scss @@ -0,0 +1,117 @@ +// tc-stamina-bar — value/max resource bar for stamina (SP) in a game HUD. +// Structurally identical to tc-health-bar and tc-mana-bar; styled with a green +// success fill (--tc-success) so the three resource bars are visually distinct +// at a glance. All cosmetics flow through --bs-stamina-bar-* custom properties +// backed by --tc-* tokens. Sharp corners; slate neutrals; JetBrains Mono for +// machine-facing readout; 1px hairline track. + +@use '../foundation/tokens' as *; + +tc-stamina-bar { + --bs-stamina-bar-gap: 0.375rem; + + --bs-stamina-bar-label-color: var(--tc-text); + --bs-stamina-bar-label-font-size: 0.8125rem; + --bs-stamina-bar-label-font-weight: 500; + + --bs-stamina-bar-value-color: var(--tc-text-muted); + --bs-stamina-bar-value-font-size: 0.75rem; + + --bs-stamina-bar-track-bg: var(--tc-slate-200); + --bs-stamina-bar-track-height: 0.625rem; + // Green accent fills the stamina bar — conventionally green in game UIs; --tc-success is the nearest token. + --bs-stamina-bar-fill-bg: var(--tc-success); + --bs-stamina-bar-fill-transition: width var(--tc-transition-base); + --bs-stamina-bar-ghost-bg: var(--tc-slate-400); + --bs-stamina-bar-tick-color: var(--tc-surface); + --bs-stamina-bar-inline-text-color: var(--tc-text-muted); +} + +.tc-stamina-bar { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: var(--bs-stamina-bar-gap); + width: 100%; +} + +// Label row — human-readable label (left) + mono value/max readout pushed right. +.tc-stamina-bar__label-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; + min-width: 0; +} + +.tc-stamina-bar__label { + min-width: 0; + font-size: var(--bs-stamina-bar-label-font-size); + font-weight: var(--bs-stamina-bar-label-font-weight); + line-height: 1.3; + color: var(--bs-stamina-bar-label-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tc-stamina-bar__label-value { + flex-shrink: 0; + font-family: 'JetBrains Mono', var(--bs-font-monospace, monospace); + font-size: var(--bs-stamina-bar-value-font-size); + letter-spacing: 0.04em; + color: var(--bs-stamina-bar-value-color); +} + +// Track — flat slate well; the fill, ghost band, and segment ticks all stack +// absolutely inside it (the shared resourceBar helper paints these elements). +.tc-stamina-bar__track { + position: relative; + width: 100%; + height: var(--bs-stamina-bar-track-height); + background: var(--bs-stamina-bar-track-bg); + border-radius: 0; + overflow: hidden; +} + +.tc-stamina-bar__ghost { + position: absolute; + inset: 0 auto 0 0; + height: 100%; + background: var(--bs-stamina-bar-ghost-bg); +} + +.tc-stamina-bar__fill { + position: absolute; + inset: 0 auto 0 0; + height: 100%; + background: var(--bs-stamina-bar-fill-bg); + transition: var(--bs-stamina-bar-fill-transition); +} + +// Segment dividers — thin vertical hairlines splitting the track into equal slots. +.tc-stamina-bar__tick { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + transform: translateX(-1px); + background: var(--bs-stamina-bar-tick-color); +} + +.tc-stamina-bar__inline-text { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-family: 'JetBrains Mono', var(--bs-font-monospace, monospace); + font-size: 0.625rem; + color: var(--bs-stamina-bar-inline-text-color); +} + +@media (prefers-reduced-motion: reduce) { + .tc-stamina-bar__fill { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 1b458224..233d886b 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -180,6 +180,7 @@ tc-boss-bar, tc-buff-bar, tc-health-bar, tc-mana-bar, +tc-stamina-bar, tc-brightness-calibration, tc-callout-quote, tc-character-create, From db48bccc1a0446cab467f78f240b90616b93ac11 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 07:08:54 +0000 Subject: [PATCH 383/632] 378-stat-row: Build the tc-stat-row web component (StatRow game-components port) --- examples/public/web-components/SKILL.md | 80 +++++++++++ examples/src/web-components/StatRowDemo.tsx | 94 +++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/StatRow.ts | 122 ++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_stat-row.scss | 131 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 434 insertions(+) create mode 100644 examples/src/web-components/StatRowDemo.tsx create mode 100644 web-components/src/StatRow.ts create mode 100644 web-components/style/components/_stat-row.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 6068b6cd..4b0381ba 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -100,6 +100,7 @@ After `register()` you can author markup directly: - [tc-settings-category-list](#tc-settings-category-list) - [tc-shake-container](#tc-shake-container) - [tc-score-display](#tc-score-display) + - [tc-stat-row](#tc-stat-row) - [tc-speedometer](#tc-speedometer) - [tc-scroll-text](#tc-scroll-text) - [tc-shop-panel](#tc-shop-panel) @@ -24919,6 +24920,85 @@ None. All content is driven by attributes. --- +### tc-stat-row + +Label + value stat row with an optional trend indicator. Port of `gc-stat-row` from `@toolcase/game-components`, restyled to the toolcase design system with slate neutrals, JetBrains Mono values, and 1px hairline separators. Purely presentational; attribute-driven with no events and no slots. All cosmetics flow through `--bs-stat-row-*` custom properties. + +**Tag:** `tc-stat-row` + +#### Attributes + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `label` | string | `""` | Row label shown on the left side in an uppercase JetBrains Mono micro-label. | +| `value` | string \| number | `""` | Value shown on the right. Pure numeric strings are formatted with `toLocaleString()`. | +| `accent` | string | `""` | CSS color value written to `--bs-stat-row-accent` on the host. Applied as a colored left-edge stripe. | +| `trend` | number \| null | `null` | Numeric delta. When non-zero, renders a `TrendingUp`/`TrendingDown` Lucide icon + the absolute value. | + +#### JS Properties + +| Property | Type | Notes | +|---|---|---| +| `label` | `string` | Reflects the `label` attribute. | +| `value` | `string \| number` | Reflects the `value` attribute. Numbers are converted to string via `String()`. | +| `accent` | `string` | Reflects the `accent` attribute. | +| `trend` | `number \| null` | Reflects the `trend` attribute; parsed to `float`; `null` when absent or non-numeric. | + +#### Events + +None. `tc-stat-row` is a purely presentational element. + +#### Slots + +None. All content is driven by attributes. + +#### CSS Custom Properties + +| Property | Default | Description | +|---|---|---| +| `--bs-stat-row-bg` | `transparent` | Row background. | +| `--bs-stat-row-border-color` | `var(--tc-border)` | 1px hairline bottom-separator color. | +| `--bs-stat-row-padding-y` | `0.5rem` | Vertical inner padding. | +| `--bs-stat-row-padding-x` | `0.75rem` | Horizontal inner padding. | +| `--bs-stat-row-gap` | `0.5rem` | Gap between label and value, and between value and trend chip. | +| `--bs-stat-row-accent` | `transparent` | Left-edge stripe color (set inline by the `accent` attribute). | +| `--bs-stat-row-accent-width` | `3px` | Width of the left-edge accent stripe. | +| `--bs-stat-row-label-color` | `var(--tc-text-muted)` | Label text color. | +| `--bs-stat-row-label-font-size` | `0.6875rem` | Label font size (~11 px). | +| `--bs-stat-row-label-letter-spacing` | `0.08em` | Label letter spacing. | +| `--bs-stat-row-value-color` | `var(--tc-text)` | Value text color. | +| `--bs-stat-row-value-font-size` | `0.925rem` | Value font size. | +| `--bs-stat-row-value-font-weight` | `600` | Value font weight. | +| `--bs-stat-row-trend-font-size` | `0.75rem` | Trend chip text size. | +| `--bs-stat-row-trend-icon-size` | `0.875rem` | Trend icon width/height. | +| `--bs-stat-row-trend-gap` | `0.25rem` | Gap between trend icon and trend text. | +| `--bs-stat-row-trend-up-color` | `var(--tc-success)` | Trend chip color when `trend > 0`. | +| `--bs-stat-row-trend-down-color` | `var(--tc-danger)` | Trend chip color when `trend < 0`. | + +#### Example + +```html + +
    + + + +
    + + + + + +``` + +--- + ### tc-speedometer Vehicle speedometer gauge — a semicircular SVG arc indicator with a JetBrains Mono centre readout (speed value, unit, optional gear and RPM). Port of `gc-speedometer`; game-specific fantasy chrome replaced with the slate design system. Purely presentational; attribute-driven with no slots. All cosmetics flow through `--bs-speedometer-*` custom properties. diff --git a/examples/src/web-components/StatRowDemo.tsx b/examples/src/web-components/StatRowDemo.tsx new file mode 100644 index 00000000..c42ab2fa --- /dev/null +++ b/examples/src/web-components/StatRowDemo.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const StatRowDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="StatRow" + description="Label + value stat row with an optional trend indicator. Port of gc-stat-row; styled to the slate design system." + /> + +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + +
    +
    +
    +
    +
    +) + +export default StatRowDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 54792304..9773b075 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -331,6 +331,7 @@ import SafeAreaDemo from './SafeAreaDemo' import SaveSlotListDemo from './SaveSlotListDemo' import SettingsCategoryListDemo from './SettingsCategoryListDemo' import ScoreDisplayDemo from './ScoreDisplayDemo' +import StatRowDemo from './StatRowDemo' import SpeedometerDemo from './SpeedometerDemo' import ScreenFlashDemo from './ScreenFlashDemo' import ShakeContainerDemo from './ShakeContainerDemo' @@ -689,6 +690,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'save-slot-list', category: 'Components', element: }, { key: 'settings-category-list', category: 'Components', element: }, { key: 'score-display', category: 'Content', element: }, + { key: 'stat-row', category: 'Components', element: }, { key: 'speedometer', category: 'Content', element: }, { key: 'scroll-text', category: 'Content', element: }, { key: 'shop-panel', category: 'Components', element: }, diff --git a/web-components/src/StatRow.ts b/web-components/src/StatRow.ts new file mode 100644 index 00000000..841f3de0 --- /dev/null +++ b/web-components/src/StatRow.ts @@ -0,0 +1,122 @@ +import * as LucideIcons from 'lucide-static' +import { icon } from './icons' + +const TAG_NAME = 'tc-stat-row' + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +// Resolve a lucide icon by PascalCase key, return empty string when absent. +function lucideByName(name: string): string { + const svg = (LucideIcons as Record)[name] + return svg ?? '' +} + +const trendUpIconHtml = icon(lucideByName('TrendingUp') || lucideByName('ArrowUp'), 'tc-stat-row-trend-icon') +const trendDownIconHtml = icon(lucideByName('TrendingDown') || lucideByName('ArrowDown'), 'tc-stat-row-trend-icon') + +export class StatRow extends HTMLElement { + private _initialised = false + + static get observedAttributes(): string[] { + return ['label', 'value', 'accent', 'trend'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get label(): string { + return this.getAttribute('label') ?? '' + } + set label(v: string) { + if (v) this.setAttribute('label', v) + else this.removeAttribute('label') + } + + get value(): string { + return this.getAttribute('value') ?? '' + } + set value(v: string | number) { + const text = typeof v === 'number' ? String(v) : v + if (text) this.setAttribute('value', text) + else this.removeAttribute('value') + } + + get accent(): string { + return this.getAttribute('accent') ?? '' + } + set accent(v: string) { + if (v) this.setAttribute('accent', v) + else this.removeAttribute('accent') + } + + get trend(): number | null { + const raw = this.getAttribute('trend') + if (raw == null || raw === '') return null + const parsed = parseFloat(raw) + return Number.isFinite(parsed) ? parsed : null + } + set trend(v: number | null) { + if (v == null) this.removeAttribute('trend') + else this.setAttribute('trend', String(v)) + } + + private render(): void { + const accent = this.accent + if (accent) this.style.setProperty('--bs-stat-row-accent', accent) + else this.style.removeProperty('--bs-stat-row-accent') + + // Format numeric string values with toLocaleString; pass through others. + const rawValue = this.value + const numeric = parseFloat(rawValue) + const displayValue = Number.isFinite(numeric) && String(numeric) === rawValue + ? numeric.toLocaleString() + : rawValue + + const trend = this.trend + let trendHtml = '' + if (trend != null && trend !== 0) { + const isUp = trend > 0 + const trendIcon = isUp ? trendUpIconHtml : trendDownIconHtml + const modClass = isUp ? 'tc-stat-row-trend--up' : 'tc-stat-row-trend--down' + const ariaLabel = isUp ? 'trending up' : 'trending down' + const trendText = Math.abs(trend).toLocaleString() + trendHtml = [ + ``, + trendIcon, + `${esc(trendText)}`, + '', + ].join('') + } + + this.innerHTML = [ + '
    ', + `${esc(this.label)}`, + '', + `${esc(displayValue)}`, + trendHtml, + '', + '
    ', + ].join('') + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: StatRow + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 23407541..5a0d938a 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -341,6 +341,7 @@ export * from './SettingsCategoryList' export * from './ScreenFlash' export * from './ShakeContainer' export * from './ScoreDisplay' +export * from './StatRow' export * from './Speedometer' export * from './ScrollText' export * from './ShopPanel' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 618a2c0e..321d743a 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -338,6 +338,7 @@ import { SafeArea } from './SafeArea' import { SaveSlotList } from './SaveSlotList' import { SettingsCategoryList } from './SettingsCategoryList' import { ScoreDisplay } from './ScoreDisplay' +import { StatRow } from './StatRow' import { Speedometer } from './Speedometer' import { ScrollText } from './ScrollText' import { ShopPanel } from './ShopPanel' @@ -692,6 +693,7 @@ export function register(): void { customElements.define('tc-save-slot-list', SaveSlotList) customElements.define('tc-settings-category-list', SettingsCategoryList) customElements.define('tc-score-display', ScoreDisplay) + customElements.define('tc-stat-row', StatRow) customElements.define('tc-speedometer', Speedometer) customElements.define('tc-scroll-text', ScrollText) customElements.define('tc-shop-panel', ShopPanel) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 88379f1c..0fa77ccb 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -335,6 +335,7 @@ @forward 'shake-container'; @forward 'screen-flash'; @forward 'score-display'; +@forward 'stat-row'; @forward 'speedometer'; @forward 'scroll-text'; @forward 'shop-panel'; diff --git a/web-components/style/components/_stat-row.scss b/web-components/style/components/_stat-row.scss new file mode 100644 index 00000000..42bd6c9d --- /dev/null +++ b/web-components/style/components/_stat-row.scss @@ -0,0 +1,131 @@ +// tc-stat-row — label + value stat row with an optional trend indicator. +// Port of gc-stat-row; game-component fantasy chrome replaced with the slate +// design system. All cosmetics flow through --bs-stat-row-* custom properties +// backed by --tc-* tokens. + +@use '../foundation/tokens' as *; + +tc-stat-row { + --bs-stat-row-bg: transparent; + --bs-stat-row-border-color: var(--tc-border); + --bs-stat-row-padding-y: 0.5rem; + --bs-stat-row-padding-x: 0.75rem; + --bs-stat-row-gap: 0.5rem; + + // Accent — written as an inline custom property by render() when set. + // Rendered as an inset box-shadow on the inner wrapper so it never shifts + // row content or layout. + --bs-stat-row-accent: transparent; + --bs-stat-row-accent-width: 3px; + + --bs-stat-row-label-color: var(--tc-text-muted); + --bs-stat-row-label-font-size: 0.6875rem; + --bs-stat-row-label-letter-spacing: 0.08em; + + --bs-stat-row-value-color: var(--tc-text); + --bs-stat-row-value-font-size: 0.925rem; + --bs-stat-row-value-font-weight: 600; + + --bs-stat-row-trend-font-size: 0.75rem; + --bs-stat-row-trend-icon-size: 0.875rem; + --bs-stat-row-trend-gap: 0.25rem; + --bs-stat-row-trend-up-color: var(--tc-success); + --bs-stat-row-trend-down-color: var(--tc-danger); + + // Hairline bottom separator lives on the HOST element so tc-stat-row:last-child + // can remove it cleanly — the inner .tc-stat-row div is always the only child + // so :last-child on it would always match. + border-bottom: 1px solid var(--bs-stat-row-border-color); + + &:last-child { + border-bottom: 0; + } +} + +.tc-stat-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--bs-stat-row-gap); + padding: var(--bs-stat-row-padding-y) var(--bs-stat-row-padding-x); + background: var(--bs-stat-row-bg); + border-radius: 0; + + // Accent stripe as an inset shadow — never shifts row content or layout. + box-shadow: inset var(--bs-stat-row-accent-width) 0 0 0 var(--bs-stat-row-accent); +} + +// Label — uppercase JetBrains Mono micro-label in muted slate. +.tc-stat-row-label { + font-family: 'JetBrains Mono', var(--bs-font-monospace, monospace); + font-size: var(--bs-stat-row-label-font-size); + font-weight: 500; + line-height: 1.4; + text-transform: uppercase; + letter-spacing: var(--bs-stat-row-label-letter-spacing); + color: var(--bs-stat-row-label-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 0 1 auto; + min-width: 0; +} + +// Right column — value + optional trend chip, baseline-aligned. +.tc-stat-row-right { + display: flex; + align-items: baseline; + gap: var(--bs-stat-row-gap); + flex-shrink: 0; +} + +// Value — JetBrains Mono, prominent weight. +.tc-stat-row-value { + font-family: 'JetBrains Mono', var(--bs-font-monospace, monospace); + font-size: var(--bs-stat-row-value-font-size); + font-weight: var(--bs-stat-row-value-font-weight); + color: var(--bs-stat-row-value-color); + line-height: 1.2; + white-space: nowrap; +} + +// Trend chip — icon + number, colored by direction. +.tc-stat-row-trend { + display: inline-flex; + align-items: center; + gap: var(--bs-stat-row-trend-gap); + font-family: 'JetBrains Mono', var(--bs-font-monospace, monospace); + font-size: var(--bs-stat-row-trend-font-size); + font-weight: 500; + line-height: 1; + white-space: nowrap; + + &.tc-stat-row-trend--up { + color: var(--bs-stat-row-trend-up-color); + } + + &.tc-stat-row-trend--down { + color: var(--bs-stat-row-trend-down-color); + } +} + +// Trend icon — inline SVG sized via em so it scales with the chip font. +.tc-stat-row-trend-icon { + width: var(--bs-stat-row-trend-icon-size); + height: var(--bs-stat-row-trend-icon-size); + flex-shrink: 0; +} + +// 44 px coarse touch target — min-height on the host so the full row is tappable. +@media (pointer: coarse) { + tc-stat-row { + min-height: 44px; + } +} + +// No animations on this component; placeholder for future additions. +@media (prefers-reduced-motion: reduce) { + .tc-stat-row { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 233d886b..25d81661 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -359,6 +359,7 @@ tc-roadmap, tc-save-slot-list, tc-settings-category-list, tc-score-display, +tc-stat-row, tc-speedometer, tc-scroll-text, tc-shop-panel, From fc14e94116e00bb8c34a5b6ab7b2e36ead7d94f6 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 07:15:52 +0000 Subject: [PATCH 384/632] 379-stats-screen: Build the tc-stats-screen web component (StatsScreen game-components port) --- examples/public/web-components/SKILL.md | 94 +++++++++ .../src/web-components/StatsScreenDemo.tsx | 99 +++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/StatsScreen.ts | 109 ++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_stats-screen.scss | 193 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 6 + 9 files changed, 507 insertions(+) create mode 100644 examples/src/web-components/StatsScreenDemo.tsx create mode 100644 web-components/src/StatsScreen.ts create mode 100644 web-components/style/components/_stats-screen.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 4b0381ba..dcbf8e5e 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -104,6 +104,7 @@ After `register()` you can author markup directly: - [tc-speedometer](#tc-speedometer) - [tc-scroll-text](#tc-scroll-text) - [tc-shop-panel](#tc-shop-panel) + - [tc-stats-screen](#tc-stats-screen) - [tc-crosshair](#tc-crosshair) - [tc-section-flag](#tc-section-flag) - [tc-skeleton](#tc-skeleton) @@ -22643,6 +22644,99 @@ Matchmaking / searching status panel with a state indicator ring, eyebrow + titl --- +### tc-stats-screen + +End-of-match statistics panel. Port of `gc-stats-screen` (game-components), restyled to the toolcase design system: slate surface, sharp corners, 1px hairline borders, JetBrains Mono for machine-facing stat values, muted prose palette for labels. Game chrome (gilded frames, glows, fantasy fills, diamond dividers) is replaced with the slate neutral ramp. All cosmetics flow through `--bs-stats-screen-*` custom properties. Purely presentational — no events, no slots. + +**Tag:** `tc-stats-screen` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `screen-title` | string | `'Stats'` | Title displayed in the header beneath the "Statistics" eyebrow. | +| `summary` | string | `''` | Optional prose note shown below the title separator. Omit or leave empty to hide. | + +**JS Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `screenTitle` | `string` | `'Stats'` | Reflects the `screen-title` attribute. | +| `summary` | `string` | `''` | Reflects the `summary` attribute. | +| `sections` | `StatsSection[]` | `[]` | Array of stat sections. Each section has a `title` string and a `stats` array of `{ label: string; value: string \| number }`. Number values are formatted with `toLocaleString()`. Setting this property triggers a re-render. | + +**Events** + +`tc-stats-screen` fires no events — it is purely presentational. + +**Slots** + +`tc-stats-screen` has no slots. All content is driven by attributes and the `sections` JS property. + +**CSS custom properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-stats-screen-bg` | `var(--tc-surface)` | Panel background. | +| `--bs-stats-screen-border` | `var(--tc-border)` | Outer 1px hairline border. | +| `--bs-stats-screen-max-width` | `480px` | Maximum panel width. | +| `--bs-stats-screen-padding-x` | `1.5rem` | Horizontal padding for the header and stat rows. | +| `--bs-stats-screen-padding-y` | `1.75rem` | Vertical padding for the root panel. | +| `--bs-stats-screen-gap` | `1.25rem` | Gap between panel sections. | +| `--bs-stats-screen-header-bg` | `var(--tc-surface-muted)` | Header block background. | +| `--bs-stats-screen-header-border` | `var(--tc-border)` | Bottom border of the header block. | +| `--bs-stats-screen-header-padding-y` | `1.25rem` | Header vertical padding. | +| `--bs-stats-screen-eyebrow-color` | `var(--tc-text-muted)` | "Statistics" eyebrow label colour. | +| `--bs-stats-screen-eyebrow-size` | `10.5px` | Eyebrow font size. | +| `--bs-stats-screen-title-color` | `var(--tc-text)` | Title colour. | +| `--bs-stats-screen-title-size` | `1.125rem` | Title font size. | +| `--bs-stats-screen-title-weight` | `600` | Title font weight. | +| `--bs-stats-screen-summary-color` | `var(--tc-text-muted)` | Summary text colour. | +| `--bs-stats-screen-separator-color` | `var(--tc-border)` | Colour of the 1px hairline separator below the title. | +| `--bs-stats-screen-section-border` | `var(--tc-border)` | Bottom border between sections. | +| `--bs-stats-screen-section-title-color` | `var(--tc-text-faint)` | Section title (eyebrow) colour. | +| `--bs-stats-screen-section-title-size` | `10.5px` | Section title font size. | +| `--bs-stats-screen-stat-border` | `var(--tc-border)` | Top hairline border on each stat row. | +| `--bs-stats-screen-stat-label-color` | `var(--tc-text-muted)` | Stat label (left column) colour. | +| `--bs-stats-screen-stat-value-color` | `var(--tc-text)` | Stat value (right column) colour. | +| `--bs-stats-screen-stat-value-size` | `0.9375rem` | Stat value font size. | +| `--bs-stats-screen-stat-value-weight` | `600` | Stat value font weight. | + +**Example** + +```html + + + +``` + +--- + ### tc-metal-button Primary call-to-action button ported from `gc-metal-button` (game-components), restyled to the toolcase design system. Game-specific chrome (metal textures, gilded frames, glows) is dropped; the button renders with slate neutrals, sharp corners (`border-radius: 0`), a 1px hairline border, and the ink primary gradient for the `primary` variant. Slotted children become the button label. Disabled state is enforced natively on the inner `` + }).join('') + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: TabBar + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 6edbf31b..08c8fcc2 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -258,6 +258,7 @@ export * from './ResizablePanel' export * from './Roadmap' export * from './SideNav' export * from './SingleCardSelect' +export * from './TabBar' export * from './TabSections' export * from './Table' export * from './AdvancedTable' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index ff632636..a92fc4c3 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -256,6 +256,7 @@ import { ResizablePanel } from './ResizablePanel' import { Roadmap } from './Roadmap' import { SideNav } from './SideNav' import { SingleCardSelect } from './SingleCardSelect' +import { TabBar } from './TabBar' import { TabSections } from './TabSections' import { Table } from './Table' import { TerminalWindow } from './TerminalWindow' @@ -607,6 +608,7 @@ export function register(): void { customElements.define('tc-roadmap', Roadmap) customElements.define('tc-side-nav', SideNav) customElements.define('tc-single-card-select', SingleCardSelect) + customElements.define('tc-tab-bar', TabBar) customElements.define('tc-tab-sections', TabSections) customElements.define('tc-table', Table) customElements.define('tc-advanced-table', AdvancedTable) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 7157a40a..e8dbcec9 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -270,6 +270,7 @@ @forward 'otp-input'; @forward 'phone-input'; @forward 'physics-editor'; +@forward 'tab-bar'; @forward 'tab-sections'; @forward 'table'; @forward 'advanced-table'; diff --git a/web-components/style/components/_tab-bar.scss b/web-components/style/components/_tab-bar.scss new file mode 100644 index 00000000..c55f1686 --- /dev/null +++ b/web-components/style/components/_tab-bar.scss @@ -0,0 +1,119 @@ +// tc-tab-bar — horizontal tab switcher bar. +// The host IS the tablist (role="tablist"); tab buttons sit directly inside it. +// Only chrome: a 2px ink underline on the active tab and a 1px hairline below +// the whole bar. All cosmetics flow through --bs-tab-bar-* backed by --tc-* tokens. + +tc-tab-bar { + // Layout + --bs-tab-bar-gap: 0.125rem; + --bs-tab-bar-border: 1px solid var(--tc-border); + + // Tab button — base + --bs-tab-bar-tab-padding: 0.5rem 0.875rem; + --bs-tab-bar-tab-font-size: 0.8125rem; + --bs-tab-bar-tab-color: var(--tc-text-muted); + --bs-tab-bar-tab-color-hover: var(--tc-text); + --bs-tab-bar-tab-bg-hover: transparent; + + // Tab button — active + --bs-tab-bar-tab-color-active: var(--tc-text); + --bs-tab-bar-tab-underline: 2px solid var(--tc-app-accent); + + // Icon + --bs-tab-bar-tab-icon-size: 0.9375rem; +} + +// ── Host ────────────────────────────────────────────────────────────────────── + +tc-tab-bar { + display: flex; + flex-wrap: wrap; + gap: var(--bs-tab-bar-gap); + border-bottom: var(--bs-tab-bar-border); + border-radius: 0; +} + +// ── Tab button ──────────────────────────────────────────────────────────────── + +.tc-tab-bar-tab { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: var(--bs-tab-bar-tab-padding); + font-family: var(--tc-font-sans); + font-size: var(--bs-tab-bar-tab-font-size); + font-weight: 400; + line-height: 1.2; + color: var(--bs-tab-bar-tab-color); + background: transparent; + border: none; + border-radius: 0; + border-bottom: 2px solid transparent; + margin-bottom: -1px; // sit flush on the bar's hairline + cursor: pointer; + white-space: nowrap; + transition: color var(--tc-transition-fast, 0.15s ease); + + &:hover { + color: var(--bs-tab-bar-tab-color-hover); + background: var(--bs-tab-bar-tab-bg-hover); + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + } + + &--active { + color: var(--bs-tab-bar-tab-color-active); + border-bottom: var(--bs-tab-bar-tab-underline); + font-weight: 500; + } + + &:disabled, + &[aria-disabled='true'] { + opacity: 0.5; + pointer-events: none; + cursor: default; + } +} + +// ── Icon ────────────────────────────────────────────────────────────────────── + +.tc-tab-bar-tab-icon { + width: var(--bs-tab-bar-tab-icon-size); + height: var(--bs-tab-bar-tab-icon-size); + flex-shrink: 0; + stroke: currentColor; + stroke-width: 2; +} + +// ── Size modifier ───────────────────────────────────────────────────────────── + +tc-tab-bar.tc-tab-bar--sm { + .tc-tab-bar-tab { + padding: 0.3125rem 0.625rem; + font-size: 0.75rem; + } + + .tc-tab-bar-tab-icon { + width: 0.8125rem; + height: 0.8125rem; + } +} + +// ── 44px coarse touch targets ───────────────────────────────────────────────── + +@media (pointer: coarse) { + .tc-tab-bar-tab { + min-height: 44px; + } +} + +// ── Reduced motion ───────────────────────────────────────────────────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-tab-bar-tab { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 3ea442c4..4f2036c1 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -369,6 +369,11 @@ tc-subtitle { display: block; } +// tc-tab-bar is a flex-row tablist; the host IS the tablist container. +tc-tab-bar { + display: flex; +} + // tc-compass-bar lays out as a vertical flex column (track above the heading // readout); the partial owns the flex details but the host must not default // to inline. From e459523bae47c36dea63f630c566c1e9e2dbd82a Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 07:41:19 +0000 Subject: [PATCH 387/632] 382-title: Build the tc-title web component (Title game-components port) --- examples/public/web-components/SKILL.md | 60 ++++++++++++++++++ examples/src/web-components/TitleDemo.tsx | 61 ++++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Title.ts | 68 +++++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + web-components/style/components/_title.scss | 50 +++++++++++++++ web-components/style/foundation/_reset.scss | 3 +- 9 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 examples/src/web-components/TitleDemo.tsx create mode 100644 web-components/src/Title.ts create mode 100644 web-components/style/components/_title.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 109a7cd5..fbfa90b6 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -248,6 +248,7 @@ After `register()` you can author markup directly: - [tc-loot-popup](#tc-loot-popup) - [tc-lore-text](#tc-lore-text) - [tc-subtitle](#tc-subtitle) + - [tc-title](#tc-title) - [tc-main-menu](#tc-main-menu) - [tc-menu-item](#tc-menu-item) - [tc-metal-button](#tc-metal-button) @@ -22482,6 +22483,65 @@ None. All content is supplied via attributes. --- +### tc-title + +Large display title text for hero sections, screen headings, and prominent labels. Port of `gc-title` (game-components), restyled to the toolcase design system: slate neutrals, sharp corners, no glows or fantasy chrome. Slot content is the title text; `size` overrides the font size in pixels. No shadow root; light DOM; `display: block`. + +**Tag:** `tc-title` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `size` | `number` | — | Optional font size override in pixels (bare integer). Sets `--bs-title-font-size` inline. | +| `align` | `'left' \| 'center' \| 'right'` | `'left'` | Text alignment. `center` also applies `margin: auto` for constrained max-widths. | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `size` | `number \| null` | Get/set the `size` attribute as a number. | +| `align` | `'left' \| 'center' \| 'right'` | Get/set the `align` attribute. | + +**Events** + +None. `tc-title` is purely presentational. + +**Slots** + +| Slot | Description | +|------|-------------| +| *(default)* | The title text or markup to display. | + +**CSS Custom Properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-title-color` | `var(--tc-emphasis)` | Title text color. | +| `--bs-title-font-size` | `2.5rem` | Font size (overridden inline by `size` attribute). | +| `--bs-title-font-weight` | `700` | Font weight. | +| `--bs-title-line-height` | `1.15` | Line height. | +| `--bs-title-letter-spacing` | `-0.02em` | Letter-spacing. | +| `--bs-title-max-width` | `none` | Host max-width. | + +**Example** + +```html + +The World Awaits + + +Forge Your Legend + + +GAME OVER + + +Champion +``` + +--- + ### tc-main-menu Main-menu container of menu items. Port of `gc-main-menu` (game-components), restyled to the toolcase design system: slate neutrals, hairline borders, sharp corners, JetBrains Mono for machine-facing labels, ink accent for the selected item. Items are set via the JS `items` property. Arrow keys navigate between enabled items; Enter/Space fires `tc-select` for the highlighted item; hovering an item also highlights it. No shadow root; light DOM; `display: block`. diff --git a/examples/src/web-components/TitleDemo.tsx b/examples/src/web-components/TitleDemo.tsx new file mode 100644 index 00000000..ba607476 --- /dev/null +++ b/examples/src/web-components/TitleDemo.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const TitleDemo: React.FC = () => { + return ( +
    +
    +
    +
    + Web Components} + title="Title" + description="Large display title text for hero sections, screen headings, and prominent labels. Port of gc-title restyled to the web-components design system." + /> + +
    + + + {/* @ts-ignore */} + The World Awaits + + + + {/* @ts-ignore */} + Forge Your Legend + + + + {/* @ts-ignore */} + Season IV + + + + {/* @ts-ignore */} + Victory Achieved + + + + {/* @ts-ignore */} + GAME OVER + + + + {/* @ts-ignore */} + + Champion + + + +
    +
    +
    +
    +
    + ) +} + +export default TitleDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 305e8f63..43d1c529 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -316,6 +316,7 @@ import LoadingOverlayDemo from './LoadingOverlayDemo' import LoadingScreenDemo from './LoadingScreenDemo' import LoreTextDemo from './LoreTextDemo' import SubtitleDemo from './SubtitleDemo' +import TitleDemo from './TitleDemo' import MinimapDemo from './MinimapDemo' import MuteListDemo from './MuteListDemo' import PlayerCardDemo from './PlayerCardDemo' @@ -675,6 +676,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'loading-screen', category: 'Overlays & Feedback', element: }, { key: 'lore-text', category: 'Content', element: }, { key: 'subtitle', category: 'Content', element: }, + { key: 'title', category: 'Content', element: }, { key: 'minimap', category: 'Components', element: }, { key: 'mute-list', category: 'Components', element: }, { key: 'player-card', category: 'Components', element: }, diff --git a/web-components/src/Title.ts b/web-components/src/Title.ts new file mode 100644 index 00000000..c5242003 --- /dev/null +++ b/web-components/src/Title.ts @@ -0,0 +1,68 @@ +const TAG_NAME = 'tc-title' + +export type TitleAlign = 'left' | 'center' | 'right' +const ALIGNS: TitleAlign[] = ['left', 'center', 'right'] + +export class Title extends HTMLElement { + private _initialised = false + + static get observedAttributes(): string[] { + return ['size', 'align'] + } + + connectedCallback(): void { + if (!this._initialised) { + const slotContent = Array.from(this.childNodes) + this.render() + const inner = this.querySelector('.tc-title-content') + if (inner) slotContent.forEach(n => inner.appendChild(n)) + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + const inner = this.querySelector('.tc-title-content') + const slotContent = inner ? Array.from(inner.childNodes) : [] + this.render() + const newInner = this.querySelector('.tc-title-content') + if (newInner) slotContent.forEach(n => newInner.appendChild(n)) + } + + // NOTE: HTMLElement.title is a native reflected attribute — getter/setter + // deliberately omitted to avoid colliding with the platform property. + + get size(): number | null { + const raw = this.getAttribute('size') + if (raw == null) return null + const parsed = parseFloat(raw) + return Number.isNaN(parsed) || parsed <= 0 ? null : parsed + } + set size(value: number | null) { + if (value == null) this.removeAttribute('size') + else this.setAttribute('size', String(value)) + } + + get align(): TitleAlign { + const raw = this.getAttribute('align') as TitleAlign + return ALIGNS.includes(raw) ? raw : 'left' + } + set align(value: TitleAlign) { + this.setAttribute('align', value) + } + + private render(): void { + this.classList.add('tc-title') + this.dataset.align = this.align + + const sz = this.size + if (sz != null) this.style.setProperty('--bs-title-font-size', `${sz}px`) + else this.style.removeProperty('--bs-title-font-size') + + this.innerHTML = `
    ` + } +} + +declare global { + interface HTMLElementTagNameMap { [TAG_NAME]: Title } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 08c8fcc2..5b8b53b0 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -314,6 +314,7 @@ export * from './LootList' export * from './LootPopup' export * from './LoreText' export * from './Subtitle' +export * from './Title' export * from './MetalButton' export * from './NavButton' export * from './NetworkStatusIcon' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index a92fc4c3..e9d64ba0 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -313,6 +313,7 @@ import { LootList } from './LootList' import { LootPopup } from './LootPopup' import { LoreText } from './LoreText' import { Subtitle } from './Subtitle' +import { Title } from './Title' import { MetalButton } from './MetalButton' import { NavButton } from './NavButton' import { NetworkStatusIcon } from './NetworkStatusIcon' @@ -668,6 +669,7 @@ export function register(): void { customElements.define('tc-loot-popup', LootPopup) customElements.define('tc-lore-text', LoreText) customElements.define('tc-subtitle', Subtitle) + customElements.define('tc-title', Title) customElements.define('tc-metal-button', MetalButton) customElements.define('tc-nav-button', NavButton) customElements.define('tc-network-status-icon', NetworkStatusIcon) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index e8dbcec9..246fa883 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -312,6 +312,7 @@ @forward 'loot-popup'; @forward 'lore-text'; @forward 'subtitle'; +@forward 'title'; @forward 'pause-menu'; @forward 'pause-screen'; @forward 'matchmaking-screen'; diff --git a/web-components/style/components/_title.scss b/web-components/style/components/_title.scss new file mode 100644 index 00000000..9dbc4980 --- /dev/null +++ b/web-components/style/components/_title.scss @@ -0,0 +1,50 @@ +// tc-title — large display title text. +// Port of gc-title (game-components); drops Shadow DOM and game-specific chrome +// in favour of the web-components design system: sharp corners, slate neutral +// ramp, no glows or fantasy fills. All cosmetics flow through --bs-title-* vars. + +@use '../foundation/tokens' as *; + +tc-title { + --bs-title-color: var(--tc-emphasis); + --bs-title-font-size: 2.5rem; + --bs-title-font-weight: 700; + --bs-title-line-height: 1.15; + --bs-title-letter-spacing: -0.02em; + --bs-title-max-width: none; +} + +.tc-title { + max-width: var(--bs-title-max-width, none); + border-radius: 0; + box-sizing: border-box; +} + +// Alignment — driven by data-align on the host. +tc-title[data-align='center'] { + text-align: center; + margin-left: auto; + margin-right: auto; +} + +tc-title[data-align='right'] { + text-align: right; +} + +tc-title[data-align='left'] { + text-align: left; +} + +.tc-title-content { + font-size: var(--bs-title-font-size); + font-weight: var(--bs-title-font-weight); + line-height: var(--bs-title-line-height); + letter-spacing: var(--bs-title-letter-spacing); + color: var(--bs-title-color); +} + +@media (prefers-reduced-motion: reduce) { + .tc-title-content { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 4f2036c1..4e6cb1c3 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -365,7 +365,8 @@ tc-scroll-text, tc-shop-panel, tc-skill-bar, tc-skill-tree, -tc-subtitle { +tc-subtitle, +tc-title { display: block; } From 396cce47de807d25baa3c2a589cf8e353813035a Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 09:52:11 +0000 Subject: [PATCH 388/632] 383-title-screen: Build the tc-title-screen web component (TitleScreen game-components port) --- examples/public/web-components/SKILL.md | 82 +++++++++ .../src/web-components/TitleScreenDemo.tsx | 157 ++++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/TitleScreen.ts | 85 ++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_title-screen.scss | 143 ++++++++++++++++ web-components/style/foundation/_reset.scss | 7 + 9 files changed, 480 insertions(+) create mode 100644 examples/src/web-components/TitleScreenDemo.tsx create mode 100644 web-components/src/TitleScreen.ts create mode 100644 web-components/style/components/_title-screen.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index fbfa90b6..67c07b98 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -310,6 +310,7 @@ After `register()` you can author markup directly: - [tc-lightbox](#tc-lightbox) - [tc-loading-overlay](#tc-loading-overlay) - [tc-loading-screen](#tc-loading-screen) + - [tc-title-screen](#tc-title-screen) - [tc-modal](#tc-modal) - [tc-offcanvas](#tc-offcanvas) - [tc-popover](#tc-popover) @@ -7219,6 +7220,87 @@ None. --- +### tc-title-screen + +Full-viewport game title / start screen. Covers the entire viewport when present in the DOM; add or remove it (or toggle `[hidden]`) to show or hide it. Port of `gc-title-screen` (game-components) restyled to the toolcase design system — slate surface, sharp corners, 1px hairline divider in place of the diamond decoration, JetBrains Mono eyebrow, no game chrome. The eyebrow defaults to `"Press Start"` and is configurable via attribute. Purely presentational — no events, no slots, no shadow root; `display: block`. + +**Tag:** `tc-title-screen` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `eyebrow` | string | `"Press Start"` | Mono uppercase micro-label shown above the title. | +| `title-text` | string | `""` | Main title heading (`

    `). Omitted when empty. | +| `subtitle` | string | `""` | Secondary text shown below the hairline divider. Omitted when empty. | + +**JS Properties** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `eyebrow` | `string` | `"Press Start"` | Reflects the `eyebrow` attribute. | +| `titleText` | `string` | `""` | Reflects the `title-text` attribute. | +| `subtitle` | `string` | `""` | Reflects the `subtitle` attribute. | + +**Events** + +None. `tc-title-screen` is a passive display element. + +**Slots** + +None. All content is attribute-driven. + +**CSS custom properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-title-screen-bg` | `var(--tc-surface)` | Background colour of the full-screen surface. | +| `--bs-title-screen-z` | `var(--tc-z-modal)` | Stacking layer (fixed `--tc-z-*` scale). | +| `--bs-title-screen-fade` | `var(--tc-transition-base)` | Fade-in animation duration. | +| `--bs-title-screen-panel-max-width` | `520px` | Maximum width of the centred content column. | +| `--bs-title-screen-panel-padding-x` | `2rem` | Horizontal padding of the content panel. | +| `--bs-title-screen-panel-padding-y` | `3rem` | Vertical padding of the content panel. | +| `--bs-title-screen-panel-gap` | `1.25rem` | Gap between panel items. | +| `--bs-title-screen-eyebrow-color` | `var(--tc-cyan, #22d3ee)` | Eyebrow micro-label colour (cyan accent). | +| `--bs-title-screen-eyebrow-size` | `10.5px` | Eyebrow font size (JetBrains Mono). | +| `--bs-title-screen-title-color` | `var(--tc-text)` | Title heading colour. | +| `--bs-title-screen-title-size` | `2rem` | Title heading font size. | +| `--bs-title-screen-title-weight` | `700` | Title heading font weight. | +| `--bs-title-screen-divider-color` | `var(--tc-border)` | Hairline divider colour. | +| `--bs-title-screen-subtitle-color` | `var(--tc-text-muted)` | Subtitle text colour. | +| `--bs-title-screen-subtitle-size` | `0.9375rem` | Subtitle font size. | + +```html + + + + + + + + + + + + + + + +``` + +--- + ### tc-popover Popover positioned by Popper.js. diff --git a/examples/src/web-components/TitleScreenDemo.tsx b/examples/src/web-components/TitleScreenDemo.tsx new file mode 100644 index 00000000..2c162133 --- /dev/null +++ b/examples/src/web-components/TitleScreenDemo.tsx @@ -0,0 +1,157 @@ +import React, { useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const TitleScreenDemo: React.FC = () => { + const [showBasic, setShowBasic] = useState(false) + const [showTitle, setShowTitle] = useState(false) + const [showFull, setShowFull] = useState(false) + const [showCustom, setShowCustom] = useState(false) + + return ( +
    +
    +
    +
    + Web Components} + title="Title Screen" + description="Full-viewport game title / start screen. Covers the viewport when present in the DOM; add or remove it (or toggle [hidden]) to show or hide it. Port of gc-title-screen — game chrome (diamond dividers, gilded frames) replaced with the slate design system: sharp corners, 1px hairline divider, JetBrains Mono eyebrow." + /> + +
    + + +

    + The minimum configuration — only the eyebrow{' '} + prompt is shown (default: "Press Start"). The + title heading and subtitle are omitted when their attributes + are absent. +

    + + + {showBasic && ( + <> + {/* @ts-ignore */} + +
    + +
    + + )} +
    + + +

    + Adding a title-text attribute renders an{' '} + <h1> heading above the hairline divider. +

    + + + {showTitle && ( + <> + {/* @ts-ignore */} + +
    + +
    + + )} +
    + + +

    + Adding a subtitle attribute renders a muted + paragraph below the hairline divider. +

    + + + {showFull && ( + <> + {/* @ts-ignore */} + +
    + +
    + + )} +
    + + +

    + Override the default "Press Start" eyebrow via the{' '} + eyebrow attribute — useful for attract screens, + credits, or any static splash that needs a different prompt. +

    + + + {showCustom && ( + <> + {/* @ts-ignore */} + +
    + +
    + + )} +
    + +
    +
    +
    +
    +
    + ) +} + +export default TitleScreenDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 43d1c529..4cfa9537 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -317,6 +317,7 @@ import LoadingScreenDemo from './LoadingScreenDemo' import LoreTextDemo from './LoreTextDemo' import SubtitleDemo from './SubtitleDemo' import TitleDemo from './TitleDemo' +import TitleScreenDemo from './TitleScreenDemo' import MinimapDemo from './MinimapDemo' import MuteListDemo from './MuteListDemo' import PlayerCardDemo from './PlayerCardDemo' @@ -674,6 +675,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'level-select', category: 'Components', element: }, { key: 'loading-overlay', category: 'Overlays & Feedback', element: }, { key: 'loading-screen', category: 'Overlays & Feedback', element: }, + { key: 'title-screen', category: 'Overlays & Feedback', element: }, { key: 'lore-text', category: 'Content', element: }, { key: 'subtitle', category: 'Content', element: }, { key: 'title', category: 'Content', element: }, diff --git a/web-components/src/TitleScreen.ts b/web-components/src/TitleScreen.ts new file mode 100644 index 00000000..d1910b16 --- /dev/null +++ b/web-components/src/TitleScreen.ts @@ -0,0 +1,85 @@ +const TAG_NAME = 'tc-title-screen' + +// Port of game-components `gc-title-screen`. Game chrome (diamond dividers, +// gilded frames, glows, fantasy fills) is dropped; this renders to the +// web-components design system — a fixed full-viewport surface with a centred +// content panel, mono eyebrow, optional title heading, hairline divider, and +// optional subtitle. All cosmetics flow through `--bs-title-screen-*` custom +// properties so themes can re-skin via vars alone. + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +export class TitleScreen extends HTMLElement { + + static get observedAttributes(): string[] { + return ['title-text', 'subtitle', 'eyebrow'] + } + + connectedCallback(): void { + if (!this.hasAttribute('role')) this.setAttribute('role', 'region') + this.render() + } + + attributeChangedCallback(): void { + if (this.isConnected) this.render() + } + + // NOTE: HTMLElement.title is a native reflected attribute — getter/setter + // deliberately omitted to avoid colliding with the platform property. + // Use `getAttribute('title-text')` or the `titleText` JS property instead. + + get titleText(): string { + return this.getAttribute('title-text') ?? '' + } + set titleText(v: string) { + if (v) this.setAttribute('title-text', v) + else this.removeAttribute('title-text') + } + + get subtitle(): string { + return this.getAttribute('subtitle') ?? '' + } + set subtitle(v: string) { + if (v) this.setAttribute('subtitle', v) + else this.removeAttribute('subtitle') + } + + get eyebrow(): string { + return this.getAttribute('eyebrow') ?? 'Press Start' + } + set eyebrow(v: string) { + this.setAttribute('eyebrow', v) + } + + private render(): void { + const titleText = this.titleText + const subtitle = this.subtitle + const eyebrow = this.eyebrow + + const titleMarkup = titleText + ? `

    ${esc(titleText)}

    ` + : '' + + const subtitleMarkup = subtitle + ? `

    ${esc(subtitle)}

    ` + : '' + + this.innerHTML = + `
    ` + + `
    ${esc(eyebrow)}
    ` + + titleMarkup + + `` + + subtitleMarkup + + `
    ` + } +} + +declare global { + interface HTMLElementTagNameMap { [TAG_NAME]: TitleScreen } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 5b8b53b0..e59eecbd 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -315,6 +315,7 @@ export * from './LootPopup' export * from './LoreText' export * from './Subtitle' export * from './Title' +export * from './TitleScreen' export * from './MetalButton' export * from './NavButton' export * from './NetworkStatusIcon' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index e9d64ba0..a0a3e872 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -314,6 +314,7 @@ import { LootPopup } from './LootPopup' import { LoreText } from './LoreText' import { Subtitle } from './Subtitle' import { Title } from './Title' +import { TitleScreen } from './TitleScreen' import { MetalButton } from './MetalButton' import { NavButton } from './NavButton' import { NetworkStatusIcon } from './NetworkStatusIcon' @@ -670,6 +671,7 @@ export function register(): void { customElements.define('tc-lore-text', LoreText) customElements.define('tc-subtitle', Subtitle) customElements.define('tc-title', Title) + customElements.define('tc-title-screen', TitleScreen) customElements.define('tc-metal-button', MetalButton) customElements.define('tc-nav-button', NavButton) customElements.define('tc-network-status-icon', NetworkStatusIcon) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 246fa883..cbce03f4 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -313,6 +313,7 @@ @forward 'lore-text'; @forward 'subtitle'; @forward 'title'; +@forward 'title-screen'; @forward 'pause-menu'; @forward 'pause-screen'; @forward 'matchmaking-screen'; diff --git a/web-components/style/components/_title-screen.scss b/web-components/style/components/_title-screen.scss new file mode 100644 index 00000000..f7e8a932 --- /dev/null +++ b/web-components/style/components/_title-screen.scss @@ -0,0 +1,143 @@ +// tc-title-screen — full-viewport game title / start screen. +// Port of gc-title-screen (game-components); game chrome (diamond dividers, +// gilded frames, glows, fantasy fills, metal textures) replaced with the +// slate design system: sharp corners, 1px hairline divider, JetBrains Mono +// eyebrow, no textures. The component covers the entire viewport when present +// in the DOM; add/remove it (or toggle [hidden]) to show or hide it. +// All cosmetics flow through --bs-title-screen-* custom properties. + +@use '../foundation/tokens' as *; + +// ── Component defaults ─────────────────────────────────────────────────────── + +tc-title-screen { + // Full-screen surface background + --bs-title-screen-bg: var(--tc-surface); + + // Z-index — modal tier to cover page content + --bs-title-screen-z: var(--tc-z-modal); + + // Fade-in animation duration + --bs-title-screen-fade: var(--tc-transition-base); + + // Panel sizing + spacing + --bs-title-screen-panel-max-width: 520px; + --bs-title-screen-panel-padding-x: 2rem; + --bs-title-screen-panel-padding-y: 3rem; + --bs-title-screen-panel-gap: 1.25rem; + + // Eyebrow — mono micro-label (cyan accent, machine-facing) + --bs-title-screen-eyebrow-color: var(--tc-cyan, #22d3ee); + --bs-title-screen-eyebrow-size: 10.5px; + + // Title heading + --bs-title-screen-title-color: var(--tc-text); + --bs-title-screen-title-size: 2rem; + --bs-title-screen-title-weight: 700; + + // Hairline divider + --bs-title-screen-divider-color: var(--tc-border); + + // Subtitle + --bs-title-screen-subtitle-color: var(--tc-text-muted); + --bs-title-screen-subtitle-size: 0.9375rem; +} + +// ── Host: fixed full-viewport shell ───────────────────────────────────────── +// The display: block from _reset.scss is overridden here to the centred flex +// container. No [open] attribute needed — add/remove the element or set +// [hidden] to control visibility. + +tc-title-screen { + position: fixed; + inset: 0; + z-index: var(--bs-title-screen-z); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--bs-title-screen-bg); + border-radius: 0; + animation: tc-title-screen-fade-in var(--bs-title-screen-fade) both; +} + +@keyframes tc-title-screen-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +// ── Panel: centred content column ──────────────────────────────────────────── +// No card chrome — the title screen IS the page at this moment. + +.tc-title-screen__panel { + width: 100%; + max-width: var(--bs-title-screen-panel-max-width); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--bs-title-screen-panel-gap); + padding: var(--bs-title-screen-panel-padding-y) var(--bs-title-screen-panel-padding-x); + text-align: center; +} + +// ── Eyebrow — mono micro-label ─────────────────────────────────────────────── +// JetBrains Mono per design system convention for machine-facing text. +// Cyan accent spent sparingly — marks the primary call-to-action prompt. + +.tc-title-screen__eyebrow { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: var(--bs-title-screen-eyebrow-size); + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--bs-title-screen-eyebrow-color); + line-height: 1.4; +} + +// ── Title heading ───────────────────────────────────────────────────────────── + +.tc-title-screen__title { + font-size: var(--bs-title-screen-title-size); + font-weight: var(--bs-title-screen-title-weight); + color: var(--bs-title-screen-title-color); + margin: 0; + line-height: 1.15; + letter-spacing: -0.02em; +} + +// ── Hairline divider — replaces the gc diamond+rule decoration ──────────────── +// Sharp per mandate; 1px height; full panel width. + +.tc-title-screen__divider { + width: 100%; + height: 1px; + background: var(--bs-title-screen-divider-color); + border-radius: 0; + flex-shrink: 0; +} + +// ── Subtitle ────────────────────────────────────────────────────────────────── + +.tc-title-screen__subtitle { + font-size: var(--bs-title-screen-subtitle-size); + color: var(--bs-title-screen-subtitle-color); + margin: 0; + line-height: 1.5; +} + +// ── Coarse pointer ──────────────────────────────────────────────────────────── + +@media (pointer: coarse) { + .tc-title-screen__panel { + padding-left: 1.5rem; + padding-right: 1.5rem; + } +} + +// ── Reduced motion ──────────────────────────────────────────────────────────── + +@media (prefers-reduced-motion: reduce) { + tc-title-screen { + animation: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 4e6cb1c3..2d8e5e7b 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -477,6 +477,13 @@ tc-loading-screen { display: block; } +// Full-viewport game title / start screen; the SCSS partial overrides display +// to a centred flex container and positions it fixed, but the host must not +// default to inline. +tc-title-screen { + display: block; +} + // Matchmaking status panel; the SCSS partial owns the inner flex layout, // but the host must not default to inline. tc-matchmaking-screen { From 074cdd35cdd1e7fdfe2d484c7c123a620f1e2f88 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 10:01:10 +0000 Subject: [PATCH 389/632] 384-toggle: Build the tc-toggle web component (Toggle game-components port) --- examples/public/web-components/SKILL.md | 85 ++++++++++++++ examples/src/web-components/ToggleDemo.tsx | 94 +++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Toggle.ts | 107 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + web-components/style/components/_toggle.scss | 113 +++++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 406 insertions(+) create mode 100644 examples/src/web-components/ToggleDemo.tsx create mode 100644 web-components/src/Toggle.ts create mode 100644 web-components/style/components/_toggle.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 67c07b98..585043ad 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -368,6 +368,7 @@ After `register()` you can author markup directly: - [tc-form-wizard](#tc-form-wizard) - [tc-multi-card-select](#tc-multi-card-select) - [tc-single-card-select](#tc-single-card-select) + - [tc-toggle](#tc-toggle) - [tc-toggle-card](#tc-toggle-card) - [tc-newsletter-signup](#tc-newsletter-signup) - [tc-number-input](#tc-number-input) @@ -9122,6 +9123,90 @@ Toggle switch (styled checkbox). --- +### tc-toggle + +Atomic standalone on/off switch — a pill-track with a sliding circular knob. The host element IS the switch: it carries `role="switch"`, `aria-checked`, and handles click + Space/Enter. Port of `@toolcase/game-components` `gc-toggle`, restyled to the toolcase design system (slate neutrals, sharp outer corners, sanctioned pill/circle geometry, ink gradient on checked). Use `tc-toggle-card` when you need a card wrapper around the switch. + +**Tag:** `tc-toggle` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `on` | boolean | false | The on/off state. Reflected — toggling the switch sets/removes the attribute. | +| `disabled` | boolean | false | Disables interaction (`opacity` + `pointer-events: none`). Removes the element from the tab order. | +| `label` | string | — | Sets `aria-label` on the host for screen-reader identification. Required when there is no visible text label alongside the switch. | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `on` | boolean | Get/set the on/off state. Reflected to the `on` attribute. | +| `disabled` | boolean | Reflected boolean. | +| `label` | `string \| null` | Reflected. | +| `onChange` | `((on: boolean) => void) \| null` | Optional callback fired alongside the `tc-change` event on every toggle. Default `null`. | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-change` | `{ on: boolean }` | Fired (bubbles, composed) whenever the user toggles the switch (click or Space/Enter). `detail.on` is the new state. Not fired while `disabled`. | + +**Slots** + +None. Content is driven entirely by attributes. + +**Accessibility** + +- Host carries `role="switch"` with `aria-checked`, is focusable (`tabindex="0"`), and toggles on click or Space/Enter. +- Disabled removes the host from the tab order (`tabindex="-1"`) and sets `aria-disabled="true"`. +- Focus is always visible (2px ink outline); `prefers-reduced-motion` is honoured — the knob slide is frozen, colour transitions kept. +- 44px coarse-pointer touch target via `min-width`/`min-height` on the host under `@media (pointer: coarse)`. + +**CSS custom properties (theming)** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-toggle-track-width` | `40px` | Track width | +| `--bs-toggle-track-height` | `22px` | Track height | +| `--bs-toggle-track-bg` | `var(--tc-border-strong)` | Track colour when off | +| `--bs-toggle-track-hover-bg` | `var(--tc-text-faint)` | Track colour on hover (off state) | +| `--bs-toggle-track-checked-bg` | `var(--tc-app-accent)` | Track base colour when on | +| `--bs-toggle-track-checked-gradient` | `linear-gradient(135deg, …)` | Ink gradient applied over the checked track | +| `--bs-toggle-knob-size` | `18px` | Knob diameter | +| `--bs-toggle-knob-bg` | `#ffffff` | Knob fill | +| `--bs-toggle-disabled-opacity` | `0.55` | Opacity of the disabled state | + +**Examples** + +```html + + + + + + + + + + + + + + + + + +``` + +--- + ### tc-textarea Multi-line text input. diff --git a/examples/src/web-components/ToggleDemo.tsx b/examples/src/web-components/ToggleDemo.tsx new file mode 100644 index 00000000..d3fad61e --- /dev/null +++ b/examples/src/web-components/ToggleDemo.tsx @@ -0,0 +1,94 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const ToggleDemo: React.FC = () => { + const controlledRef = useRef(null) + const [isOn, setIsOn] = useState(false) + + // Sync the controlled toggle's on state with React state. + useEffect(() => { + const el = controlledRef.current + if (!el) return + const handler = (e: Event) => { + setIsOn((e as CustomEvent<{ on: boolean }>).detail.on) + } + el.addEventListener('tc-change', handler) + return () => el.removeEventListener('tc-change', handler) + }, []) + + useEffect(() => { + const el = controlledRef.current + if (el) { + if (isOn) el.setAttribute('on', '') + else el.removeAttribute('on') + } + }, [isOn]) + + return ( +
    +
    +
    +
    + Web Components} + title="Toggle" + description="Atomic on/off switch — a standalone pill-track switch atom. The host IS the control: role=switch, keyboard accessible, fires tc-change on every flip. Use tc-toggle-card when you need a card wrapper around the switch." + /> + +
    + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    + + +
    +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    +
    + {/* @ts-ignore */} + + Dark mode +
    +
    + {/* @ts-ignore */} + + Auto-save +
    +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default ToggleDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 4cfa9537..7cd3c4a0 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -294,6 +294,7 @@ import TagInputDemo from './TagInputDemo' import TerminalWindowDemo from './TerminalWindowDemo' import TestimonialCarouselDemo from './TestimonialCarouselDemo' import TimePickerDemo from './TimePickerDemo' +import ToggleDemo from './ToggleDemo' import ToggleCardDemo from './ToggleCardDemo' import VerticalItemListDemo from './VerticalItemListDemo' import VideoEmbedDemo from './VideoEmbedDemo' @@ -655,6 +656,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'terminal-window', category: 'Content', element: }, { key: 'testimonial-carousel', category: 'Content', element: }, { key: 'time-picker', category: 'Forms', element: }, + { key: 'toggle', category: 'Forms', element: }, { key: 'toggle-card', category: 'Forms', element: }, { key: 'video-embed', category: 'Components', element: }, { key: 'cycle-wheel', category: 'Components', element: }, diff --git a/web-components/src/Toggle.ts b/web-components/src/Toggle.ts new file mode 100644 index 00000000..a3949185 --- /dev/null +++ b/web-components/src/Toggle.ts @@ -0,0 +1,107 @@ +const TAG_NAME = 'tc-toggle' + +export class Toggle extends HTMLElement { + + private _initialised = false + + /** Optional callback fired alongside the tc-change event. */ + onChange: ((on: boolean) => void) | null = null + + static get observedAttributes(): string[] { + return ['on', 'disabled', 'label'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.addEventListener('click', this._onClick) + this.addEventListener('keydown', this._onKeydown) + this.render() + this._initialised = true + } + } + + disconnectedCallback(): void { + this.removeEventListener('click', this._onClick) + this.removeEventListener('keydown', this._onKeydown) + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get on(): boolean { + return this.hasAttribute('on') + } + set on(v: boolean) { + if (v) this.setAttribute('on', '') + else this.removeAttribute('on') + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + get label(): string | null { + return this.getAttribute('label') + } + set label(v: string | null) { + if (v != null) this.setAttribute('label', v) + else this.removeAttribute('label') + } + + private _onClick = (): void => { + this._toggle() + } + + private _onKeydown = (e: KeyboardEvent): void => { + if (this.disabled) return + if (e.key !== ' ' && e.key !== 'Enter') return + e.preventDefault() + this._toggle() + } + + private _toggle(): void { + if (this.disabled) return + const next = !this.on + this.on = next + this.dispatchEvent(new CustomEvent('tc-change', { + bubbles: true, + composed: true, + detail: { on: next }, + })) + if (typeof this.onChange === 'function') this.onChange(next) + } + + private render(): void { + const on = this.on + const disabled = this.disabled + const label = this.label + + this.setAttribute('role', 'switch') + this.setAttribute('aria-checked', on ? 'true' : 'false') + this.setAttribute('tabindex', disabled ? '-1' : '0') + + if (label) this.setAttribute('aria-label', label) + else this.removeAttribute('aria-label') + + if (disabled) this.setAttribute('aria-disabled', 'true') + else this.removeAttribute('aria-disabled') + + this.classList.add('tc-toggle') + this.classList.toggle('tc-toggle--on', on) + this.classList.toggle('tc-toggle--disabled', disabled) + + this.innerHTML = `` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Toggle + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index e59eecbd..ac295603 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -265,6 +265,7 @@ export * from './AdvancedTable' export * from './TerminalWindow' export * from './TestimonialCarousel' export * from './TimePicker' +export * from './Toggle' export * from './ToggleCard' export * from './TreeView' export * from './UserPanel' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index a0a3e872..813cb192 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -262,6 +262,7 @@ import { Table } from './Table' import { TerminalWindow } from './TerminalWindow' import { TestimonialCarousel } from './TestimonialCarousel' import { TimePicker } from './TimePicker' +import { Toggle } from './Toggle' import { ToggleCard } from './ToggleCard' import { TreeView } from './TreeView' import { UserPanel } from './UserPanel' @@ -618,6 +619,7 @@ export function register(): void { customElements.define('tc-terminal-window', TerminalWindow) customElements.define('tc-testimonial-carousel', TestimonialCarousel) customElements.define('tc-time-picker', TimePicker) + customElements.define('tc-toggle', Toggle) customElements.define('tc-toggle-card', ToggleCard) customElements.define('tc-tree-view', TreeView) customElements.define('tc-user-panel', UserPanel) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index cbce03f4..5b872809 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -278,6 +278,7 @@ @forward 'terminal-window'; @forward 'testimonial-carousel'; @forward 'time-picker'; +@forward 'toggle'; @forward 'toggle-card'; @forward 'tree-view'; @forward 'user-panel'; diff --git a/web-components/style/components/_toggle.scss b/web-components/style/components/_toggle.scss new file mode 100644 index 00000000..90230ee9 --- /dev/null +++ b/web-components/style/components/_toggle.scss @@ -0,0 +1,113 @@ +// tc-toggle — standalone atomic on/off switch (pill track + sliding knob). +// The host element IS the switch — it carries role="switch", aria-checked, +// and the click/keyboard affordance. The only sanctioned curves are the pill +// track (999px) and the circular knob (50%); everything else is sharp. +// All cosmetics flow through --bs-toggle-* custom properties backed by --tc-* tokens. + +tc-toggle { + --bs-toggle-track-width: 40px; + --bs-toggle-track-height: 22px; + --bs-toggle-track-bg: var(--tc-border-strong); + --bs-toggle-track-hover-bg: var(--tc-text-faint); + --bs-toggle-track-checked-bg: var(--tc-app-accent); + --bs-toggle-track-checked-gradient: linear-gradient(135deg, var(--tc-app-accent), #2b3a51); + --bs-toggle-track-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08); + --bs-toggle-knob-size: 18px; + --bs-toggle-knob-inset: 2px; + --bs-toggle-knob-bg: #ffffff; + --bs-toggle-knob-shadow: 0 0 0 0.5px rgba(15, 23, 42, 0.08); + --bs-toggle-focus-ring: rgba(30, 41, 59, 0.12); + --bs-toggle-disabled-opacity: 0.55; +} + +.tc-toggle { + cursor: pointer; + user-select: none; + -webkit-tap-highlight-color: transparent; + + // Coarse-pointer touch target: expand the host without enlarging the track. + @media (pointer: coarse) { + min-width: 44px; + min-height: 44px; + align-items: center; + justify-content: center; + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: 2px; + } + + &.tc-toggle--disabled { + opacity: var(--bs-toggle-disabled-opacity); + pointer-events: none; + cursor: default; + } +} + +// Hover lifts the off-track to the stronger slate well. +.tc-toggle:not(.tc-toggle--disabled):hover .tc-toggle-track { + background-color: var(--bs-toggle-track-hover-bg); +} + +.tc-toggle-track { + position: relative; + flex-shrink: 0; + width: var(--bs-toggle-track-width); + height: var(--bs-toggle-track-height); + background-color: var(--bs-toggle-track-bg); + border: 0; + border-radius: 999px; + box-shadow: var(--bs-toggle-track-shadow); + transition: + background-color var(--tc-transition-base), + box-shadow var(--tc-transition-base); +} + +.tc-toggle-knob { + // Pure circle — the one sanctioned curve. Centred vertically; slides + // from the inset-left rest position to the right edge when checked. + position: absolute; + top: 50%; + left: var(--bs-toggle-knob-inset); + width: var(--bs-toggle-knob-size); + height: var(--bs-toggle-knob-size); + background: var(--bs-toggle-knob-bg); + border-radius: 50%; + box-shadow: var(--bs-toggle-knob-shadow); + transform: translateY(-50%); + transition: left var(--tc-transition-base); +} + +// Checked state: ink gradient track + knob slides right. +.tc-toggle--on { + .tc-toggle-track { + background-color: var(--bs-toggle-track-checked-bg); + background-image: var(--bs-toggle-track-checked-gradient); + box-shadow: none; + } + + .tc-toggle-knob { + left: calc( + var(--bs-toggle-track-width) - var(--bs-toggle-knob-size) - var(--bs-toggle-knob-inset) + ); + } +} + +// Focus ring for form-control semantics (in addition to outline above). +.tc-toggle:focus-visible .tc-toggle-track { + box-shadow: 0 0 0 0.2rem var(--bs-toggle-focus-ring); +} + +.tc-toggle--on:focus-visible .tc-toggle-track { + // Suppress the ring shadow on the checked track — outline alone is enough. + box-shadow: none; +} + +// Freeze the knob slide under reduced-motion; keep the state-conveying colour +// transition so on/off remains visually distinct without animation. +@media (prefers-reduced-motion: reduce) { + .tc-toggle-knob { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 2d8e5e7b..7d5bf78c 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -568,6 +568,7 @@ tc-visually-hidden { display: contents; } +tc-toggle, tc-icon, tc-icon-badge, tc-icon-button, From 5d33919eae513a4da3d0f21199f1effd3444f97d Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 10:08:07 +0000 Subject: [PATCH 390/632] 385-toggle-row: Build the tc-toggle-row web component (ToggleRow game-components port) --- examples/public/web-components/SKILL.md | 45 ++++++++ examples/src/web-components/ToggleRowDemo.tsx | 89 +++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/ToggleRow.ts | 94 ++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_toggle-row.scss | 103 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 338 insertions(+) create mode 100644 examples/src/web-components/ToggleRowDemo.tsx create mode 100644 web-components/src/ToggleRow.ts create mode 100644 web-components/style/components/_toggle-row.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 585043ad..b3b3b160 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -352,6 +352,7 @@ After `register()` you can author markup directly: - [tc-fps-cap-select](#tc-fps-cap-select) - [tc-select-row](#tc-select-row) - [tc-fullscreen-toggle](#tc-fullscreen-toggle) + - [tc-toggle-row](#tc-toggle-row) - [tc-graphics-preset-picker](#tc-graphics-preset-picker) - [tc-invert-axis-toggle](#tc-invert-axis-toggle) - [tc-rating](#tc-rating) @@ -8887,6 +8888,50 @@ A fullscreen on/off setting row: a label/description text block paired with a pi --- +### tc-toggle-row + +A generic labeled boolean toggle setting row: a label/description text block paired with a pill-track switch (`role="switch"`, pure-circle knob — the checked track carries the signature slate-ink gradient). Built on the shared `tc-setting-row` scaffold (a label/control row that the setting rows reuse). Port of game-components `gc-toggle-row` with the fantasy chrome dropped for the toolcase slate/ink look. + +**Tag:** `tc-toggle-row` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `row-label` | string | — | Row label displayed on the left | +| `description` | string | — | Optional secondary line beneath the label | +| `checked` | boolean | `false` | Whether the toggle is on | +| `disabled` | boolean | `false` | Disables the switch | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `checked` | `boolean` | Get/set the on/off state. Setting patches the switch in place — no full re-render. | +| `rowLabel` | `string` | Get/set the `row-label` attribute. | +| `description` | `string` | Get/set the `description` attribute. | +| `disabled` | `boolean` | Get/set the `disabled` attribute. | +| `onChange` | `((value: boolean) => void) \| null` | Optional callback fired on every toggle. Mirrors the `tc-change` event. | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-change` | `{ value: boolean }` | Fired when the switch is toggled (the new checked state). | + +**No slots.** + +```html + + +``` + +--- + ### tc-graphics-preset-picker A low / medium / high / ultra graphics-preset setting row: a label/description text block paired with a segmented preset button group (one hairline frame, 1px internal separators, sharp corners, mono uppercase labels — the active segment carries the signature slate-ink fill). Built on the shared `tc-setting-row` scaffold (a label/control row that the setting rows reuse). Port of game-components `gc-graphics-preset-picker` with the fantasy chrome dropped for the toolcase slate/ink look. diff --git a/examples/src/web-components/ToggleRowDemo.tsx b/examples/src/web-components/ToggleRowDemo.tsx new file mode 100644 index 00000000..f39b0487 --- /dev/null +++ b/examples/src/web-components/ToggleRowDemo.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +function useToggleRowValue(initial: boolean): [boolean, React.RefObject] { + const [value, setValue] = useState(initial) + const ref = useRef(null) + + useEffect(() => { + const el = ref.current + if (!el) return + const handler = (e: Event) => { + const detail = (e as CustomEvent<{ value: boolean }>).detail + if (detail) setValue(detail.value) + } + el.addEventListener('tc-change', handler) + return () => el.removeEventListener('tc-change', handler) + }, []) + + return [value, ref] +} + +const ToggleRowDemo: React.FC = () => { + const [v1, ref1] = useToggleRowValue(false) + const [v2, ref2] = useToggleRowValue(true) + + return ( +
    +
    +
    +
    + Web Components} + title="Toggle Row" + description="A generic labeled boolean toggle setting row: a label/description block paired with a pill-track switch. Built on the shared tc-setting-row scaffold. Port of gc-toggle-row." + /> + +
    + +
    + {/* @ts-ignore */} + +
    +
    Current value: {String(v1)}
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    Current value: {String(v2)}
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default ToggleRowDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 7cd3c4a0..b9576a8a 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -274,6 +274,7 @@ import ResetToDefaultsDemo from './ResetToDefaultsDemo' import FPSCapSelectDemo from './FPSCapSelectDemo' import SelectRowDemo from './SelectRowDemo' import FullscreenToggleDemo from './FullscreenToggleDemo' +import ToggleRowDemo from './ToggleRowDemo' import InvertAxisToggleDemo from './InvertAxisToggleDemo' import AudioMixerDemo from './AudioMixerDemo' import RatingDemo from './RatingDemo' @@ -636,6 +637,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'fps-cap-select', category: 'Forms', element: }, { key: 'select-row', category: 'Forms', element: }, { key: 'fullscreen-toggle', category: 'Forms', element: }, + { key: 'toggle-row', category: 'Forms', element: }, { key: 'graphics-preset-picker', category: 'Forms', element: }, { key: 'invert-axis-toggle', category: 'Forms', element: }, { key: 'resizable-panel', category: 'Layout', element: }, diff --git a/web-components/src/ToggleRow.ts b/web-components/src/ToggleRow.ts new file mode 100644 index 00000000..2a67d546 --- /dev/null +++ b/web-components/src/ToggleRow.ts @@ -0,0 +1,94 @@ +import { SettingRowBase } from './SettingRowBase' + +const TAG_NAME = 'tc-toggle-row' + +// tc-toggle-row — a generic labeled boolean toggle setting row. A label/ +// description text block on the left paired with a pill-track switch +// (role="switch", pure-circle knob — the checked track carries the signature +// slate-ink gradient). Built on the shared SettingRowBase scaffold; subclasses +// such as tc-fullscreen-toggle extend this for preset-named rows. Port of +// game-components `gc-toggle-row` with the fantasy chrome dropped for the +// toolcase slate/ink look. All cosmetics flow through `--bs-toggle-row-*`. +export class ToggleRow extends SettingRowBase { + + // Optional callback mirror of the `tc-change` event (see styleguide §events). + onChange: ((value: boolean) => void) | null = null + + static get observedAttributes(): string[] { + return [...SettingRowBase.observedAttributes, 'checked', 'disabled'] + } + + attributeChangedCallback(name: string, old: string | null, next: string | null): void { + if (!this.isConnected || !this._initialised) return + // Patch checked/disabled in place — a full re-render would drop the + // button's focus on every toggle. + if (name === 'checked') { + const btn = this.querySelector('.tc-toggle-row__switch') + const checked = this.checked + if (btn) { + btn.setAttribute('aria-checked', String(checked)) + btn.dataset.checked = String(checked) + } + return + } + if (name === 'disabled') { + const btn = this.querySelector('.tc-toggle-row__switch') + if (btn) btn.disabled = this.disabled + return + } + super.attributeChangedCallback(name, old, next) + } + + get checked(): boolean { + return this.hasAttribute('checked') + } + set checked(v: boolean) { + if (v) this.setAttribute('checked', '') + else this.removeAttribute('checked') + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + protected renderControl(): string { + const checked = this.checked + const disabledAttr = this.disabled ? ' disabled' : '' + return ` + + ` + } + + protected bindControl(): void { + const btn = this.querySelector('.tc-toggle-row__switch') + if (!btn) return + btn.addEventListener('click', () => { + if (this.disabled) return + const next = !this.checked + this.checked = next + btn.dataset.checked = String(next) + btn.setAttribute('aria-checked', String(next)) + this.emit('tc-change', { value: next }) + if (typeof this.onChange === 'function') this.onChange(next) + }) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: ToggleRow + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index ac295603..65c01f3e 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -291,6 +291,7 @@ export * from './MouseSensitivity' export * from './FPSCapSelect' export * from './SelectRow' export * from './FullscreenToggle' +export * from './ToggleRow' export * from './GameOverScreen' export * from './ResultScreen' export * from './GamepadButtonPrompt' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 813cb192..d8524139 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -288,6 +288,7 @@ import { MouseSensitivity } from './MouseSensitivity' import { FPSCapSelect } from './FPSCapSelect' import { SelectRow } from './SelectRow' import { FullscreenToggle } from './FullscreenToggle' +import { ToggleRow } from './ToggleRow' import { GameOverScreen } from './GameOverScreen' import { ResultScreen } from './ResultScreen' import { GamepadButtonPrompt } from './GamepadButtonPrompt' @@ -645,6 +646,7 @@ export function register(): void { customElements.define('tc-fps-cap-select', FPSCapSelect) customElements.define('tc-select-row', SelectRow) customElements.define('tc-fullscreen-toggle', FullscreenToggle) + customElements.define('tc-toggle-row', ToggleRow) customElements.define('tc-game-over-screen', GameOverScreen) customElements.define('tc-result-screen', ResultScreen) customElements.define('tc-gamepad-button-prompt', GamepadButtonPrompt) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 5b872809..a2b6f66e 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -63,6 +63,7 @@ @forward 'fps-cap-select'; @forward 'select-row'; @forward 'fullscreen-toggle'; +@forward 'toggle-row'; @forward 'graphics-preset-picker'; @forward 'invert-axis-toggle'; @forward 'rating'; diff --git a/web-components/style/components/_toggle-row.scss b/web-components/style/components/_toggle-row.scss new file mode 100644 index 00000000..29153e55 --- /dev/null +++ b/web-components/style/components/_toggle-row.scss @@ -0,0 +1,103 @@ +// tc-toggle-row — generic labeled boolean toggle setting row built on the shared +// tc-setting-row scaffold (`_setting-row.scss`). The control is a pill-track +// switch (sanctioned pill geometry) with a pure-circle sliding knob — never a +// check glyph. The checked track carries the signature 135° slate-ink gradient; +// the off track is a flat slate well. All cosmetics flow through +// `--bs-toggle-row-*`; the fantasy chrome of the gc-* original is dropped. + +tc-toggle-row { + --bs-toggle-row-track-width: 40px; + --bs-toggle-row-track-height: 22px; + --bs-toggle-row-track-bg: var(--tc-border-strong); + --bs-toggle-row-track-hover-bg: var(--tc-text-faint); + --bs-toggle-row-track-checked-bg: var(--tc-app-accent); + --bs-toggle-row-track-checked-gradient: linear-gradient(135deg, var(--tc-app-accent), #2b3a51); + --bs-toggle-row-track-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08); + --bs-toggle-row-knob-size: 18px; + --bs-toggle-row-knob-inset: 2px; + --bs-toggle-row-knob-bg: #ffffff; + --bs-toggle-row-knob-border: rgba(15, 23, 42, 0.08); + --bs-toggle-row-knob-shadow: 0 1px 2px rgba(15, 23, 42, 0.18); + --bs-toggle-row-focus-ring: 0 0 0 0.2rem rgba(30, 41, 59, 0.12); + --bs-toggle-row-disabled-opacity: 0.6; +} + +.tc-toggle-row__switch { + position: relative; + flex-shrink: 0; + box-sizing: border-box; + width: var(--bs-toggle-row-track-width); + height: var(--bs-toggle-row-track-height); + padding: 0; + appearance: none; + background: var(--bs-toggle-row-track-bg); + border: 0; + border-radius: 999px; + box-shadow: var(--bs-toggle-row-track-shadow); + cursor: pointer; + transition: + background-color var(--tc-transition-base), + box-shadow var(--tc-transition-base); + + // Coarse pointers get a 44px hit area without changing the visual track. + @media (pointer: coarse) { + min-height: 44px; + min-width: 44px; + } + + &:hover { + background: var(--bs-toggle-row-track-hover-bg); + } + + &:focus { + outline: 0; + } + + &:focus-visible { + box-shadow: var(--bs-toggle-row-focus-ring); + } + + &[data-checked='true'] { + background: var(--bs-toggle-row-track-checked-bg); + background-image: var(--bs-toggle-row-track-checked-gradient); + box-shadow: none; + + .tc-toggle-row__knob { + // Slide the knob to the right edge of the track (transform stays + // reserved for the translateY centring, so animate `left`). + left: calc( + var(--bs-toggle-row-track-width) - var(--bs-toggle-row-knob-size) - + var(--bs-toggle-row-knob-inset) + ); + } + } + + &:disabled { + opacity: var(--bs-toggle-row-disabled-opacity); + pointer-events: none; + } +} + +.tc-toggle-row__knob { + // Pure circle — the one sanctioned curve. Centred vertically; slides + // horizontally from the inset-left rest position to the right edge. + position: absolute; + top: 50%; + left: var(--bs-toggle-row-knob-inset); + width: var(--bs-toggle-row-knob-size); + height: var(--bs-toggle-row-knob-size); + background: var(--bs-toggle-row-knob-bg); + border: 0.5px solid var(--bs-toggle-row-knob-border); + border-radius: 50%; + box-shadow: var(--bs-toggle-row-knob-shadow); + transform: translateY(-50%); + transition: left var(--tc-transition-base); +} + +// The knob's slide is the only motion here — freeze it under reduced-motion. +@media (prefers-reduced-motion: reduce) { + .tc-toggle-row__switch, + .tc-toggle-row__knob { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 7d5bf78c..ec781364 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -264,6 +264,7 @@ tc-fps-cap-select, tc-select-row, tc-reset-to-defaults, tc-fullscreen-toggle, +tc-toggle-row, tc-graphics-preset-picker, tc-invert-axis-toggle, tc-metric-card, From ff050cfd7772fdb6b278ae1b571eae8b0c08f08f Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 10:19:35 +0000 Subject: [PATCH 391/632] 386-transition-wipe: Build the tc-transition-wipe web component (TransitionWipe game-components port) --- examples/public/web-components/SKILL.md | 80 ++++++++ .../src/web-components/TransitionWipeDemo.tsx | 175 ++++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/TransitionWipe.ts | 138 ++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_transition-wipe.scss | 142 ++++++++++++++ web-components/style/foundation/_reset.scss | 7 + 9 files changed, 548 insertions(+) create mode 100644 examples/src/web-components/TransitionWipeDemo.tsx create mode 100644 web-components/src/TransitionWipe.ts create mode 100644 web-components/style/components/_transition-wipe.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index b3b3b160..3243d0df 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -318,6 +318,7 @@ After `register()` you can author markup directly: - [tc-screen-flash](#tc-screen-flash) - [tc-toast](#tc-toast) - [tc-tooltip](#tc-tooltip) + - [tc-transition-wipe](#tc-transition-wipe) - [Forms](#forms) - [tc-card-options](#tc-card-options) - [tc-character-create](#tc-character-create) @@ -25874,3 +25875,82 @@ None. `tc-skill-tree` is entirely data-driven via JS properties and attributes. tree.addEventListener('tc-unlock', e => console.log('Unlocked:', e.detail.id)) ``` + +--- + +### tc-transition-wipe + +Full-screen scene-wipe transition overlay. Ported from `gc-transition-wipe` and restyled to the toolcase design system: no shadow DOM, no fantasy chrome — a flat-fill `
    ` that CSS transitions in/out when the `[show]` attribute is toggled. Six direction variants control the animation style: `fade` (opacity), `left`/`right`/`up`/`down` (translate slides), and `iris` (circular clip-path reveal). The host sits `position: fixed; inset: 0`; `pointer-events` are blocked while `[show]` is set so the scene beneath is non-interactive during the transition. A `tc-complete` event fires after `duration` ms to signal that the scene can change. + +**Tag:** `tc-transition-wipe` + +--- + +#### Attributes + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `show` | boolean | absent | Triggers the wipe-in animation. Set to start; remove to wipe back out. | +| `direction` | `'fade' \| 'left' \| 'right' \| 'up' \| 'down' \| 'iris'` | `'fade'` | Direction variant. Invalid values fall back to `'fade'`. | +| `duration` | number (ms) | `400` | Wipe duration in milliseconds. Values ≤ 0 fall back to the default. Must match the CSS transition length so `tc-complete` fires at the right moment. | +| `wipe-color` | CSS color | `var(--tc-ink)` | Fill color for the wipe surface. Accepts any CSS color value or custom property. Written as an inline `--bs-transition-wipe-color` override. | + +--- + +#### JS Properties + +| Property | Type | Description | +|---|---|---| +| `show` | `boolean` | Reflects `[show]` (set/remove the boolean attribute) | +| `direction` | `TransitionWipeDirection` | Reflects `direction`; validated against the allowed list | +| `duration` | `number` | Reflects `duration` in ms (default `400`) | +| `wipeColor` | `string` | Reflects `wipe-color` (default `'var(--tc-ink)'`) | +| `onComplete` | `(() => void) \| null` | Optional callback fired alongside `tc-complete` | + +--- + +#### Events + +| Event | Detail | Description | +|---|---|---| +| `tc-complete` | `{}` | Fires after `duration` ms when `[show]` is set, signalling that the wipe has covered the screen and the scene can change | + +--- + +#### CSS custom properties + +| Property | Default | Description | +|---|---|---| +| `--bs-transition-wipe-color` | `var(--tc-ink)` | Wipe fill color (overridden inline by the `wipe-color` attribute) | +| `--bs-transition-wipe-duration` | `400ms` | Transition duration (overridden inline from the `duration` attribute) | +| `--bs-transition-wipe-z` | `var(--tc-z-tooltip, 1070)` | Stacking order — sits above all UI layers | + +--- + +#### Slots + +None. All content is generated by the component. + +--- + +#### Example + +```html + + + +``` diff --git a/examples/src/web-components/TransitionWipeDemo.tsx b/examples/src/web-components/TransitionWipeDemo.tsx new file mode 100644 index 00000000..f8d16783 --- /dev/null +++ b/examples/src/web-components/TransitionWipeDemo.tsx @@ -0,0 +1,175 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' +import type { TransitionWipeDirection } from '@toolcase/web-components' + +const directions: TransitionWipeDirection[] = ['fade', 'left', 'right', 'up', 'down', 'iris'] + +const TransitionWipeDemo: React.FC = () => { + // ── Primary interactive demo ────────────────────────────────────────────── + const wipeRef = useRef(null) + const [completeCount, setCompleteCount] = useState(0) + const [lastDirection, setLastDirection] = useState('fade') + const [running, setRunning] = useState(false) + + useEffect(() => { + const el = wipeRef.current + if (!el) return + const onComplete = () => { + setCompleteCount(c => c + 1) + setRunning(false) + // Remove [show] after a brief pause so the fill can animate back out. + setTimeout(() => el.removeAttribute('show'), 300) + } + el.addEventListener('tc-complete', onComplete) + return () => el.removeEventListener('tc-complete', onComplete) + }, []) + + const fire = (d: TransitionWipeDirection) => { + const el = wipeRef.current + if (!el || running) return + setLastDirection(d) + setRunning(true) + el.setAttribute('direction', d) + el.setAttribute('show', '') + } + + // ── Custom color demo ───────────────────────────────────────────────────── + const colorRef = useRef(null) + const [colorRunning, setColorRunning] = useState(false) + + useEffect(() => { + const el = colorRef.current + if (!el) return + const onComplete = () => { + setColorRunning(false) + setTimeout(() => el.removeAttribute('show'), 300) + } + el.addEventListener('tc-complete', onComplete) + return () => el.removeEventListener('tc-complete', onComplete) + }, []) + + // ── Slow long wipe ──────────────────────────────────────────────────────── + const slowRef = useRef(null) + const [slowRunning, setSlowRunning] = useState(false) + + useEffect(() => { + const el = slowRef.current + if (!el) return + const onComplete = () => { + setSlowRunning(false) + setTimeout(() => el.removeAttribute('show'), 400) + } + el.addEventListener('tc-complete', onComplete) + return () => el.removeEventListener('tc-complete', onComplete) + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="Transition Wipe" + description="Full-screen scene-wipe transition overlay. Six direction variants (fade, left, right, up, down, iris) are driven by the direction attribute; the [show] attribute triggers the wipe and a tc-complete event fires after duration ms. Ported from gc-transition-wipe and restyled to the toolcase design system — flat ink fill, no fantasy chrome, pointer-events blocked during the wipe." + /> + +
    + + +

    + Click a direction to trigger the wipe. The fill animates in, a{' '} + tc-complete event fires after duration{' '} + ms (default 400 ms), then [show] is removed and the + fill animates back out. +

    +
    + {directions.map(d => ( + + ))} +
    + {completeCount > 0 && ( +

    + tc-complete fired {completeCount}× (last: {lastDirection}) +

    + )} + {/* @ts-ignore */} + +
    + + +

    + wipe-color accepts any CSS color or custom property. + Here it is set to var(--tc-app-accent) (the design + system ink accent) for a branded wipe. +

    + + {/* @ts-ignore */} + +
    + + +

    + Longer duration values suit cinematic cut-scenes; + shorter values suit hit reactions. Here an iris wipe runs for + 800 ms with the default ink color. +

    + + {/* @ts-ignore */} + +
    + +
    +
    +
    +
    +
    + ) +} + +export default TransitionWipeDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index b9576a8a..c87b4013 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -340,6 +340,7 @@ import ScoreDisplayDemo from './ScoreDisplayDemo' import StatRowDemo from './StatRowDemo' import SpeedometerDemo from './SpeedometerDemo' import ScreenFlashDemo from './ScreenFlashDemo' +import TransitionWipeDemo from './TransitionWipeDemo' import ShakeContainerDemo from './ShakeContainerDemo' import ScrollTextDemo from './ScrollTextDemo' import ShopPanelDemo from './ShopPanelDemo' @@ -571,6 +572,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'confirm-dialog', category: 'Overlays & Feedback', element: }, { key: 'report-player-dialog', category: 'Overlays & Feedback', element: }, { key: 'screen-flash', category: 'Overlays & Feedback', element: }, + { key: 'transition-wipe', category: 'Overlays & Feedback', element: }, { key: 'shake-container', category: 'Components', element: }, { key: 'invite-toast', category: 'Overlays & Feedback', element: }, { key: 'lightbox', category: 'Overlays & Feedback', element: }, diff --git a/web-components/src/TransitionWipe.ts b/web-components/src/TransitionWipe.ts new file mode 100644 index 00000000..9209f6f1 --- /dev/null +++ b/web-components/src/TransitionWipe.ts @@ -0,0 +1,138 @@ +const TAG_NAME = 'tc-transition-wipe' + +export type TransitionWipeDirection = 'fade' | 'left' | 'right' | 'up' | 'down' | 'iris' + +const DIRECTIONS: TransitionWipeDirection[] = ['fade', 'left', 'right', 'up', 'down', 'iris'] + +/** + * tc-transition-wipe — full-screen scene-wipe transition overlay. Ported from + * `gc-transition-wipe` but voiced for the web-components design system: no + * shadow DOM, no fantasy chrome — a single flat-fill `
    ` that CSS transitions + * in/out via the `[show]` attribute. Directions: fade, left, right, up, down, iris. + * + * When `[show]` is set the fill animates into view; a `tc-complete` event fires + * after `duration` ms (matching the CSS transition length) to signal the host + * layer that the scene can change. Removing `[show]` animates the fill back + * out. pointer-events are blocked during the wipe so the scene beneath is + * non-interactive while the transition runs. + * + * No shadow root, no slots. All cosmetics flow through `--bs-transition-wipe-*` + * custom properties; `wipe-color` and `duration` write through to those props + * inline following the arbitrary-CSS-value pattern. + * + * Events: `tc-complete` (bubbles, composed) fires after `duration` ms whenever + * `[show]` is set. + */ +export class TransitionWipe extends HTMLElement { + + private _initialised = false + private _timer: ReturnType | null = null + + /** Optional callback fired alongside the `tc-complete` CustomEvent. */ + onComplete: (() => void) | null = null + + static get observedAttributes(): string[] { + return ['show', 'direction', 'duration', 'wipe-color'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.innerHTML = '
    ' + this._initialised = true + } + this._applyState() + if (this.show) this._scheduleComplete() + } + + disconnectedCallback(): void { + this._clearTimer() + } + + attributeChangedCallback(name: string, _old: string | null, next: string | null): void { + if (!this.isConnected || !this._initialised) return + if (name === 'show') { + if (next !== null) this._scheduleComplete() + else this._clearTimer() + } + this._applyState() + } + + get show(): boolean { + return this.hasAttribute('show') + } + set show(v: boolean) { + if (v) this.setAttribute('show', '') + else this.removeAttribute('show') + } + + get direction(): TransitionWipeDirection { + const raw = this.getAttribute('direction') as TransitionWipeDirection + return DIRECTIONS.includes(raw) ? raw : 'fade' + } + set direction(v: TransitionWipeDirection) { + this.setAttribute('direction', v) + } + + get duration(): number { + const raw = this.getAttribute('duration') + if (raw == null) return 400 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) || parsed <= 0 ? 400 : parsed + } + set duration(v: number) { + this.setAttribute('duration', String(v)) + } + + get wipeColor(): string { + return this.getAttribute('wipe-color') ?? 'var(--tc-ink)' + } + set wipeColor(v: string) { + this.setAttribute('wipe-color', v) + } + + private _clearTimer(): void { + if (this._timer != null) { + clearTimeout(this._timer) + this._timer = null + } + } + + private _scheduleComplete(): void { + this._clearTimer() + this._timer = setTimeout(() => { + this._timer = null + this._emitComplete() + }, this.duration) + } + + private _emitComplete(): void { + this.dispatchEvent(new CustomEvent('tc-complete', { + bubbles: true, + composed: true, + detail: {}, + })) + if (typeof this.onComplete === 'function') this.onComplete() + } + + private _applyState(): void { + const fill = this.querySelector('.tc-transition-wipe-fill') + if (!fill) return + + // data-direction drives the CSS selector for transition type. + fill.dataset.direction = this.direction + + // wipe-color and duration write through as inline CSS custom properties + // on the host; the SCSS partial reads them via var(--bs-transition-wipe-*). + const color = this.getAttribute('wipe-color') + if (color) this.style.setProperty('--bs-transition-wipe-color', color) + else this.style.removeProperty('--bs-transition-wipe-color') + + this.style.setProperty('--bs-transition-wipe-duration', `${this.duration}ms`) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: TransitionWipe + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 65c01f3e..be0fee7f 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -345,6 +345,7 @@ export * from './SafeArea' export * from './SaveSlotList' export * from './SettingsCategoryList' export * from './ScreenFlash' +export * from './TransitionWipe' export * from './ShakeContainer' export * from './ScoreDisplay' export * from './StatRow' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index d8524139..030d6fdd 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -301,6 +301,7 @@ import { KeyBinder } from './KeyBinder' import { LegalScreen } from './LegalScreen' import { LetterboxBars } from './LetterboxBars' import { ScreenFlash } from './ScreenFlash' +import { TransitionWipe } from './TransitionWipe' import { ShakeContainer } from './ShakeContainer' import { LevelHeader } from './LevelHeader' import { LevelSelect } from './LevelSelect' @@ -702,6 +703,7 @@ export function register(): void { customElements.define('tc-rune-corner', RuneCorner) customElements.define('tc-safe-area', SafeArea) customElements.define('tc-screen-flash', ScreenFlash) + customElements.define('tc-transition-wipe', TransitionWipe) customElements.define('tc-shake-container', ShakeContainer) customElements.define('tc-save-slot-list', SaveSlotList) customElements.define('tc-settings-category-list', SettingsCategoryList) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index a2b6f66e..b4627868 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -340,6 +340,7 @@ @forward 'settings-category-list'; @forward 'shake-container'; @forward 'screen-flash'; +@forward 'transition-wipe'; @forward 'score-display'; @forward 'stat-row'; @forward 'speedometer'; diff --git a/web-components/style/components/_transition-wipe.scss b/web-components/style/components/_transition-wipe.scss new file mode 100644 index 00000000..a68d1248 --- /dev/null +++ b/web-components/style/components/_transition-wipe.scss @@ -0,0 +1,142 @@ +// tc-transition-wipe — full-screen scene-wipe transition overlay. Ported from +// `gc-transition-wipe` but voiced for the web-components design system: no +// shadow DOM, no fantasy chrome, no gilded fills — a single flat-fill div that +// CSS transitions in/out when [show] is toggled on the host. Direction variants +// (fade/left/right/up/down/iris) are driven by data-direction on the fill. +// Every cosmetic value flows through --bs-transition-wipe-* custom properties +// (the public theming contract). + +@use '../foundation/tokens' as *; + +// ── Component defaults ────────────────────────────────────────────────────────── + +tc-transition-wipe { + // Wipe fill color. The design-system default is the ink dark surface so the + // wipe reads as a purposeful hard cut; consumers supply any CSS color via + // the `wipe-color` attribute which writes through as an inline var override. + --bs-transition-wipe-color: var(--tc-ink); + + // Transition duration driven by the `duration` attribute (ms). Written inline + // by JS so this default is the safe fallback when JS hasn't applied yet. + --bs-transition-wipe-duration: 400ms; + + // Sits above the modal tier so the wipe covers the entire viewport stack. + // The fixed --tc-z-* scale: tooltip 1070 > dropdown 1060 > modal 1055. + // Using tooltip tier so nothing can bleed through a running wipe. + --bs-transition-wipe-z: var(--tc-z-tooltip, 1070); +} + +// ── Host surface ──────────────────────────────────────────────────────────────── +// Fixed full-viewport cover; pointer-events disabled at rest so the wipe never +// blocks interaction when no transition is running. + +tc-transition-wipe { + position: fixed; + inset: 0; + pointer-events: none; + z-index: var(--bs-transition-wipe-z); + border-radius: 0; +} + +// Block pointer events while the wipe fill is visible so the scene behind is +// non-interactive during the transition. +tc-transition-wipe[show] { + pointer-events: auto; +} + +// ── Fill div ──────────────────────────────────────────────────────────────────── +// Absolute within the fixed host. The fill is the wipe-color surface; CSS +// transitions animate its opacity / transform / clip-path depending on the +// data-direction attribute set by JS. Sharp by mandate — no border-radius. + +.tc-transition-wipe-fill { + position: absolute; + inset: 0; + background: var(--bs-transition-wipe-color); + border-radius: 0; + transition: + opacity var(--bs-transition-wipe-duration) ease-in-out, + transform var(--bs-transition-wipe-duration) ease-in-out, + clip-path var(--bs-transition-wipe-duration) ease-in-out; +} + +// ── Direction: fade ───────────────────────────────────────────────────────────── +// Opacity cross-fade. Rest = transparent; [show] = fully opaque. + +.tc-transition-wipe-fill[data-direction='fade'] { + opacity: 0; +} + +tc-transition-wipe[show] .tc-transition-wipe-fill[data-direction='fade'] { + opacity: 1; +} + +// ── Direction: left ───────────────────────────────────────────────────────────── +// Slide in from the left. Rest = off-screen left; [show] = covers viewport. + +.tc-transition-wipe-fill[data-direction='left'] { + transform: translateX(-100%); +} + +tc-transition-wipe[show] .tc-transition-wipe-fill[data-direction='left'] { + transform: translateX(0); +} + +// ── Direction: right ──────────────────────────────────────────────────────────── +// Slide in from the right. Rest = off-screen right; [show] = covers viewport. + +.tc-transition-wipe-fill[data-direction='right'] { + transform: translateX(100%); +} + +tc-transition-wipe[show] .tc-transition-wipe-fill[data-direction='right'] { + transform: translateX(0); +} + +// ── Direction: up ─────────────────────────────────────────────────────────────── +// Slide in from the top. Rest = off-screen top; [show] = covers viewport. + +.tc-transition-wipe-fill[data-direction='up'] { + transform: translateY(-100%); +} + +tc-transition-wipe[show] .tc-transition-wipe-fill[data-direction='up'] { + transform: translateY(0); +} + +// ── Direction: down ───────────────────────────────────────────────────────────── +// Slide in from the bottom. Rest = off-screen bottom; [show] = covers viewport. + +.tc-transition-wipe-fill[data-direction='down'] { + transform: translateY(100%); +} + +tc-transition-wipe[show] .tc-transition-wipe-fill[data-direction='down'] { + transform: translateY(0); +} + +// ── Direction: iris ───────────────────────────────────────────────────────────── +// Iris / circular reveal. Rest = fill covers viewport (large circle); [show] = +// iris closes (circle shrinks to 0) revealing content beneath. This direction +// is intentionally inverted vs. the slide/fade variants: the fill starts +// visible and the [show] state hides it — useful for "iris open" reveals where +// content is uncovered as the wipe runs. + +.tc-transition-wipe-fill[data-direction='iris'] { + clip-path: circle(150% at 50% 50%); +} + +tc-transition-wipe[show] .tc-transition-wipe-fill[data-direction='iris'] { + clip-path: circle(0% at 50% 50%); +} + +// ── Reduced motion ────────────────────────────────────────────────────────────── +// Disable all transitions; direction-state changes still apply instantly so +// the wipe functions without animation. The tc-complete event continues to fire +// after `duration` ms — the consumer's timing contract is unchanged. + +@media (prefers-reduced-motion: reduce) { + .tc-transition-wipe-fill { + transition: none !important; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index ec781364..1542cf4a 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -504,6 +504,13 @@ tc-screen-flash { display: block; } +// Fixed full-viewport scene-wipe transition; the SCSS partial positions it +// fixed and drives direction-based transitions, but the host must not default +// to inline. +tc-transition-wipe { + display: block; +} + // Camera-shake wrapper; the SCSS partial owns the inner transform animation, // but the host must not default to inline so it can act as a block container. tc-shake-container { From a30cda9e9f1752792382a17ee6928be5de64a70d Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 10:26:52 +0000 Subject: [PATCH 392/632] 387-version-label: Build the tc-version-label web component (VersionLabel game-components port) --- examples/public/web-components/SKILL.md | 68 +++++++++++++++- .../src/web-components/VersionLabelDemo.tsx | 51 ++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/VersionLabel.ts | 80 +++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_version-label.scss | 71 ++++++++++++++++ web-components/style/foundation/_reset.scss | 3 +- 9 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 examples/src/web-components/VersionLabelDemo.tsx create mode 100644 web-components/src/VersionLabel.ts create mode 100644 web-components/style/components/_version-label.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 3243d0df..60ac1148 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -1,6 +1,6 @@ --- name: web-components -description: Use when building UI with @toolcase/web-components — framework-free HTML5 Web Components (`tc-*` custom elements) with from-scratch toolcase styling and a Bootstrap-compatible class API. Covers layout (BasicLayout, DashboardLayout, DashboardContent, DashboardSidebar, Login, Container, Row, Col, Spacer, Stack), content (ActionHeader, ActionItems, ActionRowList, Alert, AnnouncementBar, ApiReferenceTable, AssetRow, AssetRowList, Avatar, Badge, BadgeRow, Banner, Brand, Build, BriefCard, BundleBar, CdnMap, CalloutQuote, Changelog, ChartContainer, Sparkline, TrendIndicator, Leaderboard, LeaderboardTrend, CodeLabelCell, CodeSnippet, CodeWithOutput, CommunityLinks, ConfigPreview, ContributorWall, CookbookGrid, CoolButton, ActivityCard, BasicCard, Button, ButtonGroup, Card, Carousel, CloseButton, Collapse, Divider, Dropdown, DownloadStats, EcosystemMap, EmptyState, GameShowcaseCard, GithubStarsCard, GoodFirstIssues, Group, Hero, HeroStatsBar, Heading, Image, InfiniteScroll, Kbd, ListCard, ListGroup, LogoCloud, MaintainerCard, Marquee, MetricTile, MetricGrid, MigrationGuide, PageFooter, Panel, PhaseGrid, Pipeline, PinnedFeatureShowcase, PluginGrid, PricingCard, File, UserPanel, QueuedFile, Placeholder, Progress, PulseIndicator, ScoringRules, ScoreDisplay, Speedometer, SectionCard, SectionFlag, Skeleton, Spinner, SprintChain, Stamp, MetricCard, StatCard, StateMachine, StatusCard, StatusDot, Stepper, Tag, TeamList, TierLadder, Timeline, UsageSummaryPanel, WelcomeGuide, CommandReference, Comparator, CompatibilityMatrix, CountdownTimer, FAQList, FeatureMatrix, Text, VisuallyHidden), navigation (Breadcrumb, CoolNav, Nav, Navbar, Pagination, Scrollspy, SocialLinks, Stepper), overlays & feedback (ContextMenu, DebugOverlay, Modal, Offcanvas, Popover, Toast, Tooltip), and forms (CardOptions, Check, CheckboxGroup, Chip, ChipGroup, ColorPicker, IconPicker, DatePicker, EarlySignupForm, EditableText, FloatingLabel, Form, HelperText, Input, InputGroup, InputGroupText, Label, MultiCardSelect, NewsletterSignup, Option, Radio, RadioGroup, Range, RangeSlider, DeadzoneSlider, Select, Switch, Textarea). Consumable from any stack — React, Vue, Svelte, or plain HTML. +description: Use when building UI with @toolcase/web-components — framework-free HTML5 Web Components (`tc-*` custom elements) with from-scratch toolcase styling and a Bootstrap-compatible class API. Covers layout (BasicLayout, DashboardLayout, DashboardContent, DashboardSidebar, Login, Container, Row, Col, Spacer, Stack), content (ActionHeader, ActionItems, ActionRowList, Alert, AnnouncementBar, ApiReferenceTable, AssetRow, AssetRowList, Avatar, Badge, BadgeRow, Banner, Brand, Build, BriefCard, BundleBar, CdnMap, CalloutQuote, Changelog, ChartContainer, Sparkline, TrendIndicator, Leaderboard, LeaderboardTrend, CodeLabelCell, CodeSnippet, CodeWithOutput, CommunityLinks, ConfigPreview, ContributorWall, CookbookGrid, CoolButton, ActivityCard, BasicCard, Button, ButtonGroup, Card, Carousel, CloseButton, Collapse, Divider, Dropdown, DownloadStats, EcosystemMap, EmptyState, GameShowcaseCard, GithubStarsCard, GoodFirstIssues, Group, Hero, HeroStatsBar, Heading, Image, InfiniteScroll, Kbd, ListCard, ListGroup, LogoCloud, MaintainerCard, Marquee, MetricTile, MetricGrid, MigrationGuide, PageFooter, Panel, PhaseGrid, Pipeline, PinnedFeatureShowcase, PluginGrid, PricingCard, File, UserPanel, QueuedFile, Placeholder, Progress, PulseIndicator, ScoringRules, ScoreDisplay, Speedometer, SectionCard, SectionFlag, Skeleton, Spinner, SprintChain, Stamp, MetricCard, StatCard, StateMachine, StatusCard, StatusDot, Stepper, Tag, TeamList, TierLadder, Timeline, UsageSummaryPanel, WelcomeGuide, CommandReference, Comparator, CompatibilityMatrix, CountdownTimer, FAQList, FeatureMatrix, Text, VersionLabel, VisuallyHidden), navigation (Breadcrumb, CoolNav, Nav, Navbar, Pagination, Scrollspy, SocialLinks, Stepper), overlays & feedback (ContextMenu, DebugOverlay, Modal, Offcanvas, Popover, Toast, Tooltip), and forms (CardOptions, Check, CheckboxGroup, Chip, ChipGroup, ColorPicker, IconPicker, DatePicker, EarlySignupForm, EditableText, FloatingLabel, Form, HelperText, Input, InputGroup, InputGroupText, Label, MultiCardSelect, NewsletterSignup, Option, Radio, RadioGroup, Range, RangeSlider, DeadzoneSlider, Select, Switch, Textarea). Consumable from any stack — React, Vue, Svelte, or plain HTML. --- # web-components — API Reference @@ -282,6 +282,7 @@ After `register()` you can author markup directly: - [tc-text](#tc-text) - [tc-video-embed](#tc-video-embed) - [tc-visually-hidden](#tc-visually-hidden) + - [tc-version-label](#tc-version-label) - [Navigation](#navigation) - [tc-breadcrumb](#tc-breadcrumb) - [tc-cool-nav](#tc-cool-nav) @@ -5377,6 +5378,71 @@ document.querySelector('[animate-typing]').lines = [ --- +### tc-version-label + +Corner build / version stamp — a compact JetBrains Mono inline label that shows version, build hash, and branch name separated by `·` dots. + +**Tag:** `tc-version-label` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `version` | string | — | Semver or release tag. Rendered as `v{version}`. | +| `build` | string | — | Build hash or commit identifier. | +| `branch` | string | — | Git branch name. Rendered with the ink accent (`--bs-version-label-branch-color`). | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `version` | `string` | Reflects the `version` attribute. | +| `build` | `string` | Reflects the `build` attribute. | +| `branch` | `string` | Reflects the `branch` attribute. | + +**Events:** none — `tc-version-label` is purely presentational. + +**Slots:** none — all content is driven by attributes. + +**Custom properties** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-version-label-color` | `var(--tc-text-muted)` | Default text color. | +| `--bs-version-label-font-size` | `0.6875rem` | Label font size. | +| `--bs-version-label-font-weight` | `500` | Label font weight. | +| `--bs-version-label-letter-spacing` | `0.04em` | Label letter spacing. | +| `--bs-version-label-bg` | `transparent` | Background color. | +| `--bs-version-label-border-color` | `var(--tc-border)` | 1px hairline border color. | +| `--bs-version-label-padding-y` | `0.1875rem` | Vertical padding. | +| `--bs-version-label-padding-x` | `0.5rem` | Horizontal padding. | +| `--bs-version-label-sep-color` | `var(--tc-border-strong)` | Separator `·` dot color. | +| `--bs-version-label-version-color` | `var(--tc-text)` | Version segment text color. | +| `--bs-version-label-build-color` | `var(--tc-text-muted)` | Build segment text color. | +| `--bs-version-label-branch-color` | `var(--tc-app-accent)` | Branch segment text color (ink accent). | +| `--bs-version-label-gap` | `0.25rem` | Gap between segments. | + +```html + + + + + + + + + + + +``` + +--- + ## Navigation ### tc-breadcrumb diff --git a/examples/src/web-components/VersionLabelDemo.tsx b/examples/src/web-components/VersionLabelDemo.tsx new file mode 100644 index 00000000..2d37ec08 --- /dev/null +++ b/examples/src/web-components/VersionLabelDemo.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const VersionLabelDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="VersionLabel" + description="Corner build / version stamp — a compact JetBrains Mono inline label showing version, build, and branch info separated by · dots." + /> + +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + +
    + {/* @ts-ignore */} + +
    +
    + +
    +
    +
    +
    +
    +) + +export default VersionLabelDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index c87b4013..256b35b4 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -345,6 +345,7 @@ import ShakeContainerDemo from './ShakeContainerDemo' import ScrollTextDemo from './ScrollTextDemo' import ShopPanelDemo from './ShopPanelDemo' import StatsScreenDemo from './StatsScreenDemo' +import VersionLabelDemo from './VersionLabelDemo' export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' @@ -710,4 +711,5 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'scroll-text', category: 'Content', element: }, { key: 'shop-panel', category: 'Components', element: }, { key: 'stats-screen', category: 'Components', element: }, + { key: 'version-label', category: 'Content', element: }, ] diff --git a/web-components/src/VersionLabel.ts b/web-components/src/VersionLabel.ts new file mode 100644 index 00000000..81416d2c --- /dev/null +++ b/web-components/src/VersionLabel.ts @@ -0,0 +1,80 @@ +const TAG_NAME = 'tc-version-label' + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +export class VersionLabel extends HTMLElement { + private _initialised = false + + static get observedAttributes(): string[] { + return ['version', 'build', 'branch'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get version(): string { + return this.getAttribute('version') ?? '' + } + set version(value: string) { + if (value) this.setAttribute('version', value) + else this.removeAttribute('version') + } + + get build(): string { + return this.getAttribute('build') ?? '' + } + set build(value: string) { + if (value) this.setAttribute('build', value) + else this.removeAttribute('build') + } + + get branch(): string { + return this.getAttribute('branch') ?? '' + } + set branch(value: string) { + if (value) this.setAttribute('branch', value) + else this.removeAttribute('branch') + } + + private render(): void { + this.classList.add('tc-version-label') + + const parts: string[] = [] + const version = this.version + const build = this.build + const branch = this.branch + + if (version) { + parts.push(`v${esc(version)}`) + } + if (build) { + parts.push(`${esc(build)}`) + } + if (branch) { + parts.push(`${esc(branch)}`) + } + + this.innerHTML = parts.join('') + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: VersionLabel + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index be0fee7f..fb0cb55f 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -269,6 +269,7 @@ export * from './Toggle' export * from './ToggleCard' export * from './TreeView' export * from './UserPanel' +export * from './VersionLabel' export * from './VersionPicker' export * from './VerticalItemList' export * from './VideoEmbed' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 030d6fdd..408626b1 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -266,6 +266,7 @@ import { Toggle } from './Toggle' import { ToggleCard } from './ToggleCard' import { TreeView } from './TreeView' import { UserPanel } from './UserPanel' +import { VersionLabel } from './VersionLabel' import { VersionPicker } from './VersionPicker' import { VerticalItemList } from './VerticalItemList' import { VideoEmbed } from './VideoEmbed' @@ -716,4 +717,5 @@ export function register(): void { customElements.define('tc-skill-tree', SkillTree) customElements.define('tc-stack', Stack) customElements.define('tc-stats-screen', StatsScreen) + customElements.define('tc-version-label', VersionLabel) } diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index b4627868..d01764ce 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -349,3 +349,4 @@ @forward 'skill-bar'; @forward 'skill-tree'; @forward 'stats-screen'; +@forward 'version-label'; diff --git a/web-components/style/components/_version-label.scss b/web-components/style/components/_version-label.scss new file mode 100644 index 00000000..2c3cdc40 --- /dev/null +++ b/web-components/style/components/_version-label.scss @@ -0,0 +1,71 @@ +// tc-version-label — corner build / version stamp. +// All cosmetics flow through --bs-version-label-* custom properties backed by --tc-* tokens. +// Sharp corners; slate neutrals; JetBrains Mono machine-facing text. +// Game-component fantasy chrome (gilded frames, glows) is dropped. + +@use '../foundation/tokens' as *; + +tc-version-label { + --bs-version-label-color: var(--tc-text-muted); + --bs-version-label-font-size: 0.6875rem; + --bs-version-label-font-weight: 500; + --bs-version-label-letter-spacing: 0.04em; + --bs-version-label-bg: transparent; + --bs-version-label-border-color: var(--tc-border); + --bs-version-label-padding-y: 0.1875rem; + --bs-version-label-padding-x: 0.5rem; + --bs-version-label-sep-color: var(--tc-border-strong); + --bs-version-label-version-color: var(--tc-text); + --bs-version-label-build-color: var(--tc-text-muted); + --bs-version-label-branch-color: var(--tc-app-accent); + --bs-version-label-gap: 0.25rem; +} + +.tc-version-label { + display: inline-flex; + align-items: center; + gap: var(--bs-version-label-gap); + padding: var(--bs-version-label-padding-y) var(--bs-version-label-padding-x); + background: var(--bs-version-label-bg); + border: 1px solid var(--bs-version-label-border-color); + border-radius: 0; + font-family: 'JetBrains Mono', var(--bs-font-monospace, monospace); + font-size: var(--bs-version-label-font-size); + font-weight: var(--bs-version-label-font-weight); + letter-spacing: var(--bs-version-label-letter-spacing); + line-height: 1.4; + color: var(--bs-version-label-color); + white-space: nowrap; + box-sizing: border-box; + + &:empty { + display: none; + } +} + +// Version segment — primary value, slightly brighter than the baseline. +.tc-version-label-version { + color: var(--bs-version-label-version-color); +} + +// Build segment — hash / commit id in muted slate. +.tc-version-label-build { + color: var(--bs-version-label-build-color); +} + +// Branch segment — ink accent so it reads as a live qualifier. +.tc-version-label-branch { + color: var(--bs-version-label-branch-color); +} + +// Separator dot — purely decorative, suppressed from accessibility tree. +.tc-version-label-sep { + color: var(--bs-version-label-sep-color); + user-select: none; +} + +@media (prefers-reduced-motion: reduce) { + .tc-version-label { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 1542cf4a..9ad6cfaa 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -558,7 +558,8 @@ tc-debug-overlay, tc-hotbar, tc-inventory-grid, tc-item-slot, -tc-icon-picker { +tc-icon-picker, +tc-version-label { display: inline-block; } From 98f4e75869a72f808e8eefbb0a381bdcc625d092 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 10:33:27 +0000 Subject: [PATCH 393/632] 388-victory-screen: Build the tc-victory-screen web component (VictoryScreen game-components port) --- examples/public/web-components/SKILL.md | 85 +++++++ .../src/web-components/VictoryScreenDemo.tsx | 92 +++++++ examples/src/web-components/index.tsx | 2 + web-components/src/VictoryScreen.ts | 224 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_victory-screen.scss | 203 ++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 611 insertions(+) create mode 100644 examples/src/web-components/VictoryScreenDemo.tsx create mode 100644 web-components/src/VictoryScreen.ts create mode 100644 web-components/style/components/_victory-screen.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 60ac1148..4e314d67 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -196,6 +196,7 @@ After `register()` you can author markup directly: - [tc-achievement-list](#tc-achievement-list) - [tc-result-screen](#tc-result-screen) - [tc-game-over-screen](#tc-game-over-screen) + - [tc-victory-screen](#tc-victory-screen) - [tc-legal-screen](#tc-legal-screen) - [tc-gamepad-button-prompt](#tc-gamepad-button-prompt) - [tc-battle-pass](#tc-battle-pass) @@ -16471,6 +16472,90 @@ el.addEventListener('tc-action', e => console.log('action', e.detail.id)) --- +### tc-victory-screen + +Victory end screen: a centred region with a mono uppercase eyebrow, a gold-toned title, a short hairline divider, an optional subtitle, a column of hairline-separated stat rows, a soft reward strip, and a wrapped row of action buttons. Stats, rewards, and actions are supplied via JS properties; the title text/colour, subtitle, and eyebrow are attributes. Clicking an action fires `tc-action` with that action's `id`. Ported from the game-components `gc-victory-screen` (a `gc-result-screen` defaulting to "Victory!" / "Triumph" in a gold tone), restyled to the toolcase design system — flat slate surface, hairline borders, sharp corners, mono machine-facing text, `.btn` action primitives, and no gilded frame / diamond divider / metal buttons. + +**Tag:** `tc-victory-screen` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `title-text` | string | `Victory!` | The large title heading | +| `subtitle` | string | — | Optional supporting line under the divider | +| `title-color` | `gold\|danger\|parch` | `gold` | Status tone of the title (`gold` → warning ramp, `danger` → danger ramp, `parch` → neutral slate) | +| `eyebrow` | string | `Triumph` | The mono uppercase micro-label above the title | + +The host element automatically gains `role="region"` (unless one is already set). + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `stats` | `VictoryStat[]` | Stat rows (set via JS, not attribute); setting re-renders | +| `rewards` | `VictoryReward[]` | Reward chips shown in the reward strip | +| `actions` | `VictoryAction[]` | Action buttons rendered at the bottom | +| `titleText` | string | Mirror of the `title-text` attribute | +| `subtitle` | string | Mirror of the `subtitle` attribute | +| `titleColor` | `gold\|danger\|parch` | Mirror of the `title-color` attribute | +| `eyebrow` | string | Mirror of the `eyebrow` attribute | +| `onAction` | `((id: string) => void) \| null` | Optional callback fired alongside `tc-action` | + +Each `VictoryStat`: + +| Field | Type | Description | +|-------|------|-------------| +| `label` | string | Stat name | +| `value` | string \| number | Stat value (numbers are locale-formatted) | + +Each `VictoryReward`: + +| Field | Type | Description | +|-------|------|-------------| +| `label` | string | Reward name | +| `glyph` | string? | Short symbol/initials shown before the label (rendered as text, `aria-hidden`) | +| `amount` | number \| string? | Optional amount shown after the label (numbers are locale-formatted) | +| `color` | string? | Optional CSS colour applied to the glyph | + +Each `VictoryAction`: + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Unique key emitted in the `tc-action` `detail` | +| `label` | string | Button text | +| `variant` | `default\|primary\|danger\|ghost`? | Button style (`default` → secondary, `primary` → ink, `danger` → danger, `ghost` → outline) | + +**Events** + +| Event | `detail` | Fired when | +|-------|----------|------------| +| `tc-action` | `{ id: string }` | An action button is clicked | + +**Slots:** none — the eyebrow, title, subtitle, stats, rewards, and actions are all generated from attributes / JS properties. + +```html + + +``` + +--- + ### tc-legal-screen Multi-section legal / EULA screen with a sidebar nav, scrollable body panel, an optional accept footer, and a close button. Ported from the game-components `gc-legal-screen`, restyled to the toolcase design system — flat `--tc-surface` card, 1px hairline borders, sharp corners (`border-radius: 0`), mono eyebrow labels, and an ink-gradient accept button. The game chrome (gilded frames, glows, fantasy fills) is dropped. Sections are supplied via the `sections` JS property; the active section is tracked internally and changes when a sidebar nav item is clicked. Fires `tc-close` when the close button is clicked and `tc-accept` when the accept button is clicked. The host carries `role="region"` unless one is already set. diff --git a/examples/src/web-components/VictoryScreenDemo.tsx b/examples/src/web-components/VictoryScreenDemo.tsx new file mode 100644 index 00000000..f63075b6 --- /dev/null +++ b/examples/src/web-components/VictoryScreenDemo.tsx @@ -0,0 +1,92 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const STATS = [ + { label: 'Score', value: 24800 }, + { label: 'Waves cleared', value: 20 }, + { label: 'Time survived', value: '12:33' }, + { label: 'Accuracy', value: '87%' }, +] + +const REWARDS = [ + { glyph: '◈', label: 'Gold', amount: 640, color: 'var(--tc-warning)' }, + { glyph: '★', label: 'XP', amount: 3000 }, + { glyph: '◆', label: 'Gems', amount: 5, color: 'var(--tc-info)' }, +] + +const ACTIONS = [ + { id: 'continue', label: 'Continue', variant: 'primary' as const }, + { id: 'retry', label: 'Play Again', variant: 'default' as const }, + { id: 'menu', label: 'Main Menu', variant: 'ghost' as const }, +] + +const VictoryScreenDemo: React.FC = () => { + const fullRef = useRef(null) + const minimalRef = useRef(null) + + const [lastAction, setLastAction] = useState('(none yet — click an action)') + + useEffect(() => { + const el = fullRef.current + if (!el) return + el.stats = STATS + el.rewards = REWARDS + el.actions = ACTIONS + + const onAction = (e: Event) => setLastAction((e as CustomEvent<{ id: string }>).detail.id) + el.addEventListener('tc-action', onAction) + return () => el.removeEventListener('tc-action', onAction) + }, []) + + useEffect(() => { + const el = minimalRef.current + if (!el) return + el.actions = [{ id: 'continue', label: 'Continue', variant: 'primary' }] + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="VictoryScreen" + description="Victory end screen: a centred region with a mono eyebrow, a gold-toned title, a hairline divider, an optional subtitle, hairline-separated stat rows, a soft reward strip, and a row of action buttons. Stats, rewards, and actions are supplied via JS properties; title text/colour, subtitle, and eyebrow are attributes. Emits tc-action with the clicked action's id." + /> + +
    + + {/* @ts-ignore */} + +
    + Last action: {lastAction} +
    +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    + ) +} + +export default VictoryScreenDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 256b35b4..fae18a1e 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -55,6 +55,7 @@ import CharacterCreateDemo from './CharacterCreateDemo' import CharacterSelectDemo from './CharacterSelectDemo' import GameOverScreenDemo from './GameOverScreenDemo' import ResultScreenDemo from './ResultScreenDemo' +import VictoryScreenDemo from './VictoryScreenDemo' import GamepadButtonPromptDemo from './GamepadButtonPromptDemo' import GraphicsPresetPickerDemo from './GraphicsPresetPickerDemo' import RadioDemo from './RadioDemo' @@ -538,6 +539,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'achievement-list', category: 'Content', element: }, { key: 'game-over-screen', category: 'Content', element: }, { key: 'result-screen', category: 'Content', element: }, + { key: 'victory-screen', category: 'Content', element: }, { key: 'gamepad-button-prompt', category: 'Content', element: }, { key: 'battle-pass', category: 'Components', element: }, { key: 'section-card', category: 'Components', element: }, diff --git a/web-components/src/VictoryScreen.ts b/web-components/src/VictoryScreen.ts new file mode 100644 index 00000000..a2b351dd --- /dev/null +++ b/web-components/src/VictoryScreen.ts @@ -0,0 +1,224 @@ +const TAG_NAME = 'tc-victory-screen' + +// Port of game-components `gc-victory-screen` (a `gc-result-screen` whose +// defaults read "Victory!" / "Triumph" in a gold tone). The fantasy chrome +// (gilded frame, diamond divider, metal buttons, parchment fills) is dropped; +// this renders to the web-components design system — a flat slate region with a +// mono eyebrow, a status-toned title, a hairline divider, hairline-separated +// stat rows, a soft reward strip, and a row of `.btn` actions. All cosmetics +// flow through `--bs-victory-screen-*` custom properties. + +export type VictoryScreenTitleColor = 'gold' | 'danger' | 'parch' +const TITLE_COLORS: VictoryScreenTitleColor[] = ['gold', 'danger', 'parch'] + +export type VictoryScreenActionVariant = 'default' | 'primary' | 'danger' | 'ghost' +const ACTION_VARIANTS: VictoryScreenActionVariant[] = ['default', 'primary', 'danger', 'ghost'] + +export interface VictoryStat { + label: string + value: string | number +} + +export interface VictoryReward { + label: string + glyph?: string + amount?: number | string + color?: string +} + +export interface VictoryAction { + id: string + label: string + variant?: VictoryScreenActionVariant +} + +export interface VictoryScreenEventMap { + 'tc-action': CustomEvent<{ id: string }> +} + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +const ACTION_CLASS: Record = { + default: 'btn btn-secondary', + primary: 'btn btn-primary', + danger: 'btn btn-danger', + ghost: 'btn btn-outline-secondary', +} + +export class VictoryScreen extends HTMLElement { + + private _initialised = false + private _stats: VictoryStat[] = [] + private _rewards: VictoryReward[] = [] + private _actions: VictoryAction[] = [] + + /** Optional callback fired alongside the `tc-action` CustomEvent. */ + onAction: ((id: string) => void) | null = null + + static get observedAttributes(): string[] { + return ['title-text', 'subtitle', 'title-color', 'eyebrow'] + } + + connectedCallback(): void { + if (!this._initialised) { + if (!this.hasAttribute('role')) this.setAttribute('role', 'region') + this.render() + this.addEventListener('click', this._onClick) + this._initialised = true + } + } + + disconnectedCallback(): void { + this.removeEventListener('click', this._onClick) + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get titleText(): string { + return this.getAttribute('title-text') ?? 'Victory!' + } + set titleText(v: string) { + if (v) this.setAttribute('title-text', v) + else this.removeAttribute('title-text') + } + + get subtitle(): string { + return this.getAttribute('subtitle') ?? '' + } + set subtitle(v: string) { + if (v) this.setAttribute('subtitle', v) + else this.removeAttribute('subtitle') + } + + get titleColor(): VictoryScreenTitleColor { + const raw = this.getAttribute('title-color') as VictoryScreenTitleColor + return TITLE_COLORS.includes(raw) ? raw : 'gold' + } + set titleColor(v: VictoryScreenTitleColor) { + if (v) this.setAttribute('title-color', v) + else this.removeAttribute('title-color') + } + + get eyebrow(): string { + return this.getAttribute('eyebrow') ?? 'Triumph' + } + set eyebrow(v: string) { + if (v) this.setAttribute('eyebrow', v) + else this.removeAttribute('eyebrow') + } + + get stats(): VictoryStat[] { + return this._stats.slice() + } + set stats(v: VictoryStat[]) { + this._stats = Array.isArray(v) ? v.slice() : [] + if (this._initialised) this.render() + } + + get rewards(): VictoryReward[] { + return this._rewards.slice() + } + set rewards(v: VictoryReward[]) { + this._rewards = Array.isArray(v) ? v.slice() : [] + if (this._initialised) this.render() + } + + get actions(): VictoryAction[] { + return this._actions.slice() + } + set actions(v: VictoryAction[]) { + this._actions = Array.isArray(v) ? v.slice() : [] + if (this._initialised) this.render() + } + + private _onClick = (e: MouseEvent): void => { + if (!(e.target instanceof HTMLElement)) return + const btn = e.target.closest('.tc-victory-screen-action') + if (!btn || !this.contains(btn)) return + const id = btn.dataset.id + if (!id) return + this.dispatchEvent(new CustomEvent('tc-action', { detail: { id }, bubbles: true, composed: true })) + if (typeof this.onAction === 'function') this.onAction(id) + } + + private formatValue(v: string | number): string { + return typeof v === 'number' ? v.toLocaleString() : v + } + + private render(): void { + this.classList.add('tc-victory-screen') + + const statMarkup = this._stats.map(stat => + `
    ` + + `${esc(stat.label)}` + + `${esc(this.formatValue(stat.value))}` + + `
    ` + ).join('') + + const rewardMarkup = this._rewards.map(reward => { + const colorStyle = reward.color ? ` style="color: ${esc(reward.color)}"` : '' + const glyphMarkup = reward.glyph + ? `` + : '' + const amountMarkup = reward.amount != null + ? `${esc(this.formatValue(reward.amount))}` + : '' + return `
    ` + + glyphMarkup + + `${esc(reward.label)}` + + amountMarkup + + `
    ` + }).join('') + + const actionMarkup = this._actions.map(action => { + const variant = ACTION_VARIANTS.includes(action.variant as VictoryScreenActionVariant) + ? (action.variant as VictoryScreenActionVariant) + : 'default' + return `` + }).join('') + + const subtitleMarkup = this.subtitle + ? `

    ${esc(this.subtitle)}

    ` + : '' + const statsBlock = statMarkup + ? `
    ${statMarkup}
    ` + : '' + const rewardsBlock = rewardMarkup + ? `
    ` + + `Rewards` + + `
    ${rewardMarkup}
    ` + + `
    ` + : '' + const actionsBlock = actionMarkup + ? `
    ${actionMarkup}
    ` + : '' + + this.innerHTML = ` +
    + ${esc(this.eyebrow)} +

    ${esc(this.titleText)}

    + + ${subtitleMarkup} + ${statsBlock} + ${rewardsBlock} + ${actionsBlock} +
    + ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: VictoryScreen + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index fb0cb55f..6c431604 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -295,6 +295,7 @@ export * from './FullscreenToggle' export * from './ToggleRow' export * from './GameOverScreen' export * from './ResultScreen' +export * from './VictoryScreen' export * from './GamepadButtonPrompt' export * from './GildedFrame' export * from './GraphicsPresetPicker' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 408626b1..c8a7d0f7 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -292,6 +292,7 @@ import { FullscreenToggle } from './FullscreenToggle' import { ToggleRow } from './ToggleRow' import { GameOverScreen } from './GameOverScreen' import { ResultScreen } from './ResultScreen' +import { VictoryScreen } from './VictoryScreen' import { GamepadButtonPrompt } from './GamepadButtonPrompt' import { GildedFrame } from './GildedFrame' import { GraphicsPresetPicker } from './GraphicsPresetPicker' @@ -651,6 +652,7 @@ export function register(): void { customElements.define('tc-toggle-row', ToggleRow) customElements.define('tc-game-over-screen', GameOverScreen) customElements.define('tc-result-screen', ResultScreen) + customElements.define('tc-victory-screen', VictoryScreen) customElements.define('tc-gamepad-button-prompt', GamepadButtonPrompt) customElements.define('tc-gilded-frame', GildedFrame) customElements.define('tc-graphics-preset-picker', GraphicsPresetPicker) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index d01764ce..9502d635 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -299,6 +299,7 @@ @forward 'dialogue-box'; @forward 'game-over-screen'; @forward 'result-screen'; +@forward 'victory-screen'; @forward 'gamepad-button-prompt'; @forward 'gilded-frame'; @forward 'grid'; diff --git a/web-components/style/components/_victory-screen.scss b/web-components/style/components/_victory-screen.scss new file mode 100644 index 00000000..56b93f58 --- /dev/null +++ b/web-components/style/components/_victory-screen.scss @@ -0,0 +1,203 @@ +// tc-victory-screen — victory end screen. Port of game-components +// `gc-victory-screen` (a `gc-result-screen` defaulting to "Victory!" / +// "Triumph" in a gold tone). Fantasy chrome dropped; flat slate surface with +// hairline borders, sharp corners, mono machine-facing text, and `.btn` action +// primitives. Every cosmetic value flows through `--bs-victory-screen-*`. + +tc-victory-screen { + --bs-victory-screen-max-width: 32rem; + --bs-victory-screen-padding: 2rem; + --bs-victory-screen-gap: 1rem; + --bs-victory-screen-bg: var(--tc-surface); + --bs-victory-screen-border-color: var(--tc-border); + --bs-victory-screen-shadow: var(--tc-shadow-sm); + + --bs-victory-screen-eyebrow-color: var(--tc-text-muted); + --bs-victory-screen-eyebrow-size: 0.6875rem; + --bs-victory-screen-eyebrow-spacing: 0.12em; + + --bs-victory-screen-title-size: 2rem; + --bs-victory-screen-title-weight: 600; + // Resolved per data-title-color below; gold is the VictoryScreen default. + --bs-victory-screen-title-color: var(--tc-warning); + + --bs-victory-screen-divider-color: var(--tc-border); + --bs-victory-screen-divider-width: 2.5rem; + + --bs-victory-screen-subtitle-color: var(--tc-text-muted); + --bs-victory-screen-subtitle-size: 0.9375rem; + + --bs-victory-screen-stat-border-color: var(--tc-border); + --bs-victory-screen-stat-label-color: var(--tc-text-muted); + --bs-victory-screen-stat-value-color: var(--tc-text); + + --bs-victory-screen-rewards-bg: var(--tc-surface-muted); + --bs-victory-screen-rewards-border-color: var(--tc-border); + --bs-victory-screen-reward-label-color: var(--tc-text); + --bs-victory-screen-reward-amount-color: var(--tc-text-muted); + --bs-victory-screen-reward-glyph-color: var(--tc-text); +} + +.tc-victory-screen-root { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--bs-victory-screen-gap); + max-width: var(--bs-victory-screen-max-width); + margin-inline: auto; + padding: var(--bs-victory-screen-padding); + text-align: center; + background: var(--bs-victory-screen-bg); + border: 1px solid var(--bs-victory-screen-border-color); + border-radius: 0; + box-shadow: var(--bs-victory-screen-shadow); + + // Status tone for the title is selected by data-title-color. + // gold maps onto the warning ramp (victory default); danger keeps the + // danger ramp; parch is the neutral (slate) reading. + &[data-title-color='gold'] { + --bs-victory-screen-title-color: var(--tc-warning); + } + &[data-title-color='danger'] { + --bs-victory-screen-title-color: var(--tc-danger); + } + &[data-title-color='parch'] { + --bs-victory-screen-title-color: var(--tc-text); + } +} + +// Mono uppercase micro-label above the title. +.tc-victory-screen-eyebrow { + font-family: var(--tc-font-mono); + font-size: var(--bs-victory-screen-eyebrow-size); + font-weight: 600; + text-transform: uppercase; + letter-spacing: var(--bs-victory-screen-eyebrow-spacing); + color: var(--bs-victory-screen-eyebrow-color); +} + +.tc-victory-screen-title { + margin: 0; + font-size: var(--bs-victory-screen-title-size); + font-weight: var(--bs-victory-screen-title-weight); + line-height: 1.1; + color: var(--bs-victory-screen-title-color); +} + +// Short centred hairline rule in place of the gc diamond divider. +.tc-victory-screen-divider { + display: block; + width: var(--bs-victory-screen-divider-width); + height: 0; + border-top: 1px solid var(--bs-victory-screen-divider-color); +} + +.tc-victory-screen-subtitle { + margin: 0; + max-width: 40ch; + font-size: var(--bs-victory-screen-subtitle-size); + line-height: 1.5; + color: var(--bs-victory-screen-subtitle-color); +} + +// Stats: full-width hairline-separated label/value rows. +.tc-victory-screen-stats { + display: flex; + flex-direction: column; + width: 100%; + border-top: 1px solid var(--bs-victory-screen-stat-border-color); +} + +.tc-victory-screen-stat { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--bs-victory-screen-stat-border-color); +} + +.tc-victory-screen-stat-label { + font-size: 0.8125rem; + color: var(--bs-victory-screen-stat-label-color); +} + +.tc-victory-screen-stat-value { + font-family: var(--tc-font-mono); + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; + font-weight: 600; + color: var(--bs-victory-screen-stat-value-color); +} + +// Rewards: a soft slate strip with a mono eyebrow and a wrapped chip row. +.tc-victory-screen-rewards { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.75rem; + background: var(--bs-victory-screen-rewards-bg); + border: 1px solid var(--bs-victory-screen-rewards-border-color); + border-radius: 0; +} + +.tc-victory-screen-rewards-eyebrow { + font-family: var(--tc-font-mono); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--bs-victory-screen-eyebrow-color); +} + +.tc-victory-screen-rewards-list { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.75rem; +} + +.tc-victory-screen-reward { + display: inline-flex; + align-items: center; + gap: 0.375rem; +} + +.tc-victory-screen-reward-glyph { + font-family: var(--tc-font-mono); + font-weight: 600; + color: var(--bs-victory-screen-reward-glyph-color); +} + +.tc-victory-screen-reward-label { + font-size: 0.8125rem; + color: var(--bs-victory-screen-reward-label-color); +} + +.tc-victory-screen-reward-amount { + font-family: var(--tc-font-mono); + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; + font-weight: 600; + color: var(--bs-victory-screen-reward-amount-color); +} + +// Actions: a wrapped row of buttons. Reuses the `.btn` primitive painted by +// the shared stylesheet; only layout lives here. +.tc-victory-screen-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.tc-victory-screen-action { + @media (pointer: coarse) { + min-height: 44px; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 9ad6cfaa..aff7c604 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -187,6 +187,7 @@ tc-character-create, tc-character-select, tc-game-over-screen, tc-result-screen, +tc-victory-screen, tc-chart-container, tc-code-with-output, tc-community-links, From 5d2918469a1db98829347d5ec1b9989b5d745842 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 10:41:07 +0000 Subject: [PATCH 394/632] 389-vignette-overlay: Build the tc-vignette-overlay web component (VignetteOverlay game-components port) --- examples/public/web-components/SKILL.md | 84 ++++++++++ .../web-components/VignetteOverlayDemo.tsx | 152 ++++++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/VignetteOverlay.ts | 80 +++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_vignette-overlay.scss | 65 ++++++++ web-components/style/foundation/_reset.scss | 6 + 9 files changed, 393 insertions(+) create mode 100644 examples/src/web-components/VignetteOverlayDemo.tsx create mode 100644 web-components/src/VignetteOverlay.ts create mode 100644 web-components/style/components/_vignette-overlay.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 4e314d67..0c46a6f1 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -321,6 +321,7 @@ After `register()` you can author markup directly: - [tc-toast](#tc-toast) - [tc-tooltip](#tc-tooltip) - [tc-transition-wipe](#tc-transition-wipe) + - [tc-vignette-overlay](#tc-vignette-overlay) - [Forms](#forms) - [tc-card-options](#tc-card-options) - [tc-character-create](#tc-character-create) @@ -26105,3 +26106,86 @@ None. All content is generated by the component. }) ``` + +--- + +### tc-vignette-overlay + +Edge vignette overlay for damage feedback, cinematic framing, or low-health UI. Ported from `gc-vignette-overlay` (game-components) and restyled to the web-components design system — no Shadow DOM, no game chrome; a block wrapper whose `::after` pseudo-element paints a radial-gradient overlay on top of slotted content. The center is transparent and the edges fade to `vignette-color`; `intensity` (0–1) controls the pseudo-element's opacity. `pointer-events: none` on the overlay keeps content fully interactive. All cosmetics flow through `--bs-vignette-overlay-*` custom properties. + +**Tag:** `tc-vignette-overlay` + +--- + +#### Attributes + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `intensity` | number (0–1) | `0.6` | Opacity of the vignette gradient. `0` = invisible, `1` = fully opaque. Clamped to [0, 1]; non-finite values fall back to `0.6`. Written as an inline `--bs-vignette-overlay-intensity` override. | +| `vignette-color` | CSS color | `#000000` | Edge color of the radial gradient (any CSS color value, e.g. `var(--tc-danger)`, `rgba(220,38,38,1)`). Written as an inline `--bs-vignette-overlay-color` override. | + +--- + +#### JS Properties + +| Property | Type | Description | +|---|---|---| +| `intensity` | `number` | Reflects `intensity` (default `0.6`, clamped 0–1) | +| `vignetteColor` | `string` | Reflects `vignette-color` (default `#000000`) | + +--- + +#### Events + +None. The component is purely presentational. + +--- + +#### CSS custom properties + +| Property | Default | Description | +|---|---|---| +| `--bs-vignette-overlay-intensity` | `0.6` | Opacity of the vignette `::after` layer (overridden inline by the `intensity` attribute) | +| `--bs-vignette-overlay-color` | `#000000` | Edge color of the radial gradient (overridden inline by the `vignette-color` attribute) | + +--- + +#### Slots + +| Slot | Description | +|---|---| +| *(default)* | Content to display inside the vignette frame. The vignette `::after` is painted on top with `pointer-events: none` so the content remains fully interactive. | + +--- + +#### Example + +```html + + + + + + + + + + +``` diff --git a/examples/src/web-components/VignetteOverlayDemo.tsx b/examples/src/web-components/VignetteOverlayDemo.tsx new file mode 100644 index 00000000..b1f4dfb1 --- /dev/null +++ b/examples/src/web-components/VignetteOverlayDemo.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const stageStyle: React.CSSProperties = { + width: '100%', + height: 240, + background: 'linear-gradient(135deg, var(--tc-surface-muted) 0%, var(--tc-surface) 100%)', + border: '1px solid var(--tc-border)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', +} + +const sceneLabel: React.CSSProperties = { + fontFamily: 'var(--tc-font-mono, "JetBrains Mono", monospace)', + fontSize: '11px', + textTransform: 'uppercase', + letterSpacing: '0.1em', + color: 'var(--tc-text-faint)', + userSelect: 'none', +} + +const VignetteOverlayDemo: React.FC = () => { + const [intensity, setIntensity] = useState(0.6) + const [active, setActive] = useState(true) + + const customRef = useRef(null) + + useEffect(() => { + const el = customRef.current + if (!el) return + el.intensity = intensity + }, [intensity]) + + return ( +
    +
    +
    +
    + Web Components} + title="Vignette Overlay" + description="Edge vignette overlay for damage feedback and cinematic framing. A radial-gradient ::after pseudo-element is painted on top of slotted content with pointer-events disabled. Intensity (0–1) and vignette-color (any CSS color) are reflected attributes that write through as CSS custom property overrides. Ported from gc-vignette-overlay and re-voiced for the design system." + /> + +
    + + +

    + The default vignette uses a black radial gradient at 60% opacity. + Content inside the element is the default slot. +

    +
    + {/* @ts-ignore */} + + Slotted content + {/* @ts-ignore */} + +
    +
    + + +

    + Toggle the vignette between visible (0.65) and hidden + (0) by setting the intensity attribute. +

    +
    + {/* @ts-ignore */} + + Slotted content + {/* @ts-ignore */} + +
    +
    + +
    +
    + + +

    + Pass any CSS color to vignette-color to change + the edge tone — useful for low-health or status feedback. +

    +
    + {/* @ts-ignore */} + + Slotted content + {/* @ts-ignore */} + +
    +
    + + +

    + Drive intensity via the JS property for animation + or reactive updates. Uses a ref to set the property + directly on the element. +

    +
    + {/* @ts-ignore */} + + Slotted content + {/* @ts-ignore */} + +
    +
    + setIntensity(parseFloat(e.target.value))} + style={{ flex: 1 }} + /> + + {intensity.toFixed(2)} + +
    +
    + +
    +
    +
    +
    +
    + ) +} + +export default VignetteOverlayDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index fae18a1e..d7def8f9 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -343,6 +343,7 @@ import SpeedometerDemo from './SpeedometerDemo' import ScreenFlashDemo from './ScreenFlashDemo' import TransitionWipeDemo from './TransitionWipeDemo' import ShakeContainerDemo from './ShakeContainerDemo' +import VignetteOverlayDemo from './VignetteOverlayDemo' import ScrollTextDemo from './ScrollTextDemo' import ShopPanelDemo from './ShopPanelDemo' import StatsScreenDemo from './StatsScreenDemo' @@ -576,6 +577,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'report-player-dialog', category: 'Overlays & Feedback', element: }, { key: 'screen-flash', category: 'Overlays & Feedback', element: }, { key: 'transition-wipe', category: 'Overlays & Feedback', element: }, + { key: 'vignette-overlay', category: 'Overlays & Feedback', element: }, { key: 'shake-container', category: 'Components', element: }, { key: 'invite-toast', category: 'Overlays & Feedback', element: }, { key: 'lightbox', category: 'Overlays & Feedback', element: }, diff --git a/web-components/src/VignetteOverlay.ts b/web-components/src/VignetteOverlay.ts new file mode 100644 index 00000000..384366ad --- /dev/null +++ b/web-components/src/VignetteOverlay.ts @@ -0,0 +1,80 @@ +const TAG_NAME = 'tc-vignette-overlay' + +/** + * tc-vignette-overlay — edge vignette overlay for low-health / damage feedback. + * Ported from `gc-vignette-overlay` but voiced for the web-components design + * system: no shadow DOM, no game chrome — a `::after` radial-gradient pseudo- + * element painted on top of slotted content with `pointer-events: none`. + * + * All cosmetics flow through `--bs-vignette-overlay-*` custom properties. + * `intensity` (0–1) and `vignette-color` (any CSS color) are reflected as + * inline style overrides so authors can drive them from JS or animation. + * + * Slot: default — the content to vignette. + */ +export class VignetteOverlay extends HTMLElement { + + private _initialised = false + + static get observedAttributes(): string[] { + return ['intensity', 'vignette-color'] + } + + connectedCallback(): void { + if (!this._initialised) { + const slotContent = Array.from(this.childNodes) + this.render() + const inner = this.querySelector('.tc-vignette-overlay-content') + if (inner) slotContent.forEach(n => inner.appendChild(n)) + this._initialised = true + } + this._applyTokens() + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this._applyTokens() + } + + get intensity(): number { + const v = parseFloat(this.getAttribute('intensity') ?? '0.6') + return isFinite(v) ? Math.max(0, Math.min(1, v)) : 0.6 + } + set intensity(v: number) { + this.setAttribute('intensity', String(v)) + } + + get vignetteColor(): string { + return this.getAttribute('vignette-color') ?? '#000000' + } + set vignetteColor(v: string) { + if (v) this.setAttribute('vignette-color', v) + else this.removeAttribute('vignette-color') + } + + private _applyTokens(): void { + const intensity = this.getAttribute('intensity') + if (intensity !== null) { + this.style.setProperty('--bs-vignette-overlay-intensity', String(this.intensity)) + } else { + this.style.removeProperty('--bs-vignette-overlay-intensity') + } + + const color = this.getAttribute('vignette-color') + if (color) { + this.style.setProperty('--bs-vignette-overlay-color', color) + } else { + this.style.removeProperty('--bs-vignette-overlay-color') + } + } + + private render(): void { + this.innerHTML = '
    ' + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: VignetteOverlay + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 6c431604..6f6a4dc2 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -349,6 +349,7 @@ export * from './SettingsCategoryList' export * from './ScreenFlash' export * from './TransitionWipe' export * from './ShakeContainer' +export * from './VignetteOverlay' export * from './ScoreDisplay' export * from './StatRow' export * from './Speedometer' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index c8a7d0f7..cf42d784 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -305,6 +305,7 @@ import { LetterboxBars } from './LetterboxBars' import { ScreenFlash } from './ScreenFlash' import { TransitionWipe } from './TransitionWipe' import { ShakeContainer } from './ShakeContainer' +import { VignetteOverlay } from './VignetteOverlay' import { LevelHeader } from './LevelHeader' import { LevelSelect } from './LevelSelect' import { LoadingOverlay } from './LoadingOverlay' @@ -708,6 +709,7 @@ export function register(): void { customElements.define('tc-screen-flash', ScreenFlash) customElements.define('tc-transition-wipe', TransitionWipe) customElements.define('tc-shake-container', ShakeContainer) + customElements.define('tc-vignette-overlay', VignetteOverlay) customElements.define('tc-save-slot-list', SaveSlotList) customElements.define('tc-settings-category-list', SettingsCategoryList) customElements.define('tc-score-display', ScoreDisplay) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 9502d635..c3d70ba0 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -342,6 +342,7 @@ @forward 'shake-container'; @forward 'screen-flash'; @forward 'transition-wipe'; +@forward 'vignette-overlay'; @forward 'score-display'; @forward 'stat-row'; @forward 'speedometer'; diff --git a/web-components/style/components/_vignette-overlay.scss b/web-components/style/components/_vignette-overlay.scss new file mode 100644 index 00000000..f51ecc91 --- /dev/null +++ b/web-components/style/components/_vignette-overlay.scss @@ -0,0 +1,65 @@ +// tc-vignette-overlay — edge vignette overlay for damage / low-health feedback. +// Ported from `gc-vignette-overlay` but voiced for the web-components design +// system: no game chrome, no gilded fills — a radial-gradient ::after pseudo- +// element on top of slotted content. Every cosmetic value flows through +// --bs-vignette-overlay-* so themes re-skin via vars alone. + +@use '../foundation/tokens' as *; + +// ── Component defaults ────────────────────────────────────────────────────────── + +tc-vignette-overlay { + // Opacity of the vignette gradient layer; overridden inline by the + // `intensity` attribute (clamped to [0, 1] by the element class). + --bs-vignette-overlay-intensity: 0.6; + + // Color at the vignette edges; overridden inline by the `vignette-color` + // attribute. Black is the canonical low-health / cinematic choice. + --bs-vignette-overlay-color: #000000; +} + +// ── Host surface ──────────────────────────────────────────────────────────────── +// The host wraps slotted content and establishes a positioned context so the +// ::after pseudo-element can be absolutely positioned inside it. + +tc-vignette-overlay { + position: relative; + display: block; + border-radius: 0; +} + +// ── Vignette gradient layer ───────────────────────────────────────────────────── +// ::after paints on top of the slot content in DOM order (no z-index needed — +// positioned non-stacked elements are above normal-flow blocks). pointer-events +// is disabled so the overlay never blocks interaction. +// +// The gradient transitions from fully transparent at the centre to +// --bs-vignette-overlay-color at the edges; `opacity` drives the strength so +// any color (including RGBA values) works transparently. + +tc-vignette-overlay::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient( + ellipse at center, + transparent 30%, + var(--bs-vignette-overlay-color) 100% + ); + opacity: var(--bs-vignette-overlay-intensity); + border-radius: 0; +} + +// ── Content wrapper ───────────────────────────────────────────────────────────── +// Holds the slotted children. No visual chrome — purely structural. + +.tc-vignette-overlay-content { + position: relative; + width: 100%; + height: 100%; +} + +// ── Reduced motion ────────────────────────────────────────────────────────────── +// The vignette is purely static (no animation). No overrides needed, but +// preserve the state-conveying opacity in case a caller animates it via JS. diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index aff7c604..aa081844 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -518,6 +518,12 @@ tc-shake-container { display: block; } +// Edge vignette overlay wrapper; the SCSS partial positions the ::after +// gradient on top of slotted content, but the host must not default to inline. +tc-vignette-overlay { + display: block; +} + tc-editable-text, tc-chip, tc-rarity-chip, From 0b75cbd7c36008bdfa9b576d1e78c6c82f36052b Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 10:49:27 +0000 Subject: [PATCH 395/632] 390-volume-slider: Build the tc-volume-slider web component (VolumeSlider game-components port) --- examples/public/web-components/SKILL.md | 50 ++++ .../src/web-components/VolumeSliderDemo.tsx | 104 ++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/VolumeSlider.ts | 150 ++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_volume-slider.scss | 225 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 536 insertions(+) create mode 100644 examples/src/web-components/VolumeSliderDemo.tsx create mode 100644 web-components/src/VolumeSlider.ts create mode 100644 web-components/style/components/_volume-slider.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 0c46a6f1..a2fce1db 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -352,6 +352,7 @@ After `register()` you can author markup directly: - [tc-deadzone-slider](#tc-deadzone-slider) - [tc-fov-slider](#tc-fov-slider) - [tc-mouse-sensitivity](#tc-mouse-sensitivity) + - [tc-volume-slider](#tc-volume-slider) - [tc-reset-to-defaults](#tc-reset-to-defaults) - [tc-fps-cap-select](#tc-fps-cap-select) - [tc-select-row](#tc-select-row) @@ -8771,6 +8772,55 @@ A mouse-sensitivity setting row: a label/description text block paired with one --- +### tc-volume-slider + +A volume setting row: a mute toggle button, a 0–100% range slider, and a mono percentage readout. Built on the shared `tc-setting-row` scaffold. Port of game-components `gc-volume-slider` with the fantasy chrome dropped for the toolcase slate/ink look. + +**Tag:** `tc-volume-slider` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `row-label` | string | `Volume` | Row label (set automatically when absent) | +| `description` | string | — | Optional secondary line beneath the label | +| `value` | number | `0.8` | Current volume, clamped to `0`–`1` (`0.01` step) | +| `muted` | boolean | `false` | When present, the range input is disabled and the mute button shows the muted icon | +| `disabled` | boolean | `false` | Disables both the mute button and the range input | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `value` | `number` | Get or set the volume (`0`–`1`). Getter clamps and defaults to `0.8`. Setting patches the input + readout in place — no full re-render. | +| `muted` | `boolean` | Get/set the `muted` attribute. Patched in place (swaps icon, toggles input disabled) — no full re-render. | +| `disabled` | `boolean` | Get/set the `disabled` attribute. | +| `rowLabel` | `string` | Get/set the `row-label` attribute. | +| `description` | `string` | Get/set the `description` attribute. | +| `onChange` | `((value: number) => void) \| null` | Optional callback fired on every slider change. Mirrors the `tc-change` event. | +| `onToggleMute` | `(() => void) \| null` | Optional callback fired when the mute button is clicked. Mirrors the `tc-toggle-mute` event. | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-change` | `{ value: number }` | Fired on every range-input change (the `0`–`1` volume). | +| `tc-toggle-mute` | `{}` | Fired when the mute button is clicked. The consumer is responsible for toggling the `muted` attribute. | + +**No slots.** + +```html + + +``` + +--- + ### tc-reset-to-defaults A two-step reset action row: a label/description text block paired with a Reset button that enters a confirmation state (Confirm reset + Cancel). Confirming fires `tc-reset` and returns to idle; cancelling discards without firing. Built on the shared `tc-setting-row` scaffold. Port of game-components `gc-reset-to-defaults` with the fantasy chrome dropped for the toolcase slate/ink look. diff --git a/examples/src/web-components/VolumeSliderDemo.tsx b/examples/src/web-components/VolumeSliderDemo.tsx new file mode 100644 index 00000000..a432f19d --- /dev/null +++ b/examples/src/web-components/VolumeSliderDemo.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +function useVolumeValue(initial: number): [number, boolean, React.RefObject] { + const [value, setValue] = useState(initial) + const [muted, setMuted] = useState(false) + const ref = useRef(null) + + useEffect(() => { + const el = ref.current + if (!el) return + + const onchange = (e: Event) => { + const detail = (e as CustomEvent<{ value: number }>).detail + if (detail) setValue(detail.value) + } + const ontoggle = () => { + setMuted(m => { + const next = !m + // Reflect the toggled state back into the element. + if (el) el.muted = next + return next + }) + } + + el.addEventListener('tc-change', onchange) + el.addEventListener('tc-toggle-mute', ontoggle) + return () => { + el.removeEventListener('tc-change', onchange) + el.removeEventListener('tc-toggle-mute', ontoggle) + } + }, []) + + return [value, muted, ref] +} + +const VolumeSliderDemo: React.FC = () => { + const [v1, muted1, ref1] = useVolumeValue(0.8) + + return ( +
    +
    +
    +
    + Web Components} + title="Volume Slider" + description="A volume setting row: a mute toggle button, a 0–100% range slider, and a mono percentage readout. Built on the shared tc-setting-row scaffold. Port of game-components gc-volume-slider with the fantasy chrome dropped for the toolcase slate/ink look." + /> + +
    + +
    + {/* @ts-ignore */} + +
    +
    + Current: {Math.round(v1 * 100)}% {muted1 ? '(muted)' : ''} +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default VolumeSliderDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index d7def8f9..82b523ee 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -271,6 +271,7 @@ import RangeSliderDemo from './RangeSliderDemo' import DeadzoneSliderDemo from './DeadzoneSliderDemo' import FOVSliderDemo from './FOVSliderDemo' import MouseSensitivityDemo from './MouseSensitivityDemo' +import VolumeSliderDemo from './VolumeSliderDemo' import ResetToDefaultsDemo from './ResetToDefaultsDemo' import FPSCapSelectDemo from './FPSCapSelectDemo' import SelectRowDemo from './SelectRowDemo' @@ -640,6 +641,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'deadzone-slider', category: 'Forms', element: }, { key: 'fov-slider', category: 'Forms', element: }, { key: 'mouse-sensitivity', category: 'Forms', element: }, + { key: 'volume-slider', category: 'Forms', element: }, { key: 'reset-to-defaults', category: 'Forms', element: }, { key: 'fps-cap-select', category: 'Forms', element: }, { key: 'select-row', category: 'Forms', element: }, diff --git a/web-components/src/VolumeSlider.ts b/web-components/src/VolumeSlider.ts new file mode 100644 index 00000000..ed200b8e --- /dev/null +++ b/web-components/src/VolumeSlider.ts @@ -0,0 +1,150 @@ +import { SettingRowBase } from './SettingRowBase' +import { icon } from './icons' +import { Volume2, VolumeX } from 'lucide-static' + +const TAG_NAME = 'tc-volume-slider' + +// Pre-computed at module load — always rendered so no conditional resolve needed. +const volumeOnIcon = icon(Volume2) +const volumeOffIcon = icon(VolumeX) + +// tc-volume-slider — a volume range-slider setting row. A mute toggle button, +// a native range input (0–1, 1% steps), and a mono percentage readout, built +// on the shared setting-row scaffold. Port of game-components `gc-volume-slider` +// with the fantasy chrome dropped for the toolcase slate/ink look. + +export class VolumeSlider extends SettingRowBase { + + // Optional callback mirrors of tc-change / tc-toggle-mute events. + onChange: ((value: number) => void) | null = null + onToggleMute: (() => void) | null = null + + static get observedAttributes(): string[] { + return [...SettingRowBase.observedAttributes, 'value', 'muted', 'disabled'] + } + + connectedCallback(): void { + if (!this.hasAttribute('row-label')) this.setAttribute('row-label', 'Volume') + super.connectedCallback() + } + + attributeChangedCallback(name: string, old: string | null, next: string | null): void { + if (!this.isConnected || !this._initialised) return + + // Patch value in place — avoids destroying the active input element + // (and any in-progress drag) on every `input` event. + if (name === 'value') { + const input = this.querySelector('.tc-volume-slider__input') + const display = this.querySelector('.tc-volume-slider__value') + const v = this.value + if (input && input.value !== String(v)) input.value = String(v) + if (display) display.textContent = `${Math.round(v * 100)}%` + return + } + + // Patch muted in place — swap icon and toggle the input's disabled state + // without destroying the whole control region. + if (name === 'muted') { + const btn = this.querySelector('.tc-volume-slider__mute-btn') + const input = this.querySelector('.tc-volume-slider__input') + const muted = this.muted + if (btn) { + btn.innerHTML = muted ? volumeOffIcon : volumeOnIcon + btn.setAttribute('aria-pressed', String(muted)) + } + if (input) { + if (muted || this.disabled) input.setAttribute('disabled', '') + else input.removeAttribute('disabled') + } + return + } + + // `disabled` is structural (affects button + input) — fall through to + // the base full re-render. + super.attributeChangedCallback(name, old, next) + } + + get value(): number { + const raw = this.getAttribute('value') + if (raw == null) return 0.8 + const parsed = parseFloat(raw) + return Number.isNaN(parsed) ? 0.8 : Math.max(0, Math.min(1, parsed)) + } + set value(v: number) { + this.setAttribute('value', String(v)) + } + + get muted(): boolean { + return this.hasAttribute('muted') + } + set muted(v: boolean) { + if (v) this.setAttribute('muted', '') + else this.removeAttribute('muted') + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + protected renderControl(): string { + const value = this.value + const muted = this.muted + const disabled = this.disabled + const inputDisabledAttr = muted || disabled ? ' disabled' : '' + const btnDisabledAttr = disabled ? ' disabled' : '' + const muteIconHtml = muted ? volumeOffIcon : volumeOnIcon + return ` +
    + + + ${Math.round(value * 100)}% +
    + ` + } + + protected bindControl(): void { + const input = this.querySelector('.tc-volume-slider__input') + const display = this.querySelector('.tc-volume-slider__value') + const muteBtn = this.querySelector('.tc-volume-slider__mute-btn') + + if (input) { + input.addEventListener('input', () => { + const v = parseFloat(input.value) + if (display) display.textContent = `${Math.round(v * 100)}%` + this.setAttribute('value', String(v)) + this.emit('tc-change', { value: v }) + if (typeof this.onChange === 'function') this.onChange(v) + }) + } + + if (muteBtn) { + muteBtn.addEventListener('click', () => { + this.emit('tc-toggle-mute', {}) + if (typeof this.onToggleMute === 'function') this.onToggleMute() + }) + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: VolumeSlider + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 6f6a4dc2..8003786e 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -289,6 +289,7 @@ export * from './DebugOverlay' export * from './DialogueBox' export * from './FOVSlider' export * from './MouseSensitivity' +export * from './VolumeSlider' export * from './FPSCapSelect' export * from './SelectRow' export * from './FullscreenToggle' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index cf42d784..a16d2221 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -286,6 +286,7 @@ import { DebugOverlay } from './DebugOverlay' import { DialogueBox } from './DialogueBox' import { FOVSlider } from './FOVSlider' import { MouseSensitivity } from './MouseSensitivity' +import { VolumeSlider } from './VolumeSlider' import { FPSCapSelect } from './FPSCapSelect' import { SelectRow } from './SelectRow' import { FullscreenToggle } from './FullscreenToggle' @@ -647,6 +648,7 @@ export function register(): void { customElements.define('tc-dialogue-box', DialogueBox) customElements.define('tc-fov-slider', FOVSlider) customElements.define('tc-mouse-sensitivity', MouseSensitivity) + customElements.define('tc-volume-slider', VolumeSlider) customElements.define('tc-fps-cap-select', FPSCapSelect) customElements.define('tc-select-row', SelectRow) customElements.define('tc-fullscreen-toggle', FullscreenToggle) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index c3d70ba0..a222d54c 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -60,6 +60,7 @@ @forward 'deadzone-slider'; @forward 'fov-slider'; @forward 'mouse-sensitivity'; +@forward 'volume-slider'; @forward 'fps-cap-select'; @forward 'select-row'; @forward 'fullscreen-toggle'; diff --git a/web-components/style/components/_volume-slider.scss b/web-components/style/components/_volume-slider.scss new file mode 100644 index 00000000..4627c291 --- /dev/null +++ b/web-components/style/components/_volume-slider.scss @@ -0,0 +1,225 @@ +// tc-volume-slider — volume range-slider setting row on the shared setting-row +// scaffold (`_setting-row.scss`). The control is a mute icon-button, a native +// range input (0–1, 1% steps), and a mono percentage readout. All cosmetics +// flow through `--bs-volume-slider-*`; the fantasy chrome of the gc-* original +// is dropped (no gilded frames, glows, or gradient fills). + +tc-volume-slider { + --bs-volume-slider-track-width: 160px; + --bs-volume-slider-track-height: 6px; + --bs-volume-slider-track-color: var(--tc-border); + --bs-volume-slider-fill-color: var(--tc-app-accent); + --bs-volume-slider-thumb-size: 16px; + --bs-volume-slider-thumb-bg: var(--tc-surface); + --bs-volume-slider-thumb-border: var(--tc-app-accent); + --bs-volume-slider-thumb-active-bg: var(--tc-surface-muted); + --bs-volume-slider-thumb-shadow: var(--tc-shadow-sm); + --bs-volume-slider-focus-ring: 0 0 0 0.2rem rgba(30, 41, 59, 0.12); + --bs-volume-slider-disabled-color: var(--tc-text-faint); + --bs-volume-slider-value-color: var(--tc-text-muted); + --bs-volume-slider-value-size: 0.6875rem; + --bs-volume-slider-value-min-width: 3rem; + --bs-volume-slider-gap: 0.625rem; + --bs-volume-slider-mute-size: 28px; + --bs-volume-slider-mute-icon-size: 14px; + --bs-volume-slider-mute-border-color: var(--tc-border); + --bs-volume-slider-mute-color: var(--tc-text-muted); + --bs-volume-slider-mute-bg: transparent; + --bs-volume-slider-mute-active-bg: var(--tc-app-accent); + --bs-volume-slider-mute-active-color: var(--tc-surface); + --bs-volume-slider-mute-active-border: var(--tc-app-accent); + --bs-volume-slider-mute-hover-bg: var(--tc-surface-muted); + --bs-volume-slider-mute-focus-ring: 0 0 0 0.2rem rgba(30, 41, 59, 0.12); +} + +.tc-volume-slider__control { + display: flex; + align-items: center; + gap: var(--bs-volume-slider-gap); +} + +// Mute toggle — an icon-only button, 1px hairline border, sharp corners (the +// sanctioned circle shape is the range thumb only), ink active state. +.tc-volume-slider__mute-btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: var(--bs-volume-slider-mute-size); + height: var(--bs-volume-slider-mute-size); + padding: 0; + background-color: var(--bs-volume-slider-mute-bg); + border: 1px solid var(--bs-volume-slider-mute-border-color); + border-radius: 0; + color: var(--bs-volume-slider-mute-color); + cursor: pointer; + transition: background-color var(--tc-transition-fast), color var(--tc-transition-fast), border-color var(--tc-transition-fast); + + // Coarse pointers get a 44px touch target without enlarging the visual button. + @media (pointer: coarse) { + min-width: 44px; + min-height: 44px; + } + + svg { + width: var(--bs-volume-slider-mute-icon-size); + height: var(--bs-volume-slider-mute-icon-size); + stroke-width: 2; + } + + &:hover:not([disabled]) { + background-color: var(--bs-volume-slider-mute-hover-bg); + } + + &:focus { + outline: 0; + } + + &:focus-visible { + box-shadow: var(--bs-volume-slider-mute-focus-ring); + } + + // Pressed / muted state — ink fill signals the active state. + &[aria-pressed="true"] { + background-color: var(--bs-volume-slider-mute-active-bg); + border-color: var(--bs-volume-slider-mute-active-border); + color: var(--bs-volume-slider-mute-active-color); + } + + &[disabled] { + pointer-events: none; + opacity: 0.45; + } +} + +.tc-volume-slider__input { + width: var(--bs-volume-slider-track-width); + height: 1.25rem; + padding: 0; + appearance: none; + -webkit-appearance: none; + background-color: transparent; + cursor: pointer; + + // Coarse pointers get a 44px hit area without changing the visual track. + @media (pointer: coarse) { + min-height: 44px; + } + + &:focus { + outline: 0; + } + + &:focus-visible { + &::-webkit-slider-thumb { + box-shadow: var(--bs-volume-slider-focus-ring); + } + + &::-moz-range-thumb { + box-shadow: var(--bs-volume-slider-focus-ring); + } + } + + &::-webkit-slider-runnable-track { + width: 100%; + height: var(--bs-volume-slider-track-height); + cursor: pointer; + background-color: var(--bs-volume-slider-track-color); + border: 0; + border-radius: 0; + } + + &::-webkit-slider-thumb { + // Centre the thumb on the track regardless of the two sizes. + margin-top: calc( + (var(--bs-volume-slider-track-height) - var(--bs-volume-slider-thumb-size)) / 2 + ); + width: var(--bs-volume-slider-thumb-size); + height: var(--bs-volume-slider-thumb-size); + appearance: none; + -webkit-appearance: none; + cursor: pointer; + background-color: var(--bs-volume-slider-thumb-bg); + border: 1.5px solid var(--bs-volume-slider-thumb-border); + border-radius: 50%; + box-shadow: var(--bs-volume-slider-thumb-shadow); + transition: background-color var(--tc-transition-fast); + + &:active { + background-color: var(--bs-volume-slider-thumb-active-bg); + } + } + + &::-moz-range-track { + width: 100%; + height: var(--bs-volume-slider-track-height); + cursor: pointer; + background-color: var(--bs-volume-slider-track-color); + border: 0; + border-radius: 0; + } + + // Filled portion of the track (ink up to the thumb) — Firefox only. + &::-moz-range-progress { + height: var(--bs-volume-slider-track-height); + background-color: var(--bs-volume-slider-fill-color); + } + + &::-moz-range-thumb { + width: var(--bs-volume-slider-thumb-size); + height: var(--bs-volume-slider-thumb-size); + cursor: pointer; + background-color: var(--bs-volume-slider-thumb-bg); + border: 1.5px solid var(--bs-volume-slider-thumb-border); + border-radius: 50%; + box-shadow: var(--bs-volume-slider-thumb-shadow); + transition: background-color var(--tc-transition-fast); + + &:active { + background-color: var(--bs-volume-slider-thumb-active-bg); + } + } + + &:disabled { + pointer-events: none; + + &::-webkit-slider-thumb { + background-color: var(--bs-volume-slider-disabled-color); + border-color: var(--bs-volume-slider-disabled-color); + } + + &::-moz-range-thumb { + background-color: var(--bs-volume-slider-disabled-color); + border-color: var(--bs-volume-slider-disabled-color); + } + } +} + +.tc-volume-slider__value { + // Mono, machine-facing percentage readout. + font-family: var(--tc-font-mono); + font-size: var(--bs-volume-slider-value-size); + letter-spacing: 0.06em; + color: var(--bs-volume-slider-value-color); + min-width: var(--bs-volume-slider-value-min-width); + text-align: right; +} + +// State-conveying colour transitions are cheap; the thumb's hover-fill and the +// mute button colour swap are the only motion here, so freeze those under +// reduced-motion. +@media (prefers-reduced-motion: reduce) { + .tc-volume-slider__mute-btn { + transition: none; + } + + .tc-volume-slider__input { + &::-webkit-slider-thumb { + transition: none; + } + + &::-moz-range-thumb { + transition: none; + } + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index aa081844..1d823647 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -261,6 +261,7 @@ tc-danger-zone-actions, tc-deadzone-slider, tc-fov-slider, tc-mouse-sensitivity, +tc-volume-slider, tc-fps-cap-select, tc-select-row, tc-reset-to-defaults, From bd80ec7f812933ef9fbb75d27b6936f4a8f56d6d Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 10:55:26 +0000 Subject: [PATCH 396/632] 391-vsync-toggle: Build the tc-vsync-toggle web component (VSyncToggle game-components port) --- examples/public/web-components/SKILL.md | 45 ++++++++ .../src/web-components/VSyncToggleDemo.tsx | 89 +++++++++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/VSyncToggle.ts | 98 +++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_vsync-toggle.scss | 103 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 342 insertions(+) create mode 100644 examples/src/web-components/VSyncToggleDemo.tsx create mode 100644 web-components/src/VSyncToggle.ts create mode 100644 web-components/style/components/_vsync-toggle.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index a2fce1db..713006ab 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -357,6 +357,7 @@ After `register()` you can author markup directly: - [tc-fps-cap-select](#tc-fps-cap-select) - [tc-select-row](#tc-select-row) - [tc-fullscreen-toggle](#tc-fullscreen-toggle) + - [tc-vsync-toggle](#tc-vsync-toggle) - [tc-toggle-row](#tc-toggle-row) - [tc-graphics-preset-picker](#tc-graphics-preset-picker) - [tc-invert-axis-toggle](#tc-invert-axis-toggle) @@ -9007,6 +9008,50 @@ A fullscreen on/off setting row: a label/description text block paired with a pi --- +### tc-vsync-toggle + +A vsync on/off setting row: a label/description text block paired with a pill-track switch (`role="switch"`, pure-circle knob — the checked track carries the signature slate-ink gradient). Built on the shared `tc-setting-row` scaffold (a label/control row that the setting rows reuse). Port of game-components `gc-vsync-toggle` with the fantasy chrome dropped for the toolcase slate/ink look; defaults its label to `V-Sync`. + +**Tag:** `tc-vsync-toggle` + +**Attributes** + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `row-label` | string | `V-Sync` | Row label (set automatically when absent) | +| `description` | string | — | Optional secondary line beneath the label | +| `checked` | boolean | `false` | Whether vsync is on | +| `disabled` | boolean | `false` | Disables the switch | + +**JS Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `checked` | `boolean` | Get/set the on/off state. Setting patches the switch in place — no full re-render. | +| `rowLabel` | `string` | Get/set the `row-label` attribute. | +| `description` | `string` | Get/set the `description` attribute. | +| `disabled` | `boolean` | Get/set the `disabled` attribute. | +| `onChange` | `((value: boolean) => void) \| null` | Optional callback fired on every toggle. Mirrors the `tc-change` event. | + +**Events** + +| Event | Detail | Description | +|-------|--------|-------------| +| `tc-change` | `{ value: boolean }` | Fired when the switch is toggled (the new checked state). | + +**No slots.** + +```html + + +``` + +--- + ### tc-toggle-row A generic labeled boolean toggle setting row: a label/description text block paired with a pill-track switch (`role="switch"`, pure-circle knob — the checked track carries the signature slate-ink gradient). Built on the shared `tc-setting-row` scaffold (a label/control row that the setting rows reuse). Port of game-components `gc-toggle-row` with the fantasy chrome dropped for the toolcase slate/ink look. diff --git a/examples/src/web-components/VSyncToggleDemo.tsx b/examples/src/web-components/VSyncToggleDemo.tsx new file mode 100644 index 00000000..72b44fe8 --- /dev/null +++ b/examples/src/web-components/VSyncToggleDemo.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +function useVSyncValue(initial: boolean): [boolean, React.RefObject] { + const [value, setValue] = useState(initial) + const ref = useRef(null) + + useEffect(() => { + const el = ref.current + if (!el) return + const handler = (e: Event) => { + const detail = (e as CustomEvent<{ value: boolean }>).detail + if (detail) setValue(detail.value) + } + el.addEventListener('tc-change', handler) + return () => el.removeEventListener('tc-change', handler) + }, []) + + return [value, ref] +} + +const VSyncToggleDemo: React.FC = () => { + const [v1, ref1] = useVSyncValue(false) + const [v2, ref2] = useVSyncValue(true) + + return ( +
    +
    +
    +
    + Web Components} + title="VSync Toggle" + description="A vsync on/off setting row: a label/description block paired with a pill-track switch. Built on the shared tc-setting-row scaffold; defaults its label to 'V-Sync'." + /> + +
    + +
    + {/* @ts-ignore */} + +
    +
    Current value: {String(v1)}
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    Current value: {String(v2)}
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default VSyncToggleDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 82b523ee..5bc54a58 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -276,6 +276,7 @@ import ResetToDefaultsDemo from './ResetToDefaultsDemo' import FPSCapSelectDemo from './FPSCapSelectDemo' import SelectRowDemo from './SelectRowDemo' import FullscreenToggleDemo from './FullscreenToggleDemo' +import VSyncToggleDemo from './VSyncToggleDemo' import ToggleRowDemo from './ToggleRowDemo' import InvertAxisToggleDemo from './InvertAxisToggleDemo' import AudioMixerDemo from './AudioMixerDemo' @@ -646,6 +647,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'fps-cap-select', category: 'Forms', element: }, { key: 'select-row', category: 'Forms', element: }, { key: 'fullscreen-toggle', category: 'Forms', element: }, + { key: 'vsync-toggle', category: 'Forms', element: }, { key: 'toggle-row', category: 'Forms', element: }, { key: 'graphics-preset-picker', category: 'Forms', element: }, { key: 'invert-axis-toggle', category: 'Forms', element: }, diff --git a/web-components/src/VSyncToggle.ts b/web-components/src/VSyncToggle.ts new file mode 100644 index 00000000..2d7d6630 --- /dev/null +++ b/web-components/src/VSyncToggle.ts @@ -0,0 +1,98 @@ +import { SettingRowBase } from './SettingRowBase' + +const TAG_NAME = 'tc-vsync-toggle' + +// tc-vsync-toggle — a vsync on/off preset row. A pill-track switch +// (role="switch") paired with the shared setting-row scaffold. Port of +// game-components `gc-vsync-toggle` (which extends `gc-toggle-row`) with the +// fantasy chrome dropped for the toolcase slate/ink look; the checked track +// carries the signature ink gradient. All cosmetics flow through +// `--bs-vsync-toggle-*`. +export class VSyncToggle extends SettingRowBase { + + // Optional callback mirror of the `tc-change` event (see styleguide §events). + onChange: ((value: boolean) => void) | null = null + + static get observedAttributes(): string[] { + return [...SettingRowBase.observedAttributes, 'checked', 'disabled'] + } + + connectedCallback(): void { + if (!this.hasAttribute('row-label')) this.setAttribute('row-label', 'V-Sync') + super.connectedCallback() + } + + attributeChangedCallback(name: string, old: string | null, next: string | null): void { + if (!this.isConnected || !this._initialised) return + // Patch checked/disabled in place — a full re-render would drop the + // button's focus on every toggle. + if (name === 'checked') { + const btn = this.querySelector('.tc-vsync-toggle__switch') + const checked = this.checked + if (btn) { + btn.setAttribute('aria-checked', String(checked)) + btn.dataset.checked = String(checked) + } + return + } + if (name === 'disabled') { + const btn = this.querySelector('.tc-vsync-toggle__switch') + if (btn) btn.disabled = this.disabled + return + } + super.attributeChangedCallback(name, old, next) + } + + get checked(): boolean { + return this.hasAttribute('checked') + } + set checked(v: boolean) { + if (v) this.setAttribute('checked', '') + else this.removeAttribute('checked') + } + + get disabled(): boolean { + return this.hasAttribute('disabled') + } + set disabled(v: boolean) { + if (v) this.setAttribute('disabled', '') + else this.removeAttribute('disabled') + } + + protected renderControl(): string { + const checked = this.checked + const disabledAttr = this.disabled ? ' disabled' : '' + return ` + + ` + } + + protected bindControl(): void { + const btn = this.querySelector('.tc-vsync-toggle__switch') + if (!btn) return + btn.addEventListener('click', () => { + if (this.disabled) return + const next = !this.checked + this.checked = next + btn.dataset.checked = String(next) + btn.setAttribute('aria-checked', String(next)) + this.emit('tc-change', { value: next }) + if (typeof this.onChange === 'function') this.onChange(next) + }) + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: VSyncToggle + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 8003786e..d941f977 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -293,6 +293,7 @@ export * from './VolumeSlider' export * from './FPSCapSelect' export * from './SelectRow' export * from './FullscreenToggle' +export * from './VSyncToggle' export * from './ToggleRow' export * from './GameOverScreen' export * from './ResultScreen' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index a16d2221..fa918be6 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -290,6 +290,7 @@ import { VolumeSlider } from './VolumeSlider' import { FPSCapSelect } from './FPSCapSelect' import { SelectRow } from './SelectRow' import { FullscreenToggle } from './FullscreenToggle' +import { VSyncToggle } from './VSyncToggle' import { ToggleRow } from './ToggleRow' import { GameOverScreen } from './GameOverScreen' import { ResultScreen } from './ResultScreen' @@ -652,6 +653,7 @@ export function register(): void { customElements.define('tc-fps-cap-select', FPSCapSelect) customElements.define('tc-select-row', SelectRow) customElements.define('tc-fullscreen-toggle', FullscreenToggle) + customElements.define('tc-vsync-toggle', VSyncToggle) customElements.define('tc-toggle-row', ToggleRow) customElements.define('tc-game-over-screen', GameOverScreen) customElements.define('tc-result-screen', ResultScreen) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index a222d54c..c53b816e 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -64,6 +64,7 @@ @forward 'fps-cap-select'; @forward 'select-row'; @forward 'fullscreen-toggle'; +@forward 'vsync-toggle'; @forward 'toggle-row'; @forward 'graphics-preset-picker'; @forward 'invert-axis-toggle'; diff --git a/web-components/style/components/_vsync-toggle.scss b/web-components/style/components/_vsync-toggle.scss new file mode 100644 index 00000000..fb441277 --- /dev/null +++ b/web-components/style/components/_vsync-toggle.scss @@ -0,0 +1,103 @@ +// tc-vsync-toggle — vsync on/off preset control on the shared +// setting-row scaffold (`_setting-row.scss`). The control is a pill-track switch +// (sanctioned pill geometry) with a pure-circle sliding knob — never a check +// glyph. The checked track carries the signature 135° slate-ink gradient; the +// off track is a flat slate well. All cosmetics flow through +// `--bs-vsync-toggle-*`; the fantasy chrome of the gc-* original is dropped. + +tc-vsync-toggle { + --bs-vsync-toggle-track-width: 40px; + --bs-vsync-toggle-track-height: 22px; + --bs-vsync-toggle-track-bg: var(--tc-border-strong); + --bs-vsync-toggle-track-hover-bg: var(--tc-text-faint); + --bs-vsync-toggle-track-checked-bg: var(--tc-app-accent); + --bs-vsync-toggle-track-checked-gradient: linear-gradient(135deg, var(--tc-app-accent), #2b3a51); + --bs-vsync-toggle-track-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08); + --bs-vsync-toggle-knob-size: 18px; + --bs-vsync-toggle-knob-inset: 2px; + --bs-vsync-toggle-knob-bg: #ffffff; + --bs-vsync-toggle-knob-border: rgba(15, 23, 42, 0.08); + --bs-vsync-toggle-knob-shadow: 0 1px 2px rgba(15, 23, 42, 0.18); + --bs-vsync-toggle-focus-ring: 0 0 0 0.2rem rgba(30, 41, 59, 0.12); + --bs-vsync-toggle-disabled-opacity: 0.6; +} + +.tc-vsync-toggle__switch { + position: relative; + flex-shrink: 0; + box-sizing: border-box; + width: var(--bs-vsync-toggle-track-width); + height: var(--bs-vsync-toggle-track-height); + padding: 0; + appearance: none; + background: var(--bs-vsync-toggle-track-bg); + border: 0; + border-radius: 999px; + box-shadow: var(--bs-vsync-toggle-track-shadow); + cursor: pointer; + transition: + background-color var(--tc-transition-base), + box-shadow var(--tc-transition-base); + + // Coarse pointers get a 44px hit area without changing the visual track. + @media (pointer: coarse) { + min-height: 44px; + min-width: 44px; + } + + &:hover { + background: var(--bs-vsync-toggle-track-hover-bg); + } + + &:focus { + outline: 0; + } + + &:focus-visible { + box-shadow: var(--bs-vsync-toggle-focus-ring); + } + + &[data-checked='true'] { + background: var(--bs-vsync-toggle-track-checked-bg); + background-image: var(--bs-vsync-toggle-track-checked-gradient); + box-shadow: none; + + .tc-vsync-toggle__knob { + // Slide the knob to the right edge of the track (transform stays + // reserved for the translateY centring, so animate `left`). + left: calc( + var(--bs-vsync-toggle-track-width) - var(--bs-vsync-toggle-knob-size) - + var(--bs-vsync-toggle-knob-inset) + ); + } + } + + &:disabled { + opacity: var(--bs-vsync-toggle-disabled-opacity); + pointer-events: none; + } +} + +.tc-vsync-toggle__knob { + // Pure circle — the one sanctioned curve. Centred vertically; slides + // horizontally from the inset-left rest position to the right edge. + position: absolute; + top: 50%; + left: var(--bs-vsync-toggle-knob-inset); + width: var(--bs-vsync-toggle-knob-size); + height: var(--bs-vsync-toggle-knob-size); + background: var(--bs-vsync-toggle-knob-bg); + border: 0.5px solid var(--bs-vsync-toggle-knob-border); + border-radius: 50%; + box-shadow: var(--bs-vsync-toggle-knob-shadow); + transform: translateY(-50%); + transition: left var(--tc-transition-base); +} + +// The knob's slide is the only motion here — freeze it under reduced-motion. +@media (prefers-reduced-motion: reduce) { + .tc-vsync-toggle__switch, + .tc-vsync-toggle__knob { + transition: none; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 1d823647..9824f7cf 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -266,6 +266,7 @@ tc-fps-cap-select, tc-select-row, tc-reset-to-defaults, tc-fullscreen-toggle, +tc-vsync-toggle, tc-toggle-row, tc-graphics-preset-picker, tc-invert-axis-toggle, From 89be714789b2c7cd1e8a5d8ada0ea81b7f134eec Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 11:01:48 +0000 Subject: [PATCH 397/632] 392-waypoint-marker: Build the tc-waypoint-marker web component (WaypointMarker game-components port) --- examples/public/web-components/SKILL.md | 74 +++++++++ .../src/web-components/WaypointMarkerDemo.tsx | 75 +++++++++ examples/src/web-components/index.tsx | 2 + web-components/src/WaypointMarker.ts | 145 ++++++++++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + .../style/components/_waypoint-marker.scss | 76 +++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 377 insertions(+) create mode 100644 examples/src/web-components/WaypointMarkerDemo.tsx create mode 100644 web-components/src/WaypointMarker.ts create mode 100644 web-components/style/components/_waypoint-marker.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 713006ab..7b24d37c 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -262,6 +262,7 @@ After `register()` you can author markup directly: - [tc-ping-display](#tc-ping-display) - [tc-platform-icon](#tc-platform-icon) - [tc-objective-marker](#tc-objective-marker) + - [tc-waypoint-marker](#tc-waypoint-marker) - [tc-page-indicator](#tc-page-indicator) - [tc-panel](#tc-panel) - [tc-panel-header](#tc-panel-header) @@ -24159,6 +24160,79 @@ None. `tc-objective-marker` is attribute-driven; all content is generated intern --- +### tc-waypoint-marker + +Absolutely-positioned world-space waypoint marker with a configurable Lucide icon glyph, optional label chip, and formatted distance readout (metres / kilometres). Port of `gc-waypoint-marker` restyled to the toolcase design system: slate neutrals, sharp corners, 1px hairlines. Drop it inside a `position: relative` container and set `x`/`y` to world coordinates. The element is `position: absolute` and transforms to pin the glyph tip at the target point. No shadow root; light DOM; `display: inline-flex`. + +**Tag:** `tc-waypoint-marker` + +#### Attributes + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `x` | `number \| null` | `null` | Horizontal position in pixels (`style.left`). Omit to leave unset. | +| `y` | `number \| null` | `null` | Vertical position in pixels (`style.top`). Omit to leave unset. | +| `label` | `string` | `''` | Waypoint label displayed below the glyph. Omit for no text chip. | +| `distance` | `number \| null` | `null` | Distance in metres. Values ≥ 1000 are formatted as `N.Nkm`. Omit for no distance readout. | +| `color` | `string` | `''` | CSS colour for the glyph and border. Writes `--bs-waypoint-marker-color` inline. | +| `icon` | `string` | `''` | Lucide icon name (kebab-case, e.g. `navigation`, `flag`, `map-pin`). Defaults to `navigation` when absent. | +| `size` | `number \| null` | `null` | Glyph icon size in pixels. Writes `--bs-waypoint-marker-size` inline. | + +#### JS Properties + +| Property | Type | Description | +|---|---|---| +| `x` | `number \| null` | Reflects the `x` attribute. | +| `y` | `number \| null` | Reflects the `y` attribute. | +| `label` | `string` | Reflects the `label` attribute. | +| `distance` | `number \| null` | Reflects the `distance` attribute. | +| `color` | `string` | Reflects the `color` attribute. | +| `icon` | `string` | Reflects the `icon` attribute. | +| `size` | `number \| null` | Reflects the `size` attribute. | + +#### Events + +None. `tc-waypoint-marker` is a purely presentational element. + +#### Slots + +None. `tc-waypoint-marker` is attribute-driven; all content is generated internally. + +#### CSS Custom Properties + +| Property | Default | Description | +|---|---|---| +| `--bs-waypoint-marker-size` | `18px` | Icon glyph size in pixels (overridden by the `size` attribute when set). | +| `--bs-waypoint-marker-color` | `var(--tc-app-accent)` | Glyph and chip-border accent colour (overridden by the `color` attribute when set). | +| `--bs-waypoint-marker-bg` | `var(--tc-surface)` | Background of the label/distance chip. | +| `--bs-waypoint-marker-border-color` | `var(--tc-border)` | 1px hairline border around the label/distance chip. | +| `--bs-waypoint-marker-text-color` | `var(--tc-text)` | Label text colour. | +| `--bs-waypoint-marker-distance-color` | `var(--tc-text-muted)` | Distance readout colour. | +| `--bs-waypoint-marker-label-size` | `11px` | Label font size. | +| `--bs-waypoint-marker-distance-size` | `10px` | Distance readout font size. | + +#### Example + +```html +
    + + +
    + + + + +``` + +--- + ### tc-page-indicator Dot page-navigation widget. Renders one circular button per page; clicking or pressing Enter/Space on a dot selects that page and fires `tc-select`. diff --git a/examples/src/web-components/WaypointMarkerDemo.tsx b/examples/src/web-components/WaypointMarkerDemo.tsx new file mode 100644 index 00000000..512d86f2 --- /dev/null +++ b/examples/src/web-components/WaypointMarkerDemo.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const WaypointMarkerDemo: React.FC = () => ( +
    +
    +
    +
    + Web Components} + title="WaypointMarker" + description="Absolutely-positioned world-space waypoint marker with a configurable Lucide icon glyph, optional label chip, and formatted distance readout. Drop inside a position:relative container and set x/y for world coordinates." + /> + +
    + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    +) + +export default WaypointMarkerDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 5bc54a58..84ce538f 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -331,6 +331,7 @@ import NetworkStatusIconDemo from './NetworkStatusIconDemo' import PingDisplayDemo from './PingDisplayDemo' import PlatformIconDemo from './PlatformIconDemo' import ObjectiveMarkerDemo from './ObjectiveMarkerDemo' +import WaypointMarkerDemo from './WaypointMarkerDemo' import PageIndicatorDemo from './PageIndicatorDemo' import PanelDemo from './PanelDemo' import ParticleEmitterDemo from './ParticleEmitterDemo' @@ -702,6 +703,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'platform-icon', category: 'Components', element: }, { key: 'ping-display', category: 'Components', element: }, { key: 'objective-marker', category: 'Components', element: }, + { key: 'waypoint-marker', category: 'Components', element: }, { key: 'page-indicator', category: 'Navigation', element: }, { key: 'panel', category: 'Components', element: }, { key: 'particle-emitter', category: 'Components', element: }, diff --git a/web-components/src/WaypointMarker.ts b/web-components/src/WaypointMarker.ts new file mode 100644 index 00000000..3656ad88 --- /dev/null +++ b/web-components/src/WaypointMarker.ts @@ -0,0 +1,145 @@ +import * as LucideIcons from 'lucide-static' +import { icon } from './icons' + +const TAG_NAME = 'tc-waypoint-marker' +const DEFAULT_ICON = 'navigation' + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function lucideByName(name: string): string { + const pascal = name + .split('-') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') + const svgStr = (LucideIcons as Record)[pascal] + if (!svgStr) return '' + return icon(svgStr) +} + +function formatDistance(d: number): string { + if (d >= 1000) return `${(d / 1000).toFixed(1)}km` + return `${Math.round(d)}m` +} + +export class WaypointMarker extends HTMLElement { + private _initialised = false + + static get observedAttributes(): string[] { + return ['x', 'y', 'label', 'distance', 'color', 'icon', 'size'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + private numberAttr(name: string): number | null { + const raw = this.getAttribute(name) + if (raw == null) return null + const parsed = parseFloat(raw) + return Number.isFinite(parsed) ? parsed : null + } + + get x(): number | null { return this.numberAttr('x') } + set x(v: number | null) { + if (v == null) this.removeAttribute('x') + else this.setAttribute('x', String(v)) + } + + get y(): number | null { return this.numberAttr('y') } + set y(v: number | null) { + if (v == null) this.removeAttribute('y') + else this.setAttribute('y', String(v)) + } + + get label(): string { return this.getAttribute('label') ?? '' } + set label(v: string) { + if (v) this.setAttribute('label', v) + else this.removeAttribute('label') + } + + get distance(): number | null { return this.numberAttr('distance') } + set distance(v: number | null) { + if (v == null) this.removeAttribute('distance') + else this.setAttribute('distance', String(v)) + } + + get color(): string { return this.getAttribute('color') ?? '' } + set color(v: string) { + if (v) this.setAttribute('color', v) + else this.removeAttribute('color') + } + + // Lucide icon name (kebab-case). Defaults to 'navigation' when absent. + // Note: HTMLElement does not define a native `icon` property so no collision. + get icon(): string { return this.getAttribute('icon') ?? '' } + set icon(v: string) { + if (v) this.setAttribute('icon', v) + else this.removeAttribute('icon') + } + + get size(): number | null { return this.numberAttr('size') } + set size(v: number | null) { + if (v == null) this.removeAttribute('size') + else this.setAttribute('size', String(v)) + } + + private render(): void { + const x = this.x + const y = this.y + const size = this.size + const color = this.color + + if (x != null) this.style.left = `${x}px` + else this.style.removeProperty('left') + if (y != null) this.style.top = `${y}px` + else this.style.removeProperty('top') + if (size != null) this.style.setProperty('--bs-waypoint-marker-size', `${size}px`) + else this.style.removeProperty('--bs-waypoint-marker-size') + if (color) this.style.setProperty('--bs-waypoint-marker-color', color) + else this.style.removeProperty('--bs-waypoint-marker-color') + + this.classList.add('tc-waypoint-marker') + + const iconName = this.icon || DEFAULT_ICON + const iconHtml = lucideByName(iconName) + const label = this.label + const distance = this.distance + + const distanceMarkup = distance != null + ? `${esc(formatDistance(distance))}` + : '' + const textMarkup = (label || distance != null) + ? `
    + ${label ? `${esc(label)}` : ''} + ${distanceMarkup} +
    ` + : '' + + this.innerHTML = ` + + ${textMarkup} + ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: WaypointMarker + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index d941f977..811e07b7 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -332,6 +332,7 @@ export * from './MuteList' export * from './PlayerCard' export * from './PlayerFrame' export * from './ObjectiveMarker' +export * from './WaypointMarker' export * from './PageIndicator' export * from './Panel' export * from './ParticleEmitter' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index fa918be6..8e24459a 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -333,6 +333,7 @@ import { MuteList } from './MuteList' import { PlayerCard } from './PlayerCard' import { PlayerFrame } from './PlayerFrame' import { ObjectiveMarker } from './ObjectiveMarker' +import { WaypointMarker } from './WaypointMarker' import { PageIndicator } from './PageIndicator' import { ParticleEmitter } from './ParticleEmitter' import { Panel, PanelHeader } from './Panel' @@ -695,6 +696,7 @@ export function register(): void { customElements.define('tc-player-card', PlayerCard) customElements.define('tc-player-frame', PlayerFrame) customElements.define('tc-objective-marker', ObjectiveMarker) + customElements.define('tc-waypoint-marker', WaypointMarker) customElements.define('tc-page-indicator', PageIndicator) customElements.define('tc-particle-emitter', ParticleEmitter) customElements.define('tc-panel', Panel) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index c53b816e..0c799f62 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -325,6 +325,7 @@ @forward 'minimap'; @forward 'mute-list'; @forward 'objective-marker'; +@forward 'waypoint-marker'; @forward 'page-indicator'; @forward 'panel'; @forward 'particle-emitter'; diff --git a/web-components/style/components/_waypoint-marker.scss b/web-components/style/components/_waypoint-marker.scss new file mode 100644 index 00000000..48303282 --- /dev/null +++ b/web-components/style/components/_waypoint-marker.scss @@ -0,0 +1,76 @@ +// tc-waypoint-marker — absolutely-positioned world-space waypoint marker with +// a configurable Lucide icon glyph, optional label chip, and formatted distance +// readout (metres / kilometres). Port of gc-waypoint-marker restyled to the +// toolcase design system: sharp corners, slate neutrals, 1px hairlines. +// All cosmetics flow through --bs-waypoint-marker-* backed by --tc-* tokens. + +@use '../foundation/tokens' as *; + +tc-waypoint-marker { + --bs-waypoint-marker-size: 18px; + --bs-waypoint-marker-color: var(--tc-app-accent); + --bs-waypoint-marker-bg: var(--tc-surface); + --bs-waypoint-marker-border-color: var(--tc-border); + --bs-waypoint-marker-text-color: var(--tc-text); + --bs-waypoint-marker-distance-color: var(--tc-text-muted); + --bs-waypoint-marker-label-size: 11px; + --bs-waypoint-marker-distance-size: 10px; + + position: absolute; + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 3px; + pointer-events: none; + transform: translate(-50%, -100%); + color: var(--bs-waypoint-marker-color); +} + +.tc-waypoint-marker { + // Glyph container — size driven by --bs-waypoint-marker-size. + &__glyph { + display: flex; + align-items: center; + justify-content: center; + color: var(--bs-waypoint-marker-color); + + svg { + width: var(--bs-waypoint-marker-size); + height: var(--bs-waypoint-marker-size); + stroke: currentColor; + } + } + + // Label + distance chip — 1px hairline, slate surface, sharp corners. + &__text { + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + background: var(--bs-waypoint-marker-bg); + border: 1px solid var(--bs-waypoint-marker-border-color); + padding: 2px 8px; + border-radius: 0; + white-space: nowrap; + } + + // Uppercase micro-label in JetBrains Mono. + &__label { + font-family: 'JetBrains Mono', monospace; + font-size: var(--bs-waypoint-marker-label-size); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bs-waypoint-marker-text-color); + line-height: 1.3; + } + + // Distance readout in JetBrains Mono — muted, tabular. + &__distance { + font-family: 'JetBrains Mono', monospace; + font-size: var(--bs-waypoint-marker-distance-size); + letter-spacing: 0.06em; + color: var(--bs-waypoint-marker-distance-color); + line-height: 1.3; + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 9824f7cf..6b8a33ae 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -596,6 +596,7 @@ tc-key-binder, tc-network-status-icon, tc-ping-display, tc-objective-marker, +tc-waypoint-marker, tc-page-indicator, tc-rating, tc-social-links, From 6e46fd889b9036376ce9bc6e91916f3d51045590 Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 14:41:29 +0200 Subject: [PATCH 398/632] component fixes --- examples/public/web-components/SKILL.md | 14 +++++++------- package-lock.json | 23 +++++++++++++++++++++++ react-components/package.json | 3 +++ web-components/src/ActivityCard.ts | 4 ++-- web-components/src/ApiReferenceTable.ts | 4 ++-- web-components/src/CommandReference.ts | 4 ++-- web-components/src/Comparator.ts | 4 ++-- web-components/src/CompatibilityMatrix.ts | 4 ++-- web-components/src/DiffViewer.ts | 6 ++++++ web-components/src/ExtendedSelect.ts | 2 +- web-components/src/FileTags.ts | 8 ++------ web-components/src/Hotbar.ts | 18 ++++++------------ web-components/src/InventoryGrid.ts | 18 ++++++------------ web-components/src/Leaderboard.ts | 4 ++-- web-components/src/LinkedProvidersCard.ts | 4 ++-- web-components/src/ListCard.ts | 12 ++++++------ web-components/src/LiveFeed.ts | 2 +- web-components/src/Marquee.ts | 2 +- web-components/src/MigrationGuide.ts | 4 ++-- web-components/src/NormalMapGenerator.ts | 6 ++++-- web-components/src/RarityChip.ts | 6 ++++-- web-components/src/Roadmap.ts | 4 ++-- web-components/src/StatusCard.ts | 4 ++-- web-components/src/register.ts | 6 +++++- 24 files changed, 95 insertions(+), 71 deletions(-) diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index fbfa90b6..6403f40c 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -3961,15 +3961,15 @@ Dashboard card rendering a list of items with optional ranking numbers, leading | Property | Type | Description | |----------|------|-------------| -| `items` | `ListItem[]` | Array of list items to render. Set via JS property. Default: `[]`. Re-renders on assignment. | +| `items` | `ListCardItem[]` | Array of list items to render. Set via JS property. Default: `[]`. Re-renders on assignment. | | `title` | `string \| null` | Reflects the `title` attribute. | | `ordered` | `boolean` | Reflects the `ordered` attribute. | | `loading` | `boolean` | Reflects the `loading` attribute. | | `loadingCount` | `number` | Reflects the `loading-count` attribute. | -`ListItem` shape: +`ListCardItem` shape: ```ts -interface ListItem { +interface ListCardItem { id?: string // optional unique key (not rendered) icon?: string // Lucide icon name in PascalCase (e.g. "Github", "Star"). Shown when ordered=false. label: string // primary row label (required) @@ -13921,7 +13921,7 @@ None. All data is supplied via JS properties. |----------|------|---------|-------------| | `entries` | `LeaderboardEntry[]` | `[]` | Array of row data. Set via `el.entries = [...]`. Triggers a re-render. | | `columns` | `LeaderboardColumns` | `{}` (all visible) | Column visibility and header overrides. Each key (`rank`, `dev`, `tier`, `sprints`, `trend`, `points`) can be `false` (hidden) or a `string` (custom header label). `undefined` shows the column with the default label. | -| `onselect` | `function \| null` | `null` | Optional callback fired when an interactive row is selected. Receives the `LeaderboardEntry` object. | +| `onSelect` | `function \| null` | `null` | Optional callback fired when an interactive row is selected. Receives the `LeaderboardEntry` object. | **LeaderboardEntry shape** @@ -13996,7 +13996,7 @@ Renders a semantic `` with ``, ``, and `` + return `` } return rows .map( (r) => - ``, + ``, ) .join('') } const AdvancedTableDemo: React.FC = () => { const tableRef = useRef(null) + const switchRef = useRef(null) const loadingRef = useRef(null) - const [filterValues, setFilterValues] = useState>({ name: '', role: '' }) + const [filterValues, setFilterValues] = useState>(INITIAL_FILTERS) const [sort, setSort] = useState({ column: 'commits', direction: 'desc' }) const [offset, setOffset] = useState(0) @@ -106,6 +143,20 @@ const AdvancedTableDemo: React.FC = () => { } }, []) + // The tc-switch toggle ("Active only") is a separate control above the table, + // wired into the same filterValues state — it filters the same dataset. + useEffect(() => { + const el = switchRef.current + if (!el) return + const onToggle = (e: Event) => { + const value = !!(e as CustomEvent).detail.value + setFilterValues((prev) => ({ ...prev, activeOnly: value })) + setOffset(0) + } + el.addEventListener('tc-change', onToggle) + return () => el.removeEventListener('tc-change', onToggle) + }, []) + // Re-derive the filtered / sorted / paginated view and push everything to the // element. Body rows are set imperatively into the projected . useEffect(() => { @@ -114,9 +165,17 @@ const AdvancedTableDemo: React.FC = () => { const search = String(filterValues.name ?? '').toLowerCase() const role = String(filterValues.role ?? '') + const team = String(filterValues.team ?? '') + const minCommits = parseInt(String(filterValues.minCommits ?? ''), 10) || 0 + const activeOnly = !!filterValues.activeOnly let view = DATA.filter( - (r) => (!search || r.name.toLowerCase().includes(search)) && (!role || r.role === role), + (r) => + (!search || r.name.toLowerCase().includes(search)) && + (!role || r.role === role) && + (!team || r.team === team) && + r.commits >= minCommits && + (!activeOnly || r.active), ) if (sort) { @@ -160,6 +219,15 @@ const AdvancedTableDemo: React.FC = () => { if (body) body.innerHTML = rowsHtml(DATA.slice(0, LIMIT)) }, []) + const matchCount = DATA.filter( + (r) => + (!filterValues.name || r.name.toLowerCase().includes(String(filterValues.name).toLowerCase())) && + (!filterValues.role || r.role === filterValues.role) && + (!filterValues.team || r.team === filterValues.team) && + r.commits >= (parseInt(String(filterValues.minCommits ?? ''), 10) || 0) && + (!filterValues.activeOnly || r.active), + ).length + return (
    @@ -168,11 +236,26 @@ const AdvancedTableDemo: React.FC = () => { Web Components} title="Advanced Table" - description="Data table with a filter toolbar, sortable headers, a translucent loading overlay, and a paginated footer. filters / sortableColumns / sort are set via JS properties, body rows are projected as slotted
    rows, and the element emits tc-filter-change, tc-sort-change, and tc-page-change." + description="Data table with a filter toolbar, sortable headers, a translucent loading overlay, and a paginated footer driven by the shared tc-pagination component. filters / sortableColumns / sort are set via JS properties, body rows are projected as slotted rows, and the element emits tc-filter-change, tc-sort-change, and tc-page-change." />
    + {/* Extra control above the table: a tc-switch wired into + the same filterValues state as the built-in filters. */} +
    + {/* @ts-ignore */} + + + {matchCount} of {DATA.length} match + +
    {/* @ts-ignore */}
    diff --git a/examples/src/web-components/CoolButtonDemo.tsx b/examples/src/web-components/CoolButtonDemo.tsx index e27cce1e..2d0b35bd 100644 --- a/examples/src/web-components/CoolButtonDemo.tsx +++ b/examples/src/web-components/CoolButtonDemo.tsx @@ -3,7 +3,9 @@ import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react const CoolButtonDemo: React.FC = () => { const [clicked, setClicked] = useState('') + const [busy, setBusy] = useState(false) const loadingRef = useRef(null) + const toggleRef = useRef(null) const addonLeftRef = useRef(null) const addonRightSlotRef = useRef(null) @@ -13,6 +15,23 @@ const CoolButtonDemo: React.FC = () => { el.addEventListener('tc-click', () => setClicked('loading button clicked')) }, []) + // Toggle a real loading cycle so the stable-width behaviour is visible: + // the button must not grow, shrink, or jump when `loading` flips on/off. + useEffect(() => { + const el = toggleRef.current + if (!el) return + const handler = () => { + setBusy(true) + window.setTimeout(() => setBusy(false), 1600) + } + el.addEventListener('tc-click', handler) + return () => el.removeEventListener('tc-click', handler) + }, []) + + useEffect(() => { + if (toggleRef.current) toggleRef.current.loading = busy + }, [busy]) + useEffect(() => { const el = addonLeftRef.current if (!el) return @@ -89,13 +108,48 @@ const CoolButtonDemo: React.FC = () => { -
    +

    + The spinner is centred over the label and sized to the text. The label is hidden + (not removed), so the button keeps its resting width and stays non-interactive. +

    +
    {/* @ts-ignore */} {/* @ts-ignore */} {/* @ts-ignore */} + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    + + + +

    + Click to start a 1.6s task. Watch the button: it does not resize or shift when the + spinner appears or disappears. +

    +
    + {/* @ts-ignore */} + + {busy ? 'working…' : 'idle'} +
    +
    + + +

    + With a leading addon the spinner replaces the icon glyph; with a trailing addon it + overlays the label and the addon glyph stays put. +

    +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} +
    @@ -109,7 +163,11 @@ const CoolButtonDemo: React.FC = () => { -
    +

    + Addon glyphs are sized to the label and vertically centred against it, with a + consistent gap and a 1px divider. +

    +
    {/* @ts-ignore */} {/* @ts-ignore */} @@ -120,7 +178,7 @@ const CoolButtonDemo: React.FC = () => { -
    +
    {/* @ts-ignore */} {/* @ts-ignore */} @@ -129,18 +187,22 @@ const CoolButtonDemo: React.FC = () => { -
    +

    + Slotted addon content is centred and aligned to the label too — here a mono version + tag and a glyph that inherits the label sizing. +

    +
    {/* @ts-ignore */} Publish {/* @ts-ignore */} - v2 + v2 {/* @ts-ignore */} Share {/* @ts-ignore */} - +
    diff --git a/examples/src/web-components/DividerDemo.tsx b/examples/src/web-components/DividerDemo.tsx index 0e163c6e..37dc055a 100644 --- a/examples/src/web-components/DividerDemo.tsx +++ b/examples/src/web-components/DividerDemo.tsx @@ -36,6 +36,30 @@ const DividerDemo: React.FC = () => ( Right
    + + +
    + Edit + {/* @ts-ignore */} + + Duplicate + {/* @ts-ignore */} + + Delete +
    +
    + + +
    +
    Panel A
    + {/* @ts-ignore */} + +
    Panel B
    + {/* @ts-ignore */} + +
    Panel C
    +
    +
    diff --git a/examples/src/web-components/EarlySignupFormDemo.tsx b/examples/src/web-components/EarlySignupFormDemo.tsx index 7d24484a..6897920f 100644 --- a/examples/src/web-components/EarlySignupFormDemo.tsx +++ b/examples/src/web-components/EarlySignupFormDemo.tsx @@ -43,15 +43,22 @@ const EarlySignupFormDemo: React.FC = () => { el.benefits = ['Always up to date', 'SLA-backed reliability'] }, []) - // Pre-rendered success state (submitted=true simulated via property) + // Pre-rendered success state — drive the email field + submit the form + // programmatically so the confirmation state renders on mount. useEffect(() => { const el = successRef.current if (!el) return el.benefits = ['Instant access confirmation', 'Personal onboarding call'] - // Trigger the success state by pre-filling and submitting programmatically - el.addEventListener('tc-submit', () => { - // success state is shown automatically by the component - }) + // Defer a tick so the component has finished its initial render. + const id = window.setTimeout(() => { + const input = el.querySelector('input[name="email"]') as HTMLInputElement | null + const form = el.querySelector('.tc-early-signup-form__form') as HTMLFormElement | null + if (input && form) { + input.value = 'dev@example.com' + form.requestSubmit() + } + }, 0) + return () => window.clearTimeout(id) }, []) return ( @@ -72,12 +79,14 @@ const EarlySignupFormDemo: React.FC = () => { {/* @ts-ignore */} @@ -100,12 +109,14 @@ const EarlySignupFormDemo: React.FC = () => { @@ -135,12 +146,30 @@ const EarlySignupFormDemo: React.FC = () => { /> + + {/* @ts-ignore */} + + + {/* @ts-ignore */} { const basicRef = useRef(null) const fullRef = useRef(null) + const minimalRef = useRef(null) const statsRef = useRef(null) const metricsRef = useRef(null) const bgIconsRef = useRef(null) @@ -15,20 +16,27 @@ const HeroDemo: React.FC = () => { } }, []) + // Mirrors the react Hero demo's "Full Featured" scenario: eyebrow, title, + // description, both actions, stat cards AND a centered metrics band. useEffect(() => { if (fullRef.current) { fullRef.current.primaryAction = { - label: 'Start for Free', + label: 'Get Started Free', onClick: () => console.log('primary action clicked'), } fullRef.current.secondaryAction = { - label: 'See Demo', + label: 'View Docs', onClick: () => console.log('secondary action clicked'), } fullRef.current.statCards = [ - { label: 'Total Users', value: '12K+' }, - { label: 'Packages', value: '340' }, - { label: 'Uptime', value: '99.9%' }, + { label: 'Active players', value: '12,482' }, + { label: 'Avg. session time', value: '32m' }, + { label: 'Retention', value: '91%' }, + ] + fullRef.current.metrics = [ + { label: 'studio teams', value: '180+' }, + { label: 'ms response', value: '28ms' }, + { label: 'uptime', value: '99.99%' }, ] fullRef.current.addEventListener('tc-action', (e: CustomEvent) => { console.log('tc-action', e.detail) @@ -36,6 +44,13 @@ const HeroDemo: React.FC = () => { } }, []) + // Mirrors the react Hero demo's "Minimal (No Stats)" scenario. + useEffect(() => { + if (minimalRef.current) { + minimalRef.current.primaryAction = { label: 'Start Building', href: '#' } + } + }, []) + useEffect(() => { if (statsRef.current) { statsRef.current.primaryAction = { label: 'View Stats', href: '#' } @@ -91,13 +106,22 @@ const HeroDemo: React.FC = () => { /> - + {/* @ts-ignore */} + + + + {/* @ts-ignore */} + diff --git a/examples/src/web-components/JSONEditorDemo.tsx b/examples/src/web-components/JSONEditorDemo.tsx index 0bc3e72c..4b603ef2 100644 --- a/examples/src/web-components/JSONEditorDemo.tsx +++ b/examples/src/web-components/JSONEditorDemo.tsx @@ -66,7 +66,7 @@ const JSONEditorDemo: React.FC = () => { Web Components} title="JSON Editor" - description="Schema-driven form editor for JSON objects — typed fields, enum selects, collapsible nested groups, and repeatable arrays. Every edit emits the full updated value object." + description="Compact, schema-driven form editor for JSON objects — typed fields, enum selects, collapsible nested groups, and repeatable arrays packed into a dense grid of key/value rows. Every edit emits the full updated value object." />
    diff --git a/examples/src/web-components/JSONSchemaDefDemo.tsx b/examples/src/web-components/JSONSchemaDefDemo.tsx index e4ce9364..8a11b6a7 100644 --- a/examples/src/web-components/JSONSchemaDefDemo.tsx +++ b/examples/src/web-components/JSONSchemaDefDemo.tsx @@ -18,11 +18,12 @@ const objectRefList = [ { value: 'Settings', label: 'Settings' }, ] -// Initial uncontrolled value — a serialized array of property definitions. +// Initial uncontrolled value — a serialized array of property definitions, some +// carrying a default value. const defaultValue = JSON.stringify([ - { key: 'id', type: 'string', required: true }, - { key: 'age', type: 'integer' }, - { key: 'active', type: 'boolean', required: true }, + { key: 'id', type: 'string' }, + { key: 'age', type: 'integer', default: '18' }, + { key: 'active', type: 'boolean', default: 'true' }, { key: 'owner', type: 'ref', ref: 'User' }, { key: 'tags', type: 'array', ref: 'string' }, { key: 'profile', type: 'object', ref: 'Profile' }, @@ -32,8 +33,8 @@ const defaultValue = JSON.stringify([ // inline duplicate-name validation error. const duplicateValue = JSON.stringify([ { key: 'name', type: 'string' }, - { key: 'email', type: 'string' }, - { key: 'name', type: 'string', required: true }, + { key: 'email', type: 'string', default: 'none@example.com' }, + { key: 'name', type: 'string' }, ]) const JSONSchemaDefDemo: React.FC = () => { @@ -78,7 +79,7 @@ const JSONSchemaDefDemo: React.FC = () => { Web Components} title="JSON Schema Definition" - description="Visual editor for defining JSON schema properties — editable schema name, per-property type selection, conditional $ref selectors, required toggles, reordering, and inline duplicate-name validation. Emits the serialized definition on every change." + description="Compact visual editor for defining JSON schema properties — editable schema name, per-property type selection, conditional $ref selectors, default-value inputs, reordering, and inline duplicate-name validation. Emits the serialized definition on every change." />
    diff --git a/examples/src/web-components/MaintainerCardDemo.tsx b/examples/src/web-components/MaintainerCardDemo.tsx index 423f9486..25983809 100644 --- a/examples/src/web-components/MaintainerCardDemo.tsx +++ b/examples/src/web-components/MaintainerCardDemo.tsx @@ -1,10 +1,15 @@ import React, { useEffect, useRef } from 'react' import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' +// Icons resolve through lucide-static by name (kebab → PascalCase). Brand +// glyphs (Github/Twitter/…) were dropped from lucide-static, so use generic +// icons that still exist: a code glyph for the repo, a chat bubble for social, +// a globe for the website. const LINKS_FULL = [ - { key: 'github', href: '#', label: 'GitHub', icon: 'github' }, - { key: 'twitter', href: '#', label: 'X (Twitter)', icon: 'twitter' }, - { key: 'globe', href: '#', label: 'Website', icon: 'globe' }, + { key: 'repo', href: '#', label: 'Source code', icon: 'code' }, + { key: 'social', href: '#', label: 'Social', icon: 'message-circle' }, + { key: 'website', href: '#', label: 'Website', icon: 'globe' }, + { key: 'email', href: '#', label: 'Email', icon: 'mail' }, ] const MaintainerCardDemo: React.FC = () => { diff --git a/examples/src/web-components/RadialWheelDemo.tsx b/examples/src/web-components/RadialWheelDemo.tsx index 8093aa98..9acc08bf 100644 --- a/examples/src/web-components/RadialWheelDemo.tsx +++ b/examples/src/web-components/RadialWheelDemo.tsx @@ -89,6 +89,16 @@ const RadialWheelDemo: React.FC = () => { + +

    + With more options than per-page (here 8), the wheel + pages through groups. Use the centred dot indicator below the + disc, or the / arrow keys, to switch + pages. The hub shows the current page when nothing is hovered. +

    + +
    +
    {`tc-radial-wheel {
       --bs-radial-wheel-backdrop-opacity: 0.7;
    @@ -155,4 +165,64 @@ const SmallWheelExample: React.FC = () => {
         )
     }
     
    +/** Pagination demo — 14 options paged 8-at-a-time across two wheels. */
    +const PaginatedWheelExample: React.FC = () => {
    +    const ref = useRef(null)
    +    const [open, setOpen] = useState(false)
    +    const [last, setLast] = useState(null)
    +
    +    useEffect(() => {
    +        if (!ref.current) return
    +        ref.current.options = [
    +            { id: 'sword',   icon: '⚔',  label: 'Sword' },
    +            { id: 'axe',     icon: '🪓', label: 'Axe' },
    +            { id: 'bow',     icon: '🏹', label: 'Bow' },
    +            { id: 'staff',   icon: '✦',  label: 'Staff' },
    +            { id: 'shield',  icon: '🛡', label: 'Shield' },
    +            { id: 'potion',  icon: '⚕',  label: 'Potion' },
    +            { id: 'bomb',    icon: '💣', label: 'Bomb' },
    +            { id: 'key',     icon: '🗝', label: 'Key' },
    +            { id: 'map',     icon: '🗺', label: 'Map' },
    +            { id: 'torch',   icon: '🔦', label: 'Torch' },
    +            { id: 'rope',    icon: '➰', label: 'Rope' },
    +            { id: 'scroll',  icon: '📜', label: 'Scroll' },
    +            { id: 'gem',     icon: '💎', label: 'Gem' },
    +            { id: 'ring',    icon: '💍', label: 'Ring' },
    +        ]
    +    }, [])
    +
    +    useEffect(() => {
    +        if (!ref.current) return
    +        if (open) ref.current.setAttribute('open', '')
    +        else ref.current.removeAttribute('open')
    +    }, [open])
    +
    +    useEffect(() => {
    +        const el = ref.current
    +        if (!el) return
    +        const onSelect = (e: CustomEvent) => {
    +            setLast(e.detail.id)
    +            setOpen(false)
    +        }
    +        const onClose = () => setOpen(false)
    +        el.addEventListener('tc-select', onSelect)
    +        el.addEventListener('tc-close', onClose)
    +        return () => {
    +            el.removeEventListener('tc-select', onSelect)
    +            el.removeEventListener('tc-close', onClose)
    +        }
    +    }, [])
    +
    +    return (
    +        <>
    +            
    +            {last && Selected: {last}}
    +            {/* @ts-ignore */}
    +            
    +        
    +    )
    +}
    +
     export default RadialWheelDemo
    diff --git a/examples/src/web-components/ReportPlayerDialogDemo.tsx b/examples/src/web-components/ReportDialogDemo.tsx
    similarity index 95%
    rename from examples/src/web-components/ReportPlayerDialogDemo.tsx
    rename to examples/src/web-components/ReportDialogDemo.tsx
    index 2ca06710..aa84fcea 100644
    --- a/examples/src/web-components/ReportPlayerDialogDemo.tsx
    +++ b/examples/src/web-components/ReportDialogDemo.tsx
    @@ -8,7 +8,7 @@ const CUSTOM_REASONS = [
         'Abusive voice chat',
     ]
     
    -const ReportPlayerDialogDemo: React.FC = () => {
    +const ReportDialogDemo: React.FC = () => {
         const basicRef = useRef(null)
         const customRef = useRef(null)
         const eventsRef = useRef(null)
    @@ -94,7 +94,7 @@ const ReportPlayerDialogDemo: React.FC = () => {
                         
    Web Components} - title="ReportPlayerDialog" + title="ReportDialog" description="Player-report moderation modal with a reason radio group, optional comment textarea, and Cancel / Submit Report actions. Controlled — fires tc-cancel or tc-submit; the consumer sets open to false to dismiss." /> @@ -107,7 +107,7 @@ const ReportPlayerDialogDemo: React.FC = () => { Report ShadowStriker99 {/* @ts-ignore */} - @@ -121,7 +121,7 @@ const ReportPlayerDialogDemo: React.FC = () => { Report NightRaider {/* @ts-ignore */} - @@ -135,7 +135,7 @@ const ReportPlayerDialogDemo: React.FC = () => { Open event demo {/* @ts-ignore */} - @@ -160,4 +160,4 @@ const ReportPlayerDialogDemo: React.FC = () => { ) } -export default ReportPlayerDialogDemo +export default ReportDialogDemo diff --git a/examples/src/web-components/SponsorWallDemo.tsx b/examples/src/web-components/SponsorWallDemo.tsx index 95b048e1..0b45fa7a 100644 --- a/examples/src/web-components/SponsorWallDemo.tsx +++ b/examples/src/web-components/SponsorWallDemo.tsx @@ -1,14 +1,17 @@ import React, { useEffect, useRef } from 'react' import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' +// Logo placeholders via placehold.co (slate-200 bg / slate-500 ink), sized to +// each tier's logo box. Same source used in CardDemo; via.placeholder.com is +// defunct (DNS dead), which is why these were previously broken. const TIERS_FULL = [ { name: 'Platinum', label: 'Platinum', size: 'xl', logos: [ - { src: 'https://via.placeholder.com/160x64/e2e8f0/64748b?text=Acme+Corp', alt: 'Acme Corp', href: '#' }, - { src: 'https://via.placeholder.com/160x64/e2e8f0/64748b?text=Globex', alt: 'Globex', href: '#' }, + { src: 'https://placehold.co/160x64/e2e8f0/64748b?text=Acme+Corp', alt: 'Acme Corp', href: '#' }, + { src: 'https://placehold.co/160x64/e2e8f0/64748b?text=Globex', alt: 'Globex', href: '#' }, ], }, { @@ -16,9 +19,9 @@ const TIERS_FULL = [ label: 'Gold', size: 'lg', logos: [ - { src: 'https://via.placeholder.com/120x48/e2e8f0/64748b?text=Initech', alt: 'Initech', href: '#' }, - { src: 'https://via.placeholder.com/120x48/e2e8f0/64748b?text=Umbrella', alt: 'Umbrella', href: '#' }, - { src: 'https://via.placeholder.com/120x48/e2e8f0/64748b?text=Stark+Ind', alt: 'Stark Industries', href: '#' }, + { src: 'https://placehold.co/120x48/e2e8f0/64748b?text=Initech', alt: 'Initech', href: '#' }, + { src: 'https://placehold.co/120x48/e2e8f0/64748b?text=Umbrella', alt: 'Umbrella', href: '#' }, + { src: 'https://placehold.co/120x48/e2e8f0/64748b?text=Stark+Ind', alt: 'Stark Industries', href: '#' }, ], }, { @@ -26,10 +29,10 @@ const TIERS_FULL = [ label: 'Silver', size: 'md', logos: [ - { src: 'https://via.placeholder.com/96x38/e2e8f0/64748b?text=Hooli', alt: 'Hooli', href: '#' }, - { src: 'https://via.placeholder.com/96x38/e2e8f0/64748b?text=Pied+Piper', alt: 'Pied Piper' }, - { src: 'https://via.placeholder.com/96x38/e2e8f0/64748b?text=Dunder+Mifflin', alt: 'Dunder Mifflin', href: '#' }, - { src: 'https://via.placeholder.com/96x38/e2e8f0/64748b?text=Vandelay', alt: 'Vandelay Industries' }, + { src: 'https://placehold.co/96x38/e2e8f0/64748b?text=Hooli', alt: 'Hooli', href: '#' }, + { src: 'https://placehold.co/96x38/e2e8f0/64748b?text=Pied+Piper', alt: 'Pied Piper' }, + { src: 'https://placehold.co/96x38/e2e8f0/64748b?text=Dunder+Mifflin', alt: 'Dunder Mifflin', href: '#' }, + { src: 'https://placehold.co/96x38/e2e8f0/64748b?text=Vandelay', alt: 'Vandelay Industries' }, ], }, { @@ -37,11 +40,11 @@ const TIERS_FULL = [ label: 'Community', size: 'sm', logos: [ - { src: 'https://via.placeholder.com/72x28/e2e8f0/64748b?text=Lo+Fidelity', alt: 'Lo Fidelity' }, - { src: 'https://via.placeholder.com/72x28/e2e8f0/64748b?text=Bluth+Co', alt: "Bluth's Original", href: '#' }, - { src: 'https://via.placeholder.com/72x28/e2e8f0/64748b?text=Planet+Expr', alt: 'Planet Express' }, - { src: 'https://via.placeholder.com/72x28/e2e8f0/64748b?text=Soylent', alt: 'Soylent Corp', href: '#' }, - { src: 'https://via.placeholder.com/72x28/e2e8f0/64748b?text=Aperture', alt: 'Aperture Science', href: '#' }, + { src: 'https://placehold.co/72x28/e2e8f0/64748b?text=Lo+Fidelity', alt: 'Lo Fidelity' }, + { src: 'https://placehold.co/72x28/e2e8f0/64748b?text=Bluth+Co', alt: "Bluth's Original", href: '#' }, + { src: 'https://placehold.co/72x28/e2e8f0/64748b?text=Planet+Expr', alt: 'Planet Express' }, + { src: 'https://placehold.co/72x28/e2e8f0/64748b?text=Soylent', alt: 'Soylent Corp', href: '#' }, + { src: 'https://placehold.co/72x28/e2e8f0/64748b?text=Aperture', alt: 'Aperture Science', href: '#' }, ], }, ] @@ -51,9 +54,9 @@ const TIERS_SINGLE = [ name: 'Partners', size: 'lg', logos: [ - { src: 'https://via.placeholder.com/120x48/e2e8f0/64748b?text=Acme', alt: 'Acme', href: '#' }, - { src: 'https://via.placeholder.com/120x48/e2e8f0/64748b?text=Globex', alt: 'Globex' }, - { src: 'https://via.placeholder.com/120x48/e2e8f0/64748b?text=Initech', alt: 'Initech', href: '#' }, + { src: 'https://placehold.co/120x48/e2e8f0/64748b?text=Acme', alt: 'Acme', href: '#' }, + { src: 'https://placehold.co/120x48/e2e8f0/64748b?text=Globex', alt: 'Globex' }, + { src: 'https://placehold.co/120x48/e2e8f0/64748b?text=Initech', alt: 'Initech', href: '#' }, ], }, ] diff --git a/examples/src/web-components/StampDemo.tsx b/examples/src/web-components/StampDemo.tsx index b7ce8486..8721a99c 100644 --- a/examples/src/web-components/StampDemo.tsx +++ b/examples/src/web-components/StampDemo.tsx @@ -17,7 +17,7 @@ const StampDemo: React.FC = () => ( Web Components} title="Stamp" - description="Decorative stamp badge pinned to a corner of a relatively-positioned card. Supports six status colors and four corner positions." + description="Approval-stamp badge pinned to a corner of a relatively-positioned card, tilted a few degrees so it reads as pressed-on-paper rather than a flat badge. Supports six status colors, four corner positions, and a themeable rotation angle." />
    @@ -81,6 +81,31 @@ const StampDemo: React.FC = () => (
    + +
    +
    + default (-8deg) + {/* @ts-ignore */} + +
    +
    + angle="-14" + {/* @ts-ignore */} + +
    +
    + angle="6deg" + {/* @ts-ignore */} + +
    +
    + inline --bs-stamp-rotation: 0 + {/* @ts-ignore */} + +
    +
    +
    +
    diff --git a/examples/src/web-components/WelcomeGuideDemo.tsx b/examples/src/web-components/WelcomeGuideDemo.tsx index d3552ad6..dc0a6d8f 100644 --- a/examples/src/web-components/WelcomeGuideDemo.tsx +++ b/examples/src/web-components/WelcomeGuideDemo.tsx @@ -1,67 +1,73 @@ import React, { useEffect, useRef, useState } from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -const INITIAL_STEPS = [ - { key: 'account', label: 'Create your account', completed: true }, - { key: 'profile', label: 'Set up your profile', completed: true }, - { key: 'team', label: 'Invite your team', completed: false }, - { key: 'project', label: 'Create your first project', completed: false }, - { key: 'deploy', label: 'Deploy to production', completed: false }, +import { Button, RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +type Step = { key: string; label: string; completed: boolean } + +const INITIAL_STEPS: Step[] = [ + { key: 'create-project', label: 'Create your first project', completed: true }, + { key: 'upload-asset', label: 'Upload an asset', completed: true }, + { key: 'configure-settings', label: 'Configure game settings', completed: false }, + { key: 'invite-member', label: 'Invite a team member', completed: false }, + { key: 'publish-build', label: 'Publish a build', completed: false }, ] -const ALL_DONE_STEPS = [ +const MESSAGES = [ + 'Welcome to Webgame Cloud! Follow the steps on the right to get up and running.', + 'Each step will guide you through a core feature of the platform.', + 'You can revisit this guide anytime from your dashboard.', +] + +const ALL_DONE_STEPS: Step[] = [ { key: 'a', label: 'Account created', completed: true }, { key: 'b', label: 'Profile complete', completed: true }, { key: 'c', label: 'Team invited', completed: true }, ] -const MESSAGES = [ - 'Welcome to Toolcase! Follow these steps to get started.', - 'Each step unlocks the next. Click the active step when done.', -] +// Inline SVG dot-pattern as a data URI — rides the dark hero as a decorative +// texture (screen-blended, low opacity), mirroring the react demo's DotPattern. +const DOT_PATTERN_SRC = + 'data:image/svg+xml;utf8,' + + encodeURIComponent( + `` + ) const WelcomeGuideDemo: React.FC = () => { - const basicRef = useRef(null) - const interactiveRef = useRef(null) + const withPatternRef = useRef(null) + const withoutPatternRef = useRef(null) const allDoneRef = useRef(null) const [steps, setSteps] = useState(INITIAL_STEPS) - const [lastClicked, setLastClicked] = useState(null) - - useEffect(() => { - if (!basicRef.current) return - const el = basicRef.current - el.messages = MESSAGES - el.steps = INITIAL_STEPS - }, []) + const [showPattern, setShowPattern] = useState(true) - useEffect(() => { - if (!allDoneRef.current) return - const el = allDoneRef.current - el.messages = ['You have completed all onboarding steps. Welcome aboard!'] - el.steps = ALL_DONE_STEPS - }, []) + const toggleStep = (index: number) => { + setSteps(prev => prev.map((s, i) => (i === index ? { ...s, completed: !s.completed } : s))) + } + // Seed messages once and advance the active step when the user clicks it. useEffect(() => { - if (!interactiveRef.current) return - const el = interactiveRef.current - el.messages = ['Click the highlighted step to mark it complete.'] - el.steps = steps - - const handler = (e: CustomEvent<{ key: string }>) => { - const key = e.detail.key - setLastClicked(key) - setSteps(prev => prev.map(s => s.key === key ? { ...s, completed: true } : s)) + const handler = (e: Event) => { + const key = (e as CustomEvent<{ key: string }>).detail.key + setSteps(prev => prev.map(s => (s.key === key ? { ...s, completed: true } : s))) } - el.addEventListener('tc-step-click', handler) - return () => el.removeEventListener('tc-step-click', handler) + const els = [withPatternRef.current, withoutPatternRef.current].filter(Boolean) + els.forEach(el => { + el.messages = MESSAGES + el.addEventListener('tc-step-click', handler) + }) + return () => els.forEach(el => el.removeEventListener('tc-step-click', handler)) }, []) + // Keep both pattern demos in sync with the toggleable step state. useEffect(() => { - if (interactiveRef.current) { - interactiveRef.current.steps = steps - } + if (withPatternRef.current) withPatternRef.current.steps = steps + if (withoutPatternRef.current) withoutPatternRef.current.steps = steps }, [steps]) + useEffect(() => { + if (!allDoneRef.current) return + allDoneRef.current.messages = ['You have completed all onboarding steps. Welcome aboard!'] + allDoneRef.current.steps = ALL_DONE_STEPS + }, []) + return (
    @@ -70,28 +76,47 @@ const WelcomeGuideDemo: React.FC = () => { Web Components} title="WelcomeGuide" - description="Onboarding guide with progress tracking and sequential step completion. Steps are driven by the JS steps property; the active step (first not-completed) is auto-derived." + description="An onboarding card with a dark gradient hero (title + messages) on the left and a step-progress checklist on the right. The active step (first not-completed) is auto-derived; clicking it fires tc-step-click." />
    - - {/* @ts-ignore */} - - - - + {/* @ts-ignore */} - {lastClicked && ( -

    - Last completed step: {lastClicked} -

    - )} +
    + + + {/* @ts-ignore */} + + + + +
    + {steps.map((step, i) => ( + + ))} + +
    diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 84ce538f..ad440e56 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -210,7 +210,7 @@ import DatePickerDemo from './DatePickerDemo' import DiffViewerDemo from './DiffViewerDemo' import DrawerDemo from './DrawerDemo' import ConfirmDialogDemo from './ConfirmDialogDemo' -import ReportPlayerDialogDemo from './ReportPlayerDialogDemo' +import ReportDialogDemo from './ReportDialogDemo' import InviteToastDemo from './InviteToastDemo' import LightboxDemo from './LightboxDemo' import CommandPaletteDemo from './CommandPaletteDemo' @@ -577,7 +577,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'blur-overlay', category: 'Overlays & Feedback', element: }, { key: 'drawer', category: 'Overlays & Feedback', element: }, { key: 'confirm-dialog', category: 'Overlays & Feedback', element: }, - { key: 'report-player-dialog', category: 'Overlays & Feedback', element: }, + { key: 'report-dialog', category: 'Overlays & Feedback', element: }, { key: 'screen-flash', category: 'Overlays & Feedback', element: }, { key: 'transition-wipe', category: 'Overlays & Feedback', element: }, { key: 'vignette-overlay', category: 'Overlays & Feedback', element: }, diff --git a/web-components/src/AdvancedTable.ts b/web-components/src/AdvancedTable.ts index 4e886113..29074540 100644 --- a/web-components/src/AdvancedTable.ts +++ b/web-components/src/AdvancedTable.ts @@ -1,5 +1,5 @@ import { icon } from './icons' -import { ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight } from 'lucide-static' +import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-static' const TAG_NAME = 'tc-advanced-table' @@ -9,10 +9,6 @@ const chevronUpIcon = icon(ChevronUp, 'tc-advanced-table-sort-icon') const chevronDownIcon = icon(ChevronDown, 'tc-advanced-table-sort-icon') const chevronsUpDownIcon = icon(ChevronsUpDown, 'tc-advanced-table-sort-icon') -// Pagination prev/next arrows. -const prevArrowIcon = icon(ChevronLeft, 'tc-advanced-table-page-icon') -const nextArrowIcon = icon(ChevronRight, 'tc-advanced-table-page-icon') - export type AdvancedTableAlign = 'left' | 'center' | 'right' const ALIGNS: AdvancedTableAlign[] = ['left', 'center', 'right'] @@ -84,12 +80,17 @@ export class AdvancedTable extends HTMLElement { this.addEventListener('input', this._onInput) this.addEventListener('change', this._onChange) this.addEventListener('click', this._onClick) + // The footer pager is a canonical that emits its own + // tc-page-change ({ page }). Intercept it before it bubbles to consumers + // and translate the 1-based page into this table's offset model. + this.addEventListener('tc-page-change', this._onPaginationPage) } disconnectedCallback(): void { this.removeEventListener('input', this._onInput) this.removeEventListener('change', this._onChange) this.removeEventListener('click', this._onClick) + this.removeEventListener('tc-page-change', this._onPaginationPage) } attributeChangedCallback(): void { @@ -192,14 +193,19 @@ export class AdvancedTable extends HTMLElement { if (sortBtn && this.contains(sortBtn)) { if (this.loading || sortBtn.disabled) return this._cycleSort(sortBtn.dataset.sortKey ?? '') - return } + } - const pageBtn = el.closest('[data-page-dir]') - if (pageBtn && this.contains(pageBtn)) { - if (this.loading || pageBtn.disabled) return - this._page(pageBtn.dataset.pageDir === 'prev' ? 'prev' : 'next') - } + // The nested tc-pagination dispatches tc-page-change with a 1-based { page }. + // Swallow it (so consumers only ever see this table's own offset-based event) + // and forward as an offset jump. + private _onPaginationPage = (e: Event): void => { + const target = e.target as HTMLElement + if (!(target instanceof HTMLElement) || target.tagName.toLowerCase() !== 'tc-pagination') return + e.stopPropagation() + const page = (e as CustomEvent).detail?.page + if (typeof page !== 'number') return + this._goToPage(page) } // ── Behaviour ───────────────────────────────────────────────────────────── @@ -234,14 +240,14 @@ export class AdvancedTable extends HTMLElement { if (typeof this.onSortChange === 'function') this.onSortChange(next) } - private _page(dir: 'prev' | 'next'): void { + private _goToPage(page: number): void { + if (this.loading) return const limit = this.limit - const offset = this.offset const total = this.total - let next = dir === 'prev' ? offset - limit : offset + limit - next = Math.max(0, next) // clamp lower bound - if (next >= total) return // out of [0, total) — at a boundary - if (next === offset) return + const pageCount = Math.max(1, Math.ceil(total / limit)) + const clamped = Math.min(Math.max(1, page), pageCount) + const next = (clamped - 1) * limit + if (next === this.offset) return this.setAttribute('offset', String(next)) // → attributeChangedCallback → render this.dispatchEvent(new CustomEvent('tc-page-change', { @@ -357,21 +363,21 @@ export class AdvancedTable extends HTMLElement { if (total <= 0) return '' const limit = this.limit const offset = this.offset - const loading = this.loading const start = Math.min(offset + 1, total) const end = Math.min(offset + limit, total) - const prevDisabled = offset <= 0 || loading - const nextDisabled = offset + limit >= total || loading + const pageCount = Math.max(1, Math.ceil(total / limit)) + const current = Math.min(Math.floor(offset / limit) + 1, pageCount) const summary = `${start}–${end} of ${total}` - const prev = `` - const next = `` - - return `
    ${summary}` + - `
    ${prev}${next}
    ` + // Reuse the canonical pager — its joined border / mono numerals / ink active + // page are the shared pagination motif; we only feed it total + current and + // listen for its tc-page-change (handled in _onPaginationPage). + const pager = pageCount > 1 + ? `` + : '' + + return `
    ${summary}${pager}
    ` } private render(): void { diff --git a/web-components/src/CoolButton.ts b/web-components/src/CoolButton.ts index ac89ba1c..86bae3fd 100644 --- a/web-components/src/CoolButton.ts +++ b/web-components/src/CoolButton.ts @@ -55,16 +55,20 @@ export class CoolButton extends HTMLElement { // Re-capture slot nodes from their current DOM containers before re-render. if (!this.hasAttribute('label')) { const contentEl = this.querySelector('.tc-cool-button-content') - if (contentEl) this._mainNodes = Array.from(contentEl.childNodes) + if (contentEl) this._mainNodes = Array.from(contentEl.childNodes).filter(n => !this._isSpinner(n)) } if (!this.hasAttribute('addon')) { const addonEl = this.querySelector('.tc-cool-button-addon') - if (addonEl) this._addonNodes = Array.from(addonEl.childNodes) + if (addonEl) this._addonNodes = Array.from(addonEl.childNodes).filter(n => !this._isSpinner(n)) } this.render() this._distributeSlots() } + private _isSpinner(n: Node): boolean { + return n instanceof Element && n.classList.contains('tc-cool-button-spinner') + } + private _handleClick = () => { const btn = this.querySelector('button') if (btn && btn.disabled) return @@ -147,38 +151,45 @@ export class CoolButton extends HTMLElement { const variantClass = outline ? `btn-outline-${variant}` : `btn-${variant}` const sizeClassMap: Record = { small: 'btn-sm', default: '', large: 'btn-lg' } const sizeClass = sizeClassMap[size] ? ` ${sizeClassMap[size]}` : '' + const loadingClass = loading ? ' tc-cool-button--loading' : '' - // Spinner lives before the content span so .tc-cool-button-content.childNodes - // are always purely user-provided children (never contaminated by spinner). - const spinnerHtml = loading - ? `Loading…` - : '' + const hasAddon = this.hasAttribute('addon') || this._addonNodes.length > 0 + + // When loading, the spinner is centred over the content region (or over the + // addon when the addon is the leading slot). The label/glyph it covers is + // hidden in place (kept in flow) so the button keeps its resting width — no + // collapse, no jump. The spinner ring may use border-radius (sanctioned circle). + const spinnerHtml = `` + const liveRegion = loading ? `Loading…` : '' // Label attribute text is written directly into the content span; // when absent the span is empty and slot children are appended after render. const labelText = this.hasAttribute('label') ? esc(this.label ?? '') : '' - const contentSpan = `${labelText}` + const contentSpan = `${labelText}${loading && !(hasAddon && addonPosition === 'left') ? spinnerHtml : ''}` - const hasAddon = this.hasAttribute('addon') || this._addonNodes.length > 0 const addonText = this.hasAttribute('addon') ? esc(this.addon ?? '') : '' - const addonSpan = `${addonText}` + // While loading, the spinner replaces the leading addon glyph (sits where + // the icon was); a trailing addon keeps its glyph and the spinner overlays + // the label instead. + const addonSpinner = loading && addonPosition === 'left' ? spinnerHtml : '' + const addonSpan = `${addonText}${addonSpinner}` const dividerSpan = `` let innerHtml: string if (hasAddon) { if (addonPosition === 'left') { - // addon | divider | [spinner] content - innerHtml = `${addonSpan}${dividerSpan}${spinnerHtml}${contentSpan}` + // addon (spinner overlays glyph) | divider | content + innerHtml = `${addonSpan}${dividerSpan}${contentSpan}` } else { - // [spinner] content | divider | addon - innerHtml = `${spinnerHtml}${contentSpan}${dividerSpan}${addonSpan}` + // content (spinner overlays label) | divider | addon + innerHtml = `${contentSpan}${dividerSpan}${addonSpan}` } } else { - innerHtml = `${spinnerHtml}${contentSpan}` + innerHtml = contentSpan } const disabledAttr = isDisabled ? ' disabled' : '' - this.innerHTML = `` + this.innerHTML = `` } } diff --git a/web-components/src/EarlySignupForm.ts b/web-components/src/EarlySignupForm.ts index dc49ce3b..5afa6eb3 100644 --- a/web-components/src/EarlySignupForm.ts +++ b/web-components/src/EarlySignupForm.ts @@ -29,6 +29,7 @@ function lucideByName(name: string): string { // Pre-compute icons once at module load time const checkIconHtml = lucideByName('check') const checkCircleIconHtml = lucideByName('check-circle') +const arrowRightIconHtml = lucideByName('arrow-right') export class EarlySignupForm extends HTMLElement { private _initialised = false @@ -51,7 +52,7 @@ export class EarlySignupForm extends HTMLElement { // NOTE: 'title' is listed so attributeChangedCallback fires on changes. // No getter/setter is defined — HTMLElement already reflects title natively. 'title', - 'subtitle', 'eyebrow', 'helper-text', 'cta-label', + 'subtitle', 'eyebrow', 'helper-text', 'cta-label', 'field-label', 'stat', 'placeholder', 'success-title', 'success-message', 'variant', 'loading', ] } @@ -115,6 +116,23 @@ export class EarlySignupForm extends HTMLElement { this.setAttribute('cta-label', v) } + /** Optional mono micro-label rendered above the email field. */ + get fieldLabel(): string { + return this.getAttribute('field-label') ?? 'Email address' + } + set fieldLabel(v: string) { + this.setAttribute('field-label', v) + } + + /** Optional social-proof micro-stat, e.g. "2,400+ developers already joined". */ + get stat(): string | null { + return this.getAttribute('stat') + } + set stat(v: string | null) { + if (v != null) this.setAttribute('stat', v) + else this.removeAttribute('stat') + } + get placeholder(): string { return this.getAttribute('placeholder') ?? 'you@email.com' } @@ -235,6 +253,8 @@ export class EarlySignupForm extends HTMLElement { const eyebrow = this.eyebrow const helperText = this.helperText const ctaLabel = this.ctaLabel + const fieldLabel = this.fieldLabel + const stat = this.stat const placeholder = this.placeholder const successTitle = this.successTitle const successMessage = this.successMessage @@ -242,20 +262,28 @@ export class EarlySignupForm extends HTMLElement { const loading = this.loading const inputId = `${this._idPrefix}-email` const errorId = `${this._idPrefix}-error` + const labelId = `${this._idPrefix}-label` // Manage host classes without wiping user-added ones. this.classList.add('tc-early-signup-form') VARIANTS.forEach(v => this.classList.remove(`tc-early-signup-form--${v}`)) this.classList.add(`tc-early-signup-form--${variant}`) + this.classList.toggle('tc-early-signup-form--submitted', this._submitted) + // Eyebrow — a 2px cyan brand-dot + mono micro-label. The dot is the one + // sanctioned spend of the accent in this component. const eyebrowHtml = eyebrow - ? `` + ? `` : '' const subtitleHtml = subtitle ? `` : '' + // Benefits — hairline-separated check rows. const benefitsHtml = this._benefits.length > 0 ? `` : '' + // Social-proof micro-stat — mono, faint, hairline-topped footer of the + // intro zone. + const statHtml = stat + ? `` + : '' + let formAreaHtml: string if (this._submitted) { const msg = successMessage @@ -284,30 +321,37 @@ export class EarlySignupForm extends HTMLElement { const disabledAttr = loading ? ' disabled' : '' const submitContent = loading ? `Loading…` - : esc(ctaLabel) + : `` formAreaHtml = ` - - + + - - ${helperText ? `` : ''} + ${helperText ? `` : ''} ` } @@ -318,6 +362,7 @@ export class EarlySignupForm extends HTMLElement { ${subtitleHtml} ${benefitsHtml} + ${statHtml}
    ` c }) // Or use the callback property: - document.getElementById('board').onselect = entry => console.log('Selected:', entry.name) + document.getElementById('board').onSelect = entry => console.log('Selected:', entry.name) ``` @@ -14092,7 +14092,7 @@ Section card listing OAuth providers with custom icons and brand colors. A fixed | `providers` | `LinkedProvider[]` | `[]` | Array of provider objects. Setting it re-renders the list. | | `brandColors` | `Record` | `{}` | Maps a provider key to a CSS color string applied to that provider's icon tile. Only the tile is tinted — all other UI stays slate. | | `iconForProvider` | `((key: string) => string) or null` | `null` | Optional function returning a lucide icon name (PascalCase) for a given provider key. Falls back to provider.icon, then to 'Link'. | -| `ontoggle` | `((key: string, connected: boolean) => void) or null` | `null` | Optional callback fired alongside the `tc-toggle` custom event. | +| `onToggle` | `((key: string, connected: boolean) => void) or null` | `null` | Optional callback fired alongside the `tc-toggle` custom event. | `LinkedProvider` shape: @@ -14855,7 +14855,7 @@ Kanban or stacked roadmap board with status columns (`shipped` / `in-progress` / | `columns` | `RoadmapColumn[]` | Array of status columns. Setting re-renders the whole board. Default `[]`. | | `layout` | `'kanban' \| 'stacked'` | Reflects the `layout` attribute. | | `titleText` | `string \| null` | Reflects the `title-text` attribute. | -| `onselect` | `((detail: { columnStatus, item }) => void) \| null` | Optional callback fired alongside the `tc-select` event. Default `null`. | +| `onSelect` | `((detail: { columnStatus, item }) => void) \| null` | Optional callback fired alongside the `tc-select` event. Default `null`. | **`RoadmapColumn` shape** diff --git a/package-lock.json b/package-lock.json index d5114862..dd4d078a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2851,6 +2851,26 @@ "node": ">=6.0.0" } }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, "node_modules/bootstrap-icons": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz", @@ -6286,6 +6306,9 @@ "dependencies": { "dropzone": "^6.0.0-beta.2" }, + "devDependencies": { + "bootstrap": "^5.3.3" + }, "engines": { "node": ">=18" }, diff --git a/react-components/package.json b/react-components/package.json index 0b5df325..ab7b50a1 100644 --- a/react-components/package.json +++ b/react-components/package.json @@ -29,6 +29,9 @@ "dependencies": { "dropzone": "^6.0.0-beta.2" }, + "devDependencies": { + "bootstrap": "^5.3.3" + }, "author": { "name": "Daniel Kalevski", "url": "https://kalevski.dev" diff --git a/web-components/src/ActivityCard.ts b/web-components/src/ActivityCard.ts index b1ca2451..51b0a5cd 100644 --- a/web-components/src/ActivityCard.ts +++ b/web-components/src/ActivityCard.ts @@ -41,8 +41,8 @@ export class ActivityCard extends HTMLElement { this.render() } - get title(): string | null { - return this.getAttribute('title') + get title(): string { + return this.getAttribute('title') ?? '' } set title(v: string | null) { if (v != null) this.setAttribute('title', v) diff --git a/web-components/src/ApiReferenceTable.ts b/web-components/src/ApiReferenceTable.ts index a48abeb5..64d7b6fa 100644 --- a/web-components/src/ApiReferenceTable.ts +++ b/web-components/src/ApiReferenceTable.ts @@ -81,8 +81,8 @@ export class ApiReferenceTable extends HTMLElement { if (this._initialised) this._rerenderWithSlots() } - get title(): string | null { - return this.getAttribute('title') + get title(): string { + return this.getAttribute('title') ?? '' } set title(v: string | null) { if (v != null) this.setAttribute('title', v) diff --git a/web-components/src/CommandReference.ts b/web-components/src/CommandReference.ts index 69dc8989..0130a58d 100644 --- a/web-components/src/CommandReference.ts +++ b/web-components/src/CommandReference.ts @@ -101,8 +101,8 @@ export class CommandReference extends HTMLElement { this.setAttribute('search-placeholder', v) } - get title(): string | null { - return this.getAttribute('title') + get title(): string { + return this.getAttribute('title') ?? '' } set title(v: string | null) { if (v != null) this.setAttribute('title', v) diff --git a/web-components/src/Comparator.ts b/web-components/src/Comparator.ts index f8e66a79..7ffe7106 100644 --- a/web-components/src/Comparator.ts +++ b/web-components/src/Comparator.ts @@ -115,8 +115,8 @@ export class Comparator extends HTMLElement { this.render() } - get title(): string | null { - return this.getAttribute('title') + get title(): string { + return this.getAttribute('title') ?? '' } set title(v: string | null) { if (v != null) this.setAttribute('title', v) diff --git a/web-components/src/CompatibilityMatrix.ts b/web-components/src/CompatibilityMatrix.ts index b389fa09..9ed43266 100644 --- a/web-components/src/CompatibilityMatrix.ts +++ b/web-components/src/CompatibilityMatrix.ts @@ -85,8 +85,8 @@ export class CompatibilityMatrix extends HTMLElement { this._rerenderWithSlots() } - get title(): string | null { - return this.getAttribute('title') + get title(): string { + return this.getAttribute('title') ?? '' } set title(v: string | null) { if (v != null) this.setAttribute('title', v) diff --git a/web-components/src/DiffViewer.ts b/web-components/src/DiffViewer.ts index bb611aa9..338897ed 100644 --- a/web-components/src/DiffViewer.ts +++ b/web-components/src/DiffViewer.ts @@ -84,17 +84,23 @@ export class DiffViewer extends HTMLElement { this.render() } + // `before`/`after` shadow the inherited ChildNode methods of the same name; + // the accessor pairs are the public tc-diff-viewer API (documented + demoed). + // @ts-expect-error TS2416/TS2423 — string accessor over an inherited method get before(): string { return this._before } + // @ts-expect-error TS2416 — see getter above set before(v: string) { this._before = typeof v === 'string' ? v : '' if (this._initialised) this.render() } + // @ts-expect-error TS2416/TS2423 — string accessor over an inherited method get after(): string { return this._after } + // @ts-expect-error TS2416 — see getter above set after(v: string) { this._after = typeof v === 'string' ? v : '' if (this._initialised) this.render() diff --git a/web-components/src/ExtendedSelect.ts b/web-components/src/ExtendedSelect.ts index 251fc76a..4e9e7310 100644 --- a/web-components/src/ExtendedSelect.ts +++ b/web-components/src/ExtendedSelect.ts @@ -224,7 +224,7 @@ export class ExtendedSelect extends HTMLElement { const searchInput = this.querySelector('.tc-extended-select__search-input') if (searchInput) searchInput.addEventListener('input', this._onSearchInput) - const list = this.querySelector('.tc-extended-select__list') + const list = this.querySelector('.tc-extended-select__list') if (list) { list.addEventListener('click', this._onListClick) list.addEventListener('mouseover', this._onListMouseOver) diff --git a/web-components/src/FileTags.ts b/web-components/src/FileTags.ts index 1d3a2604..fc9c74ff 100644 --- a/web-components/src/FileTags.ts +++ b/web-components/src/FileTags.ts @@ -1,5 +1,7 @@ import { Plus, X } from 'lucide-static' import { icon } from './icons' +// Re-uses the canonical FileTag shape owned by tc-file so both file ports agree. +import type { FileTag } from './File' const TAG_NAME = 'tc-file-tags' @@ -15,12 +17,6 @@ function esc(s: string): string { const plusIconHtml = icon(Plus) const xIconHtml = icon(X) -export interface FileTag { - id: string - label: string - color?: string -} - export class FileTags extends HTMLElement { private _initialised = false private _tags: FileTag[] = [] diff --git a/web-components/src/Hotbar.ts b/web-components/src/Hotbar.ts index b00242c3..c1d53baf 100644 --- a/web-components/src/Hotbar.ts +++ b/web-components/src/Hotbar.ts @@ -1,16 +1,10 @@ -const TAG_NAME = 'tc-hotbar' +// Re-uses the canonical InventoryItem owned by tc-item-slot so the same data +// drives both ports. The hotbar fallback only renders id/name/icon/qty (rarity/ +// cooldown/lock are fantasy chrome owned by tc-item-slot when that primitive is +// present); the extra optional fields are simply ignored here. +import type { InventoryItem } from './ItemSlot' -// An item/ability occupying a hotbar slot. Mirrors the game-components -// InventoryItem shape so the same data drives the composed tc-item-slot; the -// design-system port only renders id/name/icon/qty for its standalone fallback -// (rarity/cooldown/lock are fantasy chrome owned by tc-item-slot when present). -export interface InventoryItem { - id: string - name?: string - icon?: string - qty?: number - [key: string]: unknown -} +const TAG_NAME = 'tc-hotbar' export interface HotbarSlot { item?: InventoryItem | null diff --git a/web-components/src/InventoryGrid.ts b/web-components/src/InventoryGrid.ts index f023fe31..74c0fefa 100644 --- a/web-components/src/InventoryGrid.ts +++ b/web-components/src/InventoryGrid.ts @@ -1,16 +1,10 @@ -const TAG_NAME = 'tc-inventory-grid' +// Re-uses the canonical InventoryItem owned by tc-item-slot so the same data +// drives both ports. The grid fallback only renders id/name/icon/qty (rarity/ +// cooldown/lock are fantasy chrome owned by tc-item-slot when that primitive is +// present); the extra optional fields are simply ignored here. +import type { InventoryItem } from './ItemSlot' -// An item occupying an inventory cell. Mirrors the game-components InventoryItem -// shape so the same data drives the composed tc-item-slot; the design-system -// port only renders id/name/icon/qty for its standalone fallback (rarity/cooldown/ -// lock are fantasy chrome owned by tc-item-slot when that primitive is present). -export interface InventoryItem { - id: string - name?: string - icon?: string - qty?: number - [key: string]: unknown -} +const TAG_NAME = 'tc-inventory-grid' export interface InventoryGridEventMap { 'tc-select': CustomEvent<{ item: InventoryItem | null, index: number }> diff --git a/web-components/src/Leaderboard.ts b/web-components/src/Leaderboard.ts index 39f878c4..faf5e1a8 100644 --- a/web-components/src/Leaderboard.ts +++ b/web-components/src/Leaderboard.ts @@ -65,7 +65,7 @@ export class Leaderboard extends HTMLElement { private _entries: LeaderboardEntry[] = [] private _columns: LeaderboardColumns = {} - onselect: ((entry: LeaderboardEntry) => void) | null = null + onSelect: ((entry: LeaderboardEntry) => void) | null = null static get observedAttributes(): string[] { return [] @@ -111,7 +111,7 @@ export class Leaderboard extends HTMLElement { detail: { id: entry.id, entry }, }), ) - if (typeof this.onselect === 'function') this.onselect(entry) + if (typeof this.onSelect === 'function') this.onSelect(entry) } private render(): void { diff --git a/web-components/src/LinkedProvidersCard.ts b/web-components/src/LinkedProvidersCard.ts index 3ee7f158..f029af3e 100644 --- a/web-components/src/LinkedProvidersCard.ts +++ b/web-components/src/LinkedProvidersCard.ts @@ -29,7 +29,7 @@ export class LinkedProvidersCard extends HTMLElement { private _brandColors: Record = {} private _iconForProvider: ((key: string) => string) | null = null - ontoggle: ((key: string, connected: boolean) => void) | null = null + onToggle: ((key: string, connected: boolean) => void) | null = null static get observedAttributes(): string[] { return ['title', 'empty-label'] @@ -122,7 +122,7 @@ export class LinkedProvidersCard extends HTMLElement { composed: true, detail: { key, connected }, })) - if (typeof this.ontoggle === 'function') this.ontoggle(key, connected) + if (typeof this.onToggle === 'function') this.onToggle(key, connected) } private render(): void { diff --git a/web-components/src/ListCard.ts b/web-components/src/ListCard.ts index f1d4beb3..5f1999a6 100644 --- a/web-components/src/ListCard.ts +++ b/web-components/src/ListCard.ts @@ -3,7 +3,7 @@ import { icon } from './icons' const TAG_NAME = 'tc-list-card' -export interface ListItem { +export interface ListCardItem { id?: string icon?: string label: string @@ -25,7 +25,7 @@ function resolveIcon(name: string): string { export class ListCard extends HTMLElement { private _initialised = false - private _items: ListItem[] = [] + private _items: ListCardItem[] = [] static get observedAttributes(): string[] { return ['title', 'ordered', 'loading', 'loading-count'] @@ -43,18 +43,18 @@ export class ListCard extends HTMLElement { this.render() } - get title(): string | null { - return this.getAttribute('title') + get title(): string { + return this.getAttribute('title') ?? '' } set title(v: string | null) { if (v != null) this.setAttribute('title', v) else this.removeAttribute('title') } - get items(): ListItem[] { + get items(): ListCardItem[] { return this._items } - set items(v: ListItem[]) { + set items(v: ListCardItem[]) { this._items = Array.isArray(v) ? v : [] if (this._initialised) this.render() } diff --git a/web-components/src/LiveFeed.ts b/web-components/src/LiveFeed.ts index e1055025..9d1d6d26 100644 --- a/web-components/src/LiveFeed.ts +++ b/web-components/src/LiveFeed.ts @@ -192,7 +192,7 @@ export class LiveFeed extends HTMLElement { const ariaLabel = header ? esc(header) : 'Live feed' this.innerHTML = `
    ${headerHtml}
    ${rowsHtml}
    ` - const body = this.querySelector('.tc-live-feed-body') + const body = this.querySelector('.tc-live-feed-body') if (body) { body.addEventListener('click', this._onBodyClick) body.addEventListener('keydown', this._onBodyKeydown) diff --git a/web-components/src/Marquee.ts b/web-components/src/Marquee.ts index c71dad4d..8fa8ae55 100644 --- a/web-components/src/Marquee.ts +++ b/web-components/src/Marquee.ts @@ -114,7 +114,7 @@ export class Marquee extends HTMLElement { const itemWrappers = Array.from(copy1.querySelectorAll(':scope > .tc-marquee-item')) this._slotNodes = itemWrappers .map(w => w.firstChild) - .filter((n): n is Node => n !== null) + .filter((n): n is ChildNode => n !== null) } private _distributeSlotNodes(): void { diff --git a/web-components/src/MigrationGuide.ts b/web-components/src/MigrationGuide.ts index 58416eed..c264e2f0 100644 --- a/web-components/src/MigrationGuide.ts +++ b/web-components/src/MigrationGuide.ts @@ -65,8 +65,8 @@ export class MigrationGuide extends HTMLElement { this.setAttribute('to', v) } - get title(): string | null { - return this.getAttribute('title') + get title(): string { + return this.getAttribute('title') ?? '' } set title(v: string | null) { if (v != null) this.setAttribute('title', v) diff --git a/web-components/src/NormalMapGenerator.ts b/web-components/src/NormalMapGenerator.ts index 48b452b4..48855b84 100644 --- a/web-components/src/NormalMapGenerator.ts +++ b/web-components/src/NormalMapGenerator.ts @@ -532,7 +532,9 @@ export class NormalMapGenerator extends HTMLElement { if (!c || !this._normal) return const ctx = c.getContext('2d') if (!ctx) return - ctx.putImageData(new ImageData(this._normal, this._width, this._height), 0, 0) + // ImageData expects an ArrayBuffer-backed Uint8ClampedArray; ours is always + // ArrayBuffer-backed at runtime — the cast bridges the lib.dom generic gap. + ctx.putImageData(new ImageData(this._normal as Uint8ClampedArray, this._width, this._height), 0, 0) } private _emitGenerate(): void { @@ -558,7 +560,7 @@ export class NormalMapGenerator extends HTMLElement { scratch.width = this._width scratch.height = this._height const sctx = scratch.getContext('2d') - if (sctx) sctx.putImageData(new ImageData(shaded, this._width, this._height), 0, 0) + if (sctx) sctx.putImageData(new ImageData(shaded as Uint8ClampedArray, this._width, this._height), 0, 0) return scratch } diff --git a/web-components/src/RarityChip.ts b/web-components/src/RarityChip.ts index eba4afe7..b09fdc30 100644 --- a/web-components/src/RarityChip.ts +++ b/web-components/src/RarityChip.ts @@ -1,11 +1,13 @@ +// Re-uses the canonical ItemRarity union owned by tc-item-slot so every rarity- +// aware port shares one source of truth. +import type { ItemRarity } from './ItemSlot' + const TAG_NAME = 'tc-rarity-chip' function esc(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } -export type ItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary' | 'mythic' - const RARITIES: ItemRarity[] = ['common', 'uncommon', 'rare', 'epic', 'legendary', 'mythic'] const RARITY_LABEL: Record = { diff --git a/web-components/src/Roadmap.ts b/web-components/src/Roadmap.ts index 1c88f5a6..4ac2d381 100644 --- a/web-components/src/Roadmap.ts +++ b/web-components/src/Roadmap.ts @@ -68,7 +68,7 @@ export class Roadmap extends HTMLElement { private _columns: RoadmapColumn[] = [] // Optional callback mirroring the tc-select CustomEvent. - onselect: ((detail: { columnStatus: RoadmapStatus; item: RoadmapItem }) => void) | null = null + onSelect: ((detail: { columnStatus: RoadmapStatus; item: RoadmapItem }) => void) | null = null static get observedAttributes(): string[] { return ['layout', 'title-text'] @@ -121,7 +121,7 @@ export class Roadmap extends HTMLElement { detail, }), ) - if (typeof this.onselect === 'function') this.onselect(detail) + if (typeof this.onSelect === 'function') this.onSelect(detail) } private _renderItem(item: RoadmapItem, colIdx: number, itemIdx: number): string { diff --git a/web-components/src/StatusCard.ts b/web-components/src/StatusCard.ts index 02fb2ada..a6a49c1d 100644 --- a/web-components/src/StatusCard.ts +++ b/web-components/src/StatusCard.ts @@ -64,8 +64,8 @@ export class StatusCard extends HTMLElement { this.render() } - get title(): string | null { - return this.getAttribute('title') + get title(): string { + return this.getAttribute('title') ?? '' } set title(v: string | null) { if (v != null) this.setAttribute('title', v) diff --git a/web-components/src/register.ts b/web-components/src/register.ts index e9d64ba0..af41330c 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -555,7 +555,11 @@ export function register(): void { customElements.define('tc-dashboard-sidebar', DashboardSidebar) customElements.define('tc-dashboard-layout', DashboardLayout) customElements.define('tc-date-picker', DatePicker) - customElements.define('tc-diff-viewer', DiffViewer) + // tc-diff-viewer intentionally exposes `before`/`after` string properties as its + // public API (documented + used by demos), which shadow the inherited ChildNode + // methods of the same name. Harmless at runtime; the cast satisfies the structural + // CustomElementConstructor check that the repurposed member names would otherwise fail. + customElements.define('tc-diff-viewer', DiffViewer as unknown as CustomElementConstructor) customElements.define('tc-drawer', Drawer) customElements.define('tc-early-signup-form', EarlySignupForm) customElements.define('tc-ecosystem-map', EcosystemMap) From c31276f95310d009af6be2345ae4a0566c2e3caf Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 14:00:44 +0000 Subject: [PATCH 399/632] Journal component --- examples/public/web-components/SKILL.md | 135 +++++++ examples/src/web-components/JournalDemo.tsx | 133 +++++++ examples/src/web-components/index.tsx | 2 + web-components/src/Journal.ts | 202 ++++++++++ web-components/src/index.ts | 1 + web-components/src/register.ts | 2 + web-components/style/components/_index.scss | 1 + web-components/style/components/_journal.scss | 368 ++++++++++++++++++ web-components/style/foundation/_reset.scss | 1 + 9 files changed, 845 insertions(+) create mode 100644 examples/src/web-components/JournalDemo.tsx create mode 100644 web-components/src/Journal.ts create mode 100644 web-components/style/components/_journal.scss diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 7b24d37c..b9f99e9f 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -240,6 +240,7 @@ After `register()` you can author markup directly: - [tc-infinite-scroll](#tc-infinite-scroll) - [tc-virtual-list](#tc-virtual-list) - [tc-install-tabs](#tc-install-tabs) + - [tc-journal](#tc-journal) - [tc-kill-feed](#tc-kill-feed) - [tc-list](#tc-list) - [tc-list-row](#tc-list-row) @@ -24955,6 +24956,140 @@ Standalone character portrait frame. Displays a glyph (emoji, initials, unicode --- +### tc-journal + +Quest / lore journal pairing an entry list (left rail) with a detail view (right pane). Each entry carries a `state` (active / completed / failed / inactive), an optional description and body, a list of objectives with checkbox indicators, and a list of rewards. Selecting a row updates the detail pane and fires `tc-select`. Port of `gc-journal` (game-components), restyled to the slate/ink design system with sharp corners, JetBrains Mono machine-facing text, 1px hairline borders, a state-colored pip per row, a CSS-drawn objective checkbox, and `--bs-journal-*` custom properties as the theming contract. + +**Tag:** `tc-journal` + +#### Attributes + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `selected-id` | `string` | `''` | ID of the currently selected entry; its row gets an ink accent left-border and its content fills the detail pane | + +#### JS Properties + +| Property | Type | Description | +|---|---|---| +| `entries` | `JournalEntry[]` | Array of journal entry objects to render. Setting re-renders the component. | +| `selectedId` | `string` | Reflects the `selected-id` attribute | +| `onSelect` | `((id: string) => void) \| null` | Callback fired when an entry is selected by click or keyboard | + +**`JournalEntry` shape:** + +| Field | Type | Description | +|---|---|---| +| `id` | `string` | Unique entry identifier | +| `title` | `string` | Entry title shown in the list and detail heading | +| `description` | `string?` | Short summary shown at the top of the detail body | +| `body` | `string?` | Longer lore / flavour text shown below the description | +| `objectives` | `JournalObjective[]?` | Objective rows shown under an "Objectives" label | +| `state` | `'active' \| 'completed' \| 'failed' \| 'inactive'` | Drives the row pip color and the detail state micro-label (defaults to `active`) | +| `rewards` | `JournalReward[]?` | Reward rows shown under a "Rewards" label | + +**`JournalObjective` shape:** + +| Field | Type | Description | +|---|---|---| +| `id` | `string` | Unique objective identifier | +| `label` | `string` | Objective text | +| `completed` | `boolean?` | When `true` fills the checkbox and strikes through the label | +| `optional` | `boolean?` | When `true` appends an `(optional)` micro-label | + +**`JournalReward` shape:** + +| Field | Type | Description | +|---|---|---| +| `label` | `string` | Reward name | +| `value` | `string \| number` | Reward amount; numbers are formatted with thousands separators | +| `icon` | `string?` | Optional short glyph/text shown before the label | + +#### Events + +| Event | `detail` | Description | +|---|---|---| +| `tc-select` | `{ id: string }` | Fired when an entry row is clicked or activated with Enter/Space | + +#### Slots + +None. All content is driven by the `entries` JS property. + +#### CSS Custom Properties + +| Property | Default | Description | +|---|---|---| +| `--bs-journal-bg` | `var(--tc-surface)` | Component background | +| `--bs-journal-border` | `1px solid var(--tc-border)` | Outer frame border | +| `--bs-journal-min-height` | `18rem` | Minimum component height | +| `--bs-journal-list-width` | `14rem` | Width of the left entry-list rail | +| `--bs-journal-list-bg` | `var(--tc-surface)` | List rail background | +| `--bs-journal-list-border-color` | `var(--tc-border)` | Divider between list and detail | +| `--bs-journal-row-border-color` | `var(--tc-border)` | Row divider | +| `--bs-journal-row-hover-bg` | `var(--tc-surface-hover, …)` | Row hover background | +| `--bs-journal-row-selected-bg` | `var(--tc-surface-muted)` | Selected row background | +| `--bs-journal-row-selected-accent` | `var(--tc-app-accent)` | Selected row left-border accent | +| `--bs-journal-row-title-color` | `var(--tc-text)` | Row title color | +| `--bs-journal-row-title-font-size` | `0.8125rem` | Row title font size | +| `--bs-journal-pip-active` | `var(--tc-app-accent)` | Pip color for `active` entries | +| `--bs-journal-pip-completed` | `var(--tc-success, #22c55e)` | Pip color for `completed` entries | +| `--bs-journal-pip-failed` | `var(--tc-danger, #ef4444)` | Pip color for `failed` entries | +| `--bs-journal-pip-inactive` | `var(--tc-text-faint)` | Pip color for `inactive` entries | +| `--bs-journal-detail-bg` | `var(--tc-surface)` | Detail pane background | +| `--bs-journal-title-color` | `var(--tc-text)` | Detail title color | +| `--bs-journal-title-font-size` | `1.125rem` | Detail title font size | +| `--bs-journal-description-color` | `var(--tc-text)` | Detail description color | +| `--bs-journal-body-color` | `var(--tc-text-muted)` | Detail body color | +| `--bs-journal-eyebrow-color` | `var(--tc-text-muted)` | Objectives / Rewards label color | +| `--bs-journal-empty-color` | `var(--tc-text-faint)` | Empty-state message color | +| `--bs-journal-state-active` | `var(--tc-app-accent)` | State micro-label color for `active` | +| `--bs-journal-state-completed` | `var(--tc-success, #22c55e)` | State micro-label color for `completed` | +| `--bs-journal-state-failed` | `var(--tc-danger, #ef4444)` | State micro-label color for `failed` | +| `--bs-journal-state-inactive` | `var(--tc-text-faint)` | State micro-label color for `inactive` | +| `--bs-journal-check-size` | `1rem` | Objective checkbox size | +| `--bs-journal-check-color` | `var(--tc-border-strong)` | Unchecked objective box border | +| `--bs-journal-check-done-color` | `var(--tc-app-accent)` | Completed objective box fill | +| `--bs-journal-objective-color` | `var(--tc-text)` | Objective label color | +| `--bs-journal-objective-completed-color` | `var(--tc-text-faint)` | Completed objective label color | +| `--bs-journal-objective-optional-color` | `var(--tc-text-faint)` | `(optional)` micro-label color | +| `--bs-journal-reward-bg` | `var(--tc-surface-muted)` | Reward row background | +| `--bs-journal-reward-label-color` | `var(--tc-text-muted)` | Reward label color | +| `--bs-journal-reward-value-color` | `var(--tc-text)` | Reward value color | + +#### Example + +```html + + + +``` + +--- + ### tc-quest-tracker On-screen quest-objectives tracker. Renders a header title, a list of named quests, and per-quest objective rows with a checkbox indicator, label, optional badge, progress count, and a 3 px progress bar. Objectives support `completed` (struck-through, muted) and `optional` states. All content is driven by the `quests` JS property. Sharp corners; slate neutrals; JetBrains Mono for the header and progress counts. diff --git a/examples/src/web-components/JournalDemo.tsx b/examples/src/web-components/JournalDemo.tsx new file mode 100644 index 00000000..8a7d51a6 --- /dev/null +++ b/examples/src/web-components/JournalDemo.tsx @@ -0,0 +1,133 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const DEMO_ENTRIES = [ + { + id: 'q1', + title: 'The Ashen Crown', + state: 'active', + description: 'Recover the shattered crown of the old kings from the Ashfall Catacombs.', + body: 'The catacombs run deep beneath the ruined keep. Locals speak of a guardian that still walks the lowest hall, and of a light that burns where the crown was last seen.', + objectives: [ + { id: 'o1', label: 'Enter the Ashfall Catacombs', completed: true }, + { id: 'o2', label: 'Recover the three crown shards' }, + { id: 'o3', label: 'Defeat the Hollow Warden' }, + { id: 'o4', label: 'Light the four braziers', optional: true }, + ], + rewards: [ + { label: 'Gold', value: 1500, icon: '◆' }, + { label: 'Reputation', value: '+250 Vale' }, + { label: 'Crown Fragment', value: 'x3' }, + ], + }, + { + id: 'q2', + title: 'Whispers in the Mist', + state: 'completed', + description: 'Investigate the disappearances near the Vale of Mist.', + body: 'The trail led to a coven hidden in the marsh. They will trouble the village no longer.', + objectives: [ + { id: 'o1', label: 'Speak with the village elder', completed: true }, + { id: 'o2', label: 'Track the missing villagers', completed: true }, + { id: 'o3', label: 'Disband the coven', completed: true }, + ], + rewards: [ + { label: 'Gold', value: 800, icon: '◆' }, + { label: 'Marsh Charm', value: 'x1' }, + ], + }, + { + id: 'q3', + title: 'A Debt Unpaid', + state: 'failed', + description: 'Escort the merchant caravan to Ember Keep before nightfall.', + body: 'The caravan was lost on the high road. The merchant did not survive.', + objectives: [ + { id: 'o1', label: 'Meet the caravan at the crossroads', completed: true }, + { id: 'o2', label: 'Escort it to Ember Keep' }, + ], + }, + { + id: 'q4', + title: 'The Cartographer', + state: 'inactive', + description: 'Locked until you reach the Northern Reaches.', + }, +] + +const JournalDemo: React.FC = () => { + const basicRef = useRef(null) + const completedRef = useRef(null) + const emptyRef = useRef(null) + const eventsRef = useRef(null) + + const [log, setLog] = useState([]) + + useEffect(() => { + if (basicRef.current) basicRef.current.entries = DEMO_ENTRIES + if (completedRef.current) completedRef.current.entries = DEMO_ENTRIES + if (emptyRef.current) emptyRef.current.entries = [] + }, []) + + useEffect(() => { + const el = eventsRef.current + if (!el) return + el.entries = DEMO_ENTRIES + + const onSelect = (e: CustomEvent) => setLog(l => [`tc-select id="${e.detail.id}"`, ...l].slice(0, 8)) + el.addEventListener('tc-select', onSelect as EventListener) + return () => el.removeEventListener('tc-select', onSelect as EventListener) + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="Journal" + description="Quest / lore journal pairing an entry list with a detail view. Each entry carries a state, an optional description and body, objectives with checkboxes, and rewards. Selecting a row updates the detail pane and fires tc-select." + /> + +
    + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + +
    + Event log + {log.length === 0 ? ( + Click an entry to see events… + ) : ( +
      + {log.map((line, i) => ( +
    • {line}
    • + ))} +
    + )} +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default JournalDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 84ce538f..3403c41d 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -256,6 +256,7 @@ import PerkPickerDemo from './PerkPickerDemo' import PortraitDemo from './PortraitDemo' import PressAnyKeyDemo from './PressAnyKeyDemo' import QuestTrackerDemo from './QuestTrackerDemo' +import JournalDemo from './JournalDemo' import LiveFeedDemo from './LiveFeedDemo' import LoginDemo from './LoginDemo' import MarkdownEditorDemo from './MarkdownEditorDemo' @@ -710,6 +711,7 @@ export const webComponentExamples: WebComponentDef[] = [ { key: 'perk-picker', category: 'Components', element: }, { key: 'portrait', category: 'Components', element: }, { key: 'press-any-key', category: 'Components', element: }, + { key: 'journal', category: 'Components', element: }, { key: 'quest-tracker', category: 'Components', element: }, { key: 'rarity-chip', category: 'Content', element: }, { key: 'rune-corner', category: 'Components', element: }, diff --git a/web-components/src/Journal.ts b/web-components/src/Journal.ts new file mode 100644 index 00000000..3b9cc799 --- /dev/null +++ b/web-components/src/Journal.ts @@ -0,0 +1,202 @@ +const TAG_NAME = 'tc-journal' + +export type JournalState = 'active' | 'completed' | 'failed' | 'inactive' +const STATES: JournalState[] = ['active', 'completed', 'failed', 'inactive'] + +export interface JournalObjective { + id: string + label: string + completed?: boolean + optional?: boolean +} + +export interface JournalReward { + label: string + value: string | number + icon?: string +} + +export interface JournalEntry { + id: string + title: string + description?: string + body?: string + objectives?: JournalObjective[] + state?: JournalState + rewards?: JournalReward[] +} + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function normaliseState(raw: JournalState | undefined): JournalState { + return raw && STATES.includes(raw) ? raw : 'active' +} + +// tc-journal — quest / lore journal with an entry list and a detail view. +// Port of gc-journal (game-components); restyled to the slate/ink design system +// with sharp corners, JetBrains Mono machine-facing text, 1px hairline borders, +// and all cosmetics driven through --bs-journal-* custom properties. +export class Journal extends HTMLElement { + private _initialised = false + private _entries: JournalEntry[] = [] + + onSelect: ((id: string) => void) | null = null + + static get observedAttributes(): string[] { + return ['selected-id'] + } + + connectedCallback(): void { + if (!this._initialised) { + if (!this.hasAttribute('role')) this.setAttribute('role', 'group') + this.render() + this._initialised = true + } + } + + attributeChangedCallback(): void { + if (!this.isConnected || !this._initialised) return + this.render() + } + + get selectedId(): string { + return this.getAttribute('selected-id') ?? '' + } + set selectedId(v: string) { + if (v) this.setAttribute('selected-id', v) + else this.removeAttribute('selected-id') + } + + get entries(): JournalEntry[] { + return this._entries.slice() + } + set entries(value: JournalEntry[]) { + this._entries = Array.isArray(value) ? value.slice() : [] + if (this._initialised) this.render() + } + + private render(): void { + const selected = this.selectedId + const active = this._entries.find(e => e.id === selected) ?? null + + const rowsHtml = this._entries.map(e => { + const state = normaliseState(e.state) + const isSelected = e.id === selected + let rowCls = `tc-journal-row tc-journal-row--${state}` + if (isSelected) rowCls += ' tc-journal-row--selected' + return `
    + + ${esc(e.title)} +
    ` + }).join('') + + const listHtml = this._entries.length + ? `
    ${rowsHtml}
    ` + : `

    No journal entries

    ` + + const detailHtml = active + ? this.renderDetail(active) + : `

    Select a journal entry.

    ` + + this.innerHTML = `
    + ${listHtml} +
    ${detailHtml}
    +
    ` + + const list = this.querySelector('.tc-journal-list') + if (!list) return + + list.addEventListener('click', (event: Event) => { + const row = (event.target as Element).closest('.tc-journal-row') + if (!row) return + this._selectRow(row.dataset.id ?? '') + }) + + list.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') return + const row = (event.target as Element).closest('.tc-journal-row') + if (!row) return + event.preventDefault() + this._selectRow(row.dataset.id ?? '') + }) + } + + private _selectRow(id: string): void { + this.selectedId = id + this.dispatchEvent(new CustomEvent('tc-select', { bubbles: true, composed: true, detail: { id } })) + if (typeof this.onSelect === 'function') this.onSelect(id) + } + + private renderDetail(entry: JournalEntry): string { + const state = normaliseState(entry.state) + const stateLabel = state.charAt(0).toUpperCase() + state.slice(1) + + const description = entry.description + ? `

    ${esc(entry.description)}

    ` + : '' + const body = entry.body + ? `

    ${esc(entry.body)}

    ` + : '' + + const objectivesHtml = (entry.objectives || []).map(o => { + let cls = 'tc-journal-objective' + if (o.completed) cls += ' tc-journal-objective--completed' + if (o.optional) cls += ' tc-journal-objective--optional' + const checkCls = o.completed + ? 'tc-journal-objective-check tc-journal-objective-check--done' + : 'tc-journal-objective-check' + const optionalHtml = o.optional + ? `(optional)` + : '' + return `
    + + ${esc(o.label)} + ${optionalHtml} +
    ` + }).join('') + const objectivesBlock = objectivesHtml + ? `
    + Objectives +
    ${objectivesHtml}
    +
    ` + : '' + + const rewardsHtml = (entry.rewards || []).map(r => { + const value = typeof r.value === 'number' ? r.value.toLocaleString() : r.value + const iconHtml = r.icon + ? `` + : '' + return `
    + ${iconHtml} + ${esc(r.label)} + ${esc(value)} +
    ` + }).join('') + const rewardsBlock = rewardsHtml + ? `
    + Rewards +
    ${rewardsHtml}
    +
    ` + : '' + + return `${esc(stateLabel)} +

    ${esc(entry.title)}

    + ${description} + ${body} + ${objectivesBlock} + ${rewardsBlock}` + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: Journal + } +} diff --git a/web-components/src/index.ts b/web-components/src/index.ts index 811e07b7..0b40ae03 100644 --- a/web-components/src/index.ts +++ b/web-components/src/index.ts @@ -341,6 +341,7 @@ export * from './PauseScreen' export * from './PerkPicker' export * from './Portrait' export * from './PressAnyKey' +export * from './Journal' export * from './QuestTracker' export * from './RarityChip' export * from './ReportPlayerDialog' diff --git a/web-components/src/register.ts b/web-components/src/register.ts index 8e24459a..9f347053 100644 --- a/web-components/src/register.ts +++ b/web-components/src/register.ts @@ -342,6 +342,7 @@ import { PauseScreen } from './PauseScreen' import { PerkPicker } from './PerkPicker' import { Portrait } from './Portrait' import { PressAnyKey } from './PressAnyKey' +import { Journal } from './Journal' import { QuestTracker } from './QuestTracker' import { RarityChip } from './RarityChip' import { ReportPlayerDialog } from './ReportPlayerDialog' @@ -706,6 +707,7 @@ export function register(): void { customElements.define('tc-perk-picker', PerkPicker) customElements.define('tc-portrait', Portrait) customElements.define('tc-press-any-key', PressAnyKey) + customElements.define('tc-journal', Journal) customElements.define('tc-quest-tracker', QuestTracker) customElements.define('tc-rarity-chip', RarityChip) customElements.define('tc-report-player-dialog', ReportPlayerDialog) diff --git a/web-components/style/components/_index.scss b/web-components/style/components/_index.scss index 0c799f62..225ce515 100644 --- a/web-components/style/components/_index.scss +++ b/web-components/style/components/_index.scss @@ -335,6 +335,7 @@ @forward 'ping-display'; @forward 'player-card'; @forward 'player-frame'; +@forward 'journal'; @forward 'quest-tracker'; @forward 'rarity-chip'; @forward 'reset-to-defaults'; diff --git a/web-components/style/components/_journal.scss b/web-components/style/components/_journal.scss new file mode 100644 index 00000000..b9ae0998 --- /dev/null +++ b/web-components/style/components/_journal.scss @@ -0,0 +1,368 @@ +// tc-journal — quest / lore journal: entry list (left) + detail view (right). +// Port of gc-journal; restyled to the slate/ink design system. +// All cosmetics flow through --bs-journal-* custom properties. + +tc-journal { + // Frame + --bs-journal-bg: var(--tc-surface); + --bs-journal-border: 1px solid var(--tc-border); + --bs-journal-min-height: 18rem; + + // List rail + --bs-journal-list-width: 14rem; + --bs-journal-list-bg: var(--tc-surface); + --bs-journal-list-border-color: var(--tc-border); + + // Rows + --bs-journal-row-border-color: var(--tc-border); + --bs-journal-row-hover-bg: var(--tc-surface-hover, var(--tc-surface-muted)); + --bs-journal-row-selected-bg: var(--tc-surface-muted); + --bs-journal-row-selected-accent: var(--tc-app-accent); + --bs-journal-row-title-color: var(--tc-text); + --bs-journal-row-title-font-size: 0.8125rem; + + // State pip palette + --bs-journal-pip-active: var(--tc-app-accent); + --bs-journal-pip-completed: var(--tc-success, #22c55e); + --bs-journal-pip-failed: var(--tc-danger, #ef4444); + --bs-journal-pip-inactive: var(--tc-text-faint); + + // Detail + --bs-journal-detail-bg: var(--tc-surface); + --bs-journal-title-color: var(--tc-text); + --bs-journal-title-font-size: 1.125rem; + --bs-journal-description-color: var(--tc-text); + --bs-journal-body-color: var(--tc-text-muted); + --bs-journal-eyebrow-color: var(--tc-text-muted); + --bs-journal-empty-color: var(--tc-text-faint); + + // State eyebrow palette + --bs-journal-state-active: var(--tc-app-accent); + --bs-journal-state-completed: var(--tc-success, #22c55e); + --bs-journal-state-failed: var(--tc-danger, #ef4444); + --bs-journal-state-inactive: var(--tc-text-faint); + + // Objectives + --bs-journal-check-size: 1rem; + --bs-journal-check-color: var(--tc-border-strong); + --bs-journal-check-done-color: var(--tc-app-accent); + --bs-journal-objective-color: var(--tc-text); + --bs-journal-objective-completed-color: var(--tc-text-faint); + --bs-journal-objective-optional-color: var(--tc-text-faint); + + // Rewards + --bs-journal-reward-bg: var(--tc-surface-muted); + --bs-journal-reward-label-color: var(--tc-text-muted); + --bs-journal-reward-value-color: var(--tc-text); + + background: var(--bs-journal-bg); + border: var(--bs-journal-border); + border-radius: 0; + overflow: hidden; +} + +.tc-journal { + display: flex; + align-items: stretch; + min-height: var(--bs-journal-min-height); +} + +// ── List rail ─────────────────────────────────────────────────────────────────── + +.tc-journal-list { + flex: 0 0 var(--bs-journal-list-width); + display: flex; + flex-direction: column; + background: var(--bs-journal-list-bg); + border-right: 1px solid var(--bs-journal-list-border-color); + overflow-y: auto; +} + +.tc-journal-list-empty { + margin: 0; + padding: 1rem 0.75rem; + font-size: 0.8125rem; + color: var(--bs-journal-empty-color); + text-align: center; +} + +// ── Row ───────────────────────────────────────────────────────────────────────── + +.tc-journal-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + min-height: 40px; + border-bottom: 1px solid var(--bs-journal-row-border-color); + border-left: 3px solid transparent; + background: transparent; + cursor: pointer; + outline: none; + transition: background var(--tc-transition-fast, 0.15s ease); + + &:last-child { + border-bottom: none; + } + + &:hover { + background: var(--bs-journal-row-hover-bg); + } + + &:focus-visible { + outline: 2px solid var(--tc-app-accent); + outline-offset: -2px; + } + + &--selected { + background: var(--bs-journal-row-selected-bg); + border-left-color: var(--bs-journal-row-selected-accent); + } +} + +.tc-journal-row-pip { + flex-shrink: 0; + width: 8px; + height: 8px; + border-radius: 0; + background: var(--bs-journal-pip-active); +} + +.tc-journal-row--active .tc-journal-row-pip { + background: var(--bs-journal-pip-active); +} +.tc-journal-row--completed .tc-journal-row-pip { + background: var(--bs-journal-pip-completed); +} +.tc-journal-row--failed .tc-journal-row-pip { + background: var(--bs-journal-pip-failed); +} +.tc-journal-row--inactive .tc-journal-row-pip { + background: var(--bs-journal-pip-inactive); +} + +.tc-journal-row-title { + flex: 1 1 auto; + min-width: 0; + font-size: var(--bs-journal-row-title-font-size); + font-weight: 500; + color: var(--bs-journal-row-title-color); + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tc-journal-row--inactive .tc-journal-row-title { + color: var(--tc-text-muted); +} + +// ── Detail pane ─────────────────────────────────────────────────────────────────── + +.tc-journal-detail { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.625rem; + padding: 1rem 1.125rem; + background: var(--bs-journal-detail-bg); + overflow-y: auto; +} + +.tc-journal-detail-empty { + margin: auto; + font-size: 0.8125rem; + color: var(--bs-journal-empty-color); +} + +// Mono uppercase state micro-label (replaces gc-eyebrow). +.tc-journal-detail-state { + align-self: flex-start; + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + line-height: 1; + color: var(--bs-journal-state-active); + + &--active { + color: var(--bs-journal-state-active); + } + &--completed { + color: var(--bs-journal-state-completed); + } + &--failed { + color: var(--bs-journal-state-failed); + } + &--inactive { + color: var(--bs-journal-state-inactive); + } +} + +.tc-journal-detail-title { + margin: 0; + font-size: var(--bs-journal-title-font-size); + font-weight: 600; + color: var(--bs-journal-title-color); + line-height: 1.3; +} + +.tc-journal-detail-description { + margin: 0; + font-size: 0.875rem; + color: var(--bs-journal-description-color); + line-height: 1.5; +} + +.tc-journal-detail-body { + margin: 0; + font-size: 0.8125rem; + color: var(--bs-journal-body-color); + line-height: 1.6; +} + +.tc-journal-detail-section { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.tc-journal-detail-eyebrow { + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: 0.625rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--bs-journal-eyebrow-color); + line-height: 1; +} + +// ── Objectives ──────────────────────────────────────────────────────────────────── + +.tc-journal-objectives { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.tc-journal-objective { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.tc-journal-objective-check { + flex-shrink: 0; + width: var(--bs-journal-check-size); + height: var(--bs-journal-check-size); + border: 1px solid var(--bs-journal-check-color); + border-radius: 0; + background: transparent; + transition: + background var(--tc-transition-fast, 0.15s ease), + border-color var(--tc-transition-fast, 0.15s ease); + + &--done { + border-color: var(--bs-journal-check-done-color); + background: var(--bs-journal-check-done-color); + position: relative; + + // White tick injected via mask so it recolors correctly. + &::after { + content: ''; + display: block; + position: absolute; + inset: 0; + background: #fff; + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Cpolyline points='1.5,5.5 4,8 8.5,2' fill='none' stroke='%23fff' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + mask-size: cover; + } + } +} + +.tc-journal-objective-label { + flex: 1 1 auto; + min-width: 0; + font-size: 0.8125rem; + color: var(--bs-journal-objective-color); + line-height: 1.4; +} + +.tc-journal-objective--completed .tc-journal-objective-label { + color: var(--bs-journal-objective-completed-color); + text-decoration: line-through; + text-decoration-color: var(--bs-journal-objective-completed-color); +} + +.tc-journal-objective-optional { + flex-shrink: 0; + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: 0.625rem; + letter-spacing: 0.04em; + color: var(--bs-journal-objective-optional-color); +} + +// ── Rewards ─────────────────────────────────────────────────────────────────────── + +.tc-journal-rewards { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.tc-journal-reward { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.5rem; + background: var(--bs-journal-reward-bg); + border-radius: 0; +} + +.tc-journal-reward-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + font-size: 0.875rem; + line-height: 1; +} + +.tc-journal-reward-label { + flex: 1 1 auto; + min-width: 0; + font-size: 0.8125rem; + color: var(--bs-journal-reward-label-color); + line-height: 1.4; +} + +.tc-journal-reward-value { + flex-shrink: 0; + font-family: var(--tc-font-mono, 'JetBrains Mono', monospace); + font-size: 0.8125rem; + font-weight: 600; + color: var(--bs-journal-reward-value-color); + line-height: 1.4; +} + +// ── 44px coarse touch targets ────────────────────────────────────────────────────── + +@media (pointer: coarse) { + .tc-journal-row { + min-height: 44px; + } +} + +// ── Reduced motion ───────────────────────────────────────────────────────────────── + +@media (prefers-reduced-motion: reduce) { + .tc-journal-row, + .tc-journal-objective-check { + transition: background var(--tc-transition-fast, 0.15s ease); + } +} diff --git a/web-components/style/foundation/_reset.scss b/web-components/style/foundation/_reset.scss index 6b8a33ae..0643f5cd 100644 --- a/web-components/style/foundation/_reset.scss +++ b/web-components/style/foundation/_reset.scss @@ -348,6 +348,7 @@ tc-main-menu, tc-panel, tc-panel-header, tc-menu-item, +tc-journal, tc-loot-list, tc-lore-text, tc-minimap, From c5dabcfe1cb9bbe6274e9cd4f96b9502975272db Mon Sep 17 00:00:00 2001 From: kalevski Date: Thu, 18 Jun 2026 16:10:15 +0200 Subject: [PATCH 400/632] component fixes --- examples/public/web-components/SKILL.md | 60 +-- .../src/web-components/AdvancedTableDemo.tsx | 119 ++++- .../src/web-components/CoolButtonDemo.tsx | 74 +++- examples/src/web-components/DividerDemo.tsx | 24 + .../web-components/EarlySignupFormDemo.tsx | 47 +- examples/src/web-components/HeroDemo.tsx | 42 +- .../src/web-components/JSONEditorDemo.tsx | 2 +- .../src/web-components/JSONSchemaDefDemo.tsx | 15 +- .../src/web-components/MaintainerCardDemo.tsx | 11 +- .../src/web-components/RadialWheelDemo.tsx | 70 +++ ...yerDialogDemo.tsx => ReportDialogDemo.tsx} | 12 +- .../src/web-components/SponsorWallDemo.tsx | 37 +- examples/src/web-components/StampDemo.tsx | 27 +- .../src/web-components/WelcomeGuideDemo.tsx | 141 +++--- examples/src/web-components/index.tsx | 4 +- web-components/src/AdvancedTable.ts | 60 +-- web-components/src/CoolButton.ts | 43 +- web-components/src/EarlySignupForm.ts | 83 +++- web-components/src/FullscreenToggle.ts | 44 +- web-components/src/Hero.ts | 28 +- web-components/src/JSONSchemaDef.ts | 34 +- web-components/src/PhysicsEditor.ts | 176 +++++++- web-components/src/RadialWheel.ts | 79 +++- ...{ReportPlayerDialog.ts => ReportDialog.ts} | 68 +-- web-components/src/Speedometer.ts | 81 +++- web-components/src/Stamp.ts | 23 +- web-components/src/Switch.ts | 86 ++-- web-components/src/WelcomeGuide.ts | 178 ++++---- web-components/src/index.ts | 2 +- web-components/src/register.ts | 4 +- .../style/components/_advanced-table.scss | 67 +-- .../style/components/_basic-card.scss | 7 +- .../style/components/_card-options.scss | 29 +- .../style/components/_colored-card.scss | 7 +- .../style/components/_cool-button.scss | 71 +++ .../components/_danger-zone-actions.scss | 4 +- .../style/components/_early-signup-form.scss | 314 ++++++++++--- .../style/components/_extended-select.scss | 14 +- web-components/style/components/_file.scss | 10 +- .../style/components/_form-wizard.scss | 17 +- .../style/components/_fullscreen-toggle.scss | 101 +---- web-components/style/components/_hero.scss | 122 +++++- web-components/style/components/_index.scss | 2 +- .../style/components/_json-editor.scss | 16 +- .../style/components/_json-schema-def.scss | 46 +- .../style/components/_metric-grid.scss | 15 + .../style/components/_multi-card-select.scss | 20 +- .../components/_pinned-feature-showcase.scss | 35 +- .../style/components/_radial-wheel.scss | 25 ++ .../style/components/_rank-cell.scss | 20 +- ...player-dialog.scss => _report-dialog.scss} | 270 ++++++------ .../style/components/_speedometer.scss | 44 +- web-components/style/components/_stamp.scss | 6 + web-components/style/components/_switch.scss | 191 +++++--- .../style/components/_welcome-guide.scss | 412 ++++++++++-------- web-components/style/foundation/_reset.scss | 2 +- 56 files changed, 2358 insertions(+), 1183 deletions(-) rename examples/src/web-components/{ReportPlayerDialogDemo.tsx => ReportDialogDemo.tsx} (95%) rename web-components/src/{ReportPlayerDialog.ts => ReportDialog.ts} (78%) rename web-components/style/components/{_report-player-dialog.scss => _report-dialog.scss} (50%) diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 4f05b3b5..5f57a261 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -317,7 +317,7 @@ After `register()` you can author markup directly: - [tc-modal](#tc-modal) - [tc-offcanvas](#tc-offcanvas) - [tc-popover](#tc-popover) - - [tc-report-player-dialog](#tc-report-player-dialog) + - [tc-report-dialog](#tc-report-dialog) - [tc-screen-flash](#tc-screen-flash) - [tc-toast](#tc-toast) - [tc-tooltip](#tc-tooltip) @@ -25171,11 +25171,11 @@ None. All content is driven by the `options` JS property. --- -### tc-report-player-dialog +### tc-report-dialog Player-report moderation modal with a reason radio group, an optional comment textarea, and Cancel / Submit Report actions. Port of `gc-report-player-dialog` (game-components), restyled to the slate design system (sharp corners, 1px hairline, overlay-tier shadow, danger-red submit). Controlled component — fires `tc-cancel` or `tc-submit`; the consumer sets `open` to `false` to dismiss. Focus trap, scroll lock, and keyboard (`Escape`) handling included. No shadow root; light DOM; `display: block`. -**Tag:** `tc-report-player-dialog` +**Tag:** `tc-report-dialog` --- @@ -25213,32 +25213,32 @@ Player-report moderation modal with a reason radio group, an optional comment te | Property | Default | Description | |---|---|---| -| `--bs-report-player-dialog-bg` | `var(--tc-surface)` | Panel background. | -| `--bs-report-player-dialog-border-color` | `var(--tc-border)` | Panel border colour. | -| `--bs-report-player-dialog-shadow` | `var(--tc-shadow-lg)` | Panel box-shadow (overlay tier). | -| `--bs-report-player-dialog-width` | `460px` | Default panel width. | -| `--bs-report-player-dialog-max-width` | `calc(100vw - 2rem)` | Maximum panel width (viewport responsive). | -| `--bs-report-player-dialog-max-height` | `calc(100vh - 4rem)` | Maximum panel height. | -| `--bs-report-player-dialog-padding` | `1.25rem` | Header, body, and actions padding. | -| `--bs-report-player-dialog-gap` | `0.75rem` | Gap between action buttons and body items. | -| `--bs-report-player-dialog-eyebrow-color` | `var(--tc-text-muted)` | "Report Player" eyebrow micro-label colour. | -| `--bs-report-player-dialog-eyebrow-size` | `0.6875rem` | Eyebrow font size. | -| `--bs-report-player-dialog-title-color` | `var(--tc-text)` | Player name heading colour. | -| `--bs-report-player-dialog-title-size` | `1rem` | Player name heading font size. | -| `--bs-report-player-dialog-z-backdrop` | `var(--tc-z-modal-backdrop)` | Backdrop z-index. | -| `--bs-report-player-dialog-z-panel` | `var(--tc-z-modal)` | Panel z-index. | -| `--bs-report-player-dialog-backdrop-bg` | `#0f172a` | Backdrop scrim colour. | -| `--bs-report-player-dialog-backdrop-opacity` | `0.5` | Backdrop opacity when open. | -| `--bs-report-player-dialog-message-color` | `var(--tc-text-muted)` | Instruction message text colour. | -| `--bs-report-player-dialog-reason-color` | `var(--tc-text)` | Reason label text colour. | -| `--bs-report-player-dialog-reason-hover-bg` | `var(--tc-surface-muted)` | Reason row hover background. | -| `--bs-report-player-dialog-reason-checked-bg` | `var(--tc-surface-hover)` | Reason row background when selected. | -| `--bs-report-player-dialog-reason-checked-color` | `var(--tc-app-accent)` | Reason label colour when selected. | -| `--bs-report-player-dialog-radio-size` | `1rem` | Custom radio indicator diameter. | -| `--bs-report-player-dialog-radio-border` | `1px solid var(--tc-border-strong)` | Radio indicator border. | -| `--bs-report-player-dialog-radio-checked-bg` | `var(--tc-app-accent)` | Radio fill colour when checked. | -| `--bs-report-player-dialog-btn-submit-bg` | `var(--tc-danger, #dc2626)` | Submit button background (danger red). | -| `--bs-report-player-dialog-btn-submit-color` | `#fff` | Submit button text colour. | +| `--bs-report-dialog-bg` | `var(--tc-surface)` | Panel background. | +| `--bs-report-dialog-border-color` | `var(--tc-border)` | Panel border colour. | +| `--bs-report-dialog-shadow` | `var(--tc-shadow-lg)` | Panel box-shadow (overlay tier). | +| `--bs-report-dialog-width` | `460px` | Default panel width. | +| `--bs-report-dialog-max-width` | `calc(100vw - 2rem)` | Maximum panel width (viewport responsive). | +| `--bs-report-dialog-max-height` | `calc(100vh - 4rem)` | Maximum panel height. | +| `--bs-report-dialog-padding` | `1.25rem` | Header, body, and actions padding. | +| `--bs-report-dialog-gap` | `0.75rem` | Gap between action buttons and body items. | +| `--bs-report-dialog-eyebrow-color` | `var(--tc-text-muted)` | "Report Player" eyebrow micro-label colour. | +| `--bs-report-dialog-eyebrow-size` | `0.6875rem` | Eyebrow font size. | +| `--bs-report-dialog-title-color` | `var(--tc-text)` | Player name heading colour. | +| `--bs-report-dialog-title-size` | `1rem` | Player name heading font size. | +| `--bs-report-dialog-z-backdrop` | `var(--tc-z-modal-backdrop)` | Backdrop z-index. | +| `--bs-report-dialog-z-panel` | `var(--tc-z-modal)` | Panel z-index. | +| `--bs-report-dialog-backdrop-bg` | `#0f172a` | Backdrop scrim colour. | +| `--bs-report-dialog-backdrop-opacity` | `0.5` | Backdrop opacity when open. | +| `--bs-report-dialog-message-color` | `var(--tc-text-muted)` | Instruction message text colour. | +| `--bs-report-dialog-reason-color` | `var(--tc-text)` | Reason label text colour. | +| `--bs-report-dialog-reason-hover-bg` | `var(--tc-surface-muted)` | Reason row hover background. | +| `--bs-report-dialog-reason-checked-bg` | `var(--tc-surface-hover)` | Reason row background when selected. | +| `--bs-report-dialog-reason-checked-color` | `var(--tc-app-accent)` | Reason label colour when selected. | +| `--bs-report-dialog-radio-size` | `1rem` | Custom radio indicator diameter. | +| `--bs-report-dialog-radio-border` | `1px solid var(--tc-border-strong)` | Radio indicator border. | +| `--bs-report-dialog-radio-checked-bg` | `var(--tc-app-accent)` | Radio fill colour when checked. | +| `--bs-report-dialog-btn-submit-bg` | `var(--tc-danger, #dc2626)` | Submit button background (danger red). | +| `--bs-report-dialog-btn-submit-color` | `#fff` | Submit button text colour. | --- @@ -25251,7 +25251,7 @@ None. All content is driven by attributes and the `reasons` JS property. #### Example ```html - +
    No contributors match the filters
    No contributors match the filters
    ${esc(r.name)}${esc(r.role)}${r.commits}
    ${esc(r.name)}${esc(r.role)}${esc(r.team)}${r.commits}
    ` with ``, ``, and ` -``` - ---- - ### tc-linked-providers-card Section card listing OAuth providers with custom icons and brand colors. A fixed header shows the card title; the body renders one row per provider with an icon tile, label, optional connected-account sub-label, and a connect/disconnect action button. Dispatches `tc-toggle` when an action button is clicked. Empty-state fallback when the `providers` array is empty. @@ -16168,422 +15567,147 @@ None. -``` - ---- - -### tc-credits-list - -Static grouped credits roll — a centred sequence of sections, each pairing a role heading (JetBrains Mono uppercase micro-label) with one or more names (Inter). Set sections exclusively via the `sections` JS property. Non-interactive — no hover state, no events. - -**Tag:** `tc-credits-list` - -**Attributes** - -None. All content is supplied via the `sections` JS property. (The host receives `role="list"` automatically unless one is already set.) - -**JS Properties** - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `sections` | `CreditsSection[]` | `[]` | Array of credit sections (see shape below). Re-renders the roll on each set. | - -**CreditsSection shape** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `role` | `string` | yes | The credited role — rendered as a JetBrains Mono uppercase micro-label (e.g. `"Lead Engineering"`). | -| `names` | `string[]` | yes | One or more names credited under the role. Each is rendered on its own line in Inter. | - -**Events** - -None. `tc-credits-list` is purely presentational. - -**Slots** - -None. - -**CSS custom properties (theming)** - -| Property | Default | Description | -|----------|---------|-------------| -| `--bs-credits-list-section-gap` | `2rem` | Vertical gap between credit sections. | -| `--bs-credits-list-name-gap` | `0.25rem` | Vertical gap between names within a section. | -| `--bs-credits-list-role-name-gap` | `0.625rem` | Gap between a role heading and its names. | -| `--bs-credits-list-role-color` | `var(--tc-text-faint)` | Role heading text color. | -| `--bs-credits-list-role-size` | `0.6875rem` | Role heading font size. | -| `--bs-credits-list-role-spacing` | `0.12em` | Role heading letter-spacing. | -| `--bs-credits-list-name-color` | `var(--tc-text)` | Name text color. | -| `--bs-credits-list-name-size` | `0.9375rem` | Name font size. | - -```html - - - -``` - ---- - -### tc-achievement-list - -Scrollable list of achievements with locked, in-progress, and unlocked states and an optional progress bar. Ported from the game-components `gc-achievement-list`, restyled to the toolcase design system (no fantasy chrome). Set achievements exclusively via the `achievements` JS property. Secret achievements are masked (`???` name, hidden description, lock-style glyph) until unlocked. Non-interactive — no hover state, no events. - -**Tag:** `tc-achievement-list` - -**Attributes** - -None. All content is supplied via the `achievements` JS property. The host receives `role="list"` automatically (unless an explicit `role` is already set). - -**JS Properties** - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `achievements` | `Achievement[]` | `[]` | Array of achievement descriptors (see shape below). Re-renders the list on each set; the getter returns a copy. | - -**Achievement shape** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `id` | `string` | yes | Stable identifier, written to the row's `data-id` attribute. | -| `name` | `string` | yes | Achievement name — Inter 500, `--tc-text`. Hidden behind `???` while `secret` and not `unlocked`. | -| `description` | `string` | no | Secondary description line below the name — `--tc-text-muted`, smaller. Replaced by "Hidden achievement." while secret. | -| `icon` | `string` | no | Lucide icon name (kebab-case or PascalCase, e.g. `"swords"` or `"Swords"`). Rendered as inline SVG inside the icon tile. Falls back to a default glyph per state (`award` unlocked, `lock` locked, `help-circle` secret). | -| `unlocked` | `boolean` | no | When true, the row is marked unlocked — ink-filled icon tile and emphasised points. | -| `progress` | `number` | no | Current progress value. A progress bar shows only when the row is locked and both `progress` and `target` are numbers with `target > 0`. | -| `target` | `number` | no | Progress target (denominator). Required alongside `progress` to render the bar. | -| `points` | `number` | no | Point value shown in JetBrains Mono on the right. Hidden for secret rows. | -| `secret` | `boolean` | no | When true (and not `unlocked`), masks the name, description, points, and icon. | - -**Events** - -None. `tc-achievement-list` is purely presentational. - -**Slots** - -None. - -**CSS custom properties (theming)** - -| Property | Default | Description | -|----------|---------|-------------| -| `--bs-achievement-list-max-height` | `360px` | Max height before the list scrolls vertically. | -| `--bs-achievement-list-bg` | `var(--tc-surface)` | List background. | -| `--bs-achievement-list-border-color` | `var(--tc-border)` | Outer 1px hairline frame color. | -| `--bs-achievement-list-row-border-color` | `var(--tc-border)` | 1px hairline separator between rows. | -| `--bs-achievement-list-row-padding-y` | `0.75rem` | Vertical padding per row. | -| `--bs-achievement-list-row-padding-x` | `1rem` | Horizontal padding per row. | -| `--bs-achievement-list-row-gap` | `0.75rem` | Gap between icon, text, and points. | -| `--bs-achievement-list-icon-size` | `2rem` | Width/height of the square icon tile. | -| `--bs-achievement-list-icon-glyph-size` | `1.125rem` | Icon SVG width/height. | -| `--bs-achievement-list-icon-bg` | `var(--tc-surface-muted)` | Icon tile background (locked/in-progress). | -| `--bs-achievement-list-icon-border-color` | `var(--tc-border)` | Icon tile border color. | -| `--bs-achievement-list-icon-color` | `var(--tc-text-muted)` | Icon glyph color (locked/in-progress). | -| `--bs-achievement-list-name-color` | `var(--tc-text)` | Achievement name color. | -| `--bs-achievement-list-name-font-size` | `0.9375rem` | Name font size. | -| `--bs-achievement-list-description-color` | `var(--tc-text-muted)` | Description text color. | -| `--bs-achievement-list-description-font-size` | `0.8125rem` | Description font size. | -| `--bs-achievement-list-points-color` | `var(--tc-text-muted)` | Points color (locked/in-progress). | -| `--bs-achievement-list-points-font-size` | `0.8125rem` | Points font size. | -| `--bs-achievement-list-progress-track-bg` | `var(--tc-slate-200)` | Progress track background. | -| `--bs-achievement-list-progress-fill` | `var(--tc-app-accent)` | Progress fill color (ink). | -| `--bs-achievement-list-progress-height` | `0.375rem` | Progress bar height. | -| `--bs-achievement-list-progress-count-color` | `var(--tc-text-muted)` | Progress count text color. | -| `--bs-achievement-list-progress-count-font-size` | `0.6875rem` | Progress count font size. | -| `--bs-achievement-list-unlocked-icon-bg` | `var(--tc-app-accent)` | Icon tile background when unlocked. | -| `--bs-achievement-list-unlocked-icon-color` | `#fff` | Icon glyph color when unlocked. | -| `--bs-achievement-list-unlocked-icon-border-color` | `var(--tc-app-accent)` | Icon tile border when unlocked. | -| `--bs-achievement-list-unlocked-points-color` | `var(--tc-text)` | Points color when unlocked. | -| `--bs-achievement-list-locked-opacity` | `0.6` | Opacity applied to locked rows. | - -```html - - - -``` - ---- - -### tc-result-screen - -Match / round result screen: a centred region with a mono uppercase eyebrow, a status-toned title, a short hairline divider, an optional subtitle, a column of hairline-separated stat rows, a soft reward strip, and a wrapped row of action buttons. Stats, rewards, and actions are supplied via JS properties; the title text/colour, subtitle, and eyebrow are attributes. Clicking an action fires `tc-action` with that action's `id`. Ported from the game-components `gc-result-screen`, restyled to the toolcase design system — flat slate surface, hairline borders, sharp corners, mono machine-facing text, `.btn` action primitives, and no gilded frame / diamond divider / metal buttons. `tc-game-over-screen` uses the same layout with defeat-specific defaults. - -**Tag:** `tc-result-screen` - -**Attributes** - -| Attribute | Type | Default | Description | -|-----------|------|---------|-------------| -| `title-text` | string | `''` | The large title heading | -| `subtitle` | string | — | Optional supporting line under the divider | -| `title-color` | `gold\|danger\|parch` | `gold` | Status tone of the title (`gold` → warning ramp, `danger` → danger ramp, `parch` → neutral slate) | -| `eyebrow` | string | `Result` | The mono uppercase micro-label above the title | - -The host element automatically gains `role="region"` (unless one is already set). - -**JS Properties** - -| Property | Type | Description | -|----------|------|-------------| -| `stats` | `ResultStat[]` | Stat rows (set via JS, not attribute); setting re-renders | -| `rewards` | `ResultReward[]` | Reward chips shown in the reward strip | -| `actions` | `ResultAction[]` | Action buttons rendered at the bottom | -| `titleText` | string | Mirror of the `title-text` attribute | -| `subtitle` | string | Mirror of the `subtitle` attribute | -| `titleColor` | `gold\|danger\|parch` | Mirror of the `title-color` attribute | -| `eyebrow` | string | Mirror of the `eyebrow` attribute | -| `onAction` | `((id: string) => void) \| null` | Optional callback fired alongside `tc-action` | - -Each `ResultStat`: - -| Field | Type | Description | -|-------|------|-------------| -| `label` | string | Stat name | -| `value` | string \| number | Stat value (numbers are locale-formatted) | - -Each `ResultReward`: - -| Field | Type | Description | -|-------|------|-------------| -| `label` | string | Reward name | -| `glyph` | string? | Short symbol/initials shown before the label (rendered as text, `aria-hidden`) | -| `amount` | number \| string? | Optional amount shown after the label (numbers are locale-formatted) | -| `color` | string? | Optional CSS colour applied to the glyph | - -Each `ResultAction`: - -| Field | Type | Description | -|-------|------|-------------| -| `id` | string | Unique key emitted in the `tc-action` `detail` | -| `label` | string | Button text | -| `variant` | `default\|primary\|danger\|ghost`? | Button style (`default` → secondary, `primary` → ink, `danger` → danger, `ghost` → outline) | - -**Events** - -| Event | `detail` | Fired when | -|-------|----------|------------| -| `tc-action` | `{ id: string }` | An action button is clicked | - -**Slots:** none — the eyebrow, title, subtitle, stats, rewards, and actions are all generated from attributes / JS properties. - -**CSS custom properties (theming)** - -| Property | Default | Description | -|----------|---------|-------------| -| `--bs-result-screen-max-width` | `32rem` | Max width of the centred panel | -| `--bs-result-screen-padding` | `2rem` | Panel inner padding | -| `--bs-result-screen-gap` | `1rem` | Vertical gap between sections | -| `--bs-result-screen-bg` | `var(--tc-surface)` | Panel background | -| `--bs-result-screen-border-color` | `var(--tc-border)` | 1px hairline frame color | -| `--bs-result-screen-shadow` | `var(--tc-shadow-sm)` | Panel drop shadow | -| `--bs-result-screen-eyebrow-color` | `var(--tc-text-muted)` | Eyebrow text color | -| `--bs-result-screen-eyebrow-size` | `0.6875rem` | Eyebrow font size | -| `--bs-result-screen-eyebrow-spacing` | `0.12em` | Eyebrow letter-spacing | -| `--bs-result-screen-title-size` | `2rem` | Title font size | -| `--bs-result-screen-title-weight` | `600` | Title font weight | -| `--bs-result-screen-title-color` | `var(--tc-warning)` | Title color (overridden by `data-title-color`) | -| `--bs-result-screen-divider-color` | `var(--tc-border)` | Hairline divider color | -| `--bs-result-screen-divider-width` | `2.5rem` | Hairline divider width | -| `--bs-result-screen-subtitle-color` | `var(--tc-text-muted)` | Subtitle text color | -| `--bs-result-screen-subtitle-size` | `0.9375rem` | Subtitle font size | -| `--bs-result-screen-stat-border-color` | `var(--tc-border)` | Stat row separator color | -| `--bs-result-screen-stat-label-color` | `var(--tc-text-muted)` | Stat label text color | -| `--bs-result-screen-stat-value-color` | `var(--tc-text)` | Stat value text color | -| `--bs-result-screen-rewards-bg` | `var(--tc-surface-muted)` | Rewards strip background | -| `--bs-result-screen-rewards-border-color` | `var(--tc-border)` | Rewards strip 1px border | -| `--bs-result-screen-reward-label-color` | `var(--tc-text)` | Reward label color | -| `--bs-result-screen-reward-amount-color` | `var(--tc-text-muted)` | Reward amount color | -| `--bs-result-screen-reward-glyph-color` | `var(--tc-text)` | Reward glyph color | - -```html - - ``` --- -### tc-game-over-screen +### tc-data-list -Game-over / result end screen: a centred region with a mono uppercase eyebrow, a status-toned title, a short hairline divider, an optional subtitle, a column of hairline-separated stat rows, a soft reward strip, and a wrapped row of action buttons. Stats, rewards, and actions are supplied via JS properties; the title text/colour, subtitle, and eyebrow are attributes. Clicking an action fires `tc-action` with that action's `id`. Ported from the game-components `gc-game-over-screen` (a `gc-result-screen` defaulting to "Game Over" / "Defeat" in a danger tone), restyled to the toolcase design system — flat slate surface, hairline borders, sharp corners, mono machine-facing text, `.btn` action primitives, and no gilded frame / diamond divider / metal buttons. +Generic, data-driven row list. Owns the skeleton every domain list repeats — an `items` array that re-renders on assignment, an optional `list-title` header, an `empty-text` fallback, delegated per-row action buttons, and an optional single-select listbox mode. Domain rendering is supplied at the call site through the `renderRow` function property, so one element replaces the family of near-identical "render an array of rows" components (mute / team / credits / achievement / … lists). Each row must carry a `data-id`; a per-row button marks itself with `data-action="…"`. -**Tag:** `tc-game-over-screen` +**Tag:** `tc-data-list` **Attributes** | Attribute | Type | Default | Description | |-----------|------|---------|-------------| -| `title-text` | string | `Game Over` | The large title heading | -| `subtitle` | string | — | Optional supporting line under the divider | -| `title-color` | `gold\|danger\|parch` | `danger` | Status tone of the title (`danger` → danger ramp, `gold` → warning ramp, `parch` → neutral slate) | -| `eyebrow` | string | `Defeat` | The mono uppercase micro-label above the title | - -The host element automatically gains `role="region"` (unless one is already set). +| `list-title` | string | — | Optional header label shown above the rows. Omit for a bare list. | +| `empty-text` | string | `Nothing to show.` | Text rendered when `items` is empty. | +| `selectable` | boolean | absent | When present, the body becomes a `listbox`, rows become focusable `option`s, and clicking/Enter/Space on a row selects it (`tc-select`). | +| `selected-id` | string | — | The currently selected row's id (selectable mode). Reflected onto rows as `aria-selected` + a `--selected` modifier without a full rebuild. | **JS Properties** -| Property | Type | Description | -|----------|------|-------------| -| `stats` | `GameOverStat[]` | Stat rows (set via JS, not attribute); setting re-renders | -| `rewards` | `GameOverReward[]` | Reward chips shown in the reward strip | -| `actions` | `GameOverAction[]` | Action buttons rendered at the bottom | -| `titleText` | string | Mirror of the `title-text` attribute | -| `subtitle` | string | Mirror of the `subtitle` attribute | -| `titleColor` | `gold\|danger\|parch` | Mirror of the `title-color` attribute | -| `eyebrow` | string | Mirror of the `eyebrow` attribute | -| `onAction` | `((id: string) => void) \| null` | Optional callback fired alongside `tc-action` | +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `items` | `any[]` | `[]` | Row data. Re-renders on each set; the getter returns a copy. | +| `renderRow` | `(item, index) => string` | `null` | Row renderer returning the full markup for one row (typically an `
  • `). Without it, a built-in row reads `id` / `label` / `secondary` / `trailing` off each item. | +| `selectedId` | `string` | `''` | Get/set the selected row id (mirrors `selected-id`). | +| `onAction` | `(detail: { action, id }) => void` \| `null` | `null` | Callback mirror of the `tc-action` event. | +| `onSelect` | `(detail: { id }) => void` \| `null` | `null` | Callback mirror of the `tc-select` event. | -Each `GameOverStat`: +**Row markup contract** -| Field | Type | Description | -|-------|------|-------------| -| `label` | string | Stat name | -| `value` | string \| number | Stat value (numbers are locale-formatted) | +`renderRow` returns whatever markup you like, but the delegated handlers rely on two hooks: the row root carries `data-id=""`, and any actionable button carries `data-action=""`. Reusable BEM parts ship in the stylesheet: `__row` (with `--selected`), `__icon`, `__text` + `__primary` / `__secondary`, `__trailing`, `__action`, `__empty`. -Each `GameOverReward`: +**Events** -| Field | Type | Description | -|-------|------|-------------| -| `label` | string | Reward name | -| `glyph` | string? | Short symbol/initials shown before the label (rendered as text, `aria-hidden`) | -| `amount` | number \| string? | Optional amount shown after the label (numbers are locale-formatted) | -| `color` | string? | Optional CSS colour applied to the glyph | +| Event | `detail` | Fired when | +|-------|----------|-----------| +| `tc-action` | `{ action: string, id: string }` | A `[data-action]` button inside a row is clicked. `action` is the button's `data-action`; `id` is the row's `data-id`. | +| `tc-select` | `{ id: string }` | A row is selected in `selectable` mode (click or Enter/Space). Also updates `selected-id`. | -Each `GameOverAction`: +Both bubble and are `composed`. -| Field | Type | Description | -|-------|------|-------------| -| `id` | string | Unique key emitted in the `tc-action` `detail` | -| `label` | string | Button text | -| `variant` | `default\|primary\|danger\|ghost`? | Button style (`default` → secondary, `primary` → ink, `danger` → danger, `ghost` → outline) | +**Slots** -**Events** +None. Content is driven by `items` + `renderRow`. -| Event | `detail` | Fired when | -|-------|----------|------------| -| `tc-action` | `{ id: string }` | An action button is clicked | +**CSS custom properties (theming)** -**Slots:** none — the eyebrow, title, subtitle, stats, rewards, and actions are all generated from attributes / JS properties. +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-data-list-bg` | `var(--tc-surface)` | Panel background. | +| `--bs-data-list-border` | `1px solid var(--tc-border)` | Outer frame. | +| `--bs-data-list-separator` | `1px solid var(--tc-border)` | Row/header separators. | +| `--bs-data-list-row-hover-bg` | `var(--tc-surface-hover)` | Row hover background. | +| `--bs-data-list-row-selected-bg` | `var(--tc-surface-muted)` | Selected row background (selectable mode). | +| `--bs-data-list-header-bg` | `var(--tc-surface-muted)` | Header strip background. | +| `--bs-data-list-title-color` | `var(--tc-text)` | Header title color. | +| `--bs-data-list-primary-color` | `var(--tc-text)` | Primary line color. | +| `--bs-data-list-secondary-color` | `var(--tc-text-muted)` | Secondary line color. | +| `--bs-data-list-trailing-color` | `var(--tc-text-faint)` | Trailing meta color (mono). | +| `--bs-data-list-action-bg` | `var(--tc-surface)` | Action button background. | +| `--bs-data-list-action-hover-bg` | `var(--tc-app-accent)` | Action button hover background. | +| `--bs-data-list-action-hover-color` | `#fff` | Action button hover text. | +| `--bs-data-list-empty-color` | `var(--tc-text-faint)` | Empty-state text color. | + +```html + + + ``` --- +### tc-result-screen -### tc-victory-screen +Match / round result screen: a centred region with a mono uppercase eyebrow, a status-toned title, a short hairline divider, an optional subtitle, a column of hairline-separated stat rows, a soft reward strip, and a wrapped row of action buttons. Stats, rewards, and actions are supplied via JS properties; the title text/colour, subtitle, and eyebrow are attributes. Clicking an action fires `tc-action` with that action's `id`. Ported from the game-components `gc-result-screen`, restyled to the toolcase design system — flat slate surface, hairline borders, sharp corners, mono machine-facing text, `.btn` action primitives, and no gilded frame / diamond divider / metal buttons. -Victory end screen: a centred region with a mono uppercase eyebrow, a gold-toned title, a short hairline divider, an optional subtitle, a column of hairline-separated stat rows, a soft reward strip, and a wrapped row of action buttons. Stats, rewards, and actions are supplied via JS properties; the title text/colour, subtitle, and eyebrow are attributes. Clicking an action fires `tc-action` with that action's `id`. Ported from the game-components `gc-victory-screen` (a `gc-result-screen` defaulting to "Victory!" / "Triumph" in a gold tone), restyled to the toolcase design system — flat slate surface, hairline borders, sharp corners, mono machine-facing text, `.btn` action primitives, and no gilded frame / diamond divider / metal buttons. +**Tag:** `tc-result-screen` -**Tag:** `tc-victory-screen` +**Preset aliases:** `tc-game-over-screen` (variant `defeat` — "Game Over" / danger) and `tc-victory-screen` (variant `victory` — "Victory!" / gold) are aliases of `tc-result-screen` that infer the variant from the tag name. Everything below applies identically. **Attributes** | Attribute | Type | Default | Description | |-----------|------|---------|-------------| -| `title-text` | string | `Victory!` | The large title heading | +| `variant` | `neutral\|defeat\|victory` | `neutral` (per tag) | Seeds the `title-text` / `title-color` / `eyebrow` defaults. `defeat` → "Game Over" / danger / "Defeat"; `victory` → "Victory!" / gold / "Triumph"; `neutral` → "" / gold / "Result". The alias tags default this from their tag name. | +| `title-text` | string | (per variant) | The large title heading | | `subtitle` | string | — | Optional supporting line under the divider | -| `title-color` | `gold\|danger\|parch` | `gold` | Status tone of the title (`gold` → warning ramp, `danger` → danger ramp, `parch` → neutral slate) | -| `eyebrow` | string | `Triumph` | The mono uppercase micro-label above the title | +| `title-color` | `gold\|danger\|parch` | (per variant) | Status tone of the title (`gold` → warning ramp, `danger` → danger ramp, `parch` → neutral slate) | +| `eyebrow` | string | (per variant) | The mono uppercase micro-label above the title | The host element automatically gains `role="region"` (unless one is already set). @@ -16591,23 +15715,24 @@ The host element automatically gains `role="region"` (unless one is already set) | Property | Type | Description | |----------|------|-------------| -| `stats` | `VictoryStat[]` | Stat rows (set via JS, not attribute); setting re-renders | -| `rewards` | `VictoryReward[]` | Reward chips shown in the reward strip | -| `actions` | `VictoryAction[]` | Action buttons rendered at the bottom | +| `stats` | `ResultStat[]` | Stat rows (set via JS, not attribute); setting re-renders | +| `rewards` | `ResultReward[]` | Reward chips shown in the reward strip | +| `actions` | `ResultAction[]` | Action buttons rendered at the bottom | +| `variant` | `neutral\|defeat\|victory` | Mirror of the `variant` attribute | | `titleText` | string | Mirror of the `title-text` attribute | | `subtitle` | string | Mirror of the `subtitle` attribute | | `titleColor` | `gold\|danger\|parch` | Mirror of the `title-color` attribute | | `eyebrow` | string | Mirror of the `eyebrow` attribute | | `onAction` | `((id: string) => void) \| null` | Optional callback fired alongside `tc-action` | -Each `VictoryStat`: +Each `ResultStat`: | Field | Type | Description | |-------|------|-------------| | `label` | string | Stat name | | `value` | string \| number | Stat value (numbers are locale-formatted) | -Each `VictoryReward`: +Each `ResultReward`: | Field | Type | Description | |-------|------|-------------| @@ -16616,7 +15741,7 @@ Each `VictoryReward`: | `amount` | number \| string? | Optional amount shown after the label (numbers are locale-formatted) | | `color` | string? | Optional CSS colour applied to the glyph | -Each `VictoryAction`: +Each `ResultAction`: | Field | Type | Description | |-------|------|-------------| @@ -16632,21 +15757,52 @@ Each `VictoryAction`: **Slots:** none — the eyebrow, title, subtitle, stats, rewards, and actions are all generated from attributes / JS properties. +**CSS custom properties (theming)** + +| Property | Default | Description | +|----------|---------|-------------| +| `--bs-result-screen-max-width` | `32rem` | Max width of the centred panel | +| `--bs-result-screen-padding` | `2rem` | Panel inner padding | +| `--bs-result-screen-gap` | `1rem` | Vertical gap between sections | +| `--bs-result-screen-bg` | `var(--tc-surface)` | Panel background | +| `--bs-result-screen-border-color` | `var(--tc-border)` | 1px hairline frame color | +| `--bs-result-screen-shadow` | `var(--tc-shadow-sm)` | Panel drop shadow | +| `--bs-result-screen-eyebrow-color` | `var(--tc-text-muted)` | Eyebrow text color | +| `--bs-result-screen-eyebrow-size` | `0.6875rem` | Eyebrow font size | +| `--bs-result-screen-eyebrow-spacing` | `0.12em` | Eyebrow letter-spacing | +| `--bs-result-screen-title-size` | `2rem` | Title font size | +| `--bs-result-screen-title-weight` | `600` | Title font weight | +| `--bs-result-screen-title-color` | `var(--tc-warning)` | Title color (overridden by `data-title-color`) | +| `--bs-result-screen-divider-color` | `var(--tc-border)` | Hairline divider color | +| `--bs-result-screen-divider-width` | `2.5rem` | Hairline divider width | +| `--bs-result-screen-subtitle-color` | `var(--tc-text-muted)` | Subtitle text color | +| `--bs-result-screen-subtitle-size` | `0.9375rem` | Subtitle font size | +| `--bs-result-screen-stat-border-color` | `var(--tc-border)` | Stat row separator color | +| `--bs-result-screen-stat-label-color` | `var(--tc-text-muted)` | Stat label text color | +| `--bs-result-screen-stat-value-color` | `var(--tc-text)` | Stat value text color | +| `--bs-result-screen-rewards-bg` | `var(--tc-surface-muted)` | Rewards strip background | +| `--bs-result-screen-rewards-border-color` | `var(--tc-border)` | Rewards strip 1px border | +| `--bs-result-screen-reward-label-color` | `var(--tc-text)` | Reward label color | +| `--bs-result-screen-reward-amount-color` | `var(--tc-text-muted)` | Reward amount color | +| `--bs-result-screen-reward-glyph-color` | `var(--tc-text)` | Reward glyph color | + ```html - + @@ -23403,6 +22559,8 @@ End-of-match statistics panel. Port of `gc-stats-screen` (game-components), rest Primary call-to-action button ported from `gc-metal-button` (game-components), restyled to the toolcase design system. Game-specific chrome (metal textures, gilded frames, glows) is dropped; the button renders with slate neutrals, sharp corners (`border-radius: 0`), a 1px hairline border, and the ink primary gradient for the `primary` variant. Slotted children become the button label. Disabled state is enforced natively on the inner ` - - - - -``` - ---- - ### tc-perk-picker Grid of selectable perk cards with selected and locked states. Perks are supplied via the `perks` JS property; each card shows an optional icon tile, a name, and an optional description. Fires `tc-select` on click, Enter, or Space. The `columns` attribute controls the CSS grid column count. Locked perks are inert (reduced opacity, not focusable). Ported from the game-components `gc-perk-picker`, restyled to the toolcase design system (flat slate cards, hairline borders, sharp corners, ink fill for the selected card, lucide lock icon for locked entries). @@ -25632,113 +24623,6 @@ None. `tc-rune-corner` renders entirely from its attributes and CSS. ``` ---- - -### tc-save-slot-list - -Save/load slot list with per-slot metadata, autosave badge, selected state, and action buttons (Load, Save/Overwrite, Delete). Port of `gc-save-slot-list` (game-components), restyled to the slate/ink design system with sharp corners, JetBrains Mono, 1px hairline borders, and `--bs-save-slot-list-*` custom properties as the theming contract. - -**Tag:** `tc-save-slot-list` - -#### Attributes - -| Attribute | Type | Default | Description | -|---|---|---|---| -| `mode` | `'load' \| 'save'` | `'load'` | Determines which action buttons are shown: `load` shows Load + Delete; `save` shows Save/Overwrite + Delete | -| `selected-id` | `string` | `''` | ID of the currently selected slot; adds an ink accent left-border to that row | - -#### JS Properties - -| Property | Type | Description | -|---|---|---| -| `slots` | `SaveSlot[]` | Array of save slot objects to render. Setting re-renders the list. | -| `mode` | `SaveSlotMode` | Reflects the `mode` attribute | -| `selectedId` | `string` | Reflects the `selected-id` attribute | -| `onSelect` | `((id: string) => void) \| null` | Callback fired when a row is selected by click or keyboard | -| `onLoad` | `((id: string) => void) \| null` | Callback fired when the Load button is clicked | -| `onSave` | `((id: string) => void) \| null` | Callback fired when the Save/Overwrite button is clicked | -| `onDelete` | `((id: string) => void) \| null` | Callback fired when the Delete button is clicked | - -**`SaveSlot` shape:** - -| Field | Type | Description | -|---|---|---| -| `id` | `string` | Unique slot identifier | -| `name` | `string?` | Display name of the save | -| `timestamp` | `string?` | Human-readable time string shown at the row end | -| `location` | `string?` | In-game location label | -| `level` | `number?` | Player level shown in meta row | -| `playtime` | `string?` | Formatted playtime string | -| `empty` | `boolean?` | When `true` renders an "Empty Slot" placeholder; Load mode suppresses all action buttons | -| `autosave` | `boolean?` | When `true` adds an "Auto" badge and disables the Save button in save mode; suppresses Delete in both modes | - -#### Events - -| Event | `detail` | Description | -|---|---|---| -| `tc-select` | `{ id: string }` | Fired when a row background is clicked or activated with Enter/Space | -| `tc-load` | `{ id: string }` | Fired when the Load button is clicked (load mode only) | -| `tc-save` | `{ id: string }` | Fired when the Save/Overwrite button is clicked (save mode only) | -| `tc-delete` | `{ id: string }` | Fired when the Delete button is clicked | - -#### Slots - -None. All content is driven by the `slots` JS property. - -#### CSS Custom Properties - -| Property | Default | Description | -|---|---|---| -| `--bs-save-slot-list-bg` | `var(--tc-surface)` | Panel background | -| `--bs-save-slot-list-border` | `1px solid var(--tc-border)` | Panel outer border | -| `--bs-save-slot-list-row-border` | `1px solid var(--tc-border)` | Row divider | -| `--bs-save-slot-list-row-hover-bg` | `var(--tc-surface-hover, …)` | Row hover background | -| `--bs-save-slot-list-row-selected-bg` | `var(--tc-surface-muted)` | Selected row background | -| `--bs-save-slot-list-row-selected-border-color` | `var(--tc-app-accent)` | Selected row left-border accent color | -| `--bs-save-slot-list-name-color` | `var(--tc-text)` | Slot name text color | -| `--bs-save-slot-list-name-font-size` | `0.875rem` | Slot name font size | -| `--bs-save-slot-list-name-empty-color` | `var(--tc-text-faint)` | Empty slot label color | -| `--bs-save-slot-list-meta-color` | `var(--tc-text-muted)` | Meta field text color | -| `--bs-save-slot-list-meta-font-size` | `0.75rem` | Meta field font size | -| `--bs-save-slot-list-timestamp-color` | `var(--tc-text-faint)` | Timestamp text color | -| `--bs-save-slot-list-timestamp-font-size` | `0.6875rem` | Timestamp font size | -| `--bs-save-slot-list-autosave-bg` | `transparent` | Autosave badge background | -| `--bs-save-slot-list-autosave-color` | `var(--tc-text-muted)` | Autosave badge text color | -| `--bs-save-slot-list-autosave-border-color` | `var(--tc-border-strong)` | Autosave badge border color | -| `--bs-save-slot-list-btn-font-size` | `0.75rem` | Action button font size | -| `--bs-save-slot-list-btn-min-height` | `1.75rem` | Action button minimum height | -| `--bs-save-slot-list-btn-bg` | `var(--tc-surface)` | Action button background | -| `--bs-save-slot-list-btn-color` | `var(--tc-text)` | Action button text color | -| `--bs-save-slot-list-btn-border` | `1px solid var(--tc-border-strong)` | Action button border | -| `--bs-save-slot-list-btn-hover-bg` | `var(--tc-app-accent)` | Action button hover background | -| `--bs-save-slot-list-btn-hover-color` | `#fff` | Action button hover text color | -| `--bs-save-slot-list-btn-disabled-opacity` | `0.45` | Disabled button opacity | -| `--bs-save-slot-list-btn-danger-color` | `var(--tc-danger, #ef4444)` | Delete button text color | -| `--bs-save-slot-list-btn-danger-border-color` | `var(--tc-danger, #ef4444)` | Delete button border color | -| `--bs-save-slot-list-btn-danger-hover-bg` | `var(--tc-danger, #ef4444)` | Delete button hover background | -| `--bs-save-slot-list-btn-danger-hover-color` | `#fff` | Delete button hover text color | -| `--bs-save-slot-list-empty-color` | `var(--tc-text-faint)` | Empty-state message color | - -#### Example - -```html - - - -``` - --- ### tc-settings-category-list diff --git a/examples/src/App.tsx b/examples/src/App.tsx index c4c2a6c4..e79ac639 100644 --- a/examples/src/App.tsx +++ b/examples/src/App.tsx @@ -85,12 +85,34 @@ const routeThemes: Record = { { value: 'default', label: 'Default' }, { value: 'dark', label: 'Dark' }, ], + 'web-components': [ + { value: 'default', label: 'Default' }, + { value: 'dungeon', label: 'Dungeon' }, + ], } const ExampleWrapper = ({ children, route, example }: ExampleWrapperProps) => { const navigate = useNavigate() const themes = routeThemes[route.key] - const [theme, setTheme] = useState('default') + // Persist the chosen theme per package so it sticks while browsing the section. + const themeStorageKey = `tc-examples-theme:${route.key}` + const [theme, setTheme] = useState(() => { + if (!themes) return 'default' + try { + return localStorage.getItem(themeStorageKey) ?? 'default' + } catch { + return 'default' + } + }) + + useEffect(() => { + if (!themes) return + try { + localStorage.setItem(themeStorageKey, theme) + } catch { + /* ignore unavailable storage */ + } + }, [theme, themes, themeStorageKey]) const index = route.examples.findIndex((e) => e.key === example.key) const prev = index > 0 ? route.examples[index - 1] : null const next = index < route.examples.length - 1 ? route.examples[index + 1] : null @@ -142,7 +164,9 @@ const ExampleWrapper = ({ children, route, example }: ExampleWrapperProps) => {
    - {route.key === 'react-components' && theme === 'dark' ? ( + {route.key === 'web-components' ? ( +
    {children}
    + ) : route.key === 'react-components' && theme === 'dark' ? (
    {children}
    ) : ( children diff --git a/examples/src/pages/Home.tsx b/examples/src/pages/Home.tsx index 9a662d49..e72b8939 100644 --- a/examples/src/pages/Home.tsx +++ b/examples/src/pages/Home.tsx @@ -5,6 +5,7 @@ import { loggingExamples } from '../logging/index' import { serializerExamples } from '../serializer/index' import { examples as reactComponentExamples } from '../react-components/index' import { gameComponentExamples } from '../game-components/index' +import { webComponentExamples } from '../web-components/index' import { phaserExamples } from '../phaser-plus/index' import { nodeExamples } from '../node/index' import { versions } from '../versions' @@ -66,6 +67,17 @@ const libs: LibCard[] = [ pkg: '@toolcase/react-components', path: '/react-components', }, + { + key: 'web-components', + scope: '@toolcase/', + name: 'web-components', + tagline: 'Framework-free HTML5 Web Components with from-scratch toolcase styling — drop into any stack, no React, Vue, or Angular required.', + category: 'UI · Web Components', + version: versions['web-components'], + examples: webComponentExamples.length, + pkg: '@toolcase/web-components', + path: '/web-components', + }, { key: 'game-components', scope: '@toolcase/', @@ -129,7 +141,7 @@ export const Home = () => {
    Index / Libraries

    A small set of tools I keep
    reaching for, every project.

    - Seven npm packages I've built, used, and rewritten across a decade of web apps and games. + Eight npm packages I've built, used, and rewritten across a decade of web apps and games. Documented and demoed here so you can install one in a single line and see how it behaves.

    diff --git a/examples/src/pages/WebComponentsPage.tsx b/examples/src/pages/WebComponentsPage.tsx index 00fe7c5c..fdd99215 100644 --- a/examples/src/pages/WebComponentsPage.tsx +++ b/examples/src/pages/WebComponentsPage.tsx @@ -1,10 +1,19 @@ import { Breadcrumbs, CategorySection, ExampleGrid, InstallBlock, PackageIntro, SkillInstall } from './_chrome' -import { webComponentExamples, categories } from '../web-components/index' +import { webComponentExamples, complexities, type WebComponentComplexity } from '../web-components/index' import { versions } from '../versions' const formatLabel = (key: string) => key.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ') +// Demos are grouped by component complexity (derived from the source size of the +// underlying tc-* element) rather than by domain category. +const complexityBlurb: Record = { + Primitives: 'Atomic building blocks — single-element wrappers, layout primitives, and tiny display helpers.', + Simple: 'Single-purpose styled components with a handful of attributes.', + Composite: 'Multi-part components that assemble several pieces — cards, lists, rows, and richer controls.', + Advanced: 'Complex, stateful, or data-driven components — tables, editors, charts, panels, and full screens.', +} + export const WebComponentsPage = () => { return (
    @@ -24,11 +33,16 @@ export const WebComponentsPage = () => { - {categories.map((category) => { - const items = webComponentExamples.filter((e) => e.category === category) + {complexities.map((complexity) => { + const items = webComponentExamples.filter((e) => e.complexity === complexity) if (items.length === 0) return null return ( - + ({ key: e.key, label: formatLabel(e.key) }))} diff --git a/examples/src/pages/_chrome.tsx b/examples/src/pages/_chrome.tsx index 5e804d97..2360778e 100644 --- a/examples/src/pages/_chrome.tsx +++ b/examples/src/pages/_chrome.tsx @@ -186,10 +186,12 @@ export const ExampleGrid = ({ export const CategorySection = ({ title, count, + subtitle, children, }: { title: string count?: number + subtitle?: string children: ReactNode }) => ( <> @@ -199,6 +201,7 @@ export const CategorySection = ({ {String(count).padStart(2, '0')} / {String(count).padStart(2, '0')} )} + {subtitle &&

    {subtitle}

    } {children} ) diff --git a/examples/src/style.css b/examples/src/style.css index a2939841..307598de 100644 --- a/examples/src/style.css +++ b/examples/src/style.css @@ -204,6 +204,13 @@ code, pre, .mono { font-family: var(--font-mono); } font-size: 12px; color: var(--fg-3); } +.section-subtitle { + margin: -8px 0 20px; + max-width: 60ch; + font-size: 14px; + line-height: 1.5; + color: var(--fg-3); +} /* Library cards grid */ .lib-grid { @@ -700,6 +707,13 @@ code, pre, .mono { font-family: var(--font-mono); } .example__canvas > .theme--dark { min-height: calc(100vh - 130px); } +/* web-components theme wrapper: fill the canvas and paint the active theme's + surface/text so dark + accent themes get a proper themed ground */ +.example__canvas > .wc-theme-scope { + min-height: calc(100vh - 130px); + background: var(--tc-surface); + color: var(--tc-text); +} /* Phaser canvas wrapper */ .tp-dfwv { diff --git a/examples/src/web-components/AchievementListDemo.tsx b/examples/src/web-components/AchievementListDemo.tsx deleted file mode 100644 index a838fd58..00000000 --- a/examples/src/web-components/AchievementListDemo.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import React, { useEffect, useRef } from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -const achievements = [ - { - id: 'first-blood', - name: 'First Blood', - description: 'Defeat your first enemy.', - icon: 'swords', - unlocked: true, - points: 10, - }, - { - id: 'explorer', - name: 'Explorer', - description: 'Discover 25 hidden locations across the world.', - icon: 'map', - progress: 18, - target: 25, - points: 50, - }, - { - id: 'collector', - name: 'Collector', - description: 'Gather every rare artifact in the realm.', - icon: 'gem', - progress: 7, - target: 40, - points: 100, - }, - { - id: 'untouchable', - name: 'Untouchable', - description: 'Finish a boss fight without taking damage.', - icon: 'shield', - unlocked: false, - points: 75, - }, - { - id: 'secret-ending', - name: 'The True Ending', - description: 'You should not be able to read this.', - secret: true, - points: 250, - }, -] - -const mixed = [ - { - id: 'speedrun', - name: 'Speedrunner', - description: 'Complete the campaign in under two hours.', - icon: 'timer', - unlocked: true, - points: 200, - }, - { - id: 'pacifist', - name: 'Pacifist', - description: 'Reach the final act without defeating any enemy.', - icon: 'feather', - progress: 3, - target: 5, - points: 150, - }, - { - id: 'maxed', - name: 'Maxed Out', - description: 'Reach the maximum character level.', - icon: 'trending-up', - progress: 42, - target: 99, - }, - { - id: 'completionist', - name: 'Completionist', - description: 'Unlock every other achievement.', - icon: 'trophy', - unlocked: false, - }, -] - -const AchievementListDemo: React.FC = () => { - const mainRef = useRef(null) - const mixedRef = useRef(null) - - useEffect(() => { - if (mainRef.current) mainRef.current.achievements = achievements - if (mixedRef.current) mixedRef.current.achievements = mixed - }, []) - - return ( -
    -
    -
    -
    - Web Components} - title="AchievementList" - description="Scrollable list of achievements with locked, in-progress, and unlocked states plus an optional progress bar. Set achievements via the achievements JS property. Secret achievements stay hidden until unlocked." - /> - -
    - -
    - {/* @ts-ignore */} - -
    -
    - - -
    - {/* @ts-ignore */} - -
    -
    -
    -
    -
    -
    -
    - ) -} - -export default AchievementListDemo diff --git a/examples/src/web-components/AnnouncementBarDemo.tsx b/examples/src/web-components/AnnouncementBarDemo.tsx deleted file mode 100644 index 6d346d61..00000000 --- a/examples/src/web-components/AnnouncementBarDemo.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -const AnnouncementBarDemo: React.FC = () => { - const dismissibleRef = useRef(null) - const [dismissed, setDismissed] = useState(false) - - useEffect(() => { - const el = dismissibleRef.current - if (!el) return - const handler = () => { - setDismissed(true) - console.log('tc-dismiss fired') - } - el.addEventListener('tc-dismiss', handler) - return () => el.removeEventListener('tc-dismiss', handler) - }, []) - - return ( -
    -
    -
    -
    - Web Components} - title="Announcement Bar" - description="Persistent announcement bar with optional CTA link, icon, and localStorage-backed dismissal. Supports info, success, warning, and announce variants." - /> - -
    - -
    - {/* @ts-ignore */} - - New docs are available — check them out. - {/* @ts-ignore */} - - {/* @ts-ignore */} - - Your workspace has been updated successfully. - {/* @ts-ignore */} - - {/* @ts-ignore */} - - Maintenance window scheduled for Sunday 02:00 UTC. - {/* @ts-ignore */} - - {/* @ts-ignore */} - - Toolcase v3 is here — explore the new component library. - {/* @ts-ignore */} - -
    -
    - - -
    - {/* @ts-ignore */} - - System status: all services are operational. - {/* @ts-ignore */} - - {/* @ts-ignore */} - - Deployment complete — zero downtime. - {/* @ts-ignore */} - - {/* @ts-ignore */} - - Some features may be degraded during peak hours. - {/* @ts-ignore */} - - {/* @ts-ignore */} - - Join our next community call on June 20 — RSVP now. - {/* @ts-ignore */} - -
    -
    - - -
    - {/* @ts-ignore */} - - Version 3.2.0 includes breaking changes to the token API. - {/* @ts-ignore */} - - {/* @ts-ignore */} - - Toolcase is now open-source. Give it a star on GitHub. - {/* @ts-ignore */} - -
    -
    - - -

    - {dismissed - ? 'Bar dismissed — tc-dismiss fired (check console).' - : 'Click × to dismiss. The tc-dismiss event is logged to the console.'} -

    - {/* @ts-ignore */} - - Your trial expires in 3 days. Upgrade to keep your data. - {/* @ts-ignore */} - -
    - - -

    - Closing this bar writes "dismissed" to{' '} - localStorage["tc-demo-announcement"]. It stays hidden - on reload. Clear the key in DevTools to see it again. -

    - {/* @ts-ignore */} - - Toolcase Web Components are now available in v3. - {/* @ts-ignore */} - -
    -
    -
    -
    -
    -
    - ) -} - -export default AnnouncementBarDemo diff --git a/examples/src/web-components/BannerDemo.tsx b/examples/src/web-components/BannerDemo.tsx index 990bc39f..9f600a35 100644 --- a/examples/src/web-components/BannerDemo.tsx +++ b/examples/src/web-components/BannerDemo.tsx @@ -131,6 +131,21 @@ const BannerDemo: React.FC = () => { {/* @ts-ignore */} + + +
    + {/* @ts-ignore */} + + Toolcase v3 is here — explore the new component library. + {/* @ts-ignore */} + + {/* @ts-ignore */} + + New docs are available — uses the legacy persist-dismiss-key attribute. + {/* @ts-ignore */} + +
    +
    diff --git a/examples/src/web-components/CreditsListDemo.tsx b/examples/src/web-components/CreditsListDemo.tsx deleted file mode 100644 index 0f3e5668..00000000 --- a/examples/src/web-components/CreditsListDemo.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React, { useEffect, useRef } from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -const gameCredits = [ - { role: 'Game Director', names: ['Mira Calloway'] }, - { role: 'Lead Engineering', names: ['Tomas Reyes', 'Aiko Nakamura'] }, - { role: 'Art & Animation', names: ['Priya Anand', 'Lukas Berg', 'Sofia Marek'] }, - { role: 'Sound Design', names: ['Daniel Cho'] }, -] - -const teamCredits = [ - { role: 'Maintainers', names: ['Dafo Kalevski', 'Jordan Wells'] }, - { role: 'Core Contributors', names: ['Elena Ruiz', 'Marco Tan', 'Nadia Osei', 'Kenji Watanabe'] }, - { role: 'Documentation', names: ['Hannah Lindqvist'] }, - { role: 'Special Thanks', names: ['The entire community'] }, -] - -const CreditsListDemo: React.FC = () => { - const gameRef = useRef(null) - const teamRef = useRef(null) - - useEffect(() => { - if (gameRef.current) gameRef.current.sections = gameCredits - if (teamRef.current) teamRef.current.sections = teamCredits - }, []) - - return ( -
    -
    -
    -
    - Web Components} - title="CreditsList" - description="Static grouped credits roll — each section pairs a role heading (JetBrains Mono micro-label) with one or more names (Inter). Set sections via the sections JS property." - /> - -
    - -
    - {/* @ts-ignore */} - -
    -
    - - -
    - {/* @ts-ignore */} - -
    -
    -
    -
    -
    -
    -
    - ) -} - -export default CreditsListDemo diff --git a/examples/src/web-components/DataListDemo.tsx b/examples/src/web-components/DataListDemo.tsx new file mode 100644 index 00000000..111ac498 --- /dev/null +++ b/examples/src/web-components/DataListDemo.tsx @@ -0,0 +1,321 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +// tc-data-list is the generic, data-driven row list. Domain rendering lives at +// the call site via the `renderRow` hook — the four examples below reproduce the +// former tc-mute-list / tc-team-list / tc-credits-list / tc-achievement-list +// purely as renderRow functions over a single shared element. + +function esc(s: unknown): string { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function deriveInitials(name: string): string { + const w = name.trim().split(/\s+/).filter(Boolean) + if (w.length === 0) return '?' + if (w.length === 1) return w[0][0].toUpperCase() + return (w[0][0] + w[1][0]).toUpperCase() +} + +const MUTED = [ + { id: '1', name: 'ToxicWizard92', reason: 'Spam', mutedAt: '2d ago' }, + { id: '2', name: 'ChatBot_AFK', mutedAt: '1w ago' }, + { id: '3', name: 'Griefer404', reason: 'Harassment', mutedAt: '1mo ago' }, +] + +const TEAM = [ + { id: '1', name: 'Kate Moore', email: 'kate@example.com', role: 'Lead' }, + { id: '2', name: 'Liam Scott', initials: 'LS', email: 'liam@example.com', role: 'Backend' }, + { id: '3', name: 'Mia Chen', email: 'mia@example.com', avatarUrl: 'https://i.pravatar.cc/64?img=9', role: 'Frontend' }, + { id: '4', name: 'Noah Patel', email: 'noah@example.com', role: 'DevOps' }, +] + +const CREDITS = [ + { role: 'Game Director', names: ['Mira Calloway'] }, + { role: 'Lead Engineering', names: ['Tomas Reyes', 'Aiko Nakamura'] }, + { role: 'Art & Animation', names: ['Priya Anand', 'Lukas Berg', 'Sofia Marek'] }, + { role: 'Sound Design', names: ['Daniel Cho'] }, +] + +const ACHIEVEMENTS = [ + { id: 'first-blood', name: 'First Blood', description: 'Defeat your first enemy.', unlocked: true, points: 10 }, + { id: 'explorer', name: 'Explorer', description: 'Discover 25 hidden locations.', progress: 18, target: 25, points: 50 }, + { id: 'collector', name: 'Collector', description: 'Gather every rare artifact.', progress: 7, target: 40, points: 100 }, + { id: 'untouchable', name: 'Untouchable', description: 'Finish a boss without taking damage.', unlocked: false, points: 75 }, +] + +const STATUS_COLOR: Record = { + 'in-game': '#a855f7', + online: '#16a34a', + busy: '#dc2626', + away: '#d97706', + offline: 'var(--tc-border-strong)', +} +const STATUS_ORDER = ['in-game', 'online', 'busy', 'away', 'offline'] + +const FRIENDS = [ + { id: '1', name: 'Aria', status: 'online' }, + { id: '2', name: 'Kestrel', status: 'in-game', activity: 'Ranked — Round 3' }, + { id: '3', name: 'Vesper', status: 'busy', activity: 'Do not disturb' }, + { id: '4', name: 'Lumen', status: 'away', activity: 'Idle 12m', rank: 'Gold' }, + { id: '5', name: 'Onyx', status: 'offline' }, +].sort((a, b) => { + const d = STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status) + return d !== 0 ? d : a.name.localeCompare(b.name) +}) + +const SLOTS = [ + { id: 'auto', name: 'Auto Save', meta: 'Lv 24 · Ember Keep · 12h 04m', autosave: true }, + { id: 's1', name: 'The Long Road', meta: 'Lv 22 · Vale of Mist · 11h 02m' }, + { id: 's2', name: 'Before the Boss', meta: 'Lv 19 · Catacombs · 8h 41m' }, + { id: 's3', empty: true }, +] + +// ── renderRow hooks ────────────────────────────────────────────────────────── + +const renderMuted = (p: any) => + `
  • ` + + `
    ` + + `${esc(p.name)}` + + (p.reason ? `${esc(p.reason)}` : '') + + `
    ` + + (p.mutedAt ? `${esc(p.mutedAt)}` : '') + + `` + + `
  • ` + +const renderMember = (m: any) => { + const avatar = m.avatarUrl + ? `${esc(m.name)}` + : `` + return ( + `
  • ` + + avatar + + `
    ` + + `${esc(m.name)}` + + (m.email ? `${esc(m.email)}` : '') + + `
    ` + + (m.role ? `${esc(m.role)}` : '') + + `
  • ` + ) +} + +const renderCredit = (s: any) => + `
  • ` + + `
    ` + + `${esc(s.role)}` + + (s.names as string[]).map(n => `${esc(n)}`).join('') + + `
    ` + + `
  • ` + +const renderAchievement = (a: any) => { + const hasProgress = !a.unlocked && typeof a.target === 'number' + const pct = hasProgress ? Math.min(100, Math.round((a.progress / a.target) * 100)) : 0 + const dot = `` + const progress = hasProgress + ? `
    ` + + `${a.progress}/${a.target}` + : '' + return ( + `
  • ` + + dot + + `
    ` + + `${esc(a.name)}` + + (a.description ? `${esc(a.description)}` : '') + + progress + + `
    ` + + (typeof a.points === 'number' ? `${a.points} pts` : '') + + `
  • ` + ) +} + +const renderFriend = (f: any) => { + const color = STATUS_COLOR[f.status] ?? STATUS_COLOR.offline + const activity = f.activity ?? (f.status === 'offline' ? 'Offline' : '') + return ( + `
  • ` + + `` + + `
    ` + + `${esc(f.name)}` + + (activity ? `${esc(activity)}` : '') + + `
    ` + + (f.rank ? `${esc(f.rank)}` : '') + + `` + + `` + + `
  • ` + ) +} + +// Selectable (listbox) row — note role="option" + tabindex so keyboard select works. +const renderSlot = (s: any) => { + const primary = s.empty + ? `Empty Slot` + : `${esc(s.name)}` + const meta = s.meta ? `${esc(s.meta)}` : '' + const actions = s.empty + ? '' + : `` + + (s.autosave ? '' : ``) + return ( + `
  • ` + + `
    ` + + primary + + meta + + `
    ` + + (s.autosave ? `Auto` : '') + + actions + + `
  • ` + ) +} + +const DataListDemo: React.FC = () => { + const muteRef = useRef(null) + const teamRef = useRef(null) + const creditsRef = useRef(null) + const achRef = useRef(null) + const friendsRef = useRef(null) + const slotsRef = useRef(null) + const emptyRef = useRef(null) + const [log, setLog] = useState([]) + const [slotLog, setSlotLog] = useState([]) + + useEffect(() => { + if (muteRef.current) { + muteRef.current.renderRow = renderMuted + muteRef.current.items = MUTED + const handler = (e: any) => + setLog(l => [`tc-action — action: "${e.detail.action}", id: "${e.detail.id}"`, ...l].slice(0, 8)) + muteRef.current.addEventListener('tc-action', handler) + return () => muteRef.current?.removeEventListener('tc-action', handler) + } + }, []) + + useEffect(() => { + if (teamRef.current) { + teamRef.current.renderRow = renderMember + teamRef.current.items = TEAM + } + }, []) + + useEffect(() => { + if (creditsRef.current) { + creditsRef.current.renderRow = renderCredit + creditsRef.current.items = CREDITS + } + }, []) + + useEffect(() => { + if (achRef.current) { + achRef.current.renderRow = renderAchievement + achRef.current.items = ACHIEVEMENTS + } + }, []) + + useEffect(() => { + const el = friendsRef.current + if (!el) return + const online = FRIENDS.filter(f => f.status !== 'offline').length + el.setAttribute('list-title', `Friends · ${online}/${FRIENDS.length}`) + el.renderRow = renderFriend + el.items = FRIENDS + }, []) + + useEffect(() => { + const el = slotsRef.current + if (!el) return + el.renderRow = renderSlot + el.items = SLOTS + const onAction = (e: any) => + setSlotLog(l => [`tc-action — "${e.detail.action}" id="${e.detail.id}"`, ...l].slice(0, 8)) + const onSelect = (e: any) => + setSlotLog(l => [`tc-select — id="${e.detail.id}"`, ...l].slice(0, 8)) + el.addEventListener('tc-action', onAction) + el.addEventListener('tc-select', onSelect) + return () => { + el.removeEventListener('tc-action', onAction) + el.removeEventListener('tc-select', onSelect) + } + }, []) + + return ( +
    +
    +
    +
    + Web Components} + title="DataList" + description="Generic data-driven row list. Assign items (array) plus a renderRow(item, index) hook returning each row's markup. Per-row buttons marked data-action fire tc-action with { action, id }; selectable mode adds listbox selection via tc-select. The four examples below reproduce the former mute / team / credits / achievement lists with no bespoke element." + /> + +
    + + {/* @ts-ignore */} + +
    + Event log + {log.length === 0 ? ( + Click Unmute… + ) : ( +
      + {log.map((line, i) => ( +
    • {line}
    • + ))} +
    + )} +
    +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + +
    + Event log + {slotLog.length === 0 ? ( + Select a slot or click an action… + ) : ( +
      + {slotLog.map((line, i) => ( +
    • {line}
    • + ))} +
    + )} +
    +
    + + + {/* @ts-ignore */} + + +
    +
    +
    +
    +
    + ) +} + +export default DataListDemo diff --git a/examples/src/web-components/DeadzoneSliderDemo.tsx b/examples/src/web-components/DeadzoneSliderDemo.tsx deleted file mode 100644 index ae5cf136..00000000 --- a/examples/src/web-components/DeadzoneSliderDemo.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -function useDeadzoneValue(initial: number): [number, React.RefObject] { - const [value, setValue] = useState(initial) - const ref = useRef(null) - - useEffect(() => { - const el = ref.current - if (!el) return - const handler = (e: Event) => { - const detail = (e as CustomEvent<{ value: number }>).detail - if (detail) setValue(detail.value) - } - el.addEventListener('tc-change', handler) - return () => el.removeEventListener('tc-change', handler) - }, []) - - return [value, ref] -} - -const DeadzoneSliderDemo: React.FC = () => { - const [v1, ref1] = useDeadzoneValue(0.15) - const [v2, ref2] = useDeadzoneValue(0.25) - - return ( -
    -
    -
    -
    - Web Components} - title="Deadzone Slider" - description="A stick-deadzone setting row: a label/description block paired with a 0–100% range control and a mono percentage readout. Built on the shared tc-setting-row scaffold." - /> - -
    - -
    - {/* @ts-ignore */} - -
    -
    - Current value: {v1.toFixed(2)} ({Math.round(v1 * 100)}%) -
    -
    - - -
    - {/* @ts-ignore */} - -
    -
    - Current value: {v2.toFixed(2)} ({Math.round(v2 * 100)}%) -
    -
    - - -
    - {/* @ts-ignore */} - -
    -
    -
    -
    -
    -
    -
    - ) -} - -export default DeadzoneSliderDemo diff --git a/examples/src/web-components/FOVSliderDemo.tsx b/examples/src/web-components/FOVSliderDemo.tsx deleted file mode 100644 index b6aaef72..00000000 --- a/examples/src/web-components/FOVSliderDemo.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -function useFovValue(initial: number): [number, React.RefObject] { - const [value, setValue] = useState(initial) - const ref = useRef(null) - - useEffect(() => { - const el = ref.current - if (!el) return - const handler = (e: Event) => { - const detail = (e as CustomEvent<{ value: number }>).detail - if (detail) setValue(detail.value) - } - el.addEventListener('tc-change', handler) - return () => el.removeEventListener('tc-change', handler) - }, []) - - return [value, ref] -} - -const FOVSliderDemo: React.FC = () => { - const [v1, ref1] = useFovValue(90) - const [v2, ref2] = useFovValue(103) - - return ( -
    -
    -
    -
    - Web Components} - title="FOV Slider" - description="A field-of-view setting row: a label/description block paired with a range control and a mono degree readout. Built on the shared tc-setting-row scaffold." - /> - -
    - -
    - {/* @ts-ignore */} - -
    -
    Current value: {Math.round(v1)}°
    -
    - - -
    - {/* @ts-ignore */} - -
    -
    Current value: {Math.round(v2)}°
    -
    - - -
    - {/* @ts-ignore */} - -
    -
    -
    -
    -
    -
    -
    - ) -} - -export default FOVSliderDemo diff --git a/examples/src/web-components/FriendsListDemo.tsx b/examples/src/web-components/FriendsListDemo.tsx deleted file mode 100644 index 6ae8a4b6..00000000 --- a/examples/src/web-components/FriendsListDemo.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -const FriendsListDemo: React.FC = () => { - const basicRef = useRef(null) - const rankedRef = useRef(null) - const eventsRef = useRef(null) - const titledRef = useRef(null) - - const [log, setLog] = useState([]) - - useEffect(() => { - if (basicRef.current) { - basicRef.current.friends = [ - { id: '1', name: 'Aria', status: 'online' }, - { id: '2', name: 'Kestrel', status: 'in-game', activity: 'Ranked — Round 3' }, - { id: '3', name: 'Vesper', status: 'busy', activity: 'Do not disturb' }, - { id: '4', name: 'Lumen', status: 'away', activity: 'Away' }, - { id: '5', name: 'Onyx', status: 'offline' }, - ] - } - }, []) - - useEffect(() => { - if (rankedRef.current) { - rankedRef.current.friends = [ - { id: '1', name: 'Aria', status: 'in-game', activity: 'Capture the Flag', rank: 'Diamond' }, - { id: '2', name: 'Kestrel', status: 'online', rank: 'Platinum' }, - { id: '3', name: 'Vesper', status: 'online', rank: 'Gold' }, - { id: '4', name: 'Onyx', status: 'offline', rank: 'Silver' }, - ] - } - }, []) - - useEffect(() => { - if (titledRef.current) { - titledRef.current.friends = [ - { id: '1', name: 'Nova', status: 'online' }, - { id: '2', name: 'Quill', status: 'away', activity: 'Idle 12m' }, - { id: '3', name: 'Sable', status: 'offline' }, - ] - } - }, []) - - useEffect(() => { - const el = eventsRef.current - if (!el) return - el.friends = [ - { id: 'a', name: 'Aria', status: 'online' }, - { id: 'b', name: 'Kestrel', status: 'in-game', activity: 'In lobby' }, - { id: 'c', name: 'Vesper', status: 'busy' }, - ] - const onInvite = (e: any) => setLog(l => [`invite → ${e.detail.id}`, ...l].slice(0, 5)) - const onMessage = (e: any) => setLog(l => [`message → ${e.detail.id}`, ...l].slice(0, 5)) - el.addEventListener('tc-invite', onInvite) - el.addEventListener('tc-message', onMessage) - return () => { - el.removeEventListener('tc-invite', onInvite) - el.removeEventListener('tc-message', onMessage) - } - }, []) - - return ( -
    -
    -
    -
    - Web Components} - title="FriendsList" - description="Friends roster with online/status pips, activity text, optional rank chips, and message/invite actions. Friends are set via the JS friends property; rows are auto-sorted by status (in-game → online → busy → away → offline) then name." - /> - -
    - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - -
    - Event log - {log.length === 0 ? ( - Click a message or invite action… - ) : ( -
      - {log.map((line, i) => ( -
    • {line}
    • - ))} -
    - )} -
    -
    -
    -
    -
    -
    -
    - ) -} - -export default FriendsListDemo diff --git a/examples/src/web-components/GameOverScreenDemo.tsx b/examples/src/web-components/GameOverScreenDemo.tsx deleted file mode 100644 index 2f1d1076..00000000 --- a/examples/src/web-components/GameOverScreenDemo.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -const STATS = [ - { label: 'Score', value: 18420 }, - { label: 'Waves cleared', value: 12 }, - { label: 'Time survived', value: '07:41' }, -] - -const REWARDS = [ - { glyph: '◈', label: 'Gold', amount: 320, color: 'var(--tc-warning)' }, - { glyph: '★', label: 'XP', amount: 1500 }, -] - -const ACTIONS = [ - { id: 'retry', label: 'Try Again', variant: 'primary' as const }, - { id: 'menu', label: 'Main Menu', variant: 'default' as const }, - { id: 'quit', label: 'Quit', variant: 'ghost' as const }, -] - -const GameOverScreenDemo: React.FC = () => { - const fullRef = useRef(null) - const minimalRef = useRef(null) - - const [lastAction, setLastAction] = useState('(none yet — click an action)') - - useEffect(() => { - const el = fullRef.current - if (!el) return - el.stats = STATS - el.rewards = REWARDS - el.actions = ACTIONS - - const onAction = (e: Event) => setLastAction((e as CustomEvent<{ id: string }>).detail.id) - el.addEventListener('tc-action', onAction) - return () => el.removeEventListener('tc-action', onAction) - }, []) - - useEffect(() => { - const el = minimalRef.current - if (!el) return - el.actions = [{ id: 'restart', label: 'Restart', variant: 'primary' }] - }, []) - - return ( -
    -
    -
    -
    - Web Components} - title="GameOverScreen" - description="Game-over end screen: a centred region with a mono eyebrow, a status-toned title, a hairline divider, an optional subtitle, hairline-separated stat rows, a soft reward strip, and a row of action buttons. Stats, rewards, and actions are supplied via JS properties; title text/colour, subtitle, and eyebrow are attributes. Emits tc-action with the clicked action's id." - /> - -
    - - {/* @ts-ignore */} - -
    - Last action: {lastAction} -
    -
    - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - -
    -
    -
    -
    -
    - ) -} - -export default GameOverScreenDemo diff --git a/examples/src/web-components/HealthBarDemo.tsx b/examples/src/web-components/HealthBarDemo.tsx deleted file mode 100644 index 6b7c9f7d..00000000 --- a/examples/src/web-components/HealthBarDemo.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -const HealthBarDemo: React.FC = () => { - return ( -
    -
    -
    -
    - Web Components} - title="HealthBar" - description="Value/max resource bar (HP, mana, stamina) — an ink fill over a flat slate track, an optional label row with a mono value/max readout, an optional ghost band for recent loss, and optional even segment dividers." - /> - -
    - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - -
    -
    -
    -
    -
    - ) -} - -export default HealthBarDemo diff --git a/examples/src/web-components/InvertAxisToggleDemo.tsx b/examples/src/web-components/InvertAxisToggleDemo.tsx deleted file mode 100644 index d4d2f032..00000000 --- a/examples/src/web-components/InvertAxisToggleDemo.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -function useInvertAxisValue(initial: boolean): [boolean, React.RefObject] { - const [value, setValue] = useState(initial) - const ref = useRef(null) - - useEffect(() => { - const el = ref.current - if (!el) return - const handler = (e: Event) => { - const detail = (e as CustomEvent<{ value: boolean }>).detail - if (detail) setValue(detail.value) - } - el.addEventListener('tc-change', handler) - return () => el.removeEventListener('tc-change', handler) - }, []) - - return [value, ref] -} - -const InvertAxisToggleDemo: React.FC = () => { - const [v1, ref1] = useInvertAxisValue(false) - const [v2, ref2] = useInvertAxisValue(true) - - return ( -
    -
    -
    -
    - Web Components} - title="Invert Axis Toggle" - description="An invert-axis on/off setting row: a label/description block paired with a pill-track switch. Built on the shared tc-setting-row scaffold; defaults its label to 'Invert Y axis'." - /> - -
    - -
    - {/* @ts-ignore */} - -
    -
    Current value: {String(v1)}
    -
    - - -
    - {/* @ts-ignore */} - -
    -
    Current value: {String(v2)}
    -
    - - -
    - {/* @ts-ignore */} - -
    -
    - - -
    - {/* @ts-ignore */} - -
    -
    -
    -
    -
    -
    -
    - ) -} - -export default InvertAxisToggleDemo diff --git a/examples/src/web-components/LeaderboardTrendDemo.tsx b/examples/src/web-components/LeaderboardTrendDemo.tsx deleted file mode 100644 index d23fe1ca..00000000 --- a/examples/src/web-components/LeaderboardTrendDemo.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -const LeaderboardTrendDemo: React.FC = () => ( -
    -
    -
    -
    - Web Components} - title="LeaderboardTrend" - description="Small directional trend indicator with an arrow icon and value. Three directions — up, down, flat — drive the icon and color. Purely presentational; designed to sit inline within table cells or metric rows." - /> - -
    - -
    - {/* @ts-ignore */} - - {/* @ts-ignore */} - - {/* @ts-ignore */} - -
    -
    - - -

    - When value is omitted, child nodes are projected into the value span. -

    -
    - {/* @ts-ignore */} - +5 pts - {/* @ts-ignore */} - -3 pts - {/* @ts-ignore */} - no change -
    -
    - - -
  • ` c --- -### tc-leaderboard-trend - -Small directional trend indicator with a Lucide arrow icon and a value. Three directions drive the icon shape and color. Designed to sit inline within table cells, metric rows, or leaderboard entries. Purely presentational — no interaction, no events. - -**Tag:** `tc-leaderboard-trend` - -**Attributes** - -| Attribute | Type | Default | Description | -|-----------|------|---------|-------------| -| `value` | string | — | The trend value text (e.g. `"+240"`, `"-8%"`). When set, rendered as escaped text. When omitted, slotted children are projected into the value span instead. | -| `direction` | `'up' \| 'down' \| 'flat'` | `'flat'` | Direction of the trend. Drives the arrow icon and color. `up` → `--tc-success`, `down` → `--tc-danger`, `flat` → `--tc-text-muted`. | - -**JS Properties** - -| Property | Type | Description | -|----------|------|-------------| -| `value` | `string \| null` | Reflects the `value` attribute. | -| `direction` | `LeaderboardTrendDirection` | Reflects the `direction` attribute. Defaults to `'flat'` when the attribute is absent or invalid. | - -**Events** - -None. `tc-leaderboard-trend` is purely presentational. - -**Slots** - -| Slot | Description | -|------|-------------| -| *(default)* | Value content when the `value` attribute is absent. Projected into the inner `.tc-leaderboard-trend-value` span. Useful for rich markup (e.g. `+5 pts`). | - -**Accessibility** - -The arrow icon SVG carries `aria-hidden="true"` so it is decorative. Direction is conveyed by both icon shape and color — not color alone. Reduced motion is honoured globally via the `prefers-reduced-motion` reset. - -**CSS Custom Properties** - -| Property | Default | Description | -|----------|---------|-------------| -| `--bs-leaderboard-trend-color` | direction-mapped | Text and icon color. `up` → `--tc-success`, `down` → `--tc-danger`, `flat` → `--tc-text-muted`. | -| `--bs-leaderboard-trend-icon-size` | `0.875em` | Icon width and height (relative to the element's font size). | -| `--bs-leaderboard-trend-font-size` | `12px` | Value text font size (JetBrains Mono, weight 500). | -| `--bs-leaderboard-trend-gap` | `0.25rem` | Gap between the icon and the value text. | - -```html - - - - - - -+5 pts - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    RankPlayerScoreTrend
    1Alice9,420 - {/* @ts-ignore */} - -
    2Bob8,870 - {/* @ts-ignore */} - -
    3Carol8,450 - {/* @ts-ignore */} - -
    - -
    -
    - - - -) - -export default LeaderboardTrendDemo diff --git a/examples/src/web-components/ManaBarDemo.tsx b/examples/src/web-components/ManaBarDemo.tsx deleted file mode 100644 index 0eba06eb..00000000 --- a/examples/src/web-components/ManaBarDemo.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -const ManaBarDemo: React.FC = () => { - return ( -
    -
    -
    -
    - Web Components} - title="ManaBar" - description="Value/max resource bar for mana (MP) — a cyan-accent fill over a flat slate track. Supports an optional label row with a mono value/max readout, a ghost band for recent loss, inline mono text, and evenly-spaced segment dividers." - /> - -
    - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - -
    -
    -
    -
    -
    - ) -} - -export default ManaBarDemo diff --git a/examples/src/web-components/MuteListDemo.tsx b/examples/src/web-components/MuteListDemo.tsx deleted file mode 100644 index afbb5640..00000000 --- a/examples/src/web-components/MuteListDemo.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -const PLAYERS = [ - { id: '1', name: 'ToxicWizard92', mutedAt: '2d ago', reason: 'Spam' }, - { id: '2', name: 'ChatBot_AFK', mutedAt: '1w ago' }, - { id: '3', name: 'Griefer404', mutedAt: '1mo ago', reason: 'Harassment' }, -] - -const MuteListDemo: React.FC = () => { - const populatedRef = useRef(null) - const eventsRef = useRef(null) - const [log, setLog] = useState([]) - - useEffect(() => { - if (!populatedRef.current) return - populatedRef.current.players = PLAYERS - }, []) - - useEffect(() => { - const el = eventsRef.current - if (!el) return - el.players = PLAYERS.slice(0, 2) - const handler = (e: CustomEvent) => - setLog(l => [`tc-unmute — id: "${e.detail.id}"`, ...l].slice(0, 8)) - el.addEventListener('tc-unmute', handler as EventListener) - return () => el.removeEventListener('tc-unmute', handler as EventListener) - }, []) - - return ( -
    -
    -
    -
    - Web Components} - title="MuteList" - description="A list of muted players with optional reason, timestamp, and a per-row Unmute button. Players are set via the JS players property. Clicking Unmute fires tc-unmute with the player id." - /> - -
    - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - -
    - Event log - {log.length === 0 ? ( - Click Unmute… - ) : ( -
      - {log.map((line, i) => ( -
    • {line}
    • - ))} -
    - )} -
    -
    -
    -
    -
    -
    -
    - ) -} - -export default MuteListDemo diff --git a/examples/src/web-components/PauseMenuDemo.tsx b/examples/src/web-components/PauseMenuDemo.tsx index 4d7a137c..999174a2 100644 --- a/examples/src/web-components/PauseMenuDemo.tsx +++ b/examples/src/web-components/PauseMenuDemo.tsx @@ -19,9 +19,11 @@ const BADGE_ITEMS = [ const PauseMenuDemo: React.FC = () => { const basicRef = useRef(null) const eventsRef = useRef(null) + const screenRef = useRef(null) const [basicOpen, setBasicOpen] = useState(false) const [eventsOpen, setEventsOpen] = useState(false) + const [screenOpen, setScreenOpen] = useState(false) const [log, setLog] = useState([]) const appendLog = (msg: string) => setLog(l => [msg, ...l].slice(0, 10)) @@ -71,6 +73,32 @@ const PauseMenuDemo: React.FC = () => { else eventsRef.current.removeAttribute('open') }, [eventsOpen]) + // tc-pause-screen preset — default resume/restart/quit items + named events. + useEffect(() => { + const el = screenRef.current + if (!el) return + const close = () => setScreenOpen(false) + const onResume = () => { appendLog('tc-resume fired'); close() } + const onRestart = () => { appendLog('tc-restart fired'); close() } + const onQuit = () => { appendLog('tc-quit fired'); close() } + el.addEventListener('tc-close', close) + el.addEventListener('tc-resume', onResume) + el.addEventListener('tc-restart', onRestart) + el.addEventListener('tc-quit', onQuit) + return () => { + el.removeEventListener('tc-close', close) + el.removeEventListener('tc-resume', onResume) + el.removeEventListener('tc-restart', onRestart) + el.removeEventListener('tc-quit', onQuit) + } + }, []) + + useEffect(() => { + if (!screenRef.current) return + if (screenOpen) screenRef.current.setAttribute('open', '') + else screenRef.current.removeAttribute('open') + }, [screenOpen]) + return (
    @@ -122,6 +150,20 @@ const PauseMenuDemo: React.FC = () => { )}
    + + + + {/* @ts-ignore */} + +
    + Seeds resume/restart/quit and re-dispatches tc-resume / tc-restart / tc-quit. +
    +
    diff --git a/examples/src/web-components/PauseScreenDemo.tsx b/examples/src/web-components/PauseScreenDemo.tsx deleted file mode 100644 index a471e914..00000000 --- a/examples/src/web-components/PauseScreenDemo.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -const DEFAULT_ITEMS = [ - { id: 'resume', label: 'Resume' }, - { id: 'restart', label: 'Restart' }, - { id: 'quit', label: 'Quit' }, -] - -const CUSTOM_ITEMS = [ - { id: 'resume', label: 'Resume' }, - { id: 'settings', label: 'Settings', badge: 'New' }, - { id: 'achievements', label: 'Achievements', badge: '3' }, - { id: 'leaderboard', label: 'Leaderboards', disabled: true }, - { id: 'quit', label: 'Quit to Menu' }, -] - -const PauseScreenDemo: React.FC = () => { - const defaultRef = useRef(null) - const customRef = useRef(null) - - const [defaultOpen, setDefaultOpen] = useState(false) - const [customOpen, setCustomOpen] = useState(false) - const [log, setLog] = useState([]) - - const appendLog = (msg: string) => setLog(l => [msg, ...l].slice(0, 10)) - - // Default items demo - useEffect(() => { - const el = defaultRef.current - if (!el) return - const onClose = () => setDefaultOpen(false) - const onResume = () => { appendLog('tc-resume fired'); setDefaultOpen(false) } - const onRestart = () => appendLog('tc-restart fired') - const onQuit = () => appendLog('tc-quit fired') - const onSelect = (e: CustomEvent) => appendLog(`tc-select — id: "${e.detail.id}"`) - el.addEventListener('tc-close', onClose) - el.addEventListener('tc-resume', onResume) - el.addEventListener('tc-restart', onRestart) - el.addEventListener('tc-quit', onQuit) - el.addEventListener('tc-select', onSelect as EventListener) - return () => { - el.removeEventListener('tc-close', onClose) - el.removeEventListener('tc-resume', onResume) - el.removeEventListener('tc-restart', onRestart) - el.removeEventListener('tc-quit', onQuit) - el.removeEventListener('tc-select', onSelect as EventListener) - } - }, []) - - useEffect(() => { - if (!defaultRef.current) return - if (defaultOpen) defaultRef.current.setAttribute('open', '') - else defaultRef.current.removeAttribute('open') - }, [defaultOpen]) - - // Custom items demo - useEffect(() => { - const el = customRef.current - if (!el) return - el.items = CUSTOM_ITEMS - const onClose = () => setCustomOpen(false) - const onSelect = (e: CustomEvent) => appendLog(`tc-select — id: "${e.detail.id}"`) - el.addEventListener('tc-close', onClose) - el.addEventListener('tc-select', onSelect as EventListener) - return () => { - el.removeEventListener('tc-close', onClose) - el.removeEventListener('tc-select', onSelect as EventListener) - } - }, []) - - useEffect(() => { - if (!customRef.current) return - if (customOpen) customRef.current.setAttribute('open', '') - else customRef.current.removeAttribute('open') - }, [customOpen]) - - return ( -
    -
    -
    -
    - Web Components} - title="PauseScreen" - description="Full-screen pause overlay with a backdrop, eyebrow header, and a keyboard-navigable item list. Controlled component — fire tc-close / tc-resume to dismiss. Escape and backdrop click emit tc-close. The default items list [Resume, Restart, Quit] fires dedicated tc-resume, tc-restart, and tc-quit events in addition to tc-select." - /> - -
    - - - {/* @ts-ignore */} - -
    - Event log - {log.length === 0 ? ( - Open the screen and interact… - ) : ( -
      - {log.map((line, i) => ( -
    • {line}
    • - ))} -
    - )} -
    -
    - - - - {/* @ts-ignore */} - - -
    -
    -
    -
    -
    - ) -} - -export default PauseScreenDemo diff --git a/examples/src/web-components/ResourceBarDemo.tsx b/examples/src/web-components/ResourceBarDemo.tsx new file mode 100644 index 00000000..851cc1ea --- /dev/null +++ b/examples/src/web-components/ResourceBarDemo.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +const ResourceBarDemo: React.FC = () => { + return ( +
    +
    +
    +
    + Web Components} + title="ResourceBar" + description="Value/max resource bar for a game HUD — an ink fill over a flat slate track, an optional label row with a mono value/max readout, an optional ghost band for recent loss, and optional even segment dividers. The variant attribute selects the fill color; tc-health-bar / tc-mana-bar / tc-stamina-bar are presets." + /> + +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + + + +
    +
    +
    +
    +
    + ) +} + +export default ResourceBarDemo diff --git a/examples/src/web-components/ResultScreenDemo.tsx b/examples/src/web-components/ResultScreenDemo.tsx index 9bdee6e7..81f86b93 100644 --- a/examples/src/web-components/ResultScreenDemo.tsx +++ b/examples/src/web-components/ResultScreenDemo.tsx @@ -29,6 +29,8 @@ const ResultScreenDemo: React.FC = () => { const fullRef = useRef(null) const victoryRef = useRef(null) const minimalRef = useRef(null) + const defeatRef = useRef(null) + const winRef = useRef(null) const [lastAction, setLastAction] = useState('(none yet — click an action)') @@ -58,6 +60,16 @@ const ResultScreenDemo: React.FC = () => { el.actions = [{ id: 'continue', label: 'Continue', variant: 'primary' }] }, []) + useEffect(() => { + const el = defeatRef.current + if (el) el.actions = ACTIONS + const win = winRef.current + if (win) { + win.stats = STATS + win.actions = VICTORY_ACTIONS + } + }, []) + return (
    @@ -107,6 +119,16 @@ const ResultScreenDemo: React.FC = () => { {/* @ts-ignore */} + + + {/* @ts-ignore */} + + + + + {/* @ts-ignore */} + +
    diff --git a/examples/src/web-components/SaveSlotListDemo.tsx b/examples/src/web-components/SaveSlotListDemo.tsx deleted file mode 100644 index 4a688d82..00000000 --- a/examples/src/web-components/SaveSlotListDemo.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -const DEMO_SLOTS = [ - { id: 'auto', name: 'Auto Save', timestamp: '2 min ago', location: 'Ember Keep', level: 24, playtime: '12h 04m', autosave: true }, - { id: 's1', name: 'The Long Road', timestamp: 'Yesterday 22:14', location: 'Vale of Mist', level: 22, playtime: '11h 02m' }, - { id: 's2', name: 'Before the Boss', timestamp: '3 days ago', location: 'Catacombs', level: 19, playtime: '8h 41m' }, - { id: 's3', empty: true }, - { id: 's4', empty: true }, -] - -const SaveSlotListDemo: React.FC = () => { - const loadRef = useRef(null) - const saveRef = useRef(null) - const emptyRef = useRef(null) - const eventsRef = useRef(null) - - const [log, setLog] = useState([]) - - useEffect(() => { - if (loadRef.current) loadRef.current.slots = DEMO_SLOTS - if (saveRef.current) saveRef.current.slots = DEMO_SLOTS - if (emptyRef.current) emptyRef.current.slots = [] - }, []) - - useEffect(() => { - const el = eventsRef.current - if (!el) return - el.slots = DEMO_SLOTS - - const onSelect = (e: CustomEvent) => setLog(l => [`tc-select id="${e.detail.id}"`, ...l].slice(0, 8)) - const onLoad = (e: CustomEvent) => setLog(l => [`tc-load id="${e.detail.id}"`, ...l].slice(0, 8)) - const onSave = (e: CustomEvent) => setLog(l => [`tc-save id="${e.detail.id}"`, ...l].slice(0, 8)) - const onDelete = (e: CustomEvent) => setLog(l => [`tc-delete id="${e.detail.id}"`, ...l].slice(0, 8)) - - el.addEventListener('tc-select', onSelect as EventListener) - el.addEventListener('tc-load', onLoad as EventListener) - el.addEventListener('tc-save', onSave as EventListener) - el.addEventListener('tc-delete', onDelete as EventListener) - - return () => { - el.removeEventListener('tc-select', onSelect as EventListener) - el.removeEventListener('tc-load', onLoad as EventListener) - el.removeEventListener('tc-save', onSave as EventListener) - el.removeEventListener('tc-delete', onDelete as EventListener) - } - }, []) - - return ( -
    -
    -
    -
    - Web Components} - title="SaveSlotList" - description="Save/load slot list with autosave, empty, and selected states. The mode attribute switches between Load and Save/Overwrite/Delete actions." - /> - -
    - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - -
    - Event log - {log.length === 0 ? ( - Click a row or button to see events… - ) : ( -
      - {log.map((line, i) => ( -
    • {line}
    • - ))} -
    - )} -
    -
    -
    -
    -
    -
    -
    - ) -} - -export default SaveSlotListDemo diff --git a/examples/src/web-components/SettingSliderDemo.tsx b/examples/src/web-components/SettingSliderDemo.tsx new file mode 100644 index 00000000..a55e04fe --- /dev/null +++ b/examples/src/web-components/SettingSliderDemo.tsx @@ -0,0 +1,98 @@ +import React, { useEffect, useRef, useState } from 'react' +import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' + +function useSliderValue(initial: number): [number, boolean, React.RefObject] { + const [value, setValue] = useState(initial) + const [muted, setMuted] = useState(false) + const ref = useRef(null) + + useEffect(() => { + const el = ref.current + if (!el) return + + const onchange = (e: Event) => { + const detail = (e as CustomEvent<{ value: number }>).detail + if (detail) setValue(detail.value) + } + const ontoggle = () => { + setMuted(m => { + const next = !m + if (el) el.muted = next + return next + }) + } + + el.addEventListener('tc-change', onchange) + el.addEventListener('tc-toggle-mute', ontoggle) + return () => { + el.removeEventListener('tc-change', onchange) + el.removeEventListener('tc-toggle-mute', ontoggle) + } + }, []) + + return [value, muted, ref] +} + +const wellStyle = { maxWidth: 520, border: '1px solid var(--tc-border)' } + +const SettingSliderDemo: React.FC = () => { + const [vol, muted, refVol] = useSliderValue(0.8) + + return ( +
    +
    +
    +
    + Web Components} + title="Setting Slider" + description="A generic range-slider setting row: a native range input paired with a mono readout, plus an optional mute button. The readout format is driven by `format` (percent / int / float + unit). tc-volume-slider, tc-deadzone-slider and tc-fov-slider are presets." + /> + +
    + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + Current: {Math.round(vol * 100)}% {muted ? '(muted)' : ''} +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    + + +
    + {/* @ts-ignore */} + +
    +
    +
    +
    +
    +
    +
    + ) +} + +export default SettingSliderDemo diff --git a/examples/src/web-components/StaminaBarDemo.tsx b/examples/src/web-components/StaminaBarDemo.tsx deleted file mode 100644 index d7e39a04..00000000 --- a/examples/src/web-components/StaminaBarDemo.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -const StaminaBarDemo: React.FC = () => { - return ( -
    -
    -
    -
    - Web Components} - title="StaminaBar" - description="Value/max resource bar for stamina (SP) — a green success fill over a flat slate track. Supports an optional label row with a mono value/max readout, a ghost band for recent drain, inline mono text inside the track, and evenly-spaced segment dividers." - /> - -
    - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - -
    -
    -
    -
    -
    - ) -} - -export default StaminaBarDemo diff --git a/examples/src/web-components/TeamListDemo.tsx b/examples/src/web-components/TeamListDemo.tsx deleted file mode 100644 index 9044fe60..00000000 --- a/examples/src/web-components/TeamListDemo.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { useEffect, useRef } from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -const TeamListDemo: React.FC = () => { - const derivedRef = useRef(null) - const explicitRef = useRef(null) - const imgRef = useRef(null) - const rolesRef = useRef(null) - const mixedRef = useRef(null) - - useEffect(() => { - if (derivedRef.current) { - derivedRef.current.members = [ - { id: '1', name: 'Alice Johnson' }, - { id: '2', name: 'Bob Smith' }, - { id: '3', name: 'Carol White' }, - ] - } - }, []) - - useEffect(() => { - if (explicitRef.current) { - explicitRef.current.members = [ - { id: '1', name: 'Dave Kumar', initials: 'DK', email: 'dave@example.com' }, - { id: '2', name: 'Eva Müller', initials: 'EM', email: 'eva@example.com' }, - ] - } - }, []) - - useEffect(() => { - if (imgRef.current) { - imgRef.current.members = [ - { - id: '1', - name: 'Frank Lee', - email: 'frank@example.com', - avatarUrl: 'https://i.pravatar.cc/64?img=3', - }, - { - id: '2', - name: 'Grace Park', - email: 'grace@example.com', - avatarUrl: 'https://i.pravatar.cc/64?img=5', - }, - ] - } - }, []) - - useEffect(() => { - if (rolesRef.current) { - rolesRef.current.members = [ - { id: '1', name: 'Henry Zhao', email: 'henry@example.com', role: 'Engineering' }, - { id: '2', name: 'Iris Nakamura', email: 'iris@example.com', role: 'Design' }, - { id: '3', name: 'Jack Rivera', email: 'jack@example.com', role: 'Product' }, - ] - } - }, []) - - useEffect(() => { - if (mixedRef.current) { - mixedRef.current.members = [ - { id: '1', name: 'Kate Moore', email: 'kate@example.com', role: 'Lead', gradient: true }, - { id: '2', name: 'Liam Scott', initials: 'LS', email: 'liam@example.com', role: 'Backend', gradient: true }, - { id: '3', name: 'Mia Chen', email: 'mia@example.com', avatarUrl: 'https://i.pravatar.cc/64?img=9', role: 'Frontend' }, - { id: '4', name: 'Noah Patel', email: 'noah@example.com', role: 'DevOps', gradient: true }, - ] - } - }, []) - - return ( -
    -
    -
    -
    - Web Components} - title="TeamList" - description="List of team members with gradient avatar tiles, names, emails, and optional role chips. Members are set via the JS members property." - /> - -
    - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - -
    -
    -
    -
    -
    - ) -} - -export default TeamListDemo diff --git a/examples/src/web-components/ToggleRowDemo.tsx b/examples/src/web-components/ToggleRowDemo.tsx index f39b0487..309f07e1 100644 --- a/examples/src/web-components/ToggleRowDemo.tsx +++ b/examples/src/web-components/ToggleRowDemo.tsx @@ -78,6 +78,18 @@ const ToggleRowDemo: React.FC = () => { /> + + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + +
    +
    + The former tc-vsync-toggle / tc-invert-axis-toggle presets are just tc-toggle-row with a fixed row-label. +
    +
    diff --git a/examples/src/web-components/TrendIndicatorDemo.tsx b/examples/src/web-components/TrendIndicatorDemo.tsx index 356363b5..6e2f70c3 100644 --- a/examples/src/web-components/TrendIndicatorDemo.tsx +++ b/examples/src/web-components/TrendIndicatorDemo.tsx @@ -50,6 +50,17 @@ const TrendIndicatorDemo: React.FC = () => { + +
    + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + no change +
    +
    +

    Revenue $42,180{' '} diff --git a/examples/src/web-components/VSyncToggleDemo.tsx b/examples/src/web-components/VSyncToggleDemo.tsx deleted file mode 100644 index 72b44fe8..00000000 --- a/examples/src/web-components/VSyncToggleDemo.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -function useVSyncValue(initial: boolean): [boolean, React.RefObject] { - const [value, setValue] = useState(initial) - const ref = useRef(null) - - useEffect(() => { - const el = ref.current - if (!el) return - const handler = (e: Event) => { - const detail = (e as CustomEvent<{ value: boolean }>).detail - if (detail) setValue(detail.value) - } - el.addEventListener('tc-change', handler) - return () => el.removeEventListener('tc-change', handler) - }, []) - - return [value, ref] -} - -const VSyncToggleDemo: React.FC = () => { - const [v1, ref1] = useVSyncValue(false) - const [v2, ref2] = useVSyncValue(true) - - return ( -

    -
    -
    -
    - Web Components} - title="VSync Toggle" - description="A vsync on/off setting row: a label/description block paired with a pill-track switch. Built on the shared tc-setting-row scaffold; defaults its label to 'V-Sync'." - /> - -
    - -
    - {/* @ts-ignore */} - -
    -
    Current value: {String(v1)}
    -
    - - -
    - {/* @ts-ignore */} - -
    -
    Current value: {String(v2)}
    -
    - - -
    - {/* @ts-ignore */} - -
    -
    - - -
    - {/* @ts-ignore */} - -
    -
    -
    -
    -
    -
    -
    - ) -} - -export default VSyncToggleDemo diff --git a/examples/src/web-components/VictoryScreenDemo.tsx b/examples/src/web-components/VictoryScreenDemo.tsx deleted file mode 100644 index f63075b6..00000000 --- a/examples/src/web-components/VictoryScreenDemo.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -const STATS = [ - { label: 'Score', value: 24800 }, - { label: 'Waves cleared', value: 20 }, - { label: 'Time survived', value: '12:33' }, - { label: 'Accuracy', value: '87%' }, -] - -const REWARDS = [ - { glyph: '◈', label: 'Gold', amount: 640, color: 'var(--tc-warning)' }, - { glyph: '★', label: 'XP', amount: 3000 }, - { glyph: '◆', label: 'Gems', amount: 5, color: 'var(--tc-info)' }, -] - -const ACTIONS = [ - { id: 'continue', label: 'Continue', variant: 'primary' as const }, - { id: 'retry', label: 'Play Again', variant: 'default' as const }, - { id: 'menu', label: 'Main Menu', variant: 'ghost' as const }, -] - -const VictoryScreenDemo: React.FC = () => { - const fullRef = useRef(null) - const minimalRef = useRef(null) - - const [lastAction, setLastAction] = useState('(none yet — click an action)') - - useEffect(() => { - const el = fullRef.current - if (!el) return - el.stats = STATS - el.rewards = REWARDS - el.actions = ACTIONS - - const onAction = (e: Event) => setLastAction((e as CustomEvent<{ id: string }>).detail.id) - el.addEventListener('tc-action', onAction) - return () => el.removeEventListener('tc-action', onAction) - }, []) - - useEffect(() => { - const el = minimalRef.current - if (!el) return - el.actions = [{ id: 'continue', label: 'Continue', variant: 'primary' }] - }, []) - - return ( -
    -
    -
    -
    - Web Components} - title="VictoryScreen" - description="Victory end screen: a centred region with a mono eyebrow, a gold-toned title, a hairline divider, an optional subtitle, hairline-separated stat rows, a soft reward strip, and a row of action buttons. Stats, rewards, and actions are supplied via JS properties; title text/colour, subtitle, and eyebrow are attributes. Emits tc-action with the clicked action's id." - /> - -
    - - {/* @ts-ignore */} - -
    - Last action: {lastAction} -
    -
    - - - {/* @ts-ignore */} - - - - - {/* @ts-ignore */} - - -
    -
    -
    -
    -
    - ) -} - -export default VictoryScreenDemo diff --git a/examples/src/web-components/VolumeSliderDemo.tsx b/examples/src/web-components/VolumeSliderDemo.tsx deleted file mode 100644 index a432f19d..00000000 --- a/examples/src/web-components/VolumeSliderDemo.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react' -import { RichPageHeader, RichPageHeaderChip, SectionCard } from '@toolcase/react-components' - -function useVolumeValue(initial: number): [number, boolean, React.RefObject] { - const [value, setValue] = useState(initial) - const [muted, setMuted] = useState(false) - const ref = useRef(null) - - useEffect(() => { - const el = ref.current - if (!el) return - - const onchange = (e: Event) => { - const detail = (e as CustomEvent<{ value: number }>).detail - if (detail) setValue(detail.value) - } - const ontoggle = () => { - setMuted(m => { - const next = !m - // Reflect the toggled state back into the element. - if (el) el.muted = next - return next - }) - } - - el.addEventListener('tc-change', onchange) - el.addEventListener('tc-toggle-mute', ontoggle) - return () => { - el.removeEventListener('tc-change', onchange) - el.removeEventListener('tc-toggle-mute', ontoggle) - } - }, []) - - return [value, muted, ref] -} - -const VolumeSliderDemo: React.FC = () => { - const [v1, muted1, ref1] = useVolumeValue(0.8) - - return ( -
    -
    -
    -
    - Web Components} - title="Volume Slider" - description="A volume setting row: a mute toggle button, a 0–100% range slider, and a mono percentage readout. Built on the shared tc-setting-row scaffold. Port of game-components gc-volume-slider with the fantasy chrome dropped for the toolcase slate/ink look." - /> - -
    - -
    - {/* @ts-ignore */} - -
    -
    - Current: {Math.round(v1 * 100)}% {muted1 ? '(muted)' : ''} -
    -
    - - -
    - {/* @ts-ignore */} - -
    -
    - - -
    - {/* @ts-ignore */} - -
    -
    - - -
    - {/* @ts-ignore */} - -
    -
    -
    -
    -
    -
    -
    - ) -} - -export default VolumeSliderDemo diff --git a/examples/src/web-components/index.tsx b/examples/src/web-components/index.tsx index 2470b958..6b208081 100644 --- a/examples/src/web-components/index.tsx +++ b/examples/src/web-components/index.tsx @@ -13,7 +13,6 @@ import RowDemo from './RowDemo' import ColDemo from './ColDemo' import AccordionDemo from './AccordionDemo' import AlertDemo from './AlertDemo' -import AnnouncementBarDemo from './AnnouncementBarDemo' import BadgeDemo from './BadgeDemo' import BrandDemo from './BrandDemo' import BadgeRowDemo from './BadgeRowDemo' @@ -53,9 +52,7 @@ import CheckDemo from './CheckDemo' import CheckboxGroupDemo from './CheckboxGroupDemo' import CharacterCreateDemo from './CharacterCreateDemo' import CharacterSelectDemo from './CharacterSelectDemo' -import GameOverScreenDemo from './GameOverScreenDemo' import ResultScreenDemo from './ResultScreenDemo' -import VictoryScreenDemo from './VictoryScreenDemo' import GamepadButtonPromptDemo from './GamepadButtonPromptDemo' import GraphicsPresetPickerDemo from './GraphicsPresetPickerDemo' import RadioDemo from './RadioDemo' @@ -99,9 +96,7 @@ import BundleBarDemo from './BundleBarDemo' import BossBarDemo from './BossBarDemo' import BuffBarDemo from './BuffBarDemo' import BuffIconDemo from './BuffIconDemo' -import HealthBarDemo from './HealthBarDemo' -import ManaBarDemo from './ManaBarDemo' -import StaminaBarDemo from './StaminaBarDemo' +import ResourceBarDemo from './ResourceBarDemo' import BrightnessCalibrationDemo from './BrightnessCalibrationDemo' import CalloutQuoteDemo from './CalloutQuoteDemo' import ChartContainerDemo from './ChartContainerDemo' @@ -143,7 +138,6 @@ import AmmoCounterDemo from './AmmoCounterDemo' import ComboCounterDemo from './ComboCounterDemo' import GoodFirstIssuesDemo from './GoodFirstIssuesDemo' import HeroStatsBarDemo from './HeroStatsBarDemo' -import LeaderboardTrendDemo from './LeaderboardTrendDemo' import LinkedProvidersCardDemo from './LinkedProvidersCardDemo' import LogoCloudDemo from './LogoCloudDemo' import MaintainerCardDemo from './MaintainerCardDemo' @@ -163,8 +157,7 @@ import SimpleFileDemo from './SimpleFileDemo' import RankCellDemo from './RankCellDemo' import RichPageHeaderDemo from './RichPageHeaderDemo' import ScoringRulesDemo from './ScoringRulesDemo' -import CreditsListDemo from './CreditsListDemo' -import AchievementListDemo from './AchievementListDemo' +import DataListDemo from './DataListDemo' import BattlePassDemo from './BattlePassDemo' import SectionCardDemo from './SectionCardDemo' import SponsorWallDemo from './SponsorWallDemo' @@ -172,8 +165,6 @@ import SprintChainDemo from './SprintChainDemo' import StatCardDemo from './StatCardDemo' import StateMachineDemo from './StateMachineDemo' import StepperDemo from './StepperDemo' -import TeamListDemo from './TeamListDemo' -import FriendsListDemo from './FriendsListDemo' import GuildPanelDemo from './GuildPanelDemo' import TierLadderDemo from './TierLadderDemo' import TimelineDemo from './TimelineDemo' @@ -251,7 +242,6 @@ import NavButtonDemo from './NavButtonDemo' import LootListDemo from './LootListDemo' import LootPopupDemo from './LootPopupDemo' import PauseMenuDemo from './PauseMenuDemo' -import PauseScreenDemo from './PauseScreenDemo' import PerkPickerDemo from './PerkPickerDemo' import PortraitDemo from './PortraitDemo' import PressAnyKeyDemo from './PressAnyKeyDemo' @@ -269,17 +259,13 @@ import NumberInputDemo from './NumberInputDemo' import OTPInputDemo from './OTPInputDemo' import PhoneInputDemo from './PhoneInputDemo' import RangeSliderDemo from './RangeSliderDemo' -import DeadzoneSliderDemo from './DeadzoneSliderDemo' -import FOVSliderDemo from './FOVSliderDemo' +import SettingSliderDemo from './SettingSliderDemo' import MouseSensitivityDemo from './MouseSensitivityDemo' -import VolumeSliderDemo from './VolumeSliderDemo' import ResetToDefaultsDemo from './ResetToDefaultsDemo' import FPSCapSelectDemo from './FPSCapSelectDemo' import SelectRowDemo from './SelectRowDemo' import FullscreenToggleDemo from './FullscreenToggleDemo' -import VSyncToggleDemo from './VSyncToggleDemo' import ToggleRowDemo from './ToggleRowDemo' -import InvertAxisToggleDemo from './InvertAxisToggleDemo' import AudioMixerDemo from './AudioMixerDemo' import RatingDemo from './RatingDemo' import SliderDemo from './SliderDemo' @@ -325,7 +311,6 @@ import SubtitleDemo from './SubtitleDemo' import TitleDemo from './TitleDemo' import TitleScreenDemo from './TitleScreenDemo' import MinimapDemo from './MinimapDemo' -import MuteListDemo from './MuteListDemo' import PlayerCardDemo from './PlayerCardDemo' import PlayerFrameDemo from './PlayerFrameDemo' import NetworkStatusIconDemo from './NetworkStatusIconDemo' @@ -339,7 +324,6 @@ import ParticleEmitterDemo from './ParticleEmitterDemo' import RarityChipDemo from './RarityChipDemo' import RuneCornerDemo from './RuneCornerDemo' import SafeAreaDemo from './SafeAreaDemo' -import SaveSlotListDemo from './SaveSlotListDemo' import SettingsCategoryListDemo from './SettingsCategoryListDemo' import ScoreDisplayDemo from './ScoreDisplayDemo' import StatRowDemo from './StatRowDemo' @@ -353,375 +337,352 @@ import ShopPanelDemo from './ShopPanelDemo' import StatsScreenDemo from './StatsScreenDemo' import VersionLabelDemo from './VersionLabelDemo' -export type WebComponentCategory = 'Layout' | 'Content' | 'Components' | 'Overlays & Feedback' | 'Navigation' | 'Forms' +export type WebComponentComplexity = 'Primitives' | 'Simple' | 'Composite' | 'Advanced' export type WebComponentDef = { key: string - category: WebComponentCategory + complexity: WebComponentComplexity element: JSX.Element } -export const categories: WebComponentCategory[] = [ - 'Layout', - 'Content', - 'Components', - 'Overlays & Feedback', - 'Navigation', - 'Forms', -] +export const complexities: WebComponentComplexity[] = ['Primitives', 'Simple', 'Composite', 'Advanced'] export const webComponentExamples: WebComponentDef[] = [ - { key: 'avatar', category: 'Components', element: }, - { key: 'action-header', category: 'Components', element: }, - { key: 'action-items', category: 'Components', element: }, - { key: 'action-row-list', category: 'Components', element: }, - { key: 'basic-layout', category: 'Layout', element: }, - { key: 'container', category: 'Layout', element: }, - { key: 'row', category: 'Layout', element: }, - { key: 'col', category: 'Layout', element: }, - { key: 'scroll-area', category: 'Layout', element: }, - { key: 'artboard-backdrop', category: 'Layout', element: }, - { key: 'accordion', category: 'Components', element: }, - { key: 'alert', category: 'Components', element: }, - { key: 'announcement-bar', category: 'Components', element: }, - { key: 'badge', category: 'Components', element: }, - { key: 'brand', category: 'Content', element: }, - { key: 'badge-row', category: 'Components', element: }, - { key: 'area-chart', category: 'Components', element: }, - { key: 'bar-chart', category: 'Components', element: }, - { key: 'funnel-chart', category: 'Components', element: }, - { key: 'line-chart', category: 'Components', element: }, - { key: 'pie-chart', category: 'Components', element: }, - { key: 'gantt-chart', category: 'Components', element: }, - { key: 'benchmark-chart', category: 'Components', element: }, - { key: 'bitmap-font-generator', category: 'Components', element: }, - { key: 'button', category: 'Components', element: }, - { key: 'button-group', category: 'Components', element: }, - { key: 'card', category: 'Components', element: }, - { key: 'carousel', category: 'Components', element: }, - { key: 'close-button', category: 'Components', element: }, - { key: 'collapse', category: 'Components', element: }, - { key: 'dropdown', category: 'Components', element: }, - { key: 'list-group', category: 'Components', element: }, - { key: 'breadcrumb', category: 'Navigation', element: }, - { key: 'nav', category: 'Navigation', element: }, - { key: 'navbar', category: 'Navigation', element: }, - { key: 'pagination', category: 'Navigation', element: }, - { key: 'modal', category: 'Overlays & Feedback', element: }, - { key: 'offcanvas', category: 'Overlays & Feedback', element: }, - { key: 'popover', category: 'Overlays & Feedback', element: }, - { key: 'tooltip', category: 'Overlays & Feedback', element: }, - { key: 'toast', category: 'Overlays & Feedback', element: }, - { key: 'placeholder', category: 'Components', element: }, - { key: 'progress', category: 'Components', element: }, - { key: 'spinner', category: 'Components', element: }, - { key: 'scrollspy', category: 'Navigation', element: }, - { key: 'input', category: 'Forms', element: }, - { key: 'textarea', category: 'Forms', element: }, - { key: 'markdown-editor', category: 'Forms', element: }, - { key: 'select', category: 'Forms', element: }, - { key: 'combo-box', category: 'Forms', element: }, - { key: 'check', category: 'Forms', element: }, - { key: 'checkbox-group', category: 'Forms', element: }, - { key: 'character-create', category: 'Forms', element: }, - { key: 'character-select', category: 'Forms', element: }, - { key: 'radio', category: 'Forms', element: }, - { key: 'radio-group', category: 'Forms', element: }, - { key: 'version-picker', category: 'Forms', element: }, - { key: 'rating', category: 'Forms', element: }, - { key: 'slider', category: 'Forms', element: }, - { key: 'switch', category: 'Forms', element: }, - { key: 'range', category: 'Forms', element: }, - { key: 'date-picker', category: 'Forms', element: }, - { key: 'floating-label', category: 'Forms', element: }, - { key: 'input-group', category: 'Forms', element: }, - { key: 'form', category: 'Forms', element: }, - { key: 'form-input', category: 'Forms', element: }, - { key: 'divider', category: 'Components', element: }, - { key: 'eyebrow', category: 'Content', element: }, - { key: 'heading', category: 'Content', element: }, - { key: 'helper-text', category: 'Forms', element: }, - { key: 'icon', category: 'Content', element: }, - { key: 'icon-badge', category: 'Content', element: }, - { key: 'icon-button', category: 'Components', element: }, - { key: 'kbd', category: 'Content', element: }, - { key: 'key', category: 'Content', element: }, - { key: 'label', category: 'Forms', element: }, - { key: 'link', category: 'Content', element: }, - { key: 'safe-area', category: 'Layout', element: }, - { key: 'spacer', category: 'Layout', element: }, - { key: 'stack', category: 'Layout', element: }, - { key: 'anchor', category: 'Layout', element: }, - { key: 'aspect-ratio-box', category: 'Layout', element: }, - { key: 'grid', category: 'Layout', element: }, - { key: 'gilded-frame', category: 'Layout', element: }, - { key: 'text', category: 'Content', element: }, - { key: 'visually-hidden', category: 'Components', element: }, - { key: 'pulse-indicator', category: 'Components', element: }, - { key: 'currency-chip', category: 'Content', element: }, - { key: 'currency-display', category: 'Content', element: }, - { key: 'crosshair', category: 'Components', element: }, - { key: 'section-flag', category: 'Content', element: }, - { key: 'skeleton', category: 'Components', element: }, - { key: 'social-links', category: 'Navigation', element: }, - { key: 'stamp', category: 'Content', element: }, - { key: 'status-dot', category: 'Content', element: }, - { key: 'tag', category: 'Content', element: }, - { key: 'asset-row', category: 'Components', element: }, - { key: 'asset-row-list', category: 'Components', element: }, - { key: 'asset-bundle', category: 'Components', element: }, - { key: 'brief-card', category: 'Content', element: }, - { key: 'bundle-bar', category: 'Content', element: }, - { key: 'boss-bar', category: 'Content', element: }, - { key: 'buff-bar', category: 'Content', element: }, - { key: 'health-bar', category: 'Content', element: }, - { key: 'mana-bar', category: 'Content', element: }, - { key: 'stamina-bar', category: 'Content', element: }, - { key: 'buff-icon', category: 'Content', element: }, - { key: 'brightness-calibration', category: 'Components', element: }, - { key: 'callout-quote', category: 'Content', element: }, - { key: 'chart-container', category: 'Components', element: }, - { key: 'chat-window', category: 'Components', element: }, - { key: 'sparkline', category: 'Components', element: }, - { key: 'trend-indicator', category: 'Components', element: }, - { key: 'code-label-cell', category: 'Components', element: }, - { key: 'code-with-output', category: 'Components', element: }, - { key: 'community-links', category: 'Components', element: }, - { key: 'config-preview', category: 'Content', element: }, - { key: 'contributor-wall', category: 'Components', element: }, - { key: 'cookbook-grid', category: 'Components', element: }, - { key: 'cool-button', category: 'Components', element: }, - { key: 'activity-card', category: 'Components', element: }, - { key: 'basic-card', category: 'Components', element: }, - { key: 'colored-card', category: 'Components', element: }, - { key: 'difference-card', category: 'Components', element: }, - { key: 'list-card', category: 'Components', element: }, - { key: 'status-card', category: 'Components', element: }, - { key: 'dashboard-content', category: 'Layout', element: }, - { key: 'dashboard-sidebar', category: 'Layout', element: }, - { key: 'dashboard-layout', category: 'Layout', element: }, - { key: 'download-stats', category: 'Components', element: }, - { key: 'empty-state', category: 'Components', element: }, - { key: 'entity-cell', category: 'Components', element: }, - { key: 'equipment-doll', category: 'Components', element: }, - { key: 'hotbar', category: 'Components', element: }, - { key: 'inventory-grid', category: 'Components', element: }, - { key: 'item-slot', category: 'Components', element: }, - { key: 'item-compare', category: 'Components', element: }, - { key: 'item-tooltip', category: 'Components', element: }, - { key: 'feature-card', category: 'Components', element: }, - { key: 'ability-card', category: 'Content', element: }, - { key: 'skill-bar', category: 'Components', element: }, - { key: 'skill-tree', category: 'Components', element: }, - { key: 'ammo-counter', category: 'Content', element: }, - { key: 'combo-counter', category: 'Content', element: }, - { key: 'good-first-issues', category: 'Components', element: }, - { key: 'hero-stats-bar', category: 'Components', element: }, - { key: 'leaderboard', category: 'Components', element: }, - { key: 'leaderboard-trend', category: 'Components', element: }, - { key: 'linked-providers-card', category: 'Components', element: }, - { key: 'logo-cloud', category: 'Content', element: }, - { key: 'maintainer-card', category: 'Components', element: }, - { key: 'metric-tile', category: 'Components', element: }, - { key: 'metric-grid', category: 'Content', element: }, - { key: 'migration-guide', category: 'Content', element: }, - { key: 'quick-start', category: 'Content', element: }, - { key: 'page-footer', category: 'Content', element: }, - { key: 'phase-grid', category: 'Content', element: }, - { key: 'roadmap', category: 'Content', element: }, - { key: 'pinned-feature-showcase', category: 'Content', element: }, - { key: 'pipeline', category: 'Components', element: }, - { key: 'plugin-grid', category: 'Components', element: }, - { key: 'pricing-card', category: 'Components', element: }, - { key: 'file', category: 'Components', element: }, - { key: 'queued-file', category: 'Components', element: }, - { key: 'simple-file', category: 'Content', element: }, - { key: 'sponsor-wall', category: 'Content', element: }, - { key: 'rank-cell', category: 'Components', element: }, - { key: 'rich-page-header', category: 'Components', element: }, - { key: 'scoring-rules', category: 'Content', element: }, - { key: 'credits-list', category: 'Content', element: }, - { key: 'achievement-list', category: 'Content', element: }, - { key: 'game-over-screen', category: 'Content', element: }, - { key: 'result-screen', category: 'Content', element: }, - { key: 'victory-screen', category: 'Content', element: }, - { key: 'gamepad-button-prompt', category: 'Content', element: }, - { key: 'battle-pass', category: 'Components', element: }, - { key: 'section-card', category: 'Components', element: }, - { key: 'sprint-chain', category: 'Content', element: }, - { key: 'stat-card', category: 'Content', element: }, - { key: 'state-machine', category: 'Content', element: }, - { key: 'stepper', category: 'Navigation', element: }, - { key: 'team-list', category: 'Content', element: }, - { key: 'friends-list', category: 'Content', element: }, - { key: 'guild-panel', category: 'Content', element: }, - { key: 'tier-ladder', category: 'Components', element: }, - { key: 'timeline', category: 'Components', element: }, - { key: 'usage-summary-panel', category: 'Components', element: }, - { key: 'welcome-guide', category: 'Components', element: }, - { key: 'api-reference-table', category: 'Content', element: }, - { key: 'banner', category: 'Components', element: }, - { key: 'build', category: 'Content', element: }, - { key: 'card-options', category: 'Forms', element: }, - { key: 'cdn-map', category: 'Content', element: }, - { key: 'changelog', category: 'Content', element: }, - { key: 'chip', category: 'Forms', element: }, - { key: 'chip-group', category: 'Forms', element: }, - { key: 'code-snippet', category: 'Components', element: }, - { key: 'color-picker', category: 'Forms', element: }, - { key: 'icon-picker', category: 'Forms', element: }, - { key: 'command-reference', category: 'Content', element: }, - { key: 'codex', category: 'Components', element: }, - { key: 'comparator', category: 'Components', element: }, - { key: 'compatibility-matrix', category: 'Components', element: }, - { key: 'context-menu', category: 'Overlays & Feedback', element: }, - { key: 'blur-overlay', category: 'Overlays & Feedback', element: }, - { key: 'drawer', category: 'Overlays & Feedback', element: }, - { key: 'confirm-dialog', category: 'Overlays & Feedback', element: }, - { key: 'report-dialog', category: 'Overlays & Feedback', element: }, - { key: 'screen-flash', category: 'Overlays & Feedback', element: }, - { key: 'transition-wipe', category: 'Overlays & Feedback', element: }, - { key: 'vignette-overlay', category: 'Overlays & Feedback', element: }, - { key: 'shake-container', category: 'Components', element: }, - { key: 'invite-toast', category: 'Overlays & Feedback', element: }, - { key: 'lightbox', category: 'Overlays & Feedback', element: }, - { key: 'command-palette', category: 'Overlays & Feedback', element: }, - { key: 'cool-nav', category: 'Navigation', element: }, - { key: 'countdown-timer', category: 'Components', element: }, - { key: 'damage-number', category: 'Components', element: }, - { key: 'hit-marker', category: 'Components', element: }, - { key: 'credits-scroll', category: 'Components', element: }, - { key: 'danger-zone-actions', category: 'Components', element: }, - { key: 'metric-card', category: 'Components', element: }, - { key: 'slices-card', category: 'Components', element: }, - { key: 'diff-viewer', category: 'Components', element: }, - { key: 'early-signup-form', category: 'Forms', element: }, - { key: 'ecosystem-map', category: 'Content', element: }, - { key: 'editable-text', category: 'Forms', element: }, - { key: 'entity-profile-card', category: 'Components', element: }, - { key: 'extended-select', category: 'Forms', element: }, - { key: 'faq-list', category: 'Components', element: }, - { key: 'feature-matrix', category: 'Components', element: }, - { key: 'file-dropzone', category: 'Forms', element: }, - { key: 'file-tags', category: 'Forms', element: }, - { key: 'form-wizard', category: 'Forms', element: }, - { key: 'game-showcase-card', category: 'Components', element: }, - { key: 'github-stars-card', category: 'Components', element: }, - { key: 'group', category: 'Components', element: }, - { key: 'heatmap', category: 'Components', element: }, - { key: 'hero', category: 'Content', element: }, - { key: 'image', category: 'Content', element: }, - { key: 'image-crop', category: 'Forms', element: }, - { key: 'json-editor', category: 'Forms', element: }, - { key: 'json-schema-def', category: 'Forms', element: }, - { key: 'infinite-scroll', category: 'Components', element: }, - { key: 'virtual-list', category: 'Components', element: }, - { key: 'install-tabs', category: 'Components', element: }, - { key: 'interact-prompt', category: 'Overlays & Feedback', element: }, - { key: 'kill-feed', category: 'Components', element: }, - { key: 'list', category: 'Components', element: }, - { key: 'list-row', category: 'Components', element: }, - { key: 'lobby', category: 'Components', element: }, - { key: 'party-panel', category: 'Components', element: }, - { key: 'matchmaking-screen', category: 'Components', element: }, - { key: 'main-menu', category: 'Components', element: }, - { key: 'menu-item', category: 'Components', element: }, - { key: 'metal-button', category: 'Components', element: }, - { key: 'nav-button', category: 'Navigation', element: }, - { key: 'loot-list', category: 'Components', element: }, - { key: 'loot-popup', category: 'Components', element: }, - { key: 'pause-menu', category: 'Overlays & Feedback', element: }, - { key: 'pause-screen', category: 'Overlays & Feedback', element: }, - { key: 'live-feed', category: 'Components', element: }, - { key: 'login', category: 'Layout', element: }, - { key: 'marquee', category: 'Content', element: }, - { key: 'multi-card-select', category: 'Forms', element: }, - { key: 'newsletter-signup', category: 'Forms', element: }, - { key: 'number-input', category: 'Forms', element: }, - { key: 'otp-input', category: 'Forms', element: }, - { key: 'phone-input', category: 'Forms', element: }, - { key: 'range-slider', category: 'Forms', element: }, - { key: 'deadzone-slider', category: 'Forms', element: }, - { key: 'fov-slider', category: 'Forms', element: }, - { key: 'mouse-sensitivity', category: 'Forms', element: }, - { key: 'volume-slider', category: 'Forms', element: }, - { key: 'reset-to-defaults', category: 'Forms', element: }, - { key: 'fps-cap-select', category: 'Forms', element: }, - { key: 'select-row', category: 'Forms', element: }, - { key: 'fullscreen-toggle', category: 'Forms', element: }, - { key: 'vsync-toggle', category: 'Forms', element: }, - { key: 'toggle-row', category: 'Forms', element: }, - { key: 'graphics-preset-picker', category: 'Forms', element: }, - { key: 'invert-axis-toggle', category: 'Forms', element: }, - { key: 'resizable-panel', category: 'Layout', element: }, - { key: 'side-nav', category: 'Navigation', element: }, - { key: 'tree-view', category: 'Navigation', element: }, - { key: 'user-panel', category: 'Components', element: }, - { key: 'single-card-select', category: 'Forms', element: }, - { key: 'tab-bar', category: 'Navigation', element: }, - { key: 'tab-sections', category: 'Navigation', element: }, - { key: 'vertical-item-list', category: 'Navigation', element: }, - { key: 'table', category: 'Content', element: }, - { key: 'advanced-table', category: 'Content', element: }, - { key: 'audio-mixer', category: 'Components', element: }, - { key: 'node-editor', category: 'Components', element: }, - { key: 'normal-map-generator', category: 'Components', element: }, - { key: 'physics-editor', category: 'Components', element: }, - { key: 'tag-input', category: 'Forms', element: }, - { key: 'terminal-window', category: 'Content', element: }, - { key: 'testimonial-carousel', category: 'Content', element: }, - { key: 'time-picker', category: 'Forms', element: }, - { key: 'toggle', category: 'Forms', element: }, - { key: 'toggle-card', category: 'Forms', element: }, - { key: 'video-embed', category: 'Components', element: }, - { key: 'cycle-wheel', category: 'Components', element: }, - { key: 'radial-wheel', category: 'Overlays & Feedback', element: }, - { key: 'circular-progress', category: 'Components', element: }, - { key: 'cooldown-badge', category: 'Components', element: }, - { key: 'compass-bar', category: 'Content', element: }, - { key: 'compass-rose', category: 'Content', element: }, - { key: 'controller-layout-preview', category: 'Content', element: }, - { key: 'controls-rebind-list', category: 'Components', element: }, - { key: 'crafting-panel', category: 'Components', element: }, - { key: 'debug-overlay', category: 'Overlays & Feedback', element: }, - { key: 'dialogue-box', category: 'Overlays & Feedback', element: }, - { key: 'key-binder', category: 'Forms', element: }, - { key: 'legal-screen', category: 'Content', element: }, - { key: 'letterbox-bars', category: 'Overlays & Feedback', element: }, - { key: 'level-header', category: 'Components', element: }, - { key: 'level-select', category: 'Components', element: }, - { key: 'loading-overlay', category: 'Overlays & Feedback', element: }, - { key: 'loading-screen', category: 'Overlays & Feedback', element: }, - { key: 'title-screen', category: 'Overlays & Feedback', element: }, - { key: 'lore-text', category: 'Content', element: }, - { key: 'subtitle', category: 'Content', element: }, - { key: 'title', category: 'Content', element: }, - { key: 'minimap', category: 'Components', element: }, - { key: 'mute-list', category: 'Components', element: }, - { key: 'player-card', category: 'Components', element: }, - { key: 'player-frame', category: 'Components', element: }, - { key: 'network-status-icon', category: 'Components', element: }, - { key: 'platform-icon', category: 'Components', element: }, - { key: 'ping-display', category: 'Components', element: }, - { key: 'objective-marker', category: 'Components', element: }, - { key: 'waypoint-marker', category: 'Components', element: }, - { key: 'page-indicator', category: 'Navigation', element: }, - { key: 'panel', category: 'Components', element: }, - { key: 'particle-emitter', category: 'Components', element: }, - { key: 'perk-picker', category: 'Components', element: }, - { key: 'portrait', category: 'Components', element: }, - { key: 'press-any-key', category: 'Components', element: }, - { key: 'journal', category: 'Components', element: }, - { key: 'quest-tracker', category: 'Components', element: }, - { key: 'rarity-chip', category: 'Content', element: }, - { key: 'rune-corner', category: 'Components', element: }, - { key: 'save-slot-list', category: 'Components', element: }, - { key: 'settings-category-list', category: 'Components', element: }, - { key: 'score-display', category: 'Content', element: }, - { key: 'stat-row', category: 'Components', element: }, - { key: 'speedometer', category: 'Content', element: }, - { key: 'scroll-text', category: 'Content', element: }, - { key: 'shop-panel', category: 'Components', element: }, - { key: 'stats-screen', category: 'Components', element: }, - { key: 'version-label', category: 'Content', element: }, + { key: 'avatar', complexity: 'Simple', element: }, + { key: 'action-header', complexity: 'Simple', element: }, + { key: 'action-items', complexity: 'Composite', element: }, + { key: 'action-row-list', complexity: 'Simple', element: }, + { key: 'basic-layout', complexity: 'Primitives', element: }, + { key: 'container', complexity: 'Primitives', element: }, + { key: 'row', complexity: 'Primitives', element: }, + { key: 'col', complexity: 'Simple', element: }, + { key: 'scroll-area', complexity: 'Primitives', element: }, + { key: 'artboard-backdrop', complexity: 'Primitives', element: }, + { key: 'accordion', complexity: 'Primitives', element: }, + { key: 'alert', complexity: 'Primitives', element: }, + { key: 'badge', complexity: 'Primitives', element: }, + { key: 'brand', complexity: 'Simple', element: }, + { key: 'badge-row', complexity: 'Primitives', element: }, + { key: 'area-chart', complexity: 'Advanced', element: }, + { key: 'bar-chart', complexity: 'Advanced', element: }, + { key: 'funnel-chart', complexity: 'Advanced', element: }, + { key: 'line-chart', complexity: 'Advanced', element: }, + { key: 'pie-chart', complexity: 'Advanced', element: }, + { key: 'gantt-chart', complexity: 'Advanced', element: }, + { key: 'benchmark-chart', complexity: 'Advanced', element: }, + { key: 'bitmap-font-generator', complexity: 'Advanced', element: }, + { key: 'button', complexity: 'Simple', element: }, + { key: 'button-group', complexity: 'Primitives', element: }, + { key: 'card', complexity: 'Simple', element: }, + { key: 'carousel', complexity: 'Composite', element: }, + { key: 'close-button', complexity: 'Primitives', element: }, + { key: 'collapse', complexity: 'Simple', element: }, + { key: 'dropdown', complexity: 'Simple', element: }, + { key: 'list-group', complexity: 'Primitives', element: }, + { key: 'breadcrumb', complexity: 'Primitives', element: }, + { key: 'nav', complexity: 'Primitives', element: }, + { key: 'navbar', complexity: 'Simple', element: }, + { key: 'pagination', complexity: 'Simple', element: }, + { key: 'modal', complexity: 'Simple', element: }, + { key: 'offcanvas', complexity: 'Primitives', element: }, + { key: 'popover', complexity: 'Primitives', element: }, + { key: 'tooltip', complexity: 'Primitives', element: }, + { key: 'toast', complexity: 'Composite', element: }, + { key: 'placeholder', complexity: 'Simple', element: }, + { key: 'progress', complexity: 'Simple', element: }, + { key: 'spinner', complexity: 'Primitives', element: }, + { key: 'scrollspy', complexity: 'Primitives', element: }, + { key: 'input', complexity: 'Primitives', element: }, + { key: 'textarea', complexity: 'Primitives', element: }, + { key: 'markdown-editor', complexity: 'Advanced', element: }, + { key: 'select', complexity: 'Composite', element: }, + { key: 'combo-box', complexity: 'Composite', element: }, + { key: 'check', complexity: 'Simple', element: }, + { key: 'checkbox-group', complexity: 'Composite', element: }, + { key: 'character-create', complexity: 'Composite', element: }, + { key: 'character-select', complexity: 'Composite', element: }, + { key: 'radio', complexity: 'Simple', element: }, + { key: 'radio-group', complexity: 'Composite', element: }, + { key: 'version-picker', complexity: 'Composite', element: }, + { key: 'rating', complexity: 'Composite', element: }, + { key: 'slider', complexity: 'Advanced', element: }, + { key: 'switch', complexity: 'Simple', element: }, + { key: 'range', complexity: 'Simple', element: }, + { key: 'date-picker', complexity: 'Simple', element: }, + { key: 'floating-label', complexity: 'Primitives', element: }, + { key: 'input-group', complexity: 'Primitives', element: }, + { key: 'form', complexity: 'Primitives', element: }, + { key: 'form-input', complexity: 'Advanced', element: }, + { key: 'divider', complexity: 'Primitives', element: }, + { key: 'eyebrow', complexity: 'Primitives', element: }, + { key: 'heading', complexity: 'Primitives', element: }, + { key: 'helper-text', complexity: 'Simple', element: }, + { key: 'icon', complexity: 'Simple', element: }, + { key: 'icon-badge', complexity: 'Primitives', element: }, + { key: 'icon-button', complexity: 'Simple', element: }, + { key: 'kbd', complexity: 'Primitives', element: }, + { key: 'key', complexity: 'Primitives', element: }, + { key: 'label', complexity: 'Primitives', element: }, + { key: 'link', complexity: 'Primitives', element: }, + { key: 'safe-area', complexity: 'Primitives', element: }, + { key: 'spacer', complexity: 'Primitives', element: }, + { key: 'stack', complexity: 'Primitives', element: }, + { key: 'anchor', complexity: 'Primitives', element: }, + { key: 'aspect-ratio-box', complexity: 'Primitives', element: }, + { key: 'grid', complexity: 'Primitives', element: }, + { key: 'gilded-frame', complexity: 'Primitives', element: }, + { key: 'text', complexity: 'Primitives', element: }, + { key: 'visually-hidden', complexity: 'Primitives', element: }, + { key: 'pulse-indicator', complexity: 'Primitives', element: }, + { key: 'currency-chip', complexity: 'Primitives', element: }, + { key: 'currency-display', complexity: 'Simple', element: }, + { key: 'crosshair', complexity: 'Simple', element: }, + { key: 'section-flag', complexity: 'Primitives', element: }, + { key: 'skeleton', complexity: 'Primitives', element: }, + { key: 'social-links', complexity: 'Simple', element: }, + { key: 'stamp', complexity: 'Primitives', element: }, + { key: 'status-dot', complexity: 'Primitives', element: }, + { key: 'tag', complexity: 'Composite', element: }, + { key: 'asset-row', complexity: 'Simple', element: }, + { key: 'asset-row-list', complexity: 'Primitives', element: }, + { key: 'asset-bundle', complexity: 'Advanced', element: }, + { key: 'brief-card', complexity: 'Composite', element: }, + { key: 'bundle-bar', complexity: 'Simple', element: }, + { key: 'boss-bar', complexity: 'Simple', element: }, + { key: 'buff-bar', complexity: 'Simple', element: }, + { key: 'resource-bar', complexity: 'Simple', element: }, + { key: 'buff-icon', complexity: 'Simple', element: }, + { key: 'brightness-calibration', complexity: 'Primitives', element: }, + { key: 'callout-quote', complexity: 'Simple', element: }, + { key: 'chart-container', complexity: 'Composite', element: }, + { key: 'chat-window', complexity: 'Composite', element: }, + { key: 'sparkline', complexity: 'Simple', element: }, + { key: 'trend-indicator', complexity: 'Simple', element: }, + { key: 'code-label-cell', complexity: 'Primitives', element: }, + { key: 'code-with-output', complexity: 'Simple', element: }, + { key: 'community-links', complexity: 'Composite', element: }, + { key: 'config-preview', complexity: 'Simple', element: }, + { key: 'contributor-wall', complexity: 'Simple', element: }, + { key: 'cookbook-grid', complexity: 'Simple', element: }, + { key: 'cool-button', complexity: 'Composite', element: }, + { key: 'activity-card', complexity: 'Simple', element: }, + { key: 'basic-card', complexity: 'Simple', element: }, + { key: 'colored-card', complexity: 'Simple', element: }, + { key: 'difference-card', complexity: 'Simple', element: }, + { key: 'list-card', complexity: 'Simple', element: }, + { key: 'status-card', complexity: 'Simple', element: }, + { key: 'dashboard-content', complexity: 'Primitives', element: }, + { key: 'dashboard-sidebar', complexity: 'Primitives', element: }, + { key: 'dashboard-layout', complexity: 'Simple', element: }, + { key: 'download-stats', complexity: 'Composite', element: }, + { key: 'empty-state', complexity: 'Primitives', element: }, + { key: 'entity-cell', complexity: 'Simple', element: }, + { key: 'equipment-doll', complexity: 'Composite', element: }, + { key: 'hotbar', complexity: 'Composite', element: }, + { key: 'inventory-grid', complexity: 'Composite', element: }, + { key: 'item-slot', complexity: 'Composite', element: }, + { key: 'item-compare', complexity: 'Simple', element: }, + { key: 'item-tooltip', complexity: 'Simple', element: }, + { key: 'feature-card', complexity: 'Simple', element: }, + { key: 'ability-card', complexity: 'Simple', element: }, + { key: 'skill-bar', complexity: 'Simple', element: }, + { key: 'skill-tree', complexity: 'Composite', element: }, + { key: 'ammo-counter', complexity: 'Simple', element: }, + { key: 'combo-counter', complexity: 'Primitives', element: }, + { key: 'good-first-issues', complexity: 'Composite', element: }, + { key: 'hero-stats-bar', complexity: 'Primitives', element: }, + { key: 'leaderboard', complexity: 'Composite', element: }, + { key: 'linked-providers-card', complexity: 'Composite', element: }, + { key: 'logo-cloud', complexity: 'Simple', element: }, + { key: 'maintainer-card', complexity: 'Composite', element: }, + { key: 'metric-tile', complexity: 'Simple', element: }, + { key: 'metric-grid', complexity: 'Simple', element: }, + { key: 'migration-guide', complexity: 'Simple', element: }, + { key: 'quick-start', complexity: 'Simple', element: }, + { key: 'page-footer', complexity: 'Composite', element: }, + { key: 'phase-grid', complexity: 'Simple', element: }, + { key: 'roadmap', complexity: 'Composite', element: }, + { key: 'pinned-feature-showcase', complexity: 'Composite', element: }, + { key: 'pipeline', complexity: 'Primitives', element: }, + { key: 'plugin-grid', complexity: 'Simple', element: }, + { key: 'pricing-card', complexity: 'Composite', element: }, + { key: 'file', complexity: 'Advanced', element: }, + { key: 'queued-file', complexity: 'Simple', element: }, + { key: 'simple-file', complexity: 'Primitives', element: }, + { key: 'sponsor-wall', complexity: 'Simple', element: }, + { key: 'rank-cell', complexity: 'Primitives', element: }, + { key: 'rich-page-header', complexity: 'Simple', element: }, + { key: 'scoring-rules', complexity: 'Primitives', element: }, + { key: 'data-list', complexity: 'Composite', element: }, + { key: 'result-screen', complexity: 'Composite', element: }, + { key: 'gamepad-button-prompt', complexity: 'Primitives', element: }, + { key: 'battle-pass', complexity: 'Composite', element: }, + { key: 'section-card', complexity: 'Simple', element: }, + { key: 'sprint-chain', complexity: 'Simple', element: }, + { key: 'stat-card', complexity: 'Composite', element: }, + { key: 'state-machine', complexity: 'Simple', element: }, + { key: 'stepper', complexity: 'Simple', element: }, + { key: 'guild-panel', complexity: 'Composite', element: }, + { key: 'tier-ladder', complexity: 'Simple', element: }, + { key: 'timeline', complexity: 'Composite', element: }, + { key: 'usage-summary-panel', complexity: 'Simple', element: }, + { key: 'welcome-guide', complexity: 'Composite', element: }, + { key: 'api-reference-table', complexity: 'Composite', element: }, + { key: 'banner', complexity: 'Composite', element: }, + { key: 'build', complexity: 'Advanced', element: }, + { key: 'card-options', complexity: 'Composite', element: }, + { key: 'cdn-map', complexity: 'Primitives', element: }, + { key: 'changelog', complexity: 'Simple', element: }, + { key: 'chip', complexity: 'Composite', element: }, + { key: 'chip-group', complexity: 'Composite', element: }, + { key: 'code-snippet', complexity: 'Composite', element: }, + { key: 'color-picker', complexity: 'Advanced', element: }, + { key: 'icon-picker', complexity: 'Advanced', element: }, + { key: 'command-reference', complexity: 'Composite', element: }, + { key: 'codex', complexity: 'Composite', element: }, + { key: 'comparator', complexity: 'Advanced', element: }, + { key: 'compatibility-matrix', complexity: 'Composite', element: }, + { key: 'context-menu', complexity: 'Advanced', element: }, + { key: 'blur-overlay', complexity: 'Primitives', element: }, + { key: 'drawer', complexity: 'Composite', element: }, + { key: 'confirm-dialog', complexity: 'Simple', element: }, + { key: 'report-dialog', complexity: 'Simple', element: }, + { key: 'screen-flash', complexity: 'Simple', element: }, + { key: 'transition-wipe', complexity: 'Simple', element: }, + { key: 'vignette-overlay', complexity: 'Primitives', element: }, + { key: 'shake-container', complexity: 'Simple', element: }, + { key: 'invite-toast', complexity: 'Composite', element: }, + { key: 'lightbox', complexity: 'Advanced', element: }, + { key: 'command-palette', complexity: 'Advanced', element: }, + { key: 'cool-nav', complexity: 'Advanced', element: }, + { key: 'countdown-timer', complexity: 'Composite', element: }, + { key: 'damage-number', complexity: 'Simple', element: }, + { key: 'hit-marker', complexity: 'Simple', element: }, + { key: 'credits-scroll', complexity: 'Composite', element: }, + { key: 'danger-zone-actions', complexity: 'Simple', element: }, + { key: 'metric-card', complexity: 'Composite', element: }, + { key: 'slices-card', complexity: 'Composite', element: }, + { key: 'diff-viewer', complexity: 'Composite', element: }, + { key: 'early-signup-form', complexity: 'Advanced', element: }, + { key: 'ecosystem-map', complexity: 'Composite', element: }, + { key: 'editable-text', complexity: 'Simple', element: }, + { key: 'entity-profile-card', complexity: 'Composite', element: }, + { key: 'extended-select', complexity: 'Advanced', element: }, + { key: 'faq-list', complexity: 'Composite', element: }, + { key: 'feature-matrix', complexity: 'Composite', element: }, + { key: 'file-dropzone', complexity: 'Simple', element: }, + { key: 'file-tags', complexity: 'Advanced', element: }, + { key: 'form-wizard', complexity: 'Advanced', element: }, + { key: 'game-showcase-card', complexity: 'Composite', element: }, + { key: 'github-stars-card', complexity: 'Advanced', element: }, + { key: 'group', complexity: 'Composite', element: }, + { key: 'heatmap', complexity: 'Advanced', element: }, + { key: 'hero', complexity: 'Advanced', element: }, + { key: 'image', complexity: 'Simple', element: }, + { key: 'image-crop', complexity: 'Advanced', element: }, + { key: 'json-editor', complexity: 'Advanced', element: }, + { key: 'json-schema-def', complexity: 'Advanced', element: }, + { key: 'infinite-scroll', complexity: 'Composite', element: }, + { key: 'virtual-list', complexity: 'Advanced', element: }, + { key: 'install-tabs', complexity: 'Advanced', element: }, + { key: 'interact-prompt', complexity: 'Primitives', element: }, + { key: 'kill-feed', complexity: 'Primitives', element: }, + { key: 'list', complexity: 'Simple', element: }, + { key: 'list-row', complexity: 'Primitives', element: }, + { key: 'lobby', complexity: 'Composite', element: }, + { key: 'party-panel', complexity: 'Simple', element: }, + { key: 'matchmaking-screen', complexity: 'Composite', element: }, + { key: 'main-menu', complexity: 'Composite', element: }, + { key: 'menu-item', complexity: 'Simple', element: }, + { key: 'metal-button', complexity: 'Simple', element: }, + { key: 'nav-button', complexity: 'Primitives', element: }, + { key: 'loot-list', complexity: 'Simple', element: }, + { key: 'loot-popup', complexity: 'Advanced', element: }, + { key: 'pause-menu', complexity: 'Advanced', element: }, + { key: 'live-feed', complexity: 'Composite', element: }, + { key: 'login', complexity: 'Composite', element: }, + { key: 'marquee', complexity: 'Composite', element: }, + { key: 'multi-card-select', complexity: 'Composite', element: }, + { key: 'newsletter-signup', complexity: 'Composite', element: }, + { key: 'number-input', complexity: 'Advanced', element: }, + { key: 'otp-input', complexity: 'Advanced', element: }, + { key: 'phone-input', complexity: 'Advanced', element: }, + { key: 'range-slider', complexity: 'Advanced', element: }, + { key: 'setting-slider', complexity: 'Composite', element: }, + { key: 'mouse-sensitivity', complexity: 'Simple', element: }, + { key: 'reset-to-defaults', complexity: 'Primitives', element: }, + { key: 'fps-cap-select', complexity: 'Simple', element: }, + { key: 'select-row', complexity: 'Primitives', element: }, + { key: 'fullscreen-toggle', complexity: 'Primitives', element: }, + { key: 'toggle-row', complexity: 'Primitives', element: }, + { key: 'graphics-preset-picker', complexity: 'Simple', element: }, + { key: 'resizable-panel', complexity: 'Advanced', element: }, + { key: 'side-nav', complexity: 'Composite', element: }, + { key: 'tree-view', complexity: 'Advanced', element: }, + { key: 'user-panel', complexity: 'Advanced', element: }, + { key: 'single-card-select', complexity: 'Composite', element: }, + { key: 'tab-bar', complexity: 'Composite', element: }, + { key: 'tab-sections', complexity: 'Advanced', element: }, + { key: 'vertical-item-list', complexity: 'Advanced', element: }, + { key: 'table', complexity: 'Advanced', element: }, + { key: 'advanced-table', complexity: 'Advanced', element: }, + { key: 'audio-mixer', complexity: 'Advanced', element: }, + { key: 'node-editor', complexity: 'Advanced', element: }, + { key: 'normal-map-generator', complexity: 'Advanced', element: }, + { key: 'physics-editor', complexity: 'Advanced', element: }, + { key: 'tag-input', complexity: 'Advanced', element: }, + { key: 'terminal-window', complexity: 'Composite', element: }, + { key: 'testimonial-carousel', complexity: 'Advanced', element: }, + { key: 'time-picker', complexity: 'Advanced', element: }, + { key: 'toggle', complexity: 'Primitives', element: }, + { key: 'toggle-card', complexity: 'Composite', element: }, + { key: 'video-embed', complexity: 'Composite', element: }, + { key: 'cycle-wheel', complexity: 'Simple', element: }, + { key: 'radial-wheel', complexity: 'Composite', element: }, + { key: 'circular-progress', complexity: 'Simple', element: }, + { key: 'cooldown-badge', complexity: 'Simple', element: }, + { key: 'compass-bar', complexity: 'Composite', element: }, + { key: 'compass-rose', complexity: 'Primitives', element: }, + { key: 'controller-layout-preview', complexity: 'Simple', element: }, + { key: 'controls-rebind-list', complexity: 'Simple', element: }, + { key: 'crafting-panel', complexity: 'Composite', element: }, + { key: 'debug-overlay', complexity: 'Simple', element: }, + { key: 'dialogue-box', complexity: 'Composite', element: }, + { key: 'key-binder', complexity: 'Simple', element: }, + { key: 'legal-screen', complexity: 'Simple', element: }, + { key: 'letterbox-bars', complexity: 'Primitives', element: }, + { key: 'level-header', complexity: 'Primitives', element: }, + { key: 'level-select', complexity: 'Composite', element: }, + { key: 'loading-overlay', complexity: 'Simple', element: }, + { key: 'loading-screen', complexity: 'Composite', element: }, + { key: 'title-screen', complexity: 'Primitives', element: }, + { key: 'lore-text', complexity: 'Primitives', element: }, + { key: 'subtitle', complexity: 'Simple', element: }, + { key: 'title', complexity: 'Primitives', element: }, + { key: 'minimap', complexity: 'Simple', element: }, + { key: 'player-card', complexity: 'Composite', element: }, + { key: 'player-frame', complexity: 'Composite', element: }, + { key: 'network-status-icon', complexity: 'Simple', element: }, + { key: 'platform-icon', complexity: 'Simple', element: }, + { key: 'ping-display', complexity: 'Primitives', element: }, + { key: 'objective-marker', complexity: 'Simple', element: }, + { key: 'waypoint-marker', complexity: 'Simple', element: }, + { key: 'page-indicator', complexity: 'Simple', element: }, + { key: 'panel', complexity: 'Simple', element: }, + { key: 'particle-emitter', complexity: 'Composite', element: }, + { key: 'perk-picker', complexity: 'Simple', element: }, + { key: 'portrait', complexity: 'Simple', element: }, + { key: 'press-any-key', complexity: 'Primitives', element: }, + { key: 'journal', complexity: 'Composite', element: }, + { key: 'quest-tracker', complexity: 'Simple', element: }, + { key: 'rarity-chip', complexity: 'Primitives', element: }, + { key: 'rune-corner', complexity: 'Primitives', element: }, + { key: 'settings-category-list', complexity: 'Composite', element: }, + { key: 'score-display', complexity: 'Simple', element: }, + { key: 'stat-row', complexity: 'Simple', element: }, + { key: 'speedometer', complexity: 'Composite', element: }, + { key: 'scroll-text', complexity: 'Primitives', element: }, + { key: 'shop-panel', complexity: 'Simple', element: }, + { key: 'stats-screen', complexity: 'Primitives', element: }, + { key: 'version-label', complexity: 'Primitives', element: }, ] diff --git a/web-components/src/AchievementList.ts b/web-components/src/AchievementList.ts deleted file mode 100644 index 2c8cbd7c..00000000 --- a/web-components/src/AchievementList.ts +++ /dev/null @@ -1,125 +0,0 @@ -import * as LucideIcons from 'lucide-static' -import { icon } from './icons' - -const TAG_NAME = 'tc-achievement-list' - -export interface Achievement { - id: string - name: string - description?: string - icon?: string - unlocked?: boolean - progress?: number - target?: number - points?: number - secret?: boolean -} - -function esc(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -function lucideByName(name: string): string { - const pascal = name - .split('-') - .map(part => part.charAt(0).toUpperCase() + part.slice(1)) - .join('') - const svgStr = (LucideIcons as Record)[pascal] - if (!svgStr) return '' - return icon(svgStr) -} - -// Default state glyphs (resolved once) — lucide inline SVG, never unicode/emoji. -const unlockedIconHtml = lucideByName('award') -const lockedIconHtml = lucideByName('lock') -const secretIconHtml = lucideByName('help-circle') - -export class AchievementList extends HTMLElement { - private _initialised = false - private _achievements: Achievement[] = [] - - static get observedAttributes(): string[] { - return [] - } - - connectedCallback(): void { - if (!this._initialised) { - if (!this.hasAttribute('role')) this.setAttribute('role', 'list') - this.render() - this._initialised = true - } - } - - get achievements(): Achievement[] { - return this._achievements.slice() - } - set achievements(value: Achievement[]) { - this._achievements = Array.isArray(value) ? value.slice() : [] - if (this._initialised) this.render() - } - - private render(): void { - this.classList.add('tc-achievement-list') - - this.innerHTML = this._achievements.map(a => { - const isSecret = !!a.secret && !a.unlocked - const hasProgress = - !a.unlocked && - typeof a.progress === 'number' && - typeof a.target === 'number' && - a.target > 0 - - const stateCls = a.unlocked - ? 'is-unlocked' - : hasProgress - ? 'is-in-progress' - : 'is-locked' - const cls = `tc-achievement-list-row ${stateCls}${isSecret ? ' is-secret' : ''}` - - // Icon: user-supplied lucide name wins; otherwise a state glyph. - const customIcon = a.icon ? lucideByName(a.icon) : '' - const iconHtml = isSecret - ? secretIconHtml - : a.unlocked - ? customIcon || unlockedIconHtml - : customIcon || lockedIconHtml - - const name = isSecret ? '???' : esc(a.name) - const description = isSecret - ? 'Hidden achievement.' - : a.description - ? esc(a.description) - : '' - const descMarkup = description - ? `${description}` - : '' - - const pointsMarkup = - typeof a.points === 'number' && !isSecret - ? `${esc(String(a.points))}` - : '' - - let progressMarkup = '' - if (hasProgress) { - const pct = Math.max( - 0, - Math.min(100, ((a.progress as number) / (a.target as number)) * 100), - ) - progressMarkup = `
    ${esc(String(a.progress))}/${esc(String(a.target))}
    ` - } - - return `
    ${name}${descMarkup}${progressMarkup}
    ${pointsMarkup}
    ` - }).join('') - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: AchievementList - } -} diff --git a/web-components/src/AnnouncementBar.ts b/web-components/src/AnnouncementBar.ts deleted file mode 100644 index 42f16946..00000000 --- a/web-components/src/AnnouncementBar.ts +++ /dev/null @@ -1,197 +0,0 @@ -import * as LucideIcons from 'lucide-static' -import { icon, closeIcon } from './icons' - -const TAG_NAME = 'tc-announcement-bar' - -function esc(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') -} - -function lucideByName(name: string): string { - const pascal = name - .split('-') - .map(part => part.charAt(0).toUpperCase() + part.slice(1)) - .join('') - const svgStr = (LucideIcons as Record)[pascal] - if (!svgStr) return '' - return icon(svgStr) -} - -export type AnnouncementBarVariant = 'info' | 'success' | 'warning' | 'announce' -const VARIANTS: AnnouncementBarVariant[] = ['info', 'success', 'warning', 'announce'] - -export class AnnouncementBar extends HTMLElement { - private _initialised = false - private _iconSlotNodes: Node[] = [] - private _messageNodes: Node[] = [] - - onDismiss: (() => void) | null = null - - static get observedAttributes(): string[] { - return ['variant', 'cta-label', 'cta-href', 'dismissible', 'persist-dismiss-key', 'icon-name'] - } - - connectedCallback(): void { - if (!this._initialised) { - const key = this.persistDismissKey - if (key && this._isStored(key)) { - this.hidden = true - this._initialised = true - return - } - - // Named icon slot + default slot: filter named nodes out of childNodes - this._iconSlotNodes = Array.from(this.querySelectorAll('[slot="icon"]')) - this._messageNodes = Array.from(this.childNodes).filter( - n => !(n instanceof Element && n.getAttribute('slot') === 'icon') - ) - - this.render() - this._redistributeSlots() - this._attachCloseHandler() - this._initialised = true - } - } - - attributeChangedCallback(): void { - if (!this.isConnected || !this._initialised || this.hidden) return - // Re-capture from within their containers (nodes have already been moved) - this._iconSlotNodes = Array.from( - this.querySelectorAll('.tc-announcement-bar-icon [slot="icon"]') - ) - const msgContainer = this.querySelector('.tc-announcement-bar-message') - this._messageNodes = msgContainer ? Array.from(msgContainer.childNodes) : [] - this.render() - this._redistributeSlots() - this._attachCloseHandler() - } - - get variant(): AnnouncementBarVariant { - const v = this.getAttribute('variant') as AnnouncementBarVariant - return VARIANTS.includes(v) ? v : 'info' - } - set variant(v: AnnouncementBarVariant) { - this.setAttribute('variant', v) - } - - get ctaLabel(): string | null { - return this.getAttribute('cta-label') - } - set ctaLabel(v: string | null) { - if (v != null) this.setAttribute('cta-label', v) - else this.removeAttribute('cta-label') - } - - get ctaHref(): string | null { - return this.getAttribute('cta-href') - } - set ctaHref(v: string | null) { - if (v != null) this.setAttribute('cta-href', v) - else this.removeAttribute('cta-href') - } - - get dismissible(): boolean { - return this.hasAttribute('dismissible') - } - set dismissible(v: boolean) { - if (v) this.setAttribute('dismissible', '') - else this.removeAttribute('dismissible') - } - - get persistDismissKey(): string | null { - return this.getAttribute('persist-dismiss-key') - } - set persistDismissKey(v: string | null) { - if (v != null) this.setAttribute('persist-dismiss-key', v) - else this.removeAttribute('persist-dismiss-key') - } - - get iconName(): string | null { - return this.getAttribute('icon-name') - } - set iconName(v: string | null) { - if (v != null) this.setAttribute('icon-name', v) - else this.removeAttribute('icon-name') - } - - private _isStored(key: string): boolean { - try { - return localStorage.getItem(key) === 'dismissed' - } catch { - return false - } - } - - private _handleDismiss = (): void => { - const key = this.persistDismissKey - if (key) { - try { localStorage.setItem(key, 'dismissed') } catch {} - } - this.hidden = true - this.dispatchEvent(new CustomEvent('tc-dismiss', { bubbles: true, composed: true })) - if (typeof this.onDismiss === 'function') this.onDismiss() - } - - private _attachCloseHandler(): void { - const btn = this.querySelector('.tc-announcement-bar-close') - if (btn) { - btn.addEventListener('click', this._handleDismiss, { once: true }) - } - } - - private _redistributeSlots(): void { - const iconContainer = this.querySelector('.tc-announcement-bar-icon') - if (iconContainer && this._iconSlotNodes.length > 0) { - // Slot children take priority over the iconName lucide fallback - iconContainer.innerHTML = '' - this._iconSlotNodes.forEach(n => iconContainer.appendChild(n)) - } - const msgContainer = this.querySelector('.tc-announcement-bar-message') - if (msgContainer) { - this._messageNodes.forEach(n => msgContainer.appendChild(n)) - } - } - - private render(): void { - const variant = this.variant - const ctaLabel = this.ctaLabel - const ctaHref = this.ctaHref - const dismissible = this.dismissible - const iconName = this.iconName - - // Host carries the layout classes - this.classList.add('tc-announcement-bar') - VARIANTS.forEach(v => this.classList.remove(`tc-announcement-bar-${v}`)) - this.classList.add(`tc-announcement-bar-${variant}`) - this.setAttribute('role', 'region') - this.setAttribute('aria-label', 'Announcement') - - const hasIconSlot = this._iconSlotNodes.length > 0 - const showIcon = hasIconSlot || !!iconName - // If slot nodes exist they'll override this content in _redistributeSlots - const iconSvg = !hasIconSlot && iconName ? lucideByName(iconName) : '' - const iconHtml = showIcon - ? `` - : '' - - const ctaHtml = ctaLabel && ctaHref - ? `${esc(ctaLabel)}` - : '' - - const closeHtml = dismissible - ? `` - : '' - - this.innerHTML = `${iconHtml}${ctaHtml}${closeHtml}` - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: AnnouncementBar - } -} diff --git a/web-components/src/Banner.ts b/web-components/src/Banner.ts index 6a7f8f2e..62e7b032 100644 --- a/web-components/src/Banner.ts +++ b/web-components/src/Banner.ts @@ -3,6 +3,14 @@ import { icon, closeIcon } from './icons' const TAG_NAME = 'tc-banner' +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + function lucideByName(name: string): string { const pascal = name .split('-') @@ -20,34 +28,90 @@ const DEFAULT_ICONS: Record = { error: 'circle-x', } -export type BannerVariant = 'info' | 'warning' | 'success' | 'error' -const VARIANTS: BannerVariant[] = ['info', 'warning', 'success', 'error'] +export type BannerVariant = 'info' | 'warning' | 'success' | 'error' | 'announce' +const VARIANTS: BannerVariant[] = ['info', 'warning', 'success', 'error', 'announce'] + +interface BannerTagConfig { + // Attribute the dismiss-persistence key is read from. + storageAttr: string + // Attribute the lucide icon name is read from. + iconAttr: string + // `auto` → role=alert for error else status; `region` → role=region (announcement). + role: 'auto' | 'region' + ariaLabel?: string + // When true, render a per-variant default icon if none is supplied. + autoIcon: boolean +} + +// Per-tag behaviour. tc-banner is the canonical status banner (per-variant default +// icon, alert/status role, `storage-key` / `icon`). tc-announcement-bar is the +// persistent announcement preset: region role, no auto icon, and the legacy +// `persist-dismiss-key` / `icon-name` attribute names. +const TAG_CONFIG: Record = { + 'tc-banner': { + storageAttr: 'storage-key', + iconAttr: 'icon', + role: 'auto', + autoIcon: true, + }, + 'tc-announcement-bar': { + storageAttr: 'persist-dismiss-key', + iconAttr: 'icon-name', + role: 'region', + ariaLabel: 'Announcement', + autoIcon: false, + }, +} +const DEFAULT_CONFIG = TAG_CONFIG['tc-banner'] +/** + * tc-banner — dismissible status banner with a leading icon, body content, an + * optional action slot or CTA link, and optional localStorage dismiss + * persistence. Fires `tc-dismiss` (+ `onDismiss`) when closed. + * + * tc-announcement-bar is a preset alias: a region-role announcement bar with no + * auto icon and the legacy `persist-dismiss-key` / `icon-name` attribute names. + * Both tags support a named `icon` slot, a named `action` slot, and the + * `cta-label` / `cta-href` link passthrough. + */ export class Banner extends HTMLElement { private _initialised = false private _contentNodes: Node[] = [] private _actionNodes: Node[] = [] + private _iconSlotNodes: Node[] = [] onDismiss: (() => void) | null = null static get observedAttributes(): string[] { - return ['variant', 'dismissible', 'storage-key', 'icon'] + return [ + 'variant', + 'dismissible', + 'storage-key', + 'persist-dismiss-key', + 'icon', + 'icon-name', + 'cta-label', + 'cta-href', + ] } connectedCallback(): void { if (!this._initialised) { - const key = this.storageKey + const key = this._resolvedStorageKey() if (key && this._isStored(key)) { this.hidden = true this._initialised = true return } - // Capture named action slot nodes, filter out of default content + // Capture named slot nodes (icon / action), keep the rest as content. + this._iconSlotNodes = Array.from(this.querySelectorAll('[slot="icon"]')) this._actionNodes = Array.from(this.querySelectorAll('[slot="action"]')) - this._contentNodes = Array.from(this.childNodes).filter( - n => !(n instanceof Element && n.getAttribute('slot') === 'action') - ) + this._contentNodes = Array.from(this.childNodes).filter(n => { + if (!(n instanceof Element)) return true + const slot = n.getAttribute('slot') + return slot !== 'icon' && slot !== 'action' + }) this.render() this._redistributeSlots() @@ -58,10 +122,9 @@ export class Banner extends HTMLElement { attributeChangedCallback(): void { if (!this.isConnected || !this._initialised || this.hidden) return - // Re-capture from their containers (nodes have already moved) - this._actionNodes = Array.from( - this.querySelectorAll('.tc-banner-action [slot="action"]') - ) + // Re-capture from their containers (nodes have already moved). + this._iconSlotNodes = Array.from(this.querySelectorAll('.tc-banner-icon [slot="icon"]')) + this._actionNodes = Array.from(this.querySelectorAll('.tc-banner-action [slot="action"]')) const contentEl = this.querySelector('.tc-banner-content') this._contentNodes = contentEl ? Array.from(contentEl.childNodes) : [] this.render() @@ -69,6 +132,10 @@ export class Banner extends HTMLElement { this._attachCloseHandler() } + private get _config(): BannerTagConfig { + return TAG_CONFIG[this.localName] ?? DEFAULT_CONFIG + } + get variant(): BannerVariant { const v = this.getAttribute('variant') as BannerVariant return VARIANTS.includes(v) ? v : 'info' @@ -86,19 +153,39 @@ export class Banner extends HTMLElement { } get storageKey(): string | null { - return this.getAttribute('storage-key') + return this.getAttribute(this._config.storageAttr) } set storageKey(v: string | null) { - if (v != null) this.setAttribute('storage-key', v) - else this.removeAttribute('storage-key') + if (v != null) this.setAttribute(this._config.storageAttr, v) + else this.removeAttribute(this._config.storageAttr) } get iconName(): string | null { - return this.getAttribute('icon') + return this.getAttribute(this._config.iconAttr) } set iconName(v: string | null) { - if (v != null) this.setAttribute('icon', v) - else this.removeAttribute('icon') + if (v != null) this.setAttribute(this._config.iconAttr, v) + else this.removeAttribute(this._config.iconAttr) + } + + get ctaLabel(): string | null { + return this.getAttribute('cta-label') + } + set ctaLabel(v: string | null) { + if (v != null) this.setAttribute('cta-label', v) + else this.removeAttribute('cta-label') + } + + get ctaHref(): string | null { + return this.getAttribute('cta-href') + } + set ctaHref(v: string | null) { + if (v != null) this.setAttribute('cta-href', v) + else this.removeAttribute('cta-href') + } + + private _resolvedStorageKey(): string | null { + return this.getAttribute(this._config.storageAttr) } private _isStored(key: string): boolean { @@ -110,7 +197,7 @@ export class Banner extends HTMLElement { } private _handleDismiss = (): void => { - const key = this.storageKey + const key = this._resolvedStorageKey() if (key) { try { localStorage.setItem(key, 'dismissed') } catch {} } @@ -127,6 +214,12 @@ export class Banner extends HTMLElement { } private _redistributeSlots(): void { + const iconEl = this.querySelector('.tc-banner-icon') + if (iconEl && this._iconSlotNodes.length > 0) { + // Slotted icon nodes take priority over the lucide fallback. + iconEl.innerHTML = '' + this._iconSlotNodes.forEach(n => iconEl.appendChild(n)) + } const contentEl = this.querySelector('.tc-banner-content') if (contentEl) { this._contentNodes.forEach(n => contentEl.appendChild(n)) @@ -138,22 +231,33 @@ export class Banner extends HTMLElement { } private render(): void { + const config = this._config const variant = this.variant const dismissible = this.dismissible - const iconAttr = this.iconName + const iconAttr = this.getAttribute(config.iconAttr) const hasAction = this._actionNodes.length > 0 + const hasIconSlot = this._iconSlotNodes.length > 0 + const ctaLabel = this.ctaLabel + const ctaHref = this.ctaHref - // Apply host classes — preserve any user-added classes + // Apply host classes — preserve any user-added classes. this.classList.add('tc-banner') VARIANTS.forEach(v => this.classList.remove(`tc-banner-${v}`)) this.classList.add(`tc-banner-${variant}`) - // ARIA: error variant uses alert role for immediate announcement - this.setAttribute('role', variant === 'error' ? 'alert' : 'status') + // ARIA: announcement bars are regions; status banners announce via + // alert (error) or status (everything else). + if (config.role === 'region') { + this.setAttribute('role', 'region') + if (config.ariaLabel) this.setAttribute('aria-label', config.ariaLabel) + } else { + this.setAttribute('role', variant === 'error' ? 'alert' : 'status') + } - const resolvedIcon = iconAttr ?? DEFAULT_ICONS[variant] - const iconSvg = lucideByName(resolvedIcon) - const iconHtml = iconSvg + const resolvedIcon = iconAttr ?? (config.autoIcon ? DEFAULT_ICONS[variant] : undefined) + const showIcon = hasIconSlot || !!resolvedIcon + const iconSvg = !hasIconSlot && resolvedIcon ? lucideByName(resolvedIcon) : '' + const iconHtml = showIcon ? `` : '' @@ -161,16 +265,21 @@ export class Banner extends HTMLElement { ? `` : '' + const ctaHtml = ctaLabel && ctaHref + ? `${esc(ctaLabel)}` + : '' + const closeHtml = dismissible ? `` : '' - this.innerHTML = `${iconHtml}${actionHtml}${closeHtml}` + this.innerHTML = `${iconHtml}${actionHtml}${ctaHtml}${closeHtml}` } } declare global { interface HTMLElementTagNameMap { [TAG_NAME]: Banner + 'tc-announcement-bar': Banner } } diff --git a/web-components/src/BasicCard.ts b/web-components/src/BasicCard.ts index bd08d936..3f4d8a32 100644 --- a/web-components/src/BasicCard.ts +++ b/web-components/src/BasicCard.ts @@ -20,7 +20,9 @@ export class BasicCard extends HTMLElement { private _initialised = false static get observedAttributes(): string[] { - return ['text-a', 'text-b', 'icon', 'loading'] + // `value` / `text` are the legacy tc-colored-card attribute names, accepted + // as aliases of `text-a` / `text-b`; `color` drives the accent-tinted chip. + return ['text-a', 'text-b', 'icon', 'loading', 'color', 'value', 'text'] } connectedCallback(): void { @@ -36,19 +38,27 @@ export class BasicCard extends HTMLElement { } get textA(): string { - return this.getAttribute('text-a') ?? '' + return this.getAttribute('text-a') ?? this.getAttribute('value') ?? '' } set textA(v: string) { this.setAttribute('text-a', v) } get textB(): string { - return this.getAttribute('text-b') ?? '' + return this.getAttribute('text-b') ?? this.getAttribute('text') ?? '' } set textB(v: string) { this.setAttribute('text-b', v) } + get color(): string | null { + return this.getAttribute('color') + } + set color(v: string | null) { + if (v != null) this.setAttribute('color', v) + else this.removeAttribute('color') + } + get icon(): string | null { return this.getAttribute('icon') } @@ -67,11 +77,18 @@ export class BasicCard extends HTMLElement { private render(): void { const loading = this.loading + const color = this.getAttribute('color') + + // Inline --bs-basic-card-accent so the SCSS tint + glyph color pick it up; + // the `--accent` modifier switches the icon chip to the tinted treatment. + if (color) this.style.setProperty('--bs-basic-card-accent', color) + else this.style.removeProperty('--bs-basic-card-accent') + const rootClass = color ? 'card tc-basic-card tc-basic-card--accent' : 'card tc-basic-card' if (loading) { this.setAttribute('aria-busy', 'true') this.innerHTML = [ - '
    ', + `
    `, '
    ', '', '
    ', @@ -91,7 +108,7 @@ export class BasicCard extends HTMLElement { : '' this.innerHTML = [ - '
    ', + `
    `, '
    ', iconChipHtml, '
    ', diff --git a/web-components/src/BreadcrumbItem.ts b/web-components/src/BreadcrumbItem.ts index cc73739e..bdc2356c 100644 --- a/web-components/src/BreadcrumbItem.ts +++ b/web-components/src/BreadcrumbItem.ts @@ -1,53 +1,23 @@ -const TAG_NAME = 'tc-breadcrumb-item' - -export class BreadcrumbItem extends HTMLElement { +import { LinkItemBase } from './internal/link-item' - private _initialised = false +const TAG_NAME = 'tc-breadcrumb-item' +/** + * tc-breadcrumb-item — a single breadcrumb crumb on the shared + * {@link LinkItemBase} slot-wrapping scaffold. Renders a linked crumb (``) + * unless it is `active` (or has no `href`), in which case it becomes a plain + * `` marked `aria-current="page"`. + */ +export class BreadcrumbItem extends LinkItemBase { static get observedAttributes(): string[] { return ['href', 'active'] } - constructor() { - super() - } - - connectedCallback(): void { - if (!this._initialised) { - const slotContent = Array.from(this.childNodes) - this.render() - const inner = this.querySelector('.tc-breadcrumb-item-content') - if (inner) slotContent.forEach(n => inner.appendChild(n)) - this._initialised = true - } - } - - attributeChangedCallback(): void { - if (!this.isConnected || !this._initialised) return - const inner = this.querySelector('.tc-breadcrumb-item-content') - const slotContent = inner ? Array.from(inner.childNodes) : [] - this.render() - const newInner = this.querySelector('.tc-breadcrumb-item-content') - if (newInner) slotContent.forEach(n => newInner.appendChild(n)) - } - - get href(): string | null { - return this.getAttribute('href') - } - set href(v: string | null) { - if (v != null) this.setAttribute('href', v) - else this.removeAttribute('href') - } - - get active(): boolean { - return this.hasAttribute('active') - } - set active(v: boolean) { - if (v) this.setAttribute('active', '') - else this.removeAttribute('active') + protected getContentEl(): HTMLElement | null { + return this.querySelector('.tc-breadcrumb-item-content') } - private render(): void { + protected render(): void { const active = this.active const href = this.getAttribute('href') diff --git a/web-components/src/Button.ts b/web-components/src/Button.ts index b9504872..365c3d65 100644 --- a/web-components/src/Button.ts +++ b/web-components/src/Button.ts @@ -12,7 +12,20 @@ export class Button extends HTMLElement { private _initialised = false static get observedAttributes(): string[] { - return ['variant', 'outline', 'size', 'disabled', 'loading', 'href', 'type'] + return ['variant', 'outline', 'size', 'disabled', 'loading', 'href', 'type', 'skin'] + } + + // `skin="metal"` (and the tc-metal-button alias) render the brushed-metal + // button skin — its own `tc-metal-button__btn` class scheme with the + // default/primary/danger/ghost variants and sm/md/lg sizes. + get skin(): 'default' | 'metal' { + return this.getAttribute('skin') === 'metal' || this.localName === 'tc-metal-button' + ? 'metal' + : 'default' + } + set skin(v: 'default' | 'metal') { + if (v === 'metal') this.setAttribute('skin', 'metal') + else this.removeAttribute('skin') } constructor() { @@ -96,6 +109,21 @@ export class Button extends HTMLElement { } private render(): void { + // Metal skin — distinct class scheme; its own variant/size sets. Shares the + // `.tc-button-content` slot wrapper so the base slot-capture still works. + if (this.skin === 'metal') { + const metalVariants = ['default', 'primary', 'danger', 'ghost'] + const rawV = this.getAttribute('variant') ?? '' + const mv = metalVariants.includes(rawV) ? rawV : 'default' + const metalSizes = ['sm', 'md', 'lg'] + const rawS = this.getAttribute('size') ?? '' + const ms = metalSizes.includes(rawS) ? rawS : 'md' + const sizeClass = ms !== 'md' ? ` tc-metal-button__btn--${ms}` : '' + const disabledAttr = this.disabled ? ' disabled' : '' + this.innerHTML = `` + return + } + const variant = this.variant const outline = this.outline const size = this.size diff --git a/web-components/src/Chip.ts b/web-components/src/Chip.ts index a6145640..9d6f563d 100644 --- a/web-components/src/Chip.ts +++ b/web-components/src/Chip.ts @@ -30,7 +30,14 @@ export class Chip extends HTMLElement { private _onRemove: (() => void) | null = null static get observedAttributes(): string[] { - return ['selected', 'variant', 'icon', 'count', 'removable', 'disabled'] + return ['selected', 'variant', 'icon', 'count', 'removable', 'disabled', 'static'] + } + + // Static chips render a non-interactive `` root (no tc-click, no + // aria-pressed) — this is what tc-tag aliases onto. The tc-tag element always + // renders static regardless of the attribute. + private get isStatic(): boolean { + return this.hasAttribute('static') || this.localName === 'tc-tag' } constructor() { @@ -141,10 +148,14 @@ export class Chip extends HTMLElement { const count = this.getAttribute('count') const disabled = this.disabled const isRemovable = this.hasAttribute('removable') || this._onRemove !== null + const isStatic = this.isStatic const selectedClass = selected ? ' is-selected' : '' const disabledAttr = disabled ? ' disabled' : '' - const ariaPressedAttr = ` aria-pressed="${selected}"` + // A non-interactive span carries neither aria-pressed nor a type attribute. + const ariaPressedAttr = isStatic ? '' : ` aria-pressed="${selected}"` + const rootTag = isStatic ? 'span' : 'button' + const typeAttr = isStatic ? '' : ' type="button"' const iconHtml = iconName ? `` @@ -158,10 +169,12 @@ export class Chip extends HTMLElement { ? `` : '' - this.innerHTML = `${removeHtml}` + this.innerHTML = `<${rootTag} class="tc-chip tc-chip--${variant}${selectedClass}"${typeAttr}${ariaPressedAttr}${disabledAttr}>${iconHtml}${countHtml}${removeHtml}` - const chipBtn = this.querySelector('.tc-chip') - if (chipBtn) chipBtn.addEventListener('click', this._handleClick) + if (!isStatic) { + const chipBtn = this.querySelector('.tc-chip') + if (chipBtn) chipBtn.addEventListener('click', this._handleClick) + } if (isRemovable) { const removeBtn = this.querySelector('.tc-chip-remove') diff --git a/web-components/src/ColoredCard.ts b/web-components/src/ColoredCard.ts deleted file mode 100644 index 87cee402..00000000 --- a/web-components/src/ColoredCard.ts +++ /dev/null @@ -1,128 +0,0 @@ -import * as LucideIcons from 'lucide-static' -import { icon } from './icons' - -const TAG_NAME = 'tc-colored-card' - -function esc(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') -} - -function resolveIcon(name: string): string { - const svg = (LucideIcons as Record)[name] - return svg ? icon(svg, 'tc-colored-card-icon-svg') : '' -} - -export class ColoredCard extends HTMLElement { - private _initialised = false - - static get observedAttributes(): string[] { - return ['text', 'value', 'icon', 'color', 'loading'] - } - - connectedCallback(): void { - if (!this._initialised) { - this.render() - this._initialised = true - } - } - - attributeChangedCallback(): void { - if (!this.isConnected || !this._initialised) return - this.render() - } - - get text(): string { - return this.getAttribute('text') ?? '' - } - set text(v: string) { - this.setAttribute('text', v) - } - - get value(): string { - return this.getAttribute('value') ?? '' - } - set value(v: string | number) { - this.setAttribute('value', String(v)) - } - - get icon(): string | null { - return this.getAttribute('icon') - } - set icon(v: string | null) { - if (v != null) this.setAttribute('icon', v) - else this.removeAttribute('icon') - } - - get color(): string | null { - return this.getAttribute('color') - } - set color(v: string | null) { - if (v != null) this.setAttribute('color', v) - else this.removeAttribute('color') - } - - get loading(): boolean { - return this.hasAttribute('loading') - } - set loading(v: boolean) { - if (v) this.setAttribute('loading', '') - else this.removeAttribute('loading') - } - - private render(): void { - const loading = this.loading - const color = this.getAttribute('color') - - // Inline --bs-colored-card-accent so the SCSS tint and glyph color pick it up. - if (color) { - this.style.setProperty('--bs-colored-card-accent', color) - } else { - this.style.removeProperty('--bs-colored-card-accent') - } - - if (loading) { - this.setAttribute('aria-busy', 'true') - this.innerHTML = [ - '
    ', - '
    ', - '', - '
    ', - '
    ', - '
    ', - '
    ', - '
    ', - 'Loading…', - '
    ', - ].join('') - } else { - this.removeAttribute('aria-busy') - const iconName = this.getAttribute('icon') - const iconSvg = iconName ? resolveIcon(iconName) : '' - const iconChipHtml = iconSvg - ? `` - : '' - - this.innerHTML = [ - '
    ', - '
    ', - iconChipHtml, - '
    ', - `${esc(this.getAttribute('value') ?? '')}`, - `${esc(this.getAttribute('text') ?? '')}`, - '
    ', - '
    ', - '
    ', - ].join('') - } - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: ColoredCard - } -} diff --git a/web-components/src/ConfirmDialog.ts b/web-components/src/ConfirmDialog.ts index 58d8de49..5f4aa29e 100644 --- a/web-components/src/ConfirmDialog.ts +++ b/web-components/src/ConfirmDialog.ts @@ -1,77 +1,21 @@ -const TAG_NAME = 'tc-confirm-dialog' - -let _idCounter = 0 - -function esc(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') -} +import { DialogBase, esc } from './internal/dialog-base' -function getFocusable(root: Element): HTMLElement[] { - return Array.from( - root.querySelectorAll( - 'a[href],area[href],button:not([disabled]),details>summary,[tabindex]:not([tabindex="-1"]),input:not([disabled]),select:not([disabled]),textarea:not([disabled])', - ), - ).filter(el => !el.closest('[hidden]') && el.tabIndex >= 0) -} - -export class ConfirmDialog extends HTMLElement { - private _initialised = false - private _previousFocus: Element | null = null - private _idPrefix: string +const TAG_NAME = 'tc-confirm-dialog' +/** + * tc-confirm-dialog — a centered confirm/cancel modal on the shared + * {@link DialogBase} scaffold. Adds the confirm/cancel button body, the + * danger-variant + label attributes, Enter-to-confirm, and the tc-confirm / + * tc-cancel events. + */ +export class ConfirmDialog extends DialogBase { onConfirm: (() => void) | null = null onCancel: (() => void) | null = null - constructor() { - super() - this._idPrefix = `tc-confirm-dialog-${++_idCounter}` - } - static get observedAttributes(): string[] { return ['open', 'dialog-title', 'eyebrow', 'message', 'confirm-label', 'cancel-label', 'danger'] } - connectedCallback(): void { - if (!this._initialised) { - this.render() - this._initialised = true - } - // Detach before attach to prevent double-registration on reconnect. - this._detachHandlers() - this._attachHandlers() - if (this.open) this._applyOpenState(true) - } - - disconnectedCallback(): void { - this._detachHandlers() - this._restoreScroll() - } - - attributeChangedCallback(name: string): void { - if (!this.isConnected || !this._initialised) return - - if (name === 'open') { - this._applyOpenState(this.open) - return - } - - // Structural attribute changed — re-render, then re-apply visibility. - this.render() - if (this.open) this._applyOpenState(true) - } - - get open(): boolean { - return this.hasAttribute('open') - } - set open(v: boolean) { - if (v) this.setAttribute('open', '') - else this.removeAttribute('open') - } - get dialogTitle(): string { return this.getAttribute('dialog-title') ?? 'Are you sure?' } @@ -120,77 +64,35 @@ export class ConfirmDialog extends HTMLElement { else this.removeAttribute('danger') } - // ── Open / close lifecycle ───────────────────────────────────────────────── - - private _applyOpenState(opening: boolean): void { - const panel = this.querySelector('.tc-confirm-dialog__panel') - const backdrop = this.querySelector('.tc-confirm-dialog__backdrop') - - if (opening) { - this._previousFocus = document.activeElement - panel?.removeAttribute('hidden') - backdrop?.removeAttribute('hidden') - // Settle one frame before adding the open class to trigger the transition. - requestAnimationFrame(() => { - this.classList.add('tc-confirm-dialog--open') - panel?.setAttribute('aria-hidden', 'false') - this._lockScroll() - this._trapFocus(panel) - }) - } else { - this.classList.remove('tc-confirm-dialog--open') - panel?.setAttribute('aria-hidden', 'true') - this._restoreScroll() - this._restoreFocus() - const delay = this._getTransitionDuration(panel) - setTimeout(() => { - if (!this.open) { - panel?.setAttribute('hidden', '') - backdrop?.setAttribute('hidden', '') - } - }, delay) - } - } - - private _getTransitionDuration(el: HTMLElement | null): number { - if (!el) return 0 - const style = getComputedStyle(el) - const raw = style.transitionDuration || '0s' - let max = 0 - for (const p of raw.split(',')) { - const sec = parseFloat(p.trim()) - if (!isNaN(sec)) max = Math.max(max, sec) - } - return max * 1000 + // Focus the confirm action first when the dialog opens. + protected initialFocusSelector(): string | null { + return `.${this.classPrefix}__confirm` } - private _lockScroll(): void { - document.body.style.overflow = 'hidden' + protected onCloseRequest(): void { + this._emitCancel() } - private _restoreScroll(): void { - document.body.style.overflow = '' + protected onExtraKeydown(e: KeyboardEvent): void { + if (e.key !== 'Enter') return + // Don't hijack Enter while focus is on the cancel button — let the native + // click fire instead so the button does what it shows. + const active = document.activeElement + if (active instanceof HTMLElement && active.closest(`.${this.classPrefix}__cancel`)) return + e.preventDefault() + this._emitConfirm() } - private _restoreFocus(): void { - if (this._previousFocus instanceof HTMLElement) { - this._previousFocus.focus() + protected onBodyClick(e: MouseEvent): void { + const target = e.target as Element | null + if (!target) return + if (target.closest(`.${this.classPrefix}__confirm`)) { + this._emitConfirm() + } else if (target.closest(`.${this.classPrefix}__cancel`)) { + this._emitCancel() } - this._previousFocus = null - } - - private _trapFocus(panel: HTMLElement | null): void { - if (!panel) return - // Default focus to the confirm action; fall back to the panel itself. - const confirm = panel.querySelector('.tc-confirm-dialog__confirm') - const focusable = getFocusable(panel) - if (confirm) confirm.focus() - else if (focusable.length > 0) focusable[0].focus() - else panel.focus() } - // ── Event emit ───────────────────────────────────────────────────────────── - private _emitConfirm(): void { this.dispatchEvent(new CustomEvent('tc-confirm', { bubbles: true, composed: true, detail: {} })) if (typeof this.onConfirm === 'function') this.onConfirm() @@ -201,70 +103,7 @@ export class ConfirmDialog extends HTMLElement { if (typeof this.onCancel === 'function') this.onCancel() } - // ── Listeners ────────────────────────────────────────────────────────────── - - private _onKeydown = (e: KeyboardEvent): void => { - if (!this.open) return - if (e.key === 'Escape') { - e.preventDefault() - this._emitCancel() - return - } - if (e.key === 'Enter') { - // Don't hijack Enter while focus is on the cancel button — let the - // native click fire instead so the button does what it shows. - const active = document.activeElement - if (active instanceof HTMLElement && active.closest('.tc-confirm-dialog__cancel')) return - e.preventDefault() - this._emitConfirm() - return - } - if (e.key === 'Tab') { - const panel = this.querySelector('.tc-confirm-dialog__panel') - if (!panel) return - const focusable = getFocusable(panel) - if (focusable.length === 0) return - const first = focusable[0] - const last = focusable[focusable.length - 1] - if (e.shiftKey) { - if (document.activeElement === first) { - e.preventDefault() - last.focus() - } - } else { - if (document.activeElement === last) { - e.preventDefault() - first.focus() - } - } - } - } - - private _onClick = (e: MouseEvent): void => { - const target = e.target as Element | null - if (!target) return - if (target.closest('.tc-confirm-dialog__confirm')) { - this._emitConfirm() - } else if (target.closest('.tc-confirm-dialog__cancel')) { - this._emitCancel() - } else if (target.classList.contains('tc-confirm-dialog__backdrop')) { - this._emitCancel() - } - } - - private _attachHandlers(): void { - document.addEventListener('keydown', this._onKeydown) - this.addEventListener('click', this._onClick) - } - - private _detachHandlers(): void { - document.removeEventListener('keydown', this._onKeydown) - this.removeEventListener('click', this._onClick) - } - - // ── Render ───────────────────────────────────────────────────────────────── - - private render(): void { + protected render(): void { if (!this.hasAttribute('role')) this.setAttribute('role', 'dialog') const isOpen = this.open diff --git a/web-components/src/CreditsList.ts b/web-components/src/CreditsList.ts deleted file mode 100644 index aab5c149..00000000 --- a/web-components/src/CreditsList.ts +++ /dev/null @@ -1,59 +0,0 @@ -const TAG_NAME = 'tc-credits-list' - -export interface CreditsSection { - role: string - names: string[] -} - -function esc(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') -} - -export class CreditsList extends HTMLElement { - private _initialised = false - private _sections: CreditsSection[] = [] - - static get observedAttributes(): string[] { - return [] - } - - connectedCallback(): void { - if (!this._initialised) { - this.render() - this._initialised = true - } - } - - get sections(): CreditsSection[] { - return this._sections - } - set sections(v: CreditsSection[]) { - this._sections = Array.isArray(v) ? v : [] - if (this._initialised) this.render() - } - - private render(): void { - this.classList.add('tc-credits-list') - // `role="list"` mirrors the source component's accessibility contract. - if (!this.hasAttribute('role')) this.setAttribute('role', 'list') - - this.innerHTML = this._sections - .map(section => { - const names = (Array.isArray(section.names) ? section.names : []) - .map(name => `
    ${esc(name)}
    `) - .join('') - return `
    ${esc(section.role)}
    ${names}
    ` - }) - .join('') - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: CreditsList - } -} diff --git a/web-components/src/DataList.ts b/web-components/src/DataList.ts new file mode 100644 index 00000000..d2d0a7c5 --- /dev/null +++ b/web-components/src/DataList.ts @@ -0,0 +1,248 @@ +const TAG_NAME = 'tc-data-list' + +export interface DataListActionDetail { + action: string + id: string +} + +export interface DataListSelectDetail { + id: string +} + +export interface DataListEventMap { + 'tc-action': CustomEvent + 'tc-select': CustomEvent +} + +/** Row renderer hook — returns the full markup for one row (typically an `
  • `). */ +export type DataListRenderRow = (item: any, index: number) => string + +function esc(s: unknown): string { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +/** + * tc-data-list — a generic, data-driven row list. It owns the shared skeleton + * every domain list repeats: an `items` array that re-renders on assignment, an + * optional `list-title` header, an `empty-text` fallback, delegated per-row + * action buttons (`tc-action`), and an optional single-select listbox mode + * (`selectable` + `selected-id` → `tc-select`). + * + * Domain rendering is supplied by the consumer via the `renderRow` function + * property — `el.renderRow = (item, i) => '
  • '`. Each row must carry a + * `data-id` so the delegated action/select handlers can resolve which item was + * acted on; an action button inside a row marks itself with `data-action="…"`. + * Without a `renderRow` the element falls back to a built-in row that reads + * `id` / `label` / `secondary` / `trailing` off each item. + * + * This element replaces the family of near-identical "render an array of rows" + * components (mute / team / credits / achievement / … lists): the skeleton lives + * here once and each former list becomes a `renderRow` at the call site. + */ +export class DataList extends HTMLElement { + private _initialised = false + private _items: any[] = [] + private _renderRow: DataListRenderRow | null = null + + // Optional callback mirrors of the tc-action / tc-select events. + onAction: ((detail: DataListActionDetail) => void) | null = null + onSelect: ((detail: DataListSelectDetail) => void) | null = null + + static get observedAttributes(): string[] { + return ['list-title', 'empty-text', 'selectable', 'selected-id'] + } + + connectedCallback(): void { + if (!this._initialised) { + this.render() + this._initialised = true + } + } + + attributeChangedCallback(name: string): void { + if (!this.isConnected || !this._initialised) return + // selected-id only repaints the selection state — no full rebuild, so an + // in-progress keyboard focus survives. + if (name === 'selected-id') { + this._syncSelection() + return + } + this.render() + } + + get items(): any[] { + return this._items.slice() + } + set items(value: any[]) { + this._items = Array.isArray(value) ? value.slice() : [] + if (this._initialised) this.render() + } + + /** Row renderer. Assign a function returning the full row markup per item. */ + get renderRow(): DataListRenderRow | null { + return this._renderRow + } + set renderRow(fn: DataListRenderRow | null) { + this._renderRow = typeof fn === 'function' ? fn : null + if (this._initialised) this.render() + } + + get listTitle(): string { + return this.getAttribute('list-title') ?? '' + } + set listTitle(v: string) { + if (v) this.setAttribute('list-title', v) + else this.removeAttribute('list-title') + } + + get emptyText(): string { + return this.getAttribute('empty-text') ?? 'Nothing to show.' + } + set emptyText(v: string) { + if (v) this.setAttribute('empty-text', v) + else this.removeAttribute('empty-text') + } + + get selectable(): boolean { + return this.hasAttribute('selectable') + } + set selectable(v: boolean) { + if (v) this.setAttribute('selectable', '') + else this.removeAttribute('selectable') + } + + get selectedId(): string { + return this.getAttribute('selected-id') ?? '' + } + set selectedId(v: string) { + if (v) this.setAttribute('selected-id', v) + else this.removeAttribute('selected-id') + } + + // Built-in row used only when no `renderRow` is supplied. Reads conventional + // fields off the item so trivial lists work with zero config. + private _defaultRow(item: any, index: number): string { + const id = item?.id ?? String(index) + const label = item?.label ?? item?.name ?? item?.title ?? String(item) + const secondary = item?.secondary ?? item?.description ?? '' + const trailing = item?.trailing ?? '' + const selectable = this.selectable + + const secondaryHtml = secondary + ? `${esc(secondary)}` + : '' + const trailingHtml = trailing + ? `${esc(trailing)}` + : '' + const roleAttr = selectable + ? ' role="option" tabindex="0" aria-selected="false"' + : ' role="listitem"' + + return ( + `
  • ` + + `
    ` + + `${esc(label)}` + + secondaryHtml + + `
    ` + + trailingHtml + + `
  • ` + ) + } + + private render(): void { + this.classList.add('tc-data-list') + + const title = this.listTitle + const headerHtml = title + ? `
    ${esc(title)}
    ` + : '' + + let bodyInner: string + if (this._items.length === 0) { + bodyInner = `
  • ${esc(this.emptyText)}
  • ` + } else { + const fn = this._renderRow ?? ((it: any, i: number) => this._defaultRow(it, i)) + bodyInner = this._items.map((it, i) => fn(it, i)).join('') + } + + const bodyRole = this.selectable ? 'listbox' : 'list' + this.innerHTML = + headerHtml + + `
      ${bodyInner}
    ` + + this._wire() + this._syncSelection() + } + + // Reflect `selected-id` onto the rendered rows (selectable mode only). + private _syncSelection(): void { + if (!this.selectable) return + const selected = this.selectedId + const rows = this.querySelectorAll('.tc-data-list__row[data-id]') + rows.forEach(row => { + const isSel = row.dataset.id === selected && selected !== '' + row.classList.toggle('tc-data-list__row--selected', isSel) + row.setAttribute('aria-selected', isSel ? 'true' : 'false') + }) + } + + private _emitSelect(id: string): void { + this.selectedId = id + this.dispatchEvent( + new CustomEvent('tc-select', { bubbles: true, composed: true, detail: { id } }), + ) + if (typeof this.onSelect === 'function') this.onSelect({ id }) + } + + // Delegated listeners on the freshly-rendered body — the previous body and + // its listeners are garbage-collected together on every render, so no leak. + private _wire(): void { + const body = this.querySelector('.tc-data-list__body') + if (!body) return + + body.addEventListener('click', (e: Event) => { + const target = e.target as Element + const btn = target.closest('[data-action]') + const row = target.closest('.tc-data-list__row[data-id]') + const id = row?.dataset.id ?? '' + + if (btn && !btn.disabled) { + e.stopPropagation() + const action = btn.dataset.action ?? '' + this.dispatchEvent( + new CustomEvent('tc-action', { + bubbles: true, + composed: true, + detail: { action, id }, + }), + ) + if (typeof this.onAction === 'function') this.onAction({ action, id }) + return + } + + if (this.selectable && row) this._emitSelect(id) + }) + + if (this.selectable) { + body.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key !== 'Enter' && e.key !== ' ') return + const target = e.target as Element + if (target.closest('button')) return + const row = target.closest('.tc-data-list__row[data-id]') + if (!row) return + e.preventDefault() + this._emitSelect(row.dataset.id ?? '') + }) + } + } +} + +declare global { + interface HTMLElementTagNameMap { + [TAG_NAME]: DataList + } +} diff --git a/web-components/src/DeadzoneSlider.ts b/web-components/src/DeadzoneSlider.ts deleted file mode 100644 index 0b506583..00000000 --- a/web-components/src/DeadzoneSlider.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { SettingRowBase } from './SettingRowBase' - -const TAG_NAME = 'tc-deadzone-slider' - -// tc-deadzone-slider — a stick-deadzone preset row. A native range input -// (0–1, 1% steps) paired with a mono percentage readout, built on the shared -// setting-row scaffold. Port of game-components `gc-deadzone-slider` with the -// fantasy chrome dropped for the toolcase slate/ink look. - -export class DeadzoneSlider extends SettingRowBase { - - // Optional callback mirror of the `tc-change` event (see styleguide §events). - onChange: ((value: number) => void) | null = null - - static get observedAttributes(): string[] { - return [...SettingRowBase.observedAttributes, 'value', 'disabled'] - } - - connectedCallback(): void { - if (!this.hasAttribute('row-label')) this.setAttribute('row-label', 'Stick deadzone') - super.connectedCallback() - } - - attributeChangedCallback(name: string, old: string | null, next: string | null): void { - if (!this.isConnected || !this._initialised) return - // Patch the value in place — a full re-render would destroy the input - // (and the active drag) on every `input` event. - if (name === 'value') { - const input = this.querySelector('.tc-deadzone-slider__input') - const display = this.querySelector('.tc-deadzone-slider__value') - const v = this.value - if (input && input.value !== String(v)) input.value = String(v) - if (display) display.textContent = `${Math.round(v * 100)}%` - return - } - super.attributeChangedCallback(name, old, next) - } - - get value(): number { - const raw = this.getAttribute('value') - if (raw == null) return 0.15 - const parsed = parseFloat(raw) - return Number.isNaN(parsed) ? 0.15 : Math.max(0, Math.min(1, parsed)) - } - set value(v: number) { - this.setAttribute('value', String(v)) - } - - get disabled(): boolean { - return this.hasAttribute('disabled') - } - set disabled(v: boolean) { - if (v) this.setAttribute('disabled', '') - else this.removeAttribute('disabled') - } - - protected renderControl(): string { - const value = this.value - const disabledAttr = this.disabled ? ' disabled' : '' - return ` -
    - - ${Math.round(value * 100)}% -
    - ` - } - - protected bindControl(): void { - const input = this.querySelector('.tc-deadzone-slider__input') - const display = this.querySelector('.tc-deadzone-slider__value') - if (!input) return - input.addEventListener('input', () => { - const v = parseFloat(input.value) - if (display) display.textContent = `${Math.round(v * 100)}%` - this.setAttribute('value', String(v)) - this.emit('tc-change', { value: v }) - if (typeof this.onChange === 'function') this.onChange(v) - }) - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: DeadzoneSlider - } -} diff --git a/web-components/src/DropdownItem.ts b/web-components/src/DropdownItem.ts index 1a44297e..3bb98edc 100644 --- a/web-components/src/DropdownItem.ts +++ b/web-components/src/DropdownItem.ts @@ -1,56 +1,18 @@ -const TAG_NAME = 'tc-dropdown-item' - -export class DropdownItem extends HTMLElement { +import { LinkItemBase } from './internal/link-item' - private _initialised = false +const TAG_NAME = 'tc-dropdown-item' +/** + * tc-dropdown-item — a single dropdown menu entry on the shared + * {@link LinkItemBase} slot-wrapping scaffold. Renders an `
  • `-wrapped `` + * (when `href` is set) or ``, - ``, - `
  • `, - ``, - ].join('') - } - - private render(): void { - const sorted = this._friends.slice().sort((a, b) => { - const oa = statusOrder(a.status) - const ob = statusOrder(b.status) - if (oa !== ob) return oa - ob - return a.name.localeCompare(b.name) - }) - - const onlineCount = this._friends.filter(f => f.status && f.status !== 'offline').length - const total = this._friends.length - - const rows = sorted.map(f => this._renderRow(f)).join('') - - this.innerHTML = [ - `
    `, - `
    `, - `${esc(this.listTitle)}`, - `${onlineCount}/${total}`, - `
    `, - `
      ${rows}
    `, - `
    `, - ].join('') - - // Delegate clicks on the freshly-rendered body — the old container and its - // listener are garbage-collected together on every render, so no leak. - const body = this.querySelector('.tc-friends-list-body') - body?.addEventListener('click', (event: MouseEvent) => { - const btn = (event.target as HTMLElement).closest('.tc-friends-list-action') - if (!btn) return - event.stopPropagation() - const row = btn.closest('.tc-friends-list-row') - const id = row?.dataset.id ?? '' - const action = btn.dataset.action - if (action === 'invite') this._emit('tc-invite', id) - else if (action === 'message') this._emit('tc-message', id) - }) - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: FriendsList - } -} diff --git a/web-components/src/GameOverScreen.ts b/web-components/src/GameOverScreen.ts deleted file mode 100644 index 10bc2fd6..00000000 --- a/web-components/src/GameOverScreen.ts +++ /dev/null @@ -1,226 +0,0 @@ -const TAG_NAME = 'tc-game-over-screen' - -// Port of game-components `gc-game-over-screen` (a `gc-result-screen` whose -// defaults read "Game Over" / "Defeat" in a danger tone). The fantasy chrome -// (gilded frame, diamond divider, metal buttons) is dropped; this renders to -// the web-components design system — a flat slate region with a mono eyebrow, a -// status-toned title, a hairline divider, hairline-separated stat rows, a soft -// reward strip, and a row of `.btn` actions. All cosmetics flow through -// `--bs-game-over-screen-*` custom properties. - -export type GameOverScreenTitleColor = 'gold' | 'danger' | 'parch' -const TITLE_COLORS: GameOverScreenTitleColor[] = ['gold', 'danger', 'parch'] - -export type GameOverScreenActionVariant = 'default' | 'primary' | 'danger' | 'ghost' -const ACTION_VARIANTS: GameOverScreenActionVariant[] = ['default', 'primary', 'danger', 'ghost'] - -export interface GameOverStat { - label: string - value: string | number -} - -export interface GameOverReward { - label: string - glyph?: string - amount?: number | string - color?: string -} - -export interface GameOverAction { - id: string - label: string - variant?: GameOverScreenActionVariant -} - -export interface GameOverScreenEventMap { - 'tc-action': CustomEvent<{ id: string }> -} - -function esc(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -// `default`/`ghost` are toned-down per the design voice (slate, not colour); -// `primary` is the ink action; `danger` keeps the status colour. -const ACTION_CLASS: Record = { - default: 'btn btn-secondary', - primary: 'btn btn-primary', - danger: 'btn btn-danger', - ghost: 'btn btn-outline-secondary', -} - -export class GameOverScreen extends HTMLElement { - - private _initialised = false - private _stats: GameOverStat[] = [] - private _rewards: GameOverReward[] = [] - private _actions: GameOverAction[] = [] - - /** Optional callback fired alongside the `tc-action` CustomEvent. */ - onAction: ((id: string) => void) | null = null - - static get observedAttributes(): string[] { - return ['title-text', 'subtitle', 'title-color', 'eyebrow'] - } - - connectedCallback(): void { - if (!this._initialised) { - if (!this.hasAttribute('role')) this.setAttribute('role', 'region') - this.render() - this.addEventListener('click', this._onClick) - this._initialised = true - } - } - - disconnectedCallback(): void { - this.removeEventListener('click', this._onClick) - } - - attributeChangedCallback(): void { - if (!this.isConnected || !this._initialised) return - this.render() - } - - get titleText(): string { - return this.getAttribute('title-text') ?? 'Game Over' - } - set titleText(v: string) { - if (v) this.setAttribute('title-text', v) - else this.removeAttribute('title-text') - } - - get subtitle(): string { - return this.getAttribute('subtitle') ?? '' - } - set subtitle(v: string) { - if (v) this.setAttribute('subtitle', v) - else this.removeAttribute('subtitle') - } - - get titleColor(): GameOverScreenTitleColor { - const raw = this.getAttribute('title-color') as GameOverScreenTitleColor - return TITLE_COLORS.includes(raw) ? raw : 'danger' - } - set titleColor(v: GameOverScreenTitleColor) { - if (v) this.setAttribute('title-color', v) - else this.removeAttribute('title-color') - } - - get eyebrow(): string { - return this.getAttribute('eyebrow') ?? 'Defeat' - } - set eyebrow(v: string) { - if (v) this.setAttribute('eyebrow', v) - else this.removeAttribute('eyebrow') - } - - get stats(): GameOverStat[] { - return this._stats.slice() - } - set stats(v: GameOverStat[]) { - this._stats = Array.isArray(v) ? v.slice() : [] - if (this._initialised) this.render() - } - - get rewards(): GameOverReward[] { - return this._rewards.slice() - } - set rewards(v: GameOverReward[]) { - this._rewards = Array.isArray(v) ? v.slice() : [] - if (this._initialised) this.render() - } - - get actions(): GameOverAction[] { - return this._actions.slice() - } - set actions(v: GameOverAction[]) { - this._actions = Array.isArray(v) ? v.slice() : [] - if (this._initialised) this.render() - } - - private _onClick = (e: MouseEvent): void => { - if (!(e.target instanceof HTMLElement)) return - const btn = e.target.closest('.tc-game-over-screen-action') - if (!btn || !this.contains(btn)) return - const id = btn.dataset.id - if (!id) return - this.dispatchEvent(new CustomEvent('tc-action', { detail: { id }, bubbles: true, composed: true })) - if (typeof this.onAction === 'function') this.onAction(id) - } - - private formatValue(v: string | number): string { - return typeof v === 'number' ? v.toLocaleString() : v - } - - private render(): void { - this.classList.add('tc-game-over-screen') - - const statMarkup = this._stats.map(stat => - `
    ` - + `${esc(stat.label)}` - + `${esc(this.formatValue(stat.value))}` - + `
    ` - ).join('') - - const rewardMarkup = this._rewards.map(reward => { - const colorStyle = reward.color ? ` style="color: ${esc(reward.color)}"` : '' - const glyphMarkup = reward.glyph - ? `` - : '' - const amountMarkup = reward.amount != null - ? `${esc(this.formatValue(reward.amount))}` - : '' - return `
    ` - + glyphMarkup - + `${esc(reward.label)}` - + amountMarkup - + `
    ` - }).join('') - - const actionMarkup = this._actions.map(action => { - const variant = ACTION_VARIANTS.includes(action.variant as GameOverScreenActionVariant) - ? (action.variant as GameOverScreenActionVariant) - : 'default' - return `` - }).join('') - - const subtitleMarkup = this.subtitle - ? `

    ${esc(this.subtitle)}

    ` - : '' - const statsBlock = statMarkup - ? `
    ${statMarkup}
    ` - : '' - const rewardsBlock = rewardMarkup - ? `
    ` - + `Rewards` - + `
    ${rewardMarkup}
    ` - + `
    ` - : '' - const actionsBlock = actionMarkup - ? `
    ${actionMarkup}
    ` - : '' - - this.innerHTML = ` -
    - ${esc(this.eyebrow)} -

    ${esc(this.titleText)}

    - - ${subtitleMarkup} - ${statsBlock} - ${rewardsBlock} - ${actionsBlock} -
    - ` - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: GameOverScreen - } -} diff --git a/web-components/src/Heading.ts b/web-components/src/Heading.ts index 8c4cbf27..8e3da6d0 100644 --- a/web-components/src/Heading.ts +++ b/web-components/src/Heading.ts @@ -1,39 +1,19 @@ +import { SlotWrapBase } from './internal/slot-wrap' + const TAG_NAME = 'tc-heading' export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' const LEVELS: HeadingLevel[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] -export class Heading extends HTMLElement { - - private _initialised = false - +/** + * tc-heading — a heading (`h1`–`h6`) with optional gradient ink fill. Built on the + * shared {@link SlotWrapBase} slot-wrapping scaffold. + */ +export class Heading extends SlotWrapBase { static get observedAttributes(): string[] { return ['as', 'gradient'] } - constructor() { - super() - } - - connectedCallback(): void { - if (!this._initialised) { - const slotContent = Array.from(this.childNodes) - this.render() - const inner = this.querySelector('.tc-heading-content') - if (inner) slotContent.forEach(n => inner.appendChild(n)) - this._initialised = true - } - } - - attributeChangedCallback(): void { - if (!this.isConnected || !this._initialised) return - const inner = this.querySelector('.tc-heading-content') - const slotContent = inner ? Array.from(inner.childNodes) : [] - this.render() - const newInner = this.querySelector('.tc-heading-content') - if (newInner) slotContent.forEach(n => newInner.appendChild(n)) - } - get as(): HeadingLevel { const v = this.getAttribute('as') as HeadingLevel return LEVELS.includes(v) ? v : 'h2' @@ -50,7 +30,11 @@ export class Heading extends HTMLElement { else this.removeAttribute('gradient') } - private render(): void { + protected getContentEl(): Element | null { + return this.querySelector('.tc-heading-content') + } + + protected render(): void { const level = this.as const gradientClass = this.gradient ? ' tc-heading--gradient' : '' this.innerHTML = `<${level} class="tc-heading${gradientClass}">` diff --git a/web-components/src/Input.ts b/web-components/src/Input.ts index 055d70fe..a8a5d38a 100644 --- a/web-components/src/Input.ts +++ b/web-components/src/Input.ts @@ -1,43 +1,23 @@ -const TAG_NAME = 'tc-input' +import { + TextFieldBase, + TEXT_FIELD_ATTRIBUTES, + esc, + type ControlRenderContext, +} from './internal/text-field-base' -let _idCounter = 0 +const TAG_NAME = 'tc-input' export type InputSize = 'sm' | 'lg' export type InputState = 'valid' | 'invalid' -const SIZES: InputSize[] = ['sm', 'lg'] -const STATES: InputState[] = ['valid', 'invalid'] - -export class Input extends HTMLElement { - - private _inputId: string - private _helpId: string - private _initialised = false - +/** + * tc-input — a labelled Bootstrap text input with validation feedback and help + * text. Built on the shared {@link TextFieldBase} frame; the input-specific bits + * are the `type` attribute and the `` control element. + */ +export class Input extends TextFieldBase { static get observedAttributes(): string[] { - return ['type', 'value', 'placeholder', 'label', 'size', 'disabled', 'readonly', 'required', 'state', 'help'] - } - - constructor() { - super() - const uid = ++_idCounter - this._inputId = `tc-input-${uid}` - this._helpId = `tc-input-help-${uid}` - } - - connectedCallback(): void { - this.render() - this._initialised = true - } - - attributeChangedCallback(name: string, _old: string | null, next: string | null): void { - if (!this.isConnected || !this._initialised) return - if (name === 'value') { - const input = this.querySelector('input') - if (input && input.value !== (next ?? '')) input.value = next ?? '' - return - } - this.render() + return ['type', ...TEXT_FIELD_ATTRIBUTES] } get type(): string { @@ -47,124 +27,16 @@ export class Input extends HTMLElement { this.setAttribute('type', v) } - get value(): string { - return this.querySelector('input')?.value ?? this.getAttribute('value') ?? '' - } - set value(v: string) { - const input = this.querySelector('input') - if (input) input.value = v - this.setAttribute('value', v) - } - - get placeholder(): string { - return this.getAttribute('placeholder') ?? '' - } - set placeholder(v: string) { - this.setAttribute('placeholder', v) + protected get controlSelector(): string { + return 'input' } - get label(): string | null { - return this.getAttribute('label') - } - set label(v: string | null) { - if (v != null) this.setAttribute('label', v) - else this.removeAttribute('label') - } - - get size(): InputSize | null { - const v = this.getAttribute('size') as InputSize - return SIZES.includes(v) ? v : null - } - set size(v: InputSize | null) { - if (v != null) this.setAttribute('size', v) - else this.removeAttribute('size') - } - - get disabled(): boolean { - return this.hasAttribute('disabled') - } - set disabled(v: boolean) { - if (v) this.setAttribute('disabled', '') - else this.removeAttribute('disabled') + protected renderControl(ctx: ControlRenderContext): string { + return ( + `` + ) } - - get readonly(): boolean { - return this.hasAttribute('readonly') - } - set readonly(v: boolean) { - if (v) this.setAttribute('readonly', '') - else this.removeAttribute('readonly') - } - - get required(): boolean { - return this.hasAttribute('required') - } - set required(v: boolean) { - if (v) this.setAttribute('required', '') - else this.removeAttribute('required') - } - - get state(): InputState | null { - const v = this.getAttribute('state') as InputState - return STATES.includes(v) ? v : null - } - set state(v: InputState | null) { - if (v != null) this.setAttribute('state', v) - else this.removeAttribute('state') - } - - get help(): string | null { - return this.getAttribute('help') - } - set help(v: string | null) { - if (v != null) this.setAttribute('help', v) - else this.removeAttribute('help') - } - - private render(): void { - const label = this.label - const size = this.size - const state = this.state - const help = this.help - const disabled = this.disabled - const readonly = this.readonly - const required = this.required - const placeholder = this.placeholder - const currentValue = this.querySelector('input')?.value ?? this.getAttribute('value') ?? '' - - const sizeClass = size ? ` form-control-${size}` : '' - const stateClass = state === 'valid' ? ' is-valid' : state === 'invalid' ? ' is-invalid' : '' - const ariaDescribedBy = help ? ` aria-describedby="${this._helpId}"` : '' - const disabledAttr = disabled ? ' disabled' : '' - const readonlyAttr = readonly ? ' readonly' : '' - const requiredAttr = required ? ' required' : '' - - const labelHtml = label - ? `` - : '' - - const feedbackHtml = state === 'valid' - ? `
    Looks good!
    ` - : state === 'invalid' - ? `
    Please provide a valid value.
    ` - : '' - - const helpHtml = help - ? `
    ${esc(help)}
    ` - : '' - - this.innerHTML = [ - labelHtml, - ``, - feedbackHtml, - helpHtml, - ].join('') - } -} - -function esc(str: string): string { - return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') } declare global { diff --git a/web-components/src/InvertAxisToggle.ts b/web-components/src/InvertAxisToggle.ts deleted file mode 100644 index c2b51360..00000000 --- a/web-components/src/InvertAxisToggle.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { SettingRowBase } from './SettingRowBase' - -const TAG_NAME = 'tc-invert-axis-toggle' - -// tc-invert-axis-toggle — an invert-axis on/off preset row. A pill-track switch -// (role="switch") paired with the shared setting-row scaffold. Port of -// game-components `gc-invert-axis-toggle` (which extends `gc-toggle-row`) with the -// fantasy chrome dropped for the toolcase slate/ink look; the checked track -// carries the signature ink gradient. All cosmetics flow through -// `--bs-invert-axis-toggle-*`. -export class InvertAxisToggle extends SettingRowBase { - - // Optional callback mirror of the `tc-change` event (see styleguide §events). - onChange: ((value: boolean) => void) | null = null - - static get observedAttributes(): string[] { - return [...SettingRowBase.observedAttributes, 'checked', 'disabled'] - } - - connectedCallback(): void { - if (!this.hasAttribute('row-label')) this.setAttribute('row-label', 'Invert Y axis') - super.connectedCallback() - } - - attributeChangedCallback(name: string, old: string | null, next: string | null): void { - if (!this.isConnected || !this._initialised) return - // Patch checked/disabled in place — a full re-render would drop the - // button's focus on every toggle. - if (name === 'checked') { - const btn = this.querySelector('.tc-invert-axis-toggle__switch') - const checked = this.checked - if (btn) { - btn.setAttribute('aria-checked', String(checked)) - btn.dataset.checked = String(checked) - } - return - } - if (name === 'disabled') { - const btn = this.querySelector('.tc-invert-axis-toggle__switch') - if (btn) btn.disabled = this.disabled - return - } - super.attributeChangedCallback(name, old, next) - } - - get checked(): boolean { - return this.hasAttribute('checked') - } - set checked(v: boolean) { - if (v) this.setAttribute('checked', '') - else this.removeAttribute('checked') - } - - get disabled(): boolean { - return this.hasAttribute('disabled') - } - set disabled(v: boolean) { - if (v) this.setAttribute('disabled', '') - else this.removeAttribute('disabled') - } - - protected renderControl(): string { - const checked = this.checked - const disabledAttr = this.disabled ? ' disabled' : '' - return ` - - ` - } - - protected bindControl(): void { - const btn = this.querySelector('.tc-invert-axis-toggle__switch') - if (!btn) return - btn.addEventListener('click', () => { - if (this.disabled) return - const next = !this.checked - this.checked = next - btn.dataset.checked = String(next) - btn.setAttribute('aria-checked', String(next)) - this.emit('tc-change', { value: next }) - if (typeof this.onChange === 'function') this.onChange(next) - }) - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: InvertAxisToggle - } -} diff --git a/web-components/src/LeaderboardTrend.ts b/web-components/src/LeaderboardTrend.ts deleted file mode 100644 index 165968d8..00000000 --- a/web-components/src/LeaderboardTrend.ts +++ /dev/null @@ -1,89 +0,0 @@ -import * as LucideIcons from 'lucide-static' -import { icon } from './icons' - -const TAG_NAME = 'tc-leaderboard-trend' - -export type LeaderboardTrendDirection = 'up' | 'down' | 'flat' - -const DIRECTIONS: LeaderboardTrendDirection[] = ['up', 'down', 'flat'] - -const DIRECTION_ICONS: Record = { - up: ['TrendingUp', 'ArrowUp'], - down: ['TrendingDown', 'ArrowDown'], - flat: ['Minus', 'ArrowRight'], -} - -function esc(s: string): string { - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') -} - -function resolveIcon(direction: LeaderboardTrendDirection): string { - for (const k of DIRECTION_ICONS[direction]) { - const svg = (LucideIcons as Record)[k] - if (svg) return icon(svg, 'tc-leaderboard-trend-icon') - } - return '' -} - -export class LeaderboardTrend extends HTMLElement { - - private _initialised = false - - static get observedAttributes(): string[] { - return ['value', 'direction'] - } - - connectedCallback(): void { - if (!this._initialised) { - const slotContent = Array.from(this.childNodes) - this.render() - if (!this.hasAttribute('value')) { - const inner = this.querySelector('.tc-leaderboard-trend-value') - if (inner) slotContent.forEach(n => inner.appendChild(n)) - } - this._initialised = true - } - } - - attributeChangedCallback(): void { - if (!this.isConnected || !this._initialised) return - const inner = this.querySelector('.tc-leaderboard-trend-value') - const slotContent = !this.hasAttribute('value') && inner ? Array.from(inner.childNodes) : [] - this.render() - if (!this.hasAttribute('value')) { - const newInner = this.querySelector('.tc-leaderboard-trend-value') - if (newInner) slotContent.forEach(n => newInner.appendChild(n)) - } - } - - get value(): string | null { - return this.getAttribute('value') - } - set value(v: string | null) { - if (v != null) this.setAttribute('value', v) - else this.removeAttribute('value') - } - - get direction(): LeaderboardTrendDirection { - const d = this.getAttribute('direction') as LeaderboardTrendDirection - return DIRECTIONS.includes(d) ? d : 'flat' - } - set direction(v: LeaderboardTrendDirection) { - this.setAttribute('direction', v) - } - - private render(): void { - const direction = this.direction - const value = this.getAttribute('value') - const iconHtml = resolveIcon(direction) - const valueHtml = value != null ? esc(value) : '' - - this.innerHTML = `${iconHtml}${valueHtml}` - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: LeaderboardTrend - } -} diff --git a/web-components/src/ManaBar.ts b/web-components/src/ManaBar.ts deleted file mode 100644 index 2b84ceb0..00000000 --- a/web-components/src/ManaBar.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { escapeHtml, renderResourceBarTrack } from './internal/resourceBar' - -const TAG_NAME = 'tc-mana-bar' - -export class ManaBar extends HTMLElement { - private _initialised = false - - static get observedAttributes(): string[] { - return ['value', 'max', 'ghost', 'segments', 'show-text', 'label'] - } - - connectedCallback(): void { - if (!this._initialised) { - this.render() - this._initialised = true - } - } - - attributeChangedCallback(): void { - if (!this.isConnected || !this._initialised) return - this.render() - } - - get value(): number { - const raw = this.getAttribute('value') - if (raw == null) return 0 - const parsed = parseFloat(raw) - return Number.isNaN(parsed) ? 0 : parsed - } - set value(v: number) { - this.setAttribute('value', String(v)) - } - - get max(): number { - const raw = this.getAttribute('max') - if (raw == null) return 100 - const parsed = parseFloat(raw) - return Number.isNaN(parsed) || parsed <= 0 ? 100 : parsed - } - set max(v: number) { - this.setAttribute('max', String(v)) - } - - get ghost(): number | null { - const raw = this.getAttribute('ghost') - if (raw == null) return null - const parsed = parseFloat(raw) - return Number.isNaN(parsed) ? null : parsed - } - set ghost(v: number | null) { - if (v == null) this.removeAttribute('ghost') - else this.setAttribute('ghost', String(v)) - } - - get segments(): number { - const raw = this.getAttribute('segments') - if (raw == null) return 1 - const parsed = parseInt(raw, 10) - return Number.isNaN(parsed) || parsed < 1 ? 1 : parsed - } - set segments(v: number) { - this.setAttribute('segments', String(v)) - } - - get showText(): boolean { - return this.hasAttribute('show-text') - } - set showText(v: boolean) { - if (v) this.setAttribute('show-text', '') - else this.removeAttribute('show-text') - } - - get label(): string { - return this.getAttribute('label') ?? '' - } - set label(v: string) { - this.setAttribute('label', v) - } - - private render(): void { - const max = this.max - const value = Math.max(0, Math.min(max, this.value)) - const label = this.label - const showText = this.showText - const segments = this.segments - - this.classList.add('tc-mana-bar') - - const numericText = `${Math.round(value)} / ${Math.round(max)}` - - const ticks = - segments > 1 - ? Array.from({ length: segments - 1 }, (_, i) => (i + 1) / segments) - : [] - - const labelRow = label - ? `
    ` + - `${escapeHtml(label)}` + - `${numericText}` + - `
    ` - : '' - - const track = renderResourceBarTrack({ - prefix: 'tc-mana-bar', - value, - max, - ghost: this.ghost, - ticks, - label: label || 'Mana', - inlineText: showText && !label ? numericText : null, - }) - - this.innerHTML = labelRow + track - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: ManaBar - } -} diff --git a/web-components/src/MetalButton.ts b/web-components/src/MetalButton.ts deleted file mode 100644 index 20d6478f..00000000 --- a/web-components/src/MetalButton.ts +++ /dev/null @@ -1,76 +0,0 @@ -const TAG_NAME = 'tc-metal-button' - -export type MetalButtonVariant = 'default' | 'primary' | 'danger' | 'ghost' -export type MetalButtonSize = 'sm' | 'md' | 'lg' - -const VARIANTS: MetalButtonVariant[] = ['default', 'primary', 'danger', 'ghost'] -const SIZES: MetalButtonSize[] = ['sm', 'md', 'lg'] - -export class MetalButton extends HTMLElement { - - private _initialised = false - - static get observedAttributes(): string[] { - return ['variant', 'size', 'disabled'] - } - - connectedCallback(): void { - if (!this._initialised) { - const slotContent = Array.from(this.childNodes) - this.render() - const inner = this.querySelector('.tc-metal-button-content') - if (inner) slotContent.forEach(n => inner.appendChild(n)) - this._initialised = true - } - } - - attributeChangedCallback(): void { - if (!this.isConnected || !this._initialised) return - const inner = this.querySelector('.tc-metal-button-content') - const slotContent = inner ? Array.from(inner.childNodes) : [] - this.render() - const newInner = this.querySelector('.tc-metal-button-content') - if (newInner) slotContent.forEach(n => newInner.appendChild(n)) - } - - get variant(): MetalButtonVariant { - const v = this.getAttribute('variant') as MetalButtonVariant - return VARIANTS.includes(v) ? v : 'default' - } - set variant(v: MetalButtonVariant) { - this.setAttribute('variant', v) - } - - get size(): MetalButtonSize { - const v = this.getAttribute('size') as MetalButtonSize - return SIZES.includes(v) ? v : 'md' - } - set size(v: MetalButtonSize) { - this.setAttribute('size', v) - } - - get disabled(): boolean { - return this.hasAttribute('disabled') - } - set disabled(v: boolean) { - if (v) this.setAttribute('disabled', '') - else this.removeAttribute('disabled') - } - - private render(): void { - const variant = this.variant - const size = this.size - const disabled = this.disabled - - const sizeClass = size !== 'md' ? ` tc-metal-button__btn--${size}` : '' - const disabledAttr = disabled ? ' disabled' : '' - - this.innerHTML = `` - } -} - -declare global { - interface HTMLElementTagNameMap { - [TAG_NAME]: MetalButton - } -} diff --git a/web-components/src/Modal.ts b/web-components/src/Modal.ts index 1fb3721e..48477031 100644 --- a/web-components/src/Modal.ts +++ b/web-components/src/Modal.ts @@ -1,64 +1,25 @@ import { Modal as BsModal } from './internal/Modal' +import { BsOverlay, escapeHtml, type OverlayPlugin } from './internal/bs-overlay' import { closeIcon } from './icons' const TAG_NAME = 'tc-modal' -export class Modal extends HTMLElement { - - private _bsModal: BsModal | null = null +/** + * tc-modal — a Bootstrap modal wrapper on the shared {@link BsOverlay} scaffold. + * Modal specifics: the `.modal` dialog markup with size / centered / scrollable / + * fullscreen options, a `footer` slot, the static-backdrop option, and the + * `.bs.modal` event namespace. + */ +export class Modal extends BsOverlay { private _bodyNodes: Node[] = [] private _footerNodes: Node[] = [] - private _initialised = false - private _syncing = false static get observedAttributes(): string[] { return ['open', 'title', 'size', 'centered', 'scrollable', 'static-backdrop', 'fullscreen'] } - constructor() { - super() - } - - connectedCallback(): void { - if (!this._initialised) { - this._bodyNodes = Array.from(this.childNodes).filter( - n => !(n instanceof Element && n.getAttribute('slot') === 'footer'), - ) - this._footerNodes = Array.from(this.childNodes).filter( - n => n instanceof Element && n.getAttribute('slot') === 'footer', - ) - this._initialised = true - } - this.render() - this._initPlugin() - if (this.open) this._bsModal?.show() - } - - disconnectedCallback(): void { - this._teardown() - } - - attributeChangedCallback(name: string): void { - if (!this.isConnected || !this._initialised || this._syncing) return - if (name === 'open') { - if (this.open) { - this._bsModal?.show() - } else { - this._bsModal?.hide() - } - } else { - this._teardown() - this.render() - this._initPlugin() - } - } - - get open(): boolean { - return this.hasAttribute('open') - } - set open(v: boolean) { - if (v) this.setAttribute('open', '') - else this.removeAttribute('open') + protected get eventNs(): string { + return 'modal' } get title(): string { @@ -106,41 +67,21 @@ export class Modal extends HTMLElement { this.setAttribute('fullscreen', v) } - show(): void { - this._bsModal?.show() - } - - hide(): void { - this._bsModal?.hide() + protected captureSlots(): void { + this._bodyNodes = Array.from(this.childNodes).filter( + n => !(n instanceof Element && n.getAttribute('slot') === 'footer'), + ) + this._footerNodes = Array.from(this.childNodes).filter( + n => n instanceof Element && n.getAttribute('slot') === 'footer', + ) } - toggle(): void { - this._bsModal?.toggle() - } - - private _onShow = (): void => { - this._syncing = true - this.setAttribute('open', '') - this._syncing = false - this.dispatchEvent(new CustomEvent('tc-show', { bubbles: true, composed: true })) - } - - private _onShown = (): void => { - this.dispatchEvent(new CustomEvent('tc-shown', { bubbles: true, composed: true })) - } - - private _onHide = (): void => { - this._syncing = true - this.removeAttribute('open') - this._syncing = false - this.dispatchEvent(new CustomEvent('tc-hide', { bubbles: true, composed: true })) - } - - private _onHidden = (): void => { - this.dispatchEvent(new CustomEvent('tc-hidden', { bubbles: true, composed: true })) + protected createPlugin(): OverlayPlugin { + const backdrop: boolean | 'static' = this.staticBackdrop ? 'static' : true + return new BsModal(this, { backdrop }) as unknown as OverlayPlugin } - private render(): void { + protected render(): void { const dialogClasses = ['modal-dialog'] if (this.centered) dialogClasses.push('modal-dialog-centered') @@ -168,9 +109,10 @@ export class Modal extends HTMLElement { const hasFooter = this._footerNodes.length > 0 const footerHtml = hasFooter ? '' : '' - const titleText = this._escapeHtml(this.getAttribute('title') ?? '') + const titleText = escapeHtml(this.getAttribute('title') ?? '') - this.innerHTML = `
    ` + + this.innerHTML = + `
    ` + `` : '' const leftHeadHtml = renderTechHead(this._left) @@ -201,30 +182,32 @@ export class Comparator extends HTMLElement { const theadHtml = `` + - `` + - `${leftHeadHtml}` + - `` + - `${rightHeadHtml}` + - `` + + `` + + `${leftHeadHtml}` + + `` + + `${rightHeadHtml}` + + `` + `` if (isLoading) { - const skeletonRows = Array.from({ length: count }, () => - `` + + const skeletonRows = Array.from( + { length: count }, + () => + `` + `` + `` + `` + - ``, + ``, ).join('') this.innerHTML = `
    ${headerHtml}` + - `
    ` + - `` + - theadHtml + - `${skeletonRows}` + - `
    ` + - `
    ` + + `
    ` + + `` + + theadHtml + + `${skeletonRows}` + + `
    ` + + `
    ` + `
    ` return } @@ -232,7 +215,7 @@ export class Comparator extends HTMLElement { // Compute winners let leftWins = 0 let rightWins = 0 - const winners = features.map(f => { + const winners = features.map((f) => { const w = compareValues(f.left, f.right) if (w === 'left') leftWins++ if (w === 'right') rightWins++ @@ -255,12 +238,12 @@ export class Comparator extends HTMLElement { : '' return ( `` + - `${renderCellValue(f.left)}` + - `` + - `${esc(f.label)}` + - descHtml + - `` + - `${renderCellValue(f.right)}` + + `${renderCellValue(f.left)}` + + `` + + `${esc(f.label)}` + + descHtml + + `` + + `${renderCellValue(f.right)}` + `` ) }) @@ -269,26 +252,26 @@ export class Comparator extends HTMLElement { const summaryHtml = showSum && features.length > 0 ? `` + - `` + - `${leftWins} win${leftWins !== 1 ? 's' : ''}` + - `` + - `` + - `Summary` + - `` + - `` + - `${rightWins} win${rightWins !== 1 ? 's' : ''}` + - `` + + `` + + `${leftWins} win${leftWins !== 1 ? 's' : ''}` + + `` + + `` + + `Summary` + + `` + + `` + + `${rightWins} win${rightWins !== 1 ? 's' : ''}` + + `` + `` : '' this.innerHTML = `
    ${headerHtml}` + - `
    ` + - `` + - theadHtml + - `${featureRowsHtml}${summaryHtml}` + - `
    ` + - `
    ` + + `
    ` + + `` + + theadHtml + + `${featureRowsHtml}${summaryHtml}` + + `
    ` + + `
    ` + `
    ` } } diff --git a/web-components/src/CompassBar.ts b/web-components/src/CompassBar.ts index 79051e76..36aa012b 100644 --- a/web-components/src/CompassBar.ts +++ b/web-components/src/CompassBar.ts @@ -1,3 +1,4 @@ +import { esc } from './internal/esc' const TAG_NAME = 'tc-compass-bar' export interface CompassMarker { @@ -8,15 +9,6 @@ export interface CompassMarker { icon?: string } -function esc(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - export class CompassBar extends HTMLElement { private _initialised = false private _markers: CompassMarker[] = [] @@ -129,35 +121,39 @@ export class CompassBar extends HTMLElement { { heading: 180, label: 'S' }, { heading: 225, label: 'SW' }, { heading: 270, label: 'W' }, - { heading: 315, label: 'NW' } + { heading: 315, label: 'NW' }, ] const cardinalsMarkup = showCardinals - ? cardinals.map((c) => { - const t = this.offsetWithinFOV(c.heading, heading, fov) - if (t == null) return '' - const pct = (t * 0.5 + 0.5) * 100 - return `` - }).join('') + ? cardinals + .map((c) => { + const t = this.offsetWithinFOV(c.heading, heading, fov) + if (t == null) return '' + const pct = (t * 0.5 + 0.5) * 100 + return `` + }) + .join('') : '' - const markerMarkup = this._markers.map((m) => { - const t = this.offsetWithinFOV(m.heading, heading, fov) - if (t == null) return '' - const pct = (t * 0.5 + 0.5) * 100 - const color = m.color ? esc(m.color) : '' - const styleAttr = `left:${pct.toFixed(2)}%;${color ? `--bs-compass-bar-marker-color:${color};` : ''}` - const hasGlyph = !!m.icon - const glyphMarkup = hasGlyph - ? `${esc(m.icon as string)}` - : '' - const labelMarkup = m.label - ? `${esc(m.label)}` - : '' - const markerClass = hasGlyph - ? 'tc-compass-bar__marker tc-compass-bar__marker--glyph' - : 'tc-compass-bar__marker' - return `${glyphMarkup}${labelMarkup}` - }).join('') + const markerMarkup = this._markers + .map((m) => { + const t = this.offsetWithinFOV(m.heading, heading, fov) + if (t == null) return '' + const pct = (t * 0.5 + 0.5) * 100 + const color = m.color ? esc(m.color) : '' + const styleAttr = `left:${pct.toFixed(2)}%;${color ? `--bs-compass-bar-marker-color:${color};` : ''}` + const hasGlyph = !!m.icon + const glyphMarkup = hasGlyph + ? `${esc(m.icon as string)}` + : '' + const labelMarkup = m.label + ? `${esc(m.label)}` + : '' + const markerClass = hasGlyph + ? 'tc-compass-bar__marker tc-compass-bar__marker--glyph' + : 'tc-compass-bar__marker' + return `${glyphMarkup}${labelMarkup}` + }) + .join('') const normalized = Math.round(this.normalizeDeg(heading)) const headingLabel = normalized.toString().padStart(3, '0') diff --git a/web-components/src/CompassRose.ts b/web-components/src/CompassRose.ts index 9a4c65eb..22d376de 100644 --- a/web-components/src/CompassRose.ts +++ b/web-components/src/CompassRose.ts @@ -8,7 +8,6 @@ const TAG_NAME = 'tc-compass-rose' * ink north needle). Purely presentational: no events, no slots. */ export class CompassRose extends HTMLElement { - private _initialised = false static get observedAttributes(): string[] { diff --git a/web-components/src/CompatibilityMatrix.ts b/web-components/src/CompatibilityMatrix.ts index 9ed43266..f293d14f 100644 --- a/web-components/src/CompatibilityMatrix.ts +++ b/web-components/src/CompatibilityMatrix.ts @@ -1,4 +1,5 @@ -import * as LucideIcons from 'lucide-static' +import { lucideByName } from './internal/lucide' +import { esc } from './internal/esc' import { icon } from './icons' const TAG_NAME = 'tc-compatibility-matrix' @@ -6,24 +7,6 @@ const TAG_NAME = 'tc-compatibility-matrix' export type CompatStatus = 'yes' | 'no' | 'partial' | 'unknown' const STATUSES: CompatStatus[] = ['yes', 'no', 'partial', 'unknown'] -function esc(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') -} - -function lucideByName(name: string): string { - const pascal = name - .split('-') - .map(part => part.charAt(0).toUpperCase() + part.slice(1)) - .join('') - const svgStr = (LucideIcons as Record)[pascal] - if (!svgStr) return '' - return icon(svgStr) -} - // Module-level icon constants — resolved once at load time. const checkIconHtml = lucideByName('check') const xIconHtml = lucideByName('x') @@ -32,27 +15,35 @@ const helpCircleIconHtml = lucideByName('help-circle') function statusIconHtml(status: CompatStatus): string { switch (status) { - case 'yes': return checkIconHtml - case 'no': return xIconHtml - case 'partial': return minusIconHtml - case 'unknown': return helpCircleIconHtml + case 'yes': + return checkIconHtml + case 'no': + return xIconHtml + case 'partial': + return minusIconHtml + case 'unknown': + return helpCircleIconHtml } } function statusText(status: CompatStatus): string { switch (status) { - case 'yes': return 'Supported' - case 'no': return 'Not supported' - case 'partial': return 'Partial support' - case 'unknown': return 'Unknown' + case 'yes': + return 'Supported' + case 'no': + return 'Not supported' + case 'partial': + return 'Partial support' + case 'unknown': + return 'Unknown' } } function renderStatusCell(status: CompatStatus): string { return ( `` + - statusIconHtml(status) + - `${statusText(status)}` + + statusIconHtml(status) + + `${statusText(status)}` + `` ) } @@ -75,7 +66,7 @@ export class CompatibilityMatrix extends HTMLElement { } this.render() const slot = this.querySelector('.tc-compatibility-matrix__title-slot') - if (slot) this._titleSlotNodes.forEach(n => slot.appendChild(n)) + if (slot) this._titleSlotNodes.forEach((n) => slot.appendChild(n)) this._initialised = true } } @@ -113,7 +104,7 @@ export class CompatibilityMatrix extends HTMLElement { return this._support } set support(v: Record>) { - this._support = (v && typeof v === 'object' && !Array.isArray(v)) ? v : {} + this._support = v && typeof v === 'object' && !Array.isArray(v) ? v : {} if (this._initialised) this._rerenderWithSlots() } @@ -124,7 +115,7 @@ export class CompatibilityMatrix extends HTMLElement { } this.render() const newSlot = this.querySelector('.tc-compatibility-matrix__title-slot') - if (newSlot) this._titleSlotNodes.forEach(n => newSlot.appendChild(n)) + if (newSlot) this._titleSlotNodes.forEach((n) => newSlot.appendChild(n)) } private render(): void { @@ -138,43 +129,45 @@ export class CompatibilityMatrix extends HTMLElement { if (titleAttr) { headerHtml = `
    ` + - `

    ${esc(titleAttr)}

    ` + + `

    ${esc(titleAttr)}

    ` + `
    ` } else if (this._titleSlotNodes.length > 0) { headerHtml = `
    ` + - `
    ` + + `
    ` + `
    ` } // Table head: corner + platform columns. const platformHeadCells = platforms - .map(p => - `` + + .map( + (p) => + `` + `${esc(p)}` + - ``, + ``, ) .join('') const theadHtml = `` + - `` + - `` + - platformHeadCells + - `` + + `` + + `` + + platformHeadCells + + `` + `` // Table body: one row per version. const tbodyHtml = versions - .map(v => { + .map((v) => { const statusCells = platforms - .map(p => { + .map((p) => { const raw = support[v]?.[p] - const status: CompatStatus = - STATUSES.includes(raw as CompatStatus) ? (raw as CompatStatus) : 'unknown' + const status: CompatStatus = STATUSES.includes(raw as CompatStatus) + ? (raw as CompatStatus) + : 'unknown' return ( `` + - renderStatusCell(status) + + renderStatusCell(status) + `` ) }) @@ -182,10 +175,10 @@ export class CompatibilityMatrix extends HTMLElement { return ( `` + - `` + - `${esc(v)}` + - `` + - statusCells + + `` + + `${esc(v)}` + + `` + + statusCells + `` ) }) @@ -193,29 +186,28 @@ export class CompatibilityMatrix extends HTMLElement { // Legend — all four statuses. const legendEntriesHtml = (STATUSES as CompatStatus[]) - .map(s => - `` + + .map( + (s) => + `` + statusIconHtml(s) + `${statusText(s)}` + - ``, + ``, ) .join('') const legendHtml = - `
    ` + - legendEntriesHtml + - `
    ` + `
    ` + legendEntriesHtml + `
    ` this.innerHTML = `
    ` + - headerHtml + - `
    ` + - `` + - theadHtml + - `${tbodyHtml}` + - `
    ` + - `
    ` + - legendHtml + + headerHtml + + `
    ` + + `` + + theadHtml + + `${tbodyHtml}` + + `
    ` + + `
    ` + + legendHtml + `
    ` } } diff --git a/web-components/src/ConfigPreview.ts b/web-components/src/ConfigPreview.ts index fb447b74..01407965 100644 --- a/web-components/src/ConfigPreview.ts +++ b/web-components/src/ConfigPreview.ts @@ -1,13 +1,6 @@ +import { esc } from './internal/esc' const TAG_NAME = 'tc-config-preview' -function esc(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') -} - export interface ConfigPreviewEntry { key: string value: string | number | boolean | null @@ -26,21 +19,21 @@ export class ConfigPreview extends HTMLElement { if (!this._initialised) { // Capture named-slot "live-label" nodes (direct children only) const liveLabelNodes = Array.from(this.children).filter( - el => el.getAttribute('slot') === 'live-label' + (el) => el.getAttribute('slot') === 'live-label', ) as Element[] // Capture default nodes (excluding named-slot nodes) const defaultNodes = Array.from(this.childNodes).filter( - n => !(n instanceof Element && n.getAttribute('slot') === 'live-label') + (n) => !(n instanceof Element && n.getAttribute('slot') === 'live-label'), ) this.render() // Re-append live-label slot children when no attribute overrides them if (!this.hasAttribute('live-label')) { const liveLabelEl = this.querySelector('.tc-config-preview-live-label') - if (liveLabelEl) liveLabelNodes.forEach(n => liveLabelEl.appendChild(n)) + if (liveLabelEl) liveLabelNodes.forEach((n) => liveLabelEl.appendChild(n)) } // Re-append default slot children const contentEl = this.querySelector('.tc-config-preview-content') - if (contentEl) defaultNodes.forEach(n => contentEl.appendChild(n)) + if (contentEl) defaultNodes.forEach((n) => contentEl.appendChild(n)) this._initialised = true } } @@ -81,43 +74,45 @@ export class ConfigPreview extends HTMLElement { // Re-distribute live-label slot children (only when no attribute) if (!this.hasAttribute('live-label')) { const newLiveLabelEl = this.querySelector('.tc-config-preview-live-label') - if (newLiveLabelEl) liveLabelNodes.forEach(n => newLiveLabelEl.appendChild(n)) + if (newLiveLabelEl) liveLabelNodes.forEach((n) => newLiveLabelEl.appendChild(n)) } // Re-distribute default slot children const newContentEl = this.querySelector('.tc-config-preview-content') - if (newContentEl) defaultNodes.forEach(n => newContentEl.appendChild(n)) + if (newContentEl) defaultNodes.forEach((n) => newContentEl.appendChild(n)) } private render(): void { const liveLabel = this.getAttribute('live-label') // Build entry lines; no newlines in the HTML to avoid whitespace in
    -        const entriesHtml = this._entries.map(entry => {
    -            const keyHtml = `${esc(entry.key)}`
    -
    -            let valueClass = 'tc-config-preview-value'
    -            let valueText: string
    -            if (typeof entry.value === 'string') {
    -                valueClass += ' tc-config-preview-value--string'
    -                valueText = `"${esc(entry.value)}"`
    -            } else if (typeof entry.value === 'number') {
    -                valueClass += ' tc-config-preview-value--number'
    -                valueText = esc(String(entry.value))
    -            } else if (typeof entry.value === 'boolean') {
    -                valueClass += ' tc-config-preview-value--boolean'
    -                valueText = entry.value ? 'true' : 'false'
    -            } else {
    -                valueClass += ' tc-config-preview-value--null'
    -                valueText = 'null'
    -            }
    -            const valueHtml = `${valueText}`
    -
    -            const commentHtml = entry.comment
    -                ? ` // ${esc(entry.comment)}`
    -                : ''
    -
    -            return `${keyHtml}: ${valueHtml}${commentHtml}`
    -        }).join('')
    +        const entriesHtml = this._entries
    +            .map((entry) => {
    +                const keyHtml = `${esc(entry.key)}`
    +
    +                let valueClass = 'tc-config-preview-value'
    +                let valueText: string
    +                if (typeof entry.value === 'string') {
    +                    valueClass += ' tc-config-preview-value--string'
    +                    valueText = `"${esc(entry.value)}"`
    +                } else if (typeof entry.value === 'number') {
    +                    valueClass += ' tc-config-preview-value--number'
    +                    valueText = esc(String(entry.value))
    +                } else if (typeof entry.value === 'boolean') {
    +                    valueClass += ' tc-config-preview-value--boolean'
    +                    valueText = entry.value ? 'true' : 'false'
    +                } else {
    +                    valueClass += ' tc-config-preview-value--null'
    +                    valueText = 'null'
    +                }
    +                const valueHtml = `${valueText}`
    +
    +                const commentHtml = entry.comment
    +                    ? ` // ${esc(entry.comment)}`
    +                    : ''
    +
    +                return `${keyHtml}: ${valueHtml}${commentHtml}`
    +            })
    +            .join('')
     
             // Live-label text (attribute takes precedence over slot)
             const liveLabelText = liveLabel != null ? esc(liveLabel) : ''
    diff --git a/web-components/src/ConfirmDialog.ts b/web-components/src/ConfirmDialog.ts
    index 5f4aa29e..e392d34b 100644
    --- a/web-components/src/ConfirmDialog.ts
    +++ b/web-components/src/ConfirmDialog.ts
    @@ -13,7 +13,15 @@ export class ConfirmDialog extends DialogBase {
         onCancel: (() => void) | null = null
     
         static get observedAttributes(): string[] {
    -        return ['open', 'dialog-title', 'eyebrow', 'message', 'confirm-label', 'cancel-label', 'danger']
    +        return [
    +            'open',
    +            'dialog-title',
    +            'eyebrow',
    +            'message',
    +            'confirm-label',
    +            'cancel-label',
    +            'danger',
    +        ]
         }
     
         get dialogTitle(): string {
    @@ -94,12 +102,16 @@ export class ConfirmDialog extends DialogBase {
         }
     
         private _emitConfirm(): void {
    -        this.dispatchEvent(new CustomEvent('tc-confirm', { bubbles: true, composed: true, detail: {} }))
    +        this.dispatchEvent(
    +            new CustomEvent('tc-confirm', { bubbles: true, composed: true, detail: {} }),
    +        )
             if (typeof this.onConfirm === 'function') this.onConfirm()
         }
     
         private _emitCancel(): void {
    -        this.dispatchEvent(new CustomEvent('tc-cancel', { bubbles: true, composed: true, detail: {} }))
    +        this.dispatchEvent(
    +            new CustomEvent('tc-cancel', { bubbles: true, composed: true, detail: {} }),
    +        )
             if (typeof this.onCancel === 'function') this.onCancel()
         }
     
    diff --git a/web-components/src/Container.ts b/web-components/src/Container.ts
    index d7ce8366..efdf3c68 100644
    --- a/web-components/src/Container.ts
    +++ b/web-components/src/Container.ts
    @@ -13,7 +13,6 @@ const CONTAINER_CLASSES = [
     ]
     
     export class Container extends HTMLElement {
    -
         static get observedAttributes(): string[] {
             return ['fluid', 'breakpoint']
         }
    diff --git a/web-components/src/ContextMenu.ts b/web-components/src/ContextMenu.ts
    index 2e544e2a..7a42c4d2 100644
    --- a/web-components/src/ContextMenu.ts
    +++ b/web-components/src/ContextMenu.ts
    @@ -1,12 +1,9 @@
    +import { esc } from './internal/esc'
     import * as LucideIcons from 'lucide-static'
     import { icon, chevronRightIcon } from './icons'
     
     const TAG_NAME = 'tc-context-menu'
     
    -function esc(s: string): string {
    -    return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')
    -}
    -
     export interface ContextMenuItem {
         key: string
         label: string
    @@ -41,7 +38,7 @@ export class ContextMenu extends HTMLElement {
                 const slotContent = Array.from(this.childNodes)
                 this.render()
                 const inner = this.querySelector('.tc-context-menu-trigger')
    -            if (inner) slotContent.forEach(n => inner.appendChild(n))
    +            if (inner) slotContent.forEach((n) => inner.appendChild(n))
                 this._initialised = true
             }
         }
    @@ -83,33 +80,37 @@ export class ContextMenu extends HTMLElement {
         }
     
         private _renderItems(items: ContextMenuItem[]): string {
    -        return items.map(item => {
    -            if (item.separator) {
    -                return ``
    -            }
    -            const disabled = item.disabled === true
    -            const danger = item.danger === true
    -            const hasChildren = Array.isArray(item.children) && item.children.length > 0
    -
    -            const disabledAttr = disabled ? ' aria-disabled="true"' : ''
    -            const hasPopupAttr = hasChildren ? ' aria-haspopup="menu" aria-expanded="false"' : ''
    -            const iconHtml = item.icon ? this._resolveIcon(item.icon) : ''
    -            const chevronHtml = hasChildren
    -                ? ``
    -                : ''
    -
    -            let cls = 'tc-context-menu-item'
    -            if (danger) cls += ' tc-context-menu-item--danger'
    -            if (disabled) cls += ' tc-context-menu-item--disabled'
    -            if (hasChildren) cls += ' tc-context-menu-item--has-children'
    -
    -            const submenuHtml = hasChildren
    -                ? ``
    -                : ''
    -
    -            // tabindex="-1": not in the Tab order; focused only programmatically (ARIA menu pattern)
    -            return ``
    -        }).join('')
    +        return items
    +            .map((item) => {
    +                if (item.separator) {
    +                    return ``
    +                }
    +                const disabled = item.disabled === true
    +                const danger = item.danger === true
    +                const hasChildren = Array.isArray(item.children) && item.children.length > 0
    +
    +                const disabledAttr = disabled ? ' aria-disabled="true"' : ''
    +                const hasPopupAttr = hasChildren
    +                    ? ' aria-haspopup="menu" aria-expanded="false"'
    +                    : ''
    +                const iconHtml = item.icon ? this._resolveIcon(item.icon) : ''
    +                const chevronHtml = hasChildren
    +                    ? ``
    +                    : ''
    +
    +                let cls = 'tc-context-menu-item'
    +                if (danger) cls += ' tc-context-menu-item--danger'
    +                if (disabled) cls += ' tc-context-menu-item--disabled'
    +                if (hasChildren) cls += ' tc-context-menu-item--has-children'
    +
    +                const submenuHtml = hasChildren
    +                    ? ``
    +                    : ''
    +
    +                // tabindex="-1": not in the Tab order; focused only programmatically (ARIA menu pattern)
    +                return ``
    +            })
    +            .join('')
         }
     
         private render(): void {
    @@ -196,7 +197,7 @@ export class ContextMenu extends HTMLElement {
     
             // Focus first enabled item
             const firstItem = menu.querySelector(
    -            '.tc-context-menu-item:not(.tc-context-menu-item--disabled)'
    +            '.tc-context-menu-item:not(.tc-context-menu-item--disabled)',
             )
             firstItem?.focus()
     
    @@ -218,7 +219,7 @@ export class ContextMenu extends HTMLElement {
             const menu = this._getMenu()
             if (menu) {
                 // Close all open submenus
    -            menu.querySelectorAll('.tc-context-menu-item--open').forEach(el => {
    +            menu.querySelectorAll('.tc-context-menu-item--open').forEach((el) => {
                     el.classList.remove('tc-context-menu-item--open')
                     el.setAttribute('aria-expanded', 'false')
                 })
    @@ -237,7 +238,7 @@ export class ContextMenu extends HTMLElement {
             if (refocus) {
                 const trigger = this._getTrigger()
                 const focusable = trigger?.querySelector(
    -                'button:not([disabled]), a[href], input:not([disabled]), [tabindex]:not([tabindex="-1"])'
    +                'button:not([disabled]), a[href], input:not([disabled]), [tabindex]:not([tabindex="-1"])',
                 )
                 focusable?.focus()
             }
    @@ -257,7 +258,10 @@ export class ContextMenu extends HTMLElement {
             requestAnimationFrame(() => {
                 const itemRect = item.getBoundingClientRect()
                 const subRect = sub.getBoundingClientRect()
    -            sub.classList.toggle('tc-context-menu-list--flip-x', itemRect.right + subRect.width > window.innerWidth)
    +            sub.classList.toggle(
    +                'tc-context-menu-list--flip-x',
    +                itemRect.right + subRect.width > window.innerWidth,
    +            )
             })
         }
     
    @@ -265,7 +269,7 @@ export class ContextMenu extends HTMLElement {
             const sub = item.querySelector('.tc-context-menu-list--sub')
             if (!sub) return
             // Recursively close nested submenus
    -        sub.querySelectorAll('.tc-context-menu-item--open').forEach(el => {
    +        sub.querySelectorAll('.tc-context-menu-item--open').forEach((el) => {
                 el.classList.remove('tc-context-menu-item--open')
                 el.setAttribute('aria-expanded', 'false')
             })
    @@ -307,10 +311,10 @@ export class ContextMenu extends HTMLElement {
             const parentMenu = target.parentElement
             if (parentMenu) {
                 Array.from(
    -                parentMenu.querySelectorAll(':scope > .tc-context-menu-item--open')
    +                parentMenu.querySelectorAll(':scope > .tc-context-menu-item--open'),
                 )
    -                .filter(s => s !== target)
    -                .forEach(s => this._closeSubmenu(s))
    +                .filter((s) => s !== target)
    +                .forEach((s) => this._closeSubmenu(s))
             }
     
             if (target.classList.contains('tc-context-menu-item--has-children')) {
    @@ -331,7 +335,9 @@ export class ContextMenu extends HTMLElement {
             if (!currentMenu || !this.contains(currentMenu)) return
     
             const enabled = Array.from(
    -            currentMenu.querySelectorAll(':scope > .tc-context-menu-item:not(.tc-context-menu-item--disabled)')
    +            currentMenu.querySelectorAll(
    +                ':scope > .tc-context-menu-item:not(.tc-context-menu-item--disabled)',
    +            ),
             )
             const currentIdx = enabled.indexOf(focused)
     
    @@ -352,7 +358,7 @@ export class ContextMenu extends HTMLElement {
                         this._openSubmenu(focused)
                         const sub = focused.querySelector('.tc-context-menu-list--sub')
                         const firstEnabled = sub?.querySelector(
    -                        '.tc-context-menu-item:not(.tc-context-menu-item--disabled)'
    +                        '.tc-context-menu-item:not(.tc-context-menu-item--disabled)',
                         )
                         firstEnabled?.focus()
                     }
    @@ -375,7 +381,7 @@ export class ContextMenu extends HTMLElement {
                         this._openSubmenu(focused)
                         const sub = focused.querySelector('.tc-context-menu-list--sub')
                         const firstEnabled = sub?.querySelector(
    -                        '.tc-context-menu-item:not(.tc-context-menu-item--disabled)'
    +                        '.tc-context-menu-item:not(.tc-context-menu-item--disabled)',
                         )
                         firstEnabled?.focus()
                     } else {
    @@ -412,7 +418,7 @@ export class ContextMenu extends HTMLElement {
                     bubbles: true,
                     composed: true,
                     detail: { key },
    -            })
    +            }),
             )
             if (typeof this.onSelect === 'function') this.onSelect(key)
             this._closeMenu()
    diff --git a/web-components/src/ContributorWall.ts b/web-components/src/ContributorWall.ts
    index 6aa19075..7948e0e7 100644
    --- a/web-components/src/ContributorWall.ts
    +++ b/web-components/src/ContributorWall.ts
    @@ -1,20 +1,7 @@
    +import { deriveInitials } from './internal/initials'
    +import { esc } from './internal/esc'
     const TAG_NAME = 'tc-contributor-wall'
     
    -function esc(s: string): string {
    -    return s
    -        .replace(/&/g, '&')
    -        .replace(//g, '>')
    -        .replace(/"/g, '"')
    -}
    -
    -function deriveInitials(name: string): string {
    -    const parts = name.trim().split(/\s+/).filter(Boolean)
    -    if (parts.length === 0) return '?'
    -    if (parts.length === 1) return parts[0].charAt(0).toUpperCase()
    -    return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase()
    -}
    -
     export interface Contributor {
         name: string
         avatarUrl?: string
    @@ -33,11 +20,13 @@ export class ContributorWall extends HTMLElement {
         connectedCallback(): void {
             if (!this._initialised) {
                 const hasTitleAttr = this.hasAttribute('title')
    -            const slotNodes = hasTitleAttr ? [] : Array.from(this.querySelectorAll('[slot="title"]'))
    +            const slotNodes = hasTitleAttr
    +                ? []
    +                : Array.from(this.querySelectorAll('[slot="title"]'))
                 this.render()
                 if (!hasTitleAttr) {
                     const slot = this.querySelector('.tc-contributor-wall-title-slot')
    -                if (slot) slotNodes.forEach(n => slot.appendChild(n))
    +                if (slot) slotNodes.forEach((n) => slot.appendChild(n))
                     this._updateHeaderVisibility()
                 }
                 this._initialised = true
    @@ -79,19 +68,20 @@ export class ContributorWall extends HTMLElement {
         private _rerenderWithSlots(): void {
             const hasTitleAttr = this.hasAttribute('title')
             const slot = this.querySelector('.tc-contributor-wall-title-slot')
    -        const slotNodes = (!hasTitleAttr && slot)
    -            ? Array.from(slot.querySelectorAll('[slot="title"]'))
    -            : []
    +        const slotNodes =
    +            !hasTitleAttr && slot ? Array.from(slot.querySelectorAll('[slot="title"]')) : []
             this.render()
             if (!hasTitleAttr) {
                 const newSlot = this.querySelector('.tc-contributor-wall-title-slot')
    -            if (newSlot) slotNodes.forEach(n => newSlot.appendChild(n))
    +            if (newSlot) slotNodes.forEach((n) => newSlot.appendChild(n))
                 this._updateHeaderVisibility()
             }
         }
     
         private _updateHeaderVisibility(): void {
    -        const header = this.querySelector('.tc-contributor-wall__header--slot') as HTMLElement | null
    +        const header = this.querySelector(
    +            '.tc-contributor-wall__header--slot',
    +        ) as HTMLElement | null
             if (!header) return
             const slot = header.querySelector('.tc-contributor-wall-title-slot')
             if (slot && slot.hasChildNodes()) {
    @@ -102,15 +92,16 @@ export class ContributorWall extends HTMLElement {
         }
     
         private _itemHtml(c: Contributor): string {
    -        const accessibleLabel = c.contributions != null
    -            ? `${esc(c.name)} — ${c.contributions} contributions`
    -            : esc(c.name)
    +        const accessibleLabel =
    +            c.contributions != null
    +                ? `${esc(c.name)} — ${c.contributions} contributions`
    +                : esc(c.name)
     
             let inner: string
             if (c.avatarUrl) {
                 inner = ``
             } else {
    -            inner = ``
    +            inner = ``
             }
     
             if (c.profileUrl) {
    @@ -137,11 +128,13 @@ export class ContributorWall extends HTMLElement {
             let headerHtml = ''
             if (hasTitleAttr) {
                 if (titleAttr) {
    -                headerHtml = `
    ` + + headerHtml = + `
    ` + `

    ${esc(titleAttr)}

    ` } } else { - headerHtml = `` - const tagsHtml = recipe.tags && recipe.tags.length > 0 - ? `
    ` + - recipe.tags.map(t => `${esc(t)}`).join('') + - `
    ` - : '' + const tagsHtml = + recipe.tags && recipe.tags.length > 0 + ? `
    ` + + recipe.tags + .map((t) => `${esc(t)}`) + .join('') + + `
    ` + : '' const inner = `
    ` + @@ -155,7 +152,7 @@ export class CookbookGrid extends HTMLElement { `
    ` } - const itemsHtml = this._recipes.map(r => this._recipeHtml(r)).join('') + const itemsHtml = this._recipes.map((r) => this._recipeHtml(r)).join('') this.innerHTML = `${headerHtml}` + diff --git a/web-components/src/CoolButton.ts b/web-components/src/CoolButton.ts index 86bae3fd..944314e1 100644 --- a/web-components/src/CoolButton.ts +++ b/web-components/src/CoolButton.ts @@ -1,19 +1,16 @@ +import { VARIANTS_CORE } from './internal/variants' +import { esc } from './internal/esc' const TAG_NAME = 'tc-cool-button' export type CoolButtonVariant = 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' export type CoolButtonSize = 'small' | 'default' | 'large' export type CoolButtonAddonPosition = 'left' | 'right' -const VARIANTS: CoolButtonVariant[] = ['primary', 'secondary', 'success', 'danger', 'warning', 'info'] +const VARIANTS: CoolButtonVariant[] = [...VARIANTS_CORE] const SIZES: CoolButtonSize[] = ['small', 'default', 'large'] const ADDON_POSITIONS: CoolButtonAddonPosition[] = ['left', 'right'] -function esc(s: string): string { - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') -} - export class CoolButton extends HTMLElement { - private _initialised = false private _mainNodes: Node[] = [] private _addonNodes: Node[] = [] @@ -21,7 +18,16 @@ export class CoolButton extends HTMLElement { onClick: (() => void) | null = null static get observedAttributes(): string[] { - return ['variant', 'size', 'outline', 'loading', 'label', 'addon', 'addon-position', 'disabled'] + return [ + 'variant', + 'size', + 'outline', + 'loading', + 'label', + 'addon', + 'addon-position', + 'disabled', + ] } constructor() { @@ -37,7 +43,8 @@ export class CoolButton extends HTMLElement { : Array.from(this.querySelectorAll('[slot="addon"]')) if (!this.hasAttribute('label')) { this._mainNodes = Array.from(this.childNodes).filter( - n => !(n instanceof Element && (n as Element).getAttribute('slot') === 'addon'), + (n) => + !(n instanceof Element && (n as Element).getAttribute('slot') === 'addon'), ) } this.render() @@ -55,11 +62,15 @@ export class CoolButton extends HTMLElement { // Re-capture slot nodes from their current DOM containers before re-render. if (!this.hasAttribute('label')) { const contentEl = this.querySelector('.tc-cool-button-content') - if (contentEl) this._mainNodes = Array.from(contentEl.childNodes).filter(n => !this._isSpinner(n)) + if (contentEl) + this._mainNodes = Array.from(contentEl.childNodes).filter( + (n) => !this._isSpinner(n), + ) } if (!this.hasAttribute('addon')) { const addonEl = this.querySelector('.tc-cool-button-addon') - if (addonEl) this._addonNodes = Array.from(addonEl.childNodes).filter(n => !this._isSpinner(n)) + if (addonEl) + this._addonNodes = Array.from(addonEl.childNodes).filter((n) => !this._isSpinner(n)) } this.render() this._distributeSlots() @@ -72,22 +83,24 @@ export class CoolButton extends HTMLElement { private _handleClick = () => { const btn = this.querySelector('button') if (btn && btn.disabled) return - this.dispatchEvent(new CustomEvent('tc-click', { - bubbles: true, - composed: true, - detail: {}, - })) + this.dispatchEvent( + new CustomEvent('tc-click', { + bubbles: true, + composed: true, + detail: {}, + }), + ) if (typeof this.onClick === 'function') this.onClick() } private _distributeSlots(): void { if (!this.hasAttribute('label')) { const contentEl = this.querySelector('.tc-cool-button-content') - if (contentEl) this._mainNodes.forEach(n => contentEl.appendChild(n)) + if (contentEl) this._mainNodes.forEach((n) => contentEl.appendChild(n)) } if (!this.hasAttribute('addon')) { const addonEl = this.querySelector('.tc-cool-button-addon') - if (addonEl) this._addonNodes.forEach(n => addonEl.appendChild(n)) + if (addonEl) this._addonNodes.forEach((n) => addonEl.appendChild(n)) } } @@ -95,39 +108,53 @@ export class CoolButton extends HTMLElement { const v = this.getAttribute('variant') as CoolButtonVariant return VARIANTS.includes(v) ? v : 'primary' } - set variant(v: CoolButtonVariant) { this.setAttribute('variant', v) } + set variant(v: CoolButtonVariant) { + this.setAttribute('variant', v) + } get size(): CoolButtonSize { const v = this.getAttribute('size') as CoolButtonSize return SIZES.includes(v) ? v : 'default' } - set size(v: CoolButtonSize) { this.setAttribute('size', v) } + set size(v: CoolButtonSize) { + this.setAttribute('size', v) + } - get outline(): boolean { return this.hasAttribute('outline') } + get outline(): boolean { + return this.hasAttribute('outline') + } set outline(v: boolean) { if (v) this.setAttribute('outline', '') else this.removeAttribute('outline') } - get loading(): boolean { return this.hasAttribute('loading') } + get loading(): boolean { + return this.hasAttribute('loading') + } set loading(v: boolean) { if (v) this.setAttribute('loading', '') else this.removeAttribute('loading') } - get disabled(): boolean { return this.hasAttribute('disabled') } + get disabled(): boolean { + return this.hasAttribute('disabled') + } set disabled(v: boolean) { if (v) this.setAttribute('disabled', '') else this.removeAttribute('disabled') } - get label(): string | null { return this.getAttribute('label') } + get label(): string | null { + return this.getAttribute('label') + } set label(v: string | null) { if (v !== null) this.setAttribute('label', v) else this.removeAttribute('label') } - get addon(): string | null { return this.getAttribute('addon') } + get addon(): string | null { + return this.getAttribute('addon') + } set addon(v: string | null) { if (v !== null) this.setAttribute('addon', v) else this.removeAttribute('addon') @@ -137,7 +164,9 @@ export class CoolButton extends HTMLElement { const v = this.getAttribute('addon-position') as CoolButtonAddonPosition return ADDON_POSITIONS.includes(v) ? v : 'right' } - set addonPosition(v: CoolButtonAddonPosition) { this.setAttribute('addon-position', v) } + set addonPosition(v: CoolButtonAddonPosition) { + this.setAttribute('addon-position', v) + } private render(): void { const variant = this.variant @@ -149,7 +178,11 @@ export class CoolButton extends HTMLElement { const isDisabled = disabled || loading const variantClass = outline ? `btn-outline-${variant}` : `btn-${variant}` - const sizeClassMap: Record = { small: 'btn-sm', default: '', large: 'btn-lg' } + const sizeClassMap: Record = { + small: 'btn-sm', + default: '', + large: 'btn-lg', + } const sizeClass = sizeClassMap[size] ? ` ${sizeClassMap[size]}` : '' const loadingClass = loading ? ' tc-cool-button--loading' : '' @@ -160,7 +193,9 @@ export class CoolButton extends HTMLElement { // hidden in place (kept in flow) so the button keeps its resting width — no // collapse, no jump. The spinner ring may use border-radius (sanctioned circle). const spinnerHtml = `` - const liveRegion = loading ? `Loading…` : '' + const liveRegion = loading + ? `Loading…` + : '' // Label attribute text is written directly into the content span; // when absent the span is empty and slot children are appended after render. diff --git a/web-components/src/CoolNav.ts b/web-components/src/CoolNav.ts index 68bc94c6..45493784 100644 --- a/web-components/src/CoolNav.ts +++ b/web-components/src/CoolNav.ts @@ -1,3 +1,4 @@ +import { esc } from './internal/esc' import { Menu } from 'lucide-static' import { icon } from './icons' @@ -7,14 +8,6 @@ const menuIcon = icon(Menu) let _uid = 0 -function esc(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') -} - const BREAKPOINTS = ['sm', 'md', 'lg', 'xl', 'xxl'] const BP_PX: Record = { sm: 576, md: 768, lg: 992, xl: 1200, xxl: 1400 } @@ -47,7 +40,16 @@ export class CoolNav extends HTMLElement { onLogin: (() => void) | null = null static get observedAttributes(): string[] { - return ['brand', 'login-label', 'login-href', 'login-variant', 'scroll-offset', 'expand-breakpoint', 'theme', 'sticky'] + return [ + 'brand', + 'login-label', + 'login-href', + 'login-variant', + 'scroll-offset', + 'expand-breakpoint', + 'theme', + 'sticky', + ] } constructor() { @@ -160,10 +162,10 @@ export class CoolNav extends HTMLElement { private _distributeSlots(): void { if (!this.hasAttribute('brand')) { const brandEl = this.querySelector('.tc-cool-nav-brand-slot') - if (brandEl) this._brandSlotNodes.forEach(n => brandEl.appendChild(n)) + if (brandEl) this._brandSlotNodes.forEach((n) => brandEl.appendChild(n)) } const rightEl = this.querySelector('.tc-cool-nav-right-slot') - if (rightEl) this._rightSlotNodes.forEach(n => rightEl.appendChild(n)) + if (rightEl) this._rightSlotNodes.forEach((n) => rightEl.appendChild(n)) } private _recaptureSlots(): void { @@ -226,7 +228,9 @@ export class CoolNav extends HTMLElement { } // Login CTA if (target.closest('.tc-cool-nav-login')) { - this.dispatchEvent(new CustomEvent('tc-login', { bubbles: true, composed: true, detail: {} })) + this.dispatchEvent( + new CustomEvent('tc-login', { bubbles: true, composed: true, detail: {} }), + ) if (typeof this.onLogin === 'function') this.onLogin() } } @@ -246,7 +250,13 @@ export class CoolNav extends HTMLElement { toggler.setAttribute('aria-expanded', open ? 'true' : 'false') } if (dispatch) { - this.dispatchEvent(new CustomEvent('tc-nav-toggle', { bubbles: true, composed: true, detail: { open } })) + this.dispatchEvent( + new CustomEvent('tc-nav-toggle', { + bubbles: true, + composed: true, + detail: { open }, + }), + ) if (typeof this.onNavToggle === 'function') this.onNavToggle(open) } } @@ -272,7 +282,7 @@ export class CoolNav extends HTMLElement { this.classList.remove('tc-cool-nav--sticky') } - THEMES.forEach(t => this.classList.remove(`tc-cool-nav--${t}`)) + THEMES.forEach((t) => this.classList.remove(`tc-cool-nav--${t}`)) this.classList.add(`tc-cool-nav--${theme}`) // Brand area @@ -288,15 +298,17 @@ export class CoolNav extends HTMLElement { } // Nav items - const itemsHtml = this._items.map(item => { - const activeAttr = item.active ? ' aria-current="page"' : '' - const activeCls = item.active ? ' tc-cool-nav-link--active' : '' - return ( - `
  • ` + - `${esc(item.label)}` + - `
  • ` - ) - }).join('') + const itemsHtml = this._items + .map((item) => { + const activeAttr = item.active ? ' aria-current="page"' : '' + const activeCls = item.active ? ' tc-cool-nav-link--active' : '' + return ( + `
  • ` + + `${esc(item.label)}` + + `
  • ` + ) + }) + .join('') // Login CTA let loginHtml = '' diff --git a/web-components/src/CooldownBadge.ts b/web-components/src/CooldownBadge.ts index d9a93dae..ee96cbbb 100644 --- a/web-components/src/CooldownBadge.ts +++ b/web-components/src/CooldownBadge.ts @@ -1,13 +1,6 @@ +import { esc } from './internal/esc' const TAG_NAME = 'tc-cooldown-badge' -function esc(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') -} - // Format the remaining cooldown the way the game-components source does: m:ss // above a minute, whole seconds from 10s up, one decimal under 10s. function formatTime(seconds: number): string { @@ -21,7 +14,6 @@ function formatTime(seconds: number): string { } export class CooldownBadge extends HTMLElement { - private _initialised = false static get observedAttributes(): string[] { @@ -112,20 +104,20 @@ export class CooldownBadge extends HTMLElement { const customLabel = this.label const showLabel = this.showLabel || this.hasAttribute('label') - const labelText = customLabel !== '' - ? esc(customLabel) - : remaining > 0 ? formatTime(remaining) : '' - const labelHtml = showLabel && labelText !== '' - ? `` - : '' + const labelText = + customLabel !== '' ? esc(customLabel) : remaining > 0 ? formatTime(remaining) : '' + const labelHtml = + showLabel && labelText !== '' + ? `` + : '' // Accessible name: the badge is a presentational status, so describe its // state on the wrapper and hide the decorative SVG + visual label. const aria = ready - ? (customLabel || 'Ready') + ? customLabel || 'Ready' : customLabel - ? `${customLabel}: ${formatTime(remaining)} remaining` - : `Cooldown ${formatTime(remaining)} remaining` + ? `${customLabel}: ${formatTime(remaining)} remaining` + : `Cooldown ${formatTime(remaining)} remaining` const readyClass = ready ? ' tc-cooldown-badge--ready' : '' diff --git a/web-components/src/CountdownTimer.ts b/web-components/src/CountdownTimer.ts index da411dc7..d87c0964 100644 --- a/web-components/src/CountdownTimer.ts +++ b/web-components/src/CountdownTimer.ts @@ -1,3 +1,4 @@ +import { esc } from './internal/esc' const TAG_NAME = 'tc-countdown-timer' export type CountdownUnit = 'days' | 'hours' | 'minutes' | 'seconds' @@ -19,14 +20,6 @@ const UNIT_SECONDS: Record = { seconds: 1, } -function esc(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') -} - function parseTargetRaw(raw: string): number { const n = Number(raw) if (!isNaN(n) && n > 0) return n @@ -37,8 +30,8 @@ function parseTargetRaw(raw: string): number { function parseUnitsRaw(raw: string): CountdownUnit[] { const parts = raw .split(',') - .map(u => u.trim() as CountdownUnit) - .filter(u => ALL_UNITS.includes(u)) + .map((u) => u.trim() as CountdownUnit) + .filter((u) => ALL_UNITS.includes(u)) return parts.length > 0 ? parts : [...DEFAULT_UNITS] } @@ -106,7 +99,7 @@ export class CountdownTimer extends HTMLElement { return this._units } set units(v: CountdownUnit[]) { - const filtered = Array.isArray(v) ? v.filter(u => ALL_UNITS.includes(u)) : [] + const filtered = Array.isArray(v) ? v.filter((u) => ALL_UNITS.includes(u)) : [] this._units = filtered.length > 0 ? filtered : [...DEFAULT_UNITS] if (this._initialised) this.render() } @@ -201,11 +194,13 @@ export class CountdownTimer extends HTMLElement { if (remaining === 0 && !this._expired) { this._expired = true this._clearInterval() - this.dispatchEvent(new CustomEvent('tc-expire', { - bubbles: true, - composed: true, - detail: {}, - })) + this.dispatchEvent( + new CustomEvent('tc-expire', { + bubbles: true, + composed: true, + detail: {}, + }), + ) if (typeof this.onexpire === 'function') this.onexpire() } } @@ -235,7 +230,7 @@ export class CountdownTimer extends HTMLElement { }) unitsHtml = `
    ${items.join('')}
    ` } else { - const cells = units.map(unit => { + const cells = units.map((unit) => { const num = parts[unit] const val = String(num).padStart(2, '0') return ( diff --git a/web-components/src/CraftingPanel.ts b/web-components/src/CraftingPanel.ts index 7c556d2f..7a7a2f80 100644 --- a/web-components/src/CraftingPanel.ts +++ b/web-components/src/CraftingPanel.ts @@ -1,3 +1,5 @@ +import { isImageSrc } from './internal/image' +import { esc } from './internal/esc' const TAG_NAME = 'tc-crafting-panel' export interface CraftingItem { @@ -17,7 +19,7 @@ export interface CraftingRecipe { name: string icon?: string inputs: CraftingIngredient[] - output: { item: CraftingItem, qty?: number } + output: { item: CraftingItem; qty?: number } } export interface CraftingPanelEventMap { @@ -25,22 +27,10 @@ export interface CraftingPanelEventMap { 'tc-craft': CustomEvent<{ id: string }> } -function esc(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - // A recipe/item icon that looks like an image source is rendered as an ; // otherwise the string is treated as a short glyph/initials label inside the // sharp icon tile (mirrors the tc-character-select portrait pattern — the // design system forbids emoji-as-icon, so free-form data glyphs stay content). -function isImageSrc(value: string): boolean { - return /^(https?:|\/|\.\/|\.\.\/|data:image\/)/.test(value) || /\.(png|jpe?g|gif|svg|webp|avif)$/i.test(value) -} export class CraftingPanel extends HTMLElement { private _initialised = false @@ -58,10 +48,14 @@ export class CraftingPanel extends HTMLElement { if (!this._initialised) { if (!this.hasAttribute('role')) this.setAttribute('role', 'group') this.render() - this.addEventListener('click', this._onClick) - this.addEventListener('keydown', this._onKeydown) this._initialised = true } + // Listeners are (re)attached on every connect — disconnectedCallback removes + // them, and a move/remount (React reconciliation) disconnects then reconnects + // without re-running the one-time init above. Re-adding the same handler + // reference is a no-op, so this is safe to repeat. + this.addEventListener('click', this._onClick) + this.addEventListener('keydown', this._onKeydown) } disconnectedCallback(): void { @@ -103,7 +97,7 @@ export class CraftingPanel extends HTMLElement { } private isAffordable(recipe: CraftingRecipe): boolean { - return recipe.inputs.every(ing => { + return recipe.inputs.every((ing) => { if (typeof ing.available !== 'number') return true return ing.available >= ing.qty }) @@ -167,29 +161,33 @@ export class CraftingPanel extends HTMLElement { const insufficient = have != null && have < ing.qty const cls = `tc-crafting-panel-ingredient${insufficient ? ' is-insufficient' : ''}` const qtyText = have != null ? `${have}/${ing.qty}` : `${ing.qty}` - return `
    ` - + this._iconTile('tc-crafting-panel-ingredient-icon', ing.item.icon, name) - + `${esc(name)}` - + `${esc(qtyText)}` - + `
    ` + return ( + `
    ` + + this._iconTile('tc-crafting-panel-ingredient-icon', ing.item.icon, name) + + `${esc(name)}` + + `${esc(qtyText)}` + + `
    ` + ) } private _detailMarkup(recipe: CraftingRecipe, crafting: boolean): string { const affordable = this.isAffordable(recipe) - const ingredients = recipe.inputs.map(ing => this._ingredientMarkup(ing)).join('') + const ingredients = recipe.inputs.map((ing) => this._ingredientMarkup(ing)).join('') const outputName = recipe.output.item.name ?? recipe.output.item.id const outputQty = recipe.output.qty ?? 1 const disabled = !affordable || crafting const btnLabel = crafting ? 'Crafting…' : 'Craft' - return `Output` - + `
    ` - + this._iconTile('tc-crafting-panel-output-icon', recipe.output.item.icon, outputName) - + `${esc(outputName)}` - + `×${esc(String(outputQty))}` - + `
    ` - + `Inputs` - + `
    ${ingredients}
    ` - + `` + return ( + `Output` + + `
    ` + + this._iconTile('tc-crafting-panel-output-icon', recipe.output.item.icon, outputName) + + `${esc(outputName)}` + + `×${esc(String(outputQty))}` + + `
    ` + + `Inputs` + + `
    ${ingredients}
    ` + + `` + ) } private render(): void { @@ -197,29 +195,38 @@ export class CraftingPanel extends HTMLElement { const selected = this.selectedId const crafting = this.crafting - const active = this._recipes.find(r => r.id === selected) ?? null - - const rowsMarkup = this._recipes.map(r => { - const isSelected = r.id === selected - const affordable = this.isAffordable(r) - const classes = ['tc-crafting-panel-row'] - if (isSelected) classes.push('is-selected') - if (!affordable) classes.push('is-unaffordable') - const name = r.name - return `
    ` - + this._iconTile('tc-crafting-panel-row-icon', r.icon || r.output.item.icon, name) - + `${esc(name)}` - + `
    ` - }).join('') + const active = this._recipes.find((r) => r.id === selected) ?? null + + const rowsMarkup = this._recipes + .map((r) => { + const isSelected = r.id === selected + const affordable = this.isAffordable(r) + const classes = ['tc-crafting-panel-row'] + if (isSelected) classes.push('is-selected') + if (!affordable) classes.push('is-unaffordable') + const name = r.name + return ( + `
    ` + + this._iconTile( + 'tc-crafting-panel-row-icon', + r.icon || r.output.item.icon, + name, + ) + + `${esc(name)}` + + `
    ` + ) + }) + .join('') const detailMarkup = active ? this._detailMarkup(active, crafting) : `

    Select a recipe.

    ` - this.innerHTML = `
    ${rowsMarkup}
    ` - + `
    ${detailMarkup}
    ` + this.innerHTML = + `
    ${rowsMarkup}
    ` + + `
    ${detailMarkup}
    ` } } diff --git a/web-components/src/CreditsScroll.ts b/web-components/src/CreditsScroll.ts index a8cef3f8..243e0334 100644 --- a/web-components/src/CreditsScroll.ts +++ b/web-components/src/CreditsScroll.ts @@ -1,3 +1,4 @@ +import { esc } from './internal/esc' import { Pause } from 'lucide-static' import { icon } from './icons' @@ -12,15 +13,6 @@ export interface CreditsScrollSection { names: string[] } -function esc(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - export class CreditsScroll extends HTMLElement { private _initialised = false private _sections: CreditsScrollSection[] = [] @@ -105,8 +97,10 @@ export class CreditsScroll extends HTMLElement { } private _prefersReducedMotion(): boolean { - return typeof window.matchMedia === 'function' - && window.matchMedia('(prefers-reduced-motion: reduce)').matches + return ( + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) } private togglePlay(): void { @@ -160,7 +154,9 @@ export class CreditsScroll extends HTMLElement { } private emitComplete(): void { - this.dispatchEvent(new CustomEvent('tc-complete', { bubbles: true, composed: true, detail: {} })) + this.dispatchEvent( + new CustomEvent('tc-complete', { bubbles: true, composed: true, detail: {} }), + ) if (typeof this.onComplete === 'function') this.onComplete() } @@ -168,15 +164,17 @@ export class CreditsScroll extends HTMLElement { const titleMarkup = this.scrollTitle ? `
    ${esc(this.scrollTitle)}
    ` : '' - const sectionsMarkup = this._sections.map((s) => { - const names = (s.names || []) - .map((n) => `
    ${esc(n)}
    `) - .join('') - return `
    + const sectionsMarkup = this._sections + .map((s) => { + const names = (s.names || []) + .map((n) => `
    ${esc(n)}
    `) + .join('') + return `
    ${esc(s.role)}
    ${names}
    ` - }).join('') + }) + .join('') this.innerHTML = `
    diff --git a/web-components/src/Crosshair.ts b/web-components/src/Crosshair.ts index de079144..3864c297 100644 --- a/web-components/src/Crosshair.ts +++ b/web-components/src/Crosshair.ts @@ -4,7 +4,6 @@ export type CrosshairVariant = 'cross' | 'dot' | 'circle' | 'tShape' | 'classic' const VARIANTS: CrosshairVariant[] = ['cross', 'dot', 'circle', 'tShape', 'classic', 'rune'] export class Crosshair extends HTMLElement { - private _initialised = false static get observedAttributes(): string[] { diff --git a/web-components/src/CurrencyChip.ts b/web-components/src/CurrencyChip.ts index f25edd6c..d2e4b5de 100644 --- a/web-components/src/CurrencyChip.ts +++ b/web-components/src/CurrencyChip.ts @@ -1,15 +1,11 @@ +import { esc } from './internal/esc' const TAG_NAME = 'tc-currency-chip' -function esc(s: string): string { - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') -} - // Port of game-components `gc-currency-chip`: a compact currency pill pairing a // leading glyph (currency symbol or short icon string) with a formatted amount. // Purely attribute-driven, no slots, no events. The fantasy chrome (gilded frame, // metal fill) is dropped — this renders as a sharp slate chip per the design system. export class CurrencyChip extends HTMLElement { - private _initialised = false static get observedAttributes(): string[] { diff --git a/web-components/src/CurrencyDisplay.ts b/web-components/src/CurrencyDisplay.ts index 9089d15c..de65da01 100644 --- a/web-components/src/CurrencyDisplay.ts +++ b/web-components/src/CurrencyDisplay.ts @@ -1,9 +1,6 @@ +import { esc } from './internal/esc' const TAG_NAME = 'tc-currency-display' -function esc(s: string): string { - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') -} - // Port of game-components `gc-currency-display`: a larger currency readout pairing // an optional label and currency icon with a prominent formatted amount. Purely // attribute-driven, no slots, no events. The fantasy chrome (gilded frame, glow, @@ -15,7 +12,6 @@ function esc(s: string): string { // host; the `font-size` attribute writes --bs-currency-display-amount-font-size (in // px) so the prominent amount can be scaled per-instance. export class CurrencyDisplay extends HTMLElement { - private _initialised = false static get observedAttributes(): string[] { diff --git a/web-components/src/CycleWheel.ts b/web-components/src/CycleWheel.ts index c4e9f98d..17b469e8 100644 --- a/web-components/src/CycleWheel.ts +++ b/web-components/src/CycleWheel.ts @@ -1,13 +1,7 @@ +import { esc } from './internal/esc' const TAG_NAME = 'tc-cycle-wheel' // Minimal HTML-escape for user-supplied strings injected into innerHTML. -function esc(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') -} const DEFAULT_SPIN = 20 @@ -17,12 +11,19 @@ const DEFAULT_SPIN = 20 * Port of `@toolcase/react-components` `CycleWheel`. */ export class CycleWheel extends HTMLElement { - private _initialised = false private _phases: string[] = [] static get observedAttributes(): string[] { - return ['current-index', 'center-label', 'center-value', 'center-pill', 'center-sub', 'spin-seconds', 'paused'] + return [ + 'current-index', + 'center-label', + 'center-value', + 'center-pill', + 'center-sub', + 'spin-seconds', + 'paused', + ] } connectedCallback(): void { @@ -105,7 +106,9 @@ export class CycleWheel extends HTMLElement { const phases = this._phases const count = Math.max(phases.length, 1) const slotAngle = 360 / count - const active = phases.length ? ((this.currentIndex % phases.length) + phases.length) % phases.length : -1 + const active = phases.length + ? ((this.currentIndex % phases.length) + phases.length) % phases.length + : -1 const arrowRot = active >= 0 ? active * slotAngle : 0 const spin = this.spinSeconds @@ -129,23 +132,31 @@ export class CycleWheel extends HTMLElement { // Ring labels — each phase placed at its slot via a per-label rotation. // The whole ring spins; the active label is the only accented element. - const labelsHtml = phases.map((p, i) => { - const rot = (i * slotAngle).toFixed(3) - const cls = i === active - ? 'tc-cycle-wheel__label tc-cycle-wheel__label--active' - : 'tc-cycle-wheel__label' - return `${esc(p.toUpperCase())}` - }).join('') + const labelsHtml = phases + .map((p, i) => { + const rot = (i * slotAngle).toFixed(3) + const cls = + i === active + ? 'tc-cycle-wheel__label tc-cycle-wheel__label--active' + : 'tc-cycle-wheel__label' + return `${esc(p.toUpperCase())}` + }) + .join('') // Static pointer (does not spin): per-slot ticks + an arrow oriented to // the active phase, so the wheel reads as "oriented" even at rest. - const ticksHtml = phases.map((_, i) => - `` - ).join('') + const ticksHtml = phases + .map( + (_, i) => + ``, + ) + .join('') const labelEl = label ? `
    ${esc(label)}
    ` : '' const valueEl = `
    ${esc(value)}
    ` - const pillEl = this.centerPill ? `
    ${esc(this.centerPill)}
    ` : '' + const pillEl = this.centerPill + ? `
    ${esc(this.centerPill)}
    ` + : '' const subEl = sub ? `
    ${esc(sub)}
    ` : '' this.innerHTML = `
    diff --git a/web-components/src/DamageNumber.ts b/web-components/src/DamageNumber.ts index a9a66fcc..0af7fe7f 100644 --- a/web-components/src/DamageNumber.ts +++ b/web-components/src/DamageNumber.ts @@ -1,18 +1,10 @@ +import { esc } from './internal/esc' const TAG_NAME = 'tc-damage-number' export type DamageNumberVariant = 'normal' | 'crit' | 'heal' | 'miss' const DEFAULT_DURATION = 700 -function esc(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - export class DamageNumber extends HTMLElement { private _initialised = false private _timer: ReturnType | null = null @@ -94,11 +86,13 @@ export class DamageNumber extends HTMLElement { this._clearTimer() this._timer = setTimeout(() => { this._timer = null - this.dispatchEvent(new CustomEvent('tc-done', { - bubbles: true, - composed: true, - detail: {}, - })) + this.dispatchEvent( + new CustomEvent('tc-done', { + bubbles: true, + composed: true, + detail: {}, + }), + ) if (typeof this.ondone === 'function') this.ondone() }, this.duration) } @@ -107,10 +101,10 @@ export class DamageNumber extends HTMLElement { const variant: DamageNumberVariant = this.miss ? 'miss' : this.heal - ? 'heal' - : this.crit - ? 'crit' - : 'normal' + ? 'heal' + : this.crit + ? 'crit' + : 'normal' const text = this.miss ? 'MISS' : this.value const prefix = this.heal ? '+' : '' diff --git a/web-components/src/DangerZoneActions.ts b/web-components/src/DangerZoneActions.ts index fbc17ac3..f55db979 100644 --- a/web-components/src/DangerZoneActions.ts +++ b/web-components/src/DangerZoneActions.ts @@ -1,4 +1,5 @@ -import * as LucideIcons from 'lucide-static' +import { lucideByName } from './internal/lucide' +import { esc } from './internal/esc' import { icon } from './icons' const TAG_NAME = 'tc-danger-zone-actions' @@ -12,20 +13,6 @@ export interface DangerZoneAction { disabled?: boolean } -function esc(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') -} - -function lucideByName(name: string): string { - const svgStr = (LucideIcons as Record)[name] - if (!svgStr) return '' - return icon(svgStr, 'tc-danger-zone-icon') -} - export class DangerZoneActions extends HTMLElement { private _initialised = false private _actions: DangerZoneAction[] = [] @@ -60,49 +47,56 @@ export class DangerZoneActions extends HTMLElement { const extraClass = this.getAttribute('class-name') const wrapperClass = `tc-danger-zone${extraClass ? ` ${esc(extraClass)}` : ''}` - const items = this._actions.map((action, idx) => { - const disabled = action.disabled === true - const disabledAttr = disabled ? ' disabled' : '' - const iconHtml = action.icon ? lucideByName(action.icon) : '' - const descHtml = action.description != null - ? `${esc(action.description)}` - : '' - - return ( - `
    ` + + const items = this._actions + .map((action, idx) => { + const disabled = action.disabled === true + const disabledAttr = disabled ? ' disabled' : '' + const iconHtml = action.icon ? lucideByName(action.icon) : '' + const descHtml = + action.description != null + ? `${esc(action.description)}` + : '' + + return ( + `
    ` + `
    ` + - `${esc(action.title)}` + - descHtml + + `${esc(action.title)}` + + descHtml + `
    ` + `` + - iconHtml + - `${esc(action.buttonLabel)}` + + iconHtml + + `${esc(action.buttonLabel)}` + `` + - `
    ` - ) - }).join('') + `
    ` + ) + }) + .join('') this.innerHTML = `
    ${items}
    ` const wrapper = this.querySelector('.tc-danger-zone') if (wrapper) { wrapper.addEventListener('click', (e: Event) => { - const btn = (e.target as HTMLElement).closest('.tc-danger-zone-btn') + const btn = (e.target as HTMLElement).closest( + '.tc-danger-zone-btn', + ) if (!btn || btn.disabled) return const idx = parseInt(btn.dataset.idx ?? '-1', 10) if (idx >= 0 && idx < this._actions.length) { const key = this._actions[idx].key - this.dispatchEvent(new CustomEvent('tc-action-click', { - bubbles: true, - composed: true, - detail: { key }, - })) + this.dispatchEvent( + new CustomEvent('tc-action-click', { + bubbles: true, + composed: true, + detail: { key }, + }), + ) if (typeof this.onactionclick === 'function') this.onactionclick(key) } }) diff --git a/web-components/src/DashboardContent.ts b/web-components/src/DashboardContent.ts index 0c4516da..21a92db8 100644 --- a/web-components/src/DashboardContent.ts +++ b/web-components/src/DashboardContent.ts @@ -12,7 +12,7 @@ export class DashboardContent extends HTMLElement { const slotContent = Array.from(this.childNodes) this.render() const inner = this.querySelector('.tc-dashboard-content-inner') - if (inner) slotContent.forEach(n => inner.appendChild(n)) + if (inner) slotContent.forEach((n) => inner.appendChild(n)) this._initialised = true } } diff --git a/web-components/src/DashboardLayout.ts b/web-components/src/DashboardLayout.ts index c69fd6b0..7e58cc46 100644 --- a/web-components/src/DashboardLayout.ts +++ b/web-components/src/DashboardLayout.ts @@ -7,7 +7,13 @@ const menuIcon = icon(Menu) let _uid = 0 -const NAMED_SLOTS: string[] = ['navbar-left', 'navbar-right', 'brand', 'sidebar-menu', 'sidebar-panel'] +const NAMED_SLOTS: string[] = [ + 'navbar-left', + 'navbar-right', + 'brand', + 'sidebar-menu', + 'sidebar-panel', +] function isNamedSlotNode(n: Node): boolean { return n instanceof Element && NAMED_SLOTS.includes(n.getAttribute('slot') ?? '') @@ -42,13 +48,10 @@ export class DashboardLayout extends HTMLElement { this._brandNodes = Array.from(this.querySelectorAll('[slot="brand"]')) this._sidebarMenuNodes = Array.from(this.querySelectorAll('[slot="sidebar-menu"]')) this._sidebarPanelNodes = Array.from(this.querySelectorAll('[slot="sidebar-panel"]')) - this._contentNodes = Array.from(this.childNodes).filter(n => !isNamedSlotNode(n)) - - // Default to open on first connect if attribute not already set - if (!this.hasAttribute('sidebar-open')) { - this.setAttribute('sidebar-open', '') - } + this._contentNodes = Array.from(this.childNodes).filter((n) => !isNamedSlotNode(n)) + // Closed by default: on mobile this keeps the drawer hidden; on desktop + // (≥992px) CSS pins the sidebar open regardless of this attribute. this.render() this._distributeSlots() this._applyOpenClass() @@ -77,39 +80,45 @@ export class DashboardLayout extends HTMLElement { private _handleToggle(): void { const newOpen = !this.sidebarOpen this.sidebarOpen = newOpen - this.dispatchEvent(new CustomEvent('tc-toggle-sidebar', { - bubbles: true, - composed: true, - detail: { open: newOpen }, - })) + this.dispatchEvent( + new CustomEvent('tc-toggle-sidebar', { + bubbles: true, + composed: true, + detail: { open: newOpen }, + }), + ) if (typeof this.onToggleSidebar === 'function') this.onToggleSidebar(newOpen) } private _applyOpenClass(): void { const open = this.sidebarOpen - const sidebar = this.querySelector('.tc-dashboard-layout__sidebar') + const wrapper = this.querySelector('.tc-dashboard-layout__wrapper') const toggle = this.querySelector('.tc-dashboard-layout__toggle') - if (sidebar) { - sidebar.classList.toggle('tc-dashboard-layout__sidebar--collapsed', !open) + const overlay = this.querySelector('.tc-dashboard-layout__overlay') + if (wrapper) { + wrapper.classList.toggle('tc-dashboard-layout__wrapper--open', open) } if (toggle) { toggle.setAttribute('aria-expanded', open ? 'true' : 'false') } + if (overlay) { + overlay.setAttribute('aria-hidden', open ? 'false' : 'true') + } } private _distributeSlots(): void { const navLeftEl = this.querySelector('.tc-dashboard-layout__navbar-left') - if (navLeftEl) this._navbarLeftNodes.forEach(n => navLeftEl.appendChild(n)) + if (navLeftEl) this._navbarLeftNodes.forEach((n) => navLeftEl.appendChild(n)) const navRightEl = this.querySelector('.tc-dashboard-layout__navbar-right') - if (navRightEl) this._navbarRightNodes.forEach(n => navRightEl.appendChild(n)) + if (navRightEl) this._navbarRightNodes.forEach((n) => navRightEl.appendChild(n)) const brandEl = this.querySelector('.tc-dashboard-layout__brand') - if (brandEl) this._brandNodes.forEach(n => brandEl.appendChild(n)) + if (brandEl) this._brandNodes.forEach((n) => brandEl.appendChild(n)) const sidebarMenuEl = this.querySelector('.tc-dashboard-layout__sidebar-menu') - if (sidebarMenuEl) this._sidebarMenuNodes.forEach(n => sidebarMenuEl.appendChild(n)) + if (sidebarMenuEl) this._sidebarMenuNodes.forEach((n) => sidebarMenuEl.appendChild(n)) const sidebarPanelEl = this.querySelector('.tc-dashboard-layout__sidebar-panel') - if (sidebarPanelEl) this._sidebarPanelNodes.forEach(n => sidebarPanelEl.appendChild(n)) + if (sidebarPanelEl) this._sidebarPanelNodes.forEach((n) => sidebarPanelEl.appendChild(n)) const contentEl = this.querySelector('.tc-dashboard-layout__content') - if (contentEl) this._contentNodes.forEach(n => contentEl.appendChild(n)) + if (contentEl) this._contentNodes.forEach((n) => contentEl.appendChild(n)) } private _attachHandlers(): void { @@ -117,6 +126,8 @@ export class DashboardLayout extends HTMLElement { if ((e.ctrlKey || e.metaKey) && e.key === 'b') { e.preventDefault() this._handleToggle() + } else if (e.key === 'Escape' && this.sidebarOpen) { + this._handleToggle() } } document.addEventListener('keydown', this._keydownHandler) @@ -135,6 +146,11 @@ export class DashboardLayout extends HTMLElement { const target = e.target as Element if (target.closest('.tc-dashboard-layout__toggle')) { this._handleToggle() + return + } + // Tapping the dimmed backdrop closes the mobile drawer. + if (target.closest('.tc-dashboard-layout__overlay') && this.sidebarOpen) { + this._handleToggle() } } @@ -143,14 +159,15 @@ export class DashboardLayout extends HTMLElement { this.innerHTML = `
    ` + + `
    ` + `` + - `
    ` + + `` + `