| 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)'}, |
| ), |
| ]), |
| ]; |
| } |