mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
* 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>
330 lines
10 KiB
SCSS
330 lines
10 KiB
SCSS
// Buttons, menus and the segmented control of the IPTVnator UI kit, over
|
|
// Angular Material: Material keeps the behaviour (focus, keyboard, overlay,
|
|
// ARIA), these mixins set the look through `mat.*-overrides()`.
|
|
//
|
|
// Button variants are roles, not looks:
|
|
// - `secondary-button` (`mat-flat-button`): every ordinary action and every
|
|
// dropdown trigger (label + 12px chevron, opening a `mat-menu`);
|
|
// - `ghost-button` (`mat-button`) and `ghost-icon-button`
|
|
// (`mat-icon-button`): row and header actions.
|
|
// No outline, no ripple: hover changes the fill only, and a disabled button
|
|
// is the same button at 40% opacity. Focus keeps the app's ring
|
|
// (`styles.scss` rings every `.mat-mdc-button-base`).
|
|
@use 'sass:map';
|
|
@use '@angular/material' as mat;
|
|
|
|
$radius: 10px;
|
|
$radius-sm: 8px;
|
|
|
|
$_sizes: (
|
|
md: (
|
|
height: 40px,
|
|
radius: $radius,
|
|
padding: 16px,
|
|
icon-padding: 14px,
|
|
text: 13.5px,
|
|
icon: 16px,
|
|
gap: 8px,
|
|
),
|
|
sm: (
|
|
height: 32px,
|
|
radius: $radius-sm,
|
|
padding: 12px,
|
|
icon-padding: 10px,
|
|
text: 12.5px,
|
|
icon: 14px,
|
|
gap: 6px,
|
|
),
|
|
);
|
|
|
|
@function _size($size, $key) {
|
|
@return map.get($_sizes, $size, $key);
|
|
}
|
|
|
|
@mixin _icon-size($size) {
|
|
.mat-icon {
|
|
width: $size;
|
|
height: $size;
|
|
font-size: $size;
|
|
line-height: $size;
|
|
}
|
|
}
|
|
|
|
@mixin _disabled {
|
|
&[disabled],
|
|
&.mat-mdc-button-disabled {
|
|
opacity: 0.4;
|
|
}
|
|
}
|
|
|
|
/// `$size`: `md` (40px) or `sm` (32px).
|
|
@mixin secondary-button($size: md) {
|
|
$fill: color-mix(in srgb, var(--app-on-surface) 10%, transparent);
|
|
@include mat.button-overrides(
|
|
(
|
|
filled-container-color: $fill,
|
|
filled-disabled-container-color: $fill,
|
|
filled-label-text-color: var(--app-heading-color),
|
|
filled-disabled-label-text-color: var(--app-heading-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-on-surface),
|
|
filled-hover-state-layer-opacity: 0.06,
|
|
filled-focus-state-layer-opacity: 0.06,
|
|
filled-pressed-state-layer-opacity: 0.1,
|
|
filled-ripple-color: transparent,
|
|
)
|
|
);
|
|
@include _icon-size(_size($size, icon));
|
|
@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) {
|
|
@include mat.button-overrides(
|
|
(
|
|
filled-container-height: $height,
|
|
)
|
|
);
|
|
}
|
|
|
|
/// A text action: `--app-body-color`, the heading colour on hover.
|
|
@mixin ghost-button($size: sm) {
|
|
@include mat.button-overrides(
|
|
(
|
|
text-label-text-color: var(--app-body-color),
|
|
text-disabled-label-text-color: var(--app-body-color),
|
|
text-container-height: _size($size, height),
|
|
text-container-shape: _size($size, radius),
|
|
text-horizontal-padding: _size($size, padding),
|
|
text-with-icon-horizontal-padding: _size($size, icon-padding),
|
|
text-label-text-size: _size($size, text),
|
|
text-label-text-weight: 600,
|
|
text-label-text-tracking: 0,
|
|
text-icon-spacing: 6px,
|
|
text-state-layer-color: var(--app-on-surface),
|
|
text-hover-state-layer-opacity: 0.08,
|
|
text-focus-state-layer-opacity: 0.08,
|
|
text-pressed-state-layer-opacity: 0.12,
|
|
text-ripple-color: transparent,
|
|
)
|
|
);
|
|
@include _icon-size(14px);
|
|
@include _disabled;
|
|
|
|
&:not([disabled]):hover {
|
|
@include mat.button-overrides(
|
|
(
|
|
text-label-text-color: var(--app-heading-color),
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A square icon action with an 8px radius, not Material's circle.
|
|
@mixin ghost-icon-button($size: 32px, $icon-size: 16px) {
|
|
@include mat.icon-button-overrides(
|
|
(
|
|
state-layer-size: $size,
|
|
container-shape: $radius-sm,
|
|
icon-size: $icon-size,
|
|
icon-color: var(--app-body-color),
|
|
disabled-icon-color: var(--app-body-color),
|
|
state-layer-color: var(--app-on-surface),
|
|
hover-state-layer-opacity: 0.08,
|
|
focus-state-layer-opacity: 0.08,
|
|
pressed-state-layer-opacity: 0.12,
|
|
ripple-color: transparent,
|
|
touch-target-display: none,
|
|
)
|
|
);
|
|
border-radius: $radius-sm;
|
|
@include _icon-size($icon-size);
|
|
@include _disabled;
|
|
|
|
&:not([disabled]):hover {
|
|
@include mat.icon-button-overrides(
|
|
(
|
|
icon-color: var(--app-heading-color),
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The trailing 12px chevron of a dropdown trigger.
|
|
@mixin trigger-chevron {
|
|
width: 12px;
|
|
height: 12px;
|
|
font-size: 12px;
|
|
line-height: 12px;
|
|
margin-inline-start: 2px;
|
|
color: var(--app-body-color);
|
|
}
|
|
|
|
/// A `mat-menu` panel; apply to the class given to `<mat-menu class>`.
|
|
/// Items: 36px, 13px, 16px icons in the body colour; destructive items
|
|
/// carry `app-menu-item--danger` and sit after a divider; a keyboard hint
|
|
/// is a trailing `kbd.app-menu-item__kbd`.
|
|
@mixin menu-panel {
|
|
@include mat.menu-overrides(
|
|
(
|
|
container-color: var(--app-widget-bg),
|
|
container-shape: $radius,
|
|
item-label-text-color: var(--app-heading-color),
|
|
item-label-text-size: 13px,
|
|
item-label-text-weight: 400,
|
|
item-icon-color: var(--app-body-color),
|
|
item-icon-size: 16px,
|
|
item-hover-state-layer-color: color-mix(
|
|
in srgb,
|
|
var(--app-on-surface) 8%,
|
|
transparent
|
|
),
|
|
item-focus-state-layer-color: color-mix(
|
|
in srgb,
|
|
var(--app-on-surface) 10%,
|
|
transparent
|
|
),
|
|
item-leading-spacing: 10px,
|
|
item-trailing-spacing: 10px,
|
|
item-with-icon-leading-spacing: 10px,
|
|
item-with-icon-trailing-spacing: 10px,
|
|
item-spacing: 10px,
|
|
divider-color: var(--app-separator),
|
|
divider-top-spacing: 4px,
|
|
divider-bottom-spacing: 4px,
|
|
)
|
|
);
|
|
min-width: 240px;
|
|
border: 1px solid var(--app-line-strong);
|
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
|
|
|
.mat-mdc-menu-content {
|
|
padding: 6px;
|
|
}
|
|
|
|
.mat-mdc-menu-item {
|
|
min-height: 36px;
|
|
border-radius: 6px;
|
|
|
|
.mat-icon {
|
|
font-size: 16px;
|
|
line-height: 16px;
|
|
}
|
|
}
|
|
|
|
// The label span fills the row, so a trailing keyboard hint can sit at
|
|
// its end.
|
|
.mat-mdc-menu-item-text {
|
|
display: flex;
|
|
flex: 1;
|
|
align-items: center;
|
|
}
|
|
|
|
.mat-mdc-menu-item[aria-checked='true'] {
|
|
background: var(--app-list-selected-bg);
|
|
color: var(--app-list-selected-fg);
|
|
|
|
.mat-icon {
|
|
color: inherit;
|
|
}
|
|
}
|
|
|
|
.app-menu-item--danger,
|
|
.app-menu-item--danger .mat-icon {
|
|
color: var(--app-danger-text);
|
|
}
|
|
|
|
.app-menu-item__kbd {
|
|
margin-inline-start: auto;
|
|
padding-inline-start: 16px;
|
|
font-family: var(--app-font-mono);
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
color: var(--app-muted-color);
|
|
}
|
|
}
|
|
|
|
/// `mat-button-toggle-group` as a segmented control: a recessed track, no
|
|
/// dividers, the chosen segment filled.
|
|
@mixin segmented-control($height: 30px) {
|
|
@include mat.button-toggle-overrides(
|
|
(
|
|
height: $height,
|
|
shape: 10px,
|
|
divider-color: transparent,
|
|
background-color: transparent,
|
|
text-color: var(--app-muted-color),
|
|
selected-state-background-color: color-mix(
|
|
in srgb,
|
|
var(--app-on-surface) 10%,
|
|
transparent
|
|
),
|
|
selected-state-text-color: var(--app-heading-color),
|
|
state-layer-color: var(--app-on-surface),
|
|
hover-state-layer-opacity: 0.06,
|
|
focus-state-layer-opacity: 0.06,
|
|
label-text-size: 12.5px,
|
|
label-text-weight: 500,
|
|
label-text-tracking: 0,
|
|
)
|
|
);
|
|
display: inline-flex;
|
|
gap: 1px;
|
|
padding: 2px;
|
|
border: 1px solid var(--app-line-strong);
|
|
background: var(--app-content-bg);
|
|
|
|
.mat-button-toggle {
|
|
border-radius: 8px;
|
|
border: 0;
|
|
}
|
|
|
|
// The group already draws its frame; Material adds a left border per
|
|
// toggle as the divider.
|
|
.mat-button-toggle + .mat-button-toggle {
|
|
border-inline-start: 0;
|
|
}
|
|
|
|
.mat-button-toggle-label-content {
|
|
padding: 0 10px;
|
|
}
|
|
|
|
.mat-pseudo-checkbox {
|
|
display: none;
|
|
}
|
|
}
|