Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

tabs with behavior propety and select manual mode #6761

Merged
merged 14 commits into from
Sep 11, 2024
145 changes: 108 additions & 37 deletions packages/components/src/components/tabs/shadow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
LabelPropType,
StencilUnknown,
Stringified,
TabBehaviorPropType,
TabButtonProps,
TabsAPI,
TabsStates,
Expand All @@ -18,6 +19,7 @@ import {
uiUxHintMillerscheZahl,
validateAlign,
validateLabel,
validateTabBehavior,
watchJsonArrayString,
watchNumber,
} from '../../schema';
Expand All @@ -28,6 +30,7 @@ import { translate } from '../../i18n';
import type { JSX } from '@stencil/core';
import type { Generic } from 'adopted-style-sheets';
import { KolButtonGroupWcTag, KolButtonWcTag } from '../../core/component-names';
import { KeyboardKey } from '../../schema/enums';
// https://www.w3.org/TR/wai-aria-practices-1.1/examples/tabs/tabs-2/tabs.html

@Component({
Expand All @@ -42,53 +45,104 @@ export class KolTabs implements TabsAPI {
private tabPanelsElement?: HTMLElement;
private onCreateLabel = `${translate('kol-new')} …`;
private showCreateTab = false;
private currentFocusIndex: number | undefined;

private nextPossibleTabIndex = (tabs: TabButtonProps[], offset: number, step: number): number => {
if (step > 0) {
if (offset + step < tabs.length) {
if (tabs[offset + step]._disabled) {
return this.nextPossibleTabIndex(tabs, offset, step + 1);
}
return offset + step;
private nextPossibleTabIndex = (tabs: TabButtonProps[], offset: number, step = 1): number => {
const nextOffset = offset + step;

if (nextOffset < tabs.length) {
if (tabs[nextOffset]._disabled) {
return this.nextPossibleTabIndex(tabs, offset, step + 1);
}
} else if (step < 0) {
if (offset + step >= 0) {
if (tabs[offset + step]._disabled) {
return this.nextPossibleTabIndex(tabs, offset, step - 1);
}
return offset + step;
return nextOffset;
}

return offset;
};

private prevPossibleTabIndex = (tabs: TabButtonProps[], offset: number, step = 1): number => {
const nextOffset = offset - step;

if (nextOffset >= 0) {
if (tabs[nextOffset]._disabled) {
return this.prevPossibleTabIndex(tabs, offset, step + 1);
sdvg marked this conversation as resolved.
Show resolved Hide resolved
}
return nextOffset;
}

return offset;
};

private onKeyDown = (event: KeyboardEvent) => {
let selectedIndex: number | null = null;
switch (event.key) {
case 'ArrowRight':
selectedIndex = this.nextPossibleTabIndex(this.state._tabs, this.state._selected, 1);
switch (event.key as KeyboardKey) {
case KeyboardKey.ArrowRight:
this.goToNextTab(event);
break;
case 'ArrowLeft':
selectedIndex = this.nextPossibleTabIndex(this.state._tabs, this.state._selected, -1);
case KeyboardKey.ArrowLeft:
this.goToPreviousTab(event);
break;
case KeyboardKey.Space:
case KeyboardKey.Enter:
this.activateFocusedTab(event);
break;
}
if (selectedIndex !== null) {
const tab = this.state._tabs[selectedIndex];
if (tab._on?.onSelect) {
tab._on?.onSelect(event, selectedIndex);
}
this.onSelect(event, selectedIndex);
}
};

private readonly onClickSelect = (event: MouseEvent, index: number): void => {
const tab = this.state._tabs[index];
if (tab._on?.onSelect) {
tab._on?.onSelect(event, index);
private getCurrentFocusIndex(): number {
if (typeof this.currentFocusIndex === 'number') {
return this.currentFocusIndex;
}

return this.state._selected;
}

private getKeyboardTabChangeMode(): 'selectFocusOnly' | 'activateCompletely' {
if (this._behavior === 'select-manual') {
return 'selectFocusOnly';
}
this.onSelect(event, index);

return 'activateCompletely';
}

private goToNextTab(event: KeyboardEvent) {
const nextFocusIndex = this.nextPossibleTabIndex(this.state._tabs, this.getCurrentFocusIndex());
this.selectNextTabEvent(event, nextFocusIndex, this.getKeyboardTabChangeMode());
}

private goToPreviousTab(event: KeyboardEvent) {
const nextFocusIndex = this.prevPossibleTabIndex(this.state._tabs, this.getCurrentFocusIndex());
this.selectNextTabEvent(event, nextFocusIndex, this.getKeyboardTabChangeMode());
}

private activateFocusedTab(event: KeyboardEvent) {
if (typeof this.currentFocusIndex === 'number') {
this.onSelect(event, this.currentFocusIndex);
}
}

private readonly onClickSelect = (event: MouseEvent, index: number): void => {
this.selectNextTabEvent(event, index);
};

private selectNextTabEvent(
event: KeyboardEvent | MouseEvent,
nextTabIndex: number,
changeMode: 'selectFocusOnly' | 'activateCompletely' = 'activateCompletely',
): void {
this.currentFocusIndex = nextTabIndex;

this.focusTabById(nextTabIndex);

if (changeMode === 'activateCompletely') {
this._selected = nextTabIndex;

const tab = this.state._tabs[nextTabIndex];
tab._on?.onSelect?.(event, nextTabIndex);

this.onSelect(event, nextTabIndex);
}
}

// private readonly onClickClose = (event: Event, button: TabButtonProps, index: number) => {
// event.preventDefault();
// event.stopPropagation();
Expand All @@ -107,7 +161,7 @@ export class KolTabs implements TabsAPI {

private renderButtonGroup() {
return (
<KolButtonGroupWcTag class="tabs-button-group" role="tablist" aria-label={this.state._label} onKeyDown={this.onKeyDown}>
<KolButtonGroupWcTag class="tabs-button-group" role="tablist" aria-label={this.state._label} onKeyDown={this.onKeyDown} onBlur={this.onBlur}>
{this.state._tabs.map((button: TabButtonProps, index: number) => (
<KolButtonWcTag
_disabled={button._disabled}
Expand Down Expand Up @@ -170,6 +224,11 @@ export class KolTabs implements TabsAPI {
*/
@Prop() public _align?: AlignPropType = 'top';

/**
* Defines which behavior is active.
*/
@Prop() public _behavior?: TabBehaviorPropType;

/**
* Defines the visible or semantic label of the component (e.g. aria-label, label, headline, caption, summary, etc.).
*/
Expand Down Expand Up @@ -249,6 +308,11 @@ export class KolTabs implements TabsAPI {
validateAlign(this, value);
}

@Watch('_behavior')
public validateBehavior(value?: TabBehaviorPropType) {
validateTabBehavior(this, value);
}

@Watch('_label')
public validateLabel(value?: LabelPropType): void {
validateLabel(this, value, {
Expand Down Expand Up @@ -331,6 +395,7 @@ export class KolTabs implements TabsAPI {
this.validateOn(this._on);
this.validateSelected(this._selected);
this.validateTabs(this._tabs);
this.validateBehavior(this._behavior);
}

private readonly handleTabPanels = () => {
Expand Down Expand Up @@ -366,22 +431,28 @@ export class KolTabs implements TabsAPI {
}
}

private onSelect(event: CustomEvent | KeyboardEvent | MouseEvent | PointerEvent, index: number): void {
this._selected = index;
if (typeof this._on?.onSelect === 'function') {
this._on?.onSelect(event, index);
}
private focusTabById(index: number): void {
if (this.tabPanelsElement /* SSR instanceof HTMLElement */) {
const button: HTMLElement | null = koliBriQuerySelector(`button#${this.state._label.replace(/\s/g, '-')}-tab-${index}`, this.tabPanelsElement);
button?.focus();
}
}

private onSelect(event: CustomEvent | KeyboardEvent | MouseEvent | PointerEvent, index: number): void {
this._on?.onSelect?.(event, index);

this.focusTabById(index);
}

private onCreate = (event: Event) => {
event.preventDefault();
event.stopPropagation();
if (typeof this.state._on?.onCreate === 'function') {
this.state._on?.onCreate(event);
}
};

private onBlur = () => {
this.currentFocusIndex = undefined;
};
}
7 changes: 4 additions & 3 deletions packages/components/src/schema/components/tabs.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Generic } from 'adopted-style-sheets';

import type { Events } from '../enums';
import type { PropAlign, PropDisabled, PropHideLabel, PropLabel, PropTooltipAlign } from '../props';
import type { PropAlign, PropDisabled, PropHideLabel, PropLabel, PropTabBehavior, PropTooltipAlign } from '../props';
import type { EventCallback, EventValueOrEventCallback, KoliBriIconsProp, Stringified } from '../types';

export type KoliBriTabsCallbacks = {
Expand Down Expand Up @@ -33,7 +33,8 @@ type RequiredProps = {
type OptionalProps = {
on: KoliBriTabsCallbacks;
selected: number;
} & PropAlign;
} & PropAlign &
PropTabBehavior;

type RequiredStates = {
selected: number;
Expand All @@ -42,7 +43,7 @@ type RequiredStates = {
PropAlign;
type OptionalStates = {
on: KoliBriTabsCallbacks;
};
} & PropTabBehavior;

export type TabsProps = Generic.Element.Members<RequiredProps, OptionalProps>;
export type TabsStates = Generic.Element.Members<RequiredStates, OptionalStates>;
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/schema/enums/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './events';
export * from './keyboard';
8 changes: 8 additions & 0 deletions packages/components/src/schema/enums/keyboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export enum KeyboardKey {
ArrowDown = 'ArrowDown',
ArrowLeft = 'ArrowLeft',
ArrowRight = 'ArrowRight',
ArrowUp = 'ArrowUp',
Enter = 'Enter',
Space = ' ',
}
1 change: 1 addition & 0 deletions packages/components/src/schema/props/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export * from './rows';
export * from './show';
export * from './suggestions';
export * from './sync-value-by-selector';
export * from './tab-behavior';
export * from './table-callbacks';
export * from './table-data';
export * from './table-data-foot';
Expand Down
24 changes: 24 additions & 0 deletions packages/components/src/schema/props/tab-behavior.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/* types */
import type { Generic } from 'adopted-style-sheets';
import { watchValidator } from '../utils';

const tabBehaviorPropTypeOptions = ['select-automatic', 'select-manual'] as const;
export type TabBehaviorPropType = (typeof tabBehaviorPropTypeOptions)[number];

/**
* Defines which behavior is active.
*/
export type PropTabBehavior = {
behavior: TabBehaviorPropType;
};

/* validator */
export const validateTabBehavior = (component: Generic.Element.Component, value?: TabBehaviorPropType): void => {
watchValidator(
component,
`_behavior`,
(value) => typeof value === 'string' && tabBehaviorPropTypeOptions.includes(value),
new Set([`KoliBriTabBehavior {${tabBehaviorPropTypeOptions.join(', ')}`]),
value,
);
};
61 changes: 61 additions & 0 deletions packages/samples/react/src/components/tabs/behavior.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { FC } from 'react';
import React from 'react';

import { KolHeading, KolTabs } from '@public-ui/react';
import { SampleDescription } from '../SampleDescription';

const tabs = [
{
_icons: 'codicon codicon-pie-chart',
_label: 'First tab',
_on: {
onSelect: (event: Event) => {
console.log('First tab selected', event);
},
},
},
{
_icons: 'codicon codicon-calendar',
_label: 'Second Tab',
},
{
_disabled: true,
_icons: 'codicon codicon-briefcase',
_label: 'Disabled Tab',
},
{
_icons: 'codicon codicon-telescope',
_label: 'Last tab',
},
];

export const TabsBehavior: FC = () => (
<>
<SampleDescription>
<p>
This sample shows KolTabs with the property <code>_behavior</code> set to <code>select-manual</code> and <code>select-automatic</code>.
</p>
<p>This property allows controlling when an arrow key is pressed whether the tab change takes place right away or only focuses the tab caption.</p>
</SampleDescription>
<div className="grid gap-8">
<div className="grid gap-4">
<KolHeading _level={2} _label='Tabs with "select manual" behavior' />
<KolTabs _tabs={tabs} _behavior="select-manual" _label="Tabs with select manual behavior">
<div slot="tab-0">Contents of Tab 1</div>
<div slot="tab-1">Contents of Tab 2</div>
<div slot="tab-2">Contents of Tab 3</div>
<div slot="tab-3">Contents of Tab 4</div>
</KolTabs>
</div>
<div className="grid gap-4">
<KolHeading _level={2} _label='Tabs with "select automatic" behavior' />
<KolTabs _tabs={tabs} className="mt-4" _behavior="select-automatic" _label="Tabs with select automatic behavior">
<div slot="tab-0">Contents of Tab 1</div>
<div slot="tab-1">Contents of Tab 2</div>
<div slot="tab-2">Contents of Tab 3</div>
<div slot="tab-3">Contents of Tab 4</div>
</KolTabs>
</div>
</div>
</>
);
2 changes: 2 additions & 0 deletions packages/samples/react/src/components/tabs/routes.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Routes } from '../../shares/types';
import { TabsBasic } from './basic';
import { TabsIconsOnly } from './icons-only';
import { TabsBehavior } from './behavior';

export const TABS_ROUTES: Routes = {
tabs: {
basic: TabsBasic,
'icons-only': TabsIconsOnly,
behavior: TabsBehavior,
},
};
Loading