diff --git a/src/Check.ts b/src/Check.ts new file mode 100644 index 0000000000000..da7523f37b386 --- /dev/null +++ b/src/Check.ts @@ -0,0 +1 @@ +export * from './components/Check/index'; diff --git a/src/GroupedList.ts b/src/GroupedList.ts new file mode 100644 index 0000000000000..c55c281b2c37c --- /dev/null +++ b/src/GroupedList.ts @@ -0,0 +1 @@ +export * from './components/GroupedList/index'; diff --git a/src/components/DetailsList/Check.scss b/src/components/Check/Check.scss similarity index 100% rename from src/components/DetailsList/Check.scss rename to src/components/Check/Check.scss diff --git a/src/components/DetailsList/Check.tsx b/src/components/Check/Check.tsx similarity index 100% rename from src/components/DetailsList/Check.tsx rename to src/components/Check/Check.tsx diff --git a/src/components/Check/index.ts b/src/components/Check/index.ts new file mode 100644 index 0000000000000..96b6c5ff96424 --- /dev/null +++ b/src/components/Check/index.ts @@ -0,0 +1 @@ +export * from './Check'; \ No newline at end of file diff --git a/src/components/DetailsList/DetailsGroup.scss b/src/components/DetailsList/DetailsGroup.scss deleted file mode 100644 index e4e03499f94ca..0000000000000 --- a/src/components/DetailsList/DetailsGroup.scss +++ /dev/null @@ -1,7 +0,0 @@ -@import '../../common/common'; - -.ms-DetailsList-group { - &.is-dropping { - background-color: $ms-color-neutralLight; - } -} diff --git a/src/components/DetailsList/DetailsHeader.tsx b/src/components/DetailsList/DetailsHeader.tsx index 63f518b127d63..509c3ce4edcc9 100644 --- a/src/components/DetailsList/DetailsHeader.tsx +++ b/src/components/DetailsList/DetailsHeader.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { IColumn, DetailsListLayoutMode, ColumnActionsMode } from './DetailsList.Props'; import { FocusZone, FocusZoneDirection } from '../../FocusZone'; -import { Check } from './Check'; -import { GroupSpacer } from './GroupSpacer'; +import { Check } from '../Check/Check'; +import { GroupSpacer } from '../GroupedList/GroupSpacer'; import { css } from '../../utilities/css'; import { ISelection, SelectionMode, SELECTION_CHANGE } from '../../utilities/selection/interfaces'; import { getRTL } from '../../utilities/rtl'; diff --git a/src/components/DetailsList/DetailsList.Props.ts b/src/components/DetailsList/DetailsList.Props.ts index fe43f89dc8938..4641176c4f96a 100644 --- a/src/components/DetailsList/DetailsList.Props.ts +++ b/src/components/DetailsList/DetailsList.Props.ts @@ -8,6 +8,10 @@ import { IDragDropEvents, IDragDropContext } from './../../utilities/dragdrop/interfaces'; +import { + IGroup, + IGroupRenderProps +} from '../GroupedList/index'; import { IViewport } from '../../utilities/decorators/withViewport'; export interface IDetailsList { @@ -29,11 +33,11 @@ export interface IDetailsListProps extends React.Props { /** Optional class name to add to the root element. */ className?: string; - /** Optional grouping instructions. */ + /** Optional grouping instructions. The definition for IGroup can be found under the GroupedList component. */ groups?: IGroup[]; - /** Optional override properties to render groups. */ - groupProps?: IDetailsGroupProps; + /** Optional override properties to render groups. The definition for IGroupRenderProps can be found under the GroupedList component. */ + groupProps?: IGroupRenderProps; /** Optional selection model to track selection state. */ selection?: ISelection; @@ -304,113 +308,3 @@ export enum CheckboxVisibility { */ always } - -export interface IGroup { - /** - * Unique identifier for the group. - */ - key: string; - - /** - * Display name for the group, rendered on the header. - */ - name: string; - - /** - * Start index for the group within the given items. - */ - startIndex: number; - - /** - * How many items should be rendered within the group. - */ - count: number; - - /** - * Nested groups, if any. - */ - children?: IGroup[]; - - /** - * Number indicating the level of nested groups. - */ - level?: number; - - /** - * If all the items in the group are selected. - */ - isSelected?: boolean; - - /** - * If all the items in the group are collapsed. - */ - isCollapsed?: boolean; - - /** - * If the items within the group are summarized or showing all. - */ - isShowingAll?: boolean; - - /** - * If drag/drop is enabled for the group header. - */ - isDropEnabled?: boolean; - - /** - * Arbitrary data required to be preserved by the caller. - */ - data?: any; - - /** - * Override which allows the caller to provide a custom header. - */ - onRenderHeader?: (group: IGroup) => React.ReactNode; - - /** - * Override which allows the caller to provider a customer footer. - */ - onRenderFooter?: (group: IGroup) => React.ReactNode; -} - -export interface IDetailsGroupProps { - /** Boolean indicating if all groups are in collapsed state. */ - isAllGroupsCollapsed?: boolean; - - /** Grouping item limit. */ - getGroupItemLimit?: (group: IGroup) => number; - - /** Callback for when all groups are expanded or collapsed. */ - onToggleCollapseAll?: (isAllCollapsed: boolean) => void; - - /** Information to pass in to the group header. */ - headerProps?: IDetailsGroupHeaderProps; - - /** Information to pass in to the group footer. */ - footerProps?: IDetailsGroupFooterProps; -} - -export interface IDetailsGroupHeaderProps { - /** Callback to determine if a group has missing items and needs to load them from the server. */ - isGroupLoading?: (group: IGroup) => boolean; - - /** Text shown on group headers to indicate the group is being loaded. */ - loadingText?: string; - - /** Callback for when the group header is clicked. */ - onGroupHeaderClick?: (group: IGroup) => void; - - /** Callback for when the group is expanded or collapsed. */ - onToggleCollapse?: (group: IGroup) => void; - - /** Callback for when the group is selected. */ - onToggleSelectGroup?: (group: IGroup) => void; -} - -export interface IDetailsGroupFooterProps { - /** Callback for when the "Show All" link in group footer is clicked */ - onToggleSummarize?: (group: IGroup) => void; - - /** Text to display for the group footer show all link. */ - showAllLinkText?: string; -} - diff --git a/src/components/DetailsList/DetailsList.tsx b/src/components/DetailsList/DetailsList.tsx index 23b4e26741721..501d8d5b11569 100644 --- a/src/components/DetailsList/DetailsList.tsx +++ b/src/components/DetailsList/DetailsList.tsx @@ -1,20 +1,17 @@ import * as React from 'react'; import { + IDetailsListProps, ColumnActionsMode, ConstrainMode, DetailsListLayoutMode, IColumn, - IDetailsGroupFooterProps, - IDetailsGroupHeaderProps, IDetailsList, - IDetailsListProps, - IGroup, CheckboxVisibility -} from './DetailsList.Props'; -import { DetailsGroup } from './DetailsGroup'; -import { DetailsHeader } from './DetailsHeader'; -import { DetailsRow } from './DetailsRow'; +} from '../DetailsList/DetailsList.Props'; +import { DetailsHeader } from '../DetailsList/DetailsHeader'; +import { DetailsRow } from '../DetailsList/DetailsRow'; import { FocusZone, FocusZoneDirection } from '../../FocusZone'; +import { GroupedList } from '../GroupedList'; import { withViewport } from '../../utilities/decorators/withViewport'; import { assign } from '../../utilities/object'; import { css } from '../../utilities/css'; @@ -36,9 +33,9 @@ export interface IDetailsListState { lastSelectionMode?: SelectionMode; adjustedColumns?: IColumn[]; layoutMode?: DetailsListLayoutMode; - groups?: IGroup[]; - isAllCollapsed?: boolean; + isCollapsed?: boolean; isSizing?: boolean; + isDropping?: boolean; } const MIN_COLUMN_WIDTH = 100; // this is the global min width @@ -46,6 +43,9 @@ const CHECKBOX_WIDTH = 36; const GROUP_EXPAND_WIDTH = 36; const DEFAULT_INNER_PADDING = 16; +const DEFAULT_RENDERED_WINDOWS_AHEAD = 2; +const DEFAULT_RENDERED_WINDOWS_BEHIND = 2; + @withViewport export class DetailsList extends React.Component implements IDetailsList { public static defaultProps = { @@ -60,6 +60,7 @@ export class DetailsList extends React.Component ( - (!group || group.count > 0) ? ( - - ) : null - )); - return ( // If shouldApplyApplicationRole is true, role application will be applied to make arrow keys work // with JAWS. @@ -264,8 +210,8 @@ export class DetailsList extends React.Component @@ -282,9 +228,23 @@ export class DetailsList extends React.Component - { renderedGroups } + - @@ -296,6 +256,58 @@ export class DetailsList extends React.Component + ); + } + private _onColumnIsSizingChanged(column: IColumn, isSizing: boolean) { this.setState({ isSizing: isSizing }); } @@ -318,14 +330,8 @@ export class DetailsList extends React.Component 0) { - group.children.forEach((childGroup: IGroup) => { - this._selectGroup(childGroup, isSelected); - }); - } else { - let getGroupItemLimit = groupProps && groupProps.getGroupItemLimit; - let groupItemLimit = getGroupItemLimit ? getGroupItemLimit(group) : items.length; - let start = group.startIndex; - let end = group.startIndex + Math.min(group.count, groupItemLimit); - for (let idx = start; idx < end; idx++) { - this._selection.setIndexSelected(idx, isSelected, false /* shouldAnchor */); - } - } - } - - private _forceListUpdates(groups?: IGroup[]) { - groups = groups || this.state.groups; - - let groupCount = groups ? groups.length : 1; + private _onToggleCollapse(collapsed: boolean) { + this.setState({ + isCollapsed: collapsed + }); - for (let i = 0; i < groupCount; i++) { - let detailsGroup = this.refs['detailsGroup_' + String(i)] as DetailsGroup; - if (detailsGroup) { - detailsGroup.forceListUpdate(); - } - } + this.forceUpdate(); } - private _onToggleSummarize(group: IGroup) { - let { groups } = this.state; - let { groupProps } = this.props; - let onToggleSummarize = groupProps && groupProps.footerProps && groupProps.footerProps.onToggleSummarize; - - if (onToggleSummarize) { - onToggleSummarize(group); - } else { - if (group) { - group.isShowingAll = !group.isShowingAll; - - this.setState({ - groups: groups - }); - } - } + private _forceListUpdates() { + this.refs.groups.forceUpdate(); } private _adjustColumns(newProps: IDetailsListProps, forceUpdate?: boolean, layoutMode?: DetailsListLayoutMode) { @@ -529,7 +446,10 @@ export class DetailsList extends React.Component { - /** The items to render. */ - items: any[]; - - /** Given column definitions */ - columns?: IColumn[]; - - /** Optional grouping instructions. */ - group?: IGroup; - - /** Optional grouping instructions. */ - groupIndex?: number; - /** Optional group nesting level. */ - groupNestingDepth?: number; +export interface IGroupProps extends React.Props { + /** Map of callback functions related to drag and drop functionality. */ + dragDropEvents?: IDragDropEvents; - /** Optional selection model to track selection state. */ - selection?: ISelection; + /** helper to manage drag/drop across item rows and groups */ + dragDropHelper?: IDragDropHelper; - /** Controls how/if the details list manages selection. */ - selectionMode?: SelectionMode; + /** Event names and corresponding callbacks that will be registered to the group and the rendered elements */ + eventsToRegister?: [{ eventName: string, callback: (context: IDragDropContext, event?: any) => void }]; - /** Controls the visibility of selection check box. */ - checkboxVisibility?: CheckboxVisibility; + /** Information to pass in to the group footer. */ + footerProps?: IGroupFooterProps; /** Grouping item limit. */ getGroupItemLimit?: (group: IGroup) => number; - /** Event names and corresponding callbacks that will be registered to the group and the rendered row elements */ - eventsToRegister?: [{ eventName: string, callback: (context: IDragDropContext, event?: any) => void }]; - - /** Callback for when a given row has been mounted. Useful for identifying when a row has been rendered on the page. */ - onRowDidMount?: (item?: any, index?: number) => void; - - /** Callback for when a given row has been mounted. Useful for identifying when a row has been removed from the page. */ - onRowWillUnmount?: (item?: any, index?: number) => void; - - /** - * If provided, will be the "default" cell renderer method. This affects cells within the rows; not the rows themselves. - * If a column definition provides its own onRender method, that will be used instead of this. - */ - onRenderItemColumn?: (item?: any, index?: number, column?: IColumn) => any; - - /** Map of callback functions related to drag and drop functionality. */ - dragDropEvents?: IDragDropEvents; + /** Optional grouping instructions. */ + groupIndex?: number; - /** Callback for what to render when the item is missing. */ - onRenderMissingItem?: (index?: number) => React.ReactNode; + /** Optional group nesting level. */ + groupNestingDepth?: number; - /** helper to manage drag/drop across item rows and groups */ - dragDropHelper?: IDragDropHelper; + /** Optional grouping instructions. */ + group?: IGroup; /** Information to pass in to the group header. */ - headerProps?: IDetailsGroupHeaderProps; + headerProps?: IGroupHeaderProps; - /** Information to pass in to the group footer. */ - footerProps?: IDetailsGroupFooterProps; + /** List of items to render. */ + items: any[]; - /** Viewport, provided by the withViewport decorator. */ - viewport?: IViewport; + /** Optional list props to pass to list renderer. */ + listProps?: any; - /** Is the list resizing a column */ - isSizing?: boolean; + /** Rendering callback to render the group items. */ + onRenderCell: ( + nestingDepth?: number, + item?: any, + index?: number + ) => React.ReactNode; - /** Callback to get the aria-label string for a given item. */ - getRowAriaLabel?: (item: any) => string; + /** Optional selection model to track selection state. */ + selection?: ISelection; - /** Optional callback to determine if an item is selectable. */ - canSelectItem?: (item: any) => boolean; + /** Controls how/if the details list manages selection. */ + selectionMode?: SelectionMode; - /** Check button aria label for details list. */ - checkButtonAriaLabel?: string; + /** Optional Viewport, provided by the parent component. */ + viewport?: IViewport; } -export interface IDetailsGroupState { +export interface IGroupState { isDropping?: boolean; } const DEFAULT_DROPPING_CSS_CLASS = 'is-dropping'; -const DEFAULT_RENDERED_WINDOWS_AHEAD = 2; -const DEFAULT_RENDERED_WINDOWS_BEHIND = 2; -export class DetailsGroup extends React.Component { +export class Group extends React.Component { public refs: { [key: string]: React.ReactInstance, root: HTMLElement, + footer: GroupFooter, + header: GroupHeader, list: List }; private _events: EventGroup; private _dragDropKey: string; - constructor(props: IDetailsGroupProps) { + constructor(props: IGroupProps) { super(props); this.state = { isDropping: false }; - this._onRenderCell = this._onRenderCell.bind(this); this._getGroupDragDropOptions = this._getGroupDragDropOptions.bind(this); this._updateDroppingState = this._updateDroppingState.bind(this); + this._renderSubGroup = this._renderSubGroup.bind(this); this._events = new EventGroup(this); } @@ -151,71 +129,25 @@ export class DetailsGroup extends React.Component renderCount; - let nestedGroups = null; let hasNestedGroups = group && group.children && group.children.length > 0; - if (hasNestedGroups) { - nestedGroups = group.children.map((subGroup: IGroup, subGroupIndex: number) => ( - (!subGroup || subGroup.count > 0) ? ( - - ) : null - )); - } return (
- { group && group.onRenderHeader ? + className={ css('ms-GroupedList-group', this._getDroppingClassName()) } + > + { + group && group.onRenderHeader ? group.onRenderHeader(group) : + ( + 1 } + /> + ) : + this._onRenderGroup(renderCount) ) } { @@ -262,78 +192,106 @@ export class DetailsGroup extends React.Component 0) { - let subGroupCount = group.children.length; - - for (let i = 0; i < subGroupCount; i++) { - let subGroup = this.refs['subGroup_' + String(i)] as DetailsGroup; - if (subGroup) { - subGroup.forceListUpdate(); + if (group && group.children && group.children.length > 0) { + let subGroupCount = group.children.length; + + for (let i = 0; i < subGroupCount; i++) { + let subGroup = this.refs.list.refs['subGroup_' + String(i)] as Group; + if (subGroup) { + subGroup.forceListUpdate(); + } } } + } else { + let subGroup = this.refs['subGroup_' + String(0)] as Group; + if (subGroup) { + subGroup.forceListUpdate(); + } } } - private _getGroupKey(group: IGroup, groupIndex: number): string { - return 'group-' + (group ? - group.key + '-' + group.count : - ''); + private _onRenderGroup(renderCount: number) { + let { + group, + items, + onRenderCell, + listProps + } = this.props; + let count = group ? group.count : items.length; + let startIndex = group ? group.startIndex : 0; + let level = group ? group.level : 0; + + return ( +
+ onRenderCell(level, item, itemIndex) } + ref={ 'list' } + renderCount={ Math.min(count, renderCount) } + startIndex={ startIndex } + { ...listProps } + /> +
+ ); } - private _onRenderCell(item: any, index: number): React.ReactNode { + private _renderSubGroup(subGroup, subGroupIndex) { let { - columns, dragDropEvents, dragDropHelper, eventsToRegister, + getGroupItemLimit, groupNestingDepth, - onRenderMissingItem, - onRowDidMount, - onRowWillUnmount, - onRenderItemColumn, + items, + headerProps, + footerProps, + listProps, + onRenderCell, selection, selectionMode, - viewport, - checkboxVisibility, - getRowAriaLabel, - canSelectItem, - checkButtonAriaLabel + viewport } = this.props; - if (!item) { - if (onRenderMissingItem) { - onRenderMissingItem(index); - } - - return null; - } + return (!subGroup || subGroup.count > 0) ? ( + + ) : null; + } - return ( - - ); + private _getGroupKey(group: IGroup, groupIndex: number): string { + return 'group-' + (group ? + group.key + '-' + group.count : + ''); } /** diff --git a/src/components/DetailsList/GroupFooter.scss b/src/components/GroupedList/GroupFooter.scss similarity index 100% rename from src/components/DetailsList/GroupFooter.scss rename to src/components/GroupedList/GroupFooter.scss diff --git a/src/components/DetailsList/GroupFooter.tsx b/src/components/GroupedList/GroupFooter.tsx similarity index 78% rename from src/components/DetailsList/GroupFooter.tsx rename to src/components/GroupedList/GroupFooter.tsx index 43dcdfbe9869e..14691145fee7d 100644 --- a/src/components/DetailsList/GroupFooter.tsx +++ b/src/components/GroupedList/GroupFooter.tsx @@ -1,21 +1,21 @@ import * as React from 'react'; import { Link } from '../../Link'; import { - IDetailsGroupFooterProps, + IGroupFooterProps, IGroup, - } from './DetailsList.Props'; +} from './GroupedList.Props'; import { GroupSpacer } from './GroupSpacer'; import './GroupFooter.scss'; -export interface IGroupFooterProps { +export interface IGroupFooter { group: IGroup; groupIndex: number; groupLevel: number; - footerProps?: IDetailsGroupFooterProps; + footerProps?: IGroupFooterProps; } -export class GroupFooter extends React.Component { - constructor(props: IGroupFooterProps) { +export class GroupFooter extends React.Component { + constructor(props: IGroupFooter) { super(props); this._onToggleSummarize = this._onToggleSummarize.bind(this); diff --git a/src/components/DetailsList/GroupHeader.scss b/src/components/GroupedList/GroupHeader.scss similarity index 100% rename from src/components/DetailsList/GroupHeader.scss rename to src/components/GroupedList/GroupHeader.scss diff --git a/src/components/DetailsList/GroupHeader.tsx b/src/components/GroupedList/GroupHeader.tsx similarity index 90% rename from src/components/DetailsList/GroupHeader.tsx rename to src/components/GroupedList/GroupHeader.tsx index c0220dcab663c..c08f3a4e89711 100644 --- a/src/components/DetailsList/GroupHeader.tsx +++ b/src/components/GroupedList/GroupHeader.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; import { - IDetailsGroupHeaderProps, + IGroupHeaderProps, IGroup } from './index'; -import { Check } from './Check'; +import { Check } from '../Check/Check'; import { GroupSpacer } from './GroupSpacer'; import { Spinner } from '../../Spinner'; import { FocusZone, FocusZoneDirection } from '../../FocusZone'; @@ -11,11 +11,11 @@ import { css } from '../../utilities/css'; import { IViewport } from '../../utilities/decorators/withViewport'; import './GroupHeader.scss'; -export interface IGroupHeaderProps { +export interface IGroupHeader { group: IGroup; groupIndex: number; groupLevel: number; - headerProps?: IDetailsGroupHeaderProps; + headerProps?: IGroupHeaderProps; viewport?: IViewport; } @@ -24,8 +24,8 @@ export interface IGroupHeaderState { isLoadingVisible: boolean; } -export class GroupHeader extends React.Component { - constructor(props: IGroupHeaderProps) { +export class GroupHeader extends React.Component { + constructor(props: IGroupHeader) { super(props); this._onToggleCollapse = this._onToggleCollapse.bind(this); @@ -52,7 +52,12 @@ export class GroupHeader extends React.Component diff --git a/src/components/DetailsList/GroupSpacer.scss b/src/components/GroupedList/GroupSpacer.scss similarity index 100% rename from src/components/DetailsList/GroupSpacer.scss rename to src/components/GroupedList/GroupSpacer.scss diff --git a/src/components/DetailsList/GroupSpacer.tsx b/src/components/GroupedList/GroupSpacer.tsx similarity index 100% rename from src/components/DetailsList/GroupSpacer.tsx rename to src/components/GroupedList/GroupSpacer.tsx diff --git a/src/components/GroupedList/GroupedList.Props.ts b/src/components/GroupedList/GroupedList.Props.ts new file mode 100644 index 0000000000000..379b05d96b1df --- /dev/null +++ b/src/components/GroupedList/GroupedList.Props.ts @@ -0,0 +1,179 @@ +import * as React from 'react'; +import { + GroupedList +} from './GroupedList'; +import { + IListProps +} from '../List'; + +import { + IDragDropContext, + IDragDropEvents, + IDragDropHelper +} from '../../utilities/dragdrop/index'; +import { + ISelection, + SelectionMode +} from '../../utilities/selection/index'; +import { IViewport } from '../../utilities/decorators/withViewport'; + +export interface IGroupedList { + /** + * Ensures that the list content is updated. Call this in cases where the list prop updates don't change, but the list + * still needs to be re-evaluated. For example, if a sizer bar is adjusted and causes the list width to change, you can + * call this to force a re-evaluation. Be aware that this can be an expensive operation and should be done sparingly. + */ + forceUpdate: () => void; +} + +export interface IGroupedListProps extends React.Props { + /** Optional class name to add to the root element. */ + className?: string; + + /** Map of callback functions related to drag and drop functionality. */ + dragDropEvents?: IDragDropEvents; + + /** helper to manage drag/drop across item and groups */ + dragDropHelper?: IDragDropHelper; + + /** Event names and corresponding callbacks that will be registered to groups and rendered elements */ + eventsToRegister?: [{ eventName: string, callback: (context: IDragDropContext, event?: any) => void }]; + + /** Optional override properties to render groups. */ + groupProps?: IGroupRenderProps; + + /** Optional grouping instructions. */ + groups?: IGroup[]; + + /** List of items to render. */ + items: any[]; + + /** Optional properties to pass through to the list components being rendered. */ + listProps?: IListProps; + + /** Rendering callback to render the group items. */ + onRenderCell: ( + nestingDepth?: number, + item?: any, + index?: number + ) => React.ReactNode; + + /** Optional selection model to track selection state. */ + selection?: ISelection; + + /** Controls how/if the list manages selection. */ + selectionMode?: SelectionMode; + + /** Optional Viewport, provided by the parent component. */ + viewport?: IViewport; +} + +export interface IGroup { + /** + * Unique identifier for the group. + */ + key: string; + + /** + * Display name for the group, rendered on the header. + */ + name: string; + + /** + * Start index for the group within the given items. + */ + startIndex: number; + + /** + * How many items should be rendered within the group. + */ + count: number; + + /** + * Nested groups, if any. + */ + children?: IGroup[]; + + /** + * Number indicating the level of nested groups. + */ + level?: number; + + /** + * If all the items in the group are selected. + */ + isSelected?: boolean; + + /** + * If all the items in the group are collapsed. + */ + isCollapsed?: boolean; + + /** + * If the items within the group are summarized or showing all. + */ + isShowingAll?: boolean; + + /** + * If drag/drop is enabled for the group header. + */ + isDropEnabled?: boolean; + + /** + * Arbitrary data required to be preserved by the caller. + */ + data?: any; + + /** + * Override which allows the caller to provide a custom header. + */ + onRenderHeader?: (group: IGroup) => React.ReactNode; + + /** + * Override which allows the caller to provider a customer footer. + */ + onRenderFooter?: (group: IGroup) => React.ReactNode; +} + +export interface IGroupRenderProps { + /** Boolean indicating if all groups are in collapsed state. */ + isAllGroupsCollapsed?: boolean; + + /** Grouping item limit. */ + getGroupItemLimit?: (group: IGroup) => number; + + /** Callback for when all groups are expanded or collapsed. */ + onToggleCollapseAll?: (isAllCollapsed: boolean) => void; + + /** Information to pass in to the group header. */ + headerProps?: IGroupHeaderProps; + + /** Information to pass in to the group footer. */ + footerProps?: IGroupFooterProps; +} + +export interface IGroupHeaderProps { + /** Callback to determine if a group has missing items and needs to load them from the server. */ + isGroupLoading?: (group: IGroup) => boolean; + + /** Text shown on group headers to indicate the group is being loaded. */ + loadingText?: string; + + /** Callback for when the group header is clicked. */ + onGroupHeaderClick?: (group: IGroup) => void; + + /** Callback for when the group is expanded or collapsed. */ + onToggleCollapse?: (group: IGroup) => void; + + /** Callback for when the group is selected. */ + onToggleSelectGroup?: (group: IGroup) => void; +} + +export interface IGroupFooterProps { + /** Callback for when the "Show All" link in group footer is clicked */ + onToggleSummarize?: (group: IGroup) => void; + + /** Text to display for the group footer show all link. */ + showAllLinkText?: string; +} + diff --git a/src/components/GroupedList/GroupedList.scss b/src/components/GroupedList/GroupedList.scss new file mode 100644 index 0000000000000..5585b5edd40fd --- /dev/null +++ b/src/components/GroupedList/GroupedList.scss @@ -0,0 +1,42 @@ +@import '../../common/common'; + +.ms-GroupedList { + position: relative; + font-size: $ms-font-size-s; + + BUTTON { + font-family: inherit; + background-color: transparent; + } + + > .ms-FocusZone { + display: inline-block; + vertical-align: top; + min-width: 1px; + min-height: 1px; + } + +} + +.ms-GroupedList.is-horizontalConstrained { + overflow-x: auto; + overflow-y: visible; + + -webkit-overflow-scrolling: touch; + transform: translateZ(0); +} + +.ms-GroupedList-cell { + word-break: break-word; +} + +.ms-GroupedList-group { + &.is-dropping { + background-color: $ms-color-neutralLight; + } +} + +/* Set the min height for a row to 38px so even rendering empty cells takes that space. */ +.ms-GroupedList .ms-List-cell { + min-height: 38px; +} \ No newline at end of file diff --git a/src/components/GroupedList/GroupedList.tsx b/src/components/GroupedList/GroupedList.tsx new file mode 100644 index 0000000000000..1344c659a70fe --- /dev/null +++ b/src/components/GroupedList/GroupedList.tsx @@ -0,0 +1,265 @@ +import * as React from 'react'; +import { + IGroupedList, + IGroupedListProps, + IGroup, + IGroupFooterProps, + IGroupHeaderProps +} from './GroupedList.Props'; +import { + Group +} from './Group'; + +import { css } from '../../utilities/css'; +import { + List +} from '../../List'; +import { + SelectionMode +} from '../../utilities/selection/index'; + +import { assign } from '../../utilities/object'; +import './GroupedList.scss'; + +export interface IGroupedListState { + lastWidth?: number; + lastSelectionMode?: SelectionMode; + groups?: IGroup[]; +} + +export class GroupedList extends React.Component implements IGroupedList { + public static defaultProps = { + selectionMode: SelectionMode.multiple, + isHeaderVisible: true + }; + + public refs: { + [key: string]: React.ReactInstance, + root: HTMLElement, + list: List + }; + + constructor(props: IGroupedListProps) { + super(props); + + this._onToggleCollapse = this._onToggleCollapse.bind(this); + this._onToggleSelectGroup = this._onToggleSelectGroup.bind(this); + this._onToggleSummarize = this._onToggleSummarize.bind(this); + this._getGroupKey = this._getGroupKey.bind(this); + this._renderGroup = this._renderGroup.bind(this); + + this.state = { + lastWidth: 0, + groups: props.groups + }; + } + + public componentWillReceiveProps(newProps) { + let { + groups, + selectionMode + } = this.props; + let shouldForceUpdates = false; + + if (newProps.groups !== groups) { + this.setState({ groups: newProps.groups }); + shouldForceUpdates = true; + } + + if (newProps.selectionMode !== selectionMode) { + shouldForceUpdates = true; + } + + if (shouldForceUpdates) { + this._forceListUpdates(); + } + } + + public render() { + let { + className + } = this.props; + let { + groups + } = this.state; + + return ( +
+ { !groups ? + this._renderGroup(null, 0) : ( + 1 } + /> + ) + } +
+ ); + } + + public forceUpdate() { + super.forceUpdate(); + this._forceListUpdates(); + } + + private _renderGroup(group, groupIndex) { + let { + dragDropEvents, + dragDropHelper, + eventsToRegister, + groupProps, + items, + listProps, + onRenderCell, + selectionMode, + selection, + viewport + } = this.props; + + // override group header/footer props as needed + let headerProps = assign({}, groupProps && groupProps.headerProps ? groupProps.headerProps : {}, { + onToggleCollapse: this._onToggleCollapse, + onToggleSelectGroup: this._onToggleSelectGroup + }) as IGroupHeaderProps; + let footerProps = assign({}, groupProps && groupProps.footerProps ? groupProps.footerProps : {}, { + onToggleSummarize: this._onToggleSummarize + }) as IGroupFooterProps; + let groupNestingDepth = this._getGroupNestingDepth(); + + return (!group || group.count > 0) ? ( + + ) : null; + } + + private _getGroupKey(group: IGroup): string { + return 'group-' + (group ? + group.key + '-' + group.count : + ''); + } + + private _getGroupNestingDepth(): number { + let { groups } = this.state; + let level = 0; + let groupsInLevel = groups; + + while (groupsInLevel && groupsInLevel.length > 0) { + level++; + groupsInLevel = groupsInLevel[0].children; + } + + return level; + } + + private _onToggleCollapse(group: IGroup) { + let { groupProps } = this.props; + let onToggleCollapse = groupProps && groupProps.headerProps && groupProps.headerProps.onToggleCollapse; + + if (group) { + if (onToggleCollapse) { + onToggleCollapse(group); + } + + group.isCollapsed = !group.isCollapsed; + this.setState({ }, this.forceUpdate); + } + } + + private _onToggleSelectGroup(group: IGroup) { + let { groups } = this.state; + + if (group) { + let isSelected = !group.isSelected; + this._selectGroup(group, isSelected); + + this.setState({ + groups: groups + }); + } + } + + private _selectGroup(group: IGroup, isSelected: boolean) { + let { groupProps } = this.props; + + group.isSelected = isSelected; + if (group.children && group.children.length > 0) { + group.children.forEach((childGroup: IGroup) => { + this._selectGroup(childGroup, isSelected); + }); + } else { + let getGroupItemLimit = groupProps && groupProps.getGroupItemLimit; + let groupItemLimit = getGroupItemLimit ? getGroupItemLimit(group) : Infinity; + let start = group.startIndex; + let end = group.startIndex + Math.min(group.count, groupItemLimit); + for (let idx = start; idx < end; idx++) { + this.props.selection.setIndexSelected(idx, isSelected, false /* shouldAnchor */); + } + this.setState({ }, this.forceUpdate); + } + } + + private _forceListUpdates(groups?: IGroup[]) { + groups = groups || this.state.groups; + + let groupCount = groups ? groups.length : 1; + + if (this.refs.list) { + this.refs.list.forceUpdate(); + + for (let i = 0; i < groupCount; i++) { + let group = this.refs.list.refs['group_' + String(i)] as Group; + if (group) { + group.forceListUpdate(); + } + } + } else { + let group = this.refs['group_' + String(0)] as Group; + if (group) { + group.forceListUpdate(); + } + } + } + + private _onToggleSummarize(group: IGroup) { + let { groups } = this.state; + let { groupProps } = this.props; + let onToggleSummarize = groupProps && groupProps.footerProps && groupProps.footerProps.onToggleSummarize; + + if (onToggleSummarize) { + onToggleSummarize(group); + } else { + if (group) { + group.isShowingAll = !group.isShowingAll; + + this.setState({ + groups: groups + }); + } + } + } +} diff --git a/src/components/GroupedList/index.ts b/src/components/GroupedList/index.ts new file mode 100644 index 0000000000000..0f160bac47c2d --- /dev/null +++ b/src/components/GroupedList/index.ts @@ -0,0 +1,3 @@ +export * from './GroupedList'; +export * from './GroupedList.Props'; +export * from './Group'; \ No newline at end of file diff --git a/src/components/List/List.Props.ts b/src/components/List/List.Props.ts index e202983b553d4..79b22fcd402e7 100644 --- a/src/components/List/List.Props.ts +++ b/src/components/List/List.Props.ts @@ -1,5 +1,4 @@ import * as React from 'react'; -import { ISelection } from '../../utilities/selection/interfaces'; import { List } from './List'; export interface IListProps extends React.Props { @@ -52,9 +51,6 @@ export interface IListProps extends React.Props { /** Number of items to render. Defaults to items.length. */ renderCount?: number; - - /** Optional selection model to track selection state. */ - selection?: ISelection; } export interface IPage { diff --git a/src/demo/components/app/AppState.ts b/src/demo/components/app/AppState.ts index cb7c49f78b381..7b2b1f6798f8c 100644 --- a/src/demo/components/app/AppState.ts +++ b/src/demo/components/app/AppState.ts @@ -14,6 +14,7 @@ import { DropdownPage } from '../../pages/DropdownPage/DropdownPage'; import { FacepilePage } from '../../pages/Facepile/FacepilePage'; import { FocusZonePage } from '../../pages/FocusZonePage/FocusZonePage'; import { FocusTrapZonePage } from '../../pages/FocusTrapZonePage/FocusTrapZonePage'; +import { GroupedListPage } from '../../pages/GroupedListPage/GroupedListPage'; import { ImagePage } from '../../pages/ImagePage/ImagePage'; import { INavLink, INavLinkGroup } from '../../../components/Nav/index'; import { LabelPage } from '../../pages/LabelPage/LabelPage'; @@ -242,6 +243,12 @@ export const AppState: IAppState = { status: ExampleStatus.started, url: '#/examples/colorpicker' }, + { + component: GroupedListPage, + name: 'GroupedList', + status: ExampleStatus.started, + url: '#examples/GroupedList' + }, { component: ImagePage, name: 'Image', diff --git a/src/demo/pages/GroupedListPage/GroupedListPage.tsx b/src/demo/pages/GroupedListPage/GroupedListPage.tsx new file mode 100644 index 0000000000000..06bfd558a151e --- /dev/null +++ b/src/demo/pages/GroupedListPage/GroupedListPage.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { + ExampleCard, + PropertiesTableSet +} from '../../components/index'; + +import { GroupedListBasicExample } from './examples/GroupedList.Basic.Example'; +import { GroupedListCustomExample } from './examples/GroupedList.Custom.Example'; + +const GroupedListBasicExampleCode = require('./examples/GroupedList.Basic.Example.tsx'); +const GroupedListCustomExampleCode = require('./examples/GroupedList.Custom.Example.tsx'); + +export class GroupedListPage extends React.Component { + + public render() { + return ( +
+

GroupedList

+ +

Allows you to render a set of items as multiple lists with various grouping properties.

+ +

Examples

+ + + + + + + +
+ ); + } + +} diff --git a/src/demo/pages/GroupedListPage/examples/GroupedList.Basic.Example.tsx b/src/demo/pages/GroupedListPage/examples/GroupedList.Basic.Example.tsx new file mode 100644 index 0000000000000..8988d66399c23 --- /dev/null +++ b/src/demo/pages/GroupedListPage/examples/GroupedList.Basic.Example.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +import { + GroupedList, + IGroup +} from '../../../../components/GroupedList/index'; +import { IColumn } from '../../../../DetailsList'; +import { DetailsRow } from '../../../../components/DetailsList/DetailsRow'; +import { + FocusZone +} from '../../../../FocusZone'; +import { + Selection, + SelectionMode, + SelectionZone +} from '../../../../utilities/selection/index'; + +import { + createListItems, + createGroups +} from '../../../utilities/data'; + +const groupCount = 15; +const groupDepth = 3; +const items = createListItems(Math.pow(groupCount, groupDepth + 1)); + +export class GroupedListBasicExample extends React.Component { + private _selection: Selection; + private _groups: IGroup[]; + + constructor() { + super(); + this._onRenderCell = this._onRenderCell.bind(this); + this._selection = new Selection; + this._selection.setItems(items); + + this._groups = createGroups(groupCount, groupDepth, 0, groupCount); + } + + public render() { + return ( + + + + + + ); + } + + private _onRenderCell(nestingDepth: number, item: any, itemIndex: number) { + let { + _selection: selection + } = this; + return ( + { + return { + key: value, + name: value, + fieldName: value, + minWidth: 300 + }; + }) + } + groupNestingDepth={ nestingDepth } + item={ item } + itemIndex={ itemIndex } + selection={ selection } + selectionMode={ SelectionMode.multiple } + canSelectItem={ () => true } + /> + ); + } +} diff --git a/src/demo/pages/GroupedListPage/examples/GroupedList.Custom.Example.scss b/src/demo/pages/GroupedListPage/examples/GroupedList.Custom.Example.scss new file mode 100644 index 0000000000000..76006b885832d --- /dev/null +++ b/src/demo/pages/GroupedListPage/examples/GroupedList.Custom.Example.scss @@ -0,0 +1,20 @@ +.ms-GroupedListExample-header, +.ms-GroupedListExample-footer { + min-width: 300px; + min-height: 40px; + line-height: 40px; + padding-left: 16px; +} + +.ms-GroupedListExample-name { + display: inline-block; + overflow: hidden; + height: 24px; + cursor: default; + padding: 8px; + box-sizing: border-box; + vertical-align: top; + background: none; + border: none; + padding-left: 32px; +} diff --git a/src/demo/pages/GroupedListPage/examples/GroupedList.Custom.Example.tsx b/src/demo/pages/GroupedListPage/examples/GroupedList.Custom.Example.tsx new file mode 100644 index 0000000000000..750363ccfbc2a --- /dev/null +++ b/src/demo/pages/GroupedListPage/examples/GroupedList.Custom.Example.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { + GroupedList, + IGroup +} from '../../../../components/GroupedList/index'; + +import { createListItems } from '../../../utilities/data'; +import './GroupedList.Custom.Example.scss'; + +export class GroupedListCustomExample extends React.Component { + private _items: any[]; + private _groups: IGroup[]; + + constructor() { + super(); + + this._items = createListItems(20); + this._groups = Array.apply(null, Array(4)).map((value, index): IGroup => { + return { + count: 5, + key: 'group' + index, + name: 'group ' + index, + startIndex: 5 * index, + level: 0, + onRenderFooter: this._onRenderFooter, + onRenderHeader: this._onRenderHeader + }; + }); + } + + public render() { + return ( + + ); + } + + private _onRenderCell(nestingDepth: number, item: any, itemIndex: number) { + return ( +
+ + { item.name } + +
+ ); + } + + private _onRenderHeader(group: IGroup): React.ReactNode { + return ( +
+ This is a custom header for { group.name } +
+ ); + } + + private _onRenderFooter(group: IGroup): React.ReactNode { + return ( +
+ This is a custom footer for { group.name } +
+ ); + } +} diff --git a/src/demo/pages/NavPage/examples/Nav.Basic.Example.scss b/src/demo/pages/NavPage/examples/Nav.Basic.Example.scss new file mode 100644 index 0000000000000..6552f9d869556 --- /dev/null +++ b/src/demo/pages/NavPage/examples/Nav.Basic.Example.scss @@ -0,0 +1,9 @@ +@import '../../../../common/common'; + +.ms-NavExample-LeftPane { + width: 207px; + height: 400px; + box-sizing: border-box; + @include border-right(1px, solid, #EEE); + overflow-y: auto; +} \ No newline at end of file diff --git a/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx b/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx index 795bf58fb4615..1f995cf48cce7 100644 --- a/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx +++ b/src/demo/pages/SelectionPage/examples/Selection.Basic.Example.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { CommandBar, IContextualMenuItem } from '../../../../index'; -import { Check } from '../../../../components/DetailsList/Check'; +import { Check } from '../../../../components/Check/Check'; import { IObjectWithKey, ISelection, diff --git a/src/demo/utilities/data.ts b/src/demo/utilities/data.ts index 0966eb30aff80..493b79627f7a5 100644 --- a/src/demo/utilities/data.ts +++ b/src/demo/utilities/data.ts @@ -30,6 +30,26 @@ export function createListItems(count: number, startIndex = 0): any { }); } +export function createGroups( + groupCount: number, groupDepth: number, startIndex: number, + itemsPerGroup: number, level?: number, key?: string) { + key = key ? key + '-' : ''; + level = level ? level : 0; + let count = Math.pow(itemsPerGroup, groupDepth); + return Array.apply(null, Array(groupCount)).map((value, index) => { + return { + count: count, + key: 'group' + key + index, + name: 'group ' + key + index, + startIndex: index * count + startIndex, + level: level, + children: groupDepth > 1 ? + createGroups(groupCount, groupDepth - 1, index * count + startIndex, itemsPerGroup, level + 1, key + index) : + [] + }; + }); +} + export function lorem(wordCount: number): string { return Array.apply(null, Array(wordCount)) .map(item => _randWord(LOREM_IPSUM)) diff --git a/src/index.ts b/src/index.ts index 850a4afdf6c93..00b5ed7ca280c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ export * from './Fabric'; export * from './Facepile'; export * from './FocusTrapZone'; export * from './FocusZone'; +export * from './GroupedList'; export * from './Image'; export * from './Label'; export * from './Layer';