mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
feat(ui): one dialog frame, a shared dialog shell and three width presets (#1942)
* feat(ui): one dialog frame, a shared dialog shell and three width presets
Every dialog gets the kit frame from m3-theme.scss: a 16px panel on the
widget surface with a hairline border and the popover shadow, over a tinted
backdrop blurred at 6px. The command palette and the programme dialog keep
their own surface through their panel classes.
`app-dialog-shell` (@iptvnator/ui/components) draws the header (optional
icon tile, the heading as mat-dialog-title, subtitle, a header-end slot and
a close button), the body and a footer that renders only when an element
marked appDialogFooter wraps the actions. The close button is last in the
DOM, so first-tabbable autofocus lands in the body. Footer buttons take the
kit look: ghost, accent (new accent-button mixin) or secondary; destructive
buttons keep their tint and get the kit's 10px radius.
`dialogSize('sm' | 'md' | 'lg')` owns the width and viewport limits with a
type-only MatDialogConfig import; MAT_DIALOG_DEFAULT_OPTIONS is deliberately
not provided at the root, which would put Material's dialog back on the
startup path.
Migrated: the confirm dialog (sm), the add-playlist dialog (lg) and the
keyboard shortcuts dialog (lg, close button only). Slot wrappers render as
display: contents, because a control-flow block at the top level of the
content is projected into the body, not into a named slot.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(ui): name the dialog shell's close button "Close dialog"
A confirm dialog opened with a "Close" dismiss (the download recovery
prompt, the VOD download prompt) had two buttons named "Close" once the
shell added its header ×, and the Electron download-recovery test failed
on a strict-mode violation for `getByRole('button', { name: 'Close' })`.
The × is now named by the new `CLOSE_DIALOG` key ("Close dialog") in every
locale, so a footer "Close" stays the only button of that name; the same
holds for the dialogs PR 3 migrates, whose E2E flows select "Close".
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(ui): let short windows scroll the whole dialog shell
With the shell capped at the viewport, a stacked footer (the add-playlist
Xtream mode has five rows) left the body a 40px sliver at 360×480. Under
600px of viewport height the cap comes off and Material's surface scrolls
the whole dialog as the single scroll owner, which is how the dialog
behaved before the shell.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
9b5586d328
commit
2139ddd5c0
47 files changed
+904
-451
No files matched your search
+18
-38
@@ -1,14 +1,8 @@
|
||||
<div class="apd">
|
||||
<div mat-dialog-title class="apd-head">
|
||||
<h2 class="apd-title">
|
||||
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
|
||||
</h2>
|
||||
<p class="apd-sub">
|
||||
{{ 'HOME.ADD_PLAYLIST.PICK_METHOD' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content>
|
||||
<app-dialog-shell
|
||||
[heading]="'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate"
|
||||
[subtitle]="'HOME.ADD_PLAYLIST.PICK_METHOD' | translate"
|
||||
>
|
||||
<div
|
||||
class="apd-method-grid"
|
||||
role="radiogroup"
|
||||
@@ -68,18 +62,15 @@
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions class="apd-actions">
|
||||
@if (playlistType() === 'xtream') {
|
||||
<p class="w-full" id="xtream-http-test-notice">
|
||||
{{
|
||||
'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.HTTP_PERMISSION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
<div class="apd-actions__reset">
|
||||
<div appDialogFooterStart>
|
||||
@if (playlistType() === 'xtream') {
|
||||
<p class="w-full" id="xtream-http-test-notice">
|
||||
{{
|
||||
'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.HTTP_PERMISSION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
@@ -89,17 +80,14 @@
|
||||
{{ 'CLEAR' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="apd-actions__decision">
|
||||
<div appDialogFooter>
|
||||
<button mat-button type="button" (click)="closeDialog()">
|
||||
{{ 'CANCEL' | translate }}
|
||||
</button>
|
||||
|
||||
@switch (playlistType()) {
|
||||
@case ('url') {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="!urlUpload()?.form?.valid"
|
||||
(click)="submitUrlPlaylist()"
|
||||
@@ -110,7 +98,6 @@
|
||||
@case ('file') {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="!fileUpload()?.selectedFile()"
|
||||
(click)="fileUpload()?.confirm()"
|
||||
@@ -121,7 +108,6 @@
|
||||
@case ('text') {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="!textImport()?.textForm?.valid"
|
||||
(click)="
|
||||
@@ -135,7 +121,8 @@
|
||||
}
|
||||
@case ('xtream') {
|
||||
<button
|
||||
mat-stroked-button
|
||||
mat-flat-button
|
||||
class="app-dialog__secondary"
|
||||
type="button"
|
||||
[disabled]="
|
||||
!xtreamImport()?.form?.valid ||
|
||||
@@ -153,7 +140,6 @@
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="
|
||||
!xtreamImport()?.form?.valid ||
|
||||
@@ -166,18 +152,12 @@
|
||||
}
|
||||
@case ('stalker') {
|
||||
@if (stalkerImport()?.isLoading()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
disabled
|
||||
>
|
||||
<button mat-flat-button type="button" disabled>
|
||||
{{ 'HOME.STALKER_PORTAL.VALIDATING' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="!stalkerImport()?.form?.valid"
|
||||
(click)="stalkerImport()?.addPlaylist()"
|
||||
@@ -188,5 +168,5 @@
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</mat-dialog-actions>
|
||||
</app-dialog-shell>
|
||||
</div>
|
||||
+3
-79
@@ -25,33 +25,6 @@
|
||||
// 6px/9px asymmetric padding. Both fight our custom two-line head — kill the
|
||||
// ghost and apply explicit padding so the title shares the same 24px
|
||||
// horizontal inset as `mat-dialog-content` below.
|
||||
.apd-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 20px 24px 4px;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.apd-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--apd-heading);
|
||||
}
|
||||
|
||||
.apd-sub {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--apd-body);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
// ─── Method grid (5 flat cards instead of 2-level segmented control) ────────
|
||||
// One card per source method. Replaces the prior 3×3 type×subtype matrix
|
||||
// which had 9 cells of which only 5 were real (Xtream-via-file, etc. didn't
|
||||
@@ -95,11 +68,7 @@
|
||||
border-color: color-mix(in srgb, var(--apd-accent) 32%, transparent);
|
||||
|
||||
.apd-method__icon {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--apd-accent) 14%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(in srgb, var(--apd-accent) 14%, transparent);
|
||||
color: var(--apd-accent);
|
||||
}
|
||||
}
|
||||
@@ -111,11 +80,7 @@
|
||||
color-mix(in srgb, var(--apd-accent) 32%, transparent);
|
||||
|
||||
.apd-method__icon {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--apd-accent) 22%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(in srgb, var(--apd-accent) 22%, transparent);
|
||||
color: var(--apd-accent);
|
||||
}
|
||||
|
||||
@@ -135,11 +100,7 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--apd-surface-raised) 75%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(in srgb, var(--apd-surface-raised) 75%, transparent);
|
||||
color: var(--apd-muted);
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
@@ -226,43 +187,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.apd-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 8px 12px;
|
||||
|
||||
&__reset,
|
||||
&__decision {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__decision {
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.apd-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
|
||||
&__reset,
|
||||
&__decision {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__decision {
|
||||
justify-content: flex-end;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Light-theme segmented control tweak ────────────────────────────────────
|
||||
// `.dark-theme` is on <body>, so target light mode via `body:not(...)`.
|
||||
body:not(.dark-theme) .apd {
|
||||
|
||||
+1
-1
@@ -447,7 +447,7 @@ describe('AddPlaylistDialogComponent actions', () => {
|
||||
|
||||
function primaryLabel(root: HTMLElement): string | undefined {
|
||||
const buttons = root.querySelectorAll<HTMLButtonElement>(
|
||||
'.apd-actions__decision button'
|
||||
'[appDialogFooter] button'
|
||||
);
|
||||
return buttons[buttons.length - 1]?.textContent?.trim();
|
||||
}
|
||||
|
||||
+8
@@ -18,6 +18,11 @@ import { MatIcon } from '@angular/material/icon';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
DialogFooterDirective,
|
||||
DialogFooterStartDirective,
|
||||
DialogShellComponent,
|
||||
} from '@iptvnator/ui/components';
|
||||
import { PlaylistType } from '@iptvnator/playlist/shared/ui';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
@@ -59,6 +64,9 @@ const METHOD_BY_CANDIDATE_KIND: Record<
|
||||
@Component({
|
||||
imports: [
|
||||
AutoImportComponent,
|
||||
DialogFooterDirective,
|
||||
DialogFooterStartDirective,
|
||||
DialogShellComponent,
|
||||
FileUploadComponent,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
|
||||
@@ -56,3 +56,6 @@ export * from './lib/detail-ui/vod-more-menu.component';
|
||||
export * from './lib/detail-ui/vod-more-menu.model';
|
||||
export * from './lib/detail-ui/series-hero.state';
|
||||
export * from './lib/detail-ui/hero-trailer-backdrop.component';
|
||||
export * from './lib/dialog-shell/dialog-shell.component';
|
||||
export * from './lib/dialog-shell/dialog-shell.directives';
|
||||
export * from './lib/dialog-shell/dialog-size';
|
||||
@@ -2,6 +2,8 @@ import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { DialogShellComponent } from '../dialog-shell/dialog-shell.component';
|
||||
import { DialogFooterDirective } from '../dialog-shell/dialog-shell.directives';
|
||||
|
||||
/**
|
||||
* `destructive` renders the confirm action with the app-wide error styling
|
||||
@@ -28,39 +30,45 @@ export interface ConfirmDialogData {
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [MatButtonModule, MatDialogModule, TranslateModule],
|
||||
imports: [
|
||||
DialogFooterDirective,
|
||||
DialogShellComponent,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
TranslateModule,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<h2 mat-dialog-title>
|
||||
{{ dialogData.title }}
|
||||
</h2>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
<app-dialog-shell
|
||||
[heading]="dialogData.title"
|
||||
[iconTone]="isDestructive ? 'warn' : 'default'"
|
||||
>
|
||||
{{ dialogData.message }}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close cdkFocusInitial>
|
||||
{{ dialogData.cancelLabel || ('CANCEL' | translate) }}
|
||||
</button>
|
||||
@if (dialogData.keepOpenOnConfirm) {
|
||||
<button
|
||||
mat-flat-button
|
||||
data-test-id="confirm-dialog-confirm"
|
||||
[class.app-destructive-button]="isDestructive"
|
||||
(click)="dialogData.onConfirm()"
|
||||
>
|
||||
{{ dialogData.confirmLabel }}
|
||||
<div appDialogFooter>
|
||||
<button mat-button mat-dialog-close cdkFocusInitial>
|
||||
{{ dialogData.cancelLabel || ('CANCEL' | translate) }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-flat-button
|
||||
data-test-id="confirm-dialog-confirm"
|
||||
[class.app-destructive-button]="isDestructive"
|
||||
[mat-dialog-close]="true"
|
||||
>
|
||||
{{ dialogData.confirmLabel }}
|
||||
</button>
|
||||
}
|
||||
</mat-dialog-actions>
|
||||
@if (dialogData.keepOpenOnConfirm) {
|
||||
<button
|
||||
mat-flat-button
|
||||
data-test-id="confirm-dialog-confirm"
|
||||
[class.app-destructive-button]="isDestructive"
|
||||
(click)="dialogData.onConfirm()"
|
||||
>
|
||||
{{ dialogData.confirmLabel }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-flat-button
|
||||
data-test-id="confirm-dialog-confirm"
|
||||
[class.app-destructive-button]="isDestructive"
|
||||
[mat-dialog-close]="true"
|
||||
>
|
||||
{{ dialogData.confirmLabel }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</app-dialog-shell>
|
||||
`,
|
||||
})
|
||||
export class ConfirmDialogComponent {
|
||||
|
||||
@@ -45,8 +45,9 @@ describe('DialogService', () => {
|
||||
title: 'Remove',
|
||||
message: 'Confirm removal?',
|
||||
}),
|
||||
width: '420px',
|
||||
width: '440px',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: 'calc(100vh - 32px)',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { dialogSize } from '../dialog-shell/dialog-size';
|
||||
import { LazyMatDialog } from '../lazy-dialog/lazy-mat-dialog';
|
||||
import type {
|
||||
ConfirmDialogComponent,
|
||||
@@ -41,12 +42,10 @@ export class DialogService {
|
||||
ConfirmDialogComponent,
|
||||
ConfirmDialogData
|
||||
>(dialogModule.ConfirmDialogComponent, {
|
||||
...dialogSize('sm'),
|
||||
data,
|
||||
// Wide enough for two side-by-side action buttons with
|
||||
// translated labels; 300px forced them to wrap into a
|
||||
// vertical stack.
|
||||
width: data.width ?? '420px',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
// A caller may still ask for a wider dialog (a long message).
|
||||
...(data.width ? { width: data.width } : {}),
|
||||
});
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<header class="app-dialog__header">
|
||||
@if (icon(); as icon) {
|
||||
<span
|
||||
class="app-dialog__icon"
|
||||
[class.app-dialog__icon--warn]="iconTone() === 'warn'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<mat-icon>{{ icon }}</mat-icon>
|
||||
</span>
|
||||
}
|
||||
<div class="app-dialog__heading">
|
||||
<h2 mat-dialog-title class="app-dialog__title">{{ heading() }}</h2>
|
||||
@if (subtitle(); as subtitle) {
|
||||
<p class="app-dialog__subtitle">{{ subtitle }}</p>
|
||||
}
|
||||
</div>
|
||||
<div class="app-dialog__header-end">
|
||||
<ng-content select="[appDialogHeaderEnd]" />
|
||||
</div>
|
||||
</header>
|
||||
<mat-dialog-content class="app-dialog__body">
|
||||
<ng-content />
|
||||
</mat-dialog-content>
|
||||
@if (hasFooter()) {
|
||||
<mat-dialog-actions align="end" class="app-dialog__footer">
|
||||
<ng-content select="[appDialogFooterStart]" />
|
||||
<span class="app-dialog__footer-spacer"></span>
|
||||
<ng-content select="[appDialogFooter]" />
|
||||
</mat-dialog-actions>
|
||||
}
|
||||
@if (showClose()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
mat-dialog-close
|
||||
class="app-dialog__close"
|
||||
[aria-label]="'CLOSE_DIALOG' | translate"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
@use '@angular/material' as mat;
|
||||
@use '../../../../styles/app-controls' as controls;
|
||||
|
||||
// Unencapsulated (see the component): every selector carries the
|
||||
// `app-dialog` prefix.
|
||||
app-dialog-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
// The pane's height limit does not reach the shell through the dialog
|
||||
// component and its wrappers (an inherited `max-height` is `none`
|
||||
// there), so the shell caps itself at the viewport minus the preset's
|
||||
// margins and the surface borders; only the body scrolls.
|
||||
max-height: calc(100vh - 34px);
|
||||
|
||||
// The shell draws its own section paddings and headline. Material's
|
||||
// paddings, text sizes and the 40px headline strut are overridden on
|
||||
// the shell only, so a dialog without it keeps Material's layout.
|
||||
@include mat.dialog-overrides(
|
||||
(
|
||||
headline-padding: 0,
|
||||
content-padding: 20px,
|
||||
with-actions-content-padding: 20px,
|
||||
actions-padding: 14px 20px,
|
||||
actions-alignment: flex-end,
|
||||
supporting-text-size: 13.5px,
|
||||
supporting-text-line-height: 21px,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
.app-dialog__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 20px 56px 16px 20px;
|
||||
border-bottom: 1px solid var(--app-widget-header-border);
|
||||
}
|
||||
|
||||
.app-dialog__icon {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: var(--app-list-selected-bg);
|
||||
color: var(--app-list-selected-fg);
|
||||
|
||||
.mat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: var(--app-danger-surface);
|
||||
color: var(--app-danger-text);
|
||||
}
|
||||
}
|
||||
|
||||
.app-dialog__heading {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Material names and pads the title from `.mat-mdc-dialog-container
|
||||
// .mat-mdc-dialog-title`; the header prefix matches that specificity.
|
||||
.app-dialog__header .app-dialog__title {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: var(--app-heading-color);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.2px;
|
||||
line-height: 1.3;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.app-dialog__subtitle {
|
||||
margin: 3px 0 0;
|
||||
color: var(--app-body-color);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.app-dialog__body {
|
||||
// Flex sizes the body to what is left under the cap; Material's own
|
||||
// 65vh limit would leave the surface to scroll past it.
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.app-dialog__header-end {
|
||||
flex: none;
|
||||
align-self: center;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.app-dialog__close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
inset-inline-end: 14px;
|
||||
@include controls.ghost-icon-button(32px, 18px);
|
||||
}
|
||||
|
||||
.app-dialog__footer {
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
border-top: 1px solid var(--app-widget-header-border);
|
||||
|
||||
// The gap spaces the buttons; Material's sibling margin would double it.
|
||||
.mat-mdc-button-base + .mat-mdc-button-base {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.app-dialog__footer-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
// The slot wrappers add no box: their buttons are the row's items.
|
||||
[appDialogFooter],
|
||||
[appDialogFooterStart] {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
// The kit's footer: a ghost dismiss, one accent primary, a secondary
|
||||
// action beside it when needed. Destructive buttons keep their tint.
|
||||
.mat-mdc-button:not(.app-destructive-button) {
|
||||
@include controls.ghost-button(md);
|
||||
}
|
||||
|
||||
.mat-mdc-unelevated-button:not(.app-destructive-button):not(
|
||||
.app-dialog__secondary
|
||||
) {
|
||||
@include controls.accent-button(md);
|
||||
}
|
||||
|
||||
.app-dialog__secondary {
|
||||
@include controls.secondary-button(md);
|
||||
}
|
||||
}
|
||||
|
||||
// Narrow windows: the header-end content (a platform chip, a status) drops
|
||||
// under the heading instead of squeezing it against the close button.
|
||||
@media (max-width: 640px) {
|
||||
.app-dialog__header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-dialog__header-end {
|
||||
flex-basis: 100%;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
// Short windows: a stacked footer can take most of the cap and leave the
|
||||
// body a sliver, so the cap comes off and Material's surface scrolls the
|
||||
// whole dialog as the single scroll owner, as it did before the shell.
|
||||
@media (max-height: 600px) {
|
||||
app-dialog-shell {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.app-dialog__body {
|
||||
flex: none;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
// Phone: one full-width action per row, in DOM order.
|
||||
@media (max-width: 480px) {
|
||||
.app-dialog__footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.app-dialog__footer-spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mat-mdc-button-base {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Component, input, Type } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialogRef } from '@angular/material/dialog';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { DialogShellComponent } from './dialog-shell.component';
|
||||
import {
|
||||
DialogFooterDirective,
|
||||
DialogFooterStartDirective,
|
||||
DialogHeaderEndDirective,
|
||||
} from './dialog-shell.directives';
|
||||
|
||||
@Component({
|
||||
imports: [
|
||||
DialogShellComponent,
|
||||
DialogFooterDirective,
|
||||
DialogFooterStartDirective,
|
||||
DialogHeaderEndDirective,
|
||||
],
|
||||
// The slot wrappers sit at the top level of the content: a control-flow
|
||||
// block there would be projected into the body, so the conditional
|
||||
// action lives inside the footer wrapper.
|
||||
template: `
|
||||
<app-dialog-shell
|
||||
heading="Remove playlist"
|
||||
subtitle="Pick how"
|
||||
icon="keyboard"
|
||||
[showClose]="showClose()"
|
||||
>
|
||||
<span appDialogHeaderEnd>macOS</span>
|
||||
<p>Body text</p>
|
||||
<div appDialogFooterStart><button>Clear</button></div>
|
||||
<div appDialogFooter>
|
||||
<button>Cancel</button>
|
||||
@if (withRemove()) {
|
||||
<button>Remove</button>
|
||||
}
|
||||
</div>
|
||||
</app-dialog-shell>
|
||||
`,
|
||||
})
|
||||
class FullHostComponent {
|
||||
readonly showClose = input(true);
|
||||
readonly withRemove = input(true);
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [DialogShellComponent],
|
||||
template: `
|
||||
<app-dialog-shell heading="Keyboard shortcuts">
|
||||
<p>Body only</p>
|
||||
</app-dialog-shell>
|
||||
`,
|
||||
})
|
||||
class BareHostComponent {}
|
||||
|
||||
describe('DialogShellComponent', () => {
|
||||
const close = jest.fn();
|
||||
|
||||
async function render<T>(
|
||||
host: Type<T>,
|
||||
inputs: Partial<Record<string, unknown>> = {}
|
||||
) {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [host, TranslateModule.forRoot()],
|
||||
providers: [{ provide: MatDialogRef, useValue: { close } }],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(host);
|
||||
for (const [name, value] of Object.entries(inputs)) {
|
||||
fixture.componentRef.setInput(name, value);
|
||||
}
|
||||
fixture.detectChanges();
|
||||
return fixture.nativeElement as HTMLElement;
|
||||
}
|
||||
|
||||
beforeEach(() => close.mockClear());
|
||||
|
||||
it('names the dialog by the heading and projects the header, body and footer slots in order, including a conditional action', async () => {
|
||||
const root = await render(FullHostComponent);
|
||||
|
||||
const title = root.querySelector('h2.app-dialog__title');
|
||||
expect(title?.textContent?.trim()).toBe('Remove playlist');
|
||||
expect(title?.id).toMatch(/^mat-mdc-dialog-title-/);
|
||||
expect(
|
||||
root.querySelector('.app-dialog__subtitle')?.textContent?.trim()
|
||||
).toBe('Pick how');
|
||||
expect(
|
||||
root.querySelector('.app-dialog__icon mat-icon')?.textContent
|
||||
).toBe('keyboard');
|
||||
expect(
|
||||
root.querySelector('.app-dialog__header-end')?.textContent?.trim()
|
||||
).toBe('macOS');
|
||||
expect(root.querySelector('.app-dialog__body')?.textContent).toContain(
|
||||
'Body text'
|
||||
);
|
||||
const footerTexts = Array.from(
|
||||
root.querySelectorAll('.app-dialog__footer button')
|
||||
).map((button) => button.textContent?.trim());
|
||||
expect(footerTexts).toEqual(['Clear', 'Cancel', 'Remove']);
|
||||
});
|
||||
|
||||
it('drops a conditional action from the footer without losing the others', async () => {
|
||||
const root = await render(FullHostComponent, { withRemove: false });
|
||||
|
||||
const footerTexts = Array.from(
|
||||
root.querySelectorAll('.app-dialog__footer button')
|
||||
).map((button) => button.textContent?.trim());
|
||||
expect(footerTexts).toEqual(['Clear', 'Cancel']);
|
||||
});
|
||||
|
||||
it('renders no footer, icon tile or subtitle when nothing is projected or set', async () => {
|
||||
const root = await render(BareHostComponent);
|
||||
|
||||
expect(root.querySelector('.app-dialog__footer')).toBeNull();
|
||||
expect(root.querySelector('.app-dialog__icon')).toBeNull();
|
||||
expect(root.querySelector('.app-dialog__subtitle')).toBeNull();
|
||||
expect(root.querySelector('.app-dialog__body')?.textContent).toContain(
|
||||
'Body only'
|
||||
);
|
||||
});
|
||||
|
||||
it('closes through the header button, which comes last in the DOM so autofocus lands in the body', async () => {
|
||||
const root = await render(FullHostComponent);
|
||||
|
||||
const buttons = Array.from(root.querySelectorAll('button'));
|
||||
const closeButton = buttons.at(-1) as HTMLButtonElement;
|
||||
expect(closeButton.classList).toContain('app-dialog__close');
|
||||
expect(closeButton.getAttribute('aria-label')).toBe('CLOSE_DIALOG');
|
||||
closeButton.click();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hides the close button on request', async () => {
|
||||
const root = await render(FullHostComponent, { showClose: false });
|
||||
|
||||
expect(root.querySelector('.app-dialog__close')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
contentChild,
|
||||
input,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
DialogFooterDirective,
|
||||
DialogFooterStartDirective,
|
||||
} from './dialog-shell.directives';
|
||||
|
||||
export type DialogIconTone = 'default' | 'warn';
|
||||
|
||||
/**
|
||||
* The UI kit's dialog shell: a header with an optional icon tile, the
|
||||
* heading (`mat-dialog-title`, so the container is named by it), an optional
|
||||
* subtitle and a close button; the body; and a footer that renders only when
|
||||
* actions are projected into it.
|
||||
*
|
||||
* Slots: the body is the default content; an element with
|
||||
* `appDialogFooter` wraps the decision buttons, `appDialogFooterStart` the
|
||||
* content at the footer's start, `appDialogHeaderEnd` the content before the
|
||||
* close button. Conditional buttons go inside the wrapper: a control-flow
|
||||
* block at the top level of the content is projected into the body.
|
||||
*
|
||||
* The close button is the last element in the DOM and positioned into the
|
||||
* header, so Material's `first-tabbable` autofocus lands in the body and Tab
|
||||
* reaches the close button after the footer. It is named "Close dialog", so a
|
||||
* footer "Close" stays the only button of that name.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-dialog-shell',
|
||||
imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe],
|
||||
templateUrl: './dialog-shell.component.html',
|
||||
styleUrl: './dialog-shell.component.scss',
|
||||
// The footer buttons and the header-end content are projected from the
|
||||
// dialog's own template; the rules below reach them through the
|
||||
// `app-dialog__*` classes.
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DialogShellComponent {
|
||||
/** The translated heading; it names the dialog for assistive tech. */
|
||||
readonly heading = input.required<string>();
|
||||
readonly subtitle = input<string>();
|
||||
/** A Material icon name for the 40px tile before the heading. */
|
||||
readonly icon = input<string>();
|
||||
readonly iconTone = input<DialogIconTone>('default');
|
||||
/** The header close button; Escape and the backdrop still close. */
|
||||
readonly showClose = input(true);
|
||||
|
||||
private readonly footer = contentChild(DialogFooterDirective);
|
||||
private readonly footerStart = contentChild(DialogFooterStartDirective);
|
||||
|
||||
protected readonly hasFooter = computed(
|
||||
() => this.footer() !== undefined || this.footerStart() !== undefined
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Directive } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Wraps the footer actions of an `app-dialog-shell`. The wrapper renders as
|
||||
* `display: contents`, so the buttons sit in the footer's flex row in DOM
|
||||
* order, the dismiss first and the primary last, and `@if`/`@switch`
|
||||
* blocks inside it stay in the footer (a block at the top level of the
|
||||
* dialog's content would be projected into the body instead).
|
||||
*/
|
||||
@Directive({ selector: '[appDialogFooter]' })
|
||||
export class DialogFooterDirective {}
|
||||
|
||||
/**
|
||||
* Wraps content that sits at the start of the footer, away from the
|
||||
* decision buttons, such as a Clear action or a notice.
|
||||
*/
|
||||
@Directive({ selector: '[appDialogFooterStart]' })
|
||||
export class DialogFooterStartDirective {}
|
||||
|
||||
/** Wraps content shown at the end of the header, before the close button. */
|
||||
@Directive({ selector: '[appDialogHeaderEnd]' })
|
||||
export class DialogHeaderEndDirective {}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { DIALOG_WIDTHS, dialogSize } from './dialog-size';
|
||||
|
||||
describe('dialogSize', () => {
|
||||
it.each(['sm', 'md', 'lg'] as const)(
|
||||
'owns the width and the viewport limits of the %s preset',
|
||||
(size) => {
|
||||
expect(dialogSize(size)).toEqual({
|
||||
width: DIALOG_WIDTHS[size],
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: 'calc(100vh - 32px)',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('lets the call site override a limit by spreading the preset first', () => {
|
||||
expect({ ...dialogSize('md'), maxHeight: '90vh' }).toMatchObject({
|
||||
width: '560px',
|
||||
maxHeight: '90vh',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { MatDialogConfig } from '@angular/material/dialog';
|
||||
|
||||
/** The width presets of the UI kit's dialog shell. */
|
||||
export type DialogSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* - `sm`: a confirmation or a PIN prompt; two actions fit side by side in
|
||||
* every locale.
|
||||
* - `md`: a form or a list.
|
||||
* - `lg`: a picker with a grid, a table, or long text.
|
||||
*/
|
||||
export const DIALOG_WIDTHS: Readonly<Record<DialogSize, string>> = {
|
||||
sm: '440px',
|
||||
md: '560px',
|
||||
lg: '760px',
|
||||
};
|
||||
|
||||
/**
|
||||
* The part of a `MatDialogConfig` a size preset owns. Spread it before the
|
||||
* call site's own options, so `data`, `autoFocus` or a tighter `maxHeight`
|
||||
* still win. The import is type-only, and a service created with the app
|
||||
* imports this file through `@iptvnator/ui/components/dialog-size`: the
|
||||
* library barrel would carry its components onto the startup path.
|
||||
*/
|
||||
export function dialogSize(
|
||||
size: DialogSize
|
||||
): Pick<MatDialogConfig, 'width' | 'maxWidth' | 'maxHeight'> {
|
||||
return {
|
||||
width: DIALOG_WIDTHS[size],
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: 'calc(100vh - 32px)',
|
||||
};
|
||||
}
|
||||
@@ -250,6 +250,7 @@
|
||||
.mat-mdc-dialog-surface,
|
||||
.mdc-dialog__surface {
|
||||
background: transparent !important;
|
||||
border: 0;
|
||||
box-shadow: none !important;
|
||||
border-radius: 14px !important;
|
||||
overflow: visible;
|
||||
|
||||
@@ -84,6 +84,33 @@ $_sizes: (
|
||||
@include _disabled;
|
||||
}
|
||||
|
||||
/// The one accent action of a dialog or form: the selection blue, 40px,
|
||||
/// 10px radius, no ripple. Disabled keeps the fill at 40% opacity.
|
||||
@mixin accent-button($size: md) {
|
||||
@include mat.button-overrides(
|
||||
(
|
||||
filled-container-color: var(--app-selection-color),
|
||||
filled-disabled-container-color: var(--app-selection-color),
|
||||
filled-label-text-color: var(--app-selection-on-color),
|
||||
filled-disabled-label-text-color: var(--app-selection-on-color),
|
||||
filled-container-height: _size($size, height),
|
||||
filled-container-shape: _size($size, radius),
|
||||
filled-horizontal-padding: _size($size, padding),
|
||||
filled-label-text-size: _size($size, text),
|
||||
filled-label-text-weight: 600,
|
||||
filled-label-text-tracking: 0,
|
||||
filled-icon-spacing: _size($size, gap),
|
||||
filled-state-layer-color: var(--app-selection-on-color),
|
||||
filled-hover-state-layer-opacity: 0.1,
|
||||
filled-focus-state-layer-opacity: 0.1,
|
||||
filled-pressed-state-layer-opacity: 0.16,
|
||||
filled-ripple-color: transparent,
|
||||
)
|
||||
);
|
||||
@include _icon-size(_size($size, icon));
|
||||
@include _disabled;
|
||||
}
|
||||
|
||||
/// A secondary button at another height (toolbars that stack on narrow
|
||||
/// windows use 36px).
|
||||
@mixin secondary-button-height($height) {
|
||||
|
||||
+67
-102
@@ -1,105 +1,70 @@
|
||||
<section class="shortcuts-dialog">
|
||||
<header class="shortcuts-dialog__header">
|
||||
<div class="shortcuts-dialog__title-block">
|
||||
<span class="shortcuts-dialog__hero-icon" aria-hidden="true">
|
||||
<mat-icon>keyboard</mat-icon>
|
||||
</span>
|
||||
<div>
|
||||
<h2 mat-dialog-title>
|
||||
{{ 'WORKSPACE.SHORTCUTS.TITLE' | translate }}
|
||||
</h2>
|
||||
<p>{{ 'WORKSPACE.SHORTCUTS.SUBTITLE' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shortcuts-dialog__header-actions">
|
||||
<span class="shortcuts-dialog__platform">
|
||||
<mat-icon aria-hidden="true">{{ data.platformIcon }}</mat-icon>
|
||||
{{ data.platformLabelKey | translate }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="shortcuts-dialog__close"
|
||||
[attr.aria-label]="'CLOSE' | translate"
|
||||
(click)="close()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<app-dialog-shell
|
||||
icon="keyboard"
|
||||
[heading]="'WORKSPACE.SHORTCUTS.TITLE' | translate"
|
||||
[subtitle]="'WORKSPACE.SHORTCUTS.SUBTITLE' | translate"
|
||||
>
|
||||
<span class="shortcuts-dialog__platform" appDialogHeaderEnd>
|
||||
<mat-icon aria-hidden="true">{{ data.platformIcon }}</mat-icon>
|
||||
{{ data.platformLabelKey | translate }}
|
||||
</span>
|
||||
<div class="shortcuts-dialog__groups">
|
||||
@for (group of groups; track group.id) {
|
||||
<section class="shortcuts-dialog__group">
|
||||
<header class="shortcuts-dialog__group-header">
|
||||
<span
|
||||
class="shortcuts-dialog__group-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<mat-icon>{{ group.icon }}</mat-icon>
|
||||
</span>
|
||||
<h3>{{ group.labelKey | translate }}</h3>
|
||||
</header>
|
||||
|
||||
<mat-dialog-content class="shortcuts-dialog__content">
|
||||
<div class="shortcuts-dialog__groups">
|
||||
@for (group of groups; track group.id) {
|
||||
<section class="shortcuts-dialog__group">
|
||||
<header class="shortcuts-dialog__group-header">
|
||||
<span
|
||||
class="shortcuts-dialog__group-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<mat-icon>{{ group.icon }}</mat-icon>
|
||||
</span>
|
||||
<h3>{{ group.labelKey | translate }}</h3>
|
||||
</header>
|
||||
|
||||
<div class="shortcuts-dialog__rows">
|
||||
@for (item of group.items; track item.id) {
|
||||
<article class="shortcuts-dialog__row">
|
||||
<span
|
||||
class="shortcuts-dialog__action-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<mat-icon>{{ item.icon }}</mat-icon>
|
||||
</span>
|
||||
<span class="shortcuts-dialog__label">
|
||||
{{ item.labelKey | translate }}
|
||||
</span>
|
||||
<span class="shortcuts-dialog__chords">
|
||||
@for (
|
||||
chord of item.chords;
|
||||
track chord.id
|
||||
) {
|
||||
<span
|
||||
class="shortcuts-dialog__chord"
|
||||
[attr.aria-label]="chord.ariaLabel"
|
||||
>
|
||||
@for (
|
||||
key of chord.keys;
|
||||
track key.id
|
||||
) {
|
||||
<kbd
|
||||
[attr.aria-label]="
|
||||
key.ariaLabel
|
||||
"
|
||||
[class.shortcuts-dialog__keycap--modifier]="
|
||||
key.isModifier
|
||||
"
|
||||
<div class="shortcuts-dialog__rows">
|
||||
@for (item of group.items; track item.id) {
|
||||
<article class="shortcuts-dialog__row">
|
||||
<span
|
||||
class="shortcuts-dialog__action-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<mat-icon>{{ item.icon }}</mat-icon>
|
||||
</span>
|
||||
<span class="shortcuts-dialog__label">
|
||||
{{ item.labelKey | translate }}
|
||||
</span>
|
||||
<span class="shortcuts-dialog__chords">
|
||||
@for (chord of item.chords; track chord.id) {
|
||||
<span
|
||||
class="shortcuts-dialog__chord"
|
||||
[attr.aria-label]="chord.ariaLabel"
|
||||
>
|
||||
@for (key of chord.keys; track key.id) {
|
||||
<kbd
|
||||
[attr.aria-label]="
|
||||
key.ariaLabel
|
||||
"
|
||||
[class.shortcuts-dialog__keycap--modifier]="
|
||||
key.isModifier
|
||||
"
|
||||
>
|
||||
{{ key.label }}
|
||||
</kbd>
|
||||
@if (!$last) {
|
||||
<span
|
||||
class="shortcuts-dialog__joiner"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ key.label }}
|
||||
</kbd>
|
||||
@if (!$last) {
|
||||
<span
|
||||
class="shortcuts-dialog__joiner"
|
||||
aria-hidden="true"
|
||||
>
|
||||
+
|
||||
</span>
|
||||
}
|
||||
+
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end" class="shortcuts-dialog__actions">
|
||||
<button type="button" mat-button (click)="close()">
|
||||
{{ 'CLOSE' | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</section>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</app-dialog-shell>
|
||||
-124
@@ -1,9 +1,5 @@
|
||||
:host {
|
||||
display: block;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.shortcuts-dialog {
|
||||
--shortcut-surface: var(--app-widget-bg, #1e2330);
|
||||
--shortcut-surface-raised: var(--app-widget-header-bg, #232840);
|
||||
--shortcut-text: var(--app-heading-color, #d8dce8);
|
||||
@@ -26,80 +22,6 @@
|
||||
white 12%
|
||||
);
|
||||
--shortcut-key-shadow: color-mix(in srgb, black 24%, transparent);
|
||||
|
||||
color: var(--shortcut-text);
|
||||
background:
|
||||
linear-gradient(
|
||||
145deg,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--shortcut-accent) 8%,
|
||||
var(--shortcut-surface) 92%
|
||||
),
|
||||
var(--shortcut-surface) 42%
|
||||
),
|
||||
var(--shortcut-surface);
|
||||
border: 1px solid var(--shortcut-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 22px 70px color-mix(in srgb, black 34%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shortcuts-dialog__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 20px 22px 16px;
|
||||
border-bottom: 1px solid var(--shortcut-border);
|
||||
}
|
||||
|
||||
.shortcuts-dialog__title-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.shortcuts-dialog__hero-icon {
|
||||
display: inline-grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
color: var(--shortcut-accent);
|
||||
background: color-mix(in srgb, var(--shortcut-accent) 14%, transparent);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--shortcut-accent) 28%, transparent);
|
||||
border-radius: 8px;
|
||||
|
||||
mat-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.shortcuts-dialog__header h2 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.shortcuts-dialog__header p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--shortcut-muted);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.shortcuts-dialog__header-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shortcuts-dialog__platform {
|
||||
@@ -128,15 +50,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.shortcuts-dialog__close {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.shortcuts-dialog__content {
|
||||
max-height: min(68vh, 670px);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.shortcuts-dialog__groups {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -288,32 +201,7 @@
|
||||
);
|
||||
}
|
||||
|
||||
.shortcuts-dialog__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 9px 14px 14px;
|
||||
border-top: 1px solid var(--shortcut-border);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.shortcuts-dialog {
|
||||
animation: shortcuts-dialog-enter 160ms ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shortcuts-dialog__header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: 16px 16px 12px;
|
||||
}
|
||||
|
||||
.shortcuts-dialog__header-actions {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.shortcuts-dialog__groups {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -328,15 +216,3 @@
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shortcuts-dialog-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.985);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
+11
-14
@@ -1,13 +1,12 @@
|
||||
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { KeyboardShortcutDisplayGroup } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
DialogHeaderEndDirective,
|
||||
DialogShellComponent,
|
||||
} from '@iptvnator/ui/components';
|
||||
|
||||
export interface WorkspaceKeyboardShortcutsDialogData {
|
||||
groups: readonly KeyboardShortcutDisplayGroup[];
|
||||
@@ -17,21 +16,19 @@ export interface WorkspaceKeyboardShortcutsDialogData {
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-keyboard-shortcuts-dialog',
|
||||
imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe],
|
||||
imports: [
|
||||
DialogHeaderEndDirective,
|
||||
DialogShellComponent,
|
||||
MatIconModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './workspace-keyboard-shortcuts-dialog.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrl: './workspace-keyboard-shortcuts-dialog.component.scss',
|
||||
})
|
||||
export class WorkspaceKeyboardShortcutsDialogComponent {
|
||||
private readonly dialogRef = inject(
|
||||
MatDialogRef<WorkspaceKeyboardShortcutsDialogComponent>
|
||||
);
|
||||
readonly data =
|
||||
inject<WorkspaceKeyboardShortcutsDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
readonly groups = this.data.groups;
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -1,4 +1,5 @@
|
||||
import { DestroyRef, Injectable, inject } from '@angular/core';
|
||||
import { dialogSize } from '@iptvnator/ui/components';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
|
||||
import {
|
||||
@@ -21,9 +22,12 @@ export class WorkspaceKeyboardShortcutsService {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
// Root-provided; optional keeps standalone unit tests light. The help key must not open a dialog over the modal phone
|
||||
// context drawer.
|
||||
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, {
|
||||
optional: true,
|
||||
});
|
||||
private readonly contextDrawer = inject(
|
||||
WorkspaceShellContextDrawerService,
|
||||
{
|
||||
optional: true,
|
||||
}
|
||||
);
|
||||
private readonly onDocumentKeydown = (event: KeyboardEvent): void =>
|
||||
this.handleKeydown(event);
|
||||
|
||||
@@ -51,9 +55,7 @@ export class WorkspaceKeyboardShortcutsService {
|
||||
WorkspaceKeyboardShortcutsDialogComponent,
|
||||
WorkspaceKeyboardShortcutsDialogData
|
||||
>(WorkspaceKeyboardShortcutsDialogComponent, {
|
||||
width: 'min(960px, 94vw)',
|
||||
maxWidth: '94vw',
|
||||
panelClass: 'workspace-shortcuts-overlay',
|
||||
...dialogSize('lg'),
|
||||
autoFocus: false,
|
||||
data: {
|
||||
groups: getKeyboardShortcutGroups({
|
||||
@@ -185,5 +187,4 @@ export class WorkspaceKeyboardShortcutsService {
|
||||
|
||||
return /Mac|iPhone|iPad|iPod/i.test(platform) ? 'mac' : 'other';
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user