Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
* Hardened the grid column filter's Custom tab against filters it previously mishandled - multi-value
clauses are now expanded into editable rows and recombined on commit, and filters it cannot
represent are left untouched rather than corrupted.
* Fixed `FilterChooser` popover mode (formerly `PopoverFilterChooser`) so its collapsed control no
longer disappears when opened - it now always occupies its place in the layout, so surrounding
elements no longer shift. Its clear and favorites controls also respond to a single click rather
than requiring the popover to be opened first. This mode is now enabled more naturally via
a new option `filterChooser({popover: true})`, deprecating `PopoverFilterChooser`, which remains
as a thin alias.

### ⚙️ Typescript API Adjustments

Expand Down
5 changes: 3 additions & 2 deletions admin/tabs/activity/tracking/ActivityTrackingPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {grid} from '@xh/hoist/cmp/grid';
import {div, filler, hframe} from '@xh/hoist/cmp/layout';
import {creates, hoistCmp} from '@xh/hoist/core';
import {button, buttonGroup, colChooserButton, exportButton} from '@xh/hoist/desktop/cmp/button';
import {popoverFilterChooser} from '@xh/hoist/desktop/cmp/filter';
import {filterChooser} from '@xh/hoist/desktop/cmp/filter';
import {formField} from '@xh/hoist/desktop/cmp/form';
import {groupingChooser} from '@xh/hoist/desktop/cmp/grouping';
import {dateInput, DateInputProps, select} from '@xh/hoist/desktop/cmp/input';
Expand Down Expand Up @@ -119,7 +119,8 @@ const filterChooserToggleButton = hoistCmp.factory<ActivityTrackingModel>(({mode
const filterBar = hoistCmp.factory<ActivityTrackingModel>(({model}) => {
return model.showFilterChooser
? toolbar(
popoverFilterChooser({
filterChooser({
popover: true,
flex: 1,
enableClear: true
})
Expand Down
4 changes: 2 additions & 2 deletions admin/tabs/userData/roles/RolePanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {fragment, hframe, vframe} from '@xh/hoist/cmp/layout';
import {creates, hoistCmp} from '@xh/hoist/core';
import {button, colChooserButton} from '@xh/hoist/desktop/cmp/button';
import {errorMessage} from '@xh/hoist/cmp/error';
import {popoverFilterChooser} from '@xh/hoist/desktop/cmp/filter';
import {filterChooser} from '@xh/hoist/desktop/cmp/filter';
import {switchInput} from '@xh/hoist/desktop/cmp/input';
import {panel} from '@xh/hoist/desktop/cmp/panel';
import {recordActionBar} from '@xh/hoist/desktop/cmp/record';
Expand Down Expand Up @@ -44,7 +44,7 @@ export const rolePanel = hoistCmp.factory({
selModel: gridModel.selModel
}),
'-',
popoverFilterChooser({flex: 1}),
filterChooser({popover: true, flex: 1}),
'-',
switchInput({
bind: 'showInGroups',
Expand Down
41 changes: 41 additions & 0 deletions desktop/cmp/filter/FilterChooser.scss
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,47 @@
margin: 7px 4px;
align-self: start;
}

// Popover mode - collapsed trigger expands into an overlaid popover.
&--popover > .bp6-popover-target {
display: flex;
flex: 1;
}

&__trigger {
.xh-select__value-container--is-multi {
overflow-y: hidden !important;
}
}

// Extra class names required to override the default styles of the popover.
&__popover.bp6-popover.bp6-minimal {
box-shadow: none;

// Overlay the expanded input onto the collapsed 30px trigger. Offset follows Blueprint's
// resolved placement, which flips (bottom-anchored) near the viewport's bottom edge.
&.bp6-popover-placement-bottom {
margin-top: -30px !important;
}
&.bp6-popover-placement-top {
margin-bottom: -30px !important;
}

// Content is portaled out of .xh-filter-chooser - restate the flex rule so it fills the width.
.bp6-popover-target {
display: flex;
flex: 1;
}

.bp6-popover-content {
background: transparent;
}

.xh-select__value-container--is-multi {
height: unset;
line-height: unset;
}
}
}

.xh-filter-chooser-option {
Expand Down
154 changes: 147 additions & 7 deletions desktop/cmp/filter/FilterChooser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,23 @@
*/
import {FilterChooserFilter, FilterChooserModel} from '@xh/hoist/cmp/filter';
import {box, div, hbox, hframe, vbox} from '@xh/hoist/cmp/layout';
import {hoistCmp, HoistProps, LayoutProps, uses} from '@xh/hoist/core';
import {
hoistCmp,
HoistModel,
HoistProps,
LayoutProps,
lookup,
useLocalModel,
uses
} from '@xh/hoist/core';
import {bindable, makeObservable} from '@xh/hoist/mobx';
import {button} from '@xh/hoist/desktop/cmp/button';
import {select} from '@xh/hoist/desktop/cmp/input';
import '@xh/hoist/desktop/register';
import {Icon} from '@xh/hoist/icon';
import {menu, menuDivider, menuItem, popover} from '@xh/hoist/kit/blueprint';
import {withDefault} from '@xh/hoist/utils/js';
import {splitLayoutProps} from '@xh/hoist/utils/react';
import {elemWithin, withDefault} from '@xh/hoist/utils/js';
import {getLayoutProps, splitLayoutProps} from '@xh/hoist/utils/react';
import classNames from 'classnames';
import {isEmpty, sortBy} from 'lodash';
import {badge} from '@xh/hoist/cmp/badge';
Expand Down Expand Up @@ -41,6 +50,11 @@ export interface FilterChooserProps extends HoistProps<FilterChooserModel>, Layo
placeholder?: string;
/** Icon clicked to launch favorites menu. (Defaults to Icon.favorite()) */
favoritesIcon?: ReactElement;
/**
* True to render collapsed in-place, expanding into a popover when opened - useful in toolbars
* and other height-constrained containers. Opens in the direction set by `menuPlacement`.
*/
popover?: boolean;
}

/**
Expand All @@ -50,6 +64,30 @@ export interface FilterChooserProps extends HoistProps<FilterChooserModel>, Layo
export const [FilterChooser, filterChooser] = hoistCmp.withFactory<FilterChooserProps>({
model: uses(FilterChooserModel),
className: 'xh-filter-chooser',
render({model, className, ...props}, ref) {
return props.popover
? popoverFilterChooser({model, className, ...props, ref})
: filterChooserControl({model, className, ...props, ref});
}
});

//------------------
// Implementation
//------------------
interface FilterChooserControlProps extends FilterChooserProps {
/** Internal - false to render a non-interactive display (the collapsed popover trigger). */
xhInteractive?: boolean;
/** Internal - override for the favorites menu open state. */
xhFavoritesOpen?: boolean;
}

/**
* The Select-based control shared by the inline FilterChooser and both faces (collapsed trigger and
* expanded content) of the popover variant. Only the interactive instance binds `model.inputRef`,
* so the popover can mount trigger and content simultaneously without contention.
*/
const filterChooserControl = hoistCmp.factory<FilterChooserControlProps>({
model: uses(FilterChooserModel),
render({model, className, ...props}, ref) {
const [layoutProps, chooserProps] = splitLayoutProps(props),
{
Expand All @@ -68,7 +106,9 @@ export const [FilterChooser, filterChooser] = hoistCmp.withFactory<FilterChooser
maxMenuHeight,
menuPlacement,
menuWidth,
favoritesIcon
favoritesIcon,
xhInteractive = true,
xhFavoritesOpen
} = chooserProps,
disabled = unsupportedFilter || chooserProps.disabled,
placeholder = unsupportedFilter
Expand All @@ -90,7 +130,8 @@ export const [FilterChooser, filterChooser] = hoistCmp.withFactory<FilterChooser
flex: 1,
height: layoutProps?.height,
bind: 'selectValue',
ref: inputRef,
// Only the interactive instance owns the shared inputRef.
ref: xhInteractive ? inputRef : undefined,

autoFocus,
disabled,
Expand Down Expand Up @@ -119,12 +160,15 @@ export const [FilterChooser, filterChooser] = hoistCmp.withFactory<FilterChooser
},
components: {
DropdownIndicator: () => favoritesIconCmp(model, favoritesIcon)
}
},
// Display-only trigger: suppress menu + typing, but leave clear and
// favorites affordances live for single-click access.
...(xhInteractive ? {} : {menuIsOpen: false, isSearchable: false})
}
})
),
content: favoritesMenu(),
isOpen: favoritesIsOpen,
isOpen: xhFavoritesOpen ?? favoritesIsOpen,
position: 'bottom-right',
minimal: true,
onInteraction: willOpen => {
Expand All @@ -136,6 +180,102 @@ export const [FilterChooser, filterChooser] = hoistCmp.withFactory<FilterChooser
}
});

/**
* Wraps a FilterChooser so it renders collapsed in-place and expands into a popover when opened,
* allowing it to grow vertically beyond the height of a toolbar. The collapsed trigger always
* occupies its space (so surrounding layout never shifts) and routes single clicks on its clear
* and favorites controls directly, rather than first opening the popover.
*/
const popoverFilterChooser = hoistCmp.factory<FilterChooserProps>({
model: uses(FilterChooserModel),
render({model, className, ...props}, ref) {
const impl = useLocalModel(FilterChooserLocalModel),
{popoverIsOpen} = impl,
{popover: _popover, ...rest} = props,
layoutProps = getLayoutProps(rest);

return box({
ref,
className: classNames(className, 'xh-filter-chooser--popover'),
...layoutProps,
item: popover({
isOpen: popoverIsOpen,
popoverClassName: 'xh-filter-chooser__popover',
matchTargetWidth: true,
minimal: true,
position: 'bottom',
item: filterChooserControl({
model,
flex: 1,
className: 'xh-filter-chooser__trigger',
...rest,
displayCount: true,
xhInteractive: false,
// Trigger hosts the favorites menu only while collapsed; once open, the
// expanded content takes over.
xhFavoritesOpen: model.favoritesIsOpen && !popoverIsOpen
}),
content: filterChooserControl({
model,
flex: 1,
className: 'xh-filter-chooser__content',
...rest,
displayCount: true
}),
onInteraction: (willOpen, e) => {
if (willOpen) {
// Let clicks on the inline clear / favorites controls act directly.
const target = e?.target as HTMLElement;
if (
target &&
(elemWithin(target, 'xh-select__clear-indicator') ||
elemWithin(target, 'xh-filter-chooser-favorite-icon'))
) {
return;
}
impl.open();
} else {
impl.close();
}
}
})
});
}
});

class FilterChooserLocalModel extends HoistModel {
override xhImpl = true;

@lookup(FilterChooserModel)
model: FilterChooserModel;

@bindable
popoverIsOpen: boolean = false;

constructor() {
super();
makeObservable(this);
}

open() {
this.popoverIsOpen = true;

// Focus the (now-mounted) interactive input and open its menu once available.
this.addReaction({
when: () => !!this.model.inputRef.current,
run: () => {
const inputRef = this.model.inputRef.current;
inputRef.focus();
(inputRef as any).reactSelectRef.current?.openMenu('first');
}
});
}

close() {
this.popoverIsOpen = false;
}
}

//-----------------
// Options
//------------------
Expand Down
42 changes: 0 additions & 42 deletions desktop/cmp/filter/PopoverFilterChooser.scss

This file was deleted.

Loading
Loading