@pindoba/astro-tab 0.0.0-alpha-20250517160011

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 ADDED
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2024 Eduardo Lopes
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the β€œSoftware”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED β€œAS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,229 @@
1
+ # Astro Starter Kit: Component Package
2
+
3
+ This is a template for an Astro component library. Use this template for writing components to use in multiple projects or publish to NPM.
4
+
5
+ ```sh
6
+ npm create astro@latest -- --template component
7
+ ```
8
+
9
+ [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/non-html-pages)
10
+ [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/non-html-pages)
11
+ [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/component/devcontainer.json)
12
+
13
+ ## πŸš€ Project Structure
14
+
15
+ Inside of your Astro project, you'll see the following folders and files:
16
+
17
+ ```text
18
+ /
19
+ β”œβ”€β”€ index.ts
20
+ β”œβ”€β”€ src
21
+ β”‚ β”œβ”€β”€ tab.astro
22
+ β”‚ β”œβ”€β”€ tab-group.astro
23
+ β”‚ β”œβ”€β”€ tab-panel.astro
24
+ β”‚ └── tab-nav.astro
25
+ β”œβ”€β”€ tsconfig.json
26
+ β”œβ”€β”€ package.json
27
+ ```
28
+
29
+ The `index.ts` file is the "entry point" for your package. Export your components in `index.ts` to make them importable from your package.
30
+
31
+ ## Tab Components
32
+
33
+ ### Using TabNav with TabGroup
34
+
35
+ The TabNav component uses the Navigation component for styled tab buttons:
36
+
37
+ ```astro
38
+ ---
39
+ import { TabGroup, TabNav, TabPanel } from "@pindoba/astro-tab";
40
+ ---
41
+
42
+ <TabGroup useNavigation={true} defaultTab="tab1">
43
+ <TabNav
44
+ slot="tab-nav"
45
+ tabs={[
46
+ { id: "tab1", label: "First Tab" },
47
+ { id: "tab2", label: "Second Tab" },
48
+ { id: "tab3", label: "Third Tab", disabled: true },
49
+ ]}
50
+ selectedTab="tab1"
51
+ />
52
+
53
+ <TabPanel id="tab1" slot="panel"> Content for the first tab </TabPanel>
54
+
55
+ <TabPanel id="tab2" slot="panel"> Content for the second tab </TabPanel>
56
+
57
+ <TabPanel id="tab3" slot="panel"> Content for the third tab </TabPanel>
58
+ </TabGroup>
59
+ ```
60
+
61
+ ### Using Traditional Tabs
62
+
63
+ You can still use the traditional Tab components:
64
+
65
+ ```astro
66
+ ---
67
+ import { TabGroup, Tab, TabPanel } from "@pindoba/astro-tab";
68
+ ---
69
+
70
+ <TabGroup>
71
+ <Tab id="tab1" label="First Tab" slot="tab" />
72
+ <Tab id="tab2" label="Second Tab" slot="tab" />
73
+ <Tab id="tab3" label="Third Tab" slot="tab" disabled />
74
+
75
+ <TabPanel id="tab1" slot="panel"> Content for the first tab </TabPanel>
76
+
77
+ <TabPanel id="tab2" slot="panel"> Content for the second tab </TabPanel>
78
+
79
+ <TabPanel id="tab3" slot="panel"> Content for the third tab </TabPanel>
80
+ </TabGroup>
81
+ ```
82
+
83
+ ## 🧞 Commands
84
+
85
+ All commands are run from the root of the project, from a terminal:
86
+
87
+ | Command | Action |
88
+ | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
89
+ | `npm link` | Registers this package locally. Run `npm link my-component-library` in an Astro project to install your components |
90
+ | `npm publish` | [Publishes](https://docs.npmjs.com/creating-and-publishing-unscoped-public-packages#publishing-unscoped-public-packages) this package to NPM. Requires you to be [logged in](https://docs.npmjs.com/cli/v8/commands/npm-adduser) |
91
+
92
+ # Pindoba Tab Component
93
+
94
+ A flexible and accessible tab component for Astro projects.
95
+
96
+ ## Installation
97
+
98
+ ```bash
99
+ npm install @pindoba/astro-tab
100
+ ```
101
+
102
+ ## Usage
103
+
104
+ ### Basic Usage
105
+
106
+ ```astro
107
+ ---
108
+ import { Tab } from "@pindoba/astro-tab";
109
+
110
+ const tabItems = [
111
+ {
112
+ id: "tab1",
113
+ label: "First Tab",
114
+ content: "This is the content of the first tab",
115
+ },
116
+ {
117
+ id: "tab2",
118
+ label: "Second Tab",
119
+ content: "This is the content of the second tab",
120
+ },
121
+ {
122
+ id: "tab3",
123
+ label: "Third Tab",
124
+ content: "This is the content of the third tab",
125
+ disabled: true,
126
+ },
127
+ ];
128
+ ---
129
+
130
+ <Tab items={tabItems} defaultTab="tab1" />
131
+ ```
132
+
133
+ ### With Custom Content
134
+
135
+ ```astro
136
+ ---
137
+ import { Tab } from "@pindoba/astro-tab";
138
+
139
+ const tabItems = [
140
+ {
141
+ id: "tab1",
142
+ label: "Dashboard",
143
+ content: (
144
+ <div class="p-4">
145
+ <h2>Dashboard Content</h2>
146
+ <p>Welcome to your dashboard!</p>
147
+ </div>
148
+ ),
149
+ },
150
+ {
151
+ id: "tab2",
152
+ label: "Settings",
153
+ content: (
154
+ <div class="p-4">
155
+ <h2>Settings</h2>
156
+ <form>
157
+ <input type="text" placeholder="Username" />
158
+ <button type="submit">Save</button>
159
+ </form>
160
+ </div>
161
+ ),
162
+ },
163
+ ];
164
+ ---
165
+
166
+ <Tab items={tabItems} defaultTab="tab1" class="my-4" />
167
+ ```
168
+
169
+ ### With Custom Styling
170
+
171
+ ```astro
172
+ ---
173
+ import { Tab } from "@pindoba/astro-tab";
174
+
175
+ const tabItems = [
176
+ {
177
+ id: "tab1",
178
+ label: "First Tab",
179
+ content: "This is the content of the first tab",
180
+ },
181
+ {
182
+ id: "tab2",
183
+ label: "Second Tab",
184
+ content: "This is the content of the second tab",
185
+ },
186
+ ];
187
+
188
+ const customStyles = {
189
+ root: {
190
+ borderRadius: "xl",
191
+ boxShadow: "md",
192
+ bg: "gray.50",
193
+ },
194
+ panel: {
195
+ p: "6",
196
+ fontSize: "lg",
197
+ },
198
+ };
199
+ ---
200
+
201
+ <Tab items={tabItems} defaultTab="tab1" passThrough={customStyles} />
202
+ ```
203
+
204
+ ## Props
205
+
206
+ | Name | Type | Default | Description |
207
+ | ------------- | ----------- | -------------- | --------------------------------- |
208
+ | `items` | `TabItem[]` | Required | Array of tab items |
209
+ | `defaultTab` | `string` | First tab's ID | ID of the tab to show by default |
210
+ | `passThrough` | `object` | `{}` | Custom styling for tab components |
211
+ | `class` | `string` | `''` | Additional CSS classes |
212
+
213
+ ### TabItem Interface
214
+
215
+ ```typescript
216
+ interface TabItem {
217
+ id: string; // Unique identifier
218
+ label: string; // Tab label text
219
+ content: any; // Tab panel content
220
+ selected?: boolean; // Whether tab is selected
221
+ disabled?: boolean; // Whether tab is disabled
222
+ }
223
+ ```
224
+
225
+ ## Accessibility
226
+
227
+ - Fully accessible with keyboard navigation
228
+ - ARIA attributes for screen readers
229
+ - Focus management for tab panels
package/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ // Do not write code directly here, instead use the `src` folder!
2
+ // Then, use this file to export everything you want your user to access.
3
+
4
+ import Tab from "./src/tab.astro";
5
+ import TabGroup from "./src/tab-group.astro";
6
+ import TabPanel from "./src/tab-panel.astro";
7
+ import TabNav from "./src/tab-nav.astro";
8
+
9
+ export * from "./src/tab-group.astro";
10
+ export * from "./src/tab-nav.astro";
11
+ export * from "./src/tab-panel.astro";
12
+ export * from "./src/tab.astro";
13
+
14
+ export { Tab, TabGroup, TabPanel, TabNav };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@pindoba/astro-tab",
3
+ "version": "0.0.0-alpha-20250517160011",
4
+ "homepage": "https://github.com/EduardoLopes/pindoba/tree/main/packages/blocks/astro/tab#README.md",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/EduardoLopes/pindoba.git",
8
+ "directory": "packages/ui/astro/tab"
9
+ },
10
+ "sideEffects": false,
11
+ "license": "MIT",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./index.ts"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "index.ts"
19
+ ],
20
+ "keywords": [
21
+ "astro-component"
22
+ ],
23
+ "devDependencies": {
24
+ "@astrojs/check": "^0.9.4",
25
+ "astro": "^5.7.13",
26
+ "typescript": "^5.8.3"
27
+ },
28
+ "peerDependencies": {
29
+ "astro": "^4.0.0"
30
+ },
31
+ "dependencies": {
32
+ "@pindoba/astro-badge": "0.0.0-alpha-20250517160011",
33
+ "@pindoba/astro-group": "0.0.0-alpha-20250517160011",
34
+ "@pindoba/astro-radio": "0.0.0-alpha-20250517160011",
35
+ "@pindoba/panda": "0.0.0-alpha-20250517160011",
36
+ "@pindoba/styles": "0.0.0-alpha-20250517160011",
37
+ "@pindoba/utils": "0.0.0-alpha-20250517160011"
38
+ },
39
+ "scripts": {
40
+ "lint": "TIMING=1 eslint . --max-warnings 0",
41
+ "lint:fix": "TIMING=1 eslint . --fix --max-warnings 0",
42
+ "format": "prettier --write .",
43
+ "format:fix": "prettier --write .",
44
+ "check": "astro check",
45
+ "dev": "astro check --watch",
46
+ "clean": "rm -rf .astro && rm -rf node_modules && rm -rf .turbo"
47
+ }
48
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ // eslint-disable-next-line @typescript-eslint/triple-slash-reference
2
+ /// <reference path="../.astro/types.d.ts" />
3
+
4
+ /// <reference types="astro/client" />
5
+
6
+ interface HTMLElementTagNameMap {
7
+ "pindoba-tab-group": HTMLDivElement;
8
+ }
@@ -0,0 +1,276 @@
1
+ ---
2
+ import { css, cx } from "@pindoba/panda/css";
3
+ import {
4
+ tabStyles,
5
+ type TabSlots,
6
+ type TabStyleProps,
7
+ } from "@pindoba/styles/tab";
8
+ import type { HTMLAttributes } from "astro/types";
9
+ import type { SystemStyleObject } from "@pindoba/panda/types";
10
+
11
+ export interface TabGroupProps extends HTMLAttributes<"div">, TabStyleProps {
12
+ defaultTab?: string;
13
+ passThrough?: { [key in TabSlots]?: SystemStyleObject };
14
+ id?: string;
15
+ }
16
+
17
+ type Props = TabGroupProps;
18
+
19
+ const { class: className, defaultTab, passThrough, id, ...props } = Astro.props;
20
+
21
+ const classes = tabStyles.raw();
22
+ // Use provided ID or generate a unique one
23
+ const uniqueId = id || `tab-group-${crypto.randomUUID().slice(0, 8)}`;
24
+ ---
25
+
26
+ <div
27
+ is="pindoba-tab-group"
28
+ class={cx(css(classes.root, passThrough?.root), className)}
29
+ data-default-tab={defaultTab}
30
+ id={uniqueId}
31
+ {...props}
32
+ >
33
+ <slot name="tab-nav" />
34
+ <slot name="panel" />
35
+ </div>
36
+
37
+ <script>
38
+ // Define type for tab change event
39
+ interface TabChangeDetail {
40
+ id: string;
41
+ label: string;
42
+ disabled?: boolean;
43
+ }
44
+
45
+ class PindobaTabGroup extends HTMLDivElement {
46
+ // Cache DOM queries for better performance
47
+ private tablist: HTMLElement | null = null;
48
+ private panels: NodeListOf<Element> | null = null;
49
+ private activeTabId: string | null = null;
50
+
51
+ constructor() {
52
+ super();
53
+ }
54
+
55
+ connectedCallback() {
56
+ // Initialize after the element is connected to ensure all children are available
57
+ this.init();
58
+
59
+ // Listen for tab:change events from tab-nav
60
+ this.addEventListener("tab:change", this.handleTabChange.bind(this));
61
+
62
+ // Listen for keyboard events for a11y support
63
+ this.addEventListener("keydown", this.handleKeyDown.bind(this));
64
+ }
65
+
66
+ disconnectedCallback() {
67
+ // Clean up event listeners
68
+ this.removeEventListener("tab:change", this.handleTabChange);
69
+ this.removeEventListener("keydown", this.handleKeyDown);
70
+
71
+ // Clear cache references
72
+ this.tablist = null;
73
+ this.panels = null;
74
+ this.activeTabId = null;
75
+ }
76
+
77
+ private init() {
78
+ // Cache DOM queries
79
+ this.tablist = this.querySelector('[role="tablist"]');
80
+ this.panels = this.querySelectorAll('[role="tabpanel"]');
81
+
82
+ // check if the default tab is set
83
+ const defaultTab = this.getAttribute("data-default-tab");
84
+
85
+ if (defaultTab) {
86
+ this.selectTab(defaultTab);
87
+ } else {
88
+ // Find the first non-disabled tab
89
+ const firstTab = this.findFirstSelectableTab();
90
+ if (firstTab) {
91
+ this.selectTab(firstTab.id);
92
+ } else {
93
+ console.warn(`TabGroup ${this.id}: No selectable tabs found`);
94
+ }
95
+ }
96
+ }
97
+
98
+ private findFirstSelectableTab(): HTMLElement | null {
99
+ if (!this.tablist) return null;
100
+
101
+ const tabs = Array.from(
102
+ this.tablist.querySelectorAll('[role="tab"]'),
103
+ ) as HTMLElement[];
104
+ return (
105
+ tabs.find(
106
+ (tab) =>
107
+ !tab.hasAttribute("disabled") &&
108
+ tab.getAttribute("aria-disabled") !== "true",
109
+ ) || null
110
+ );
111
+ }
112
+
113
+ private handleTabChange(e: Event) {
114
+ try {
115
+ const customEvent = e as CustomEvent<TabChangeDetail>;
116
+
117
+ if (customEvent?.detail?.id) {
118
+ this.selectTab(customEvent.detail.id);
119
+ } else {
120
+ console.warn(
121
+ `TabGroup ${this.id}: Invalid tab:change event`,
122
+ customEvent,
123
+ );
124
+ }
125
+ } catch (error) {
126
+ console.error(`TabGroup ${this.id}: Error handling tab change`, error);
127
+ }
128
+ }
129
+
130
+ private handleKeyDown(e: KeyboardEvent) {
131
+ // Only handle keyboard events when focus is within the tab group
132
+ if (!this.contains(document.activeElement)) return;
133
+
134
+ // If the user presses Home/End, navigate to the first/last tab
135
+ if (e.key === "Home" || e.key === "End") {
136
+ e.preventDefault();
137
+ if (!this.tablist) return;
138
+
139
+ const tabs = Array.from(
140
+ this.tablist.querySelectorAll(
141
+ '[role="tab"]:not([disabled]):not([aria-disabled="true"])',
142
+ ),
143
+ ) as HTMLElement[];
144
+ if (tabs.length === 0) return;
145
+
146
+ const targetTab = e.key === "Home" ? tabs[0] : tabs[tabs.length - 1];
147
+ targetTab.click();
148
+ targetTab.focus();
149
+ }
150
+
151
+ // If user presses F6, move focus to the panel
152
+ if (e.key === "F6") {
153
+ e.preventDefault();
154
+ this.focusSelectedTabPanel();
155
+ }
156
+ }
157
+
158
+ selectTab(selectedTab?: string) {
159
+ if (!selectedTab) {
160
+ console.warn(`TabGroup ${this.id}: No tab ID provided to selectTab`);
161
+ return;
162
+ }
163
+
164
+ // Store the active tab id
165
+ this.activeTabId = selectedTab;
166
+
167
+ // Extract the tab ID portion if it contains a group ID
168
+ const tabId = selectedTab.includes("-")
169
+ ? selectedTab.split("-").pop()
170
+ : selectedTab;
171
+
172
+ if (!tabId) {
173
+ console.warn(
174
+ `TabGroup ${this.id}: Invalid tab ID format: ${selectedTab}`,
175
+ );
176
+ return;
177
+ }
178
+
179
+ // Find the corresponding panel
180
+ const panelElement = this.querySelector(
181
+ `#panel-${tabId}[role="tabpanel"]`,
182
+ );
183
+
184
+ if (!panelElement) {
185
+ console.warn(
186
+ `TabGroup ${this.id}: No panel found for tab ID: ${tabId}`,
187
+ );
188
+ return;
189
+ }
190
+
191
+ // Update tab state (selected attribute)
192
+ if (this.tablist) {
193
+ const tabs = this.tablist.querySelectorAll('[role="tab"]');
194
+ tabs.forEach((tab) => {
195
+ const isSelected = tab.id.includes(tabId);
196
+ tab.setAttribute("aria-selected", isSelected ? "true" : "false");
197
+ if (isSelected) {
198
+ tab.removeAttribute("tabindex");
199
+ } else {
200
+ tab.setAttribute("tabindex", "-1");
201
+ }
202
+ });
203
+ }
204
+
205
+ // Get all panels within this specific tab group only
206
+ const panels = this.querySelectorAll(`[role="tabpanel"]`);
207
+
208
+ // Update panel visibility with better accessibility
209
+ panels.forEach((panel: Element) => {
210
+ const isVisible = panel.id === panelElement.id;
211
+ panel.setAttribute("aria-hidden", isVisible ? "false" : "true");
212
+
213
+ // Use tabindex for proper focus management
214
+ if (isVisible) {
215
+ panel.setAttribute("tabindex", "0");
216
+ panel.classList.remove("hidden");
217
+ } else {
218
+ panel.setAttribute("tabindex", "-1");
219
+ panel.classList.add("hidden");
220
+ }
221
+ });
222
+
223
+ // Dispatch a custom event for external components to react to tab changes
224
+ try {
225
+ const changeEvent = new CustomEvent("tabgroup:selected", {
226
+ bubbles: true,
227
+ detail: {
228
+ tabId: tabId,
229
+ groupId: this.id,
230
+ focusPanel: false, // Default to false; will be set to true for keyboard navigation
231
+ },
232
+ });
233
+ this.dispatchEvent(changeEvent);
234
+ } catch (error) {
235
+ console.error(
236
+ `TabGroup ${this.id}: Error dispatching selection event`,
237
+ error,
238
+ );
239
+ }
240
+ }
241
+
242
+ // Method to update the keyboard focus to the selected tab's panel
243
+ private focusSelectedTabPanel() {
244
+ if (!this.activeTabId) return;
245
+
246
+ // Extract the tab ID portion if it contains a group ID
247
+ const tabId = this.activeTabId.includes("-")
248
+ ? this.activeTabId.split("-").pop()
249
+ : this.activeTabId;
250
+
251
+ try {
252
+ const changeEvent = new CustomEvent("tabgroup:selected", {
253
+ bubbles: true,
254
+ detail: {
255
+ tabId: tabId,
256
+ groupId: this.id,
257
+ focusPanel: true, // Signal that panel should receive focus
258
+ },
259
+ });
260
+ this.dispatchEvent(changeEvent);
261
+ } catch (error) {
262
+ console.error(
263
+ `TabGroup ${this.id}: Error dispatching focus panel event`,
264
+ error,
265
+ );
266
+ }
267
+ }
268
+ }
269
+
270
+ // Register the custom element if not already registered
271
+ if (!customElements.get("pindoba-tab-group")) {
272
+ customElements.define("pindoba-tab-group", PindobaTabGroup, {
273
+ extends: "div",
274
+ });
275
+ }
276
+ </script>
@@ -0,0 +1,184 @@
1
+ ---
2
+ import {
3
+ tabStyles,
4
+ type TabSlots,
5
+ type TabStyleProps,
6
+ } from "@pindoba/styles/tab";
7
+ import type { HTMLAttributes } from "astro/types";
8
+ import type { SystemStyleObject } from "@pindoba/panda/types";
9
+ import Radio, { type RadioProps } from "@pindoba/astro-radio";
10
+ import Group from "@pindoba/astro-group";
11
+ import { cx } from "@pindoba/panda/css";
12
+ import Badge from "@pindoba/astro-badge";
13
+ export interface TabNavItem extends RadioProps {
14
+ id: string;
15
+ label: string;
16
+ disabled?: boolean;
17
+ badge?: string;
18
+ }
19
+
20
+ export interface Props extends HTMLAttributes<"nav">, TabStyleProps {
21
+ tabs: TabNavItem[];
22
+ selectedTab?: string;
23
+ fullWidth?: boolean;
24
+ passThrough?: { [key in TabSlots]?: SystemStyleObject };
25
+ onChange?: (tab: TabNavItem) => void;
26
+ }
27
+
28
+ const classes = tabStyles();
29
+
30
+ export type TabNavProps = Props;
31
+
32
+ const {
33
+ class: className,
34
+ tabs,
35
+ selectedTab,
36
+ fullWidth = true,
37
+ passThrough,
38
+ ...props
39
+ } = Astro.props;
40
+
41
+ // Safety check for required props
42
+ if (!Array.isArray(tabs) || tabs.length === 0) {
43
+ throw new Error(
44
+ 'TabNav component requires at least one tab in the "tabs" array',
45
+ );
46
+ }
47
+
48
+ // Transform tabs into navigation items
49
+ const navItems: TabNavItem[] = tabs.map(
50
+ ({ label, id, disabled, badge, ...rest }: TabNavItem) => ({
51
+ label: label || "",
52
+ id: id || crypto.randomUUID(), // Ensure an ID always exists
53
+ disabled: disabled || false,
54
+ badge: badge || "",
55
+ ...rest,
56
+ }),
57
+ );
58
+
59
+ const radioGroupId = crypto.randomUUID();
60
+ ---
61
+
62
+ <nav
63
+ is="pindoba-tab-nav"
64
+ class={cx(classes.nav, className)}
65
+ role="tablist"
66
+ data-tablist
67
+ aria-orientation="horizontal"
68
+ {...props}
69
+ >
70
+ <Group direction="horizontal" noBorderRadius="bottom">
71
+ {
72
+ navItems.map((tab) => {
73
+ const { id, label, disabled, badge, ...rest } = tab;
74
+
75
+ return (
76
+ <Radio
77
+ name={radioGroupId}
78
+ data-tab-id={id}
79
+ id={`${radioGroupId}-${id}`}
80
+ checked={id === selectedTab}
81
+ appearance="tab-horizontal"
82
+ disabled={disabled}
83
+ aria-controls={`panel-${id}`}
84
+ aria-selected={id === selectedTab ? "true" : "false"}
85
+ role="tab"
86
+ fullWidth={fullWidth}
87
+ {...rest}
88
+ >
89
+ {label}
90
+ {badge && <Badge size="sm">{badge}</Badge>}
91
+ </Radio>
92
+ );
93
+ })
94
+ }
95
+ </Group>
96
+ </nav>
97
+
98
+ <script>
99
+ // Define TabNavItem type for client script
100
+ type TabNavItem = {
101
+ id: string;
102
+ label: string;
103
+ disabled?: boolean;
104
+ };
105
+
106
+ class PindobaTabNav extends HTMLElement {
107
+ private radioInputs: NodeListOf<HTMLInputElement> | null = null;
108
+
109
+ connectedCallback() {
110
+ this.initTabNav();
111
+ }
112
+
113
+ disconnectedCallback() {
114
+ this.cleanup();
115
+ }
116
+
117
+ private initTabNav() {
118
+ // Use 'this' directly as it's the nav element with role="tablist"
119
+ this.radioInputs = this.querySelectorAll('input[type="radio"]');
120
+
121
+ if (!this.radioInputs || this.radioInputs.length === 0) {
122
+ console.warn("TabNav: No radio inputs found");
123
+ return;
124
+ }
125
+
126
+ // Add listeners directly - this avoids TypeScript issues with event binding
127
+ this.radioInputs.forEach((radio) => {
128
+ radio.addEventListener("change", (e: Event) => {
129
+ this.handleRadioChange(e);
130
+ });
131
+ });
132
+ }
133
+
134
+ private handleRadioChange(e: Event) {
135
+ const target = e.target as HTMLInputElement;
136
+ if (!target || !target.checked) return;
137
+
138
+ const tabId = target.dataset.tabId;
139
+ if (!tabId) {
140
+ console.warn("TabNav: Changed radio button has no tab ID");
141
+ return;
142
+ }
143
+
144
+ // Get the text content from the label element
145
+ const labelElement = target.closest("label");
146
+ if (!labelElement) {
147
+ console.warn("TabNav: Cannot find label element for radio button");
148
+ return;
149
+ }
150
+
151
+ const textDiv = labelElement.querySelector("div");
152
+ const tabLabel = textDiv?.textContent?.trim() || "";
153
+
154
+ // Create tab object to match TabNavItem interface
155
+ const tab: TabNavItem = {
156
+ id: tabId,
157
+ label: tabLabel,
158
+ disabled: target.disabled,
159
+ };
160
+
161
+ // Dispatch custom event that can be listened to by parent components
162
+ try {
163
+ const customEvent = new CustomEvent("tab:change", {
164
+ detail: tab,
165
+ bubbles: true,
166
+ });
167
+
168
+ // Dispatch the event from this element
169
+ this.dispatchEvent(customEvent);
170
+ } catch (error) {
171
+ console.error("TabNav: Error dispatching tab:change event", error);
172
+ }
173
+ }
174
+
175
+ private cleanup() {
176
+ this.radioInputs = null;
177
+ }
178
+ }
179
+
180
+ // Register the web component if not already registered
181
+ if (!customElements.get("pindoba-tab-nav")) {
182
+ customElements.define("pindoba-tab-nav", PindobaTabNav, { extends: "nav" });
183
+ }
184
+ </script>
@@ -0,0 +1,127 @@
1
+ ---
2
+ import { css, cx } from "@pindoba/panda/css";
3
+ import {
4
+ tabStyles,
5
+ type TabSlots,
6
+ type TabStyleProps,
7
+ } from "@pindoba/styles/tab";
8
+ import type { HTMLAttributes } from "astro/types";
9
+ import type { SystemStyleObject } from "@pindoba/panda/types";
10
+
11
+ export interface TabPanelProps extends HTMLAttributes<"div">, TabStyleProps {
12
+ id: string;
13
+ selected?: boolean;
14
+ passThrough?: { [key in TabSlots]?: SystemStyleObject };
15
+ ariaLabelledBy?: string;
16
+ }
17
+
18
+ type Props = TabPanelProps;
19
+
20
+ const {
21
+ class: className,
22
+ id,
23
+ selected = false,
24
+ passThrough,
25
+ ariaLabelledBy,
26
+ ...props
27
+ } = Astro.props;
28
+
29
+ // Validation for required props
30
+ if (!id) {
31
+ throw new Error('TabPanel component requires an "id" prop');
32
+ }
33
+
34
+ const classes = tabStyles.raw();
35
+
36
+ const panelId = `panel-${id}`;
37
+
38
+ // For improved accessibility, determine which element labels this panel
39
+ const labelId = ariaLabelledBy || id;
40
+ ---
41
+
42
+ <div
43
+ id={panelId}
44
+ role="tabpanel"
45
+ class={cx(
46
+ css(classes.panel, passThrough?.panel),
47
+ !selected && "hidden",
48
+ className,
49
+ )}
50
+ aria-labelledby={labelId}
51
+ aria-hidden={!selected ? "true" : "false"}
52
+ tabindex={selected ? "0" : "-1"}
53
+ data-tab-panel
54
+ data-tab-id={id}
55
+ {...props}
56
+ >
57
+ <slot />
58
+ </div>
59
+
60
+ <script>
61
+ // Client-side behavior for tab panels
62
+ class PindobaTabPanel extends HTMLDivElement {
63
+ private unsubscribe: (() => void) | null = null;
64
+
65
+ connectedCallback() {
66
+ // Find the parent tab group
67
+ const tabGroup = this.closest('[is="pindoba-tab-group"]');
68
+ if (!tabGroup) return;
69
+
70
+ // Listen for selection changes
71
+ const handleSelection = (e: CustomEvent) => {
72
+ if (!e.detail) return;
73
+
74
+ // Get our tab id from the data attribute
75
+ const myTabId = this.dataset.tabId;
76
+ if (!myTabId) return;
77
+
78
+ // Check if this panel should be shown
79
+ const isVisible = e.detail.tabId === myTabId;
80
+
81
+ // Update attributes for accessibility
82
+ this.setAttribute("aria-hidden", isVisible ? "false" : "true");
83
+ this.setAttribute("tabindex", isVisible ? "0" : "-1");
84
+
85
+ // Update visibility
86
+ if (isVisible) {
87
+ this.classList.remove("hidden");
88
+ // Only focus if the selection was triggered by keyboard navigation
89
+ if (e.detail.focusPanel) {
90
+ this.focus();
91
+ }
92
+ } else {
93
+ this.classList.add("hidden");
94
+ }
95
+ };
96
+
97
+ // Add event listener
98
+ tabGroup.addEventListener(
99
+ "tabgroup:selected",
100
+ handleSelection as EventListener,
101
+ );
102
+
103
+ // Store cleanup function
104
+ this.unsubscribe = () => {
105
+ tabGroup.removeEventListener(
106
+ "tabgroup:selected",
107
+ handleSelection as EventListener,
108
+ );
109
+ };
110
+ }
111
+
112
+ disconnectedCallback() {
113
+ // Clean up event listeners
114
+ if (this.unsubscribe) {
115
+ this.unsubscribe();
116
+ this.unsubscribe = null;
117
+ }
118
+ }
119
+ }
120
+
121
+ // Register the custom element if not already registered
122
+ if (!customElements.get("pindoba-tab-panel")) {
123
+ customElements.define("pindoba-tab-panel", PindobaTabPanel, {
124
+ extends: "div",
125
+ });
126
+ }
127
+ </script>
package/src/tab.astro ADDED
@@ -0,0 +1,141 @@
1
+ ---
2
+ import { cx } from "@pindoba/panda/css";
3
+ import type { SystemStyleObject } from "@pindoba/panda/types";
4
+ import { type TabSlots } from "@pindoba/styles/tab";
5
+ import TabGroup, { type TabGroupProps } from "./tab-group.astro";
6
+ import TabNav, { type TabNavItem } from "./tab-nav.astro";
7
+ import TabPanel from "./tab-panel.astro";
8
+
9
+ export interface TabItem extends TabNavItem {
10
+ selected?: boolean;
11
+ disabled?: boolean;
12
+ }
13
+
14
+ export interface TabProps extends Omit<TabGroupProps, "defaultTab"> {
15
+ items: TabItem[];
16
+ defaultTab?: string;
17
+ fullWidth?: boolean;
18
+ passThrough?: { [key in TabSlots]?: SystemStyleObject };
19
+ onTabChange?: (tabId: string) => void;
20
+ }
21
+
22
+ type Props = TabProps;
23
+
24
+ const {
25
+ items,
26
+ defaultTab,
27
+ fullWidth = true,
28
+ passThrough,
29
+ class: className,
30
+ ...props
31
+ } = Astro.props;
32
+
33
+ // Validate input props
34
+ if (!Array.isArray(items) || items.length === 0) {
35
+ throw new Error(
36
+ 'Tab component requires at least one item in the "items" array',
37
+ );
38
+ }
39
+
40
+ // Ensure all items have an ID
41
+ items.forEach((item, index) => {
42
+ if (!item.id) {
43
+ throw new Error(`Tab item at index ${index} is missing an "id" property`);
44
+ }
45
+ });
46
+
47
+ // Find the selected tab or use the default tab
48
+ const selectedTabId =
49
+ items.find((item) => item.selected)?.id || defaultTab || items[0]?.id;
50
+
51
+ // Generate a unique ID for this tab component
52
+ const tabGroupId = crypto.randomUUID().slice(0, 8);
53
+ ---
54
+
55
+ <TabGroup
56
+ defaultTab={selectedTabId}
57
+ class={cx(className)}
58
+ passThrough={passThrough}
59
+ id={`tab-group-${tabGroupId}`}
60
+ data-tab-component
61
+ {...props}
62
+ >
63
+ <TabNav
64
+ slot="tab-nav"
65
+ tabs={items}
66
+ selectedTab={selectedTabId}
67
+ fullWidth={fullWidth}
68
+ passThrough={passThrough}
69
+ />
70
+ <div slot="panel" class="tab-panels">
71
+ {
72
+ items.map(async (item) => {
73
+ const hasSlotContent = Astro.slots.has(item.id);
74
+
75
+ // Check for empty content
76
+ let content = "";
77
+ if (hasSlotContent) {
78
+ content = await Astro.slots.render(item.id);
79
+ }
80
+
81
+ // If there's no content, render a placeholder or warning
82
+ if (!content.trim()) {
83
+ content = `<div class="tab-panel-empty">No content for tab "${item.label || item.id}"</div>`;
84
+ }
85
+
86
+ return (
87
+ <TabPanel
88
+ id={item.id}
89
+ selected={item.id === selectedTabId}
90
+ passThrough={passThrough}
91
+ ariaLabelledBy={`${tabGroupId}-${item.id}`}
92
+ data-tab-content={item.id}
93
+ >
94
+ <Fragment set:html={content} />
95
+ </TabPanel>
96
+ );
97
+ })
98
+ }
99
+ </div>
100
+ </TabGroup>
101
+
102
+ <script>
103
+ // Client-side behavior to enhance the tab component
104
+ document.addEventListener("DOMContentLoaded", () => {
105
+ // Find all tab components
106
+ const tabComponents = document.querySelectorAll("[data-tab-component]");
107
+
108
+ tabComponents.forEach((tabComponent) => {
109
+ // Listen for tab selection events
110
+ tabComponent.addEventListener("tabgroup:selected", (e) => {
111
+ const customEvent = e as CustomEvent;
112
+
113
+ // Dispatch a higher-level event that can be listened to by consumers
114
+ if (customEvent?.detail?.tabId) {
115
+ const tabChangeEvent = new CustomEvent("tab:selected", {
116
+ bubbles: true,
117
+ detail: {
118
+ tabId: customEvent.detail.tabId,
119
+ groupId: customEvent.detail.groupId,
120
+ },
121
+ });
122
+
123
+ tabComponent.dispatchEvent(tabChangeEvent);
124
+ }
125
+ });
126
+ });
127
+ });
128
+ </script>
129
+
130
+ <style>
131
+ .tab-panels {
132
+ width: 100%;
133
+ }
134
+
135
+ .tab-panel-empty {
136
+ padding: 1rem;
137
+ color: #666;
138
+ font-style: italic;
139
+ text-align: center;
140
+ }
141
+ </style>