Skip to content

Commit 204de4d

Browse files
committed
Add ability to programmatically control focus on list and grid
1 parent 4721a70 commit 204de4d

11 files changed

Lines changed: 207 additions & 104 deletions

File tree

dev_plugin/src/test-grid-focus.tsx

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,43 @@
1-
import { ReactElement } from "react";
2-
import { Grid } from "@project-gauntlet/api/components";
1+
import { ReactElement, useState } from "react";
2+
import { Action, ActionPanel, Grid } from "@project-gauntlet/api/components";
33

44
export default function Main(): ReactElement {
5-
const content = (
5+
const [id, setId] = useState<string | null>(null);
6+
7+
const content = (id: string) => (
68
<Grid.Item.Content>
79
<Grid.Item.Content.Paragraph>
8-
Test
10+
{id}
911
</Grid.Item.Content.Paragraph>
1012
</Grid.Item.Content>
1113
);
12-
14+
1315
return (
14-
<Grid onItemFocusChange={(id: string | null) => console.log("onItemFocusChange", id)}>
15-
<Grid.Item id="adarian" title="Adarian">{content}</Grid.Item>
16-
<Grid.Item id="aruzan" title="Aruzan">{content}</Grid.Item>
17-
<Grid.Item id="blutopian" title="Blutopian">{content}</Grid.Item>
18-
<Grid.Item id="caphex" title="Caphex">{content}</Grid.Item>
19-
<Grid.Item id="condluran" title="Condluran">{content}</Grid.Item>
20-
<Grid.Item id="frozian" title="Frozian">{content}</Grid.Item>
21-
<Grid.Item id="evereni" title="Evereni">{content}</Grid.Item>
22-
<Grid.Item id="ezaraa" title="Ezaraa">{content}</Grid.Item>
23-
<Grid.Item id="houk" title="Houk">{content}</Grid.Item>
24-
<Grid.Item id="inleshat" title="Inleshat">{content}</Grid.Item>
16+
<Grid
17+
onItemFocusChange={setId}
18+
focusedItemId={id}
19+
actions={
20+
<ActionPanel>
21+
<Action
22+
label={`Focused: ${id}`}
23+
onAction={(id) => {
24+
console.log(id)
25+
setId("condluran")
26+
}}
27+
/>
28+
</ActionPanel>
29+
}
30+
>
31+
<Grid.Item id="adarian" title="Adarian">{content("adarian")}</Grid.Item>
32+
<Grid.Item id="aruzan" title="Aruzan">{content("aruzan")}</Grid.Item>
33+
<Grid.Item id="blutopian" title="Blutopian">{content("blutopian")}</Grid.Item>
34+
<Grid.Item id="caphex" title="Caphex">{content("caphex")}</Grid.Item>
35+
<Grid.Item id="condluran" title="Condluran">{content("condluran")}</Grid.Item>
36+
<Grid.Item id="frozian" title="Frozian">{content("frozian")}</Grid.Item>
37+
<Grid.Item id="evereni" title="Evereni">{content("evereni")}</Grid.Item>
38+
<Grid.Item id="ezaraa" title="Ezaraa">{content("ezaraa")}</Grid.Item>
39+
<Grid.Item id="houk" title="Houk">{content("houk")}</Grid.Item>
40+
<Grid.Item id="inleshat" title="Inleshat">{content("inleshat")}</Grid.Item>
2541
</Grid>
2642
)
2743
}

dev_plugin/src/test-list-focus.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export default function Main(): ReactElement {
55
const [id, setId] = useState<string | null>(null);
66

77
return (
8-
<List onItemFocusChange={setId}>
8+
<List onItemFocusChange={setId} focusedItemId={id}>
99
<List.Item id="adarian" title="Adarian"/>
1010
<List.Item id="aruzan" title="Aruzan"/>
1111
<List.Item id="blutopian" title="Blutopian"/>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Sets item with specified id to be the one that is focused
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Sets item with specified id to be the one that is focused

js/api/src/gen/components.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ declare global {
151151
children?: ElementComponent<typeof ActionPanel | typeof ListItem | typeof ListSection | typeof SearchBar | typeof EmptyView | typeof Detail>;
152152
isLoading?: boolean;
153153
onItemFocusChange?: (itemId: string | null) => void;
154+
focusedItemId?: string | null;
154155
};
155156
["gauntlet:grid_item"]: {
156157
children?: ElementComponent<typeof IconAccessory | typeof Content>;
@@ -169,6 +170,7 @@ declare global {
169170
isLoading?: boolean;
170171
columns?: number;
171172
onItemFocusChange?: (itemId: string | null) => void;
173+
focusedItemId?: string | null;
172174
};
173175
}
174176
}
@@ -700,6 +702,7 @@ export interface ListProps {
700702
actions?: ElementComponent<typeof ActionPanel>;
701703
isLoading?: boolean;
702704
onItemFocusChange?: (itemId: string | null) => void;
705+
focusedItemId?: string | null;
703706
}
704707
export const List: FC<ListProps> & {
705708
Item: typeof ListItem;
@@ -708,7 +711,7 @@ export const List: FC<ListProps> & {
708711
EmptyView: typeof EmptyView;
709712
Detail: typeof Detail;
710713
} = (props: ListProps): ReactNode => {
711-
return <gauntlet:list isLoading={props.isLoading} onItemFocusChange={props.onItemFocusChange}>{props.actions as any}{props.children}</gauntlet:list>;
714+
return <gauntlet:list isLoading={props.isLoading} onItemFocusChange={props.onItemFocusChange} focusedItemId={props.focusedItemId}>{props.actions as any}{props.children}</gauntlet:list>;
712715
};
713716
List.Item = ListItem;
714717
List.Section = ListSection;
@@ -746,14 +749,15 @@ export interface GridProps {
746749
actions?: ElementComponent<typeof ActionPanel>;
747750
columns?: number;
748751
onItemFocusChange?: (itemId: string | null) => void;
752+
focusedItemId?: string | null;
749753
}
750754
export const Grid: FC<GridProps> & {
751755
Item: typeof GridItem;
752756
Section: typeof GridSection;
753757
SearchBar: typeof SearchBar;
754758
EmptyView: typeof EmptyView;
755759
} = (props: GridProps): ReactNode => {
756-
return <gauntlet:grid isLoading={props.isLoading} columns={props.columns} onItemFocusChange={props.onItemFocusChange}>{props.actions as any}{props.children}</gauntlet:grid>;
760+
return <gauntlet:grid isLoading={props.isLoading} columns={props.columns} onItemFocusChange={props.onItemFocusChange} focusedItemId={props.focusedItemId}>{props.actions as any}{props.children}</gauntlet:grid>;
757761
};
758762
Grid.Item = GridItem;
759763
Grid.Section = GridSection;

rust/client/src/ui/scroll_handle.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ impl ScrollHandle {
6565
}
6666
}
6767

68+
pub fn from(scroll_handle: &ScrollHandle, current_item_id: Option<container::Id>) -> ScrollHandle {
69+
ScrollHandle {
70+
scrollable_id: scroll_handle.scrollable_id.clone(),
71+
current_item_id,
72+
}
73+
}
74+
6875
pub fn get<'a, T>(&self, items: &'a ScrollContent<T>) -> Option<&'a T> {
6976
self.get_by_id(items, self.current_item_id.clone())
7077
}
@@ -121,7 +128,8 @@ impl ScrollHandle {
121128
grid: Vec<Vec<container::Id>>,
122129
) -> (Option<container::Id>, Option<Task<AppMsg>>) {
123130
let Some(current_item_id) = &self.current_item_id else {
124-
return (None, self.focus_target(behavior.unfocused(grid)));
131+
let target_item_id = behavior.unfocused(grid);
132+
return (target_item_id.clone(), self.focus_target(target_item_id));
125133
};
126134

127135
let Some((row_index, row)) = grid.iter().find_position(|row| row.contains(&current_item_id)) else {

rust/client/src/ui/widget/data.rs

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use gauntlet_common::model::ActionPanelWidgetOrderedMembers;
66
use gauntlet_common::model::GridSectionWidgetOrderedMembers;
77
use gauntlet_common::model::GridWidget;
88
use gauntlet_common::model::GridWidgetOrderedMembers;
9+
use gauntlet_common::model::JsOption;
910
use gauntlet_common::model::ListSectionWidgetOrderedMembers;
1011
use gauntlet_common::model::ListWidget;
1112
use gauntlet_common::model::ListWidgetOrderedMembers;
@@ -27,7 +28,6 @@ use crate::ui::widget::action_panel::convert_action_panel;
2728
use crate::ui::widget::data_mut::ComponentWidgetsMut;
2829
use crate::ui::widget::events::ComponentWidgetEvent;
2930
use crate::ui::widget::state::ComponentWidgetStateContainer;
30-
use crate::ui::widget::state::ScrollableRootState;
3131
use crate::ui::widget::state::TextFieldState;
3232

3333
#[derive(Debug)]
@@ -115,20 +115,34 @@ impl<'b> ComponentWidgets<'b> {
115115
RootWidgetMembers::Form(_) => None,
116116
RootWidgetMembers::Inline(_) => None,
117117
RootWidgetMembers::List(widget) => {
118-
let ScrollableRootState {
119-
scroll_handle: focused_item,
120-
..
121-
} = self.state.scrollable_root_state(widget.__id__);
122-
123-
self.list_focused_item_id(focused_item, focused_item.current_item_id.clone(), widget)
118+
match &widget.focused_item_id {
119+
JsOption::Undefined => {
120+
let state = self.state.scrollable_root_state(widget.__id__);
121+
122+
self.list_focused_item_id(
123+
&state.scroll_handle,
124+
state.scroll_handle.current_item_id.clone(),
125+
widget,
126+
)
127+
}
128+
JsOption::Null => None,
129+
JsOption::Value(value) => Some(value.clone()),
130+
}
124131
}
125132
RootWidgetMembers::Grid(widget) => {
126-
let ScrollableRootState {
127-
scroll_handle: focused_item,
128-
..
129-
} = self.state.scrollable_root_state(widget.__id__);
130-
131-
self.grid_focused_item_id(focused_item, focused_item.current_item_id.clone(), widget)
133+
match &widget.focused_item_id {
134+
JsOption::Undefined => {
135+
let state = self.state.scrollable_root_state(widget.__id__);
136+
137+
self.grid_focused_item_id(
138+
&state.scroll_handle,
139+
state.scroll_handle.current_item_id.clone(),
140+
widget,
141+
)
142+
}
143+
JsOption::Null => None,
144+
JsOption::Value(value) => Some(value.clone()),
145+
}
132146
}
133147
}
134148
}

rust/client/src/ui/widget/data_mut.rs

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::sync::Arc;
55
use gauntlet_common::model::GridSectionWidgetOrderedMembers;
66
use gauntlet_common::model::GridWidget;
77
use gauntlet_common::model::GridWidgetOrderedMembers;
8+
use gauntlet_common::model::JsOption;
89
use gauntlet_common::model::ListSectionWidgetOrderedMembers;
910
use gauntlet_common::model::ListWidget;
1011
use gauntlet_common::model::ListWidgetOrderedMembers;
@@ -20,6 +21,7 @@ use iced::widget::text_input;
2021
use itertools::Itertools;
2122

2223
use crate::ui::AppMsg;
24+
use crate::ui::scroll_handle::ScrollHandle;
2325
use crate::ui::widget::data::ComponentWidgets;
2426
use crate::ui::widget::grid::grid_width;
2527
use crate::ui::widget::state::ComponentWidgetStateContainer;
@@ -172,8 +174,8 @@ impl<'b> ComponentWidgetsMut<'b> {
172174
RootWidgetMembers::Inline(_) => Task::none(),
173175
RootWidgetMembers::List(widget) => {
174176
let ids = self.list_collect_ids(&widget);
175-
let state = self.state.scrollable_root_state_mut(widget.__id__);
176-
let (next_item, Some(focus_task)) = state.scroll_handle.list_focus_up(ids) else {
177+
let mut scroll_handle = self.list_scroll_handle(widget);
178+
let (next_item, Some(focus_task)) = scroll_handle.list_focus_up(ids) else {
177179
return Task::none();
178180
};
179181

@@ -189,8 +191,8 @@ impl<'b> ComponentWidgetsMut<'b> {
189191
}
190192
RootWidgetMembers::Grid(widget) => {
191193
let ids = self.grid_collect_ids(widget);
192-
let state = self.state.scrollable_root_state_mut(widget.__id__);
193-
let (next_item, Some(focus_task)) = state.scroll_handle.grid_focus_up(ids) else {
194+
let mut scroll_handle = self.grid_scroll_handle(widget);
195+
let (next_item, Some(focus_task)) = scroll_handle.grid_focus_up(ids) else {
194196
return Task::none();
195197
};
196198

@@ -221,8 +223,8 @@ impl<'b> ComponentWidgetsMut<'b> {
221223
RootWidgetMembers::Form(_) => Task::none(),
222224
RootWidgetMembers::List(widget) => {
223225
let ids = self.list_collect_ids(widget);
224-
let state = self.state.scrollable_root_state_mut(widget.__id__);
225-
let (next_item, Some(focus_task)) = state.scroll_handle.list_focus_down(ids) else {
226+
let mut scroll_handle = self.list_scroll_handle(widget);
227+
let (next_item, Some(focus_task)) = scroll_handle.list_focus_down(ids) else {
226228
return Task::none();
227229
};
228230

@@ -243,8 +245,8 @@ impl<'b> ComponentWidgetsMut<'b> {
243245
};
244246

245247
let ids = self.grid_collect_ids(widget);
246-
let state = self.state.scrollable_root_state_mut(widget.__id__);
247-
let (next_item, Some(focus_task)) = state.scroll_handle.grid_focus_down(ids) else {
248+
let mut scroll_handle = self.grid_scroll_handle(widget);
249+
let (next_item, Some(focus_task)) = scroll_handle.grid_focus_down(ids) else {
248250
return Task::none();
249251
};
250252

@@ -278,8 +280,8 @@ impl<'b> ComponentWidgetsMut<'b> {
278280
RootWidgetMembers::List(_) => Task::none(),
279281
RootWidgetMembers::Grid(widget) => {
280282
let ids = self.grid_collect_ids(widget);
281-
let state = self.state.scrollable_root_state_mut(widget.__id__);
282-
let (next_item, Some(focus_task)) = state.scroll_handle.grid_focus_left(ids) else {
283+
let mut scroll_handle = self.grid_scroll_handle(widget);
284+
let (next_item, Some(focus_task)) = scroll_handle.grid_focus_left(ids) else {
283285
return Task::none();
284286
};
285287

@@ -312,8 +314,8 @@ impl<'b> ComponentWidgetsMut<'b> {
312314
RootWidgetMembers::List(_) => Task::none(),
313315
RootWidgetMembers::Grid(widget) => {
314316
let ids = self.grid_collect_ids(widget);
315-
let state = self.state.scrollable_root_state_mut(widget.__id__);
316-
let (next_item, Some(focus_task)) = state.scroll_handle.grid_focus_right(ids) else {
317+
let mut scroll_handle = self.grid_scroll_handle(widget);
318+
let (next_item, Some(focus_task)) = scroll_handle.grid_focus_right(ids) else {
317319
return Task::none();
318320
};
319321

@@ -443,4 +445,80 @@ impl<'b> ComponentWidgetsMut<'b> {
443445

444446
result
445447
}
448+
449+
pub fn list_id_for_id(&self, widget: &ListWidget, target_item_id: &String) -> Option<container::Id> {
450+
for members in &widget.content.ordered_members {
451+
match &members {
452+
ListWidgetOrderedMembers::ListItem(item) => {
453+
let state = self.state.scrollable_item_state(item.__id__);
454+
if &item.id == target_item_id {
455+
return Some(state.id.clone());
456+
}
457+
}
458+
ListWidgetOrderedMembers::ListSection(section) => {
459+
for members in &section.content.ordered_members {
460+
match &members {
461+
ListSectionWidgetOrderedMembers::ListItem(item) => {
462+
let state = self.state.scrollable_item_state(item.__id__);
463+
if &item.id == target_item_id {
464+
return Some(state.id.clone());
465+
}
466+
}
467+
}
468+
}
469+
}
470+
}
471+
}
472+
473+
None
474+
}
475+
476+
pub fn grid_id_for_id(&self, widget: &GridWidget, target_item_id: &String) -> Option<container::Id> {
477+
for members in &widget.content.ordered_members {
478+
match &members {
479+
GridWidgetOrderedMembers::GridItem(item) => {
480+
let state = self.state.scrollable_item_state(item.__id__);
481+
if &item.id == target_item_id {
482+
return Some(state.id.clone());
483+
}
484+
}
485+
GridWidgetOrderedMembers::GridSection(section) => {
486+
for members in &section.content.ordered_members {
487+
match &members {
488+
GridSectionWidgetOrderedMembers::GridItem(item) => {
489+
let state = self.state.scrollable_item_state(item.__id__);
490+
if &item.id == target_item_id {
491+
return Some(state.id.clone());
492+
}
493+
}
494+
}
495+
}
496+
}
497+
}
498+
}
499+
500+
None
501+
}
502+
503+
fn grid_scroll_handle(&self, widget: &GridWidget) -> ScrollHandle {
504+
let state = self.state.scrollable_root_state(widget.__id__);
505+
match &widget.focused_item_id {
506+
JsOption::Undefined => state.scroll_handle.clone(),
507+
JsOption::Null => ScrollHandle::from(&state.scroll_handle, None),
508+
JsOption::Value(focused_item_id) => {
509+
ScrollHandle::from(&state.scroll_handle, self.grid_id_for_id(widget, focused_item_id))
510+
}
511+
}
512+
}
513+
514+
fn list_scroll_handle(&self, widget: &ListWidget) -> ScrollHandle {
515+
let state = self.state.scrollable_root_state(widget.__id__);
516+
match &widget.focused_item_id {
517+
JsOption::Undefined => state.scroll_handle.clone(),
518+
JsOption::Null => ScrollHandle::from(&state.scroll_handle, None),
519+
JsOption::Value(focused_item_id) => {
520+
ScrollHandle::from(&state.scroll_handle, self.list_id_for_id(widget, focused_item_id))
521+
}
522+
}
523+
}
446524
}

0 commit comments

Comments
 (0)