blob: 730f7cd355ed4a2a88f1a6a19e48f305a9157200 [file]
import 'dart:async';
import 'package:jaspr/dom.dart';
import 'package:jaspr/jaspr.dart';
import '../../../utils/styles.dart';
/// The visual variant of the dialog, which determines the accent color
/// used for the title bar indicator and the primary button.
enum DialogVariant {
/// Neutral informational dialog (blue accent).
info,
/// Confirmation dialog for normal actions (blue accent).
confirm,
/// Warning / destructive dialog (error-red accent).
warn,
}
/// A single action button shown in the dialog footer.
class DialogAction {
const DialogAction({
required this.label,
required this.onPressed,
this.primary = false,
this.destructive = false,
});
/// Button label text.
final String label;
/// Callback when pressed.
final FutureOr<void> Function() onPressed;
/// Whether this is the primary (filled) button.
final bool primary;
/// Whether this button uses the destructive (red) style.
/// Only applied when [primary] is also true.
final bool destructive;
}
/// A modal dialog overlay used throughout the application.
///
/// ```dart
/// AppDialog(
/// variant: DialogVariant.confirm,
/// title: 'Confirm Deletion',
/// body: [p([.text('Are you sure?')])],
/// actions: [
/// DialogAction(label: 'Delete', onPressed: _delete, primary: true, destructive: true),
/// DialogAction(label: 'Cancel', onPressed: _cancel),
/// ],
/// )
/// ```
class AppDialog extends StatelessComponent {
const AppDialog({
required this.title,
required this.actions,
this.variant = DialogVariant.info,
this.body = const [],
this.width,
super.key,
});
/// The dialog variant controlling accent color.
final DialogVariant variant;
/// The title displayed at the top of the dialog.
final String title;
/// Optional body content between the title and actions.
final List<Component> body;
/// The action buttons displayed at the bottom.
final List<DialogAction> actions;
/// Optional custom width for the dialog. Defaults to 380px via CSS.
final Unit? width;
@override
Component build(BuildContext context) {
final variantClass = switch (variant) {
DialogVariant.info => 'variant-info',
DialogVariant.confirm => 'variant-confirm',
DialogVariant.warn => 'variant-warn',
};
return div(classes: 'app-dialog-overlay', [
div(
classes: 'app-dialog $variantClass',
styles: width != null ? Styles(width: width) : null,
[
// Title
h3(classes: 'app-dialog-title', [.text(title)]),
// Body
...body,
// Actions
if (actions.isNotEmpty)
div(
classes: 'app-dialog-actions${actions.length >= 3 ? ' spread' : ''}',
[
for (final action in actions)
button(
classes: [
'app-dialog-btn',
if (action.primary && action.destructive)
'destructive'
else if (action.primary)
'primary',
].join(' '),
onClick: action.onPressed,
[.text(action.label)],
),
],
),
]),
]);
}
@css
static List<StyleRule> get styles => [
css('.app-dialog-overlay').styles(
display: .flex,
position: .fixed(top: 0.px, left: 0.px, right: 0.px, bottom: 0.px),
zIndex: const ZIndex(1000),
backdropFilter: .blur(6.px),
justifyContent: .center,
alignItems: .center,
backgroundColor: Colors.black.withOpacity(0.55),
),
css('.app-dialog', [
css('&').styles(
display: .flex,
width: 380.px,
maxWidth: 90.vw,
padding: .all(20.px),
border: .all(color: colorBorder, width: 1.px),
radius: .circular(10.px),
shadow: BoxShadow(offsetX: 0.px, offsetY: 12.px, blur: 32.px, color: Colors.black.withOpacity(0.6)),
flexDirection: .column,
gap: .all(14.px),
backgroundColor: colorContainer,
),
// Top accent bar
css('&::before').styles(
display: .block,
height: 3.px,
margin: .only(bottom: 2.px),
radius: .circular(2.px),
raw: {'content': '""'},
),
css('&.variant-info::before, &.variant-confirm::before').styles(
backgroundColor: colorPrimary,
),
css('&.variant-warn::before').styles(
backgroundColor: colorError,
),
]),
// Title
css('.app-dialog-title').styles(
margin: .zero,
color: colorOnSurface,
fontSize: 15.px,
fontWeight: .w600,
),
// Body text helper — consumers can use this class on <p> elements.
css('.app-dialog-body').styles(
margin: .zero,
color: colorOnSurfaceVariant,
fontSize: 13.px,
lineHeight: 1.5.em,
),
// Scrollable list helper — consumers can wrap list items in this.
css('.app-dialog-list', [
css('&').styles(
display: .flex,
maxHeight: 300.px,
flexDirection: .column,
gap: .all(4.px),
raw: {'overflow-y': 'auto'},
),
css('& > button', [
css('&').styles(
display: .flex,
padding: .symmetric(vertical: 8.px, horizontal: 10.px),
border: .all(color: colorBorder, width: 1.px),
radius: .circular(6.px),
cursor: .pointer,
justifyContent: .spaceBetween,
alignItems: .center,
gap: .all(12.px),
color: colorOnSurface,
fontSize: 13.px,
fontWeight: .w500,
backgroundColor: colorContainerLow,
),
css('&:hover').styles(
backgroundColor: colorContainerHigh,
),
]),
]),
// List item sub-labels (date, size, etc.).
css('.app-dialog-list-sublabel').styles(
color: colorOnSurfaceVariant,
fontSize: 12.px,
whiteSpace: .noWrap,
),
// Actions bar
css('.app-dialog-actions', [
css('&').styles(
display: .flex,
justifyContent: .end,
gap: .all(8.px),
),
css('&.spread').styles(
justifyContent: .spaceBetween,
),
]),
// Buttons
css('.app-dialog-btn', [
css('&').styles(
minWidth: 84.px,
padding: .symmetric(vertical: 8.px, horizontal: 12.px),
border: .all(color: colorBorder, width: 1.px),
radius: .circular(6.px),
cursor: .pointer,
transition: Transition.combine([
Transition('background-color', duration: 150.ms, curve: .ease),
Transition('border-color', duration: 150.ms, curve: .ease),
]),
color: colorOnSurface,
fontSize: 13.px,
fontWeight: .w500,
backgroundColor: colorContainerHigh,
),
css('&:hover').styles(
backgroundColor: colorContainer,
),
css('&.primary').styles(
border: .all(color: colorPrimary, width: 1.px),
color: colorOnPrimary,
backgroundColor: colorPrimary,
),
css('&.primary:hover').styles(
raw: {'filter': 'brightness(1.15)'},
),
css('&.destructive').styles(
border: .all(color: colorError, width: 1.px),
color: colorOnPrimary,
backgroundColor: colorError,
),
css('&.destructive:hover').styles(
raw: {'filter': 'brightness(1.15)'},
),
]),
];
}