diff --git a/.changes/ui-dialog-shell.md b/.changes/ui-dialog-shell.md new file mode 100644 index 000000000..591ae8bdd --- /dev/null +++ b/.changes/ui-dialog-shell.md @@ -0,0 +1,9 @@ +--- +type: feature +area: ui +--- + +Dialogs share one frame: a rounded panel with a hairline border over a tinted, +blurred backdrop. The confirmation, add-playlist and keyboard-shortcuts +dialogs get the new header with a close button and a footer with the dismiss +before the main action; the others follow in a later release. diff --git a/apps/web-e2e/src/basic.e2e.ts b/apps/web-e2e/src/basic.e2e.ts index 4d4b8b594..4ff25c031 100644 --- a/apps/web-e2e/src/basic.e2e.ts +++ b/apps/web-e2e/src/basic.e2e.ts @@ -65,7 +65,9 @@ test('@web @auto-detect pasted provider message prefills the Xtream form', async '├◉𝙿𝙰𝚂𝚂➤ e2epass' ); - const candidate = dialog.locator('[data-test-id="auto-detect-candidate"]').first(); + const candidate = dialog + .locator('[data-test-id="auto-detect-candidate"]') + .first(); await expect(candidate).toBeVisible(); await expect(candidate.getByText('e2euser')).toBeVisible(); // The card must never print the password in clear. @@ -108,7 +110,9 @@ test('@web @auto-detect keeps the pasted message when switching methods', async const message = 'Portal: http://stb.example.com/c/\nMAC: 00:1A:79:12:34:56'; await dialog.locator('[data-test-id="auto-detect-textarea"]').fill(message); - const candidate = dialog.locator('[data-test-id="auto-detect-candidate"]').first(); + const candidate = dialog + .locator('[data-test-id="auto-detect-candidate"]') + .first(); await expect(candidate.getByText('00:1A:79:12:34:56')).toBeVisible(); await candidate.locator('[data-test-id="auto-detect-use"]').click(); @@ -123,9 +127,9 @@ test('@web @auto-detect keeps the pasted message when switching methods', async // Returning to auto-detect must not cost the user their paste. await dialog.getByRole('radio', { name: /Auto-detect/i }).click(); - await expect(dialog.locator('[data-test-id="auto-detect-textarea"]')).toHaveValue( - message - ); + await expect( + dialog.locator('[data-test-id="auto-detect-textarea"]') + ).toHaveValue(message); }); test('@web @m3u reads an existing v1 IndexedDB playlist after application upgrade', async ({ @@ -299,3 +303,60 @@ test('@web keyboard shortcuts help opens from question mark', async ({ await page.keyboard.press('Escape'); await expect(dialog).toBeHidden(); }); + +test('@web keyboard shortcuts header fits a phone width', async ({ page }) => { + // The shell's header-end slot (the platform chip) wraps under the + // heading on narrow windows; neither may run into the close button or + // past the panel. + // The phone layout folds the header's shortcuts button away, so the + // dialog opens at desktop width and the window shrinks around it. + await page.goto('/'); + await page.getByRole('button', { name: 'Open keyboard shortcuts' }).click(); + const dialog = page.getByRole('dialog'); + const heading = dialog.getByRole('heading', { name: 'Keyboard shortcuts' }); + await expect(heading).toBeVisible(); + await page.setViewportSize({ width: 360, height: 740 }); + await expect + .poll(() => dialog.evaluate((el) => el.getBoundingClientRect().width)) + .toBeLessThanOrEqual(328); + const chip = dialog.locator('.app-dialog__header-end'); + const close = dialog.getByRole('button', { + name: 'Close dialog', + exact: true, + }); + await expect(close).toBeVisible(); + + const bounds = await dialog.evaluate((el) => { + const rect = el.getBoundingClientRect(); + return { + left: rect.left, + right: rect.right, + overflow: el.scrollWidth - el.clientWidth, + }; + }); + expect(bounds.overflow).toBeLessThanOrEqual(0); + for (const part of [heading, chip, close]) { + const rect = await part.evaluate((el) => { + const r = el.getBoundingClientRect(); + return { left: r.left, right: r.right, top: r.top }; + }); + expect(rect.left).toBeGreaterThanOrEqual(bounds.left); + expect(rect.right).toBeLessThanOrEqual(bounds.right); + } + const box = (part: typeof heading) => + part.evaluate((el) => { + const r = el.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height }; + }); + const headingBox = await box(heading); + const chipBox = await box(chip); + const closeBox = await box(close); + // The chip sits below the heading; the close button stays beside it. + expect(chipBox.y).toBeGreaterThanOrEqual( + headingBox.y + headingBox.height - 1 + ); + expect(closeBox.x).toBeGreaterThan(headingBox.x + headingBox.width - 1); + + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); +}); diff --git a/apps/web/src/app/services/workspace-shell-actions.service.ts b/apps/web/src/app/services/workspace-shell-actions.service.ts index c87c0765a..4366d7620 100644 --- a/apps/web/src/app/services/workspace-shell-actions.service.ts +++ b/apps/web/src/app/services/workspace-shell-actions.service.ts @@ -3,6 +3,7 @@ import type { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; import { PORTAL_SHELL_ACTIONS } from '@iptvnator/portal/shared/util/tokens'; import { LazyMatDialog } from '@iptvnator/ui/components/lazy-dialog'; +import { dialogSize } from '@iptvnator/ui/components/dialog-size'; import { WORKSPACE_SHELL_ACTIONS, WorkspaceAccountInfoData, @@ -26,13 +27,9 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions { import('@iptvnator/playlist/import/feature'), (dialog, { AddPlaylistDialogComponent }) => { dialog.open(AddPlaylistDialogComponent, { - // Width sized for the 5-card method picker plus the - // selected method's form below. Matches the v0.22 - // mockup; falls back to viewport-clamped width on - // narrow screens so the grid can collapse via the - // SCSS responsive breakpoints. - width: '780px', - maxWidth: '92vw', + // The method-card grid collapses through its own + // breakpoints inside the large preset. + ...dialogSize('lg'), data: type ? { type } : {}, }); } diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index d9695f59b..7a788f97a 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -3,6 +3,7 @@ "CLEAR": "مسح", "CLEAR_SEARCH": "مسح البحث", "CLOSE": "إغلاق", + "CLOSE_DIALOG": "إغلاق مربع الحوار", "REMOVE": "إزالة", "BACK": "رجوع", "SHOW_MORE": "المزيد", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index d0faa8e50..80153a6f7 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -3,6 +3,7 @@ "CLEAR": "مسح", "CLEAR_SEARCH": "مسح البحث", "CLOSE": "سد", + "CLOSE_DIALOG": "سدّ مربع الحوار", "REMOVE": "حيد", "BACK": "رجع", "SHOW_MORE": "وري كثر", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 3be626e68..fdf3e2447 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -3,6 +3,7 @@ "CLEAR": "Ачысціць", "CLEAR_SEARCH": "Ачысціць пошук", "CLOSE": "Закрыць", + "CLOSE_DIALOG": "Закрыць дыялог", "REMOVE": "Выдаліць", "BACK": "Назад", "SHOW_MORE": "Болей", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index feb6ee0e0..1c439f42e 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -3,6 +3,7 @@ "CLEAR": "Leeren", "CLEAR_SEARCH": "Suche löschen", "CLOSE": "Schließen", + "CLOSE_DIALOG": "Dialog schließen", "REMOVE": "Entfernen", "BACK": "Zurück", "SHOW_MORE": "Mehr", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index f427ba100..ebe75ba21 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -3,6 +3,7 @@ "CLEAR": "Εκκαθάριση", "CLEAR_SEARCH": "Καθαρισμός αναζήτησης", "CLOSE": "Κλείσιμο", + "CLOSE_DIALOG": "Κλείσιμο παραθύρου διαλόγου", "REMOVE": "Αφαίρεση", "BACK": "Πίσω", "SHOW_MORE": "Περισσότερα", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index cf9e5bd7c..07ba1f306 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -3,6 +3,7 @@ "CLEAR": "Clear", "CLEAR_SEARCH": "Clear search", "CLOSE": "Close", + "CLOSE_DIALOG": "Close dialog", "REMOVE": "Remove", "BACK": "Back", "SHOW_MORE": "More", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 17d845b3c..df43610e5 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -3,6 +3,7 @@ "CLEAR": "Borrar", "CLEAR_SEARCH": "Borrar búsqueda", "CLOSE": "Cerrar", + "CLOSE_DIALOG": "Cerrar el diálogo", "REMOVE": "Quitar", "BACK": "Atrás", "SHOW_MORE": "Más", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index e17c937e8..008bf6a3c 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -3,6 +3,7 @@ "CLEAR": "Effacer", "CLEAR_SEARCH": "Effacer la recherche", "CLOSE": "Fermer", + "CLOSE_DIALOG": "Fermer la boîte de dialogue", "REMOVE": "Retirer", "BACK": "Retour", "SHOW_MORE": "Plus", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index ccb9b0f19..1d9022689 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -3,6 +3,7 @@ "CLEAR": "Törlés", "CLEAR_SEARCH": "Keresés törlése", "CLOSE": "Bezárás", + "CLOSE_DIALOG": "Párbeszédablak bezárása", "REMOVE": "Eltávolítás", "BACK": "Vissza", "SHOW_MORE": "Több mutatása", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index e28779378..70ded8ebd 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -3,6 +3,7 @@ "CLEAR": "Cancella", "CLEAR_SEARCH": "Cancella ricerca", "CLOSE": "Chiudi", + "CLOSE_DIALOG": "Chiudi la finestra di dialogo", "REMOVE": "Rimuovi", "BACK": "Indietro", "SHOW_MORE": "Altro", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 83d6d2f6b..f0a250cb8 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -3,6 +3,7 @@ "CLEAR": "クリア", "CLEAR_SEARCH": "検索をクリア", "CLOSE": "閉じる", + "CLOSE_DIALOG": "ダイアログを閉じる", "REMOVE": "削除", "BACK": "戻る", "SHOW_MORE": "もっと見る", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 15f7b33c4..0eb25a156 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -3,6 +3,7 @@ "CLEAR": "지우기", "CLEAR_SEARCH": "검색 지우기", "CLOSE": "닫기", + "CLOSE_DIALOG": "대화 상자 닫기", "REMOVE": "제거", "BACK": "뒤로", "SHOW_MORE": "더 보기", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 14d4a7968..44d9ce9a7 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -3,6 +3,7 @@ "CLEAR": "Wissen", "CLEAR_SEARCH": "Zoekopdracht wissen", "CLOSE": "Sluiten", + "CLOSE_DIALOG": "Dialoogvenster sluiten", "REMOVE": "Verwijderen", "BACK": "Terug", "SHOW_MORE": "Meer", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index b3d3f4098..33e91a86c 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -3,6 +3,7 @@ "CLEAR": "Wyczyść", "CLEAR_SEARCH": "Wyczyść wyszukiwanie", "CLOSE": "Zamknij", + "CLOSE_DIALOG": "Zamknij okno dialogowe", "REMOVE": "Usuń", "BACK": "Wstecz", "SHOW_MORE": "Więcej", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index b58921338..1211261b7 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -3,6 +3,7 @@ "CLEAR": "Limpar", "CLEAR_SEARCH": "Limpar pesquisa", "CLOSE": "Fechar", + "CLOSE_DIALOG": "Fechar a caixa de diálogo", "REMOVE": "Remover", "BACK": "Voltar", "SHOW_MORE": "Mais", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 2f395e18c..3919f253d 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -3,6 +3,7 @@ "CLEAR": "Очистить", "CLEAR_SEARCH": "Очистить поиск", "CLOSE": "Закрыть", + "CLOSE_DIALOG": "Закрыть диалог", "REMOVE": "Удалить", "BACK": "Назад", "SHOW_MORE": "Больше", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index b2446e752..7eda1bf19 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -3,6 +3,7 @@ "CLEAR": "Temizle", "CLEAR_SEARCH": "Aramayı temizle", "CLOSE": "Kapat", + "CLOSE_DIALOG": "İletişim kutusunu kapat", "REMOVE": "Kaldır", "BACK": "Geri", "SHOW_MORE": "Daha fazla", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index ec13f426f..ce66b3a47 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -3,6 +3,7 @@ "CLEAR": "清除", "CLEAR_SEARCH": "清除搜索", "CLOSE": "关闭", + "CLOSE_DIALOG": "关闭对话框", "REMOVE": "移除", "BACK": "返回", "SHOW_MORE": "更多", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 8a2a4468e..4352c0b5c 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -3,6 +3,7 @@ "CLEAR": "清除", "CLEAR_SEARCH": "清除搜尋", "CLOSE": "關閉", + "CLOSE_DIALOG": "關閉對話方塊", "REMOVE": "移除", "BACK": "返回", "SHOW_MORE": "更多", diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss index 891c238fd..c8b55e4df 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -11,8 +11,7 @@ // The stack is declared once on html; every Material typography token and the // typography hierarchy read it through the variable. Inlined, the 103-char // stack was copied into ~90 declarations (about 7 KB of the initial CSS). -$app-font-stack-value: - "'DM Sans', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; +$app-font-stack-value: "'DM Sans', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; $app-font-stack: var(--app-font-family); html { @@ -179,7 +178,8 @@ html { --app-widget-bg: #ffffff; --app-widget-header-bg: #fafbfc; --app-widget-border: transparent; - --app-widget-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.04); + --app-widget-shadow: + 0 1px 3px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.04); --app-widget-header-border: rgba(0, 0, 0, 0.06); --app-card-hover-bg: #f0f2f5; --app-separator: rgba(0, 0, 0, 0.14); @@ -224,7 +224,8 @@ html { // Source lists (Sources page, playlist switcher) and their popovers. --app-line-strong: rgba(0, 0, 0, 0.12); - --app-font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace; + --app-font-mono: + 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace; // A failing source's tag and destructive menu items. --app-danger-text: #b3261e; --app-danger-surface: color-mix(in srgb, #d12c2c 12%, transparent); @@ -309,10 +310,10 @@ html { --app-separator: rgba(255, 255, 255, 0.14); // Typography — NOT pure white. Warm graphite-tinted whites - --app-heading-color: #d8dce8; // soft off-white, not #fff - --app-body-color: #8b93a8; // muted blue-grey - --app-muted-color: #5c6478; // dimmed - --app-eyebrow-color: #6b7494; // eyebrow labels + --app-heading-color: #d8dce8; // soft off-white, not #fff + --app-body-color: #8b93a8; // muted blue-grey + --app-muted-color: #5c6478; // dimmed + --app-eyebrow-color: #6b7494; // eyebrow labels // Provider accents (slightly brighter for dark bg) --app-provider-xtream: #78adff; @@ -365,9 +366,13 @@ html { // cascade. Pairs with the outline + dynamic MAT_FORM_FIELD_DEFAULT_OPTIONS // that provideFormFieldDefaults() (@iptvnator/ui/components) sets on each // component rendering a form field. -$app-refined-input-density: mat.define-theme(( - density: (scale: -2), -)); +$app-refined-input-density: mat.define-theme( + ( + density: ( + scale: -2, + ), + ) +); html { @include mat.form-field-density($app-refined-input-density); @@ -430,10 +435,16 @@ html { label-text-color: var(--app-heading-color), selected-state-label-text-color: var(--app-list-selected-fg), selected-state-layer-color: var(--app-list-selected-bg), - hover-state-layer-color: - color-mix(in srgb, var(--app-on-surface) 8%, transparent), - focus-state-layer-color: - color-mix(in srgb, var(--app-on-surface) 10%, transparent), + hover-state-layer-color: color-mix( + in srgb, + var(--app-on-surface) 8%, + transparent + ), + focus-state-layer-color: color-mix( + in srgb, + var(--app-on-surface) 10%, + transparent + ), ) ); padding: 6px; @@ -447,6 +458,27 @@ html { } } + // Every dialog shares one frame: the kit's 16px panel on the widget + // surface, a hairline border and the popover shadow. The tokens sit on + // the container, inside the overlay, so the `--app-*` values resolve + // per theme. A dialog that draws its own surface (the command palette, + // the programme dialog) strips this frame through its panel class. + .mat-mdc-dialog-container { + @include mat.dialog-overrides( + ( + container-shape: 16px, + container-color: var(--app-widget-bg), + container-elevation-shadow: var(--app-popover-shadow), + subhead-color: var(--app-heading-color), + supporting-text-color: var(--app-body-color), + ) + ); + } + + .mat-mdc-dialog-surface { + border: 1px solid var(--app-line-strong); + } + // ─── Destructive actions ─────────────────────────────────────────────── // Material only emits `.mat-warn` button colors for M2 themes, so the // `warn` color input is a no-op with this M3 theme. Buttons that remove or @@ -457,6 +489,9 @@ html { .app-destructive-button { @include mat.button-overrides( ( + filled-container-shape: 10px, + text-container-shape: 10px, + outlined-container-shape: 10px, filled-container-color: var(--app-danger-surface), filled-disabled-container-color: var(--app-danger-surface), filled-label-text-color: var(--app-danger-text), diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index 11d43ed18..cf96f696c 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -239,12 +239,19 @@ body.dark-theme { overflow-x: hidden !important; } -// Modal overlays blur the page behind their backdrop. The CDK gives a dialog -// the dark backdrop class by default; menus, selects and popovers open over +// Modal overlays tint and blur the page behind their backdrop, on the same +// token as the switcher popover. The CDK gives a dialog the dark backdrop +// class by default; menus, selects and popovers open over // `cdk-overlay-transparent-backdrop`, which sits at opacity 0 and stays a // plain layer without a filter of its own. .cdk-overlay-dark-backdrop { - backdrop-filter: blur(4px); + background: var(--app-popover-backdrop); + backdrop-filter: blur(6px); +} + +// Dialog surfaces may overlap the frameless title bar's drag region. +.mat-mdc-dialog-surface { + app-region: no-drag; } // UI-kit menu panel: ``. The panel renders in the @@ -258,20 +265,7 @@ body.dark-theme { // halo peeking out behind the rounded corners. .workspace-command-palette-overlay .mat-mdc-dialog-surface { background: transparent; - box-shadow: none; - border-radius: 14px; - padding: 0; - overflow: visible; -} - -.workspace-shortcuts-overlay, -.workspace-shortcuts-overlay .mat-mdc-dialog-surface, -.workspace-shortcuts-overlay .mat-mdc-dialog-container { - app-region: no-drag; -} - -.workspace-shortcuts-overlay .mat-mdc-dialog-surface { - background: transparent; + border: 0; box-shadow: none; border-radius: 14px; padding: 0; diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index c9455db57..c8442ca79 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -86,6 +86,8 @@ the subsystem's document. `libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss` - Shared list selection style: `libs/ui/styles/_nav-list.scss` +- Dialog shell and size presets: + `libs/ui/components/src/lib/dialog-shell/` - Source row (Sources page and playlist switcher): `libs/playlist/shared/ui/src/lib/source-row/source-row.component.html` - UI-kit buttons, menus and segmented control: @@ -1133,8 +1135,33 @@ per-theme tokens in `apps/web/src/_settings-theme.scss`. strut, as the programme dialog does. - **Order.** Dismiss first, primary last. With end alignment the primary sits on the inline end: the right in LTR, the left in RTL. -- **One dismiss.** Offer one visible dismiss: a footer "Cancel"/"Close" or a - header close icon, not both. Escape and the backdrop still close. +- **Shell.** A dialog's markup is `app-dialog-shell` + (`@iptvnator/ui/components`): the heading (its `mat-dialog-title`, so + the container is named by it), an optional subtitle and 40px icon tile + (`iconTone="warn"` for a destructive question), the body as default + content, and a footer rendered only when an element with + `appDialogFooter` wraps the actions (`appDialogFooterStart` wraps a Clear + action or a notice at the footer's start, `appDialogHeaderEnd` a chip + before the close button); a conditional action sits inside the wrapper, + since a control-flow block at the top level is projected into the body. + Footer + buttons take the kit look from the shell: `mat-button` ghost, + `mat-flat-button` accent, `mat-flat-button.app-dialog__secondary` + secondary; `.app-destructive-button` keeps its tint. The frame itself + (16px panel on the widget surface, hairline border, popover shadow, + tinted blurred backdrop) is global in `m3-theme.scss` and reaches every + dialog; a dialog that draws its own surface strips it through its panel + class, as the command palette and the programme dialog do. + The shell caps itself at the viewport minus the preset's margins and + lets only the body scroll; under 600px of height the cap comes off and the + surface scrolls the whole dialog, so a stacked footer never squeezes the + body to a sliver. +- **Dismiss.** The shell's header × (named "Close dialog", so a footer + "Close" stays the only button of that name) and the footer + "Cancel"/"Close" both dismiss; a dialog never adds a second footer + dismiss. The × is the last + element in the DOM, so `first-tabbable` autofocus lands in the body and + Tab reaches it after the footer. Escape and the backdrop still close. - **One row.** Action labels are short verbs ("Cancel", "Discard", "Save"), not phrases, so the row fits one line in every locale at the dialog width. Secondary actions tied to one part of the content stay with that content @@ -1147,11 +1174,17 @@ per-theme tokens in `apps/web/src/_settings-theme.scss`. locales. The programme dialog stacks its archive tools and footer the same way, letting a long label wrap inside its button; the Electron `epg-timeline-interaction.e2e.ts` measures it in French at 360px. -- **Width.** A dialog with several openers is opened through one helper that - owns its `MatDialogConfig`, so its width never depends on the entry point. - `EpgProgrammeDialogService` opens the programme dialog at 540px from the - timeline, list, guide and channel rows, with a panel class that scopes its - surface overrides. +- **Width.** An opener spreads a size preset first, `...dialogSize('sm' | + 'md' | 'lg')` (440, 560 and 760px with the viewport limits), then its own + options. `sm` is a confirmation or a PIN prompt, `md` a form or a list, + `lg` a picker with a grid, a table or long text. The helper imports + `MatDialogConfig` as a type only, so a root service may use it without + putting Material's dialog on the startup path; `MAT_DIALOG_DEFAULT_OPTIONS` + is never provided at the root for that reason. A dialog with several + openers is opened through one helper that owns its config, so its width + never depends on the entry point: `EpgProgrammeDialogService` opens the + programme dialog at 540px from the timeline, list, guide and channel rows, + with a panel class that scopes its surface overrides. - **Destructive actions.** Material only emits `warn` button colors for M2 themes, so the `color` input is a no-op here. A button that removes or discards user data uses the global `.app-destructive-button` class from diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html index 6ea3fb467..b200cc1be 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html @@ -1,14 +1,8 @@
-
-

- {{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }} -

-

- {{ 'HOME.ADD_PLAYLIST.PICK_METHOD' | translate }} -

-
- - +
- - - - @if (playlistType() === 'xtream') { -

- {{ - 'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.HTTP_PERMISSION' - | translate - }} -

- } -
+
+ @if (playlistType() === 'xtream') { +

+ {{ + 'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.HTTP_PERMISSION' + | translate + }} +

+ }
- -
+
- @switch (playlistType()) { @case ('url') { } @else {
diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss index dc2692729..aefbcec78 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss @@ -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 , so target light mode via `body:not(...)`. body:not(.dark-theme) .apd { diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts index 82d9e6bd9..36e5281bc 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts @@ -447,7 +447,7 @@ describe('AddPlaylistDialogComponent actions', () => { function primaryLabel(root: HTMLElement): string | undefined { const buttons = root.querySelectorAll( - '.apd-actions__decision button' + '[appDialogFooter] button' ); return buttons[buttons.length - 1]?.textContent?.trim(); } diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts index 6c71550ba..42c220dc8 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts @@ -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, diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index 83826f8ea..bd6c3cd00 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -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'; diff --git a/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts b/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts index 375f073d2..1709bbf5f 100644 --- a/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts +++ b/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts @@ -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: ` -

- {{ dialogData.title }} -

- + {{ dialogData.message }} - - - - @if (dialogData.keepOpenOnConfirm) { - - } @else { - - } - + @if (dialogData.keepOpenOnConfirm) { + + } @else { + + } +
+ `, }) export class ConfirmDialogComponent { diff --git a/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts b/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts index c371430fd..5f1657ab2 100644 --- a/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts +++ b/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts @@ -45,8 +45,9 @@ describe('DialogService', () => { title: 'Remove', message: 'Confirm removal?', }), - width: '420px', + width: '440px', maxWidth: 'calc(100vw - 32px)', + maxHeight: 'calc(100vh - 32px)', }) ); }); diff --git a/libs/ui/components/src/lib/confirm-dialog/dialog.service.ts b/libs/ui/components/src/lib/confirm-dialog/dialog.service.ts index b459d3ff6..b36eedbc0 100644 --- a/libs/ui/components/src/lib/confirm-dialog/dialog.service.ts +++ b/libs/ui/components/src/lib/confirm-dialog/dialog.service.ts @@ -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() diff --git a/libs/ui/components/src/lib/dialog-shell/dialog-shell.component.html b/libs/ui/components/src/lib/dialog-shell/dialog-shell.component.html new file mode 100644 index 000000000..cca1f152d --- /dev/null +++ b/libs/ui/components/src/lib/dialog-shell/dialog-shell.component.html @@ -0,0 +1,41 @@ +
+ @if (icon(); as icon) { + + } +
+

{{ heading() }}

+ @if (subtitle(); as subtitle) { +

{{ subtitle }}

+ } +
+
+ +
+
+ + + +@if (hasFooter()) { + + + + + +} +@if (showClose()) { + +} diff --git a/libs/ui/components/src/lib/dialog-shell/dialog-shell.component.scss b/libs/ui/components/src/lib/dialog-shell/dialog-shell.component.scss new file mode 100644 index 000000000..a98a680e0 --- /dev/null +++ b/libs/ui/components/src/lib/dialog-shell/dialog-shell.component.scss @@ -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%; + } + } +} diff --git a/libs/ui/components/src/lib/dialog-shell/dialog-shell.component.spec.ts b/libs/ui/components/src/lib/dialog-shell/dialog-shell.component.spec.ts new file mode 100644 index 000000000..24c674e78 --- /dev/null +++ b/libs/ui/components/src/lib/dialog-shell/dialog-shell.component.spec.ts @@ -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: ` + + macOS +

Body text

+
+
+ + @if (withRemove()) { + + } +
+
+ `, +}) +class FullHostComponent { + readonly showClose = input(true); + readonly withRemove = input(true); +} + +@Component({ + imports: [DialogShellComponent], + template: ` + +

Body only

+
+ `, +}) +class BareHostComponent {} + +describe('DialogShellComponent', () => { + const close = jest.fn(); + + async function render( + host: Type, + inputs: Partial> = {} + ) { + 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(); + }); +}); diff --git a/libs/ui/components/src/lib/dialog-shell/dialog-shell.component.ts b/libs/ui/components/src/lib/dialog-shell/dialog-shell.component.ts new file mode 100644 index 000000000..70e13e935 --- /dev/null +++ b/libs/ui/components/src/lib/dialog-shell/dialog-shell.component.ts @@ -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(); + readonly subtitle = input(); + /** A Material icon name for the 40px tile before the heading. */ + readonly icon = input(); + readonly iconTone = input('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 + ); +} diff --git a/libs/ui/components/src/lib/dialog-shell/dialog-shell.directives.ts b/libs/ui/components/src/lib/dialog-shell/dialog-shell.directives.ts new file mode 100644 index 000000000..9bdf2d5a0 --- /dev/null +++ b/libs/ui/components/src/lib/dialog-shell/dialog-shell.directives.ts @@ -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 {} diff --git a/libs/ui/components/src/lib/dialog-shell/dialog-size.spec.ts b/libs/ui/components/src/lib/dialog-shell/dialog-size.spec.ts new file mode 100644 index 000000000..d1d2606e9 --- /dev/null +++ b/libs/ui/components/src/lib/dialog-shell/dialog-size.spec.ts @@ -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', + }); + }); +}); diff --git a/libs/ui/components/src/lib/dialog-shell/dialog-size.ts b/libs/ui/components/src/lib/dialog-shell/dialog-size.ts new file mode 100644 index 000000000..e1a7d3994 --- /dev/null +++ b/libs/ui/components/src/lib/dialog-shell/dialog-size.ts @@ -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> = { + 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 { + return { + width: DIALOG_WIDTHS[size], + maxWidth: 'calc(100vw - 32px)', + maxHeight: 'calc(100vh - 32px)', + }; +} diff --git a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss index fd6661e7f..4804ffba3 100644 --- a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss +++ b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss @@ -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; diff --git a/libs/ui/styles/_app-controls.scss b/libs/ui/styles/_app-controls.scss index 8804d8c6f..adedbcf2f 100644 --- a/libs/ui/styles/_app-controls.scss +++ b/libs/ui/styles/_app-controls.scss @@ -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) { diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.html b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.html index bb6925e26..a0f0d129b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.html @@ -1,105 +1,70 @@ -
-
-
- -
-

- {{ 'WORKSPACE.SHORTCUTS.TITLE' | translate }} -

-

{{ 'WORKSPACE.SHORTCUTS.SUBTITLE' | translate }}

-
-
-
- - - {{ data.platformLabelKey | translate }} - - -
-
+ + + + {{ data.platformLabelKey | translate }} + +
+ @for (group of groups; track group.id) { +
+
+ +

{{ group.labelKey | translate }}

+
- -
- @for (group of groups; track group.id) { -
-
- -

{{ group.labelKey | translate }}

-
- -
- @for (item of group.items; track item.id) { -
- - - {{ item.labelKey | translate }} - - - @for ( - chord of item.chords; - track chord.id - ) { - - @for ( - key of chord.keys; - track key.id - ) { - + @for (item of group.items; track item.id) { +
+ + + {{ item.labelKey | translate }} + + + @for (chord of item.chords; track chord.id) { + + @for (key of chord.keys; track key.id) { + + {{ key.label }} + + @if (!$last) { + } - - } - -
- } -
-
- } -
-
- - - - -
+ } + + } + + + } +
+
+ } +
+ diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.scss b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.scss index 11fbc3bdb..c3cc84bb5 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.scss @@ -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); - } -} diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts index 07d5c036c..81db62c0e 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts @@ -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 - ); readonly data = inject(MAT_DIALOG_DATA); readonly groups = this.data.groups; - - close(): void { - this.dialogRef.close(); - } } diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts index 1fe63f29e..318e0c084 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts @@ -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'; } - } diff --git a/tsconfig.base.json b/tsconfig.base.json index 4f5bd9c80..179c75d3f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -105,6 +105,9 @@ "@iptvnator/ui/components/confirm-dialog": [ "libs/ui/components/src/lib/confirm-dialog/dialog.service.ts" ], + "@iptvnator/ui/components/dialog-size": [ + "libs/ui/components/src/lib/dialog-shell/dialog-size.ts" + ], "@iptvnator/ui/components/form-field-defaults": [ "libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts" ],