Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/components/accordion.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Widget-state variants (`onHovered`, `onPressed`, `onFocused`, `onFocusVisible`,
`onDisabled`) describe interaction with the **trigger**, but may style any part
of the item, including the container. The expanded content is not a separate
hover or press target — moving the pointer into it clears the hovered state.
Widgets you place in `child` do not inherit the trigger's widget states.

<Warning>
The top-level Box shorthand on `AccordionStyler` (`.color()`, `.padding()`,
Expand Down
7 changes: 6 additions & 1 deletion docs/components/data_table.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,9 @@ Required. Builds this column's cell for one row.

Exactly one is required. `label` renders with the table's header typography;
`header` renders arbitrary content and additionally requires `semanticLabel`.
Content of a header that is not sortable resolves widget-state variants against
an empty state, so a plain Mix widget there does not react to its own hover or
press. Wrap it in its own `Pressable` if it should.

#### `semanticLabel` → `String?`

Expand Down Expand Up @@ -437,7 +440,9 @@ Optional. Whether activating the header emits a new sort descriptor.

Sets the row chrome painted behind every cell of the header row, of a body
row, and of the final body row. `bodyRow` variants such as `onHovered` and
`onSelected` resolve against that row's own state.
`onSelected` resolve against that row's own state. Header regions resolve
against a sortable header's own state; a header that is not sortable matches
no widget-state variant, even inside a focused or hovered ancestor.

#### `headerCell(BoxStyler value)` / `bodyCell(BoxStyler value)` / `selectionCell(BoxStyler value)`

Expand Down
3 changes: 2 additions & 1 deletion docs/components/disclosure.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ and `borderRadius` to the trigger. Style the other parts explicitly:
- `containerEffects(...)` — shadows, strokes, backdrop blur
- `trigger(...)` / `content(...)`
- `onExpanded(...)` / `onCollapsed(...)`
- `onHovered`, `onPressed`, `onFocused`, `onFocusVisible`, `onDisabled`
- `onHovered`, `onPressed`, `onFocused`, `onFocusVisible`, `onDisabled`;
these describe the trigger, so widgets in `content` do not inherit them

Default transition is a 200ms top-anchored fade and size. Use
`AnimationStyle.noAnimation` to skip it.
Expand Down
17 changes: 17 additions & 0 deletions packages/remix/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
## Unreleased

### Fixes

- Data-table headers without `sortable`, including the selection header, no
longer read widget state from outside the table. A table inside a focused,
hovered, or pressed ancestor, such as a `Pressable`, no longer lights their
`onFocusVisible`, `onHovered`, or `onPressed` styles. Sortable headers still
resolve against their own state. Content of a non-sortable header's custom
`header` resolves widget-state variants against an empty state, so wrap a
widget in its own `Pressable` if it should react to its own hover or press.
- Accordion and disclosure content no longer resolves widget-state variants
against the trigger, so widgets in the panel no longer change with the
trigger's state.
- `RemixSidebar` header and footer content no longer resolves `onDisabled`
variants when the sidebar is disabled. The sidebar's own regions still do.

## 1.0.0-beta.13

### Breaking changes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,9 @@ class _RemixAccordionBodyState<T> extends State<_RemixAccordionBody<T>> {
// whether or not the caller asked to observe presses.
onPressChange: _handlePressChange,
semanticLabel: _config.semanticLabel ?? _config.title,
child: _config.child,
// The panel is mounted inside the trigger's state scope, which belongs
// to the trigger alone.
child: WidgetStateProvider(states: const {}, child: _config.child),
builder:
_config.builder ??
(context, state) => _buildDefaultTrigger(spec, state),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ part of 'data_table.dart';
/// region: `bodyRow(BoxStyler().onSelected(...))` already expresses it, and it
/// additionally composes with hover, focus, and press.
///
/// A header that is not sortable has no state of its own. Its regions and any
/// custom [RemixDataTableColumn.header] content resolve those variants against
/// an empty state, whatever state encloses the table.
///
/// ## Composed controls
///
/// [selectionCheckbox], [pageButton], and [pageSizeSelect] deliberately hold
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,10 @@ class _DataTableHeaderCell extends StatelessWidget {
onPress: onSort,
child: cell,
);
} else {
// A static header has no state of its own. Without this scope its
// regions would resolve against whatever encloses the table.
cell = WidgetStateProvider(states: const {}, child: cell);
}

final owned = semanticLabel != null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ final class _RemixDisclosureContent extends StatelessWidget {
Widget build(BuildContext context) {
return Box(
styleSpec: _RemixDisclosureStyleScope.of(context).content,
child: child,
// The panel is mounted inside the trigger's state scope, which belongs
// to the trigger alone.
child: WidgetStateProvider(states: const {}, child: child),
);
}
}
Expand Down
19 changes: 17 additions & 2 deletions packages/remix/lib/src/components/sidebar/sidebar_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,16 @@ class RemixSidebar<T extends Object> extends StatefulWidget {
Semantics(
container: true,
explicitChildNodes: true,
child: Box(styleSpec: spec.header, child: header),
child: Box(
styleSpec: spec.header,
// The scope above belongs to the sidebar's own
// regions; header and footer content is not disabled
// with the destinations.
child: WidgetStateProvider(
states: const {},
child: header,
),
),
),
if (hasBoundedHeight)
Expanded(child: SingleChildScrollView(child: landmark))
Expand All @@ -470,7 +479,13 @@ class RemixSidebar<T extends Object> extends StatefulWidget {
Semantics(
container: true,
explicitChildNodes: true,
child: Box(styleSpec: spec.footer, child: footer),
child: Box(
styleSpec: spec.footer,
child: WidgetStateProvider(
states: const {},
child: footer,
),
),
),
],
);
Expand Down
108 changes: 106 additions & 2 deletions packages/remix/test/components/accordion/accordion_widget_test.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show LogicalKeyboardKey;
import 'package:flutter_test/flutter_test.dart';
import 'package:naked_ui/naked_ui.dart';
import 'package:remix/remix.dart';
Expand All @@ -7,8 +8,9 @@ import 'package:remix/src/utilities/remix_path_icon.dart';
import '../../helpers/test_helpers.dart';
import '../../helpers/test_methods.dart';

/// The single coloured [Box] in the tree is the outer panel: every test that
/// uses this styles `container(...)` and nothing else that paints a fill.
/// The single [Box] with a resolved fill: every test that uses this colours one
/// region, usually `container(...)`. A [Box] built from a `style:` carries no
/// resolved spec, so probes placed in the content are not counted.
Color? _panelColor(WidgetTester tester) {
return tester
.widgetList<Box>(find.byType(Box))
Expand All @@ -17,6 +19,20 @@ Color? _panelColor(WidgetTester tester) {
.single;
}

const _probeKey = ValueKey('expanded-content-probe');

/// The resolved fill of the [Box] a test places in the expanded content.
Color? _probeColor(WidgetTester tester) {
final container = tester.widget<Container>(
find.descendant(
of: find.byKey(_probeKey),
matching: find.byType(Container),
),
);

return (container.decoration as BoxDecoration?)?.color;
}

void main() {
group('RemixAccordionGroup', () {
group('Basic Rendering', () {
Expand Down Expand Up @@ -597,6 +613,63 @@ void main() {

expect(_panelColor(tester), Colors.blue);
});

final triggerActivations = <String, Future<void> Function(WidgetTester)>{
'hover': (tester) async {
final gesture = await tester.createGesture(kind: .mouse);
await gesture.addPointer(location: Offset.zero);
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.text('Test Title')));
},
'press': (tester) async {
final gesture = await tester.startGesture(
tester.getCenter(find.text('Test Title')),
);
addTearDown(gesture.up);
},
'keyboard focus': (tester) =>
tester.sendKeyEvent(LogicalKeyboardKey.tab),
};

for (final MapEntry(key: name, value: activate)
in triggerActivations.entries) {
testWidgets('trigger $name styles the trigger, not widgets in the '
'expanded content', (tester) async {
await tester.pumpRemixApp(
RemixAccordionGroup<String>(
controller: RemixAccordionController<String>(),
initialExpandedValues: const ['item1'],
child: RemixAccordion<String>(
value: 'item1',
title: 'Test Title',
style: AccordionStyler()
.container(.color(Colors.blue))
.onHovered(.container(.color(Colors.green)))
.onPressed(.container(.color(Colors.green)))
.onFocusVisible(.container(.color(Colors.green))),
child: SizedBox.square(
dimension: 10,
child: Box(
key: _probeKey,
style: BoxStyler()
.color(Colors.grey)
.onHovered(.color(Colors.red))
.onPressed(.color(Colors.red))
.onFocusVisible(.color(Colors.red)),
),
),
),
),
);
await tester.pumpAndSettle();

await activate(tester);
await tester.pumpAndSettle();

expect(_panelColor(tester), Colors.green);
expect(_probeColor(tester), Colors.grey);
});
}
});

group('Disabled State', () {
Expand Down Expand Up @@ -625,6 +698,37 @@ void main() {
// Content should still not be visible
expect(find.text('Content'), findsNothing);
});

testWidgets('expanded content takes onDisabled from its own region, not '
'from the disabled item', (tester) async {
await tester.pumpRemixApp(
RemixAccordionGroup<String>(
controller: RemixAccordionController<String>(),
initialExpandedValues: const ['item1'],
child: RemixAccordion<String>(
value: 'item1',
title: 'Disabled Item',
enabled: false,
style: AccordionStyler()
.content(.color(Colors.blue))
.onDisabled(.content(.color(Colors.green))),
child: SizedBox.square(
dimension: 10,
child: Box(
key: _probeKey,
style: BoxStyler()
.color(Colors.grey)
.onDisabled(.color(Colors.red)),
),
),
),
),
);
await tester.pumpAndSettle();

expect(_panelColor(tester), Colors.green);
expect(_probeColor(tester), Colors.grey);
});
});

group('Custom Builder', () {
Expand Down
Loading
Loading