Skip to content
Draft
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
3 changes: 3 additions & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ jsconfig.json
# Playwright
/e2e/test-results/
/e2e/playwright-report/

# Claude Code harness state
/.claude/
54 changes: 54 additions & 0 deletions frontend/app/components/exercise-playback-help/index.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import { t } from 'ember-intl';
import UiHelp from 'brn/components/ui/help';
import UiInstructionsDialog from 'brn/components/ui/instructions-dialog';

interface ExercisePlaybackHelpSignature {
Args: {
triggerClass?: string;
};
Element: HTMLButtonElement;
}

export default class ExercisePlaybackHelpComponent extends Component<ExercisePlaybackHelpSignature> {
@tracked isOpen = false;
triggerElement: HTMLButtonElement | null = null;

@action
open(event: Event) {
this.triggerElement = event.currentTarget as HTMLButtonElement;
this.isOpen = true;
}

@action
close() {
this.isOpen = false;
this.triggerElement?.focus();
}

<template>
<button
data-test-playback-help-trigger
type="button"
aria-label={{t "instructions.trigger_aria"}}
aria-haspopup="dialog"
aria-expanded={{if this.isOpen "true" "false"}}
aria-controls="instructions-dialog-title"
class="btn-press inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-full bg-white hover:bg-gray-100 shadow {{@triggerClass}}"
{{on "click" this.open}}
...attributes
>
<UiHelp class="w-6 h-6" />
</button>
{{#if this.isOpen}}
<UiInstructionsDialog @titleKey="instructions.playback_title" @onClose={{this.close}}>
<p data-test-playback-help-body>
{{t "instructions.playback_body" htmlSafe=true}}
</p>
</UiInstructionsDialog>
{{/if}}
</template>
}
2 changes: 2 additions & 0 deletions frontend/app/components/header/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import GlobalTimer from 'brn/components/global-timer';
import LoadingSpinner from 'brn/components/loading-spinner';
import XpBadge from 'brn/components/xp-badge';
import StreakCounter from 'brn/components/streak-counter';
import InstructionsModal from 'brn/components/instructions-modal';
import GamificationService from 'brn/services/gamification';

const ExternalLinkIcon = <template>
Expand Down Expand Up @@ -175,6 +176,7 @@ export default class HeaderComponent extends Component {
</div>
</div>
<div class="sm:ml-4 flex items-center shrink-0 ml-1 gap-2 sm:gap-3">
<InstructionsModal />
<LinkTo @route="profile.statistics" class="shrink-0">
<GlobalTimer />
</LinkTo>
Expand Down
83 changes: 83 additions & 0 deletions frontend/app/components/instructions-modal/index.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import { t } from 'ember-intl';
import UiHelp from 'brn/components/ui/help';
import UiInstructionsDialog from 'brn/components/ui/instructions-dialog';

interface InstructionsModalSignature {
Args: {
triggerClass?: string;
};
Element: HTMLButtonElement;
}

export default class InstructionsModalComponent extends Component<InstructionsModalSignature> {
@tracked isOpen = false;
triggerElement: HTMLButtonElement | null = null;

@action
open(event: Event) {
this.triggerElement = event.currentTarget as HTMLButtonElement;
this.isOpen = true;
}

@action
close() {
this.isOpen = false;
// Restore focus to the trigger so keyboard users return to where they
// were before the dialog opened.
this.triggerElement?.focus();
}

<template>
<button
data-test-instructions-trigger
type="button"
aria-label={{t "instructions.trigger_aria"}}
aria-haspopup="dialog"
aria-expanded={{if this.isOpen "true" "false"}}
aria-controls="instructions-dialog-title"
class="btn-press inline-flex items-center gap-1.5 min-h-[44px] px-3 py-2 text-white bg-white/10 hover:bg-white/20 rounded-full text-xs sm:text-sm font-semibold tracking-wider uppercase {{@triggerClass}}"
{{on "click" this.open}}
...attributes
>
<UiHelp class="w-5 h-5" />
<span>{{t "instructions.trigger_label"}}</span>
</button>
{{#if this.isOpen}}
<UiInstructionsDialog @titleKey="instructions.title" @onClose={{this.close}}>
<p data-test-instructions-intro class="mb-4">
{{t "instructions.intro"}}
</p>
<ol class="space-y-5">
<li>
<h3 class="font-semibold text-gray-800 mb-1">
{{t "instructions.step_1.heading"}}
</h3>
<p>{{t "instructions.step_1.body" htmlSafe=true}}</p>
</li>
<li>
<h3 class="font-semibold text-gray-800 mb-1">
{{t "instructions.step_2.heading"}}
</h3>
<p>{{t "instructions.step_2.body" htmlSafe=true}}</p>
</li>
<li>
<h3 class="font-semibold text-gray-800 mb-1">
{{t "instructions.step_3.heading"}}
</h3>
<p>{{t "instructions.step_3.body" htmlSafe=true}}</p>
</li>
<li>
<h3 class="font-semibold text-gray-800 mb-1">
{{t "instructions.step_4.heading"}}
</h3>
<p>{{t "instructions.step_4.body" htmlSafe=true}}</p>
</li>
</ol>
</UiInstructionsDialog>
{{/if}}
</template>
}
9 changes: 7 additions & 2 deletions frontend/app/components/ui/exercise-button/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import Component from '@glimmer/component';
import type { Exercise } from 'brn/schemas/exercise';
import { LinkTo } from '@ember/routing';

import { concat } from '@ember/helper';
import { t } from 'ember-intl';
import UiIconCheck from 'brn/components/ui/icon/check';

Expand Down Expand Up @@ -56,6 +55,12 @@ export default class UiExerciseButtonComponent extends Component<UiExerciseButto
return !this.args.isAvailable;
}

get tooltipKey(): string {
return this.args.isAvailable
? 'exercise_button.tooltip_available'
: 'exercise_button.tooltip_locked';
}

get titleClasses() {
if (this.mode === 'locked') {
return 'c-exercise-button__title c-exercise-button__title-locked';
Expand All @@ -73,7 +78,7 @@ export default class UiExerciseButtonComponent extends Component<UiExerciseButto
aria-disabled={{unless @isAvailable "true"}}
@route="group.series.subgroup.exercise"
@model={{@exercise.id}}
title={{concat (t "task_link.exercise") " " @exercise.level}}
title={{t this.tooltipKey}}
...attributes
>
<div class={{this.titleClasses}}>
Expand Down
91 changes: 91 additions & 0 deletions frontend/app/components/ui/instructions-dialog/index.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import didInsert from '@ember/render-modifiers/modifiers/did-insert';
import { t } from 'ember-intl';

interface UiInstructionsDialogSignature {
Args: {
titleKey: string;
onClose: () => void;
};
Blocks: {
default: [];
};
Element: HTMLDialogElement;
}

export default class UiInstructionsDialogComponent extends Component<UiInstructionsDialogSignature> {
dialogElement: HTMLDialogElement | null = null;

@action
onInsert(element: HTMLDialogElement) {
this.dialogElement = element;
element.showModal();
element.addEventListener('close', this.onDialogClose);
}

@action
onDialogClose() {
this.args.onClose();
}

@action
close() {
// Remove listener first to prevent async double-call from the native
// 'close' event, then close and notify synchronously.
this.dialogElement?.removeEventListener('close', this.onDialogClose);
this.dialogElement?.close();
this.args.onClose();
}

willDestroy(): void {
super.willDestroy();
this.dialogElement?.removeEventListener('close', this.onDialogClose);
}

<template>
<dialog
data-test-instructions-dialog
aria-modal="true"
aria-labelledby="instructions-dialog-title"
class="p-0 rounded-lg shadow-xl backdrop:bg-black/50 w-full max-w-[calc(100vw-1rem)] sm:max-w-2xl"
...attributes
{{didInsert this.onInsert}}
>
<div class="flex flex-col max-h-[85vh]">
<header class="flex items-start justify-between px-6 pt-6 pb-4 border-b border-gray-100">
<h2
id="instructions-dialog-title"
data-test-instructions-dialog-title
class="text-xl font-semibold text-gray-800"
>
{{t @titleKey}}
</h2>
<button
data-test-instructions-dialog-close-x
type="button"
aria-label={{t "instructions.close"}}
class="btn-press shrink-0 ml-4 min-h-[44px] min-w-[44px] text-gray-500 hover:text-gray-800 text-2xl leading-none"
{{on "click" this.close}}
>
×
</button>
</header>
<div class="px-6 py-5 overflow-y-auto font-openSans text-base leading-relaxed text-gray-700">
{{yield}}
</div>
<footer class="flex justify-end px-6 py-4 border-t border-gray-100">
<button
data-test-instructions-dialog-close
type="button"
class="btn-press min-h-[44px] px-6 py-2 text-sm font-semibold text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
{{on "click" this.close}}
>
{{t "instructions.close"}}
</button>
</footer>
</div>
</dialog>
</template>
}
3 changes: 3 additions & 0 deletions frontend/app/templates/group/series/subgroup/exercise.gts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { LinkTo } from '@ember/routing';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import ModalDialog from 'ember-modal-dialog/components/modal-dialog';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import ExercisePlaybackHelp from 'brn/components/exercise-playback-help';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import AnswerCorrectnessWidget from 'brn/components/answer-correctness-widget';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import ExerciseStats from 'brn/components/exercise-stats';
Expand All @@ -40,6 +42,7 @@ const tpl: TOC<Signature> = <template>
{{didInsert @controller.startStatsTracking @model}}
{{willDestroy @controller.stopStatsTracking @model}}
>
<ExercisePlaybackHelp @triggerClass="fixed top-2 right-2 z-10" />
<div class="fixed" id="modal-close-button">
<LinkTo @route="group.series.subgroup" title={{t "navigation.come_back"}}>
<svg
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { module, test } from 'qunit';
import { setupIntl } from 'ember-intl/test-support';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import ExercisePlaybackHelp from 'brn/components/exercise-playback-help';

module('Integration | Component | exercise-playback-help', function (hooks) {
setupRenderingTest(hooks);
setupIntl(hooks, 'en-us');

test('the icon trigger is rendered; dialog is closed by default', async function (assert) {
await render(<template><ExercisePlaybackHelp /></template>);

assert.dom('[data-test-playback-help-trigger]').exists();
assert.dom('[data-test-instructions-dialog]').doesNotExist();
});

test('clicking the trigger opens the playback help dialog', async function (assert) {
await render(<template><ExercisePlaybackHelp /></template>);

await click('[data-test-playback-help-trigger]');

assert.dom('[data-test-instructions-dialog]').exists();
assert
.dom('[data-test-instructions-dialog-title]')
.hasText('t:instructions.playback_title');
assert.dom('[data-test-playback-help-body]').exists();
});

test('the close button dismisses the dialog', async function (assert) {
await render(<template><ExercisePlaybackHelp /></template>);

await click('[data-test-playback-help-trigger]');
await click('[data-test-instructions-dialog-close]');

assert.dom('[data-test-instructions-dialog]').doesNotExist();
});
});
Loading
Loading