chromium/ash/style/combobox.cc

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "ash/style/combobox.h"

#include <memory>
#include <utility>

#include "ash/public/cpp/shell_window_ids.h"
#include "ash/public/cpp/style/color_provider.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/style/blurred_background_shield.h"
#include "ash/style/radio_button_group.h"
#include "ash/style/style_util.h"
#include "ash/style/typography.h"
#include "ash/wm/work_area_insets.h"
#include "base/functional/bind.h"
#include "ui/aura/env.h"
#include "ui/aura/window.h"
#include "ui/base/metadata/metadata_header_macros.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/models/combobox_model.h"
#include "ui/chromeos/styles/cros_tokens_color_mappings.h"
#include "ui/compositor/layer.h"
#include "ui/events/event.h"
#include "ui/events/event_handler.h"
#include "ui/events/event_target.h"
#include "ui/events/types/event_type.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/gfx/geometry/rounded_corners_f.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/background.h"
#include "ui/views/controls/button/button.h"
#include "ui/views/controls/focus_ring.h"
#include "ui/views/controls/highlight_path_generator.h"
#include "ui/views/controls/image_view.h"
#include "ui/views/controls/label.h"
#include "ui/views/controls/scroll_view.h"
#include "ui/views/highlight_border.h"
#include "ui/views/layout/fill_layout.h"
#include "ui/views/layout/flex_layout.h"
#include "ui/views/layout/flex_layout_types.h"
#include "ui/views/mouse_constants.h"
#include "ui/views/view_class_properties.h"
#include "ui/views/view_utils.h"
#include "ui/views/widget/widget.h"
#include "ui/wm/core/coordinate_conversion.h"

namespace ash {

namespace {

// The color constants.
constexpr ui::ColorId kActiveTitleAndIconColorId =
    cros_tokens::kCrosSysSystemOnPrimaryContainer;
constexpr ui::ColorId kInactiveTitleAndIconColorId =
    cros_tokens::kCrosSysOnSurface;
constexpr ui::ColorId kMenuTextColorId = cros_tokens::kCrosSysOnSurface;
constexpr ui::ColorId kMenuBackgroundColorId =
    cros_tokens::kCrosSysSystemBaseElevated;
constexpr ui::ColorId kComboboxActiveColorId =
    cros_tokens::kCrosSysSystemPrimaryContainer;

// The layout parameters.
constexpr gfx::RoundedCornersF kComboboxRoundedCorners =
    gfx::RoundedCornersF(12, 12, 12, 4);
constexpr gfx::RoundedCornersF kMenuRoundedCorners =
    gfx::RoundedCornersF(4, 12, 12, 12);
constexpr gfx::Insets kMenuBorderInsets = gfx::Insets::TLBR(16, 0, 12, 0);
constexpr gfx::Insets kMenuItemInnerPadding = gfx::Insets::VH(8, 16);
constexpr int kArrowIconSize = 20;
constexpr int kCheckmarkLabelSpacing = 16;
constexpr int kMaxMenuWidth = 168;
constexpr int kMaxMenuHeight = 172;
constexpr gfx::Vector2d kMenuOffset(0, 8);
constexpr int kMenuShadowElevation = 12;

class ComboboxMenuOption : public RadioButton {
  METADATA_HEADER(ComboboxMenuOption, RadioButton)

 public:
  ComboboxMenuOption(int button_width,
                     PressedCallback callback,
                     const std::u16string& label)
      : RadioButton(button_width,
                    std::move(callback),
                    label,
                    RadioButton::IconDirection::kLeading,
                    RadioButton::IconType::kCheck,
                    kMenuItemInnerPadding,
                    kCheckmarkLabelSpacing) {
    // The option is visually a radio button, but handles press actions more
    // like a button - when pressed, the combobox menu will be closed, and the
    // pressed option will get selected for the combobox. For this reason, for
    // accessibility, treat the menu option as a list box option instead of
    // radio button.
    GetViewAccessibility().SetProperties(ax::mojom::Role::kListBoxOption);
    // Clear the checked state set by the base class. The check is used as an
    // indicator of the current combobox menu selection, and gets updated as the
    // keyboard selection changes. Announcing that each item that gets keyboard
    // selection is checked does not add value to the user and may cause
    // confusion. Additionally, if checked state is set, the action verb will
    // indicate that activating the item toggles it, which would be misleading.
    GetViewAccessibility().SetCheckedState(ax::mojom::CheckedState::kNone);
    UpdateAccessibleDefaultAction();
  }

 private:
  // views::Button:
  void OnEnabledChanged() override {
    RadioButton::OnEnabledChanged();
    UpdateAccessibleDefaultAction();
  }

  // OptionButtonBase:
  void OnSelectedChanged() override {
    RadioButton::OnSelectedChanged();
    // Override the default action verb updated in OptionButtonBase.
    UpdateAccessibleDefaultAction();
  }

  void UpdateAccessibleDefaultAction() {
    GetViewAccessibility().SetDefaultActionVerb(
        ax::mojom::DefaultActionVerb::kClick);
  }
};

BEGIN_METADATA(ComboboxMenuOption)
END_METADATA

class ComboboxMenuOptionGroup : public RadioButtonGroup {
  METADATA_HEADER(ComboboxMenuOptionGroup, RadioButtonGroup)

 public:
  ComboboxMenuOptionGroup()
      : RadioButtonGroup(kMaxMenuWidth,
                         kMenuBorderInsets,
                         0,
                         RadioButton::IconDirection::kLeading,
                         RadioButton::IconType::kCheck,
                         kMenuItemInnerPadding,
                         kCheckmarkLabelSpacing) {
    GetViewAccessibility().SetProperties(ax::mojom::Role::kListBox);
    GetViewAccessibility().SetName(
        "", ax::mojom::NameFrom::kAttributeExplicitlyEmpty);
  }

  // RadioButtonGroup:
  RadioButton* AddButton(RadioButton::PressedCallback callback,
                         const std::u16string& label) override {
    auto* button = AddChildView(std::make_unique<ComboboxMenuOption>(
        group_width_ - inside_border_insets_.width(), std::move(callback),
        label));
    button->set_delegate(this);
    buttons_.push_back(button);
    return button;
  }
};

BEGIN_METADATA(ComboboxMenuOptionGroup)
END_METADATA

}  // namespace

//------------------------------------------------------------------------------
// Combobox::ComboboxMenuView:
// The contents of combobox drop down menu which contains a list of items
// corresponding to the items in combobox model. The selected item will show a
// leading checked icon.
class Combobox::ComboboxMenuView : public views::View {
  METADATA_HEADER(ComboboxMenuView, views::View)

 public:
  explicit ComboboxMenuView(base::WeakPtr<Combobox> combobox)
      : combobox_(combobox),
        background_shield_(this,
                           kMenuBackgroundColorId,
                           ColorProvider::kBackgroundBlurSigma,
                           kMenuRoundedCorners) {
    SetLayoutManager(std::make_unique<views::FillLayout>());

    scroll_view_ = AddChildView(std::make_unique<views::ScrollView>(
        views::ScrollView::ScrollWithLayers::kEnabled));
    scroll_view_->SetPaintToLayer(ui::LAYER_NOT_DRAWN);
    scroll_view_->layer()->SetFillsBoundsOpaquely(false);
    scroll_view_->ClipHeightTo(0, std::numeric_limits<int>::max());
    scroll_view_->SetDrawOverflowIndicator(false);
    scroll_view_->SetBackgroundColor(std::nullopt);
    scroll_view_->SetVerticalScrollBarMode(
        views::ScrollView::ScrollBarMode::kHiddenButEnabled);

    // Create a radio buttons group for item list.
    menu_item_group_ =
        scroll_view_->SetContents(std::make_unique<ComboboxMenuOptionGroup>());
    UpdateMenuContent();

    // Set border.
    SetBorder(std::make_unique<views::HighlightBorder>(
        kMenuRoundedCorners,
        views::HighlightBorder::Type::kHighlightBorderOnShadow));
  }
  ComboboxMenuView(const ComboboxMenuView&) = delete;
  ComboboxMenuView& operator=(const ComboboxMenuView&) = delete;
  ~ComboboxMenuView() override = default;

  void SelectItem(int index) { menu_item_group_->SelectButtonAtIndex(index); }

  OptionButtonBase* GetSelectedItemView() const {
    auto selected_views = menu_item_group_->GetSelectedButtons();
    if (selected_views.empty()) {
      return nullptr;
    }

    return selected_views[0];
  }

  void UpdateMenuContent() {
    menu_item_group_->RemoveAllChildViews();

    // Build a radio button group according to current combobox model.
    for (size_t i = 0; i < combobox_->model_->GetItemCount(); i++) {
      auto* item = menu_item_group_->AddButton(
          base::BindRepeating(&Combobox::MenuSelectionAt, combobox_, i),
          combobox_->model_->GetItemAt(i));
      item->SetLabelStyle(TypographyToken::kCrosButton2);
      item->SetLabelColorId(kMenuTextColorId);
      item->SetSelected(combobox_->selected_index_.value_or(-1) == i);
    }
    GetSelectedItemView()->ScrollViewToVisible();
  }

  void ScrollToSelectedView() {
    if (GetSelectedItemView()) {
      GetSelectedItemView()->ScrollViewToVisible();
    }
  }

  views::View* MenuItemAtIndex(int index) const {
    if (index >= 0 &&
        index < static_cast<int>(menu_item_group_->children().size())) {
      return menu_item_group_->children()[index];
    }
    return nullptr;
  }

  gfx::Size CalculatePreferredSize(
      const views::SizeBounds& available_size) const override {
    gfx::Size size = views::View::CalculatePreferredSize(available_size);
    size.SetToMin(gfx::Size(kMaxMenuWidth, kMaxMenuHeight));
    return size;
  }

 private:
  const base::WeakPtr<Combobox> combobox_;
  const BlurredBackgroundShield background_shield_;

  // Owned by this.
  raw_ptr<ComboboxMenuOptionGroup> menu_item_group_;
  raw_ptr<views::ScrollView> scroll_view_;
};

BEGIN_METADATA(Combobox, ComboboxMenuView)
END_METADATA

//------------------------------------------------------------------------------
// Combobox::ComboboxEventHandler:
// Handles the mouse and touch event that happens outside combobox and its drop
// down menu.
class Combobox::ComboboxEventHandler : public ui::EventHandler {
 public:
  explicit ComboboxEventHandler(Combobox* combobox) : combobox_(combobox) {
    aura::Env::GetInstance()->AddPreTargetHandler(
        this, ui::EventTarget::Priority::kSystem);
  }

  ComboboxEventHandler(const ComboboxEventHandler&) = delete;
  ComboboxEventHandler& operator=(const ComboboxEventHandler&) = delete;
  ~ComboboxEventHandler() override {
    aura::Env::GetInstance()->RemovePreTargetHandler(this);
  }

  // ui::EventHandler:
  void OnMouseEvent(ui::MouseEvent* event) override { OnLocatedEvent(event); }

  void OnTouchEvent(ui::TouchEvent* event) override { OnLocatedEvent(event); }

  void OnKeyEvent(ui::KeyEvent* event) override {
    // If the menu is shown, route the key event to the combobox view, to handle
    // keys impact the menu selection/state even if the combobox view is not
    // currently focused (which may be the case if the combobox is shown within
    // non-activatable widget, e.g. a system tray bubble).
    if (combobox_->IsMenuRunning() && !combobox_->HasFocus()) {
      combobox_->OnKeyEvent(event);
    }
  }

 private:
  void OnLocatedEvent(ui::LocatedEvent* event) {
    // Close drop down menu if certain mouse or touch events happening outside
    // combobox or menu area.
    if (!combobox_->IsMenuRunning()) {
      return;
    }

    // Get event location in screen.
    gfx::Point event_location = event->location();
    aura::Window* event_target = static_cast<aura::Window*>(event->target());
    wm::ConvertPointToScreen(event_target, &event_location);

    const bool event_in_combobox =
        combobox_->GetBoundsInScreen().Contains(event_location);
    const bool event_in_menu =
        combobox_->menu_->GetWindowBoundsInScreen().Contains(event_location);
    switch (event->type()) {
      case ui::EventType::kMousewheel:
        // Close menu if scrolling outside menu.
        if (!event_in_menu) {
          combobox_->CloseDropDownMenu();
        }
        break;
      case ui::EventType::kMousePressed:
      case ui::EventType::kTouchPressed:
        // Close menu if pressing outside menu and combobox.
        if (!event_in_menu && !event_in_combobox) {
          event->StopPropagation();
          combobox_->CloseDropDownMenu();
        }
        break;
      default:
        break;
    }
  }

  const raw_ptr<Combobox, DanglingUntriaged> combobox_;
};

//------------------------------------------------------------------------------
// Combobox:
Combobox::Combobox(std::unique_ptr<ui::ComboboxModel> model)
    : Combobox(model.get()) {
  owned_model_ = std::move(model);
}

Combobox::Combobox(ui::ComboboxModel* model)
    : views::Button(base::BindRepeating(&Combobox::OnComboboxPressed,
                                        base::Unretained(this))),
      model_(model),
      title_(AddChildView(std::make_unique<views::Label>())),
      drop_down_arrow_(AddChildView(std::make_unique<views::ImageView>(
          ui::ImageModel::FromVectorIcon(kDropDownArrowIcon,
                                         kInactiveTitleAndIconColorId,
                                         kArrowIconSize)))) {
  // Initialize the combobox with given model.
  CHECK(model_);
  observation_.Observe(model_.get());
  SetSelectedIndex(model_->GetDefaultIndex());
  OnPerformAction();
  OnComboboxModelChanged(model_);

  // Set up layout.
  SetLayoutManager(std::make_unique<views::FlexLayout>())
      ->SetInteriorMargin(kComboboxBorderInsets);
  // TODO(crbug.com/40232718): See View::SetLayoutManagerUseConstrainedSpace.
  SetLayoutManagerUseConstrainedSpace(false);

  // Allow `title_` to shrink and elide, so that `drop_down_arrow_` on the
  // right always remains visible.
  title_->SetProperty(
      views::kFlexBehaviorKey,
      views::FlexSpecification(views::MinimumFlexSizeRule::kScaleToZero,
                               views::MaximumFlexSizeRule::kUnbounded));

  // Stylize the title.
  TypographyProvider::Get()->StyleLabel(TypographyToken::kCrosTitle1,
                                        *title_.get());
  title_->SetAutoColorReadabilityEnabled(false);
  title_->SetEnabledColorId(kInactiveTitleAndIconColorId);
  title_->SetHorizontalAlignment(gfx::HorizontalAlignment::ALIGN_LEFT);

  SetFocusBehavior(views::View::FocusBehavior::ALWAYS);

  SetPaintToLayer();
  layer()->SetFillsBoundsOpaquely(false);

  // Set up the ink drop.
  StyleUtil::InstallRoundedCornerHighlightPathGenerator(
      this, kComboboxRoundedCorners);
  StyleUtil::SetUpInkDropForButton(this);
  views::FocusRing::Get(this)->SetProperty(views::kViewIgnoredByLayoutKey,
                                           /*ignored=*/true);

  event_handler_ = std::make_unique<ComboboxEventHandler>(this);

  // `ax::mojom::Role::kComboBox` is for UI elements with a dropdown and
  // an editable text field, which `views::Combobox` does not have. Use
  // `ax::mojom::Role::kPopUpButton` to match an HTML <select> element.
  GetViewAccessibility().SetProperties(ax::mojom::Role::kPopUpButton);
  UpdateExpandedCollapsedAccessibleState();
  UpdateAccessibleDefaultAction();
}

Combobox::~Combobox() = default;

void Combobox::SetSelectionChangedCallback(base::RepeatingClosure callback) {
  callback_ = std::move(callback);
}

void Combobox::SetSelectedIndex(std::optional<size_t> index) {
  if (selected_index_ == index) {
    return;
  }

  if (index.has_value()) {
    CHECK_LT(index.value(), model_->GetItemCount());
  }

  selected_index_ = index;

  if (!selected_index_.has_value()) {
    return;
  }

  // Update selected item on menu if the menu is opening.
  if (menu_view_) {
    menu_view_->SelectItem(selected_index_.value());
    UpdateAccessibleAccessibleActiveDescendantId();
  }
}

bool Combobox::SelectValue(const std::u16string& value) {
  for (size_t i = 0; i < model_->GetItemCount(); ++i) {
    if (value == model_->GetItemAt(i)) {
      SetSelectedIndex(i);
      return true;
    }
  }
  return false;
}

bool Combobox::IsMenuRunning() const {
  return !!menu_;
}

gfx::Size Combobox::GetMenuViewSize() const {
  if (!menu_) {
    return gfx::Size();
  }
  return menu_view_->size();
}

views::View* Combobox::MenuItemAtIndex(int index) const {
  if (!menu_) {
    return nullptr;
  }
  return menu_view_->MenuItemAtIndex(index);
}

views::View* Combobox::MenuView() const {
  return menu_view_;
}

void Combobox::SetCallback(PressedCallback callback) {
  NOTREACHED() << "Clients shouldn't modify this. Maybe you want to use "
                  "SetSelectionChangedCallback?";
}

void Combobox::OnBoundsChanged(const gfx::Rect& previous_bounds) {
  // Move menu with combobox accordingly.
  if (menu_) {
    menu_->SetBounds(GetExpectedMenuBounds());
  }
}

void Combobox::OnBlur() {
  if (menu_) {
    CloseDropDownMenu();
  }

  views::Button::OnBlur();
}

void Combobox::AddedToWidget() {
  widget_observer_.Observe(GetWidget());
}

void Combobox::RemovedFromWidget() {
  widget_observer_.Reset();
}

void Combobox::Layout(PassKey) {
  LayoutSuperclass<views::Button>(this);
  views::FocusRing::Get(this)->DeprecatedLayoutImmediately();
}

void Combobox::OnWidgetBoundsChanged(views::Widget* widget,
                                     const gfx::Rect& bounds) {
  if (menu_) {
    menu_->SetBounds(GetExpectedMenuBounds());
  }
}

std::u16string Combobox::GetTextForRow(size_t row) const {
  return model_->IsItemSeparatorAt(row) ? std::u16string()
                                        : model_->GetItemAt(row);
}

void Combobox::SelectMenuItemForTest(size_t row) {
  MenuSelectionAt(row);
}

gfx::Rect Combobox::GetExpectedMenuBounds() const {
  CHECK(menu_view_);
  WorkAreaInsets* work_area =
      WorkAreaInsets::ForWindow(GetWidget()->GetNativeWindow());
  const gfx::Rect available_bounds = work_area->user_work_area_bounds();

  const gfx::Size preferred_size = menu_view_->GetPreferredSize();
  const gfx::Rect combobox_bounds = GetBoundsInScreen();

  // Decide whether to show the combobox menu below (default) or above the
  // combobox:
  // if the combobox menu fits below the combobox, show it below.
  const int height_below =
      available_bounds.bottom() - combobox_bounds.bottom() - kMenuOffset.y();
  bool show_below_combobox = height_below >= preferred_size.height();
  // If the combobox menu does not fit below combobox, show it above the
  // combobox of there is more space available above.
  if (!show_below_combobox) {
    const int height_above =
        combobox_bounds.y() - available_bounds.y() - kMenuOffset.y();
    show_below_combobox = height_below >= height_above;
  }

  gfx::Rect preferred_bounds =
      show_below_combobox
          ? gfx::Rect(combobox_bounds.bottom_left() + kMenuOffset,
                      preferred_size)
          : gfx::Rect(
                combobox_bounds.origin() +
                    gfx::Vector2d(kMenuOffset.x(),
                                  -preferred_size.height() - kMenuOffset.y()),
                preferred_size);

  // If the combobox view is offscreen, translate the preferred combobox bounds
  // to fit available bounds.
  if (show_below_combobox && combobox_bounds.bottom() < available_bounds.y()) {
    preferred_bounds.Offset(0, available_bounds.y() - combobox_bounds.bottom());
  } else if (!show_below_combobox &&
             combobox_bounds.y() > available_bounds.bottom()) {
    preferred_bounds.Offset(0, available_bounds.bottom() - combobox_bounds.y());
  }

  preferred_bounds.Intersect(available_bounds);
  return preferred_bounds;
}

void Combobox::MenuSelectionAt(size_t index) {
  SetSelectedIndex(index);
  // Close the menu once a selection is made.
  CloseDropDownMenu();
}

void Combobox::OnComboboxPressed() {
  if (!GetEnabled()) {
    return;
  }

  if (menu_) {
    CloseDropDownMenu();
  } else if ((base::TimeTicks::Now() - closed_time_) >
             views::kMinimumTimeBetweenButtonClicks) {
    ShowDropDownMenu();
  }
}

void Combobox::ShowDropDownMenu() {
  auto* widget = GetWidget();
  if (!widget) {
    return;
  }

  auto menu_view =
      std::make_unique<ComboboxMenuView>(weak_ptr_factory_.GetWeakPtr());
  menu_view_ = menu_view.get();

  views::Widget::InitParams params(
      views::Widget::InitParams::NATIVE_WIDGET_OWNS_WIDGET,
      views::Widget::InitParams::TYPE_POPUP);
  params.opacity = views::Widget::InitParams::WindowOpacity::kTranslucent;
  params.shadow_type = views::Widget::InitParams::ShadowType::kDrop;
  params.shadow_elevation = kMenuShadowElevation;
  params.corner_radius = kMenuRoundedCorners.lower_left();

  aura::Window* root_window = widget->GetNativeWindow()->GetRootWindow();
  params.parent = root_window->GetChildById(kShellWindowId_MenuContainer);
  params.bounds = GetExpectedMenuBounds();

  menu_ = std::make_unique<views::Widget>(std::move(params));
  menu_->SetContentsView(std::move(menu_view));
  menu_->Show();
  menu_view_->ScrollToSelectedView();
  UpdateExpandedCollapsedAccessibleState();
  UpdateAccessibleAccessibleActiveDescendantId();

  SetBackground(views::CreateThemedRoundedRectBackground(
      kComboboxActiveColorId, kComboboxRoundedCorners));
  title_->SetEnabledColorId(kActiveTitleAndIconColorId);
  drop_down_arrow_->SetImage(ui::ImageModel::FromVectorIcon(
      kDropDownArrowIcon, kActiveTitleAndIconColorId, kArrowIconSize));

  RequestFocus();
}

void Combobox::CloseDropDownMenu() {
  menu_view_ = nullptr;
  menu_.reset();
  UpdateExpandedCollapsedAccessibleState();
  UpdateAccessibleAccessibleActiveDescendantId();

  closed_time_ = base::TimeTicks::Now();
  SetBackground(nullptr);
  title_->SetEnabledColorId(kInactiveTitleAndIconColorId);
  drop_down_arrow_->SetImage(ui::ImageModel::FromVectorIcon(
      kDropDownArrowIcon, kInactiveTitleAndIconColorId, kArrowIconSize));

  // Commit the selection once the combobox view state has been updated.
  // NOTE: This may run selection callback, which may end up deleting this,
  // depending on how the callback is handled.
  OnPerformAction();
}

void Combobox::OnPerformAction() {
  if (selected_index_ == last_commit_selection_) {
    return;
  }

  last_commit_selection_ = selected_index_;

  if (selected_index_.has_value()) {
    title_->SetText(model_->GetItemAt(selected_index_.value()));
  } else {
    title_->SetText(std::u16string());
  }

  if (selected_index_) {
    GetViewAccessibility().SetPosInSet(
        base::checked_cast<int>(selected_index_.value()));
    GetViewAccessibility().SetSetSize(
        base::checked_cast<int>(model_->GetItemCount()));
  } else {
    GetViewAccessibility().ClearPosInSet();
    GetViewAccessibility().ClearSetSize();
  }

  GetViewAccessibility().SetValue(title_->GetText());

  if (selected_index_.has_value() && callback_) {
    callback_.Run();
  }
}

void Combobox::OnComboboxModelChanged(ui::ComboboxModel* model) {
  DCHECK_EQ(model_, model);

  // If the selection is no longer valid (or the model is empty), restore the
  // default index.
  if (selected_index_ >= model_->GetItemCount() ||
      model_->GetItemCount() == 0 ||
      model_->IsItemSeparatorAt(selected_index_.value())) {
    SetSelectedIndex(model_->GetDefaultIndex());
  }

  if (menu_view_) {
    menu_view_->UpdateMenuContent();
    UpdateAccessibleAccessibleActiveDescendantId();
  }
}

void Combobox::OnComboboxModelDestroying(ui::ComboboxModel* model) {
  // Reset selected index to avoid using the destroying model.
  SetSelectedIndex(std::nullopt);
  model_ = nullptr;
  observation_.Reset();
  CloseDropDownMenu();
}

bool Combobox::SkipDefaultKeyEventProcessing(const ui::KeyEvent& e) {
  if (!IsMenuRunning()) {
    return false;
  }

  // Let combobox directly handle keys that update combobox menu selection if
  // the menu is running.
  if (e.key_code() == ui::VKEY_DOWN || e.key_code() == ui::VKEY_END ||
      e.key_code() == ui::VKEY_NEXT || e.key_code() == ui::VKEY_HOME ||
      e.key_code() == ui::VKEY_PRIOR || e.key_code() == ui::VKEY_UP ||
      e.key_code() == ui::VKEY_TAB) {
    return true;
  }

  // Escape should close the drop down list when it is active, not host UI.
  if (e.key_code() == ui::VKEY_ESCAPE && !e.IsShiftDown() &&
      !e.IsControlDown() && !e.IsAltDown() && !e.IsAltGrDown()) {
    return true;
  }

  return false;
}

bool Combobox::OnKeyPressed(const ui::KeyEvent& e) {
  CHECK_EQ(e.type(), ui::EventType::kKeyPressed);

  CHECK(selected_index_.has_value());
  CHECK_LT(selected_index_.value(), model_->GetItemCount());

  const auto index_at_or_after = [](ui::ComboboxModel* model,
                                    size_t index) -> std::optional<size_t> {
    for (; index < model->GetItemCount(); ++index) {
      if (!model->IsItemSeparatorAt(index) && model->IsItemEnabledAt(index)) {
        return index;
      }
    }
    return std::nullopt;
  };

  const auto index_before = [](ui::ComboboxModel* model,
                               size_t index) -> std::optional<size_t> {
    for (; index > 0; --index) {
      const auto prev = index - 1;
      if (!model->IsItemSeparatorAt(prev) && model->IsItemEnabledAt(prev)) {
        return prev;
      }
    }
    return std::nullopt;
  };

  std::optional<size_t> new_index;
  switch (e.key_code()) {
    // Show the menu on F4 without modifiers.
    case ui::VKEY_F4:
      if (e.IsAltDown() || e.IsAltGrDown() || e.IsControlDown()) {
        return false;
      }
      ShowDropDownMenu();
      return true;

    // Move to the next item if any, or show the menu on Alt+Down like Windows.
    case ui::VKEY_DOWN:
      if (e.IsAltDown()) {
        ShowDropDownMenu();
        return true;
      }
      new_index = index_at_or_after(model_, selected_index_.value() + 1);
      break;

    // Move to the end of the list.
    case ui::VKEY_END:
    case ui::VKEY_NEXT:  // Page down.
      new_index = index_before(model_, model_->GetItemCount());
      break;

    // Move to the beginning of the list.
    case ui::VKEY_HOME:
    case ui::VKEY_PRIOR:  // Page up.
      new_index = index_at_or_after(model_, 0);
      break;

    // Move to the previous item if any.
    case ui::VKEY_UP:
      new_index = index_before(model_, selected_index_.value());
      break;
    case ui::VKEY_TAB:
      if (menu_view_) {
        new_index =
            e.IsShiftDown()
                ? index_before(model_, selected_index_.value())
                : index_at_or_after(model_, selected_index_.value() + 1);
        break;
      }
      // If menu is closed, proceed with the default TAB key behavior (and let
      // it move the focus away from the combobox).
      return views::Button::OnKeyPressed(e);
    case ui::VKEY_ESCAPE:
      if (menu_view_) {
        SetSelectedIndex(last_commit_selection_);
        CloseDropDownMenu();
        return true;
      }
      return views::Button::OnKeyPressed(e);
    default:
      return views::Button::OnKeyPressed(e);
  }

  // If menu is running, only update selected item on menu instead of committing
  // the selection. Otherwise, make the selection.
  if (new_index.has_value()) {
    SetSelectedIndex(new_index);
    if (!IsMenuRunning()) {
      OnPerformAction();
    }
  }
  return true;
}

void Combobox::OnEnabledChanged() {
  views::Button::OnEnabledChanged();
  UpdateAccessibleDefaultAction();
}

void Combobox::UpdateExpandedCollapsedAccessibleState() const {
  if (IsMenuRunning()) {
    GetViewAccessibility().SetIsExpanded();
  } else {
    GetViewAccessibility().SetIsCollapsed();
  }
}

void Combobox::UpdateAccessibleAccessibleActiveDescendantId() {
  OptionButtonBase* selected_button =
      menu_view_ ? menu_view_->GetSelectedItemView() : nullptr;
  if (selected_button) {
    GetViewAccessibility().SetActiveDescendant(*selected_button);
  } else {
    GetViewAccessibility().ClearActiveDescendant();
  }
}

void Combobox::UpdateAccessibleDefaultAction() {
  GetViewAccessibility().SetDefaultActionVerb(
      ax::mojom::DefaultActionVerb::kOpen);
}

BEGIN_METADATA(Combobox)
END_METADATA

}  // namespace ash