diff --git a/.vscode/settings.json b/.vscode/settings.json index c8e5335b5..dcaaedc1b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "cSpell.words": ["combobox", "Umbraco"] + "cSpell.words": ["combobox", "cssprop", "noopener", "noreferrer", "Umbraco"] } diff --git a/packages/uui-tabs/lib/uui-tab-group.element.ts b/packages/uui-tabs/lib/uui-tab-group.element.ts index 813a7759d..cde096957 100644 --- a/packages/uui-tabs/lib/uui-tab-group.element.ts +++ b/packages/uui-tabs/lib/uui-tab-group.element.ts @@ -1,6 +1,12 @@ import { defineElement } from '@umbraco-ui/uui-base/lib/registration'; import { css, html, LitElement } from 'lit'; -import { queryAssignedElements } from 'lit/decorators.js'; +import { property, query, queryAssignedElements } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { UUIButtonElement } from '@umbraco-ui/uui-button/lib'; +import '@umbraco-ui/uui-button/lib/uui-button.element'; +import '@umbraco-ui/uui-popover-container/lib/uui-popover-container.element'; +import '@umbraco-ui/uui-symbol-more/lib/uui-symbol-more.element'; import { UUITabElement } from './uui-tab.element'; @@ -24,58 +30,263 @@ export class UUITabGroupElement extends LitElement { ::slotted(*:not(:last-of-type)) { border-right: 1px solid var(--uui-tab-divider, none); } + + .hidden-tab { + width: 100%; + } + + #hidden-tabs-container { + width: fit-content; + display: flex; + flex-direction: column; + background: var(--uui-color-surface); + border-radius: var(--uui-border-radius); + box-shadow: var(--uui-shadow-depth-3); + overflow: hidden; + } + :host([dropdown-direction='horizontal']) #hidden-tabs-container { + flex-direction: row; + } + + #more-button { + margin-left: auto; + position: relative; + } + #more-button::before { + content: ''; + position: absolute; + bottom: 0; + width: 100%; + background-color: var(--uui-color-current); + height: 0px; + border-radius: 3px 3px 0 0; + opacity: 0; + transition: opacity ease-in 120ms, height ease-in 120ms; + } + #more-button.active-inside::before { + opacity: 1; + height: 4px; + transition: opacity 120ms, height ease-out 120ms; + } `, ]; + @query('#more-button') + private _moreButtonElement!: UUIButtonElement; + @queryAssignedElements({ flatten: true, selector: 'uui-tab, [uui-tab], [role=tab]', }) private _slottedNodes?: HTMLElement[]; - private _tabElements: HTMLElement[] = []; - private _setTabArray() { - this._tabElements = this._slottedNodes ? this._slottedNodes : []; + /** + * Set the flex direction of the content of the dropdown. + * @type {string} + * @attr + * @default vertical + */ + @property({ + type: String, + reflect: true, + attribute: 'dropdown-content-direction', + }) + dropdownContentDirection: 'vertical' | 'horizontal' = 'vertical'; + + #tabElements: HTMLElement[] = []; + + #hiddenTabElements: UUITabElement[] = []; + #hiddenTabElementsMap: Map = new Map(); + + #visibilityBreakpoints: number[] = []; + #oldBreakpoint = 0; + + #resizeObserver: ResizeObserver = new ResizeObserver( + this.#onResize.bind(this) + ); + + connectedCallback() { + super.connectedCallback(); + this.#resizeObserver.observe(this); + if (!this.hasAttribute('role')) this.setAttribute('role', 'tablist'); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.#resizeObserver.unobserve(this); } - private _onSlotChange() { - this._tabElements.forEach(el => { - el.removeEventListener('click', this._onTabClicked); + #onResize(entries: ResizeObserverEntry[]) { + this.#updateCollapsibleTabs(entries[0].contentBoxSize[0].inlineSize); + } + + #onSlotChange() { + this.#tabElements.forEach(el => { + el.removeEventListener('click', this.#onTabClicked); }); - this._setTabArray(); + this.#setTabArray(); - this._tabElements.forEach(el => { - el.addEventListener('click', this._onTabClicked); + this.#tabElements.forEach(el => { + el.addEventListener('click', this.#onTabClicked); }); } - private _onTabClicked = (e: MouseEvent) => { + #onTabClicked = (e: MouseEvent) => { const selectedElement = e.currentTarget as HTMLElement; - if (this._elementIsTabLike(selectedElement)) { + if (this.#isElementTabLike(selectedElement)) { selectedElement.active = true; + const linkedElement = this.#hiddenTabElementsMap.get(selectedElement); + + if (linkedElement) { + linkedElement.active = true; + } - const filtered = this._tabElements.filter(el => el !== selectedElement); + // Reset all other tabs + const filtered = [ + ...this.#tabElements, + ...this.#hiddenTabElements, + ].filter(el => el !== selectedElement && el !== linkedElement); filtered.forEach(el => { - if (this._elementIsTabLike(el)) { + if (this.#isElementTabLike(el)) { el.active = false; } }); + + // Check if there are any active tabs in the dropdown + const hasActiveHidden = this.#hiddenTabElements.some( + el => el.active && el !== linkedElement + ); + + hasActiveHidden + ? this._moreButtonElement.classList.add('active-inside') + : this._moreButtonElement.classList.remove('active-inside'); } }; - private _elementIsTabLike(el: any): el is UUITabElement { - return el instanceof UUITabElement || 'active' in el; + #updateCollapsibleTabs(containerWidth: number) { + const buttonWidth = this._moreButtonElement.offsetWidth; + + // Only update if the container is smaller than the last breakpoint + if ( + this.#visibilityBreakpoints.slice(-1)[0] < containerWidth && + this.#hiddenTabElements.length === 0 + ) + return; + + // Only update if the new breakpoint is different from the old one + let newBreakpoint = Number.MAX_VALUE; + + for (let i = this.#visibilityBreakpoints.length - 1; i > -1; i--) { + const breakpoint = this.#visibilityBreakpoints[i]; + // Subtract the button width when we are not at the last breakpoint + const containerWidthButtonWidth = + containerWidth - + (i !== this.#visibilityBreakpoints.length - 1 ? buttonWidth : 0); + + if (breakpoint < containerWidthButtonWidth) { + newBreakpoint = i; + break; + } + } + + if (newBreakpoint === this.#oldBreakpoint) return; + this.#oldBreakpoint = newBreakpoint; + + // Do the update + // Reset the hidden tabs + this.#hiddenTabElements.forEach(el => { + el.removeEventListener('click', this.#onTabClicked); + }); + this.#hiddenTabElements = []; + this.#hiddenTabElementsMap.clear(); + + let hasActiveTabInDropdown = false; + + for (let i = 0; i < this.#visibilityBreakpoints.length; i++) { + const breakpoint = this.#visibilityBreakpoints[i]; + const tab = this.#tabElements[i] as UUITabElement; + + // Subtract the button width when we are not at the last breakpoint + const containerWidthButtonWidth = + containerWidth - + (i !== this.#visibilityBreakpoints.length - 1 ? buttonWidth : 0); + + if (breakpoint < containerWidthButtonWidth) { + tab.style.display = ''; + this._moreButtonElement.style.display = 'none'; + } else { + // Make a proxy tab to put in the hidden tabs container and link it to the original tab + const proxyTab = tab.cloneNode(true) as UUITabElement; + proxyTab.addEventListener('click', this.#onTabClicked); + proxyTab.classList.add('hidden-tab'); + proxyTab.style.display = ''; + proxyTab.orientation = this.dropdownContentDirection; + + // Link the proxy tab to the original tab + this.#hiddenTabElementsMap.set(proxyTab, tab); + this.#hiddenTabElementsMap.set(tab, proxyTab); + + this.#hiddenTabElements.push(proxyTab); + + tab.style.display = 'none'; + this._moreButtonElement.style.display = ''; + if (tab.active) { + hasActiveTabInDropdown = true; + } + } + } + + hasActiveTabInDropdown + ? this._moreButtonElement.classList.add('active-inside') + : this._moreButtonElement.classList.remove('active-inside'); + + this.requestUpdate(); } - connectedCallback() { - super.connectedCallback(); - if (!this.hasAttribute('role')) this.setAttribute('role', 'tablist'); + #calculateBreakPoints() { + // Whenever a tab is added or removed, we need to recalculate the breakpoints + let childrenWidth = 0; + + for (let i = 0; i < this.#tabElements.length; i++) { + childrenWidth += this.#tabElements[i].offsetWidth; + this.#visibilityBreakpoints[i] = childrenWidth; + } + + this.#updateCollapsibleTabs(this.offsetWidth); + } + + #setTabArray() { + this.#tabElements = this._slottedNodes ? this._slottedNodes : []; + this.#calculateBreakPoints(); + } + + #isElementTabLike(el: any): el is UUITabElement { + return el instanceof UUITabElement || 'active' in el; } render() { - return html` `; + return html` + + + +
+ ${repeat(this.#hiddenTabElements, el => html`${el}`)} +
+
+ `; } } diff --git a/packages/uui-tabs/lib/uui-tab.element.ts b/packages/uui-tabs/lib/uui-tab.element.ts index 02506953c..4f8ff187d 100644 --- a/packages/uui-tabs/lib/uui-tab.element.ts +++ b/packages/uui-tabs/lib/uui-tab.element.ts @@ -16,6 +16,7 @@ import { ifDefined } from 'lit/directives/if-defined.js'; * @cssprop --uui-tab-text-active - Define the tab text active color * @cssprop --uui-tab-background - Define the tab group background color * @cssprop --uui-tab-divider - Define the tab dividers color + * @cssprop --uui-tab-padding-horizontal - Define the tab horizontal padding */ @defineElement('uui-tab') export class UUITabElement extends ActiveMixin(LabelMixin('', LitElement)) { @@ -24,22 +25,24 @@ export class UUITabElement extends ActiveMixin(LabelMixin('', LitElement)) { :host { color: var(--uui-tab-text, var(--uui-color-interactive)); font-family: inherit; + width: fit-content; } #button { position: relative; + box-sizing: border-box; display: flex; flex-direction: column; align-items: center; justify-content: center; - text-align: center; - padding: var(--uui-size-2) var(--uui-size-4); - border: none; - box-sizing: border-box; - min-height: 32px; - font-size: inherit; + width: 100%; height: 100%; + min-height: var(--uui-size-12); min-width: 70px; + padding: var(--uui-size-2) + var(--uui-tab-padding-horizontal, var(--uui-size-4)); + border: none; + font-size: inherit; background: none; color: inherit; cursor: pointer; @@ -50,6 +53,12 @@ export class UUITabElement extends ActiveMixin(LabelMixin('', LitElement)) { line-height: normal; } + :host([orientation='vertical']) #button { + min-height: var(--uui-size-14); + padding: var(--uui-size-2) + var(--uui-tab-padding-horizontal, var(--uui-size-5)); + } + :host(:not([active]):not([disabled])) #button:hover { color: var(--uui-tab-text-hover, var(--uui-color-default-emphasis)); } @@ -70,26 +79,49 @@ export class UUITabElement extends ActiveMixin(LabelMixin('', LitElement)) { #button::before { content: ''; position: absolute; - height: 0px; - /* max-width: 50px; */ - width: calc(100% - 16px); - left: auto; - right: auto; background-color: var(--uui-color-current); - bottom: 0; - border-radius: 3px 3px 0 0; opacity: 0; - transition: opacity ease-in 120ms, height ease-in 120ms; - } - #button:hover::before { - background-color: var(--uui-color-current-emphasis); + --transitionDuration: 120ms; + --barWidth: 4px; + --borderRadius: 3px; } :host([active]) #button::before { opacity: 1; - height: 4px; - transition: opacity 120ms, height ease-out 120ms; } + /* HORIZONTAL */ + :host([orientation='horizontal']) #button::before { + left: auto; + right: auto; + border-radius: var(--borderRadius) var(--borderRadius) 0 0; + height: 0px; + width: calc(100% - 15px); + bottom: 0; + transition: opacity ease-in-out var(--transitionDuration), + height ease-in-out var(--transitionDuration); + } + :host([active][orientation='horizontal']) #button::before { + height: var(--barWidth); + } + + /* VERTICAL */ + :host([orientation='vertical']) #button::before { + top: auto; + bottom: auto; + border-radius: 0 var(--borderRadius) var(--borderRadius) 0; + height: calc(100% - 12px); + width: 0px; + left: 0; + transition: opacity ease-in-out var(--transitionDuration), + width ease-in-out var(--transitionDuration); + } + :host([active][orientation='vertical']) #button::before { + width: var(--barWidth); + } + + #button:hover::before { + background-color: var(--uui-color-current-emphasis); + } :host([disabled]) #button::before { background-color: var(--uui-color-disabled-standalone); } @@ -98,6 +130,18 @@ export class UUITabElement extends ActiveMixin(LabelMixin('', LitElement)) { font-size: 20px; margin-bottom: var(--uui-size-2); } + + slot.label { + /* TODO: Find a better selector */ + text-align: center; + display: flex; + width: 100%; + flex-direction: column; + } + + :host([orientation='vertical']) slot.label { + text-align: left; + } `, ]; @@ -128,6 +172,15 @@ export class UUITabElement extends ActiveMixin(LabelMixin('', LitElement)) { @property({ type: String }) public target?: '_blank' | '_parent' | '_self' | '_top'; + /** + * Set the visual orientation of this tab, this changes the look and placement of the active indication. + * @type {string} + * @attr + * @default horizontal + */ + @property({ type: String, reflect: true }) + public orientation?: 'horizontal' | 'vertical' = 'horizontal'; + constructor() { super(); this.addEventListener('click', this.onHostClick); diff --git a/packages/uui-tabs/lib/uui-tabs.story.ts b/packages/uui-tabs/lib/uui-tabs.story.ts index c1cb35ed6..76ac2d431 100644 --- a/packages/uui-tabs/lib/uui-tabs.story.ts +++ b/packages/uui-tabs/lib/uui-tabs.story.ts @@ -44,9 +44,12 @@ export const AAAOverview: Story = props => html` --uui-tab-background: ${props['--uui-tab-background']}; --uui-tab-divider: ${props['--uui-tab-divider']}; ${props.inlineStyles}"> - Content - Packages - Media + Content + Packages + Media + Settings + Translations + Users `; AAAOverview.storyName = 'Overview'; @@ -63,9 +66,12 @@ export const WithBorders: Story = () => html` --uui-tab-divider: var(--uui-color-divider-standalone); "> - Content - Packages - Media + Content + Packages + Media + Settings + Translations + Users `; @@ -82,9 +88,12 @@ export const Navbar: Story = () => html` --uui-tab-background: var(--uui-color-default); "> - Content - Packages - Media + Content + Packages + Media + Settings + Translations + Users `; @@ -93,6 +102,7 @@ export const WithIcons: Story = props => html`

Tabs with Icons