diff --git a/AGENTS.md b/AGENTS.md index f214d3f..6c33f00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,11 +88,12 @@ Status legend: ⬜ Todo · ✅ Done | 21 | Code block | built-in synchronous highlighter; languages host-extensible | ✅ | | 22 | Thinking indicator | turning, breathing asterisk + shimmer label; active & settled | ✅ | | 23 | Shimmer | text only; sweeping highlight, static when settled | ✅ | +| 24 | Pill | removable tool/mode pill for the composer's action row; label auto-drops on phones | ✅ | ### Surfaces | # | Component | Variants / notes | Status | |---|-----------|------------------|--------| -| 24 | Chat View | centred 760 rail; zero state (greeting, lifted composer, starters); jump to latest | ✅ | -| 25 | SidePanel | | ⬜ | -| 26 | Modal | | ⬜ | +| 25 | Chat View | centred 760 rail; zero state (greeting, lifted composer, starters); jump to latest | ✅ | +| 26 | SidePanel | | ⬜ | +| 27 | Modal | | ⬜ | diff --git a/CHANGELOG.md b/CHANGELOG.md index 4acb7b2..3991096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ retry pill) and a `FlowErrorPart` message part, with `onRetry`/`errorTitle`/`retryLabel` threaded through `FlowMessage` and `FlowThread`. +- **Pill** — `FlowPill`, a removable pill showing an enabled tool or mode + in the composer's action row: host-passed icon, label and tooltips, + removal intent on `onRemove`, and a label that auto-drops to the + icon-only form on phones (`showLabel` forces either). - **Breaking**: `FlowChatScreen` is renamed to `FlowChatView`. The widget was never a screen — it is body-only and embeddable, and upcoming surfaces (side panel, modal) will host it — so the name now follows diff --git a/CLAUDE.md b/CLAUDE.md index 94a88f0..83fc7fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,11 +90,12 @@ Values come from the Flow UI Figma file. Role names follow Material 3's `ColorSc | 21 | Code block | built-in synchronous highlighter; languages host-extensible | ✅ | | 22 | Thinking indicator | turning, breathing asterisk + shimmer label; active & settled | ✅ | | 23 | Shimmer | text only; sweeping highlight, static when settled | ✅ | +| 24 | Pill | removable tool/mode pill for the composer's action row; label auto-drops on phones | ✅ | ### Surfaces | # | Component | Variants / notes | Status | |---|-----------|------------------|--------| -| 24 | Chat View | centred 760 rail; zero state (greeting, lifted composer, starters); jump to latest | ✅ | -| 25 | SidePanel | | ⬜ | -| 26 | Modal | | ⬜ | +| 25 | Chat View | centred 760 rail; zero state (greeting, lifted composer, starters); jump to latest | ✅ | +| 26 | SidePanel | | ⬜ | +| 27 | Modal | | ⬜ | diff --git a/README.md b/README.md index 81f1ab9..c82b3f5 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ | [`FlowComposer`](https://flowui.stac.dev/components/composer) | Multiline input with send/stop, attachments strip, and leading/trailing action slots | | [`FlowMenu`](https://flowui.stac.dev/components/menu) | Icon-triggered menu with groups, submenus, and toggles — anchored card on desktop, bottom sheet on phones | | [`FlowModelSelector`](https://flowui.stac.dev/components/model-selector) | Model picker with effort and overflow submenus, sheet on phones | +| [`FlowPill`](https://flowui.stac.dev/components/pill) | Removable pill for an enabled tool or mode in the composer's action row — label auto-drops on phones | | [`FlowAttachmentGroup`](https://flowui.stac.dev/components/attachments) | Image and file tiles with a type pill | | [`FlowAttachmentPreview`](https://flowui.stac.dev/components/attachments) | Full-screen image viewer with zoom and paging | | [`FlowSuggestion`](https://flowui.stac.dev/components/suggestions) / [`FlowSuggestionGroup`](https://flowui.stac.dev/components/suggestions) | Prompt starters — plain or outlined; scroll, wrap, or column layouts | diff --git a/docs/src/content/docs/components/pill.mdx b/docs/src/content/docs/components/pill.mdx new file mode 100644 index 0000000..3ce2414 --- /dev/null +++ b/docs/src/content/docs/components/pill.mdx @@ -0,0 +1,97 @@ +--- +title: Pill +description: A removable pill for an enabled tool or mode — icon, label, and an X that reports removal. +sidebar: + order: 15 +--- + +import FlowDemo from '../../../components/FlowDemo.astro'; + +`FlowPill` is the composer's tool token: while the host has Research or +Web Search switched on, a pill says so — an icon and label on the faint +fill, with an X that reports removal through `onRemove`. Presence *is* +the state: render a pill while its tool is on, and actually turning the +tool off is the host's move. The package ships no strings, so `label` +and both tooltips are host-localized. + +## Removable + + + +```dart title="A pill per enabled tool" +FlowComposer( + onSend: send, + leadingActions: [ + FlowMenu(...), + if (researchOn) + FlowPill( + icon: Icons.school_outlined, + label: 'Research', + removeTooltip: 'Turn off Research', + onRemove: () => setResearch(false), + ), + ], +) +``` + +Pass a `removeTooltip` whenever `onRemove` is set — it is the X's +tooltip *and* its accessible name, so without one assistive tech +announces an unnamed button. + +## Icon-only on phones + +On iOS and Android the label drops away to the design's compact form — +icon and X alone. The check reads the theme's platform rather than the +real one, like the menus' sheet resolution, so hosts and tests can steer +it without a device. `showLabel` forces either form outright; a host +forcing icon-only on a hovering device can pass `tooltip` so the tool's +name survives as a hover: + + + +```dart title="Forcing the compact form" +FlowPill( + icon: Icons.language, + label: 'Web Search', // still the accessible name + showLabel: false, + tooltip: 'Web Search', + removeTooltip: 'Turn off Web Search', + onRemove: () => setWebSearch(false), +) +``` + +## Static and disabled + +A pill without `onRemove` is a static status token in full ink — not a +disabled control, which is a deliberate difference from the suggestion +row, where a null tap reads disabled. Disabling here is explicit: +`enabled: false` fades the ink and inerts the targets, keeping the fill +and hairline in place: + + + +## In the composer + +The pill is drawn 32 tall to sit flush in `FlowComposer.leadingActions`, +next to the add menu that toggles it. The action row doesn't scroll, so +keep concurrent pills few: + + + +## Key API + +- `icon` — always drawn; the pill's whole identity in the icon-only + form. +- `label` — always the pill's accessible name, painted only in the + labeled form. +- `onRemove` — removal intent from the X; null leaves the X off + entirely. +- `onTap` — optional tap on the pill's body, e.g. reopening the tool's + options; null leaves the body inert. +- `removeTooltip`, `tooltip` — host-localized; the X's name, and the + body's hover. +- `showLabel` — null resolves by platform; `true`/`false` forces. +- `enabled` — explicit disabling; static and disabled are different + states. +- `padding` (the design's 8 horizontally) and `borderRadius` (8) + override the metrics. diff --git a/docs/src/content/docs/roadmap.md b/docs/src/content/docs/roadmap.md index 3d7b0de..4e9e693 100644 --- a/docs/src/content/docs/roadmap.md +++ b/docs/src/content/docs/roadmap.md @@ -43,6 +43,7 @@ elements and the remaining AI states are on the way. | Code block | Shipped | | Thinking indicator | Shipped | | Shimmer | Shipped | +| Pill | Shipped | ## Surfaces diff --git a/lib/flow_ui.dart b/lib/flow_ui.dart index 5e5cc9f..7fc8765 100644 --- a/lib/flow_ui.dart +++ b/lib/flow_ui.dart @@ -25,6 +25,7 @@ export 'src/widgets/flow_menu_style.dart'; export 'src/widgets/flow_message.dart'; export 'src/widgets/flow_model_selector.dart'; export 'src/widgets/flow_message_actions.dart'; +export 'src/widgets/flow_pill.dart'; export 'src/widgets/flow_shimmer_text.dart'; export 'src/widgets/flow_streaming_text.dart'; export 'src/widgets/flow_suggestion.dart'; diff --git a/lib/src/widgets/flow_pill.dart b/lib/src/widgets/flow_pill.dart new file mode 100644 index 0000000..8509e46 --- /dev/null +++ b/lib/src/widgets/flow_pill.dart @@ -0,0 +1,252 @@ +import 'package:material_ui/material_ui.dart'; + +import '../theme/flow_theme.dart'; +import '../utils/flow_state_colors.dart'; + +/// A removable pill showing an enabled tool or mode — "Research", "Web +/// Search" — in the composer's action row, typically appended to +/// `FlowComposer.leadingActions` while the host's toggle is on: +/// +/// ```dart +/// if (researchOn) +/// FlowPill( +/// icon: Icons.school_outlined, +/// label: 'Research', +/// removeTooltip: 'Turn off Research', +/// onRemove: () => setResearch(false), +/// ) +/// ``` +/// +/// The X reports removal through [onRemove]; actually turning the tool off +/// is the host's move. On phones the label drops away to the design's +/// icon-only form (see [showLabel]). +/// +/// A pill without [onRemove] is a static status token in full ink, not a +/// disabled control — unlike `FlowSuggestion`, where a null tap renders +/// the row disabled. Disabling here is explicit, through [enabled]. +class FlowPill extends StatefulWidget { + const FlowPill({ + super.key, + required this.icon, + required this.label, + this.onRemove, + this.onTap, + this.removeTooltip, + this.tooltip, + this.showLabel, + this.enabled = true, + this.padding, + this.borderRadius, + }); + + /// The tool's glyph — always drawn; the pill's whole identity in the + /// icon-only form. + final IconData icon; + + /// The tool's name. Always the pill's accessible name, drawn only while + /// the labeled form is in effect (see [showLabel]). + final String label; + + /// Remove intent from the trailing X; null leaves the X off entirely — + /// a static status pill. + final VoidCallback? onRemove; + + /// Optional tap on the pill's body, e.g. reopening the tool's options. + /// Null leaves the body inert without reading disabled. + final VoidCallback? onTap; + + /// Host-localized label for the X, e.g. 'Turn off Research' — its + /// tooltip and accessible name. Pass one whenever [onRemove] is set, or + /// assistive tech announces an unnamed button. + final String? removeTooltip; + + /// Host-localized tooltip over the pill's body — the tool's name when a + /// host forces the icon-only form on a hovering device. + final String? tooltip; + + /// Whether the label is drawn. Null resolves by platform — hidden on + /// iOS and Android (the design's compact composer), shown elsewhere — + /// reading the theme's platform rather than the real one, like the + /// menus' sheet resolution, so hosts and tests can steer it without a + /// device. + final bool? showLabel; + + final bool enabled; + + /// Inside the pill. Defaults to the design's 8 horizontally. + final EdgeInsetsGeometry? padding; + + /// The pill's corner. Defaults to the design's 8. + final BorderRadius? borderRadius; + + @override + State createState() => _FlowPillState(); +} + +class _FlowPillState extends State { + /// The design's pill: 32 tall on an 8px corner, padded 8, an 18px glyph + /// 6 from its label with the 14px X another 6 along — the gap closing + /// to 4 in the icon-only form, per the compact composer. + static const double _height = 32; + static const BorderRadius _radius = BorderRadius.all(Radius.circular(8)); + static const EdgeInsetsGeometry _padding = EdgeInsets.symmetric( + horizontal: 8, + ); + static const double _iconSize = 18; + static const double _removeSize = 14; + static const double _gap = 6; + static const double _compactGap = 4; + + bool _removeHovered = false; + + bool _labelVisible(BuildContext context) { + final show = widget.showLabel; + if (show != null) return show; + final platform = Theme.of(context).platform; + return platform != TargetPlatform.iOS && platform != TargetPlatform.android; + } + + @override + Widget build(BuildContext context) { + final colors = context.flowColors; + final typography = context.flowTypography; + final enabled = widget.enabled; + final showLabel = _labelVisible(context); + final gap = showLabel ? _gap : _compactGap; + final hasRemove = widget.onRemove != null; + + // Content rests in full ink; the X a step down at the muted chrome + // level, lifting to full on hover. Disabling fades each from its own + // rest, so the X stays proportionally fainter. + final foreground = enabled + ? colors.onSurface + : flowDisabledColor(colors.onSurface); + final removeRest = enabled + ? colors.onSurfaceMuted + : flowDisabledColor(colors.onSurfaceMuted); + final removeForeground = _removeHovered && enabled + ? colors.onSurface + : removeRest; + + final shape = RoundedRectangleBorder( + borderRadius: widget.borderRadius ?? _radius, + side: BorderSide(color: colors.outlineVariant), + ); + + // The X's target spans the pill's full height and absorbs the end + // inset, so splitting the padding needs the resolved sides — start on + // the body, end inside the X — kept directional so RTL swaps them. + final direction = Directionality.of(context); + final resolved = (widget.padding ?? _padding).resolve(direction); + final startInset = direction == TextDirection.ltr + ? resolved.left + : resolved.right; + final endInset = direction == TextDirection.ltr + ? resolved.right + : resolved.left; + + Widget body = Padding( + padding: EdgeInsetsDirectional.only( + start: startInset, + end: hasRemove ? 0 : endInset, + top: resolved.top, + bottom: resolved.bottom, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(widget.icon, size: _iconSize, color: foreground), + if (showLabel) ...[ + const SizedBox(width: _gap), + Text( + widget.label, + style: typography.labelLarge.copyWith( + fontWeight: FontWeight.w500, + color: foreground, + ), + ), + ], + ], + ), + ); + + if (widget.onTap != null) { + body = InkWell( + onTap: enabled ? widget.onTap : null, + hoverColor: colors.surfaceContainerLow, + child: body, + ); + // Excluding the subtree keeps the label from reading twice, but it + // drops the InkWell's tap action with it — the node re-owns + // activation or assistive tech can announce the pill yet not tap it. + body = Semantics( + button: true, + label: widget.label, + excludeSemantics: true, + onTap: enabled ? widget.onTap : null, + child: body, + ); + } else if (!showLabel) { + // The inert icon-only body still announces the tool's name. + body = Semantics( + label: widget.label, + child: ExcludeSemantics(child: body), + ); + } + + // On the body only — the X carries its own tooltip, and covering it + // from an ancestor would raise both at once. + final tooltip = widget.tooltip; + if (tooltip != null) { + body = Tooltip(message: tooltip, child: body); + } + + Widget? remove; + if (hasRemove) { + remove = InkWell( + onTap: enabled ? widget.onRemove : null, + onHover: enabled + ? (value) => setState(() => _removeHovered = value) + : null, + hoverColor: colors.surfaceContainerLow, + child: Padding( + padding: EdgeInsetsDirectional.only( + start: gap, + end: endInset, + top: resolved.top, + bottom: resolved.bottom, + ), + child: Center( + child: Icon( + Icons.close, + size: _removeSize, + color: removeForeground, + ), + ), + ), + ); + // Tooltip already contributes the message to the semantics node, so + // it doubles as the X's accessible name — the circle button's idiom. + final removeTooltip = widget.removeTooltip; + if (removeTooltip != null) { + remove = Tooltip(message: removeTooltip, child: remove); + } + } + + return Material( + color: colors.surfaceContainerLow, + shape: shape, + clipBehavior: Clip.antiAlias, + child: SizedBox( + height: _height, + child: Row( + mainAxisSize: MainAxisSize.min, + // Stretched, so the body's ink and the X's target span the + // pill's full height; each region centres its own glyphs. + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [body, ?remove], + ), + ), + ); + } +} diff --git a/playground/lib/src/demo_registry.dart b/playground/lib/src/demo_registry.dart index 96d249f..7eceaff 100644 --- a/playground/lib/src/demo_registry.dart +++ b/playground/lib/src/demo_registry.dart @@ -10,6 +10,7 @@ import 'demos/greeting_demo.dart'; import 'demos/message_actions_demo.dart'; import 'demos/message_demo.dart'; import 'demos/model_selector_demo.dart'; +import 'demos/pill_demo.dart'; import 'demos/shimmer_text_demo.dart'; import 'demos/streaming_message_demo.dart'; import 'demos/streaming_text_demo.dart'; @@ -35,6 +36,7 @@ Widget demoFor(PlaygroundItem item, {String? variant}) { PlaygroundItem.codeBlock => CodeBlockDemo(key: key, variant: variant), PlaygroundItem.errorState => ErrorStateDemo(key: key, variant: variant), PlaygroundItem.addToChat => AddToChatDemo(key: key), + PlaygroundItem.pill => PillDemo(key: key, variant: variant), PlaygroundItem.attachments => AttachmentsDemo(key: key, variant: variant), PlaygroundItem.thread => ThreadDemo(key: key, variant: variant), PlaygroundItem.messageActions => MessageActionsDemo(key: key), @@ -84,6 +86,12 @@ List<(String, String)> variantsFor(PlaygroundItem item) { ('minimal', 'Minimal'), ('thread', 'Failed turn'), ], + PlaygroundItem.pill => const [ + ('default', 'Default'), + ('icon', 'Icon only'), + ('static', 'No remove'), + ('composer', 'In composer'), + ], PlaygroundItem.attachments => const [ ('composer', 'In composer'), ('tiles', 'Tiles only'), @@ -133,6 +141,7 @@ String snippetFor(PlaygroundItem item) { PlaygroundItem.codeBlock => codeBlockSnippet, PlaygroundItem.errorState => errorStateSnippet, PlaygroundItem.addToChat => addToChatSnippet, + PlaygroundItem.pill => pillSnippet, PlaygroundItem.attachments => attachmentsSnippet, PlaygroundItem.thread => threadSnippet, PlaygroundItem.messageActions => messageActionsSnippet, @@ -182,6 +191,13 @@ FlowChatView( entries: [...], onSelected: toggleTool, ), + if (researchOn) + FlowPill( + icon: PhosphorIconsRegular.graduationCap, + label: 'Research', + removeTooltip: 'Turn off Research', + onRemove: () => setResearch(false), + ), ], trailingActions: [ FlowModelSelector( diff --git a/playground/lib/src/demos/full_chat_demo.dart b/playground/lib/src/demos/full_chat_demo.dart index 7bfd138..0f255f3 100644 --- a/playground/lib/src/demos/full_chat_demo.dart +++ b/playground/lib/src/demos/full_chat_demo.dart @@ -189,6 +189,20 @@ class _FullChatDemoState extends State { } }, ), + if (_researchOn) + FlowPill( + icon: PhosphorIconsRegular.graduationCap, + label: 'Research', + removeTooltip: 'Turn off Research', + onRemove: () => setState(() => _researchOn = false), + ), + if (_webSearchOn) + FlowPill( + icon: PhosphorIconsRegular.globe, + label: 'Web Search', + removeTooltip: 'Turn off Web Search', + onRemove: () => setState(() => _webSearchOn = false), + ), ], trailingActions: [ FlowModelSelector( diff --git a/playground/lib/src/demos/pill_demo.dart b/playground/lib/src/demos/pill_demo.dart new file mode 100644 index 0000000..fc72bbc --- /dev/null +++ b/playground/lib/src/demos/pill_demo.dart @@ -0,0 +1,161 @@ +import 'package:flow_ui/flow_ui.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:phosphoricons_flutter/phosphoricons_flutter.dart'; + +const String pillSnippet = ''' +// Presence is the state: the host renders a pill while its tool is on, +// and the X only reports intent — removal is the host's move. +FlowComposer( + onSend: send, + leadingActions: [ + FlowMenu( + icon: PhosphorIconsRegular.plus, + sheetTitle: 'Add to Chat', + entries: [...], + onSelected: toggleTool, + ), + if (researchOn) + FlowPill( + icon: PhosphorIconsRegular.graduationCap, + label: 'Research', + removeTooltip: 'Turn off Research', + onRemove: () => setResearch(false), + ), + ], +) + +// On phones the label auto-drops to the design's icon-only form; +// showLabel forces either. No onRemove renders a static pill. +FlowPill( + icon: PhosphorIconsRegular.globe, + label: 'Web Search', + showLabel: false, + removeTooltip: 'Turn off Web Search', + onRemove: () => setWebSearch(false), +)'''; + +/// Stage demo for `FlowPill` — removable tool pills, the forced icon-only +/// form, the static and disabled tokens, and live wiring in a composer +/// whose add menu toggles them. +class PillDemo extends StatefulWidget { + const PillDemo({super.key, this.variant}); + + final String? variant; + + @override + State createState() => _PillDemoState(); +} + +class _PillDemoState extends State { + static const double _pillGap = 8; + + bool _researchOn = true; + bool _webSearchOn = true; + + List _pills({bool? showLabel}) => [ + if (_researchOn) + FlowPill( + icon: PhosphorIconsRegular.graduationCap, + label: 'Research', + showLabel: showLabel, + removeTooltip: 'Turn off Research', + onRemove: () => setState(() => _researchOn = false), + ), + if (_webSearchOn) + FlowPill( + icon: PhosphorIconsRegular.globe, + label: 'Web Search', + showLabel: showLabel, + removeTooltip: 'Turn off Web Search', + onRemove: () => setState(() => _webSearchOn = false), + ), + ]; + + /// Both pills removed: the stage resets on a variant switch, so say so + /// rather than standing empty. + Widget _row(BuildContext context, {bool? showLabel}) { + final pills = _pills(showLabel: showLabel); + if (pills.isEmpty) { + return Text( + 'Removed — switch variants to bring the pills back.', + textAlign: TextAlign.center, + style: context.flowTypography.bodyMedium.copyWith( + color: context.flowColors.onSurfaceMuted, + ), + ); + } + return Wrap( + spacing: _pillGap, + runSpacing: _pillGap, + alignment: WrapAlignment.center, + children: pills, + ); + } + + Widget _composer() { + return FlowComposer( + placeholder: 'How can I help you today?', + onSend: (_) {}, + leadingActions: [ + FlowMenu( + icon: PhosphorIconsRegular.plus, + tooltip: 'Add to chat', + sheetTitle: 'Add to Chat', + entries: [ + FlowMenuOption( + id: 'research', + icon: PhosphorIconsRegular.graduationCap, + label: 'Research', + selected: _researchOn, + ), + FlowMenuOption( + id: 'web-search', + icon: PhosphorIconsRegular.globe, + label: 'Web Search', + selected: _webSearchOn, + ), + ], + onSelected: (id) => setState(() { + if (id == 'research') _researchOn = !_researchOn; + if (id == 'web-search') _webSearchOn = !_webSearchOn; + }), + ), + ..._pills(), + ], + ); + } + + @override + Widget build(BuildContext context) { + final child = switch (widget.variant) { + 'icon' => _row(context, showLabel: false), + 'static' => Wrap( + spacing: _pillGap, + runSpacing: _pillGap, + alignment: WrapAlignment.center, + children: [ + const FlowPill( + icon: PhosphorIconsRegular.graduationCap, + label: 'Research', + ), + FlowPill( + icon: PhosphorIconsRegular.globe, + label: 'Web Search', + enabled: false, + removeTooltip: 'Turn off Web Search', + onRemove: () {}, + ), + ], + ), + 'composer' => _composer(), + _ => _row(context), + }; + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: child, + ), + ); + } +} diff --git a/playground/lib/src/playground_item.dart b/playground/lib/src/playground_item.dart index efd0aed..82f19d7 100644 --- a/playground/lib/src/playground_item.dart +++ b/playground/lib/src/playground_item.dart @@ -39,6 +39,7 @@ enum PlaygroundItem { PhosphorIconsRegular.plus, 'flow_add_to_chat_menu.dart', ), + pill('Pill', PhosphorIconsRegular.pill, 'flow_pill.dart'), attachments( 'Attachments', PhosphorIconsRegular.paperclip,