Skip to content
Merged

Next #313

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/app/components/hex-slider/hex-slider.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@
tabindex="0"
[attr.aria-label]="ariaLabel()"
[attr.aria-valuemin]="effectiveMinValue()"
[attr.aria-valuemax]="maxValue()"
[attr.aria-valuemax]="effectiveMaxValue()"
[attr.aria-valuenow]="clampedValue()"
[attr.aria-valuetext]="valueLabel()"
(keydown)="onKeyDown($event)"
(pointerdown)="startDrag($event)">
<div class="slider-scale" #sliderScale>
<div class="track" aria-hidden="true">
@if (blockedMinPercent() > 0) {
<div class="blocked-track" [style.width.%]="blockedMinPercent()"></div>
<div class="blocked-track blocked-min-track" [style.width.%]="blockedMinPercent()"></div>
}
@if (blockedMaxPercent() > 0) {
<div class="blocked-track blocked-max-track" [style.width.%]="blockedMaxPercent()"></div>
}
</div>
@for (tick of displayTicks(); track tick) {
Expand Down
16 changes: 12 additions & 4 deletions src/app/components/hex-slider/hex-slider.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,23 @@
inset-inline-end: calc(var(--hex-slider-track-overhang) * -1);
block-size: var(--hex-slider-track-height);
transform: translateY(-50%);
background: rgba(70, 70, 70, 0.72);
border: 1px solid rgba(255, 255, 255, 0.06);
background: var(--background-input);
box-sizing: border-box;
overflow: hidden;
}

.blocked-track {
block-size: 100%;
background: #800;
position: absolute;
inset-block: 0;
background: #666;
}

.blocked-min-track {
inset-inline-start: 0;
}

.blocked-max-track {
inset-inline-end: 0;
}

.tick {
Expand Down
17 changes: 17 additions & 0 deletions src/app/components/hex-slider/hex-slider.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,23 @@ describe('HexSliderComponent', () => {
expect(valueCommits).toEqual([1]);
});

it('shows and enforces a blocked upper range', () => {
fixture.componentRef.setInput('blockedMax', 6);
fixture.detectChanges();
const slider = fixture.nativeElement.querySelector('.hex-slider') as HTMLDivElement;

expect(component.effectiveMaxValue()).toBe(6);
expect(component.blockedMaxPercent()).toBe(40);
expect(slider.getAttribute('aria-valuemax')).toBe('6');
expect(fixture.nativeElement.querySelector('.blocked-max-track')).not.toBeNull();

slider.dispatchEvent(pointerEvent('pointerdown', 1, 90));
window.dispatchEvent(pointerEvent('pointerup', 1, 90));

expect(valueChanges).toEqual([6]);
expect(valueCommits).toEqual([6]);
});

it('uses tick label overrides without replacing other generated tick labels', () => {
fixture.componentRef.setInput('tickLabelOverrides', { 8: 'RUN', 10: 'MASC' });
fixture.detectChanges();
Expand Down
17 changes: 15 additions & 2 deletions src/app/components/hex-slider/hex-slider.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export class HexSliderComponent {
readonly min = input<number>(0);
readonly max = input<number>(100);
readonly blockedMin = input<number | null>(null);
readonly blockedMax = input<number | null>(null);
readonly step = input<number>(1);
readonly value = input<number>(0);
readonly ticks = input<readonly number[] | null>(null);
Expand All @@ -57,11 +58,23 @@ export class HexSliderComponent {
if (blockedMin === null) return this.minValue();
return Math.max(this.minValue(), Math.min(this.maxValue(), this.normalizeNumber(blockedMin, this.minValue())));
});
readonly effectiveMaxValue = computed(() => {
const blockedMax = this.blockedMax();
if (blockedMax === null) return this.maxValue();
return Math.max(
this.effectiveMinValue(),
Math.min(this.maxValue(), this.normalizeNumber(blockedMax, this.maxValue())),
);
});
readonly stepValue = computed(() => Math.max(0.000001, Math.abs(this.normalizeNumber(this.step(), 1))));
readonly clampedValue = computed(() => this.alignToStep(this.value()));
readonly valueLabel = computed(() => this.label() ?? `${this.clampedValue()}`);
readonly valuePercent = computed(() => this.percentForValue(this.clampedValue()));
readonly blockedMinPercent = computed(() => this.effectiveMinValue() > this.minValue() ? this.percentForValue(this.effectiveMinValue()) : 0);
readonly blockedMaxPercent = computed(() => this.effectiveMaxValue() < this.maxValue()
? 100 - this.percentForValue(this.effectiveMaxValue())
: 0
);
readonly displayTicks = computed(() => {
const explicitTicks = this.ticks();
if (explicitTicks !== null) {
Expand Down Expand Up @@ -134,7 +147,7 @@ export class HexSliderComponent {
if (event.key === 'PageUp') next = this.clampedValue() + step * 5;
if (event.key === 'PageDown') next = this.clampedValue() - step * 5;
if (event.key === 'Home') next = this.effectiveMinValue();
if (event.key === 'End') next = this.maxValue();
if (event.key === 'End') next = this.effectiveMaxValue();
if (next === null) return;

event.preventDefault();
Expand Down Expand Up @@ -198,7 +211,7 @@ export class HexSliderComponent {

private alignToStep(value: number): number {
const min = this.minValue();
const max = this.maxValue();
const max = this.effectiveMaxValue();
const effectiveMin = this.effectiveMinValue();
const step = this.stepValue();
const stepped = min + Math.round((value - min) / step) * step;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export interface JoinLobbyDialogData {
font-family: monospace;
font-size: 1.4rem;
font-weight: 700;
text-transform: lowercase;
text-transform: uppercase;
}

.hint {
Expand Down Expand Up @@ -163,7 +163,7 @@ export class JoinLobbyDialogComponent {
readonly generatingName = signal(false);
readonly joining = signal(false);
readonly joinError = signal('');
readonly isValid = computed(() => /^[a-z0-9]{4}$/.test(this.code()) && normalizeDisplayName(this.displayName()) !== null);
readonly isValid = computed(() => /^[a-z0-9]{4}$/i.test(this.code()) && normalizeDisplayName(this.displayName()) !== null);

onCodeInput(event: Event): void {
const input = event.target as HTMLInputElement;
Expand Down Expand Up @@ -194,7 +194,7 @@ export class JoinLobbyDialogComponent {
event?.stopPropagation();
if (this.joining()) return;
const displayName = normalizeDisplayName(this.displayName());
if (!displayName || !/^[a-z0-9]{4}$/.test(this.code())) return;
if (!displayName || !/^[a-z0-9]{4}$/i.test(this.code())) return;

this.joining.set(true);
this.joinError.set('');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
font-weight: bold;
line-height: 1;
letter-spacing: 0;
text-transform: lowercase;
text-transform: uppercase;
}

.lobby-code-wrapper {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,45 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Author: Drake

import { signal } from '@angular/core';
import { Injector, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Overlay } from '@angular/cdk/overlay';
import { Subject } from 'rxjs';
import { DiceRollerComponent } from '../../dice-roller/dice-roller.component';
import { OverlayManagerService } from '../../../services/overlay-manager.service';
import type { PSRCheck } from '../../../models/rules/unit-type-rules';
import { PageInteractionOverlayComponent } from './page-interaction-overlay.component';
import { PagePsrWarningPanelComponent, psrRollOutcome } from './page-psr-warning-panel.component';
import { PagePsrWarningPanelComponent, psrRollOutcome, togglePsrWarningOverlay } from './page-psr-warning-panel.component';

describe('togglePsrWarningOverlay', () => {
it('keeps Turn Summary open until PSR Warning closes', () => {
const closed = new Subject<void>();
const overlayManager = {
has: jasmine.createSpy('has').and.returnValue(false),
closeManagedOverlay: jasmine.createSpy('closeManagedOverlay'),
createManagedOverlay: jasmine.createSpy('createManagedOverlay').and.returnValue({ closed }),
blockCloseUntil: jasmine.createSpy('blockCloseUntil'),
unblockClose: jasmine.createSpy('unblockClose'),
} as unknown as OverlayManagerService;
const parent = { unit: signal({ id: 'unit-1' }) } as unknown as PageInteractionOverlayComponent;
const overlay = { scrollStrategies: { block: () => ({}) } } as unknown as Overlay;

togglePsrWarningOverlay(
parent,
overlayManager,
Injector.create({ providers: [] }),
overlay,
);

expect(overlayManager.blockCloseUntil).toHaveBeenCalledOnceWith('turnSummary-unit-1');
expect(overlayManager.closeManagedOverlay).not.toHaveBeenCalledWith('turnSummary-unit-1');

closed.next();

expect(overlayManager.unblockClose).toHaveBeenCalledOnceWith('turnSummary-unit-1');
expect(overlayManager.closeManagedOverlay).not.toHaveBeenCalledWith('turnSummary-unit-1');
});
});

describe('psrRollOutcome', () => {
it('succeeds on or above the target and fails below it', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { PSRCheck } from '../../../models/rules/unit-type-rules';
import { OverlayManagerService } from '../../../services/overlay-manager.service';
import { DiceRollerComponent } from '../../dice-roller/dice-roller.component';
import { PageInteractionOverlayComponent } from './page-interaction-overlay.component';
import { displayPsrModifiers } from './page-turn-summary.util';
import { displayPsrModifiers, openTurnSummaryChildOverlay } from './page-turn-summary.util';
import { getMekLocationLabel } from '../../../models/entity/types';

export function psrRollOutcome(sum: number, target: number): 'success' | 'failed' {
Expand Down Expand Up @@ -40,14 +40,16 @@ export function togglePsrWarningOverlay(
parent: injector
});
const portal = new ComponentPortal(PagePsrWarningPanelComponent, null, customInjector);
overlayManager.createManagedOverlay(overlayKey, null, portal, {
hasBackdrop: true,
backdropClass: 'cdk-overlay-dark-backdrop',
panelClass: 'psr-warning-overlay-panel',
closeOnOutsideClick: true,
scrollStrategy: overlay.scrollStrategies.block(),
positions: []
});
openTurnSummaryChildOverlay(overlayManager, unitId, () =>
overlayManager.createManagedOverlay(overlayKey, null, portal, {
hasBackdrop: true,
backdropClass: 'cdk-overlay-dark-backdrop',
panelClass: 'psr-warning-overlay-panel',
closeOnOutsideClick: true,
scrollStrategy: overlay.scrollStrategies.block(),
positions: []
})
);
}

@Component({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
<div class="panel glass preventZoomReset framed-borders has-shadow" (click)="$event.stopPropagation()">
<div class="header">Standing Up</div>
<div class="psr-target">
<span>Target roll</span>
<strong>{{ targetRoll() }}</strong>
</div>
<div class="header">{{ reviewOnly ? 'Standing Up Review' : 'Standing Up' }}</div>
@if (!canStandWithoutPSR()) {
<div class="psr-target">
<span>Target roll</span>
<strong>{{ targetRoll() }}</strong>
</div>
}
<div class="body">
<div class="attempts">
<span>Attempts</span>
Expand All @@ -15,68 +17,79 @@
aria-label="Increase stand attempts" title="Increase stand attempts" (click)="adjustAttempts(1)">+</button>
</div>
</div>
<div class="psr-list">
<div class="psr-item" [class.resolved]="lastOutcome() === 'success'"
[class.failed]="lastOutcome() === 'failed'">
<div class="psr-check-header">
<div class="psr-item-content">
<div class="psr-reason">Stand up!</div>
<div class="psr-subtitle">Failure: <span class="psr-failure-value">Remain prone</span></div>
</div>
@if (lastOutcome(); as result) {
<div class="psr-result" [class.failed]="result === 'failed'">{{ result }}</div>
}
</div>
@if (lastOutcome() !== 'success') {
<div class="psr-resolution-actions">
<button type="button" class="random-button" [disabled]="diceRoller()?.isRolling()"
aria-label="Roll 2d6 for standing up" title="Roll 2d6 for standing up"
(click)="roll()"></button>
<button class="bt-button success" type="button" (click)="resolve('success')">SUCCESS</button>
<button class="bt-button danger" type="button" (click)="resolve('failed')">FAILURE</button>
@if (!canStandWithoutPSR()) {
<div class="psr-list">
<div class="psr-item" [class.resolved]="lastOutcome() === 'success'"
[class.failed]="lastOutcome() === 'failed'">
<div class="psr-check-header">
<div class="psr-item-content">
<div class="psr-reason">Stand up!
@if (attemptLimit(); as limit) {
<span class="stand-attempt-limit">({{ limit }} attempt per turn)</span>
}</div>
<div class="psr-subtitle">Failure: <span class="psr-failure-value">Remain prone</span></div>
</div>
@if (lastOutcome(); as result) {
<div class="psr-result" [class.failed]="result === 'failed'">{{ result }}</div>
}
</div>
}
</div>
</div>
<label class="careful-stand">
<input type="checkbox" class="bt-checkbox" [checked]="carefulStand()" (change)="setCarefulStand($event)">
<span>Careful stand (optional)</span>
<span class="modifier-badge">-2</span>
</label>
@if (modifiersList().length > 0) {
<div class="roll-details">
<div class="roll-details-label">Modifiers</div>
<div class="modifiers">
@for (modifier of modifiersList(); track $index) {
<div class="modifier-item">
<span class="modifier-location">
@if (modifier.loc) { {{ modifier.loc }} } @else { &mdash; }
</span>
<span class="modifier-reason">{{ modifier.reason }}</span>
<strong class="modifier-value" [class.bonus]="modifier.pilotCheck < 0">
{{ modifier.pilotCheck >= 0 ? '+' : '' }}{{ modifier.pilotCheck }}
</strong>
@if (!reviewOnly && lastOutcome() !== 'success' && canAttemptStand()) {
<div class="psr-resolution-actions">
<button type="button" class="random-button" [disabled]="diceRoller()?.isRolling()"
aria-label="Roll 2d6 for standing up" title="Roll 2d6 for standing up"
(click)="roll()"></button>
<button class="bt-button success" type="button" (click)="resolve('success')">SUCCESS</button>
<button class="bt-button danger" type="button" (click)="resolve('failed')">FAILURE</button>
</div>
}
</div>
</div>
@if (!reviewOnly && supportsCarefulStand()) {
<label class="careful-stand" [class.disabled]="!canCarefulStand()"
[attr.title]="canCarefulStand() ? 'Consumes all remaining movement and counts as walking' : 'Requires at least 3 available Walking MP'">
<input type="checkbox" class="bt-checkbox" [checked]="carefulStand()" [disabled]="!canCarefulStand()"
(change)="setCarefulStand($event)">
<span>Careful stand (optional)</span>
<span class="modifier-badge">-2</span>
</label>
}
@if (modifiersList().length > 0) {
<div class="roll-details">
<div class="roll-details-label">Modifiers</div>
<div class="modifiers">
@for (modifier of modifiersList(); track $index) {
<div class="modifier-item">
<span class="modifier-location">
@if (modifier.loc) { {{ modifier.loc }} } @else { &mdash; }
</span>
<span class="modifier-reason">{{ modifier.reason }}</span>
<strong class="modifier-value" [class.bonus]="modifier.pilotCheck < 0">
{{ modifier.pilotCheck >= 0 ? '+' : '' }}{{ modifier.pilotCheck }}
</strong>
</div>
}
</div>
</div>
}
}
</div>
<div class="actions">
<button class="bt-button" type="button" (click)="close()">DISMISS</button>
</div>
</div>

<dice-roller
#roller
[diceCount]="2"
[diceSides]="6"
[showInline]="false"
[showOverlay]="true"
[reserveOverlayResultSpace]="true"
[compactOverlayResult]="true"
[overlayResult]="rolledResult()"
[overlayResultTone]="rolledResultTone()"
overlayCloseHint="Click to return to standing up"
(finished)="onRollFinished($event)"
/>
@if (!reviewOnly && !canStandWithoutPSR()) {
<dice-roller
#roller
[diceCount]="2"
[diceSides]="6"
[showInline]="false"
[showOverlay]="true"
[reserveOverlayResultSpace]="true"
[compactOverlayResult]="true"
[overlayResult]="rolledResult()"
[overlayResultTone]="rolledResultTone()"
overlayCloseHint="Click to return to standing up"
(finished)="onRollFinished($event)"
/>
}
Loading