diff --git a/src/components/data/datagrid/datagrid.scss b/src/components/data/datagrid/datagrid.scss index af790f2..410060a 100644 --- a/src/components/data/datagrid/datagrid.scss +++ b/src/components/data/datagrid/datagrid.scss @@ -3,8 +3,38 @@ .mykn-datagrid { background-color: var(--typography-color-background); border-radius: var(--border-radius-l); + display: flex; + flex-direction: column; width: 100%; + &__toolbar { + position: sticky; + top: 0; + z-index: 10; + } + + &__scrollpane { + height: 100%; + } + + &__scrollpane--overflow-x { + overflow-x: auto; + overflow-y: hidden; + } + + &__scrollpane--overflow-y { + overflow-x: hidden; + overflow-y: auto; + } + &__scrollpane--overflow-x#{&}__scrollpane--overflow-y { + overflow-x: auto; + overflow-y: auto; + } + + &__scrollpane ~ .mykn-toolbar { + border-block-start: 1px solid var(--typography-color-border); + } + &__header { padding-block: var(--spacing-v); padding-inline: var(--spacing-h); @@ -15,13 +45,17 @@ width: 100%; } - &__table &__thead { + &__thead { background-color: var(--typography-color-background); position: sticky; top: 0; z-index: 1; } + &__toolbar ~ &__scrollpane:not(&__scrollpane--overflow-y) &__thead { + top: var(--mykn-datagrid-thead-top-base, 50px); + } + &__thead &__row--filter { background-color: var(--typography-color-background-alt); @@ -49,6 +83,7 @@ box-sizing: border-box; padding: var(--spacing-v) var(--spacing-h); position: relative; + white-space: nowrap; .mykn-a:not(:last-child) { margin-inline-end: var(--spacing-h); @@ -115,133 +150,4 @@ &__cell--link .mykn-button { width: calc(100% - 1em - 3 * var(--spacing-h)); } - - &__footer { - position: sticky; - bottom: 0; - } - - &__footer &__cell { - border-block-start: 1px solid var(--typography-color-border); - border-block-end: none; - padding: 0; - } - - @media screen and (max-width: constants.$breakpoint-desktop - 1px) { - background-color: transparent; - overflow: visible; - - &__table { - display: block; - } - - &__table--layout--fixed { - table-layout: fixed; - } - - &__toolbar { - background-color: var(--typography-color-background); - border-radius: var(--border-radius-l); - display: block; - - > * { - float: none; - } - } - - &__thead { - position: static; - } - - &__thead &__row--header { - display: none; - } - - &__tbody { - display: block; - } - - &__row { - background-color: var(--typography-color-background); - display: flex; - flex-wrap: wrap; - - &:nth-child(even) { - background-color: var(--typography-color-background-alt); - } - } - - &__row:nth-child(even) &__cell { - border-block-end: 1px solid var(--typography-color-background); - } - - &__cell { - display: flex; - flex-direction: column; - gap: var(--spacing-h); - width: 100%; - padding: var(--spacing-v) var(--spacing-h); - position: relative; - - .mykn-p { - font-weight: var(--typography-font-weight-bold); - width: 100%; - } - - &:before { - color: var(--typography-color-body); - content: attr(aria-description); - font-family: var(--typography-font-family-body); - font-size: var(--typography-font-size-body-xs); - font-weight: var(--typography-font-weight-normal); - line-height: var(--typography-line-height-body-xs); - display: block; - text-align: start; - width: 40%; - } - - &:first-child .mykn-a:has(.mykn-icon) { - float: right; - } - } - - &__cell--type-boolean, - &__cell--type-number { - flex-direction: row; - justify-content: space-between; - padding: var(--spacing-v) var(--spacing-h); - width: 100%; - } - - &__footer { - display: flex; - } - - &__footer &__row { - width: 100%; - } - - &__footer &__cell { - &:before { - display: none; - } - } - - .mykn-toolbar { - border-radius: var(--border-radius-l); - } - - .mykn-paginator .mykn-icon--spin:first-child { - display: none; - } - } - - // FIXME: Improve this... - .mykn-toolbar--sticky-top + & &__thead { - top: calc(var(--typography-line-height-h1) + 2 * var(--spacing-v)); - } - - .mykn-toolbar--sticky-top:has(.mykn-form) + & &__thead { - top: calc(42px + 2 * var(--spacing-v)); - } } diff --git a/src/components/data/datagrid/datagrid.tsx b/src/components/data/datagrid/datagrid.tsx index f623276..30ea563 100644 --- a/src/components/data/datagrid/datagrid.tsx +++ b/src/components/data/datagrid/datagrid.tsx @@ -1,5 +1,6 @@ import clsx from "clsx"; import React, { + CSSProperties, useCallback, useContext, useEffect, @@ -46,6 +47,14 @@ export type DataGridProps = { /** The object list (after pagination), only primitive types supported for now. */ objectList: AttributeData[]; + /** + * Whether to allow horizontal contents to exceed the bounding box width + * resulting in a horizontal scrollbar. When this is set to `true` (default) a + * "stickyfix" is applied in certain cases to preserve the sticky behaviour of + * nested components. + */ + allowOverflowX?: boolean; + /** Whether to use a "decorative" component instead of `

` if applicable. */ decorate?: boolean; @@ -73,6 +82,16 @@ export type DataGridProps = { */ filterTransform?: (value: AttributeData) => AttributeData; + /** + * This value is copied one-to-one to the style attribute of the rendered + * datagrid. + * + * NOTE: When using `allowOverflowX=true` (default). Setting this disables + * the sticky fix in favor of the native implementation. Even if style is + * overridden completely. + */ + height?: string; + /** Whether to allow sorting/the field to sort on. */ sort?: boolean | string; @@ -202,10 +221,18 @@ export type DataGridProps = { onSort?: (sort: string) => Promise | void; } & PaginatorPropsAliases; +const dataGridRef = React.createRef(); +const toolbarRef = React.createRef(); +const scrollPaneRef = React.createRef(); + export type DataGridContextType = Omit< DataGridProps, "equalityChecker" | "fields" | "onSelect" | "onSort" > & { + dataGridRef: React.RefObject; + toolbarRef: React.RefObject; + scrollPaneRef: React.RefObject; + amountSelected: number; count: number; dataGridId: string; @@ -255,6 +282,7 @@ type PaginatorPropsAliases = { export const DataGrid: React.FC = (props) => { // Specify the default props. const defaults: Partial = { + allowOverflowX: true, showPaginator: Boolean(props.paginatorProps), selectable: false, allowSelectAll: true, @@ -276,6 +304,7 @@ export const DataGrid: React.FC = (props) => { // Strip all `props` from `attrs`, allowing `attrs` to be passed to the DOM. /* eslint-disable @typescript-eslint/no-unused-vars */ const { + allowOverflowX, aProps, badgeProps, boolProps, @@ -286,6 +315,7 @@ export const DataGrid: React.FC = (props) => { fields, filterable, filterTransform, + height, paginatorProps, showPaginator, pProps, @@ -332,18 +362,23 @@ export const DataGrid: React.FC = (props) => { const id = useId(); const onFilterTimeoutRef = useRef(); + const [editingState, setEditingState] = useState< [AttributeData | null, number | null] >([null, null]); + const [filterState, setFilterState] = useState(); const [selectedState, setSelectedState] = useState( null, ); + const [allPagesSelectedState, setAllPagesSelectedState] = useState(allPagesSelected); + const [sortState, setSortState] = useState< [string, "ASC" | "DESC"] | undefined >(); + const [fieldsState, setFieldsState] = useState>([]); // Update selectedState when selected prop changes. @@ -537,6 +572,10 @@ export const DataGrid: React.FC = (props) => { return ( = (props) => { onSelectAllPages: handleSelectAllPages, }} > -

+
{title && } {(selectable || fieldsSelectable) && } - - - -
+ + + + + + {showPaginator && } {filterable &&
} @@ -615,6 +650,7 @@ export const DataGridHeader: React.FC = () => { * DataGrid toolbar, shows selection actions and/or allows the user to select fields (columns). */ export const DataGridToolbar: React.FC = () => { + const { toolbarRef } = useContext(DataGridContext); const intl = useIntl(); const [selectFieldsModalState, setSelectFieldsModalState] = useState(false); const [selectFieldsActiveState, setSelectFieldsActiveState] = useState< @@ -704,8 +740,8 @@ export const DataGridToolbar: React.FC = () => { ]; return ( -
- +
+ { ); }; +/** + * Datagrid scroll pane, contains the scrollable content. + * @param children + * @constructor + */ +export const DataGridScrollPane: React.FC = ({ + children, +}) => { + const { allowOverflowX, scrollPaneRef } = useContext(DataGridContext); + + // Overflow detection + useEffect(() => { + detectOverflowX(); + window.addEventListener("resize", detectOverflowX); + window.addEventListener("scroll", detectOverflowX); + () => window.removeEventListener("resize", detectOverflowX); + }); + + /** + * Toggles "mykn-datagrid__scrollpane--overflow-x" to class list based on + * whether `allowOverflowX=true` and the contents are overflowing. + */ + const detectOverflowX = () => { + if (!scrollPaneRef?.current) { + return; + } + const node = scrollPaneRef.current; + + const hasOverflowX = node.scrollWidth > node.clientWidth; + const expX = allowOverflowX && hasOverflowX; + node.classList.toggle("mykn-datagrid__scrollpane--overflow-x", expX); + + const hasOverflowY = node.scrollHeight > node.clientHeight; + const expY = hasOverflowY; + node.classList.toggle("mykn-datagrid__scrollpane--overflow-y", expY); + }; + + return ( +
+ {children} +
+ ); + // return null; +}; + +/** + * DataGrid table, represents tabular: information presented in a two-dimensional table comprised of rows and columns + * (fields) of cells containing data. + */ +export const DataGridTable: React.FC = ({ + children, +}) => { + const { tableLayout, titleId } = useContext(DataGridContext); + + return ( + + {children} +
+ ); +}; + /** * DataGrid table head, encapsulates a set of table rows, indicating that they * comprise the head of a table with information about the table's columns. */ export const DataGridTHead: React.FC = () => { + const { toolbarRef, height } = useContext(DataGridContext); const intl = useIntl(); const onFilterTimeoutRef = useRef(); + const ref = useRef(null); const [filterState, setFilterState] = useState(); const { @@ -772,6 +877,49 @@ export const DataGridTHead: React.FC = () => { selectable, } = useContext(DataGridContext); + // Sticky fix + useEffect(() => { + stickyFix(); + window.addEventListener("resize", stickyFix); + window.addEventListener("scroll", stickyFix); + () => { + window.removeEventListener("resize", stickyFix); + window.addEventListener("scroll", stickyFix); + }; + }); + + /** + * Fixes sticky behaviour due to `overflow-x: auto;` not being compatible + * with native sticky in all cases. + */ + const stickyFix = () => { + if (!ref.current || !scrollPaneRef.current) { + return; + } + + const node = ref.current; + const scrollPaneNode = scrollPaneRef.current; + const indicator = "mykn-datagrid__scrollpane--overflow-x"; + + // No need for fallback implementation, native behaviour should work if height is set of no overflow is applied.. + if (height || !scrollPaneNode?.classList?.contains(indicator)) { + node.style.top = ""; + return; + } + + requestAnimationFrame(() => { + node.style.top = ""; + const computedStyle = getComputedStyle(node); + const cssTop = parseInt(computedStyle.top); + + const boundingClientRect = node.getBoundingClientRect(); + const boundingTop = boundingClientRect.top; + const compensation = boundingTop * -1 + cssTop * 2; + + node.style.top = compensation + "px"; + }); + }; + // Debounce filter useEffect(() => { const handler = () => { @@ -786,7 +934,18 @@ export const DataGridTHead: React.FC = () => { }, [filterState]); return ( - + {/* Captions */} {selectable && ( @@ -1281,19 +1440,17 @@ export const DataGridFooter: React.FC = () => { } = useContext(DataGridContext); return ( -
- - - -
+ + + ); }; diff --git a/src/components/navbar/navbar.scss b/src/components/navbar/navbar.scss index 93cb974..612a219 100644 --- a/src/components/navbar/navbar.scss +++ b/src/components/navbar/navbar.scss @@ -2,4 +2,9 @@ .mykn-logo { width: 32px; } + + // This is a hack, it prevents unintended overflowing in specific scenario's with DataGrid. + .mykn-column & + :last-child { + width: calc(100% - 40px); + } } diff --git a/src/components/toolbar/toolbar.scss b/src/components/toolbar/toolbar.scss index 6eeec58..9d5a7d1 100644 --- a/src/components/toolbar/toolbar.scss +++ b/src/components/toolbar/toolbar.scss @@ -26,6 +26,14 @@ height: 100cqh; } + &--direction-responsive#{&}--direction-horizontal { + @media screen and (max-width: constants.$breakpoint-desktop - 1px) { + #{$self}__spacer { + display: none; + } + } + } + &:not(#{&}--direction-responsive)#{&}--direction-horizontal { max-height: 100%; flex-direction: row; @@ -56,6 +64,11 @@ top: 0; } + &--sticky-bottom { + position: sticky; + bottom: 0; + } + &__spacer { height: 100%; flex-shrink: 9999; diff --git a/src/components/toolbar/toolbar.tsx b/src/components/toolbar/toolbar.tsx index 4af3703..0dd262e 100644 --- a/src/components/toolbar/toolbar.tsx +++ b/src/components/toolbar/toolbar.tsx @@ -50,7 +50,7 @@ export type ToolbarProps = React.PropsWithChildren< size?: "fit-content"; /** When set tot true, toolbar will be positioned using display: sticky. */ - sticky?: false | "top"; + sticky?: false | "top" | "bottom"; /** The variant (style) of the toolbar. */ variant?: "normal" | "primary" | "accent" | "transparent"; diff --git a/src/style/tokens/base.scss b/src/style/tokens/base.scss index c77ce9a..e7ba2cf 100644 --- a/src/style/tokens/base.scss +++ b/src/style/tokens/base.scss @@ -1,4 +1,10 @@ @mixin tokens { + @at-root { + body { + overflow-x: hidden; + } + } + /* BRANDING */ $purple-rain: #341a90; $blue-suede-shoes: #00bfcb;