diff --git a/examples/reference/layouts/Column.ipynb b/examples/reference/layouts/Column.ipynb index 399ea5347f..9cd5e54f08 100644 --- a/examples/reference/layouts/Column.ipynb +++ b/examples/reference/layouts/Column.ipynb @@ -22,6 +22,7 @@ "\n", "* **``objects``** (list): The list of objects to display in the Column, should not generally be modified directly except when replaced in its entirety.\n", "* **``scroll``** (boolean): Enable scrollbars if the content overflows the size of the container.\n", + "* **``scroll_index``** (int): The index of the object to scroll to. If set the Column will scroll to the object at the specified index and will reset back to None after scrolling.\n", "* **``scroll_position``** (int): Current scroll position of the Column. Setting this value will update the scroll position of the Column. Setting to 0 will scroll to the top.\n", "* **``auto_scroll_limit``** (int): Max pixel distance from the latest object in the Column to activate automatic scrolling upon update. Setting to 0 disables auto-scrolling\n", "* **``scroll_button_threshold``** (int): Min pixel distance from the latest object in the Column to display the scroll button. Setting to 0 disables the scroll button.\n", diff --git a/panel/layout/base.py b/panel/layout/base.py index d58286db4c..13df81f609 100644 --- a/panel/layout/base.py +++ b/panel/layout/base.py @@ -939,6 +939,12 @@ class Column(ListPanel): will update the scroll position of the Column. Setting to 0 will scroll to the top.""") + scroll_index = param.Integer(default=None, bounds=(0, None), doc=""" + The index of the object to scroll to. If set the Column will + scroll to the object at the specified index and will reset + back to None after scrolling.""", + allow_None=True) + view_latest = param.Boolean(default=False, doc=""" Whether to scroll to the latest object on init. If not enabled the view will be on the first object.""") @@ -967,6 +973,10 @@ def _set_scrollable(self): self.view_latest ) + @param.depends("scroll_index", watch=True) + def _reset_scroll_index(self): + self.scroll_index = None + class WidgetBox(ListPanel): """ diff --git a/panel/models/column.ts b/panel/models/column.ts index 477859697f..4e32ad42c7 100644 --- a/panel/models/column.ts +++ b/panel/models/column.ts @@ -19,10 +19,11 @@ export class ColumnView extends BkColumnView { override connect_signals(): void { super.connect_signals() - const {children, scroll_position, scroll_button_threshold} = this.model.properties + const {children, scroll_position, scroll_index, scroll_button_threshold} = this.model.properties this.on_change(children, () => this.trigger_auto_scroll()) this.on_change(scroll_position, () => this.scroll_to_position()) + this.on_change(scroll_index, () => this.scroll_to_index()) this.on_change(scroll_button_threshold, () => this.toggle_scroll_button()) } @@ -30,6 +31,34 @@ export class ColumnView extends BkColumnView { return this.el.scrollHeight - this.el.scrollTop - this.el.clientHeight } + scroll_to_index(): void { + const index = this.model.scroll_index + if (index === null) { + return + } + + if (index >= this.model.children.length) { + console.warn(`Invalid scroll index: ${index}`) + return + } + + // Get the child view at the specified index + const childView = this.child_views[index] + if (!childView) { + console.warn(`Child view not found for index: ${index}`) + return + } + + // Get the top position of the child element relative to the column + const childEl = childView.el + const childRect = childEl.getBoundingClientRect() + const columnRect = this.el.getBoundingClientRect() + const relativeTop = childRect.top - columnRect.top + this.el.scrollTop + + // Scroll to the child's position + this.model.scroll_position = Math.round(relativeTop) + } + scroll_to_position(): void { if (this._updating) { return @@ -106,6 +135,7 @@ export namespace Column { export type Attrs = p.AttrsOf export type Props = BkColumn.Props & { scroll_position: p.Property + scroll_index: p.Property auto_scroll_limit: p.Property scroll_button_threshold: p.Property view_latest: p.Property @@ -126,8 +156,9 @@ export class Column extends BkColumn { static { this.prototype.default_view = ColumnView - this.define(({Int, Bool}) => ({ + this.define(({Int, Bool, Nullable}) => ({ scroll_position: [Int, 0], + scroll_index: [Nullable(Int), null], auto_scroll_limit: [Int, 0], scroll_button_threshold: [Int, 0], view_latest: [Bool, false], diff --git a/panel/models/layout.py b/panel/models/layout.py index 98cae44570..7e9fd0c880 100644 --- a/panel/models/layout.py +++ b/panel/models/layout.py @@ -25,6 +25,13 @@ class Column(BkColumn): 0 will scroll to the top.""" ) + scroll_index = Nullable( + Int, + help=""" + Index of the object to scroll to. Setting this value will + scroll the Column to the object at the given index.""" +) + auto_scroll_limit = Int( default=0, help=""" diff --git a/panel/tests/ui/layout/test_column.py b/panel/tests/ui/layout/test_column.py index b746874754..0a125362f2 100644 --- a/panel/tests/ui/layout/test_column.py +++ b/panel/tests/ui/layout/test_column.py @@ -236,3 +236,33 @@ def test_column_scroll_position_param_updated(page): column = page.locator(".bk-panel-models-layout-Column") expect(column).to_have_js_property('scrollTop', 175) + + +def test_column_scroll_index(page): + col = Column( + *list(range(100)), + height=300, + sizing_mode="fixed", + scroll=True, + ) + + serve_component(page, col) + + page.wait_for_timeout(200) + + # start at 0 + column_el = page.locator(".bk-panel-models-layout-Column") + expect(column_el).to_have_js_property('scrollTop', 0) + + # scroll to 50 + col.scroll_index = 50 + expect(column_el).to_have_js_property('scrollTop', 1362) + assert col.scroll_index is None + + # scroll away using mouse wheel + column_el.evaluate('(el) => el.scrollTo({top: 1000})') + expect(column_el).to_have_js_property('scrollTop', 1000) + + # scroll to 50 again + col.scroll_index = 50 + expect(column_el).to_have_js_property('scrollTop', 1362)