diff --git a/.vscode/settings.json b/.vscode/settings.json index 8d479403..1e6c3584 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -43,6 +43,7 @@ "calendar": true, "keyboard": true, "modal": true, + "tab": true, "toast": true } } \ No newline at end of file diff --git a/custom-elements-manifest.config.js b/custom-elements-manifest.config.js index 1a6a0bb0..ba47ce26 100644 --- a/custom-elements-manifest.config.js +++ b/custom-elements-manifest.config.js @@ -68,6 +68,62 @@ const plugins = { }, }; }(), + function cssInheritPlugin() { + return { + // Runs for each module + analyzePhase({ ts, node, moduleDoc }) { + switch (node.kind) { + case ts.SyntaxKind.ClassDeclaration: + const className = node.name.getText(); + + node.jsDoc?.forEach(jsDoc => { + jsDoc.tags?.forEach(tag => { + const tagName = tag.tagName.getText(); + if (tagName && (tagName.toLowerCase() === 'cssinherit')) { + const description = tag.comment; + + const classDeclaration = moduleDoc.declarations.find(declaration => declaration.name === className); + if (!classDeclaration.cssInherit) { + classDeclaration.cssInherit = []; + } + classDeclaration.cssInherit.push(description); + } + }); + }); + + break; + } + }, + // Runs for each module, after analyzing, all information about your module should now be available + moduleLinkPhase({ moduleDoc }) { + // console.log(moduleDoc); + }, + // Runs after all modules have been parsed, and after post processing + packageLinkPhase(linkedPackage) { + linkedPackage.customElementsManifest.modules.forEach((m) => { + m.declarations.forEach((d) => { + if (d.cssInherit) { + d.cssInherit.forEach(elementName => { + const module = linkedPackage.customElementsManifest.modules.find((module) => + module.declarations.find((d) => (d.tagName === elementName && d.customElement) || d.name === elementName)); + if (!module) { + return; + } + const declaration = module.declarations.find((d) => (d.tagName === elementName && d.customElement) || d.name === elementName); + if (declaration && declaration.cssProperties) { + if (!d.cssProperties) { + d.cssProperties = []; + } + d.cssProperties = [...d.cssProperties, ...JSON.parse(JSON.stringify(declaration.cssProperties))]; + } + }) + } + }) + }) + // console.log(customElementsManifest); + }, + }; + }(), function globalAttributesPlugin() { return { // Runs for each module @@ -80,12 +136,12 @@ const plugins = { jsDoc.tags?.forEach(tag => { const tagName = tag.tagName.getText(); if (tagName && (tagName.toLowerCase() === 'globalattr' || tagName.toLowerCase() === 'global_attribute')) { - let attribute = tag.comment.substring(0,tag.comment.indexOf(' - ')); + let attribute = tag.comment.substring(0, tag.comment.indexOf(' - ')); let type = ''; if (attribute.includes('}')) { const split = attribute.split('}'); attribute = split[split.length - 1]; - type = split[0].replace('{',''); + type = split[0].replace('{', ''); } const description = tag.comment.substring(tag.comment.indexOf(' - ') + 3); @@ -275,16 +331,16 @@ const plugins = { case ts.SyntaxKind.PropertyDeclaration: const propertyName = node.name.getText(); const classNode = findParentClass(ts, node); - if (classNode){ + if (classNode) { const className = classNode.name.getText(); - + node.jsDoc?.forEach(jsDoc => { jsDoc.tags?.forEach(tag => { const tagName = tag.tagName.getText(); if (tagName && (tagName.toLowerCase() === 'no_attribute')) { const value = tag.comment; const classDeclaration = moduleDoc.declarations.find(declaration => declaration.name === className); - + const attributes = classDeclaration.attributes.filter(a => a.name !== propertyName && a.fieldName !== propertyName); classDeclaration.attributes = attributes; diff --git a/package.json b/package.json index d5ba3676..45410cc0 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,8 @@ "calendar", "keyboard", "modal", - "toast" + "toast", + "tab" ], "exports": { "./*": "./dist/*/index.js" diff --git a/src/tab/Tab.stories.ts b/src/tab/Tab.stories.ts new file mode 100644 index 00000000..d245aba9 --- /dev/null +++ b/src/tab/Tab.stories.ts @@ -0,0 +1,232 @@ +import { within } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import * as jest from 'jest-mock'; +import { html } from 'lit'; +import expect from '../utils/ExpectDOM.js'; +import { ComponentStoryFormat, querySelectorAsync, getSourceFromLit } from '../utils/StoryUtils.js'; + +import { Tab } from './Tab.js'; +import { TabGroup } from './TabGroup.js'; +import { TabHeader } from './TabHeader.js'; +import '../label/Label.js'; +import './Tab.js'; +import './TabGroup.js'; + +interface Args { + header: string; + active: boolean; + disabled: boolean; +} + +export const Basic = { + render: () => html` + + +
Tab 1 Content
+
+ +
Tab 2 Content
+
+ +
Tab 3 Content
+
+
+`, + frameworkSources: [ + { + framework: 'React', + load: () => `import { OmniTabGroup, OmniTab } from "@capitec/omni-components-react/tab"; +import { OmniLabel } from "@capitec/omni-components-react/label"; + +const App = () => + + + + + + + + + + +;` + } + ], + name: 'Basic', + description: () => html` +
+ This is the recommended use. Headers for each tab is set by the header attribute. +
+ `, + play: async (context) => { + const tabGroupElement = within(context.canvasElement).getByTestId('test-tab-group'); + + // Get the tab bar element + const tabBar = (await querySelectorAsync(tabGroupElement.shadowRoot as ShadowRoot, '.tab-bar')) as HTMLElement; + await expect(tabBar).toBeTruthy(); + + // Get all the tab headers in the tab bar + const nestedTabHeaders = tabBar.querySelectorAll('omni-tab-header'); + const tabHeadersArray = [...nestedTabHeaders]; + // Confirm that 3 Tab headers exist. + await expect(tabHeadersArray.length).toBe(3); + + // Get the default slot of the Tab element. + const tabsSlotElement = (await querySelectorAsync(tabGroupElement.shadowRoot as ShadowRoot, 'slot:not([name])')) as HTMLSlotElement; + // Get the all the Tab elements in the default slot. + const tabElements = tabsSlotElement.assignedElements(); + // Confirm that there is 3 tab elements in the default slot. + await expect(tabElements.length).toBe(3); + } +} as ComponentStoryFormat; + +export const Active = { + render: () => html` + + + + + + + + + + + +`, + + frameworkSources: [ + { + framework: 'Vue', + load: (args) => getSourceFromLit(Active?.render?.(args), undefined, (s) => s.replace(' active', ' :active="true"')) + }, + { + framework: 'React', + load: () => `import { OmniTabGroup, OmniTab } from "@capitec/omni-components-react/tab"; +import { OmniLabel } from "@capitec/omni-components-react/label"; + +const App = () => + + + + + + + + + + +;` + } + ], + args: {}, + name: 'Active', + description: () => html` +
+ Set the active attribute on an <omni-tab> to indicate its active. By default, the first slotted one is active. +
+ `, + play: async (context) => { + const tabGroupElement = within(context.canvasElement).getByTestId('test-tab-group'); + + // Get the tab bar element + const tabBar = (await querySelectorAsync(tabGroupElement.shadowRoot as ShadowRoot, '.tab-bar')) as HTMLElement; + await expect(tabBar).toBeTruthy(); + + // Get all the tab headers in the tab bar + const nestedTabHeaders = tabBar.querySelectorAll('omni-tab-header'); + const tabHeadersArray = [...nestedTabHeaders]; + + //Get the active tab header. + const activeTabHeader = tabHeadersArray.find((c) => c.hasAttribute('data-active')); + await expect(activeTabHeader).toBeTruthy(); + // Confirm that the active tab header is the second one in the tab header array + await expect(activeTabHeader).toEqual(tabHeadersArray[1]); + // Click on the first tab header + await userEvent.click(tabHeadersArray[0]); + + // Get the default slot for all the Tabs + const tabsSlotElement = (await querySelectorAsync(tabGroupElement.shadowRoot as ShadowRoot, 'slot:not([name])')) as HTMLSlotElement; + // Get the active tab + const tabElement = tabsSlotElement.assignedElements().find((e) => e.hasAttribute('active')) as Tab; + // Get the active tab component slot + const tabElementSlot = (await querySelectorAsync(tabElement.shadowRoot as ShadowRoot, 'slot')) as HTMLSlotElement; + // Get the active tab label element based on the value of the label attribute and confirm that the label exists. + const labelElement = tabElementSlot.assignedElements().find((e) => e.getAttribute('label') === 'Label of Tab 1') as Tab; + await expect(labelElement).toBeTruthy(); + } +} as ComponentStoryFormat; + +export const Disabled = { + render: () => html` + + + + + + + + + + + +`, + frameworkSources: [ + { + framework: 'Vue', + load: (args) => getSourceFromLit(Disabled?.render?.(args), undefined, (s) => s.replace(' disabled', ' :disabled="true"')) + }, + { + framework: 'React', + load: () => `import { OmniTabGroup, OmniTab } from "@capitec/omni-components-react/tab"; +import { OmniLabel } from "@capitec/omni-components-react/label"; + +const App = () => + + + + + + + + + + +;` + } + ], + name: 'Disabled', + description: () => html` +
+ Set the disabled attribute on an <omni-tab> to indicate its disabled. +
+ `, + args: {}, + play: async (context) => { + const tabGroupElement = within(context.canvasElement).getByTestId('test-tab-group'); + const tabSelect = jest.fn(); + tabGroupElement.addEventListener('tab-select', tabSelect); + + // Get the tab bar element + const tabBar = (await querySelectorAsync(tabGroupElement.shadowRoot as ShadowRoot, '.tab-bar')) as HTMLElement; + + // Get all the tabs in the tab bar + const tabHeaders = tabBar.querySelectorAll('omni-tab-header'); + const tabsHeadersArray = [...tabHeaders]; + + //Get the disabled tab. + const disabledTabHeader = tabsHeadersArray.find((c) => c.hasAttribute('data-disabled')) as TabHeader; + // Confirm that the disabled tab the last tab in the tab header array. + await expect(disabledTabHeader).toEqual(tabsHeadersArray[2]); + + //Click the disabled tab header twice + await userEvent.click(disabledTabHeader); + await userEvent.click(disabledTabHeader); + // Confirm that the tab select event was emitted zero times. + await expect(tabSelect).toBeCalledTimes(0); + // Click the second tab header + await userEvent.click(tabsHeadersArray[1]); + // Confirm that the tab select event was emitted once. + await expect(tabSelect).toBeCalledTimes(1); + } +} as ComponentStoryFormat; diff --git a/src/tab/Tab.ts b/src/tab/Tab.ts new file mode 100644 index 00000000..bd2d7b71 --- /dev/null +++ b/src/tab/Tab.ts @@ -0,0 +1,72 @@ +import { css, html, TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { OmniElement } from '../core/OmniElement.js'; + +/** + * Control that can be used to display slotted content, for use within an Tab Group component. + * + * @import + * ```js + * import '@capitec/omni-components/tab'; + * ``` + * @example + * html``` + * + * Tab 1 Content + * + * ``` + * + * @element omni-tab + * + * @slot - Content to render inside the component body. + * + */ +@customElement('omni-tab') +export class Tab extends OmniElement { + /** + * Tab header label, use the omni-tab-header component for more complex header layouts + * @attr + */ + @property({ type: String, reflect: true }) header?: string; + + /** + * Indicator if the tab is disabled. + * @attr + */ + @property({ type: Boolean, reflect: true }) disabled?: boolean; + + /** + * Indicator if the tab is active. + * @attr + */ + @property({ type: Boolean, reflect: true }) active?: boolean; + + static override get styles() { + return [ + super.styles, + css` + :host { + width: 100%; + height: 100%; + overflow: auto; + } + + :host(*:not([active])) { + display: none !important; + } + ` + ]; + } + + protected override render(): TemplateResult { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'omni-tab': Tab; + } +} diff --git a/src/tab/TabGroup.stories.ts b/src/tab/TabGroup.stories.ts new file mode 100644 index 00000000..f4778990 --- /dev/null +++ b/src/tab/TabGroup.stories.ts @@ -0,0 +1,88 @@ +import { within } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import * as jest from 'jest-mock'; +import { html, nothing } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import expect from '../utils/ExpectDOM.js'; +import { assignToSlot, ComponentStoryFormat, querySelectorAsync, raw, getSourceFromLit } from '../utils/StoryUtils.js'; + +import { Tab } from './Tab.js'; +import { TabGroup } from './TabGroup.js'; +import '../label/Label.js'; +import './TabHeader.js'; +import './Tab.js'; +import './TabGroup.js'; +import '../icon/Icon.js'; + +interface Args { + header: string; + '[Default Slot]': string; +} + +export const Interactive: ComponentStoryFormat = { + render: (args: Args) => html` + + ${args.header ? html`${'\r\n'}${unsafeHTML(assignToSlot('header', args.header))}` : nothing} + ${args['[Default Slot]'] ? html`${'\r\n'}${unsafeHTML(args['[Default Slot]'])}` : nothing} + +`, + frameworkSources: [ + { + framework: 'Vue', + load: (args) => + getSourceFromLit(Interactive!.render!(args), undefined, (s) => + s.replace(' active', ' :active="true"').replace(' disabled', ' :disabled="true"') + ) + } + ], + name: 'Interactive', + description: () => html` +

+ The <omni-tab-group> component will display content based on the slotted <omni-tab> component(s). +

+

+ For an advanced use case check the <omni-tab-header> example. +

+ `, + args: { + header: '', + '[Default Slot]': raw` +
Tab 1 Content
+
+ +
Tab 2 Content
+
+ +
Tab 3 Content
+
` + }, + play: async (context) => { + const tabGroupElement = within(context.canvasElement).getByTestId('test-tab-group'); + const tabSelect = jest.fn(); + tabGroupElement.addEventListener('tab-select', tabSelect); + + // Get the tab bar element + const tabBar = (await querySelectorAsync(tabGroupElement.shadowRoot as ShadowRoot, '.tab-bar')) as HTMLElement; + await expect(tabBar).toBeTruthy(); + + // Get all the tab headers in the tab bar + const nestedTabHeaders = tabBar.querySelectorAll('omni-tab-header'); + const tabHeadersArray = [...nestedTabHeaders]; + // Get the active tab header. + const activeTabHeader = tabHeadersArray.find((c) => c.hasAttribute('data-active')); + // Confirm that the active tab header is the first one in the tab header array + await expect(activeTabHeader).toEqual(tabHeadersArray[0]); + // Click the second tab header. + await userEvent.click(tabHeadersArray[1]); + // Get the updated active tab and confirm that it is the one that was clicked. + const nextActiveTab = tabHeadersArray.find((c) => c.hasAttribute('data-active')); + await expect(nextActiveTab).toEqual(tabHeadersArray[1]); + // Confirm that the tab select event was emitted. + await expect(tabSelect).toBeCalledTimes(1); + // Get the default slot for Tabs element and get the new active Tab + const tabsSlotElement = (await querySelectorAsync(tabGroupElement.shadowRoot as ShadowRoot, 'slot:not([name])')) as HTMLSlotElement; + const activeTabElement = tabsSlotElement.assignedElements().find((e) => e.hasAttribute('active')) as Tab; + // Confirm that the active tabs header is equal to the expected value. + await expect(activeTabElement.header).toEqual('Tab 2'); + } +}; diff --git a/src/tab/TabGroup.ts b/src/tab/TabGroup.ts new file mode 100644 index 00000000..8cae0926 --- /dev/null +++ b/src/tab/TabGroup.ts @@ -0,0 +1,240 @@ +import { html, css, TemplateResult, nothing } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { OmniElement } from '../core/OmniElement.js'; +import { TabHeader } from './TabHeader.js'; + +/** + * Component that displays content in tabs. + * + * @import + * ```js + * import '@capitec/omni-components/tab'; + * ``` + * + * @example + * ```html + * + * + * Tab 1 + * + * + * Tab 2 + * + * + * Tab 3 + * + * + *``` + * + * @element omni-tab-group + * + * Registry of all properties defined by the component. + * + * @fires {CustomEvent<{ previous: HTMLElement, selected: HTMLElement}>} tab-select - Dispatched when an omni-tab is selected. + * + * @slot - All omni-tab components that are managed by this component. + * @slot header - Optional omni-tab-header components associated with each omni-tab component. + * + * @cssprop --omni-tab-group-tab-bar-width - Tabs tab bar width. + * @cssprop --omni-tab-group-tab-bar-height - Tabs tab bar height. + * @cssprop --omni-tab-group-tab-bar-border-bottom - Tabs tab bar bottom border. + * @cssprop --omni-tab-group-tab-bar-background-color - Tabs tab bar background color. + * + * @cssinherit omni-tab-header + */ +@customElement('omni-tab-group') +export class TabGroup extends OmniElement { + @state() private _observer: MutationObserver | undefined; + + override connectedCallback(): void { + super.connectedCallback(); + this._observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === `attributes`) { + // Re-render the component when a child item's attributes has changed. + this.requestUpdate(); + } + } + + // Start observing child attribute changes. + this._observer?.observe(this, { + attributes: true, + attributeFilter: [`header`, `active`, `disabled`], + subtree: true + }); + }); + } + + /** + * Clean-up the component once removed from the DOM. + * + * @ignore + * + * @returns {void} + */ + override disconnectedCallback() { + // Stop observing child attribute changes. + if (this._observer) { + this._observer.disconnect(); + } + + // Ensure the component is cleaned up correctly. + super.disconnectedCallback(); + } + + selectTab(tabHeader: TabHeader) { + if (!tabHeader || tabHeader.classList.contains('tab-bar')) { + return; + } + + // set tab header used in cases where the tab header has slotted content. + tabHeader = tabHeader.closest('omni-tab-header') as TabHeader; + const children = Array.from(this.children); + + let tab; + // Added check for cases where clicking between tabs results in the tabHeader being null. + if (tabHeader) { + tab = children.find((t) => (t.id && t.id === tabHeader.for) || t === tabHeader.data); + } + + if (!tab || tab.hasAttribute(disabledAttribute)) { + return; + } + + const tabHeaders = [ + ...children.filter((oth) => oth.slot === 'header'), + ...(this.shadowRoot?.querySelector('slot[name=header]')?.children || []) + ] as TabHeader[]; + + const previous = children.find((c) => c.hasAttribute(activeAttribute)); + + // Remove active attributes from tab headers and tabs. + tabHeaders.forEach((header) => { + header.removeAttribute(activeHeaderAttribute); + header.requestUpdate(); + }); + children.forEach((element) => { + element.removeAttribute(activeAttribute); + }); + + // Set active tab-header and tab + tab.setAttribute(activeAttribute, ''); + tabHeader.setAttribute(activeHeaderAttribute, ''); + tabHeader.requestUpdate(); + + this.dispatchEvent( + new CustomEvent('tab-select', { + detail: { + previous: previous, + selected: tab + } + }) + ); + + this.requestUpdate(); + } + + static override get styles() { + return [ + super.styles, + css` + :host { + width:100%; + height:100%; + overflow: hidden; + } + + /* Tab bar */ + :host > .tab-bar { + display: flex; + flex-direction: row; + align-items: center; + width: var(--omni-tab-group-tab-bar-width, 100%); + height: var(--omni-tab-group-tab-bar-height, 50px); + border-bottom: var(--omni-tab-group-tab-bar-border-bottom, none); + background: var(--omni-tab-group-tab-bar-background-color, transparent); + } + + /* CONTENT */ + ::slotted(*:not([active]):not([slot])) { + display: none !important; + } + ` + ]; + } + + protected override render(): TemplateResult { + /** + * Check what type of rendering option will be utilised the recommended implementation requires only nested omni-tab components the advanced implementation requires omni-tab-headers with associated omni-tab(s). + * If omni-tab-headers are nested the associated omni-tab requires the id attribute to be set to the corresponding omni-tab-headers for attribute. + */ + const tabHeaders = Array.from(this.querySelectorAll('omni-tab-header')).filter((oth) => oth.slot === 'header'); + const tabContent = Array.from(this.querySelectorAll('omni-tab')); + + if (tabContent.length > 0) { + if (!tabContent.find((c) => c.hasAttribute(activeAttribute) || c.active)) { + tabContent[0].setAttribute(activeAttribute, ''); + const activeTabHeader = tabHeaders.find((x) => x.for === tabContent[0].id); + if (activeTabHeader) { + activeTabHeader.setAttribute(activeHeaderAttribute, ''); + activeTabHeader.requestUpdate(); + } + } else { + const activeTab = tabContent.find((c) => c.hasAttribute(activeAttribute) || c.active); + const activeTabHeader = tabHeaders.find((x) => x.for === activeTab?.id); + if (activeTabHeader) { + activeTabHeader.setAttribute(activeHeaderAttribute, ''); + activeTabHeader.requestUpdate(); + } + } + + if (tabHeaders.length > 0) { + tabContent + .filter((t) => t.hasAttribute(disabledAttribute)) + .forEach((t) => { + const header = tabHeaders.find((x) => x.for === t.id); + if (header) { + header.setAttribute(disabledHeaderAttribute, ''); + header.requestUpdate(); + } + }); + } + } + + return html` +
+ + ${tabContent.map((tab) => + tab.hasAttribute('header') + ? html` + + ${tab.getAttribute('header')} + + ` + : nothing + )} + +
+ + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'omni-tab-group': TabGroup; + } +} + +// Custom Global Attributes +/** + * Indicates which slot is active + */ +export const activeAttribute = 'active'; +export const disabledAttribute = 'disabled'; + +const activeHeaderAttribute = 'data-active'; +const disabledHeaderAttribute = 'data-disabled'; diff --git a/src/tab/TabHeader.stories.ts b/src/tab/TabHeader.stories.ts new file mode 100644 index 00000000..2132ca83 --- /dev/null +++ b/src/tab/TabHeader.stories.ts @@ -0,0 +1,141 @@ +import { within } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { html, nothing } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import expect from '../utils/ExpectDOM.js'; +import { ComponentStoryFormat, querySelectorAsync, raw } from '../utils/StoryUtils.js'; + +import { TabGroup } from './TabGroup.js'; +import '../label/Label.js'; +import './TabHeader.js'; +import './Tab.js'; +import './TabGroup.js'; +import '../icon/Icon.js'; + +interface Args { + '[Default Slot]': string; +} + +export const Advanced: ComponentStoryFormat = { + render: (args: Args) => html` + + ${args['[Default Slot]'] ? html`${'\r\n'}${unsafeHTML(args['[Default Slot]'])}` : nothing} + +`, + frameworkSources: [ + { + framework: 'React', + load: () => `import { OmniTabGroup, OmniTab, OmniTabHeader } from "@capitec/omni-components-react/tab"; +import { OmniIcon } from "@capitec/omni-components-react/icon"; + +const App = () => + + + + + + + + + + + + + + + + + + + + + + +
Up
+
+ +
Down
+
+ +
Left
+
+ +
Right
+
+
;` + } + ], + name: 'Advanced', + args: { + '[Default Slot]': raw` + + + + + + + + + + + + + + + + + + + + + +
Up
+
+ +
Down
+
+ +
Left
+
+ +
Right
+
+ ` + }, + description: () => html` +
+ For slotting custom content into the header use the <omni-tab-header> component that targets the header slot of the <omni-tab-group> component by setting slot="header" and ensure you have a <omni-tab> component which has an id attribute that matches the <omni-tab-header> for attribute to display slotted content. +
+ `, + play: async (context) => { + const tabGroupElement = within(context.canvasElement).getByTestId('test-tab-group'); + // Get the tab bar element + const tabBar = (await querySelectorAsync(tabGroupElement.shadowRoot as ShadowRoot, '.tab-bar')) as HTMLElement; + await expect(tabBar).toBeTruthy(); + + // Check for the slot then check the content of the slot + const slotElement = tabBar.querySelector('slot[name="header"]'); + await expect(slotElement).toBeTruthy(); + + //Get all the tab headers in the header slot. + const tabHeaders = slotElement?.assignedElements().filter((e) => e.tagName.toLowerCase() === 'omni-tab-header') as HTMLElement[]; + await expect(tabHeaders).toBeTruthy(); + + //Get the active tab header. + const activeTabHeader = tabHeaders?.find((c) => c.hasAttribute('data-active')); + await expect(activeTabHeader).toBeTruthy(); + + //Get nested omni-icon. + const omniIcon = activeTabHeader?.querySelector('omni-icon'); + await expect(omniIcon).toBeTruthy(); + + //Get nested svg + const svgElement = activeTabHeader?.querySelector('svg'); + await expect(svgElement).toBeTruthy(); + + //Click the second tab. + await userEvent.click(tabHeaders[1]); + const nextActiveTab = tabHeaders.find((c) => c.hasAttribute('data-active')); + await expect(nextActiveTab).toBeTruthy(); + } +}; diff --git a/src/tab/TabHeader.ts b/src/tab/TabHeader.ts new file mode 100644 index 00000000..8f0f35c9 --- /dev/null +++ b/src/tab/TabHeader.ts @@ -0,0 +1,146 @@ +import { css, html, nothing, TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { OmniElement } from '../core/OmniElement.js'; + +/** + * Control that can be used to display custom slotted content, for use within Tab Group component with associated Tab component. + * + * @import + * ```js + * import '@capitec/omni-components/tab'; + * ``` + * @example + * html``` + * + * Slotted Content + * + * ``` + * + * @element omni-tab-header + * + * @slot - Content to render inside the tab header. + * + * @cssprop --omni-tab-header-font-color - Tab header font color. + * @cssprop --omni-tab-header-font-family - Tab header font family. + * @cssprop --omni-tab-header-font-size - Tab header font size. + * @cssprop --omni-tab-header-font-weight - Tab header font weight. + * + * @cssprop --omni-tab-header-disabled-cursor - Tab header disabled cursor. + * @cssprop --omni-tab-header-disabled-background-color - Tab header disabled background color. + * @cssprop --omni-tab-header-active-font-color - Tab header active font color. + * + * @cssprop --omni-tab-header-height - Tab header tab height. + * @cssprop --omni-tab-header-min-width - Tab header tab min width. + * @cssprop --omni-tab-header-max-width - Tab header tab max width. + * @cssprop --omni-tab-header-margin - Tab header tab margin. + * + * @cssprop --omni-tab-header-hover-background-color - Tab header tab hover background. + * + * @cssprop --omni-tab-header-indicator-bar-height - Tab header indicator bar height. + * @cssprop --omni-tab-header-indicator-bar-border-radius - Tab header indicator bar border radius. + * @cssprop --omni-tab-header-indicator-bar-width - Tab header indicator bar width. + * + * @cssprop --omni-tab-header-indicator-height - Tab header indicator height. + * @cssprop --omni-tab-header-indicator-color - Tab header indicator color. + * @cssprop --omni-tab-header-indicator-border-radius - Tab header indicator border radius. + * @cssprop --omni-tab-header-indicator-width - Tab header indicator width. + */ +@customElement('omni-tab-header') +export class TabHeader extends OmniElement { + /** + * Indicator of which omni-tab element with the matching corresponding id attribute should be displayed. + * @attr + */ + @property({ type: String, reflect: true }) for?: string; + + /** + * Data associated with the component. + * @no_attribute + */ + @property({ type: Object, reflect: false }) data?: unknown; + + static override get styles() { + return [ + super.styles, + css` + + /* host styles */ + :host { + color: var(--omni-tab-header-font-color, var(--omni-font-color)); + font-family: var(--omni-tab-header-font-family, var(--omni-font-family)); + font-size: var(--omni-tab-header-font-size, var(--omni-font-size)); + font-weight: var(--omni-tab-header-font-weight, var(--omni-font-weight)); + cursor: pointer; + + } + + :host([data-disabled]){ + cursor: var(--omni-tab-header-disabled-cursor, not-allowed); + background-color: var(--omni-tab-header-disabled-background-color, var(--omni-disabled-background-color)); + } + + /*Active styles*/ + :host([data-active]){ + color: var(--omni-tab-header-active-font-color, var(--omni-primary-color)); + } + + ::slotted(*) { + display: flex; + justify-content: center; + align-items: center; + height: calc(var(--omni-tab-header-height, 100%) - var(--omni-tab-header-indicator-height, 4px)); + } + + /* Tab */ + :host > .tab { + height: var(--omni-tab-header-height, 100%); + min-width: var(--omni-tab-header-min-width, auto); + max-width: var(--omni-tab-header-max-width, auto); + margin: var(--omni-tab-header-margin, 6px); + display: flex; + justify-content: center; + align-items: center; + } + + /* Added to resolve sticky hover state on mobile devices */ + @media (hover: hover) { + :host(:not([data-disabled]):hover) { + background-color: var(--omni-tab-header-hover-background-color, var(--omni-background-hover-color)); + } + } + + + :host > .indicator-bar { + height: var(--omni-tab-header-indicator-bar-height, 4px); + border-radius: var(--omni-tab-header-indicator-bar-border-radius, 100px 100px 0 0); + width: var(--omni-tab-header-indicator-bar-width, auto); + } + + + :host > .indicator-bar > .indicator { + height: var(--omni-tab-header-indicator-height, 4px); + background-color: var(--omni-tab-header-indicator-color, var(--omni-primary-color)); + border-radius: var(--omni-tab-header-indicator-border-radius, 100px 100px 0 0); + width: var(--omni-tab-header-indicator-width, auto); + } + ` + ]; + } + + protected override render(): TemplateResult { + return html` +
+ +
+
+ ${this.hasAttribute('data-active') ? html`
` : nothing} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'omni-tab-header': TabHeader; + } +} diff --git a/src/tab/index.ts b/src/tab/index.ts new file mode 100644 index 00000000..60fb86a9 --- /dev/null +++ b/src/tab/index.ts @@ -0,0 +1,3 @@ +export * from './Tab.js'; +export * from './TabHeader.js'; +export * from './TabGroup.js'; diff --git a/src/utils/StoryRenderer.ts b/src/utils/StoryRenderer.ts index c6285141..cf23060c 100644 --- a/src/utils/StoryRenderer.ts +++ b/src/utils/StoryRenderer.ts @@ -260,7 +260,7 @@ export class StoryRenderer extends LitElement { ); this.story = this.controller.story[this.key as string]; - this.story!.originalArgs = this.story?.originalArgs ?? JSON.parse(JSON.stringify(this.story?.args)); + this.story!.originalArgs = this.story?.originalArgs ?? JSON.parse(JSON.stringify(this.story?.args ?? {})); Object.keys(this.story?.args ?? {}).forEach((o) => { if (this.story!.args![o] === undefined) { this.story!.originalArgs[o] = undefined; @@ -451,7 +451,7 @@ export class StoryRenderer extends LitElement { .replaceAll('\\n', '') .replaceAll('\t', '') .replaceAll(' ', '') !== - JSON.stringify(this.story?.args) + JSON.stringify(this.story?.args ?? {}) .replaceAll('\n', '') .replaceAll('\\n', '') .replaceAll('\t', '') diff --git a/src/utils/StoryUtils.ts b/src/utils/StoryUtils.ts index 38c95719..06df49d5 100644 --- a/src/utils/StoryUtils.ts +++ b/src/utils/StoryUtils.ts @@ -63,6 +63,7 @@ function loadCssProperties( ); let superModule = elementModule; + do { if (superModule.declarations.find((sd: any) => sd.superclass)) { superModule = customElements.modules.find((module) =>