@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.
@@ -0,0 +1,252 @@
1
+ import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit";
2
+ import { customElement, query } from "lit/decorators.js";
3
+
4
+ import {
5
+ AttachInternals,
6
+ DesignToken,
7
+ Disabled,
8
+ Focusable,
9
+ HtmlFor,
10
+ KeyboardClick,
11
+ M3eFocusRingElement,
12
+ M3eRippleElement,
13
+ M3eStateLayerElement,
14
+ Role,
15
+ Selected,
16
+ } from "@m3e/core";
17
+
18
+ import { addAriaReferencedId, removeAriaReferencedId, selectionManager } from "@m3e/core/a11y";
19
+
20
+ /**
21
+ * @summary
22
+ * An interactive element that, when activated, presents an associated tab panel.
23
+ *
24
+ * @description
25
+ * The `m3e-tab` component is an interactive control used within a tabbed interface to activate and
26
+ * reveal an associated tab panel. It supports accessible labeling, optional iconography, and selection
27
+ * state styling consistent with Material 3 guidance. Tabs may be disabled, selected, or linked to a
28
+ * specific panel via the `for` attribute, enabling declarative control and semantic clarity.
29
+ *
30
+ * @example
31
+ * The following example illustrates using the `m3e-tabs`, `m3e-tab`, and `m3e-tab-panel` components to present
32
+ * secondary tabs.
33
+ * ```html
34
+ * <m3e-tabs>
35
+ * <m3e-tab selected for="videos"><m3e-icon slot="icon" name="videocam"></m3e-icon>Video</m3e-tab>
36
+ * <m3e-tab for="photos"><m3e-icon slot="icon" name="photo"></m3e-icon>Photos</m3e-tab>
37
+ * <m3e-tab for="audio"><m3e-icon slot="icon" name="music_note"></m3e-icon>Audio</m3e-tab>
38
+ * <m3e-tab-panel id="videos">Videos</m3e-tab-panel>
39
+ * <m3e-tab-panel id="photos">Photos</m3e-tab-panel>
40
+ * <m3e-tab-panel id="audio">Audio</m3e-tab-panel>
41
+ * </m3e-tabs>
42
+ * ```
43
+ *
44
+ * @tag m3e-tab
45
+ *
46
+ * @slot - Renders the label of the tab.
47
+ * @slot icon - Renders an icon before the tab's label.
48
+ *
49
+ * @attr disabled - Whether the element is disabled.
50
+ * @attr for - The query selector used to specify the element related to this element.
51
+ * @attr selected - Whether the element is selected.
52
+ *
53
+ * @cssprop --m3e-tab-font-size - Font size for tab label.
54
+ * @cssprop --m3e-tab-font-weight - Font weight for tab label.
55
+ * @cssprop --m3e-tab-line-height - Line height for tab label.
56
+ * @cssprop --m3e-tab-tracking - Letter spacing for tab label.
57
+ * @cssprop --m3e-tab-padding-start - Padding on the inline start of the tab.
58
+ * @cssprop --m3e-tab-padding-end - Padding on the inline end of the tab.
59
+ * @cssprop --m3e-tab-focus-ring-shape - Border radius for the focus ring.
60
+ * @cssprop --m3e-tab-selected-color - Text color for selected tab.
61
+ * @cssprop --m3e-tab-selected-container-hover-color - Hover state-layer color for selected tab.
62
+ * @cssprop --m3e-tab-selected-container-focus-color - Focus state-layer color for selected tab.
63
+ * @cssprop --m3e-tab-selected-ripple-color - Ripple color for selected tab.
64
+ * @cssprop --m3e-tab-unselected-color - Text color for unselected tab.
65
+ * @cssprop --m3e-tab-unselected-container-hover-color - Hover state-layer color for unselected tab.
66
+ * @cssprop --m3e-tab-unselected-container-focus-color - Focus state-layer color for unselected tab.
67
+ * @cssprop --m3e-tab-unselected-ripple-color - Ripple color for unselected tab.
68
+ * @cssprop --m3e-tab-disabled-color - Text color for disabled tab.
69
+ * @cssprop --m3e-tab-disabled-opacity - Text opacity for disabled tab.
70
+ * @cssprop --m3e-tab-spacing - Column gap between icon and label.
71
+ * @cssprop --m3e-tab-icon-size - Font size for slotted icon.
72
+ */
73
+ @customElement("m3e-tab")
74
+ export class M3eTabElement extends Selected(
75
+ HtmlFor(KeyboardClick(Focusable(Disabled(AttachInternals(Role(LitElement, "tab"), true)))))
76
+ ) {
77
+ /** The styles of the element. */
78
+ static override styles: CSSResultGroup = css`
79
+ :host {
80
+ display: inline-block;
81
+ outline: none;
82
+ user-select: none;
83
+ height: calc(var(--_tab-height) + ${DesignToken.density.calc(-3)});
84
+ font-size: var(--m3e-tab-font-size, ${DesignToken.typescale.standard.title.small.fontSize});
85
+ font-weight: var(--m3e-tab-font-weight, ${DesignToken.typescale.standard.title.small.fontWeight});
86
+ line-height: var(--m3e-tab-line-height, ${DesignToken.typescale.standard.title.small.lineHeight});
87
+ letter-spacing: var(--m3e-tab-tracking, ${DesignToken.typescale.standard.title.small.tracking});
88
+ flex-grow: var(--_tab-grow);
89
+ }
90
+ :host(:not(:disabled)) {
91
+ cursor: pointer;
92
+ }
93
+ .base {
94
+ box-sizing: border-box;
95
+ vertical-align: middle;
96
+ display: inline-flex;
97
+ align-items: center;
98
+ justify-content: center;
99
+ position: relative;
100
+ width: 100%;
101
+ height: 100%;
102
+ padding-inline-start: var(--m3e-tab-padding-start, 1.5rem);
103
+ padding-inline-end: var(--m3e-tab-padding-end, 1.5rem);
104
+ }
105
+ .touch {
106
+ position: absolute;
107
+ height: 3rem;
108
+ left: 0;
109
+ right: 0;
110
+ }
111
+ .focus-ring {
112
+ border-radius: var(--m3e-tab-focus-ring-shape, ${DesignToken.shape.corner.large});
113
+ }
114
+ :host([selected]:focus-within) .focus-ring {
115
+ top: var(--_tab-focus-ring-top-offset, 0);
116
+ bottom: var(--_tab-focus-ring-bottom-offset, 0);
117
+ }
118
+ :host([selected]:not(:disabled)) .base {
119
+ color: var(--m3e-tab-selected-color, ${DesignToken.color.primary});
120
+ --m3e-state-layer-hover-color: var(--m3e-tab-selected-container-hover-color, ${DesignToken.color.primary});
121
+ --m3e-state-layer-focus-color: var(--m3e-tab-selected-container-focus-color, ${DesignToken.color.primary});
122
+ --m3e-ripple-color: var(--m3e-tab-selected-ripple-color, ${DesignToken.color.primary});
123
+ }
124
+ :host(:not([selected]):not(:disabled)) .base {
125
+ color: var(--m3e-tab-unselected-color, ${DesignToken.color.onSurface});
126
+ --m3e-state-layer-hover-color: var(--m3e-tab-unselected-container-hover-color, ${DesignToken.color.onSurface});
127
+ --m3e-state-layer-focus-color: var(--m3e-tab-unselected-container-focus-color, ${DesignToken.color.onSurface});
128
+ --m3e-ripple-color: var(--m3e-tab-unselected-ripple-color, ${DesignToken.color.onSurface});
129
+ }
130
+ :host(:disabled) .base {
131
+ color: color-mix(
132
+ in srgb,
133
+ var(--m3e-tab-disabled-color, ${DesignToken.color.onSurface}) var(--m3e-tab-disabled-opacity, 38%),
134
+ transparent
135
+ );
136
+ }
137
+ .wrapper {
138
+ display: inline-flex;
139
+ align-items: center;
140
+ white-space: nowrap;
141
+ flex-direction: var(--_tab-direction);
142
+ justify-content: center;
143
+ column-gap: var(--m3e-tab-spacing, 0.5rem);
144
+ }
145
+ ::slotted([slot="icon"]) {
146
+ width: 1em;
147
+ font-size: var(--m3e-tab-icon-size, 1.5rem) !important;
148
+ }
149
+ @media (forced-colors: active) {
150
+ :host([selected]:not(:disabled)) .base {
151
+ color: ButtonText;
152
+ }
153
+ :host(:not([selected]):not(:disabled)) .base {
154
+ color: ButtonText;
155
+ }
156
+ :host(:disabled) .base {
157
+ color: GrayText;
158
+ }
159
+ }
160
+ `;
161
+
162
+ /** @private */ private static __nextId = 0;
163
+
164
+ /** @private */ @query(".focus-ring") private readonly _focusRing?: M3eFocusRingElement;
165
+ /** @private */ @query(".state-layer") private readonly _stateLayer?: M3eStateLayerElement;
166
+ /** @private */ @query(".ripple") private readonly _ripple?: M3eRippleElement;
167
+ /** @private */ readonly #clickHandler = (e: Event) => this.#handleClick(e);
168
+
169
+ /** @internal A reference to the element that wraps the label of the tab. */
170
+ @query(".label") readonly label!: HTMLElement;
171
+
172
+ /** @inheritdoc */
173
+ override attach(control: HTMLElement): void {
174
+ super.attach(control);
175
+
176
+ control.id = control.id || `m3e-tab-panel-${M3eTabElement.__nextId++}`;
177
+ addAriaReferencedId(this, "aria-controls", control.id);
178
+ }
179
+
180
+ /** @inheritdoc */
181
+ override detach(): void {
182
+ if (this.control && this.control.id) {
183
+ removeAriaReferencedId(this, "aria-controls", this.control.id);
184
+ }
185
+
186
+ super.detach();
187
+ }
188
+
189
+ /** @inheritdoc */
190
+ override connectedCallback(): void {
191
+ super.connectedCallback();
192
+ this.addEventListener("click", this.#clickHandler);
193
+ }
194
+
195
+ /** @inheritdoc */
196
+ override disconnectedCallback(): void {
197
+ super.disconnectedCallback();
198
+ this.removeEventListener("click", this.#clickHandler);
199
+ }
200
+
201
+ /** @inheritdoc */
202
+ protected override firstUpdated(_changedProperties: PropertyValues<this>): void {
203
+ super.firstUpdated(_changedProperties);
204
+ [this._focusRing, this._stateLayer, this._ripple].forEach((x) => x?.attach(this));
205
+ }
206
+
207
+ /** @inheritdoc */
208
+ protected override update(changedProperties: PropertyValues<this>): void {
209
+ super.update(changedProperties);
210
+
211
+ if (changedProperties.has("selected")) {
212
+ this.closest("m3e-tabs")?.[selectionManager].notifySelectionChange(this);
213
+ }
214
+ }
215
+
216
+ /** @inheritdoc */
217
+ protected override render(): unknown {
218
+ return html`<div class="base">
219
+ <m3e-state-layer class="state-layer" ?disabled="${this.disabled}"></m3e-state-layer>
220
+ <m3e-focus-ring class="focus-ring" inward ?disabled="${this.disabled}"></m3e-focus-ring>
221
+ <m3e-ripple class="ripple" ?disabled="${this.disabled}"></m3e-ripple>
222
+ <div class="touch" aria-hidden="true"></div>
223
+ <div class="wrapper">
224
+ <slot name="icon" aria-hidden="true"></slot><span class="label"><slot></slot></span>
225
+ </div>
226
+ </div>`;
227
+ }
228
+
229
+ /** @private */
230
+ #handleClick(e: Event): void {
231
+ if (this.disabled) {
232
+ e.preventDefault();
233
+ e.stopImmediatePropagation();
234
+ }
235
+
236
+ if (e.defaultPrevented || this.selected) return;
237
+
238
+ this.selected = true;
239
+ if (this.dispatchEvent(new Event("input", { bubbles: true, composed: true, cancelable: true }))) {
240
+ this.closest("m3e-tabs")?.[selectionManager].notifySelectionChange(this);
241
+ this.dispatchEvent(new Event("change", { bubbles: true }));
242
+ } else {
243
+ this.selected = false;
244
+ }
245
+ }
246
+ }
247
+
248
+ declare global {
249
+ interface HTMLElementTagNameMap {
250
+ "m3e-tab": M3eTabElement;
251
+ }
252
+ }
@@ -0,0 +1,2 @@
1
+ /** Specifies the possible positions of a tab header. */
2
+ export type TabHeaderPosition = "before" | "after";
@@ -0,0 +1,63 @@
1
+ import { css, CSSResultGroup, html, LitElement } from "lit";
2
+ import { customElement } from "lit/decorators.js";
3
+
4
+ import { DesignToken, Role } from "@m3e/core";
5
+
6
+ /**
7
+ * @summary
8
+ * A panel presented for a tab.
9
+ *
10
+ * @description
11
+ * The `m3e-tab-panel` component represents the content region associated with a selected tab.
12
+ * It is conditionally rendered based on tab selection and provides a structured surface for
13
+ * displaying contextual information, media, or interactive elements. Panels are linked to their
14
+ * corresponding tabs via the `for` attribute on `m3e-tab`, enabling declarative control and
15
+ * accessible navigation consistent with Material 3 guidance.
16
+ *
17
+ * @example
18
+ * The following example illustrates using the `m3e-tabs`, `m3e-tab`, and `m3e-tab-panel` components to present
19
+ * secondary tabs.
20
+ * ```html
21
+ * <m3e-tabs>
22
+ * <m3e-tab selected for="videos"><m3e-icon slot="icon" name="videocam"></m3e-icon>Video</m3e-tab>
23
+ * <m3e-tab for="photos"><m3e-icon slot="icon" name="photo"></m3e-icon>Photos</m3e-tab>
24
+ * <m3e-tab for="audio"><m3e-icon slot="icon" name="music_note"></m3e-icon>Audio</m3e-tab>
25
+ * <m3e-tab-panel id="videos">Videos</m3e-tab-panel>
26
+ * <m3e-tab-panel id="photos">Photos</m3e-tab-panel>
27
+ * <m3e-tab-panel id="audio">Audio</m3e-tab-panel>
28
+ * </m3e-tabs>
29
+ * ```
30
+ *
31
+ * @tag m3e-tab-panel
32
+ *
33
+ * @slot - Renders the content of the panel.
34
+ */
35
+ @customElement("m3e-tab-panel")
36
+ export class M3eTabPanelElement extends Role(LitElement, "tabpanel") {
37
+ /** The styles of the element. */
38
+ static override styles: CSSResultGroup = css`
39
+ :host {
40
+ display: block;
41
+ overflow-y: auto;
42
+ scrollbar-width: ${DesignToken.scrollbar.width};
43
+ scrollbar-color: ${DesignToken.scrollbar.color};
44
+ }
45
+ `;
46
+
47
+ /** @inheritdoc */
48
+ override connectedCallback(): void {
49
+ super.connectedCallback();
50
+ this.slot = "panel";
51
+ }
52
+
53
+ /** @inheritdoc */
54
+ protected override render(): unknown {
55
+ return html`<slot></slot>`;
56
+ }
57
+ }
58
+
59
+ declare global {
60
+ interface HTMLElementTagNameMap {
61
+ "m3e-tab-panel": M3eTabPanelElement;
62
+ }
63
+ }
@@ -0,0 +1,2 @@
1
+ /** Specifies the possible appearance variants of a tab. */
2
+ export type TabVariant = "primary" | "secondary";