@m3e/tabs 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +186 -0
- package/cem.config.mjs +16 -0
- package/demo/index.html +103 -0
- package/dist/css-custom-data.json +137 -0
- package/dist/custom-elements.json +725 -0
- package/dist/html-custom-data.json +71 -0
- package/dist/index.js +737 -0
- package/dist/index.js.map +1 -0
- package/dist/index.min.js +278 -0
- package/dist/index.min.js.map +1 -0
- package/dist/src/TabElement.d.ts +87 -0
- package/dist/src/TabElement.d.ts.map +1 -0
- package/dist/src/TabHeaderPosition.d.ts +3 -0
- package/dist/src/TabHeaderPosition.d.ts.map +1 -0
- package/dist/src/TabPanelElement.d.ts +46 -0
- package/dist/src/TabPanelElement.d.ts.map +1 -0
- package/dist/src/TabVariant.d.ts +3 -0
- package/dist/src/TabVariant.d.ts.map +1 -0
- package/dist/src/TabsElement.d.ts +117 -0
- package/dist/src/TabsElement.d.ts.map +1 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.d.ts.map +1 -0
- package/eslint.config.mjs +13 -0
- package/package.json +49 -0
- package/rollup.config.js +32 -0
- package/src/TabElement.ts +252 -0
- package/src/TabHeaderPosition.ts +2 -0
- package/src/TabPanelElement.ts +63 -0
- package/src/TabVariant.ts +2 -0
- package/src/TabsElement.ts +385 -0
- package/src/index.ts +5 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { css, CSSResultGroup, html, LitElement, nothing, PropertyValues, unsafeCSS } from "lit";
|
|
2
|
+
import { customElement, property, query, state } from "lit/decorators.js";
|
|
3
|
+
|
|
4
|
+
import { AttachInternals, DesignToken, ResizeController } from "@m3e/core";
|
|
5
|
+
import { SelectionManager, selectionManager } from "@m3e/core/a11y";
|
|
6
|
+
|
|
7
|
+
import { TabVariant } from "./TabVariant";
|
|
8
|
+
import { M3eTabElement } from "./TabElement";
|
|
9
|
+
import { TabHeaderPosition } from "./TabHeaderPosition";
|
|
10
|
+
|
|
11
|
+
const MIN_PRIMARY_TAB_WIDTH = 24;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @summary
|
|
15
|
+
* Organizes content into separate views where only one view can be visible at a time.
|
|
16
|
+
*
|
|
17
|
+
* @description
|
|
18
|
+
* The `m3e-tabs` component provides a structured navigation surface for organizing content into distinct views,
|
|
19
|
+
* where only one view is visible at a time. It supports scrollable tab headers with optional pagination,
|
|
20
|
+
* accessible labeling for navigation controls, and configurable header positioning to suit various layout
|
|
21
|
+
* contexts. Two visual variants are available: `primary`, which emphasizes active indicators and shape styling
|
|
22
|
+
* for prominent navigation, and `secondary`, which offers a more subtle presentation with reduced indicator
|
|
23
|
+
* thickness. Stretch behavior allows tabs to expand and align rhythmically within their container, consistent
|
|
24
|
+
* with Material 3 guidance.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* The following example illustrates using the `m3e-tabs`, `m3e-tab`, and `m3e-tab-panel` components to present
|
|
28
|
+
* secondary tabs.
|
|
29
|
+
* ```html
|
|
30
|
+
* <m3e-tabs>
|
|
31
|
+
* <m3e-tab selected for="videos"><m3e-icon slot="icon" name="videocam"></m3e-icon>Video</m3e-tab>
|
|
32
|
+
* <m3e-tab for="photos"><m3e-icon slot="icon" name="photo"></m3e-icon>Photos</m3e-tab>
|
|
33
|
+
* <m3e-tab for="audio"><m3e-icon slot="icon" name="music_note"></m3e-icon>Audio</m3e-tab>
|
|
34
|
+
* <m3e-tab-panel id="videos">Videos</m3e-tab-panel>
|
|
35
|
+
* <m3e-tab-panel id="photos">Photos</m3e-tab-panel>
|
|
36
|
+
* <m3e-tab-panel id="audio">Audio</m3e-tab-panel>
|
|
37
|
+
* </m3e-tabs>
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* @tag m3e-tabs
|
|
41
|
+
*
|
|
42
|
+
* @slot - Renders the tabs.
|
|
43
|
+
* @slot panel - Renders the panels of the tabs.
|
|
44
|
+
* @slot next-icon - Renders the icon to present for the next button used to paginate.
|
|
45
|
+
* @slot prev-icon - Renders the icon to present for the previous button used to paginate.
|
|
46
|
+
*
|
|
47
|
+
* @attr disable-pagination - Whether scroll buttons are disabled.
|
|
48
|
+
* @attr header-position - The position of the tab headers.
|
|
49
|
+
* @attr next-page-label - The accessible label given to the button used to move to the previous page.
|
|
50
|
+
* @attr previous-page-label - The accessible label given to the button used to move to the next page.
|
|
51
|
+
* @attr stretch - Whether tabs are stretched to fill the header.
|
|
52
|
+
* @attr variant - The appearance variant of the tabs.
|
|
53
|
+
*
|
|
54
|
+
* @fires change - Emitted when the selected tab changes.
|
|
55
|
+
*
|
|
56
|
+
* @cssprop --m3e-tabs-paginator-button-icon-size - Overrides the icon size for paginator buttons.
|
|
57
|
+
* @cssprop --m3e-tabs-active-indicator-color - Color of the active tab indicator.
|
|
58
|
+
* @cssprop --m3e-tabs-primary-before-active-indicator-shape - Border radius for active indicator when header is before and variant is primary.
|
|
59
|
+
* @cssprop --m3e-tabs-primary-after-active-indicator-shape - Border radius for active indicator when header is after and variant is primary.
|
|
60
|
+
* @cssprop --m3e-tabs-primary-active-indicator-inset - Inset for primary variant's active indicator.
|
|
61
|
+
* @cssprop --m3e-tabs-primary-active-indicator-thickness - Thickness for primary variant's active indicator.
|
|
62
|
+
* @cssprop --m3e-tabs-secondary-active-indicator-thickness - Thickness for secondary variant's active indicator.
|
|
63
|
+
*/
|
|
64
|
+
@customElement("m3e-tabs")
|
|
65
|
+
export class M3eTabsElement extends AttachInternals(LitElement) {
|
|
66
|
+
/** The styles of the element. */
|
|
67
|
+
static override styles: CSSResultGroup = css`
|
|
68
|
+
:host {
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-direction: column;
|
|
71
|
+
position: relative;
|
|
72
|
+
}
|
|
73
|
+
.tablist {
|
|
74
|
+
position: relative;
|
|
75
|
+
box-sizing: border-box;
|
|
76
|
+
flex: none;
|
|
77
|
+
}
|
|
78
|
+
::slotted(prev-icon),
|
|
79
|
+
::slotted(next-icon),
|
|
80
|
+
.icon {
|
|
81
|
+
width: 1em;
|
|
82
|
+
font-size: var(--m3e-tabs-paginator-button-icon-size, var(--m3e-icon-button-icon-size, 1.5rem)) !important;
|
|
83
|
+
}
|
|
84
|
+
.header {
|
|
85
|
+
display: flex;
|
|
86
|
+
flex-direction: column;
|
|
87
|
+
}
|
|
88
|
+
.tabs {
|
|
89
|
+
display: flex;
|
|
90
|
+
flex-wrap: nowrap;
|
|
91
|
+
align-items: center;
|
|
92
|
+
}
|
|
93
|
+
.ink-bar {
|
|
94
|
+
box-sizing: border-box;
|
|
95
|
+
height: var(--_tabs-active-indicator-thickness);
|
|
96
|
+
}
|
|
97
|
+
.active-indicator {
|
|
98
|
+
position: relative;
|
|
99
|
+
height: var(--_tabs-active-indicator-thickness);
|
|
100
|
+
left: calc(var(--_tabs-active-tab-position) + var(--_tabs-activate-indicator-inset, 0px));
|
|
101
|
+
width: calc(var(--_tabs-active-tab-size) - calc(var(--_tabs-activate-indicator-inset, 0px) * 2));
|
|
102
|
+
background-color: var(--m3e-tabs-active-indicator-color, ${DesignToken.color.primary});
|
|
103
|
+
transition: ${unsafeCSS(
|
|
104
|
+
`left var(--m3e-slide-animation-duration, ${DesignToken.motion.duration.long2}) ${DesignToken.motion.easing.standard},
|
|
105
|
+
width var(--m3e-slide-animation-duration, ${DesignToken.motion.duration.long2}) ${DesignToken.motion.easing.standard}`
|
|
106
|
+
)};
|
|
107
|
+
}
|
|
108
|
+
:host([header-position="after"]) .header {
|
|
109
|
+
flex-direction: column-reverse;
|
|
110
|
+
}
|
|
111
|
+
:host([header-position="before"]) .ink-bar {
|
|
112
|
+
margin-top: calc(0px - var(--_tabs-active-indicator-thickness));
|
|
113
|
+
}
|
|
114
|
+
:host([header-position="before"]) .tablist {
|
|
115
|
+
--m3e-slide-group-divider-bottom: var(--m3e-divider-thickness, 1px) solid
|
|
116
|
+
var(--m3e-divider-color, ${DesignToken.color.outlineVariant});
|
|
117
|
+
}
|
|
118
|
+
:host([header-position="after"]) .ink-bar {
|
|
119
|
+
margin-bottom: calc(0px - var(--_tabs-active-indicator-thickness));
|
|
120
|
+
}
|
|
121
|
+
:host([header-position="after"]) .tablist {
|
|
122
|
+
--m3e-slide-group-divider-top: var(--m3e-divider-thickness, 1px) solid
|
|
123
|
+
var(--m3e-divider-color, ${DesignToken.color.outlineVariant});
|
|
124
|
+
}
|
|
125
|
+
:host([header-position="before"][variant="primary"]) .active-indicator {
|
|
126
|
+
border-radius: var(--m3e-tabs-primary-before-active-indicator-shape, ${DesignToken.shape.corner.extraSmallTop});
|
|
127
|
+
}
|
|
128
|
+
:host([header-position="after"][variant="primary"]) .active-indicator {
|
|
129
|
+
border-radius: var(--m3e-tabs-primary-after-active-indicator-shape, ${DesignToken.shape.corner.extraSmallBottom});
|
|
130
|
+
}
|
|
131
|
+
.tabs {
|
|
132
|
+
flex: 1 1 auto;
|
|
133
|
+
}
|
|
134
|
+
:host([variant="primary"]) .tablist {
|
|
135
|
+
--_tabs-activate-indicator-inset: var(--m3e-tabs-primary-active-indicator-inset, 0.125rem);
|
|
136
|
+
--_tabs-active-indicator-thickness: var(--m3e-tabs-primary-active-indicator-thickness, 0.1875rem);
|
|
137
|
+
--_tab-height: 4rem;
|
|
138
|
+
}
|
|
139
|
+
:host([header-position="before"]) .tablist {
|
|
140
|
+
--_tab-focus-ring-bottom-offset: calc(var(--_tabs-active-indicator-thickness) + 1px);
|
|
141
|
+
}
|
|
142
|
+
:host([header-position="after"]) .tablist {
|
|
143
|
+
--_tab-focus-ring-top-offset: calc(var(--_tabs-active-indicator-thickness) + 2px);
|
|
144
|
+
}
|
|
145
|
+
:host([header-position="before"][variant="primary"]) .tablist {
|
|
146
|
+
--_tab-direction: column;
|
|
147
|
+
}
|
|
148
|
+
:host([header-position="after"][variant="primary"]) .tablist {
|
|
149
|
+
--_tab-direction: column-reverse;
|
|
150
|
+
}
|
|
151
|
+
:host([variant="secondary"]) .tablist {
|
|
152
|
+
--_tabs-active-indicator-thickness: var(--m3e-tabs-secondary-active-indicator-thickness, 0.125rem);
|
|
153
|
+
--_tab-height: 3rem;
|
|
154
|
+
}
|
|
155
|
+
:host([stretch]) .header {
|
|
156
|
+
width: 100%;
|
|
157
|
+
--_tab-grow: 1;
|
|
158
|
+
}
|
|
159
|
+
:host(.-no-animate) .active-indicator {
|
|
160
|
+
transition: none;
|
|
161
|
+
}
|
|
162
|
+
@media (prefers-reduced-motion) {
|
|
163
|
+
.active-indicator {
|
|
164
|
+
transition: none;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
@media (forced-colors: active) {
|
|
168
|
+
.active-indicator {
|
|
169
|
+
background-color: ButtonText;
|
|
170
|
+
--m3e-divider-color: GrayText;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
`;
|
|
174
|
+
|
|
175
|
+
/** @private */ @query(".tablist") private readonly _tablist!: HTMLElement;
|
|
176
|
+
/** @private */ @state() _selectedIndex: number | null = null;
|
|
177
|
+
|
|
178
|
+
/** @internal */
|
|
179
|
+
readonly [selectionManager] = new SelectionManager<M3eTabElement>()
|
|
180
|
+
.onSelectedItemsChange(() => this.#handleSelectedChange())
|
|
181
|
+
.withHomeAndEnd()
|
|
182
|
+
.withWrap();
|
|
183
|
+
|
|
184
|
+
constructor() {
|
|
185
|
+
super();
|
|
186
|
+
new ResizeController(this, {
|
|
187
|
+
skipInitial: true,
|
|
188
|
+
callback: () => {
|
|
189
|
+
this.classList.toggle("-no-animate", true);
|
|
190
|
+
this.#updateInkBar();
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Whether scroll buttons are disabled.
|
|
197
|
+
* @default false
|
|
198
|
+
*/
|
|
199
|
+
@property({ attribute: "disable-pagination", type: Boolean }) disablePagination = false;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The position of the tab headers.
|
|
203
|
+
* @default "before"
|
|
204
|
+
*/
|
|
205
|
+
@property({ attribute: "header-position", reflect: true }) headerPosition: TabHeaderPosition = "before";
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The appearance variant of the tabs.
|
|
209
|
+
* @default "secondary"
|
|
210
|
+
*/
|
|
211
|
+
@property({ reflect: true }) variant: TabVariant = "secondary";
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Whether tabs are stretched to fill the header.
|
|
215
|
+
* @default false
|
|
216
|
+
*/
|
|
217
|
+
@property({ type: Boolean, reflect: true }) stretch = false;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The accessible label given to the button used to move to the previous page.
|
|
221
|
+
* @default "Previous page"
|
|
222
|
+
*/
|
|
223
|
+
@property({ attribute: "previous-page-label" }) previousPageLabel = "Previous page";
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The accessible label given to the button used to move to the next page.
|
|
227
|
+
* @default "Next page"
|
|
228
|
+
*/
|
|
229
|
+
@property({ attribute: "next-page-label" }) nextPageLabel = "Next page";
|
|
230
|
+
|
|
231
|
+
/** The tabs. */
|
|
232
|
+
get tabs(): readonly M3eTabElement[] {
|
|
233
|
+
return this[selectionManager]?.items ?? [];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The selected tab. */
|
|
237
|
+
get selectedTab(): M3eTabElement | null {
|
|
238
|
+
return this._selectedIndex !== null ? this.tabs[this._selectedIndex] ?? null : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The zero-based index of the selected tab. */
|
|
242
|
+
get selectedIndex(): number {
|
|
243
|
+
return this._selectedIndex ?? -1;
|
|
244
|
+
}
|
|
245
|
+
set selectedIndex(value: number) {
|
|
246
|
+
if (value >= 0 && value < this.tabs.length) {
|
|
247
|
+
this.tabs[value].selected = true;
|
|
248
|
+
} else {
|
|
249
|
+
const selectedTab = this.selectedTab;
|
|
250
|
+
if (selectedTab) {
|
|
251
|
+
selectedTab.selected = false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** @inheritdoc */
|
|
257
|
+
override connectedCallback(): void {
|
|
258
|
+
super.connectedCallback();
|
|
259
|
+
this.classList.toggle("-no-animate", true);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** @inheritdoc */
|
|
263
|
+
protected override updated(_changedProperties: PropertyValues<this>): void {
|
|
264
|
+
super.updated(_changedProperties);
|
|
265
|
+
|
|
266
|
+
if ((_changedProperties.has("variant") || _changedProperties.has("stretch")) && this._selectedIndex !== null) {
|
|
267
|
+
this.#updateInkBar();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** @inheritdoc */
|
|
272
|
+
protected override render(): unknown {
|
|
273
|
+
let panelIndex: number | null = null;
|
|
274
|
+
if (this.selectedTab?.control) {
|
|
275
|
+
panelIndex = [...this.querySelectorAll("[slot='panel']")].indexOf(this.selectedTab.control);
|
|
276
|
+
if (panelIndex === -1) {
|
|
277
|
+
panelIndex = null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return html` ${this.headerPosition === "before" ? this.#renderHeader() : nothing}
|
|
282
|
+
<m3e-slide class="tabs" .selectedIndex="${panelIndex}">
|
|
283
|
+
<slot name="panel"></slot>
|
|
284
|
+
</m3e-slide>
|
|
285
|
+
${this.headerPosition === "after" ? this.#renderHeader() : nothing}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** @private */
|
|
289
|
+
#renderHeader(): unknown {
|
|
290
|
+
return html`<m3e-slide-group
|
|
291
|
+
class="tablist"
|
|
292
|
+
threshold="8"
|
|
293
|
+
previous-page-label="${this.previousPageLabel}"
|
|
294
|
+
next-page-label="${this.nextPageLabel}"
|
|
295
|
+
?disabled="${this.disablePagination}"
|
|
296
|
+
>
|
|
297
|
+
<slot name="prev-icon" slot="prev-icon">
|
|
298
|
+
<svg class="prev icon" viewBox="0 -960 960 960" fill="currentColor">
|
|
299
|
+
<path d="M640-80 240-480l400-400 71 71-329 329 329 329-71 71Z" />
|
|
300
|
+
</svg>
|
|
301
|
+
</slot>
|
|
302
|
+
<slot name="next-icon" slot="next-icon">
|
|
303
|
+
<svg class="next icon" viewBox="0 -960 960 960" fill="currentColor">
|
|
304
|
+
<path d="m321-80-71-71 329-329-329-329 71-71 400 400L321-80Z" />
|
|
305
|
+
</svg>
|
|
306
|
+
</slot>
|
|
307
|
+
<div class="header" role="tablist">
|
|
308
|
+
<div class="tabs">
|
|
309
|
+
<slot
|
|
310
|
+
@slotchange="${this.#handleSlotChange}"
|
|
311
|
+
@keydown="${this.#handleKeyDown}"
|
|
312
|
+
@change="${this.#handleChange}"
|
|
313
|
+
></slot>
|
|
314
|
+
</div>
|
|
315
|
+
<div class="ink-bar" aria-hidden="true">
|
|
316
|
+
<div class="active-indicator"></div>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
</m3e-slide-group>`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** @private */
|
|
323
|
+
#handleSlotChange(): void {
|
|
324
|
+
this[selectionManager].setItems([...this.querySelectorAll("m3e-tab")]);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** @private */
|
|
328
|
+
#handleKeyDown(e: KeyboardEvent): void {
|
|
329
|
+
this[selectionManager].onKeyDown(e);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** @private */
|
|
333
|
+
#handleChange(e: Event): void {
|
|
334
|
+
e.stopPropagation();
|
|
335
|
+
this.dispatchEvent(new Event("change", { bubbles: true }));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** @private */
|
|
339
|
+
#handleSelectedChange(): void {
|
|
340
|
+
const selected = this[selectionManager].selectedItems[0];
|
|
341
|
+
let selectedIndex = selected ? this.tabs.indexOf(selected) : null;
|
|
342
|
+
if (selectedIndex === -1) {
|
|
343
|
+
selectedIndex = null;
|
|
344
|
+
}
|
|
345
|
+
this._selectedIndex = selectedIndex;
|
|
346
|
+
this.#updateInkBar();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** @private */
|
|
350
|
+
#updateInkBar(): void {
|
|
351
|
+
if (!this._tablist) return;
|
|
352
|
+
const selected = this[selectionManager].selectedItems[0];
|
|
353
|
+
let left = 0;
|
|
354
|
+
let width = 0;
|
|
355
|
+
|
|
356
|
+
if (selected && this._selectedIndex !== null) {
|
|
357
|
+
for (let i = 0; i < this._selectedIndex; i++) {
|
|
358
|
+
left += this.tabs[i].clientWidth;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
width = selected.clientWidth;
|
|
362
|
+
if (this.variant === "primary" && selected.label) {
|
|
363
|
+
left += selected.label.offsetLeft;
|
|
364
|
+
width = selected.label.clientWidth;
|
|
365
|
+
if (width < MIN_PRIMARY_TAB_WIDTH) {
|
|
366
|
+
left -= (MIN_PRIMARY_TAB_WIDTH - width) / 2;
|
|
367
|
+
width = MIN_PRIMARY_TAB_WIDTH;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
this._tablist.style.setProperty("--_tabs-active-tab-position", `${left}px`);
|
|
373
|
+
this._tablist.style.setProperty("--_tabs-active-tab-size", `${width}px`);
|
|
374
|
+
|
|
375
|
+
if (width > 0 && this.classList.contains("-no-animate")) {
|
|
376
|
+
setTimeout(() => this.classList.toggle("-no-animate", false));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
declare global {
|
|
382
|
+
interface HTMLElementTagNameMap {
|
|
383
|
+
"m3e-tabs": M3eTabsElement;
|
|
384
|
+
}
|
|
385
|
+
}
|
package/src/index.ts
ADDED