Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions lib/buttons/linagora_button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,44 @@ class LinagoraButton extends StatelessWidget {
final LinagoraButtonSize size;
final LinagoraButtonVariant variant;

/// Visual properties that override the variant and size defaults.
final ButtonStyle? style;

/// Space between [icon] and [label].
final double iconSpacing;

const LinagoraButton({
super.key,
required this.label,
required this.onPressed,
this.icon,
this.size = LinagoraButtonSize.m,
this.variant = LinagoraButtonVariant.filled,
});
this.style,
this.iconSpacing = LinagoraSpacing.base,
}) : assert(iconSpacing >= 0, 'Icon spacing cannot be negative');

@override
Widget build(BuildContext context) {
final style = _buildStyle(context);
final defaultStyle = _buildStyle(context);
// Caller values take precedence over the defaults.
final buttonStyle = style?.merge(defaultStyle) ?? defaultStyle;
final child = _buildChild();

return switch (variant) {
LinagoraButtonVariant.filled => FilledButton(
onPressed: onPressed,
style: style,
style: buttonStyle,
child: child,
),
LinagoraButtonVariant.outlined => OutlinedButton(
onPressed: onPressed,
style: style,
style: buttonStyle,
child: child,
),
LinagoraButtonVariant.text => TextButton(
onPressed: onPressed,
style: style,
style: buttonStyle,
child: child,
),
};
Expand All @@ -56,7 +66,7 @@ class LinagoraButton extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon),
const SizedBox(width: LinagoraSpacing.base),
SizedBox(width: iconSpacing),
Flexible(child: text),
],
);
Expand Down
3 changes: 3 additions & 0 deletions lib/linagora_design_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,11 @@ export 'package:linagora_design_flutter/banners/linagora_banner.dart';
export 'package:linagora_design_flutter/text_field/linagora_text_field.dart';
export 'package:linagora_design_flutter/text_field/linagora_text_field_variant.dart';
export 'package:linagora_design_flutter/sidebar/linagora_sidebar_badge.dart';
export 'package:linagora_design_flutter/sidebar/linagora_sidebar_button_styles.dart';
export 'package:linagora_design_flutter/sidebar/linagora_sidebar_control.dart';
export 'package:linagora_design_flutter/sidebar/linagora_sidebar_indent.dart';
export 'package:linagora_design_flutter/sidebar/linagora_sidebar_item.dart';
export 'package:linagora_design_flutter/sidebar/linagora_sidebar_section_header.dart';
export 'package:linagora_design_flutter/sidebar/linagora_sidebar_style.dart';
export 'package:linagora_design_flutter/sidebar/linagora_sidebar_sub_item.dart';
export 'package:linagora_design_flutter/sidebar/linagora_sidebar_tree_list.dart';
78 changes: 78 additions & 0 deletions lib/sidebar/linagora_sidebar_button_styles.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:linagora_design_flutter/buttons/linagora_button.dart';
import 'package:linagora_design_flutter/sidebar/linagora_sidebar_style.dart';
import 'package:linagora_design_flutter/spacings/linagora_spacing.dart';
import 'package:linagora_design_flutter/style/linagora_text_theme.dart';

/// Ready-made [ButtonStyle]s for sidebar navigation actions.
abstract final class LinagoraSidebarButtonStyles {
/// The primary sidebar action — Compose, Create, or New.
static ButtonStyle primaryAction(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final sidebar = LinagoraSidebarStyle.of(context);
final isDark = theme.brightness == Brightness.dark;
final background = isDark ? sidebar.activeForeground : colorScheme.primary;
final foreground = isDark ? _darkForeground : colorScheme.onPrimary;

return ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.disabled)
? colorScheme.onSurface.withValues(alpha: _disabledContainerOpacity)
: background,
),
foregroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.disabled)
? colorScheme.onSurface.withValues(alpha: _disabledContentOpacity)
: foreground,
),
overlayColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) return null;
if (states.contains(WidgetState.pressed)) {
return foreground.withValues(alpha: _pressedOverlayOpacity);
}
if (states.contains(WidgetState.hovered)) {
return foreground.withValues(alpha: _hoverOverlayOpacity);
}
if (states.contains(WidgetState.focused)) {
return foreground.withValues(alpha: _pressedOverlayOpacity);
}
return null;
}),
iconSize: const WidgetStatePropertyAll(_iconSize),
minimumSize: const WidgetStatePropertyAll(Size(0, _minHeight)),
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(
horizontal: LinagoraSpacing.base * 2,
vertical: LinagoraSpacing.base,
),
),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_borderRadius),
),
),
textStyle: WidgetStatePropertyAll(_primaryActionTextStyle()),
);
}

/// Gap between the glyph and label of a [primaryAction] [LinagoraButton].
static const double primaryActionIconSpacing = 7;

static const double _minHeight = 40;
static const double _borderRadius = 12;
static const double _iconSize = 12;
static const Color _darkForeground = Color(0xE61D212A);
static const double _disabledContainerOpacity = 0.12;
static const double _disabledContentOpacity = 0.38;
static const double _hoverOverlayOpacity = 0.08;
static const double _pressedOverlayOpacity = 0.12;
static const double _lineHeight = 18.4;

static TextStyle? _primaryActionTextStyle() {
final base = LinagoraTextTheme.material().bodyMedium;
final fontSize = base?.fontSize;
if (fontSize == null) return base;
return base?.copyWith(height: _lineHeight / fontSize);
}
}
84 changes: 84 additions & 0 deletions lib/sidebar/linagora_sidebar_control.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';

/// Icon control shared by sidebar rows and section headers.
///
/// Decorative icons keep their glyph size; interactive ones use [tapTarget]
/// unless a component supplies a smaller [targetSize].
class LinagoraSidebarControl extends StatelessWidget {
/// Compact target that fits within a sidebar row.
static const double tapTarget = 24;

/// Extra width the target extends past a glyph on each side.
static double overhang(double iconSize, {double targetSize = tapTarget}) =>
(targetSize - iconSize) / 2;

/// Disclosure glyph for an [expanded] state.
static IconData disclosureIcon(bool expanded) =>
expanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_right;

const LinagoraSidebarControl({
super.key,
required this.icon,
required this.iconSize,
this.color,
this.onTap,
this.semanticLabel,
this.expanded,
this.targetSize,
}) : assert(
onTap == null || semanticLabel != null,
'An interactive sidebar control needs semanticLabel for screen readers',
),
assert(
targetSize == null || targetSize >= iconSize,
'A control target cannot be smaller than its icon',
);

final IconData icon;

final double iconSize;

/// Null uses the ambient [IconTheme].
final Color? color;

final VoidCallback? onTap;

/// Localized accessible name for an interactive control.
///
/// This configures [Semantics] only. Callers that need a hover tooltip add it
/// around the control.
final String? semanticLabel;

/// Expanded state announced by a disclosure control.
final bool? expanded;

/// The interactive control's square size. Defaults to [tapTarget].
///
/// Use the glyph size for tightly packed controls whose Figma specification
/// does not reserve additional tap-target padding.
final double? targetSize;

@override
Widget build(BuildContext context) {
final glyph = Icon(icon, size: iconSize, color: color);
if (onTap == null) return glyph;
final resolvedTargetSize = targetSize ?? tapTarget;

return Semantics(
button: true,
label: semanticLabel,
expanded: expanded,
child: InkResponse(
onTap: onTap,
containedInkWell: true,
highlightShape: BoxShape.circle,
radius: resolvedTargetSize / 2,
customBorder: const CircleBorder(),
child: SizedBox.square(
dimension: resolvedTargetSize,
child: Center(child: glyph),
),
),
);
}
}
1 change: 1 addition & 0 deletions lib/sidebar/linagora_sidebar_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:linagora_design_flutter/behaviors/right_click_focus.dart';
import 'package:linagora_design_flutter/sidebar/linagora_sidebar_badge.dart';
import 'package:linagora_design_flutter/sidebar/linagora_sidebar_control.dart';
import 'package:linagora_design_flutter/sidebar/linagora_sidebar_indent.dart';
import 'package:linagora_design_flutter/sidebar/linagora_sidebar_style.dart';

Expand Down
71 changes: 14 additions & 57 deletions lib/sidebar/linagora_sidebar_item_content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ class _SidebarItemLabel extends StatelessWidget {
final LinagoraSidebarStyle style;
final Color foregroundColor;

/// How far a tappable chevron's box extends past its glyph on each side. A
/// decorative one is a bare glyph and overhangs nothing.
double get _chevronOverhang {
if (item.onExpandToggle == null || !item.enabled) return 0;
return LinagoraSidebarControl.overhang(style.chevronSize);
}

@override
Widget build(BuildContext context) {
final expanded = item.expanded;
Expand All @@ -158,17 +165,16 @@ class _SidebarItemLabel extends StatelessWidget {
// [itemSpacing]. Clamped: a style with a spacing smaller than the
// overhang would otherwise ask for a negative width.
SizedBox(
width: math.max(
0,
style.itemSpacing - _SidebarItemChevron.overhang(item, style),
),
width: math.max(0, style.itemSpacing - _chevronOverhang),
),
_SidebarItemChevron(
expanded: expanded,
LinagoraSidebarControl(
icon: LinagoraSidebarControl.disclosureIcon(expanded),
iconSize: style.chevronSize,
color: style.trailingForeground,
size: style.chevronSize,
onToggle: item.enabled ? item.onExpandToggle : null,
onTap: item.enabled ? item.onExpandToggle : null,
semanticLabel: item.expandToggleLabel,
// The row already publishes expansion, so the toggle stays quiet
// about it rather than announcing the same state twice.
),
],
],
Expand Down Expand Up @@ -198,55 +204,6 @@ class _SidebarItemLeading extends StatelessWidget {
}
}

class _SidebarItemChevron extends StatelessWidget {
const _SidebarItemChevron({
required this.expanded,
required this.color,
required this.size,
required this.onToggle,
required this.semanticLabel,
});

/// Touch target for the tappable chevron. The glyph stays [size]; this is
/// the box around it. Kept under the row height so it cannot stretch a row.
static const double _tapTarget = 24;

/// How far a tappable chevron's box extends past its glyph on each side.
static double overhang(LinagoraSidebarItem item, LinagoraSidebarStyle style) {
if (item.onExpandToggle == null || !item.enabled) return 0;
return (_tapTarget - style.chevronSize) / 2;
}

final bool expanded;
final Color color;
final double size;
final VoidCallback? onToggle;
final String? semanticLabel;

@override
Widget build(BuildContext context) {
final icon = Icon(
expanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_right,
size: size,
color: color,
);
if (onToggle == null) return icon;

return Semantics(
button: true,
label: semanticLabel,
child: InkResponse(
onTap: onToggle,
customBorder: const CircleBorder(),
child: SizedBox.square(
dimension: _tapTarget,
child: Center(child: icon),
),
),
);
}
}

class _SidebarItemTrailing extends StatelessWidget {
const _SidebarItemTrailing({required this.style, required this.child});

Expand Down
Loading